Fix twenty findings from the Motion review
Each was raised by a reviewer and then survived an independent attempt to refute it. The four that mattered most: - A third of the starter library was invisible. Three templates authored `fields` shapes no renderer read — decisions, blockingSet, checks, steps and the rest — so about forty records rendered as no DOM at all, in the library and again on the engagement that instantiated them. Nothing failed: a renderer returns null for a key set it does not recognise, and a header-plus-body page looks like a template written that way. FieldsView now reads every key the seeds carry. - "Add a framework" opened a picker that could never match, because the dialog was seeded with both the forced kind and the deal's stage, and qualification serves only the qualification stage. The stage is now dropped when MOTION_KIND_STAGES says the pair is incoherent. - Piggy reported the promotion count as an exact figure capped at 8, against a tile showing the true count beside it. It is now counted in SQL, and all three motion tools carry a ResultScope whose denominator is shared lineages — never rows, never private drafts. - No Motion test went through createApp, so the whole feature could be unmounted with a green suite. That is the AGENTS.md §5 trap that already cost this project read-guards.ts and learn.ts. Also: both sides of the instantiate/edit race now lock, so a template cannot be rewritten under an artefact that has copied it; concurrent engagement opens queue on the deal row and get the 409 the handler already promised rather than a 500; latestScore uses DISTINCT ON instead of losing engagements past a 200-row cap; the migration adds the scored_by_user_id foreign key the schema declares; and the demo clear refunds usage_count for engagements it reaches by cascade, which otherwise left starter templates permanently un-editable. Verified on a fresh database: 16 migrations apply and re-apply as a no-op, both seeds idempotent, usage_count back to zero after --clear. 564 unit tests pass. Every Motion route measures zero horizontal overflow at 393 and 1440 in both themes, and all twelve seeded field trees are asserted onto the screen by scripts/motion-fields-check.mjs. One thing left open deliberately: the shipped qualification scorecard's five bands and MOTION_BANDS' four are calibrated differently. The framework's table is now titled as its own guidance rather than the product's verdict, which removes the contradiction on screen. Making the framework's calibration authoritative over the persisted band column is a product decision nobody has made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -692,22 +692,28 @@ export class MotionService {
|
||||
),
|
||||
)
|
||||
.groupBy(engagementArtifacts.engagementId),
|
||||
// Newest first, then the first sighting of each engagement wins. A
|
||||
// `DISTINCT ON` would be tidier but this stays one bounded query and the
|
||||
// history panel needs the same ordering anyway.
|
||||
// `DISTINCT ON`, because folding a `scored_at desc` scan in memory drops
|
||||
// engagements rather than truncating a list. A hundred engagements
|
||||
// carrying three scores each can spend the whole 200-row budget on the
|
||||
// sixty most recently scored, and every other row then renders "never
|
||||
// scored" beside an exact artefact count, with nothing anywhere saying
|
||||
// so. Postgres requires the ORDER BY to lead with the distinct
|
||||
// expression, which is why `engagement_id` comes first; that is also the
|
||||
// leading column of `qualification_scores_engagement_idx`, so this reads
|
||||
// straight off the index. The result is bounded by `ids.length` and the
|
||||
// limit is left as a cap rather than a budget.
|
||||
this.db
|
||||
.select()
|
||||
.selectDistinctOn([qualificationScores.engagementId])
|
||||
.from(qualificationScores)
|
||||
.where(inArray(qualificationScores.engagementId, ids))
|
||||
.orderBy(desc(qualificationScores.scoredAt))
|
||||
.orderBy(qualificationScores.engagementId, desc(qualificationScores.scoredAt))
|
||||
.limit(SCORE_LIMIT),
|
||||
]);
|
||||
|
||||
const countById = new Map(counts.map((row) => [row.engagementId, row.total]));
|
||||
const latest = new Map<string, QualificationScore>();
|
||||
for (const score of scores) {
|
||||
if (!latest.has(score.engagementId)) latest.set(score.engagementId, score);
|
||||
}
|
||||
const latest = new Map<string, QualificationScore>(
|
||||
scores.map((score) => [score.engagementId, score]),
|
||||
);
|
||||
|
||||
return rows.map(({ engagement, dealName, stage, accountId, accountName }) => ({
|
||||
id: engagement.id,
|
||||
@@ -759,11 +765,52 @@ export async function loadTemplateForWrite(
|
||||
viewer: MotionViewer,
|
||||
id: string,
|
||||
): Promise<MotionTemplate> {
|
||||
const [row] = await tx
|
||||
return templateForWrite(tx, viewer, id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same read, holding the row until this transaction commits.
|
||||
*
|
||||
* `usage_count` is the whole of §7a, and under READ COMMITTED reading it
|
||||
* without the lock is a decision taken about a row somebody else is already
|
||||
* changing. Two requests against a template at zero: an instantiation copies
|
||||
* the body and increments the count, while a PATCH that read zero a moment
|
||||
* earlier blocks on the row lock and then applies anyway — leaving an artefact
|
||||
* whose `template_id` names a template that no longer contains what it copied,
|
||||
* which is the provenance failure the rule exists to prevent.
|
||||
*
|
||||
* Both sides therefore take this lock, and locking only the edit does not
|
||||
* work: the copy has to hold the row until its increment commits, or it is
|
||||
* still reading a body that is about to change. Whichever transaction locks
|
||||
* first serialises the other, and a blocked locking read re-fetches the
|
||||
* committed tuple — so the edit honestly sees `usage_count = 1` and answers
|
||||
* `409 template_in_use`, or the instantiation honestly copies the edited body.
|
||||
*
|
||||
* `no key update` rather than `update`, because the row is a foreign-key
|
||||
* target — `engagement_artifacts.template_id` — and nothing here is a reason
|
||||
* to block an insert that merely references it.
|
||||
*/
|
||||
export async function lockTemplateForWrite(
|
||||
tx: MotionTransaction,
|
||||
viewer: MotionViewer,
|
||||
id: string,
|
||||
): Promise<MotionTemplate> {
|
||||
return templateForWrite(tx, viewer, id, true);
|
||||
}
|
||||
|
||||
/** One predicate for both, so the lock cannot drift away from the read filter. */
|
||||
async function templateForWrite(
|
||||
tx: MotionTransaction,
|
||||
viewer: MotionViewer,
|
||||
id: string,
|
||||
lock: boolean,
|
||||
): Promise<MotionTemplate> {
|
||||
const query = tx
|
||||
.select()
|
||||
.from(motionTemplates)
|
||||
.where(and(eq(motionTemplates.id, id), visibleTemplates(viewer)))
|
||||
.limit(1);
|
||||
const [row] = lock ? await query.for('no key update') : await query;
|
||||
if (!row) throw MutationError.notFound('Motion template');
|
||||
return row;
|
||||
}
|
||||
@@ -905,8 +952,11 @@ export async function instantiateArtifact(
|
||||
input: InstantiateInput,
|
||||
now: Date,
|
||||
): Promise<{ artifact: EngagementArtifact; template: MotionTemplate | null }> {
|
||||
// Locked, not merely read: the body copied below has to be the body the
|
||||
// `usage_count` increment at the end of this function is counting, or a
|
||||
// concurrent edit rewrites the template between the two.
|
||||
const template = input.templateId
|
||||
? await loadTemplateForWrite(tx, viewer, input.templateId)
|
||||
? await lockTemplateForWrite(tx, viewer, input.templateId)
|
||||
: null;
|
||||
// Every read path hides an archived template, so instantiating one can only
|
||||
// happen by holding an id from before it was archived — and it would leave
|
||||
|
||||
Reference in New Issue
Block a user