@nanobpm/urban 0.80.0 → 0.81.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.
Files changed (34) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/gateway.js +5 -4
  6. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  7. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  8. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  9. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  10. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  11. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  12. package/dist/runtime/core/modules/pages.js +6 -4
  13. package/dist/runtime/core/read-model.d.ts +28 -0
  14. package/dist/runtime/core/read-model.js +44 -4
  15. package/dist/runtime/core/validate.js +192 -0
  16. package/dist/runtime/index.d.ts +2 -1
  17. package/dist/runtime/index.js +5 -0
  18. package/package.json +1 -1
  19. package/src/runtime/core/manifest.ts +44 -11
  20. package/src/runtime/core/modules/cancel.test.ts +112 -38
  21. package/src/runtime/core/modules/cancel.ts +42 -22
  22. package/src/runtime/core/modules/datasource.ts +7 -4
  23. package/src/runtime/core/modules/gateway.ts +7 -4
  24. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  25. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  26. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  27. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  28. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  29. package/src/runtime/core/modules/pages.ts +6 -5
  30. package/src/runtime/core/read-model.test.ts +69 -0
  31. package/src/runtime/core/read-model.ts +43 -4
  32. package/src/runtime/core/validate.test.ts +341 -0
  33. package/src/runtime/core/validate.ts +188 -0
  34. package/src/runtime/index.ts +15 -0
@@ -9,29 +9,39 @@ import { makeGateway } from "./gateway.ts";
9
9
  import type { Table } from "./gateway.ts";
10
10
  import { DataLayer, type ProvisionedSource } from "./datasource.ts";
11
11
  import type { AppApi } from "../context.ts";
12
- import type { EngineClient, ProcessInstanceSnapshot, UserTaskFilter, UserTaskSummary } from "../host.ts";
12
+ import type { EngineClient, ProcessInstanceSnapshot, SqliteDb, UserTaskFilter, UserTaskSummary } from "../host.ts";
13
13
  import type { InstanceTracking } from "../manifest.ts";
14
14
  import {
15
15
  DEFAULT_INSTANCE_TRACKING_POLL_MS,
16
16
  mountInstanceTracking,
17
+ reconcileTerminatedKey,
18
+ reconcileWaitingHumanKey,
19
+ tryInstanceProjections,
17
20
  WAITING_HUMAN_PROBE_CONCURRENCY,
18
21
  } from "./instance-tracking.ts";
22
+ import { defineInstanceTrackingReadModel } from "./instance-status-read-model.ts";
23
+ import { INSTANCE_STATE_TABLE } from "./instance-state-store.ts";
24
+ import { OPEN_USER_TASKS_TABLE } from "./open-user-tasks-store.ts";
25
+ import { assertReadModelParity, defineReadModel, lit, readModelRegistry } from "../read-model.ts";
19
26
  import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
20
27
  import { fakeScheduler } from "./scheduler.test-utils.ts";
21
28
 
22
- // A real-timer flush drains the entire pending microtask chain (find → engine → update)
23
- // each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
24
- const realSetTimeout = globalThis.setTimeout;
25
- const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
29
+ // ADR 0065 — the instanceTracking writer→source inversion. The reconciler no longer WRITES a derivable
30
+ // status column on the base row; it FEEDS engine truth into the canonical projections
31
+ // (`_urban_instance_state`, `_urban_open_user_tasks`) and the effective status is DERIVED by the managed
32
+ // `<table>__tracking` VIEW's `derived_status` column. These tests therefore assert on the DERIVED value
33
+ // and the projection state — never a base-row mutation (the base `status` is left untouched for the
34
+ // app's own workers).
26
35
 
27
36
  /** An EngineClient driven by a fixed map of processInstanceKey → state (for searchProcessInstances)
28
37
  * and an optional map of processInstanceKey → number-of-open-user-tasks (for openUserTasks, the
29
38
  * wait-on-human edge). Records the keys asked for on each call. `openUserTasks` models the engine
30
- * truth the reconciler derives `awaiting_operator` from — only *open* (CREATED) tasks count, so a
39
+ * truth the reconciler feeds `urban_open_user_tasks` from — only *open* (CREATED) tasks count, so a
31
40
  * key absent from `openTasks` (or mapped to 0) reports no parked human. */
