@cat-factory/worker 0.110.1 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,11 @@ import type { BrainstormSession, BrainstormStage } from '@cat-factory/contracts'
3
3
  import type { D1Database } from '@cloudflare/workers-types';
4
4
  /**
5
5
  * Brainstorm (structured-dialogue) sessions, stored one row per session in
6
- * `brainstorm_sessions`. The mirror of {@link D1ClarityReviewRepository}, but keyed by
7
- * (block, stage): the service keeps at most one live session per block+stage, so
8
- * `getByBlockStage` returns the latest for that stage.
6
+ * `brainstorm_sessions`. The mirror of {@link D1ClarityReviewRepository} same rev-guarded
7
+ * `compareAndSwap` contract — but keyed by (block, stage): a UNIQUE index on
8
+ * (workspace_id, block_id, stage) (migration 0066) keeps one live session per block+stage, which
9
+ * `replaceForBlockStage` upserts against, so `getByBlockStage` returns it and the block's other
10
+ * stage is never disturbed.
9
11
  */
10
12
  export declare class D1BrainstormSessionRepository implements BrainstormSessionRepository {
11
13
  private readonly db;
@@ -14,7 +16,15 @@ export declare class D1BrainstormSessionRepository implements BrainstormSessionR
14
16
  });
15
17
  getByBlockStage(workspaceId: string, blockId: string, stage: BrainstormStage): Promise<BrainstormSession | null>;
16
18
  get(workspaceId: string, id: string): Promise<BrainstormSession | null>;
19
+ /**
20
+ * Force-write by session id. A fresh insert starts at rev 0; a write over an existing row BUMPS
21
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
22
+ * Writing a DIFFERENT id onto a block+stage that already has a session now violates the UNIQUE
23
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlockStage} may
24
+ * change which session is a block+stage's live one.
25
+ */
17
26
  upsert(workspaceId: string, session: BrainstormSession): Promise<void>;
18
- deleteByBlockStage(workspaceId: string, blockId: string, stage: BrainstormStage): Promise<void>;
27
+ compareAndSwap(workspaceId: string, session: BrainstormSession): Promise<boolean>;
28
+ replaceForBlockStage(workspaceId: string, session: BrainstormSession): Promise<void>;
19
29
  }
20
30
  //# sourceMappingURL=D1BrainstormSessionRepository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1BrainstormSessionRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1BrainstormSessionRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,KAAK,EAAkB,iBAAiB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAChG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAuC3D;;;;;GAKG;AACH,qBAAa,6BAA8B,YAAW,2BAA2B;IAC/E,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,eAAe,CACnB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAUnC;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAM5E;IAEK,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiC3E;IAEK,kBAAkB,CACtB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,IAAI,CAAC,CAOf;CACF"}
1
+ {"version":3,"file":"D1BrainstormSessionRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1BrainstormSessionRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,KAAK,EAAkB,iBAAiB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAChG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAyC3D;;;;;;;GAOG;AACH,qBAAa,6BAA8B,YAAW,2BAA2B;IAC/E,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,eAAe,CACnB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAUnC;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAM5E;IAED;;;;;;OAMG;IACG,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC3E;IAEK,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAqCtF;IAEK,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCzF;CACF"}
@@ -18,15 +18,18 @@ function rowToSession(row) {
18
18
  convergedDirection: row.converged_direction,
19
19
  iteration: row.iteration,
20
20
  maxIterations: row.max_iterations,
21
+ rev: row.rev ?? 0,
21
22
  createdAt: row.created_at,
22
23
  updatedAt: row.updated_at,
23
24
  };
24
25
  }
25
26
  /**
26
27
  * Brainstorm (structured-dialogue) sessions, stored one row per session in
27
- * `brainstorm_sessions`. The mirror of {@link D1ClarityReviewRepository}, but keyed by
28
- * (block, stage): the service keeps at most one live session per block+stage, so
29
- * `getByBlockStage` returns the latest for that stage.
28
+ * `brainstorm_sessions`. The mirror of {@link D1ClarityReviewRepository} same rev-guarded
29
+ * `compareAndSwap` contract — but keyed by (block, stage): a UNIQUE index on
30
+ * (workspace_id, block_id, stage) (migration 0066) keeps one live session per block+stage, which
31
+ * `replaceForBlockStage` upserts against, so `getByBlockStage` returns it and the block's other
32
+ * stage is never disturbed.
30
33
  */
