@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
@@ -18,27 +18,60 @@ export type InstanceTracking = SchemaInstanceTracking & {
18
18
  * is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
19
19
  readonly terminalStatuses?: readonly string[];
20
20
  /**
21
- * The reconciliation applied to a row whose instance is **parked waiting on a human** — the
22
- * wait-state twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is
23
- * waiting on a human *iff* it has an open user task, so the runtime derives this edge from
24
- * engine truth (`openUserTasks`) rather than from a written column: on each poll, an active,
25
- * non-terminated row whose instance has any open user task has this patch applied (typically
26
- * `status = "awaiting_operator"`). Terminated wins over waiting-human (the {@link
27
- * SchemaInstanceTracking.onTerminated} edge is applied first and excludes the key from this
28
- * pass); a row with neither a terminated instance nor an open user task is left untouched, so
29
- * the worker-owned transient status (running / converging / merging) survives.
21
+ * The reconciliation for a row whose instance is **parked waiting on a human** — the wait-state
22
+ * twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is waiting on a
23
+ * human *iff* it has an open user task, so the runtime derives this edge from engine truth
24
+ * (`openUserTasks`); terminated wins over waiting-human, and a row with neither is left carrying
25
+ * its worker-owned transient status (running / converging / merging).
26
+ *
27
+ * Since ADR 0065 (the writer→source inversion, #439-L1 / #318) this is a DERIVED, not a stored,
28
+ * edge: the reconciler no longer writes `set` into the base row with `table.update`. Instead it
29
+ * feeds the canonical `urban_open_user_tasks` projection, and the read model derives the effective
30
+ * status from it via `defineReadModel` — so an answered escalation re-flips by construction and the
31
+ * stored-derived tearing of nano-workforce#422 cannot recur. `set[statusField]` now supplies the
32
+ * VALUE the derived wait-on-human edge resolves to (rather than a patch); any secondary columns in
33
+ * `set` are no longer written (a VIEW cannot reset a stored column). See {@link readModel}.
30
34
  *
31
35
  * Optional and, like `terminalStatuses`, bridged locally until the canonical schema
32
36
  * (`@nanobpm/nano-app-schema`) folds it in; drop this augmentation and re-export once the dep
33
37
  * bump lands (No Drift Surfaces).
34
38
  */
35
39
  readonly onWaitingHuman?: {
36
- /** Column → literal value patch written to a row whose instance has an open user task
37
- * (e.g. set the status to a "waiting on human" value). */
40
+ /** Column → literal patch. Its `statusField` entry supplies the VALUE the derived wait-on-human
41
+ * edge resolves to (an instance with an open user task ⇒ this status); no longer a stored write.
42
+ * Since the inversion the reconciler makes NO base-row write at all, so only the `statusField` entry
43
+ * is consumed — any other key is intentionally IGNORED (an app-owned column is the app worker's job,
44
+ * not the reconciler's; ADR 0065). */
38
45
  readonly set: {
39
46
  readonly [k: string]: string | number | boolean | null;
40
47
  };
41
48
  };
49
+ /**
50
+ * Optional overrides for the DERIVED read-model VIEW the runtime provisions per binding (ADR 0065,
51
+ * the writer→source inversion). The reconciler feeds engine truth into the canonical projections
52
+ * (`urban_instance_state`, `urban_open_user_tasks`) and the runtime provisions a managed SQLite VIEW
53
+ * over the binding's base table that re-exports `base.*` plus a derived status column — the terminal
54
+ * (`onTerminated`) and wait-on-human (`onWaitingHuman`) edges computed by `defineReadModel` over the
55
+ * projections, precedence-correct (terminated wins). The operator page reads this derived column; the
56
+ * base `statusField` column now holds only the worker-owned / business-outcome status. Both fields
57
+ * default sensibly (`view` → `<table>__tracking`, `statusColumn` → `derived_status`); override them to
58
+ * avoid a collision (e.g. more than one binding on one base table, or a base column already named
59
+ * `derived_status`). A binding with no `statusField` provisions no VIEW (nothing to derive), but the
60
+ * reconciler still feeds the projections.
61
+ *
62
+ * The VIEW is a first-class readable pages surface, not a separate wiring step: `SqliteGateway.schema()`
63
+ * introspects views (`type IN ('table','view')`) and `/app/data/<source>/<view>` serves `SELECT *`
64
+ * verbatim, so an operator page reads the derived status simply by pointing its page `data.table` at
65
+ * this managed VIEW (which re-exports `base.*`, so it is a drop-in for the base table) and its column at
66
+ * `statusColumn`. This is opt-in by design: the base table stays readable carrying the worker-owned
67
+ * status; the runtime does NOT silently rebind every page off the base table.
68
+ */
69
+ readonly readModel?: {
70
+ /** Managed VIEW name (must be a SQL identifier and differ from the base table). */
71
+ readonly view?: string;
72
+ /** Derived effective-status column name in that VIEW (must be a SQL identifier). */
73
+ readonly statusColumn?: string;
74
+ };
42
75
  };
