@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
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* the
|
|
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
|
|
37
|
-
* (
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
24
|
-
*
|
|
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
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
10
|
-
// `instanceTracking`; the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
18
|
-
// `
|
|
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
|
-
*
|
|
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.
|
|
30
|
-
*
|
|
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
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
const projections = tryInstanceProjections(api);
|
|
92
|
+
reconciled = reconcileTerminatedKey(api, projections, key);
|
|
71
93
|
}
|
|
72
94
|
catch (error) {
|
|
73
|
-
|
|
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 (
|
|
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
|
-
*
|
|
126
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 {
|
|
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
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
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;
|