31
34
  export class D1BrainstormSessionRepository {
32
35
  db;
@@ -49,12 +52,19 @@ export class D1BrainstormSessionRepository {
49
52
  .first();
50
53
  return row ? rowToSession(row) : null;
51
54
  }
55
+ /**
56
+ * Force-write by session id. A fresh insert starts at rev 0; a write over an existing row BUMPS
57
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
58
+ * Writing a DIFFERENT id onto a block+stage that already has a session now violates the UNIQUE
59
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlockStage} may
60
+ * change which session is a block+stage's live one.
61
+ */
52
62
  async upsert(workspaceId, session) {
53
- await this.db
63
+ const row = await this.db
54
64
  .prepare(`INSERT INTO brainstorm_sessions
55
65
  (workspace_id, id, block_id, stage, status, items, model, converged_direction,
56
- iteration, max_iterations, created_at, updated_at)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
66
+ iteration, max_iterations, rev, created_at, updated_at)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
58
68
  ON CONFLICT (workspace_id, id) DO UPDATE SET
59
69
  block_id = excluded.block_id,
60
70
  stage = excluded.stage,
@@ -64,15 +74,63 @@ export class D1BrainstormSessionRepository {
64
74
  converged_direction = excluded.converged_direction,
65
75
  iteration = excluded.iteration,
66
76
  max_iterations = excluded.max_iterations,
67
- updated_at = excluded.updated_at`)
77
+ rev = brainstorm_sessions.rev + 1,
78
+ updated_at = excluded.updated_at
79
+ RETURNING rev`)
68
80
  .bind(workspaceId, session.id, session.blockId, session.stage, session.status, JSON.stringify(session.items), session.model, session.convergedDirection, session.iteration ?? 1, session.maxIterations ?? 1, session.createdAt, session.updatedAt)
69
- .run();
81
+ .first();
82
+ if (row)
83
+ session.rev = row.rev;
70
84
  }
71
- async deleteByBlockStage(workspaceId, blockId, stage) {
72
- await this.db
73
- .prepare(`DELETE FROM brainstorm_sessions WHERE workspace_id = ? AND block_id = ? AND stage = ?`)
74
- .bind(workspaceId, blockId, stage)
75
- .run();
85
+ async compareAndSwap(workspaceId, session) {
86
+ // Conditional update guarded on the rev last read onto this session; writes only while the
87
+ // stored row is unchanged, and never inserts (a deleted session must stay deleted).
88
+ const row = await this.db
89
+ .prepare(`UPDATE brainstorm_sessions SET
90
+ block_id = ?,
91
+ stage = ?,
92
+ status = ?,
93
+ items = ?,
94
+ model = ?,
95
+ converged_direction = ?,
96
+ iteration = ?,
97
+ max_iterations = ?,
98
+ updated_at = ?,
99
+ rev = rev + 1
100
+ WHERE workspace_id = ? AND id = ? AND rev = ?
101
+ RETURNING rev`)
102
+ .bind(session.blockId, session.stage, session.status, JSON.stringify(session.items), session.model, session.convergedDirection, session.iteration ?? 1, session.maxIterations ?? 1, session.updatedAt, workspaceId, session.id, session.rev ?? 0)
103
+ .first();
104
+ if (!row)
105
+ return false;
106
+ session.rev = row.rev;
107
+ return true;
108
+ }
109
+ async replaceForBlockStage(workspaceId, session) {
110
+ // ONE conflict-targeted upsert on the (block, STAGE) UNIQUE key (migration 0066), NOT a
111
+ // delete-then-insert pair — see {@link D1RequirementReviewRepository.replaceForBlock} for why
112
+ // a transaction around that pair is not enough. The stage is part of the conflict target, so
113
+ // the block's other stage is untouched by construction rather than by a scoped delete.
114
+ const row = await this.db
115
+ .prepare(`INSERT INTO brainstorm_sessions
116
+ (workspace_id, id, block_id, stage, status, items, model, converged_direction,
117
+ iteration, max_iterations, rev, created_at, updated_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
119
+ ON CONFLICT (workspace_id, block_id, stage) DO UPDATE SET
120
+ id = excluded.id,
121
+ status = excluded.status,
122
+ items = excluded.items,
123
+ model = excluded.model,
124
+ converged_direction = excluded.converged_direction,
125
+ iteration = excluded.iteration,
126
+ max_iterations = excluded.max_iterations,
127
+ rev = 0,
128
+ created_at = excluded.created_at,
129
+ updated_at = excluded.updated_at
130
+ RETURNING rev`)
131
+ .bind(workspaceId, session.id, session.blockId, session.stage, session.status, JSON.stringify(session.items), session.model, session.convergedDirection, session.iteration ?? 1, session.maxIterations ?? 1, session.createdAt, session.updatedAt)
132
+ .first();
133
+ session.rev = row?.rev ?? 0;
76
134
  }
77
135
  }
78
136
  //# sourceMappingURL=D1BrainstormSessionRepository.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1BrainstormSessionRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1BrainstormSessionRepository.ts"],"names":[],"mappings":"AAkBA,SAAS,YAAY,CAAC,GAAyB;IAC7C,IAAI,KAAK,GAAqB,EAAE,CAAA;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACpC,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;YAAE,KAAK,GAAG,MAA0B,CAAA;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,KAAK,GAAG,EAAE,CAAA;IACZ,CAAC;IACD,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAE;QACV,OAAO,EAAE,GAAG,CAAC,QAAQ;QACrB,KAAK,EAAE,GAAG,CAAC,KAAwB;QACnC,MAAM,EAAE,GAAG,CAAC,MAAqC;QACjD,KAAK;QACL,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,kBAAkB,EAAE,GAAG,CAAC,mBAAmB;QAC3C,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,aAAa,EAAE,GAAG,CAAC,cAAc;QACjC,SAAS,EAAE,GAAG,CAAC,UAAU;QACzB,SAAS,EAAE,GAAG,CAAC,UAAU;KAC1B,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,OAAO,6BAA6B;IACvB,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAsB;QACpC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAA;IACd,CAAC;IAED,KAAK,CAAC,eAAe,CACnB,WAAmB,EACnB,OAAe,EACf,KAAsB;QAEtB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CACN;;4CAEoC,CACrC;aACA,IAAI,CAAC,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC;aACjC,KAAK,EAAwB,CAAA;QAChC,OAAO,GAAG,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,WAAmB,EAAE,EAAU;QACvC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CAAC,qEAAqE,CAAC;aAC9E,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;aACrB,KAAK,EAAwB,CAAA;QAChC,OAAO,GAAG,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAAmB,EAAE,OAA0B;QAC1D,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CACN;;;;;;;;;;;;;4CAaoC,CACrC;aACA,IAAI,CACH,WAAW,EACX,OAAO,CAAC,EAAE,EACV,OAAO,CAAC,OAAO,EACf,OAAO,CAAC,KAAK,EACb,OAAO,CAAC,MAAM,EACd,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,KAAK,CAAC,EAC7B,OAAO,CAAC,KAAK,EACb,OAAO,CAAC,kBAAkB,EAC1B,OAAO,CAAC,SAAS,IAAI,CAAC,EACtB,OAAO,CAAC,aAAa,IAAI,CAAC,EAC1B,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,SAAS,CAClB;aACA,GAAG,EAAE,CAAA;IACV,CAAC;IAED,KAAK,CAAC,kBAAkB,CACtB,WAAmB,EACnB,OAAe,EACf,KAAsB;QAEtB,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CACN,uFAAuF,CACxF;aACA,IAAI,CAAC,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC;aACjC,GAAG,EAAE,CAAA;IACV,CAAC;CACF"}
1
+ {"version":3,"file":"D1BrainstormSessionRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1BrainstormSessionRepository.ts"],"names":[],"mappings":"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"}
@@ -3,8 +3,10 @@ import type { ClarityReview } from '@cat-factory/contracts';
3
3
  import type { D1Database } from '@cloudflare/workers-types';
4
4
  /**
5
5
  * Clarity (bug-report triage) reviews, stored one row per review in `clarity_reviews`.
6
- * The mirror of {@link D1RequirementReviewRepository}: items live as a JSON array, the
7
- * service keeps at most one live review per block, so `getByBlock` returns the latest.
6
+ * The mirror of {@link D1RequirementReviewRepository}, down to the concurrency contract: items
7
+ * live as a JSON array, a UNIQUE index on (workspace_id, block_id) keeps exactly one live review
8
+ * per block (which `replaceForBlock` upserts against), and every read-modify-write rides the
9
+ * rev-guarded `compareAndSwap`.
8
10
  */
9
11
  export declare class D1ClarityReviewRepository implements ClarityReviewRepository {
10
12
  private readonly db;
@@ -13,7 +15,15 @@ export declare class D1ClarityReviewRepository implements ClarityReviewRepositor
13
15
  });
14
16
  getByBlock(workspaceId: string, blockId: string): Promise<ClarityReview | null>;
15
17
  get(workspaceId: string, id: string): Promise<ClarityReview | null>;
18
+ /**
19
+ * Force-write by review id. A fresh insert starts at rev 0; a write over an existing row BUMPS
20
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
21
+ * Writing a DIFFERENT id onto a block that already has a review now violates the block's UNIQUE
22
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlock} may change
23
+ * which review is a block's live one.
24
+ */
16
25
  upsert(workspaceId: string, review: ClarityReview): Promise<void>;
17
- deleteByBlock(workspaceId: string, blockId: string): Promise<void>;
26
+ compareAndSwap(workspaceId: string, review: ClarityReview): Promise<boolean>;
27
+ replaceForBlock(workspaceId: string, review: ClarityReview): Promise<void>;
18
28
  }
19
29
  //# sourceMappingURL=D1ClarityReviewRepository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1ClarityReviewRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1ClarityReviewRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAClE,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,wBAAwB,CAAA;AAC9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAqC3D;;;;GAIG;AACH,qBAAa,yBAA0B,YAAW,uBAAuB;IACvE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAUpF;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAMxE;IAEK,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BtE;IAEK,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvE;CACF"}
1
+ {"version":3,"file":"D1ClarityReviewRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1ClarityReviewRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAClE,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,wBAAwB,CAAA;AAC9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAuC3D;;;;;;GAMG;AACH,qBAAa,yBAA0B,YAAW,uBAAuB;IACvE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAUpF;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAMxE;IAED;;;;;;OAMG;IACG,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAkCtE;IAEK,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAmCjF;IAEK,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAsC/E;CACF"}
@@ -17,14 +17,17 @@ function rowToReview(row) {
17
17
  clarifiedReport: row.clarified_report,
18
18
  iteration: row.iteration,
19
19
  maxIterations: row.max_iterations,
20
+ rev: row.rev ?? 0,
20
21
  createdAt: row.created_at,
21
22
  updatedAt: row.updated_at,
22
23
  };
23
24
  }
24
25
  /**
25
26
  * Clarity (bug-report triage) reviews, stored one row per review in `clarity_reviews`.
26
- * The mirror of {@link D1RequirementReviewRepository}: items live as a JSON array, the
27
- * service keeps at most one live review per block, so `getByBlock` returns the latest.
27
+ * The mirror of {@link D1RequirementReviewRepository}, down to the concurrency contract: items
28
+ * live as a JSON array, a UNIQUE index on (workspace_id, block_id) keeps exactly one live review
29
+ * per block (which `replaceForBlock` upserts against), and every read-modify-write rides the
30
+ * rev-guarded `compareAndSwap`.
28
31
  */
29
32
  export class D1ClarityReviewRepository {
30
33
  db;
@@ -47,12 +50,19 @@ export class D1ClarityReviewRepository {
47
50
  .first();
48
51
  return row ? rowToReview(row) : null;
49
52
  }
53
+ /**
54
+ * Force-write by review id. A fresh insert starts at rev 0; a write over an existing row BUMPS
55
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
56
+ * Writing a DIFFERENT id onto a block that already has a review now violates the block's UNIQUE
57
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlock} may change
58
+ * which review is a block's live one.
59
+ */
50
60
  async upsert(workspaceId, review) {
51
- await this.db
61
+ const row = await this.db
52
62
  .prepare(`INSERT INTO clarity_reviews
53
63
  (workspace_id, id, block_id, status, items, model, clarified_report,
54
- iteration, max_iterations, created_at, updated_at)
55
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
64
+ iteration, max_iterations, rev, created_at, updated_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
56
66
  ON CONFLICT (workspace_id, id) DO UPDATE SET
57
67
  block_id = excluded.block_id,
58
68
  status = excluded.status,
@@ -61,15 +71,61 @@ export class D1ClarityReviewRepository {
61
71
  clarified_report = excluded.clarified_report,
62
72
  iteration = excluded.iteration,
63
73
  max_iterations = excluded.max_iterations,
64
- updated_at = excluded.updated_at`)
74
+ rev = clarity_reviews.rev + 1,
75
+ updated_at = excluded.updated_at
76
+ RETURNING rev`)
65
77
  .bind(workspaceId, review.id, review.blockId, review.status, JSON.stringify(review.items), review.model, review.clarifiedReport, review.iteration ?? 1, review.maxIterations ?? 1, review.createdAt, review.updatedAt)
66
- .run();
78
+ .first();
79
+ if (row)
80
+ review.rev = row.rev;
67
81
  }
68
- async deleteByBlock(workspaceId, blockId) {
69
- await this.db
70
- .prepare(`DELETE FROM clarity_reviews WHERE workspace_id = ? AND block_id = ?`)
71
- .bind(workspaceId, blockId)
72
- .run();
82
+ async compareAndSwap(workspaceId, review) {
83
+ // Conditional update guarded on the rev last read onto this review; writes only while the
84
+ // stored row is unchanged, and never inserts (a deleted review must stay deleted).
85
+ const row = await this.db
86
+ .prepare(`UPDATE clarity_reviews SET
87
+ block_id = ?,
88
+ status = ?,
89
+ items = ?,
90
+ model = ?,
91
+ clarified_report = ?,
92
+ iteration = ?,
93
+ max_iterations = ?,
94
+ updated_at = ?,
95
+ rev = rev + 1
96
+ WHERE workspace_id = ? AND id = ? AND rev = ?
97
+ RETURNING rev`)
98
+ .bind(review.blockId, review.status, JSON.stringify(review.items), review.model, review.clarifiedReport, review.iteration ?? 1, review.maxIterations ?? 1, review.updatedAt, workspaceId, review.id, review.rev ?? 0)
99
+ .first();
100
+ if (!row)
101
+ return false;
102
+ review.rev = row.rev;
103
+ return true;
104
+ }
105
+ async replaceForBlock(workspaceId, review) {
106
+ // ONE conflict-targeted upsert on the block's UNIQUE key (migration 0066), NOT a
107
+ // delete-then-insert pair — see {@link D1RequirementReviewRepository.replaceForBlock} for why
108
+ // a transaction around that pair is not enough.
109
+ const row = await this.db
110
+ .prepare(`INSERT INTO clarity_reviews
111
+ (workspace_id, id, block_id, status, items, model, clarified_report,
112
+ iteration, max_iterations, rev, created_at, updated_at)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
114
+ ON CONFLICT (workspace_id, block_id) DO UPDATE SET
115
+ id = excluded.id,
116
+ status = excluded.status,
117
+ items = excluded.items,
118
+ model = excluded.model,
119
+ clarified_report = excluded.clarified_report,
120
+ iteration = excluded.iteration,
121
+ max_iterations = excluded.max_iterations,
122
+ rev = 0,
123
+ created_at = excluded.created_at,
124
+ updated_at = excluded.updated_at
125
+ RETURNING rev`)
126
+ .bind(workspaceId, review.id, review.blockId, review.status, JSON.stringify(review.items), review.model, review.clarifiedReport, review.iteration ?? 1, review.maxIterations ?? 1, review.createdAt, review.updatedAt)
127
+ .first();
128
+ review.rev = row?.rev ?? 0;
73
129
  }
74
130
  }
75
131
  //# sourceMappingURL=D1ClarityReviewRepository.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1ClarityReviewRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1ClarityReviewRepository.ts"],"names":[],"mappings":"AAiBA,SAAS,WAAW,CAAC,GAAqB;IACxC,IAAI,KAAK,GAAwB,EAAE,CAAA;IACnC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACpC,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;YAAE,KAAK,GAAG,MAA6B,CAAA;IAClE,CAAC;IAAC,MAAM,CAAC;QACP,KAAK,GAAG,EAAE,CAAA;IACZ,CAAC;IACD,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAE;QACV,OAAO,EAAE,GAAG,CAAC,QAAQ;QACrB,MAAM,EAAE,GAAG,CAAC,MAAiC;QAC7C,KAAK;QACL,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,eAAe,EAAE,GAAG,CAAC,gBAAgB;QACrC,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,aAAa,EAAE,GAAG,CAAC,cAAc;QACjC,SAAS,EAAE,GAAG,CAAC,UAAU;QACzB,SAAS,EAAE,GAAG,CAAC,UAAU;KAC1B,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,yBAAyB;IACnB,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAsB;QACpC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAA;IACd,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,WAAmB,EAAE,OAAe;QACnD,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CACN;;4CAEoC,CACrC;aACA,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC;aAC1B,KAAK,EAAoB,CAAA;QAC5B,OAAO,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,WAAmB,EAAE,EAAU;QACvC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CAAC,iEAAiE,CAAC;aAC1E,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;aACrB,KAAK,EAAoB,CAAA;QAC5B,OAAO,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAAmB,EAAE,MAAqB;QACrD,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CACN;;;;;;;;;;;;4CAYoC,CACrC;aACA,IAAI,CACH,WAAW,EACX,MAAM,CAAC,EAAE,EACT,MAAM,CAAC,OAAO,EACd,MAAM,CAAC,MAAM,EACb,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,EAC5B,MAAM,CAAC,KAAK,EACZ,MAAM,CAAC,eAAe,EACtB,MAAM,CAAC,SAAS,IAAI,CAAC,EACrB,MAAM,CAAC,aAAa,IAAI,CAAC,EACzB,MAAM,CAAC,SAAS,EAChB,MAAM,CAAC,SAAS,CACjB;aACA,GAAG,EAAE,CAAA;IACV,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,WAAmB,EAAE,OAAe;QACtD,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CAAC,qEAAqE,CAAC;aAC9E,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC;aAC1B,GAAG,EAAE,CAAA;IACV,CAAC;CACF"}
1
+ {"version":3,"file":"D1ClarityReviewRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1ClarityReviewRepository.ts"],"names":[],"mappings":"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"}
@@ -3,9 +3,12 @@ import type { RequirementReview } from '@cat-factory/contracts';
3
3
  import type { D1Database } from '@cloudflare/workers-types';
4
4
  /**
5
5
  * Requirements reviews, stored one row per review in `requirement_reviews`
6
- * (migration 0021). The reviewed items live as a JSON array in `items`; the
7
- * service keeps at most one live review per block (it deletes the block's prior
8
- * review before inserting a fresh one), so `getByBlock` returns the latest.
6
+ * (migration 0021). The reviewed items live as a JSON array in `items`; a block holds at most
7
+ * ONE live review a UNIQUE index on (workspace_id, block_id) enforces that (migration 0066),
8
+ * and {@link replaceForBlock} publishes a fresh one as a single upsert against it — so
9
+ * `getByBlock` returns it. Every read-modify-write rides the rev-guarded {@link compareAndSwap}
10
+ * (migration 0065): the whole row is one blob, so a blind write would drop a concurrent editor's
11
+ * answer.
9
12
  */
10
13
  export declare class D1RequirementReviewRepository implements RequirementReviewRepository {
11
14
  private readonly db;
@@ -14,7 +17,15 @@ export declare class D1RequirementReviewRepository implements RequirementReviewR
14
17
  });