43
76
  /**
44
77
  * A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
@@ -7,29 +7,34 @@ export interface CancelInstanceResult {
7
7
  /** The instance state as read back from the engine after the cancel attempt.
8
8
  * `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
9
9
  state: CancelInstanceState;
10
- /** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
10
+ /** 1 when the immediate reconcile recorded the terminal source into the canonical instance-state
11
+ * projection (so the derived terminal status edge reflects it at once), else 0. */
11
12
  reconciled: number;
12
13
  error?: string;
13
14
  }
14
15
  type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
15
16
  /**
16
17
  * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
17
- * reconcile any instanceTracking row bound to its key.
18
+ * record the terminal source for its key into the canonical instance-state projection.
18
19
  *
19
20
  * Outcome rules:
20
21
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
21
- * 204); trust it even if the read model still lags at ACTIVE.
22
+ * 204); trust it even if the read model still lags at ACTIVE or has already cleaned the instance
23
+ * up ("gone"), and record the terminal source for the key so the derived view flips at once.
22
24
  * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
23
- * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
24
- * row here (see below); a COMPLETED read intentionally reconciles nothing.
25
+ * → the cancel was effectively idempotent; treat as success. A TERMINATED read records a terminal
26
+ * source here; a COMPLETED read intentionally records nothing.
25
27
  * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
26
28
  * (absent from the read model, which after an engine restart can transiently lack a live
27
- * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
29
+ * instance) → an honest failure: report it and do NOT record anything (the instance may still be
28
30
  * running). A throw is not a committed cancel, and absence from the read model is not proof of
29
31
  * termination, so success is never inferred from it.
30
32
  *
31
- * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
32
- * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
33
+ * Record when the cancellation is CONFIRMED — an accepted (non-throwing) cancel of any non-COMPLETED
34
+ * readback (ACTIVE lag / gone / TERMINATED), or a throw that reads back TERMINATED. A COMPLETED
35
+ * instance's terminal outcome is owned by the app's own finalize logic, so it records nothing. When
36
+ * the app declares no `instanceTracking` bindings there is nothing to reconcile, so the feed is
37
+ * skipped.
33
38
  */
34
39
  export declare function cancelInstanceReconciling(api: CancelApi, bindings: InstanceTracking[], processInstanceKey: string): Promise<CancelInstanceResult>;
35
40
  export {};
@@ -6,36 +6,42 @@
6
6
  // 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
7
7
  // a transport error). Swallowing that and reporting success leaves an instance running in
8
8
  // the engine while the UI claims it stopped — the worst possible lie for a "cancel" button.
9
- // 2. Reconcile the tracked row immediately. Apps bind process instances to rows via
10
- // `instanceTracking`; the poll reconciler flips a row to its terminal status within a few
11
- // seconds of the instance terminating. When *we* are the one terminating it, we can apply
12
- // that same declared patch right away instead of waiting for the next poll tick.
9
+ // 2. Reconcile the tracked instance immediately. Apps bind process instances to rows via
10
+ // `instanceTracking`; since ADR 0065 the reconciler is a SOURCE, not a writer — its poll feeds
11
+ // engine truth into the canonical `urban_instance_state` projection and the terminal status edge
12
+ // is DERIVED from it (no stored column to tear; nano-workforce#422). When *we* are the one
13
+ // terminating the instance, we can record that same terminal source right away instead of waiting
14
+ // for the next poll tick, and the derived read model reflects it on the next read.
13
15
  //
14
16
  // This primitive keys success on the engine's response, then reconciles off the read model: a
15
17
  // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
18
  // lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
17
- // or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
18
- // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
19
+ // or absent from the read model) is an honest failure. It records the terminal source through the
20
+ // SAME `reconcileTerminatedKey` the poll reconciler uses, so cancel and poll can never drift.
19
21
  import { errorMessage } from "../guards.js";