32
41
  function fakeEngine(
33
42
  states: Record<string, ProcessInstanceSnapshot["state"]>,
34
43
  openTasks: Record<string, number> = {},
44
+ failOpenTaskKeys: ReadonlySet<string> = new Set(),
35
45
  ): {
36
46
  engine: EngineClient;
37
47
  queries: string[][];
@@ -63,6 +73,11 @@ function fakeEngine(
63
73
  async openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]> {
64
74
  const key = filter?.processInstanceKey;
65
75
  userTaskQueries.push(key);
76
+ // A key in `failOpenTaskKeys` models a permanently-failing engine probe (issue #452 review):
77
+ // one such key must not reject the whole `Promise.all` batch and starve the rest of the backlog.
78
+ if (key !== undefined && failOpenTaskKeys.has(key)) {
79
+ throw new Error(`openUserTasks probe permanently failing for ${key}`);
80
+ }
66
81
  const n = key ? (openTasks[key] ?? 0) : 0;
67
82
  return Array.from({ length: n }, (_unused, i): UserTaskSummary => ({
68
83
  userTaskKey: `${key}-t${i}`,
@@ -84,6 +99,7 @@ function fakeEngine(
84
99
  interface Harness {
85
100
  api: AppApi;
86
101
  table: Table<PlanRow>;
102
+ db: SqliteDb;
87
103
  dir: string;
88
104
  close: () => Promise<void>;
89
105
  logs: { level: string; msg: string }[];
@@ -136,6 +152,7 @@ async function withHarness(
136
152
  return {
137
153
  api,
138
154
  table: tbl,
155
+ db,
139
156
  dir,
140
157
  logs,
141
158
  close: async () => {
@@ -158,7 +175,45 @@ const planBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking =>
158
175
  ...over,
159
176
  });
160
177
 
161
- test("reconciles an active row whose instance is TERMINATED", async () => {
178
+ // ── Derived-status / projection query helpers (the inversion's read surface) ────────────────────
179
+
180
+ /** The DERIVED effective status for a key, read from the managed `<table>__tracking` VIEW's
181
+ * `derived_status` column — the value the operator page reads post-inversion. Returns the values for
182
+ * every base row sharing the key (a non-unique keyField maps to several rows). */
183
+ function derived(h: Harness, key: string, view = "plans__tracking", keyField = "process_key"): string[] {
184
+ const rows = h.db.all<{ derived_status: string }>(
185
+ `SELECT derived_status FROM ${view} WHERE ${keyField} = ? ORDER BY plan_key`,
186
+ [key],
187
+ );
188
+ return rows.map((r) => r.derived_status);
189
+ }
190
+
191
+ /** The single derived status for a key (asserts exactly one base row). */
192
+ function derivedOne(h: Harness, key: string, view = "plans__tracking"): string | undefined {
193
+ const all = derived(h, key, view);
194
+ return all[0];
195
+ }
196
+
197
+ /** The recorded engine lifecycle state for a key in the `_urban_instance_state` projection, or
198
+ * undefined when the key was never recorded (never terminated). */
199
+ function projectedState(h: Harness, key: string): string | undefined {
200
+ const rows = h.db.all<{ state: string }>(
201
+ `SELECT state FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`,
202
+ [key],
203
+ );
204
+ return rows[0]?.state;
205
+ }
206
+
207
+ /** The number of open user tasks projected for a key in `_urban_open_user_tasks`. */
208
+ function projectedOpenTasks(h: Harness, key: string): number {
209
+ const rows = h.db.all<{ n: number }>(
210
+ `SELECT COUNT(*) AS n FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`,
211
+ [key],
212
+ );
213
+ return Number(rows[0]?.n ?? 0);
214
+ }
215
+
216
+ test("derives the terminal status for an active row whose instance is TERMINATED (no base write)", async () => {
162
217
  const { engine } = fakeEngine({ pi1: "TERMINATED" });
163
218
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
164
219
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
@@ -166,14 +221,18 @@ test("reconciles an active row whose instance is TERMINATED", async () => {
166
221
  const sched = fakeScheduler();
167
222
  const handle = mount(h, [planBinding()], sched);
168
223
  await sched.advance(1000);
224
+ // The derived status flips; the base row is UNTOUCHED (source-not-writer).
225
+ assert.equal(derivedOne(h, "pi1"), "abandoned");
169
226
  const row = await h.table.get("pi1");
170
- assert.equal(row?.status, "abandoned");
171
- assert.equal(row?.note, null);
227
+ assert.equal(row?.status, "dispatched"); // base status never rewritten
228
+ assert.equal(row?.note, "hi"); // secondary column never rewritten either
229
+ // …and the terminal fact was recorded into the canonical projection.
230
+ assert.equal(projectedState(h, "pi1"), "TERMINATED");
172
231
  await handle.stop();
173
232
  await h.close();
174
233
  });
175
234
 
176
- test("leaves an active row whose instance is still ACTIVE untouched", async () => {
235
+ test("derives the stored status for an active row whose instance is still ACTIVE", async () => {
177
236
  const { engine } = fakeEngine({ pi1: "ACTIVE" });
178
237
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
179
238
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
@@ -181,13 +240,13 @@ test("leaves an active row whose instance is still ACTIVE untouched", async () =
181
240
  const sched = fakeScheduler();
182
241
  const handle = mount(h, [planBinding()], sched);
183
242
  await sched.advance(1000);
184
- const row = await h.table.get("pi1");
185
- assert.equal(row?.status, "dispatched");
243
+ assert.equal(derivedOne(h, "pi1"), "dispatched"); // no terminal edge ⇒ passes the stored value through
244
+ assert.equal(projectedState(h, "pi1"), undefined); // ACTIVE is not recorded
186
245
  await handle.stop();
187
246
  await h.close();
188
247
  });
189
248
 
190
- test("does NOT reconcile a COMPLETED instance (a finalize worker owns that row)", async () => {
249
+ test("does NOT record a COMPLETED instance (a finalize worker owns that outcome)", async () => {
191
250
  const { engine, queries } = fakeEngine({ pi1: "COMPLETED" });
192
251
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
193
252
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
@@ -195,8 +254,8 @@ test("does NOT reconcile a COMPLETED instance (a finalize worker owns that row)"
195
254
  const sched = fakeScheduler();
196
255
  const handle = mount(h, [planBinding()], sched);
197
256
  await sched.advance(1000);
198
- const row = await h.table.get("pi1");
199
- assert.equal(row?.status, "dispatched"); // untouched
257
+ assert.equal(derivedOne(h, "pi1"), "dispatched"); // not terminal-derived
258
+ assert.equal(projectedState(h, "pi1"), undefined); // COMPLETED never recorded here
200
259
  // and it did ask the engine, filtered to TERMINATED
201
260
  assert.deepEqual(queries, [["pi1"]]);
202
261
  await handle.stop();
@@ -248,7 +307,9 @@ test("re-arms after each tick and stops cleanly", async () => {
248
307
  await h.close();
249
308
  });
250
309
 
251
- test("without statusField/activeStatuses, polls every row", async () => {
310
+ test("without statusField, polls every row and records each terminal instance into the projection", async () => {
311
+ // With no statusField there is no single column to derive, so NO VIEW is provisioned — but the
312
+ // reconciler still feeds engine truth into the canonical instance-state projection.
252
313
  const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
253
314
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
254
315
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "abandoned", note: null });
@@ -259,13 +320,13 @@ test("without statusField/activeStatuses, polls every row", async () => {
259
320
  const handle = mount(h, [binding], sched);
260
321
  await sched.advance(1000);
261
322
  assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]);
262
- // both got reconciled (already-abandoned p1 is a no-op change, p2 flips)
263
- assert.equal((await h.table.get("pi2"))?.status, "abandoned");
323
+ assert.equal(projectedState(h, "pi1"), "TERMINATED");
324
+ assert.equal(projectedState(h, "pi2"), "TERMINATED");
264
325
  await handle.stop();
265
326
  await h.close();
266
327
  });
267
328
 
268
- test("terminalStatuses: reconciles a TERMINATED row in an un-enumerated non-terminal status (the allow-list regression)", async () => {
329
+ test("terminalStatuses: derives the terminal status for a TERMINATED row in an un-enumerated non-terminal status (the allow-list regression)", async () => {
269
330
  // The row's status ("awaiting_operator") is listed in NEITHER activeStatuses nor terminalStatuses
270
331
  // — the exact drift an allow-list misses. A fail-open terminalStatuses selector still polls it.
271
332
  const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
@@ -277,8 +338,7 @@ test("terminalStatuses: reconciles a TERMINATED row in an un-enumerated non-term
277
338
  const handle = mount(h, [binding], sched);
278
339
  await sched.advance(1000);
279
340
  assert.deepEqual(queries, [["pi1"]]); // it WAS polled despite not being in any enumerated list
280
- const row = await h.table.get("pi1");
281
- assert.equal(row?.status, "abandoned"); // …and reconciled
341
+ assert.equal(derivedOne(h, "pi1"), "abandoned"); // …and derives to terminal
282
342
  await handle.stop();
283
343
  await h.close();
284
344
  });
@@ -311,7 +371,7 @@ test("terminalStatuses wins when a binding somehow declares both selectors (fail
311
371
  const handle = mount(h, [binding], sched);
312
372
  await sched.advance(1000);
313
373
  assert.deepEqual(queries, [["pi1"]]); // polled via the terminalStatuses selector
314
- assert.equal((await h.table.get("pi1"))?.status, "abandoned");
374
+ assert.equal(derivedOne(h, "pi1"), "abandoned");
315
375
  await handle.stop();
316
376
  await h.close();
317
377
  });
@@ -405,12 +465,11 @@ for (const bad of [0, -1000, Number.NaN]) {
405
465
  });
406
466
  }
407
467
 
408
-
409
468
  // ── Wait-on-human edge (onWaitingHuman, issue #355) ────────────────────────────────────────────
410
469
 
411
470
  // A binding that reconciles both engine-truth edges: terminal (onTerminated) and wait-on-human
412
471
  // (onWaitingHuman). terminalStatuses (fail-open) keeps every non-abandoned row polled so a stale
413
- // worker-written transient status (converging/merging/running) is still reconciled to awaiting.
472
+ // worker-written transient status (converging/merging/running) still derives to awaiting.
414
473
  const waitingBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
415
474
  table: "plans",
416
475
  keyField: "process_key",
@@ -424,7 +483,8 @@ const waitingBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking
424
483
 
425
484
  test("derives awaiting_operator for a row whose instance has an open user task (forward)", async () => {
426
485
  // The row carries a STALE worker-written transient status; the instance is parked at a user task.
427
- // One reconcile pass must read it back as awaiting_operator — derived, not written.
486
+ // One reconcile pass must read it back as awaiting_operator — DERIVED over the fed projection,
487
+ // never written to the base row.
428
488
  const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
429
489
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
430
490
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
@@ -432,16 +492,17 @@ test("derives awaiting_operator for a row whose instance has an open user task (
432
492
  const sched = fakeScheduler();
433
493
  const handle = mount(h, [waitingBinding()], sched);
434
494
  await sched.advance(1000);
435
- assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator");
495
+ assert.equal(derivedOne(h, "pi1"), "awaiting_operator");
496
+ assert.equal((await h.table.get("pi1"))?.status, "converging"); // base untouched
497
+ assert.equal(projectedOpenTasks(h, "pi1"), 1); // the open task was fed into the projection
436
498
  await handle.stop();
437
499
  await h.close();
438
500
  });
439
501
 
440
- test("leaves the worker-owned transient status when the instance has NO open user task (backward)", async () => {
441
- // No open user task and not terminated ⇒ the reconciler must not force awaiting_operator; the
442
- // worker-owned transient status survives. This is the "must not remain awaiting_operator once the
443
- // task completes" leg: a just-answered instance (task completed, worker wrote converging) is not
444
- // re-latched to awaiting_operator.
502
+ test("derives the worker-owned transient status when the instance has NO open user task (backward)", async () => {
503
+ // No open user task and not terminated ⇒ the derived edge must NOT be awaiting_operator; the
504
+ // worker-owned transient status shows through. This is the "must not remain awaiting_operator once
505
+ // the task completes" leg.
445
506
  const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 0 });
446
507
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
447
508
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
@@ -449,15 +510,16 @@ test("leaves the worker-owned transient status when the instance has NO open use
449
510
  const sched = fakeScheduler();
450
511
  const handle = mount(h, [waitingBinding()], sched);
451
512
  await sched.advance(1000);
452
- assert.equal((await h.table.get("pi1"))?.status, "converging");
513
+ assert.equal(derivedOne(h, "pi1"), "converging");
514
+ assert.equal(projectedOpenTasks(h, "pi1"), 0);
453
515
  await handle.stop();
454
516
  await h.close();
455
517
  });
456
518
 
457
519
  test("re-escalation after an answer re-flips to awaiting_operator on the next poll (instance A)", async () => {
458
520
  // The re-escalation-after-answer defect (nano-workforce#318): the row sits at converging after an
459
- // answer; a re-escalation opens a NEW user task. Because the status is DERIVED every tick, the
460
- // next poll re-flips it to awaiting_operator by construction — no bespoke poller, no stuck state.
521
+ // answer; a re-escalation opens a NEW user task. Because the status is DERIVED every tick over the
522
+ // live projection, the next poll re-flips it to awaiting_operator by construction.
461
523
  const openTasks: Record<string, number> = { pi1: 0 }; // answered: task completed, none open
462
524
  const { engine } = fakeEngine({ pi1: "ACTIVE" }, openTasks);
463
525
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
@@ -466,17 +528,47 @@ test("re-escalation after an answer re-flips to awaiting_operator on the next po
466
528
  const sched = fakeScheduler();
467
529
  const handle = mount(h, [waitingBinding()], sched);
468
530
  await sched.advance(1000);
469
- assert.equal((await h.table.get("pi1"))?.status, "converging"); // still converging: no open task
531
+ assert.equal(derivedOne(h, "pi1"), "converging"); // still converging: no open task
470
532
  openTasks.pi1 = 1; // re-escalation: a new user task opens
471
533
  await sched.advance(1000);
472
- assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator"); // re-flipped
534
+ assert.equal(derivedOne(h, "pi1"), "awaiting_operator"); // re-flipped
535
+ await handle.stop();
536
+ await h.close();
537
+ });
538
+
539
+ test("#422: an answered escalation reads NON-stale by construction (tearing is gone)", async () => {
540
+ // nano-workforce#422 — the tearing the inversion removes. Under the OLD writer the derivable
541
+ // `status` was STORED, so an answered escalation (its user task retired) still showed a stale ⚠
542
+ // (awaiting_operator) until a bespoke self-heal poll re-wrote it. With the derivation over the LIVE
543
+ // open-task projection, once `syncInstance([])` retires the task the EXISTS goes false on the very
544
+ // next read — there is no stored column left to tear.
545
+ const openTasks: Record<string, number> = { pi1: 1 }; // escalated: one open user task
546
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, openTasks);
547
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
548
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
549
+ });
550
+ const sched = fakeScheduler();
551
+ const handle = mount(h, [waitingBinding()], sched);
552
+ await sched.advance(1000);
553
+ assert.equal(derivedOne(h, "pi1"), "awaiting_operator"); // escalated ⇒ parked on a human
554
+ assert.equal(projectedOpenTasks(h, "pi1"), 1);
555
+
556
+ // The operator answers/closes the escalation — the engine no longer reports an open task.
557
+ openTasks.pi1 = 0;
558
+ await sched.advance(1000);
559
+ // The derived read model MUST NOT still read awaiting_operator. The retired task was cleared from
560
+ // the projection, so the edge is false — non-stale WITHOUT any compensating write.
561
+ assert.notEqual(derivedOne(h, "pi1"), "awaiting_operator");
562
+ assert.equal(derivedOne(h, "pi1"), "converging");
563
+ assert.equal(projectedOpenTasks(h, "pi1"), 0); // the answered task was retired from the projection
473
564
  await handle.stop();
474
565
  await h.close();
475
566
  });
476
567
 
477
568
  test("terminated wins over an open user task (precedence)", async () => {
478
- // A terminated instance that also (transiently) reports an open user task must reconcile to the
479
- // terminal patch, never awaiting_operator — onTerminated has precedence.
569
+ // A terminated instance that also (transiently) reports an open user task must derive to the
570
+ // terminal status, never awaiting_operator — onTerminated has precedence, and the terminal record
571
+ // retires the open tasks at the source too.
480
572
  const { engine, userTaskQueries } = fakeEngine({ pi1: "TERMINATED" }, { pi1: 1 });
481
573
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
482
574
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: "x" });
@@ -484,17 +576,17 @@ test("terminated wins over an open user task (precedence)", async () => {
484
576
  const sched = fakeScheduler();
485
577
  const handle = mount(h, [waitingBinding()], sched);
486
578
  await sched.advance(1000);
487
- const row = await h.table.get("pi1");
488
- assert.equal(row?.status, "abandoned");
489
- assert.equal(row?.note, null);
579
+ assert.equal(derivedOne(h, "pi1"), "abandoned");
580
+ assert.equal(projectedState(h, "pi1"), "TERMINATED");
581
+ assert.equal(projectedOpenTasks(h, "pi1"), 0); // terminal record cleared any open tasks
490
582
  assert.deepEqual(userTaskQueries, []); // terminated key was excluded before the openUserTasks probe
491
583
  await handle.stop();
492
584
  await h.close();
493
585
  });
494
586
 
495
- test("does not re-write or re-log a row already at awaiting_operator (quiet-idempotent)", async () => {
496
- // A long-parked instance is re-polled every tick. The wait-on-human writer must be a no-op when
497
- // the row already carries the patch, so it does not re-log on every poll.
587
+ test("does not log per-poll for a long-parked awaiting_operator instance (quiet feed)", async () => {
588
+ // A long-parked instance is re-polled every tick. Feeding the projection is idempotent and must not
589
+ // emit a per-poll info/warn log (the derived edge is recomputed silently on read).
498
590
  const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
499
591
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
500
592
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
@@ -503,9 +595,9 @@ test("does not re-write or re-log a row already at awaiting_operator (quiet-idem
503
595
  const handle = mount(h, [waitingBinding()], sched);
504
596
  await sched.advance(1000);
505
597
  await sched.advance(1000);
506
- assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator");
507
- const awaitingLogs = h.logs.filter((l) => l.msg.includes("awaiting operator"));
508
- assert.equal(awaitingLogs.length, 0); // no-op patch never logged
598
+ assert.equal(derivedOne(h, "pi1"), "awaiting_operator");
599
+ const noisy = h.logs.filter((l) => l.msg.includes("awaiting") || (l.level !== "debug" && l.msg.includes("open user tasks")));
600
+ assert.equal(noisy.length, 0); // idempotent feed never logs
509
601
  await handle.stop();
510
602
  await h.close();
511
603
  });
@@ -519,16 +611,16 @@ test("a binding without onWaitingHuman never probes open user tasks (edge is opt
519
611
  const handle = mount(h, [planBinding()], sched); // planBinding has no onWaitingHuman
520
612
  await sched.advance(1000);
521
613
  assert.deepEqual(userTaskQueries, []); // never probed
522
- assert.equal((await h.table.get("pi1"))?.status, "dispatched"); // untouched
614
+ assert.equal(derivedOne(h, "pi1"), "dispatched"); // derives to the stored status; no wait-on-human edge
615
+ assert.equal((await h.table.get("pi1"))?.status, "dispatched"); // base untouched
523
616
  await handle.stop();
524
617
  await h.close();
525
618
  });
526
619
 
527
- test("reconciles EVERY row sharing a non-unique key, even when one is already patched (multi-row)", async () => {
528
- // `keyField` (process_key) is NOT unique here, and table.update patches ALL matching rows. The
529
- // quiet-idempotence check must consider every matching row, not a single get() (LIMIT 1): with two
530
- // rows on the same process_key — one already awaiting_operator, one still converging — a LIMIT-1
531
- // probe that hits the patched row would skip the write and strand the other. Both must converge.
620
+ test("derives awaiting_operator for EVERY row sharing a non-unique key (multi-row)", async () => {
621
+ // `keyField` (process_key) is NOT unique here. Post-inversion the projection is fed ONCE per key and
622
+ // the derived VIEW joins every base row on that key to it — so both rows sharing pi1 derive to the
623
+ // same edge, regardless of their (untouched) stored status. No per-row write to strand.
532
624
  const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
533
625
  const h = await withHarness(engine, PLANS_DDL, async (t) => {
534
626
  await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
@@ -537,18 +629,17 @@ test("reconciles EVERY row sharing a non-unique key, even when one is already pa
537
629
  const sched = fakeScheduler();
538
630
  const handle = mount(h, [waitingBinding()], sched);
539
631
  await sched.advance(1000);
632
+ assert.deepEqual(derived(h, "pi1"), ["awaiting_operator", "awaiting_operator"]);
633
+ // The base rows keep their (distinct) stored statuses — nothing was written.
540
634
  const rows = await h.table.find({ process_key: "pi1" });
541
- assert.deepEqual(
542
- rows.map((r) => r.status).sort(),
543
- ["awaiting_operator", "awaiting_operator"],
544
- );
635
+ assert.deepEqual(rows.map((r) => r.status).sort(), ["awaiting_operator", "converging"]);
545
636
  await handle.stop();
546
637
  await h.close();
547
638
  });
548
639
 
549
- test("reconciles every parked key across multiple probe batches (bounded-parallel probing)", async () => {
640
+ test("feeds every parked key across multiple probe batches (bounded-parallel probing)", async () => {
550
641
  // More active keys than WAITING_HUMAN_PROBE_CONCURRENCY forces multiple probe batches. Every key
551
- // with an open user task must still be probed and reconciled — the batching must not drop keys.
642
+ // must still be probed and fed — the batching must not drop keys.
552
643
  const total = WAITING_HUMAN_PROBE_CONCURRENCY * 2 + 3; // spans three batches
553
644
  const states: Record<string, ProcessInstanceSnapshot["state"]> = {};
554
645
  const openTasks: Record<string, number> = {};
@@ -570,8 +661,446 @@ test("reconciles every parked key across multiple probe batches (bounded-paralle
570
661
  assert.deepEqual([...userTaskQueries].sort(), Object.keys(states).sort());
571
662
  for (let i = 0; i < total; i++) {
572
663
  const expected = i % 2 === 1 ? "awaiting_operator" : "converging";
573
- assert.equal((await h.table.get(`pi${i}`))?.status, expected, `pi${i}`);
664
+ assert.equal(derivedOne(h, `pi${i}`), expected, `pi${i}`);
665
+ }
666
+ await handle.stop();
667
+ await h.close();
668
+ });
669
+
670
+ // ── Read-model parity: the SQL VIEW backend and the TS function backend agree ───────────────────
671
+
672
+ test("the derived status read model's SQL and TS backends stay in parity", async () => {
673
+ // The two backends fall out of the SAME AST; `assertReadModelParity` builds TEMP projection fixtures
674
+ // and checks the compiled VIEW and the in-process function agree over sample rows — the guard that
675
+ // replaces a hand-written per-edge parity test.
676
+ const { engine } = fakeEngine({});
677
+ const h = await withHarness(engine, PLANS_DDL, async () => {});
678
+ const model = defineInstanceTrackingReadModel(waitingBinding());
679
+ assert.ok(model, "waitingBinding has a statusField ⇒ a model");
680
+ assertReadModelParity(
681
+ model,
682
+ h.db,
683
+ [
684
+ // terminal edge true → derives abandoned
685
+ {
686
+ baseRow: { process_key: "k1", status: "converging" },
687
+ projections: {
688
+ urban_instance_state: [{ process_instance_key: "k1", state: "TERMINATED" }],
689
+ urban_open_user_tasks: [],
690
+ },
691
+ },
692
+ // wait-on-human edge true, not terminated → derives awaiting_operator
693
+ {
694
+ baseRow: { process_key: "k2", status: "converging" },
695
+ projections: {
696
+ urban_instance_state: [],
697
+ urban_open_user_tasks: [{ process_instance_key: "k2", user_task_key: "t" }],
698
+ },
699
+ },
700
+ // both true → terminated wins (precedence)
701
+ {
702
+ baseRow: { process_key: "k3", status: "converging" },
703
+ projections: {
704
+ urban_instance_state: [{ process_instance_key: "k3", state: "TERMINATED" }],
705
+ urban_open_user_tasks: [{ process_instance_key: "k3", user_task_key: "t" }],
706
+ },
707
+ },
708
+ // neither → the stored status shows through
709
+ {
710
+ baseRow: { process_key: "k4", status: "running" },
711
+ projections: { urban_instance_state: [], urban_open_user_tasks: [] },
712
+ },
713
+ ],
714
+ );
715
+ await h.close();
716
+ });
717
+
718
+ // ── Efficiency + retirement edges (ADR 0065 review round) ───────────────────────────────────────
719
+
720
+ test("a settled TERMINATED key is not re-queried and does not re-log on subsequent polls", async () => {
721
+ // Once recorded TERMINATED the base row keeps its (unchanged) status, so it would keep passing the
722
+ // selector; the reconciler must skip the settled key on later ticks — no repeat engine query, no repeat
723
+ // `recorded terminated instance` log, no inflated `reconciled` (fixes the one-log-per-settled-per-tick).
724
+ const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
725
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
726
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
727
+ });
728
+ const sched = fakeScheduler();
729
+ const handle = mount(h, [planBinding()], sched);
730
+ await sched.advance(1000);
731
+ await sched.advance(1000);
732
+ await sched.advance(1000);
733
+ assert.deepEqual(queries, [["pi1"]]); // queried once; settled thereafter, never re-queried
734
+ const terminalLogs = h.logs.filter((l) => l.msg.includes("recorded terminated instance"));
735
+ assert.equal(terminalLogs.length, 1); // logged exactly once, not per tick
736
+ await handle.stop();
737
+ await h.close();
738
+ });
739
+
740
+ // A binding that both derives the wait-on-human edge AND uses a fail-CLOSED `activeStatuses` allow-list —
741
+ // the exact combination whose retirement gap the review flagged (a key answered off the allow-list would
742
+ // strand its open-task projection).
743
+ const activeWaitingBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
744
+ table: "plans",
745
+ keyField: "process_key",
746
+ statusField: "status",
747
+ activeStatuses: ["dispatched"],
748
+ onTerminated: { set: { status: "abandoned" } },
749
+ onWaitingHuman: { set: { status: "awaiting_operator" } },
750
+ pollMs: 1000,
751
+ ...over,
752
+ });
753
+
754
+ test("retires a stale open-task projection for a key that left the activeStatuses allow-list", async () => {
755
+ // Instance parked on a human while `dispatched` (in the allow-list) → projected open. The app then
756
+ // answers: the task closes AND the worker flips the row to a non-active status, so the row no longer
757
+ // passes `activeStatuses`. Without retiring projected keys independently of the allow-list, the open-task
758
+ // row would linger and the VIEW keep deriving awaiting_operator — a fresh nano-workforce#422 tear.
759
+ const openTasks: Record<string, number> = { pi1: 1 };
760
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, openTasks);
761
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
762
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
763
+ });
764
+ const sched = fakeScheduler();
765
+ const handle = mount(h, [activeWaitingBinding()], sched);
766
+ await sched.advance(1000);
767
+ assert.equal(projectedOpenTasks(h, "pi1"), 1); // parked: projected open
768
+ assert.equal(derivedOne(h, "pi1"), "awaiting_operator");
769
+
770
+ // Answer: task closed AND worker moved the row OUT of the active set.
771
+ openTasks.pi1 = 0;
772
+ await h.table.update("pi1", { status: "done" });
773
+ await sched.advance(1000);
774
+ assert.equal(projectedOpenTasks(h, "pi1"), 0); // retired even though the row left activeStatuses
775
+ await handle.stop();
776
+ await h.close();
777
+ });
778
+
779
+ test("skips the derived VIEW when its status column collides with an existing base column", async () => {
780
+ // The VIEW selects `base.*` then `${statusColumn} AS ...`; a base column already named the same
781
+ // (here the default `derived_status`) would make SQLite keep the STORED column and shadow the derived
782
+ // one, so a page reading it sees a stale value. Provisioning must detect the collision, skip the VIEW,
783
+ // and warn — rather than publish a VIEW that silently defeats the derivation.
784
+ const ddl =
785
+ "CREATE TABLE plans (plan_key TEXT PRIMARY KEY, process_key TEXT, status TEXT NOT NULL, note TEXT, derived_status TEXT)";
786
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
787
+ const h = await withHarness(engine, ddl, async (t) => {
788
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
789
+ });
790
+ const sched = fakeScheduler();
791
+ const handle = mount(h, [planBinding()], sched); // default statusColumn = derived_status ⇒ collides
792
+ await sched.advance(1000);
793
+ const views = h.db.all<{ name: string }>(
794
+ "SELECT name FROM sqlite_master WHERE type='view' AND name='plans__tracking'",
795
+ );
796
+ assert.equal(views.length, 0); // VIEW was NOT created
797
+ assert.ok(h.logs.some((l) => l.level === "warn" && l.msg.includes("collides with an existing")));
798
+ await handle.stop();
799
+ await h.close();
800
+ });
801
+
802
+ test("reconcileWaitingHumanKey does not re-project a settled TERMINATED instance (cancel-during-poll race)", async () => {
803
+ // The wait-on-human probe `await`s the engine, yielding the event loop; a concurrent cancel can record
804
+ // the instance TERMINATED and retire its tasks WHILE the probe is in flight. Re-inserting the (now
805
+ // stale) open tasks would strand a row the terminal path already cleared — the key is then classified
806
+ // settled and never re-probed, a permanent stale projection row. The terminal recheck must refuse.
807
+ const { engine } = fakeEngine({});
808
+ const h = await withHarness(engine, PLANS_DDL, async () => {});
809
+ const projections = tryInstanceProjections(h.api);
810
+ assert.ok(projections);
811
+ // A concurrent cancel recorded TERMINATED (and cleared any tasks) mid-probe.
812
+ reconcileTerminatedKey(h.api, projections, "pi1");
813
+ assert.equal(projectedState(h, "pi1"), "TERMINATED");
814
+ // The in-flight probe now tries to feed a stale open task; the guard drops it (returns 0, no row).
815
+ const fed = reconcileWaitingHumanKey(h.api, projections, "pi1", [{ userTaskKey: "pi1-t0" }]);
816
+ assert.equal(fed, 0);
817
+ assert.equal(projectedOpenTasks(h, "pi1"), 0);
818
+ await h.close();
819
+ });
820
+
821
+ test("retires a stale managed VIEW when a binding no longer declares a statusField", async () => {
822
+ // A manifest change that drops `statusField` leaves the binding with nothing derivable. An earlier
823
+ // boot's managed VIEW must be RETIRED, or its stale derived surface stays discoverable/readable while
824
+ // the runtime believes it provisioned nothing.
825
+ const { engine } = fakeEngine({});
826
+ const h = await withHarness(engine, PLANS_DDL, async () => {});
827
+ h.db.exec('CREATE VIEW main."plans__tracking" AS SELECT * FROM plans;'); // a previous boot's VIEW
828
+ const sched = fakeScheduler();
829
+ const handle = mount(h, [planBinding({ statusField: undefined, activeStatuses: undefined })], sched);
830
+ const views = h.db.all<{ name: string }>(
831
+ "SELECT name FROM sqlite_master WHERE type='view' AND name='plans__tracking'",
832
+ );
833
+ assert.equal(views.length, 0); // stale VIEW retired
834
+ await handle.stop();
835
+ await h.close();
836
+ });
837
+
838
+ test("skips provisioning when a non-view object already holds the managed VIEW name", async () => {
839
+ // A real table sharing the managed-view name shadows it: `DROP VIEW IF EXISTS` can't remove a table and
840
+ // `CREATE VIEW IF NOT EXISTS` then silently no-ops, so a page would read the wrong object while the mount
841
+ // looked successful. Provisioning must refuse (warn + skip) and leave the real table untouched.
842
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
843
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
844
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
845
+ });
846
+ h.db.exec('CREATE TABLE "plans__tracking" (x TEXT)'); // an undeclared real table squats the name
847
+ const sched = fakeScheduler();
848
+ const handle = mount(h, [planBinding()], sched);
849
+ await sched.advance(1000);
850
+ const obj = h.db.all<{ type: string }>("SELECT type FROM sqlite_master WHERE name='plans__tracking'");
851
+ assert.equal(obj.length, 1);
852
+ assert.equal(obj[0].type, "table"); // untouched — not dropped, not shadowed by a VIEW
853
+ assert.ok(h.logs.some((l) => l.level === "warn" && l.msg.includes("non-view object")));
854
+ await handle.stop();
855
+ await h.close();
856
+ });
857
+
858
+ test("detects a non-view shadow case-insensitively (SQLite folds object names)", async () => {
859
+ // SQLite resolves object names case-insensitively, so a real table `plans__tracking` shadows a
860
+ // configured view `PLANS__TRACKING` at DROP/CREATE time. A binary `=` name check would miss the
861
+ // shadow and let `CREATE VIEW IF NOT EXISTS` silently no-op against the existing table; the guard
862
+ // must fold case (COLLATE NOCASE) so it refuses (warn + skip) and leaves the real table untouched.
863
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
864
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
865
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
866
+ });
867
+ h.db.exec('CREATE TABLE "plans__tracking" (x TEXT)'); // lowercase real table squats the name
868
+ const sched = fakeScheduler();
869
+ const handle = mount(h, [planBinding({ readModel: { view: "PLANS__TRACKING" } })], sched); // cased override
870
+ await sched.advance(1000);
871
+ const obj = h.db.all<{ type: string }>("SELECT type FROM sqlite_master WHERE name='plans__tracking'");
872
+ assert.equal(obj.length, 1);
873
+ assert.equal(obj[0].type, "table"); // untouched — not dropped, not shadowed by a VIEW
874
+ assert.ok(h.logs.some((l) => l.level === "warn" && l.msg.includes("non-view object")));
875
+ await handle.stop();
876
+ await h.close();
877
+ });
878
+
879
+ test("mounts a binding with custom readModel.view + statusColumn and serves the override target", async () => {
880
+ // The public page path: a binding with BOTH overrides must provision the CUSTOM view/column (not the
881
+ // default `plans__tracking`/`derived_status`) and derive the effective status into that column.
882
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
883
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
884
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
885
+ });
886
+ const sched = fakeScheduler();
887
+ const handle = mount(h, [planBinding({ readModel: { view: "plans_board", statusColumn: "eff_status" } })], sched);
888
+ await sched.advance(1000);
889
+ // The custom view exists and its custom column carries the derived status…
890
+ const rows = h.db.all<{ eff_status: string }>("SELECT eff_status FROM plans_board WHERE process_key = ?", ["pi1"]);
891
+ assert.equal(rows[0]?.eff_status, "abandoned");
892
+ // …and the DEFAULT view name was NOT provisioned (the override target is honored, not both).
893
+ const dflt = h.db.all<{ n: number }>("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='view' AND name='plans__tracking'");
894
+ assert.equal(dflt[0]?.n, 0);
895
+ await handle.stop();
896
+ await h.close();
897
+ });
898
+
899
+ test("resolves the onTerminated patch key case-insensitively against statusField", async () => {
900
+ // SQL column identifiers fold case, but `set[statusField]` is a case-sensitive JS lookup. A binding
901
+ // with `statusField: "Status"` and `onTerminated.set: { status: "abandoned" }` must still emit the
902
+ // terminal edge (fold the key) — otherwise the derivation silently falls through to the base column.
903
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
904
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
905
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
906
+ });
907
+ const sched = fakeScheduler();
908
+ const handle = mount(h, [planBinding({ statusField: "Status", onTerminated: { set: { status: "abandoned" } } })], sched);
909
+ await sched.advance(1000);
910
+ assert.equal(derivedOne(h, "pi1"), "abandoned"); // terminal WHEN emitted despite the case mismatch
911
+ await handle.stop();
912
+ await h.close();
913
+ });
914
+
915
+ test("does not retire a default view claimed by another status-bearing binding on the same table", async () => {
916
+ // A status-bearing binding and a STATUS-LESS binding on the same table share the default
917
+ // `plans__tracking` name. The status-less binding's retire path must NOT drop the view the owner
918
+ // provisions (iteration-order dependent). Status-bearing first is the order that exposes the bug.
919
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
920
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
921
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
922
+ });
923
+ const sched = fakeScheduler();
924
+ const handle = mount(
925
+ h,
926
+ [planBinding(), planBinding({ statusField: undefined, activeStatuses: undefined })],
927
+ sched,
928
+ );
929
+ await sched.advance(1000);
930
+ const view = h.db.all<{ n: number }>("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='view' AND name='plans__tracking'");
931
+ assert.equal(view[0]?.n, 1); // survived — not retired by the status-less sibling
932
+ assert.equal(derivedOne(h, "pi1"), "abandoned");
933
+ await handle.stop();
934
+ await h.close();
935
+ });
936
+
937
+ test("memoizes the settled-state projection lookup — one settled getState per key per pass", async () => {
938
+ // pi1 is tracked AND probed waiting-on-human, so the pass-level `settled` predicate is consulted for it
939
+ // twice (the live-key filter and the humanKeySet build), each an un-memoized getState SQL round trip.
940
+ // The per-pass cache collapses those to one; `reconcileWaitingHumanKey`'s separate terminal-recheck
941
+ // getState is an intentional freshness read, so the memoized total for pi1 is exactly two (1 + 1).
942
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
943
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
944
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
945
+ });
946
+ // Count getState-shaped SELECTs against the instance-state projection. A function Proxy preserves the
947
+ // generic `all` signature (no cast) while its apply trap tallies the matching queries in place — the
948
+ // projection stores share this same db handle, so their getState calls route through the trap.
949
+ let getStateQueries = 0;
950
+ h.db.all = new Proxy(h.db.all, {
951
+ apply(target, _thisArg, args) {
952
+ const sql = args[0];
953
+ if (typeof sql === "string" && sql.includes(INSTANCE_STATE_TABLE) && sql.includes("WHERE process_instance_key = ?")) {
954
+ getStateQueries += 1;
955
+ }
956
+ return Reflect.apply(target, h.db, args);
957
+ },
958
+ });
959
+ const sched = fakeScheduler();
960
+ const handle = mount(h, [planBinding({ onWaitingHuman: { set: { status: "awaiting_operator" } } })], sched);
961
+ await sched.advance(1000);
962
+ assert.equal(getStateQueries, 2); // 1 memoized `settled` + 1 terminal-recheck (un-memoized: 3)
963
+ await handle.stop();
964
+ await h.close();
965
+ });
966
+
967
+ test("a failing openUserTasks probe does not starve other keys in later batches (issue #452 review)", async () => {
968
+ // `Promise.all` rejects the whole batch on one probe failure, and batches are awaited sequentially, so
969
+ // before the fix a permanently-failing key in the FIRST batch prevented every key in later batches from
970
+ // ever being probed/synced. With B+1 keys (B = probe concurrency) the failing key sits in batch 1 and
971
+ // one healthy key (`okLast`) sits in batch 2 — it must still get its open task synced, and the pass must
972
+ // NOT surface as a whole-pass "reconcile failed" error (only a per-key probe warn).
973
+ const C = WAITING_HUMAN_PROBE_CONCURRENCY;
974
+ const keys = ["fail", ...Array.from({ length: C }, (_u, i) => `ok${i}`)];
975
+ const okLast = `ok${C - 1}`; // the sole batch-2 key (keys = [fail, ok0..ok{C-1}] ⇒ batch2 = [ok{C-1}])
976
+ const states: Record<string, ProcessInstanceSnapshot["state"]> = {};
977
+ const openTasks: Record<string, number> = {};
978
+ for (const k of keys) {
979
+ states[k] = "ACTIVE"; // none terminal ⇒ all are probed for the wait-on-human edge
980
+ if (k !== "fail") openTasks[k] = 1; // every healthy key has one parked human task to sync
981
+ }
982
+ const { engine } = fakeEngine(states, openTasks, new Set(["fail"]));
983
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
984
+ // Seed "fail" first so it lands in batch 1 (humanKeySet preserves base-row/insertion order).
985
+ let idx = 0;
986
+ for (const k of keys) {
987
+ await t.insert({ plan_key: `p${idx++}`, process_key: k, status: "dispatched", note: null });
988
+ }
989
+ });
990
+ const sched = fakeScheduler();
991
+ const handle = mount(h, [planBinding({ onWaitingHuman: { set: { status: "awaiting_operator" } } })], sched);
992
+ await sched.advance(1000);
993
+ // The batch-2 healthy key was still probed and its projection synced despite the batch-1 failure.
994
+ assert.equal(projectedOpenTasks(h, okLast), 1);
995
+ // Isolation is per-key: a probe warn is logged, but the pass itself did not fail wholesale.
996
+ assert.ok(h.logs.some((l) => l.level === "warn" && /probe failed/.test(l.msg)));
997
+ assert.ok(!h.logs.some((l) => l.level === "error" && /reconcile failed/.test(l.msg)));
998
+ await handle.stop();
999
+ await h.close();
1000
+ });
1001
+
1002
+ test("retires a prior boot's managed VIEW when the projection feed becomes unavailable (issue #452 review)", async () => {
1003
+ // If projection-store provisioning fails on a later mount (after an earlier boot published the VIEW),
1004
+ // the whole provisioning pass used to be skipped, leaving the derived VIEW readable over an
1005
+ // unavailable/stale projection. It must instead be RETIRED — an honest missing surface, not a silent
1006
+ // stale one.
1007
+ const { engine } = fakeEngine({ pi1: "ACTIVE" });
1008
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
1009
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
1010
+ });
1011
+ const viewExists = (): number =>
1012
+ h.db.all<{ n: number }>(
1013
+ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='view' AND name='plans__tracking'",
1014
+ )[0]?.n ?? 0;
1015
+ const sched1 = fakeScheduler();
1016
+ const first = mount(h, [planBinding()], sched1);
1017
+ assert.equal(viewExists(), 1); // earlier boot published the managed VIEW
1018
+ await first.stop();
1019
+
1020
+ // Make the next mount's projection setup fail: throw only for the instance-state `ensureSchema` DDL so
1021
+ // `tryInstanceProjections` returns undefined, while the retire path's `DROP VIEW` still executes.
1022
+ h.db.exec = new Proxy(h.db.exec, {
1023
+ apply(target, _thisArg, args) {
1024
+ const sql = args[0];
1025
+ if (typeof sql === "string" && sql.includes(INSTANCE_STATE_TABLE)) {
1026
+ throw new Error("simulated projection schema provisioning failure");
1027
+ }
1028
+ return Reflect.apply(target, h.db, args);
1029
+ },
1030
+ });
1031
+ const sched2 = fakeScheduler();
1032
+ const second = mount(h, [planBinding()], sched2);
1033
+ assert.equal(viewExists(), 0); // retired — no longer serving a value over an unavailable projection
1034
+ await second.stop();
1035
+ await h.close();
1036
+ });
1037
+
1038
+ test("extracts candidate keys with case-insensitive keyField resolution (issue #452 review)", async () => {
1039
+ // SQLite folds identifier case, so a binding `keyField: "Process_Key"` over a `process_key` column
1040
+ // correlates in the derived VIEW. Candidate extraction must fold the SAME way (via the shared
1041
+ // `lookupColumn`): a case-sensitive `row["Process_Key"]` would read undefined and feed NO keys, so
1042
+ // neither terminal nor waiting-human status could derive despite a valid, provisioned VIEW.
1043
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
1044
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
1045
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
1046
+ });
1047
+ const sched = fakeScheduler();
1048
+ const handle = mount(h, [planBinding({ keyField: "Process_Key" })], sched);
1049
+ await sched.advance(1000);
1050
+ assert.equal(projectedState(h, "pi1"), "TERMINATED"); // the key was extracted and fed to the projection
1051
+ assert.equal(derivedOne(h, "pi1"), "abandoned"); // …so the derived status flipped
1052
+ await handle.stop();
1053
+ await h.close();
1054
+ });
1055
+
1056
+ test("skips provisioning a managed VIEW whose name collides with a registered read model (issue #452 review)", async () => {
1057
+ // Tracking VIEWs are provisioned OUTSIDE the `readModelRegistry`. If an app registers a model whose
1058
+ // name equals a configured `readModel.view`, an unconditional DROP+CREATE here would silently clobber
1059
+ // that registered derivation (the registry's own conflict detection never sees it). Refuse instead.
1060
+ const prior = readModelRegistry.all();
1061
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
1062
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
1063
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
1064
+ });
1065
+ try {
1066
+ readModelRegistry.register(
1067
+ defineReadModel({ name: "plans__tracking", baseTable: "plans", derive: { marker: lit("registered") } }),
1068
+ );
1069
+ const sched = fakeScheduler();
1070
+ const handle = mount(h, [planBinding()], sched);
1071
+ // The tracking mount must NOT have created its VIEW over the registered name…
1072
+ const trackingView = h.db.all<{ n: number }>(
1073
+ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='view' AND name='plans__tracking'",
1074
+ )[0]?.n ?? 0;
1075
+ assert.equal(trackingView, 0);
1076
+ // …and it must have said why.
1077
+ const warned = h.logs.some(
1078
+ (l) => l.level === "warn" && l.msg.includes("collides with a registered read model"),
1079
+ );
1080
+ assert.ok(warned, "expected a registry-collision warning");
1081
+ await handle.stop();
1082
+ } finally {
1083
+ readModelRegistry.clear();
1084
+ for (const m of prior) readModelRegistry.register(m);
1085
+ await h.close();
574
1086
  }
1087
+ });
1088
+
1089
+ test("reconcile is a silent no-op (no error log) when no default data source is configured (issue #452 review)", async () => {
1090
+ // A binding mounted with `mount.data: false` has no default source: provisioning already no-ops. The
1091
+ // timer still arms, so `reconcileOnce` must early-return instead of falling through to
1092
+ // `api.data.table(...)` (which THROWS with no default) and logging `reconcile failed` every interval.
1093
+ const { engine } = fakeEngine({});
1094
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
1095
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
1096
+ });
1097
+ h.api.data = new DataLayer(new Map(), undefined, {}); // no default source configured
1098
+ const sched = fakeScheduler();
1099
+ const handle = mount(h, [planBinding()], sched);
1100
+ await sched.advance(1000);
1101
+ await sched.advance(1000); // a per-interval throw would have logged on each tick
1102
+ const fails = h.logs.filter((l) => l.level === "error" && l.msg.includes("reconcile failed"));
1103
+ assert.equal(fails.length, 0);
575
1104
  await handle.stop();
576
1105
  await h.close();
577
1106
  });