15
18
  getByBlock(workspaceId: string, blockId: string): Promise<RequirementReview | null>;
16
19
  get(workspaceId: string, id: string): Promise<RequirementReview | null>;
20
+ /**
21
+ * Force-write by review id. A fresh insert starts at rev 0; a write over an existing row BUMPS
22
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
23
+ * Writing a DIFFERENT id onto a block that already has a review now violates the block's UNIQUE
24
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlock} may change
25
+ * which review is a block's live one.
26
+ */
17
27
  upsert(workspaceId: string, review: RequirementReview): Promise<void>;
18
- deleteByBlock(workspaceId: string, blockId: string): Promise<void>;
28
+ compareAndSwap(workspaceId: string, review: RequirementReview): Promise<boolean>;
29
+ replaceForBlock(workspaceId: string, review: RequirementReview): Promise<void>;
19
30
  }
20
31
  //# sourceMappingURL=D1RequirementReviewRepository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1RequirementReviewRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1RequirementReviewRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,KAAK,EAEV,iBAAiB,EAElB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AA0C3D;;;;;GAKG;AACH,qBAAa,6BAA8B,YAAW,2BAA2B;IAC/E,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAUxF;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAM5E;IAEK,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiC1E;IAEK,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvE;CACF"}
1
+ {"version":3,"file":"D1RequirementReviewRepository.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1RequirementReviewRepository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,KAAK,EAEV,iBAAiB,EAElB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AA4C3D;;;;;;;;GAQG;AACH,qBAAa,6BAA8B,YAAW,2BAA2B;IAC/E,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAE,EAAE;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,EAErC;IAEK,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAUxF;IAEK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAM5E;IAED;;;;;;OAMG;IACG,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC1E;IAEK,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAsCrF;IAEK,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4CnF;CACF"}
@@ -20,15 +20,19 @@ function rowToReview(row) {
20
20
  iteration: row.iteration,
21
21
  maxIterations: row.max_iterations,
22
22
  recommendations: parseJsonArray(row.recommendations),
23
+ rev: row.rev ?? 0,
23
24
  createdAt: row.created_at,
24
25
  updatedAt: row.updated_at,
25
26
  };