20
- import { reconcileTerminatedKey } from "./instance-tracking.js";
22
+ import { reconcileTerminatedKey, tryInstanceProjections } from "./instance-tracking.js";
21
23
  /**
22
24
  * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
23
- * reconcile any instanceTracking row bound to its key.
25
+ * record the terminal source for its key into the canonical instance-state projection.
24
26
  *
25
27
  * Outcome rules:
26
28
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
27
- * 204); trust it even if the read model still lags at ACTIVE.
29
+ * 204); trust it even if the read model still lags at ACTIVE or has already cleaned the instance
30
+ * up ("gone"), and record the terminal source for the key so the derived view flips at once.
28
31
  * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
29
- * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
30
- * row here (see below); a COMPLETED read intentionally reconciles nothing.
32
+ * → the cancel was effectively idempotent; treat as success. A TERMINATED read records a terminal
33
+ * source here; a COMPLETED read intentionally records nothing.
31
34
  * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
32
35
  * (absent from the read model, which after an engine restart can transiently lack a live
33
- * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
36
+ * instance) → an honest failure: report it and do NOT record anything (the instance may still be
34
37
  * running). A throw is not a committed cancel, and absence from the read model is not proof of
35
38
  * termination, so success is never inferred from it.
36
39
  *
37
- * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
38
- * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
40
+ * Record when the cancellation is CONFIRMED — an accepted (non-throwing) cancel of any non-COMPLETED
41
+ * readback (ACTIVE lag / gone / TERMINATED), or a throw that reads back TERMINATED. A COMPLETED
42
+ * instance's terminal outcome is owned by the app's own finalize logic, so it records nothing. When
43
+ * the app declares no `instanceTracking` bindings there is nothing to reconcile, so the feed is
44
+ * skipped.
39
45
  */
40
46
  export async function cancelInstanceReconciling(api, bindings, processInstanceKey) {
41
47
  const key = String(processInstanceKey);
@@ -56,7 +62,7 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
56
62
  // before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
57
63
  // Trust neither as success: claiming a cancel that may not have happened (while the run keeps
58
64
  // executing) is the worst possible lie for a cancel button. Report ok:false so the caller
59
- // surfaces it and retries; never flip a tracked row to terminal while the instance may live.
65
+ // surfaces it and retries; never record a terminal source while the instance may live.
60
66
  api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
61
67
  processInstanceKey: key,
62
68
  state,
@@ -65,15 +71,28 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
65
71
  return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
66
72
  }
67
73
  let reconciled = 0;
