@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.
- package/dist/runtime/core/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +6 -4
- package/dist/runtime/core/read-model.d.ts +28 -0
- package/dist/runtime/core/read-model.js +44 -4
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.ts +6 -5
- package/src/runtime/core/read-model.test.ts +69 -0
- package/src/runtime/core/read-model.ts +43 -4
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- 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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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, "
|
|
171
|
-
assert.equal(row?.note,
|
|
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("
|
|
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
|
-
|
|
185
|
-
assert.equal(
|
|
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
|
|
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
|
-
|
|
199
|
-
assert.equal(
|
|
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
|
|
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
|
-
|
|
263
|
-
assert.equal((
|
|
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:
|
|
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
|
-
|
|
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((
|
|
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)
|
|
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 —
|
|
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((
|
|
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("
|
|
441
|
-
// No open user task and not terminated ⇒ the
|
|
442
|
-
// worker-owned transient status
|
|
443
|
-
// task completes" leg
|
|
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((
|
|
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
|
|
460
|
-
// next poll re-flips it to awaiting_operator by construction
|
|
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((
|
|
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((
|
|
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
|
|
479
|
-
// terminal
|
|
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
|
-
|
|
488
|
-
assert.equal(
|
|
489
|
-
assert.equal(
|
|
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
|
|
496
|
-
// A long-parked instance is re-polled every tick.
|
|
497
|
-
//
|
|
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((
|
|
507
|
-
const
|
|
508
|
-
assert.equal(
|
|
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((
|
|
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("
|
|
528
|
-
// `keyField` (process_key) is NOT unique here
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
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((
|
|
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
|
});
|