26
27
  }
27
28
  /**
28
29
  * Requirements reviews, stored one row per review in `requirement_reviews`
29
- * (migration 0021). The reviewed items live as a JSON array in `items`; the
30
- * service keeps at most one live review per block (it deletes the block's prior
31
- * review before inserting a fresh one), so `getByBlock` returns the latest.
30
+ * (migration 0021). The reviewed items live as a JSON array in `items`; a block holds at most
31
+ * ONE live review a UNIQUE index on (workspace_id, block_id) enforces that (migration 0066),
32
+ * and {@link replaceForBlock} publishes a fresh one as a single upsert against it — so
33
+ * `getByBlock` returns it. Every read-modify-write rides the rev-guarded {@link compareAndSwap}
34
+ * (migration 0065): the whole row is one blob, so a blind write would drop a concurrent editor's
35
+ * answer.
32
36
  */
33
37
  export class D1RequirementReviewRepository {
34
38
  db;
@@ -51,12 +55,19 @@ export class D1RequirementReviewRepository {
51
55
  .first();
52
56
  return row ? rowToReview(row) : null;
53
57
  }
58
+ /**
59
+ * Force-write by review id. A fresh insert starts at rev 0; a write over an existing row BUMPS
60
+ * it, so a concurrent compareAndSwap holding the old revision still detects that the row moved.
61
+ * Writing a DIFFERENT id onto a block that already has a review now violates the block's UNIQUE
62
+ * index (migration 0066) — loudly, which is the point: only {@link replaceForBlock} may change
63
+ * which review is a block's live one.
64
+ */
54
65
  async upsert(workspaceId, review) {
55
- await this.db
66
+ const row = await this.db
56
67
  .prepare(`INSERT INTO requirement_reviews
57
68
  (workspace_id, id, block_id, status, items, model, incorporated_requirements,
58
- iteration, max_iterations, recommendations, created_at, updated_at)
59
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
69
+ iteration, max_iterations, recommendations, rev, created_at, updated_at)
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
60
71
  ON CONFLICT (workspace_id, id) DO UPDATE SET
61
72
  block_id = excluded.block_id,
62
73
  status = excluded.status,
@@ -66,15 +77,68 @@ export class D1RequirementReviewRepository {
66
77
  iteration = excluded.iteration,
67
78
  max_iterations = excluded.max_iterations,
68
79
  recommendations = excluded.recommendations,
69
- updated_at = excluded.updated_at`)
80
+ rev = requirement_reviews.rev + 1,
81
+ updated_at = excluded.updated_at
82
+ RETURNING rev`)
70
83
  .bind(workspaceId, review.id, review.blockId, review.status, JSON.stringify(review.items), review.model, review.incorporatedRequirements, review.iteration ?? 1, review.maxIterations ?? 1, JSON.stringify(review.recommendations ?? []), review.createdAt, review.updatedAt)
71
- .run();
84
+ .first();
85
+ if (row)
86
+ review.rev = row.rev;
72
87
  }
73
- async deleteByBlock(workspaceId, blockId) {
74
- await this.db
75
- .prepare(`DELETE FROM requirement_reviews WHERE workspace_id = ? AND block_id = ?`)
76
- .bind(workspaceId, blockId)
77
- .run();
88
+ async compareAndSwap(workspaceId, review) {
89
+ // Conditional update guarded on the rev last read onto this review; writes only while the
90
+ // stored row is unchanged, and never inserts (a deleted review must stay deleted).
91
+ const expected = review.rev ?? 0;
92
+ const row = await this.db
93
+ .prepare(`UPDATE requirement_reviews SET
94
+ block_id = ?,
95
+ status = ?,
96
+ items = ?,
97
+ model = ?,
98
+ incorporated_requirements = ?,
99
+ iteration = ?,
100
+ max_iterations = ?,
101
+ recommendations = ?,
102
+ updated_at = ?,
103
+ rev = rev + 1
104
+ WHERE workspace_id = ? AND id = ? AND rev = ?
105
+ RETURNING rev`)
106
+ .bind(review.blockId, review.status, JSON.stringify(review.items), review.model, review.incorporatedRequirements, review.iteration ?? 1, review.maxIterations ?? 1, JSON.stringify(review.recommendations ?? []), review.updatedAt, workspaceId, review.id, expected)
107
+ .first();
108
+ if (!row)
109
+ return false;
110
+ review.rev = row.rev;
111
+ return true;
112
+ }
113
+ async replaceForBlock(workspaceId, review) {
114
+ // ONE conflict-targeted upsert on the block's UNIQUE key (migration 0066), NOT a
115
+ // delete-then-insert pair: there is no window between two statements for a second review run
116
+ // to interleave into, on either runtime. The predecessor's row IS this row — its `id` is
117
+ // overwritten — so the superseded review is gone rather than left beside the live one, and
118
+ // `rev` restarts at 0 so a writer still holding the predecessor's revision can't match.
119
+ const row = await this.db
120
+ .prepare(`INSERT INTO requirement_reviews
121
+ (workspace_id, id, block_id, status, items, model, incorporated_requirements,
122
+ iteration, max_iterations, recommendations, rev, created_at, updated_at)
123
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
124
+ ON CONFLICT (workspace_id, block_id) DO UPDATE SET
125
+ id = excluded.id,
126
+ status = excluded.status,
127
+ items = excluded.items,
128
+ model = excluded.model,
129
+ incorporated_requirements = excluded.incorporated_requirements,
130
+ iteration = excluded.iteration,
131
+ max_iterations = excluded.max_iterations,
132
+ recommendations = excluded.recommendations,
133
+ rev = 0,
134
+ created_at = excluded.created_at,
135
+ updated_at = excluded.updated_at
136
+ RETURNING rev`)
137
+ .bind(workspaceId, review.id, review.blockId, review.status, JSON.stringify(review.items), review.model, review.incorporatedRequirements, review.iteration ?? 1, review.maxIterations ?? 1, JSON.stringify(review.recommendations ?? []), review.createdAt, review.updatedAt)
138
+ .first();
139
+ // Read the revision back rather than assuming it: the caller keeps mutating this object
140
+ // through `compareAndSwap`, so a `rev` it only believes is right desynchronises it.
141
+ review.rev = row?.rev ?? 0;
78
142
  }
79
143
  }
80
144
  //# sourceMappingURL=D1RequirementReviewRepository.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"D1RequirementReviewRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1RequirementReviewRepository.ts"],"names":[],"mappings":"AAsBA,SAAS,cAAc,CAAI,GAA8B;IACvD,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC9B,OAAO,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAE,MAAc,CAAC,CAAC,CAAC,EAAE,CAAA;IACrD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,GAAyB;IAC5C,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAE;QACV,OAAO,EAAE,GAAG,CAAC,QAAQ;QACrB,MAAM,EAAE,GAAG,CAAC,MAAqC;QACjD,KAAK,EAAE,cAAc,CAAwB,GAAG,CAAC,KAAK,CAAC;QACvD,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,wBAAwB,EAAE,GAAG,CAAC,yBAAyB;QACvD,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,aAAa,EAAE,GAAG,CAAC,cAAc;QACjC,eAAe,EAAE,cAAc,CAA4B,GAAG,CAAC,eAAe,CAAC;QAC/E,SAAS,EAAE,GAAG,CAAC,UAAU;QACzB,SAAS,EAAE,GAAG,CAAC,UAAU;KAC1B,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,OAAO,6BAA6B;IACvB,EAAE,CAAY;IAE/B,YAAY,EAAE,EAAE,EAAsB;QACpC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAA;IACd,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,WAAmB,EAAE,OAAe;QACnD,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CACN;;4CAEoC,CACrC;aACA,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC;aAC1B,KAAK,EAAwB,CAAA;QAChC,OAAO,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,WAAmB,EAAE,EAAU;QACvC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE;aACtB,OAAO,CAAC,qEAAqE,CAAC;aAC9E,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;aACrB,KAAK,EAAwB,CAAA;QAChC,OAAO,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAAmB,EAAE,MAAyB;QACzD,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CACN;;;;;;;;;;;;;4CAaoC,CACrC;aACA,IAAI,CACH,WAAW,EACX,MAAM,CAAC,EAAE,EACT,MAAM,CAAC,OAAO,EACd,MAAM,CAAC,MAAM,EACb,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,EAC5B,MAAM,CAAC,KAAK,EACZ,MAAM,CAAC,wBAAwB,EAC/B,MAAM,CAAC,SAAS,IAAI,CAAC,EACrB,MAAM,CAAC,aAAa,IAAI,CAAC,EACzB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,eAAe,IAAI,EAAE,CAAC,EAC5C,MAAM,CAAC,SAAS,EAChB,MAAM,CAAC,SAAS,CACjB;aACA,GAAG,EAAE,CAAA;IACV,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,WAAmB,EAAE,OAAe;QACtD,MAAM,IAAI,CAAC,EAAE;aACV,OAAO,CAAC,yEAAyE,CAAC;aAClF,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC;aAC1B,GAAG,EAAE,CAAA;IACV,CAAC;CACF"}
1
+ {"version":3,"file":"D1RequirementReviewRepository.js","sourceRoot":"","sources":["../../../src/infrastructure/repositories/D1RequirementReviewRepository.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ -- Optimistic concurrency for the iterative-review stores (race-condition audit 2.5).
2
+ --
3
+ -- A requirements / clarity / brainstorm review is ONE JSON blob holding every finding, and every
4
+ -- mutation used to load it, edit one item, and write the whole row back. Two writers in that
5
+ -- window (two people answering different findings, a dismissal racing the durable driver's
6
+ -- incorporation pass) left only the last writer's edit — and because incorporation refuses to run
7
+ -- while any finding is still `open`, a lost dismissal wedged the loop on a phantom open item.
8
+ --
9
+ -- `rev` is the monotonic token every read-modify-write now CASes on: the store writes only while
10
+ -- the row still carries the revision the caller loaded, so a lost race reloads and re-applies
11
+ -- instead of clobbering. Existing rows start at 0, which is exactly what an unset column reads as.
12
+ ALTER TABLE requirement_reviews ADD COLUMN rev INTEGER NOT NULL DEFAULT 0;
13
+ ALTER TABLE clarity_reviews ADD COLUMN rev INTEGER NOT NULL DEFAULT 0;
14
+ ALTER TABLE brainstorm_sessions ADD COLUMN rev INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,64 @@
1
+ -- One live review per block, enforced by the DATABASE (race-condition audit 2.5, follow-up).
2
+ --
3
+ -- Migration 0065 gave the review stores a `rev` + `compareAndSwap`, and the service publishes a
4
+ -- fresh review through an atomic replace. But "atomic" was carried by a transaction wrapping a
5
+ -- DELETE-then-INSERT, and a transaction is not a uniqueness constraint: under Postgres' default
6
+ -- READ COMMITTED a DELETE takes no predicate lock, so two review runs for one block both delete
7
+ -- nothing (or each other's already-gone row), both insert, and both commit — leaving TWO live
8
+ -- reviews, which is exactly the hazard the replace was introduced to close. SQLite serializes
9
+ -- writers so D1 happened to be safe; that is a runtime accident, not a guarantee, and the two
10
+ -- facades must not disagree about a domain invariant.
11
+ --
12
+ -- The invariant is therefore expressed where it belongs: a UNIQUE index on the block key. With it
13
+ -- the replace becomes a single conflict-targeted upsert (no delete, nothing to interleave) on both
14
+ -- runtimes, and any future writer that tries to add a second live review fails loudly instead of
15
+ -- silently splitting the block's review in two.
16
+ --
17
+ -- Self-healing, like every constraint-adding migration here: pre-existing duplicates (rows a
18
+ -- delete/insert pair already interleaved into existence) are deleted before the index is created,
19
+ -- newest `created_at` winning — the same row `getByBlock` returns today, so the surviving review is
20
+ -- the one the product is already showing. Dropping the superseded duplicates is correct: they were
21
+ -- never reachable.
22
+
23
+ DELETE FROM requirement_reviews
24
+ WHERE rowid NOT IN (
25
+ SELECT rowid FROM requirement_reviews r
26
+ WHERE r.rowid = (
27
+ SELECT rowid FROM requirement_reviews x
28
+ WHERE x.workspace_id = r.workspace_id AND x.block_id = r.block_id
29
+ ORDER BY x.created_at DESC, x.rowid DESC
30
+ LIMIT 1
31
+ )
32
+ );
33
+ DROP INDEX IF EXISTS idx_requirement_reviews_block;
34
+ CREATE UNIQUE INDEX idx_requirement_reviews_block
35
+ ON requirement_reviews (workspace_id, block_id);
36
+
37
+ DELETE FROM clarity_reviews
38
+ WHERE rowid NOT IN (
39
+ SELECT rowid FROM clarity_reviews r
40
+ WHERE r.rowid = (
41
+ SELECT rowid FROM clarity_reviews x
42
+ WHERE x.workspace_id = r.workspace_id AND x.block_id = r.block_id
43
+ ORDER BY x.created_at DESC, x.rowid DESC
44
+ LIMIT 1
45
+ )
46
+ );
47
+ DROP INDEX IF EXISTS idx_clarity_reviews_block;
48
+ CREATE UNIQUE INDEX idx_clarity_reviews_block ON clarity_reviews (workspace_id, block_id);
49
+
50
+ -- Brainstorm is keyed by (block, stage): a block legitimately holds one live `requirements` and
51
+ -- one live `architecture` session at once, so the stage is part of the uniqueness key.
52
+ DELETE FROM brainstorm_sessions
53
+ WHERE rowid NOT IN (
54
+ SELECT rowid FROM brainstorm_sessions r
55
+ WHERE r.rowid = (
56
+ SELECT rowid FROM brainstorm_sessions x
57
+ WHERE x.workspace_id = r.workspace_id AND x.block_id = r.block_id AND x.stage = r.stage
58
+ ORDER BY x.created_at DESC, x.rowid DESC
59
+ LIMIT 1
60
+ )
61
+ );
62
+ DROP INDEX IF EXISTS idx_brainstorm_sessions_block_stage;
63
+ CREATE UNIQUE INDEX idx_brainstorm_sessions_block_stage
64
+ ON brainstorm_sessions (workspace_id, block_id, stage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/worker",
3
- "version": "0.110.1",
3
+ "version": "0.111.0",
4
4
  "description": "Reusable Cloudflare Worker library (Hono + D1) exposing the Agent Architecture Board core: the `createApp()` Hono factory, the default fetch/scheduled/queue handler, and the Durable Object + Workflow classes. Deployments (see deploy/backend) supply the wrangler.toml + config and re-export these.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,22 +42,22 @@
42
42
  "pino": "^10.3.1",
43
43
  "valibot": "^1.4.2",
44
44
  "workers-ai-provider": "^4.0.0",
45
- "@cat-factory/agents": "0.75.0",
46
- "@cat-factory/caching": "0.10.55",
47
- "@cat-factory/consensus": "0.11.52",
48
- "@cat-factory/contracts": "0.177.0",
49
- "@cat-factory/eks": "0.1.151",
50
- "@cat-factory/gates": "0.7.42",
51
- "@cat-factory/gitlab": "0.13.18",
52
- "@cat-factory/integrations": "0.103.2",
53
- "@cat-factory/kernel": "0.170.0",
54
- "@cat-factory/observability-langfuse": "0.7.273",
55
- "@cat-factory/observability-otel": "0.2.56",
56
- "@cat-factory/orchestration": "0.150.1",
57
- "@cat-factory/prompt-fragments": "0.15.0",
58
- "@cat-factory/provider-cloudflare": "0.7.302",
59
- "@cat-factory/server": "0.160.0",
60
- "@cat-factory/spend": "0.12.99"
45
+ "@cat-factory/agents": "0.75.1",
46
+ "@cat-factory/caching": "0.10.56",
47
+ "@cat-factory/consensus": "0.11.53",
48
+ "@cat-factory/contracts": "0.178.0",
49
+ "@cat-factory/eks": "0.1.152",
50
+ "@cat-factory/gates": "0.7.43",
51
+ "@cat-factory/gitlab": "0.13.19",
52
+ "@cat-factory/integrations": "0.103.3",
53
+ "@cat-factory/kernel": "0.171.0",
54
+ "@cat-factory/observability-langfuse": "0.7.274",
55
+ "@cat-factory/observability-otel": "0.2.57",
56
+ "@cat-factory/orchestration": "0.151.0",
57
+ "@cat-factory/prompt-fragments": "0.15.1",
58
+ "@cat-factory/provider-cloudflare": "0.7.303",
59
+ "@cat-factory/server": "0.162.0",
60
+ "@cat-factory/spend": "0.12.100"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@cloudflare/vitest-pool-workers": "^0.18.8",
@@ -65,7 +65,7 @@
65
65
  "typescript": "7.0.2",
66
66
  "vitest": "^4.1.10",
67
67
  "wrangler": "^4.114.0",
68
- "@cat-factory/conformance": "0.12.13"
68
+ "@cat-factory/conformance": "0.12.15"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsc -b tsconfig.build.json",