68
- if (state === "TERMINATED") {
74
+ // Record the terminal source whenever the cancellation is CONFIRMED — not only on a positively
75
+ // TERMINATED readback. An accepted (non-throwing) `cancelInstance` is a committed 204: the instance
76
+ // WILL terminate, even if the read model still lags at ACTIVE or the engine has already cleaned the
77
+ // instance up ("gone"). A throw that nonetheless reads back TERMINATED is idempotently confirmed too.
78
+ // Excluded: COMPLETED (its terminal outcome is owned by the app's own finalize logic) and the honest-
79
+ // failure case (a throw with a non-terminal read), which already returned ok:false above. Without
80
+ // recording on the accepted-but-lagging read, a committed cancel returned ok:true / reconciled:0 and
81
+ // the derived view stayed on the stored worker status until a poll caught up — and NEVER, if the
82
+ // engine no longer exposes the instance for the poll to observe (issue: accepted-cancel/read-lag).
83
+ const cancelConfirmed = !threw || state === "TERMINATED";
84
+ if (cancelConfirmed && state !== "COMPLETED" && bindings.length > 0) {
85
+ // Record through the SAME `reconcileTerminatedKey` the poll path uses, over the app's own
86
+ // projection stores, so cancel and poll can never drift. It is internally absent-safe and never
87
+ // throws through — a projection-write failure logs a warn and returns 0 rather than turning a
88
+ // successful engine termination into a 500; the poll reconciler catches the projection up on its
89
+ // next tick. The extra try/catch is belt-and-suspenders against a store construction fault.
69
90
  try {
70
- reconciled = await reconcileTerminatedKey(api, bindings, key);
91
+ const projections = tryInstanceProjections(api);
92
+ reconciled = reconcileTerminatedKey(api, projections, key);
71
93
  }
72
94
  catch (error) {
73
- // The engine termination itself succeeded — a reconcile *write* failure (bad binding,
74
- // transient DB error) must not turn that into an unhandled 500. Log it and let the
75
- // instanceTracking poll reconciler catch the row up on its next tick.
76
- api.log("error", "cancel: instance terminated but row reconcile failed", {
95
+ api.log("error", "cancel: instance terminated but projection reconcile failed", {
77
96
  processInstanceKey: key,
78
97
  error: errorMessage(error),
79
98
  });
@@ -5,6 +5,7 @@
5
5
  import { makeGateway, } from "./gateway.js";
6
6
  import { currentJobContext } from "../execContext.js";
7
7
  import { isAbsolutePath } from "../../../toolkit/artifact.js";
8
+ import { SQL_IDENT, isReservedObjectName } from "../read-model.js";
8
9
  export function sqlitePathFromUrl(url) {
9
10
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
10
11
  return url.replace(/^(file|sqlite):(\/\/)?/, "");
@@ -74,7 +75,7 @@ export function ensureProvenanceTable(db) {
74
75
  * a throw here can never break the app insert. */
75
76
  export function makeProvenanceRecorder(host, db, source) {
76
77
  return (table, pk) => {
77
- if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
78
+ if (isReservedObjectName(table)) {
78
79
  return;
79
80
  }
80
81
  const ctx = currentJobContext();
@@ -121,9 +122,11 @@ export async function openSqliteSource(host, root, url) {
121
122
  db.exec("PRAGMA journal_mode=WAL");
122
123
  return db;
123
124
  }
124
- /** A safe unquoted SQL identifier. Table/column names are interpolated directly into SQL,
125
- * so reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
126
- const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
125
+ /** A safe unquoted SQL identifier check. Table/column names are interpolated directly into SQL, so
126
+ * reject anything that isn't a plain identifier to prevent invalid SQL / injection. The regex itself
127
+ * is the runtime-wide single source of truth in `read-model.ts` (No Drift Surfaces — a divergent copy
128
+ * could let table validation and read-model/manifest validation disagree); we reuse it here rather than
129
+ * re-declaring it, keeping this module's own error message. */
127
130
  function assertSqlIdent(kind, name) {
128
131
  if (!SQL_IDENT.test(name)) {
129
132
  throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
@@ -11,6 +11,7 @@
11
11
  // const orders = db.table<Order>("orders"); // a typed gateway over one table
12
12
  // await orders.insert({ id, status: "new" });
13
13
  // const o = await orders.get(id);
14
+ import { RESERVED_OBJECT_PREFIXES } from "../read-model.js";
14
15
  /** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
15
16
  export function quoteIdent(name) {
16
17
  return `"${name.replaceAll('"', '""')}"`;
@@ -158,10 +159,10 @@ class SqliteGateway {
158
159
  // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
159
160
  // `_nano_%`, e.g. the migrations ledger): neither is a user/domain object, so they must
160
161
  // never surface in the domain model, DB Manager, or forms. The same exclusions apply to
161
- // views.
162
- const tables = this.#db.all("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " +
163
- "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
164
- "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name");
162
+ // views. The prefix set is derived from RESERVED_OBJECT_PREFIXES (No Drift Surfaces) so this
163
+ // read-side filter cannot diverge from the write-side skip / manifest-validation rejection.
164
+ const reservedClause = RESERVED_OBJECT_PREFIXES.map((p) => `AND name NOT LIKE '${p.replace(/_/g, "\\_")}%' ESCAPE '\\'`).join(" ");
165
+ const tables = this.#db.all(`SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ${reservedClause} ORDER BY name`);
165
166
  const out = [];
166
167
  for (const t of tables) {
167
168
  // `PRAGMA table_info(<view>)` returns the view's columns (pk/notnull report 0, which is
@@ -0,0 +1,56 @@
1
+ import type { InstanceTracking } from "../manifest.ts";
2
+ import { type Expr, type ReadModel } from "../read-model.ts";
3
+ /** The engine-truth terminal lifecycle state the terminal edge keys on (mirrors
4
+ * `ProcessInstanceState`'s `TERMINATED`). Reconciled only on `TERMINATED` — never `COMPLETED`, whose
5
+ * terminal row-write the app's own finalize worker owns — exactly as the pre-inversion reconciler was. */
6
+ export declare const TERMINATED_STATE = "TERMINATED";
7
+ /** The default derived-status column name a binding's read-model VIEW exposes. Distinct from the base
8
+ * `statusField` on purpose: the managed VIEW re-exports `base.*` (so pages keep every base column),
9
+ * and SQLite lets the base column win over a same-named derived alias — so the EFFECTIVE (derived)
10
+ * status is surfaced under its own column, which the operator page reads instead of the stored one.
11
+ * A binding may override it via `readModel.statusColumn`. */
12
+ export declare const DEFAULT_DERIVED_STATUS_COLUMN = "derived_status";
13
+ /** The default managed-VIEW name for a binding: `<table>__tracking`. Distinct from the base table
14
+ * (SQLite forbids a VIEW and a table sharing a name). An app with more than one binding on the same
15
+ * base table must give each a distinct `readModel.view`. */
16
+ export declare function defaultInstanceTrackingViewName(table: string): string;
17
+ /** `EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField> AND state = 'TERMINATED')`
18
+ * — the terminal edge, derived over the canonical instance-state projection. Authored once; compiles
19
+ * to both the SQLite VIEW `EXISTS` and the TS `projections["urban_instance_state"].some(...)`. */
20
+ export declare function terminatedEdgeExpr(keyField: string): Expr;
21
+ /** `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)` — the wait-on-human
22
+ * edge, derived over the canonical open-user-tasks projection (an instance is parked on a human iff it
23
+ * has an open user task). Authored once; compiles to both backends. */
24
+ export declare function waitingHumanEdgeExpr(keyField: string): Expr;
25
+ /**
26
+ * The effective-status derivation for one binding, in the closed expression DSL:
27
+ *
28
+ * CASE
29
+ * WHEN <terminated> THEN <onTerminated.set[statusField]> -- terminal wins
30
+ * WHEN <waiting-on-human> AND NOT <terminated> THEN <onWaitingHuman.set[statusField]>
31
+ * ELSE base.<statusField> -- the worker-owned / business-outcome transient status
32
+ * END
33
+ *
34
+ * Precedence (terminated over waiting-human) is preserved: the terminal `WHEN` is emitted first, and
35
+ * the waiting `WHEN` is additionally guarded by `NOT <terminated>` so it is correct even when the two
36
+ * patches touch disjoint columns. When a binding's patch does not set the `statusField` (it reconciles
37
+ * only secondary columns), that edge contributes no `WHEN` and the derived status passes the stored
38
+ * value through — a derived read model never re-introduces a stored derivable write.
39
+ */
40
+ export declare function deriveInstanceStatusExpr(binding: InstanceTracking, statusField: string): Expr;
41
+ /** The managed-VIEW name + derived-status column a binding's read model uses (config overrides applied). */
42
+ export declare function instanceTrackingReadModelTarget(binding: InstanceTracking): {
43
+ readonly view: string;
44
+ readonly statusColumn: string;
45
+ };
46
+ /**
47
+ * Build the derived read model for one `instanceTracking` binding: a managed VIEW over the binding's
48
+ * base table that re-exports `base.*` plus one derived `statusColumn` computed by
49
+ * {@link deriveInstanceStatusExpr} over the canonical projections. Returns `undefined` when the binding
50
+ * declares no `statusField` (there is no single status column to derive — the projections still get fed,
51
+ * but nothing well-defined can be projected as "the effective status").
52
+ *
53
+ * The two backends (SQLite VIEW select-list + in-process TS function) fall out of the one AST, so they
54
+ * cannot drift; `assertReadModelParity` verifies it in tests.
55
+ */
56
+ export declare function defineInstanceTrackingReadModel(binding: InstanceTracking): ReadModel | undefined;
@@ -0,0 +1,127 @@
1
+ // instance-status-read-model — the DERIVED half of the `instanceTracking` writer→source inversion
2
+ // (ADR 0065, surface #1; #439-L1 / #318). The reconciler used to WRITE the derivable status edges
3
+ // (`onTerminated.set` / `onWaitingHuman.set` applied as `table.update` patches); that stored-derived
4
+ // value tore from its inputs (nano-workforce#422: an answered escalation still showed a stale ⚠).
5
+ //
6
+ // After the inversion, the reconciler is a SOURCE — it feeds engine truth into the canonical
7
+ // projections (`urban_instance_state`, `urban_open_user_tasks`, ADR 0065 #1) — and the two derivable
8
+ // status edges are DERIVED here, once, in the closed `defineReadModel` expression DSL:
9
+ //
10
+ // terminal edge: EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField>
11
+ // AND state = 'TERMINATED') -> onTerminated value
12
+ // wait-on-human edge: EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)
13
+ // -> onWaitingHuman value
14
+ //
15
+ // authored ONCE and compiled to BOTH the SQLite VIEW and the in-process TS function (surface #2 is
16
+ // closed by the two-backend compiler). Because a derived edge is recomputed on every read over the
17
+ // LIVE projections, a re-escalation after an answer re-flips by construction and #422 cannot recur:
18
+ // once `OpenUserTasksStore.syncInstance` retires the answered task, the `EXISTS` goes false on the
19
+ // next read — no stored column to leave stale.
20
+ import { and, caseWhen, col, defineReadModel, eq, exists, lit, not, pcol, when, } from "../read-model.js";
21
+ import { INSTANCE_STATE_PROJECTION } from "./instance-state-store.js";
22
+ import { OPEN_USER_TASKS_PROJECTION } from "./open-user-tasks-store.js";
23
+ /** The engine-truth terminal lifecycle state the terminal edge keys on (mirrors
24
+ * `ProcessInstanceState`'s `TERMINATED`). Reconciled only on `TERMINATED` — never `COMPLETED`, whose
25
+ * terminal row-write the app's own finalize worker owns — exactly as the pre-inversion reconciler was. */
26
+ export const TERMINATED_STATE = "TERMINATED";
27
+ /** The default derived-status column name a binding's read-model VIEW exposes. Distinct from the base
28
+ * `statusField` on purpose: the managed VIEW re-exports `base.*` (so pages keep every base column),
29
+ * and SQLite lets the base column win over a same-named derived alias — so the EFFECTIVE (derived)
30
+ * status is surfaced under its own column, which the operator page reads instead of the stored one.
31
+ * A binding may override it via `readModel.statusColumn`. */
32
+ export const DEFAULT_DERIVED_STATUS_COLUMN = "derived_status";
33
+ /** The default managed-VIEW name for a binding: `<table>__tracking`. Distinct from the base table
34
+ * (SQLite forbids a VIEW and a table sharing a name). An app with more than one binding on the same
35
+ * base table must give each a distinct `readModel.view`. */
36
+ export function defaultInstanceTrackingViewName(table) {
37
+ return `${table}__tracking`;
38
+ }
39
+ /** `EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField> AND state = 'TERMINATED')`
40
+ * — the terminal edge, derived over the canonical instance-state projection. Authored once; compiles
41
+ * to both the SQLite VIEW `EXISTS` and the TS `projections["urban_instance_state"].some(...)`. */
42
+ export function terminatedEdgeExpr(keyField) {
43
+ return exists(INSTANCE_STATE_PROJECTION, and(eq(pcol("process_instance_key"), col(keyField)), eq(pcol("state"), lit(TERMINATED_STATE))));
44
+ }
45
+ /** `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)` — the wait-on-human
46
+ * edge, derived over the canonical open-user-tasks projection (an instance is parked on a human iff it
47
+ * has an open user task). Authored once; compiles to both backends. */
48
+ export function waitingHumanEdgeExpr(keyField) {
49
+ return exists(OPEN_USER_TASKS_PROJECTION, eq(pcol("process_instance_key"), col(keyField)));
50
+ }
51
+ /**
52
+ * The effective-status derivation for one binding, in the closed expression DSL:
53
+ *
54
+ * CASE
55
+ * WHEN <terminated> THEN <onTerminated.set[statusField]> -- terminal wins
56
+ * WHEN <waiting-on-human> AND NOT <terminated> THEN <onWaitingHuman.set[statusField]>
57
+ * ELSE base.<statusField> -- the worker-owned / business-outcome transient status
58
+ * END
59
+ *
60
+ * Precedence (terminated over waiting-human) is preserved: the terminal `WHEN` is emitted first, and
61
+ * the waiting `WHEN` is additionally guarded by `NOT <terminated>` so it is correct even when the two
62
+ * patches touch disjoint columns. When a binding's patch does not set the `statusField` (it reconciles
63
+ * only secondary columns), that edge contributes no `WHEN` and the derived status passes the stored
64
+ * value through — a derived read model never re-introduces a stored derivable write.
65
+ */
66
+ export function deriveInstanceStatusExpr(binding, statusField) {
67
+ const terminated = terminatedEdgeExpr(binding.keyField);
68
+ const whens = [];
69
+ const terminalStatus = patchValueForColumn(binding.onTerminated?.set, statusField);
70
+ if (terminalStatus !== undefined) {
71
+ whens.push(when(terminated, lit(terminalStatus)));
72
+ }
73
+ const waitingStatus = patchValueForColumn(binding.onWaitingHuman?.set, statusField);
74
+ if (waitingStatus !== undefined) {
75
+ whens.push(when(and(waitingHumanEdgeExpr(binding.keyField), not(terminated)), lit(waitingStatus)));
76
+ }
77
+ // No edge touches the status column ⇒ the derived status is exactly the stored transient (no CASE).
78
+ return whens.length > 0 ? caseWhen(whens, col(statusField)) : col(statusField);
79
+ }
80
+ /** Read the patch value a `*.set` map assigns to the status COLUMN, matching the key case-insensitively.
81
+ * SQL column identifiers fold case (SQLite), but a JS `set[statusField]` lookup is case-sensitive, so a
82
+ * binding with `statusField: "Status"` and `onTerminated.set: { status: "abandoned" }` would emit no
83
+ * terminal `WHEN` and silently fall through to the base column. Fold the comparison so the derivation
84
+ * matches the column the equivalent SQL UPDATE would have addressed. */
85
+ function patchValueForColumn(set, statusField) {
86
+ if (set === null || typeof set !== "object")
87
+ return undefined;
88
+ const folded = statusField.toLowerCase();
89
+ for (const [k, v] of Object.entries(set)) {
90
+ if (k.toLowerCase() !== folded)
91
+ continue;
92
+ if (v === undefined)
93
+ return undefined;
94
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean")
95
+ return v;
96
+ return undefined;
97
+ }
98
+ return undefined;
99
+ }
100
+ /** The managed-VIEW name + derived-status column a binding's read model uses (config overrides applied). */
101
+ export function instanceTrackingReadModelTarget(binding) {
102
+ return {
103
+ view: binding.readModel?.view ?? defaultInstanceTrackingViewName(binding.table),
104
+ statusColumn: binding.readModel?.statusColumn ?? DEFAULT_DERIVED_STATUS_COLUMN,
105
+ };
106
+ }
107
+ /**
108
+ * Build the derived read model for one `instanceTracking` binding: a managed VIEW over the binding's
109
+ * base table that re-exports `base.*` plus one derived `statusColumn` computed by
110
+ * {@link deriveInstanceStatusExpr} over the canonical projections. Returns `undefined` when the binding
111
+ * declares no `statusField` (there is no single status column to derive — the projections still get fed,
112
+ * but nothing well-defined can be projected as "the effective status").
113
+ *
114
+ * The two backends (SQLite VIEW select-list + in-process TS function) fall out of the one AST, so they
115
+ * cannot drift; `assertReadModelParity` verifies it in tests.
116
+ */
117
+ export function defineInstanceTrackingReadModel(binding) {
118
+ const statusField = binding.statusField;
119
+ if (typeof statusField !== "string" || statusField.length === 0)
120
+ return undefined;
121
+ const { view, statusColumn } = instanceTrackingReadModelTarget(binding);
122
+ return defineReadModel({
123
+ name: view,
124
+ baseTable: binding.table,
125
+ derive: { [statusColumn]: deriveInstanceStatusExpr(binding, statusField) },
126
+ });
127
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
- import { type InstanceTracking } from "../manifest.ts";
2
+ import type { UserTaskSummary } from "../host.ts";
3
+ import { InstanceStateStore } from "./instance-state-store.ts";
4
+ import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
3
5
  import { type SchedulerDeps } from "./scheduler.ts";
4
6
  /** Default poll interval when a binding does not set `pollMs`. */
5
7
  export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
@@ -13,24 +15,50 @@ export interface InstanceTrackingHandle extends Mounted {
13
15
  /** `table.keyField` labels of the armed bindings, for `inspect()`. */
14
16
  readonly bindings: string[];
15
17
  }
16
- /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
17
- * Only the binding whose table owns that key has a matching row, so callers can fan this over
18
- * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
19
- * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
20
- * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
21
- * instance) route through here, so the two can never drift on the patch or the log. */
22
- export declare function reconcileTerminatedKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
23
- /** Apply a binding's `onWaitingHuman.set` patch to the single row keyed by `processInstanceKey`
24
- * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). The caller establishes
25
- * the precondition (the instance is non-terminated and has an open user task); this is the single
26
- * place that writes `onWaitingHuman.set` and logs the reconcile. Bindings without an
27
- * `onWaitingHuman` edge are no-ops, so callers can fan this over every binding. It skips the write
28
- * when the row already carries the patch (quiet-idempotent — a long-parked instance is re-polled
29
- * every tick and must not re-log). Returns rows changed. */
30
- export declare function reconcileWaitingHumanKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
31
18
  /**
32
- * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
33
- * the active rows, asks the engine which of their instances are terminated, and applies the
34
- * declared patch. `stop()` cancels every armed timer.
19
+ * The canonical engine-truth projections the reconciler feeds as a SOURCE (ADR 0065, the writer→source
20
+ * inversion): the instance-state projection (feeds the terminal edge) and the open-user-tasks projection
21
+ * (feeds the wait-on-human edge). Both are sidecars on the app's own default data source; the derived
22
+ * status read model `EXISTS`-derives its edges from them.
23
+ */
24
+ export interface InstanceProjections {
25
+ readonly instanceState: InstanceStateStore;
26
+ readonly openUserTasks: OpenUserTasksStore;
27
+ }
28
+ /**
29
+ * Construct the canonical projection stores over the app's default data source and ensure their schema
30
+ * (idempotent), exactly the per-source boot path `tryLineageStore` / `tryProvisionCanonicalProjections`
31
+ * use. Absent-safe: with no default source there is nothing to feed, so return `undefined` at `debug`
32
+ * (the expected sidecar-absent case). A genuine provisioning fault (missing named source, schema error)
33
+ * degrades to `undefined` at `warn` rather than breaking the mount — a projection feed must never break
34
+ * a job.
35
+ */
36
+ export declare function tryInstanceProjections(api: Pick<AppApi, "data" | "log">): InstanceProjections | undefined;
37
+ /** Record engine-truth that an instance is TERMINATED into the canonical `urban_instance_state`
38
+ * projection and retire its open-user-task rows — the writer→source replacement for the old
39
+ * `onTerminated.set` `table.update` patch (ADR 0065; #439-L1 / #318). The derived terminal status edge
40
+ * (a VIEW / function over the projection) reflects this on the next read, so there is no stored-derived
41
+ * column to tear from its inputs (nano-workforce#422). This is the SINGLE place that records the terminal
42
+ * source: both the poll reconciler (`reconcileOnce`, once per TERMINATED key it discovers) and the cancel
43
+ * primitive (`cancel.ts`, immediately when it terminates an instance) route through it, so the two can
44
+ * never drift. Absent-safe (no projections / blank key ⇒ 0) and never throws through — a projection write
45
+ * must never break a job or a cancel. Returns 1 when it recorded the terminal source, else 0. */
46
+ export declare function reconcileTerminatedKey(api: Pick<AppApi, "log">, projections: InstanceProjections | undefined, processInstanceKey: string): number;
47
+ /** Feed an instance's CURRENT open-user-task set into the canonical `urban_open_user_tasks` projection
48
+ * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). `syncInstance` is
49
+ * authoritative-per-instance: it inserts the still-open tasks and RETIRES any previously-projected task
50
+ * that is no longer open, so an answered/closed escalation makes the derived wait-on-human edge go false
51
+ * on the next read by construction (nano-workforce#422 — no stored column to leave stale). Passing an
52
+ * EMPTY `openTasks` clears the instance, which is exactly the answered-escalation transition; callers
53
+ * MUST therefore feed every probed non-terminated key, including those with no open task, or a retired
54
+ * task would linger and the edge would tear. Absent-safe (no projections / blank key ⇒ 0) and never
55
+ * throws through. Returns 1 when it fed the projection, else 0. */
56
+ export declare function reconcileWaitingHumanKey(api: Pick<AppApi, "log">, projections: InstanceProjections | undefined, processInstanceKey: string, openTasks: readonly UserTaskSummary[]): number;
57
+ /**
58
+ * Mount the `instanceTracking` reconciler: provision each binding's derived read-model VIEW, construct
59
+ * the canonical projection stores once, and arm one poll loop per binding. Each loop selects the active
60
+ * rows, asks the engine which of their instances are terminated / parked on a human, and FEEDS that
61
+ * engine truth into the canonical projections (never a base-row write — the status edges are DERIVED;
62
+ * ADR 0065). `stop()` cancels every armed timer.
35
63
  */
36
64
  export declare function mountInstanceTracking(ctx: RuntimeContext, api: AppApi, sched?: SchedulerDeps): InstanceTrackingHandle;