@nanobpm/urban 0.77.4 → 0.79.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/modules/canonical-projections.d.ts +13 -0
- package/dist/runtime/core/modules/canonical-projections.js +30 -0
- package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
- package/dist/runtime/core/modules/instance-state-store.js +101 -0
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
- package/dist/runtime/core/modules/workers.js +61 -0
- package/dist/runtime/core/read-model.d.ts +267 -0
- package/dist/runtime/core/read-model.js +792 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +21 -1
- package/package.json +1 -1
- package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
- package/src/runtime/core/modules/canonical-projections.ts +38 -0
- package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
- package/src/runtime/core/modules/instance-state-store.ts +135 -0
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
- package/src/runtime/core/modules/workers.ts +61 -0
- package/src/runtime/core/read-model.test.ts +943 -0
- package/src/runtime/core/read-model.ts +1025 -0
- package/src/runtime/index.ts +94 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ProjectionRegistry } from "../read-model.ts";
|
|
2
|
+
/** The canonical projection name → physical `_urban_` table mappings this task owns. */
|
|
3
|
+
export declare const CANONICAL_PROJECTIONS: ReadonlyArray<{
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly sqlTable: string;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
|
|
9
|
+
* process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
|
|
10
|
+
* `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
|
|
11
|
+
* resolve them to the `_urban_`-prefixed physical tables. Idempotent.
|
|
12
|
+
*/
|
|
13
|
+
export declare function registerCanonicalProjections(registry?: ProjectionRegistry): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// canonical-projections — register the framework's canonical engine-truth projections under their
|
|
2
|
+
// stable DSL names (ADR 0065, proposal point #1). This is the concrete use of the projection-name
|
|
3
|
+
// seam the read-model primitive established (`projectionRegistry.register({ name, sqlTable })`): it
|
|
4
|
+
// maps each unprefixed DSL name a `defineReadModel(...)` `exists(...)` reads
|
|
5
|
+
// (`urban_open_user_tasks`, `urban_instance_state`) onto its `_urban_`-prefixed physical sidecar
|
|
6
|
+
// table, so the SQL backend's `EXISTS` compiles against the real table while the domain model / DB
|
|
7
|
+
// Manager never sees it.
|
|
8
|
+
//
|
|
9
|
+
// Registration is idempotent (the registry no-ops a matching re-register), so calling this on every
|
|
10
|
+
// worker mount — the boot path in `workers.ts` — is safe. It is decoupled from schema provisioning:
|
|
11
|
+
// a read model must be able to reference these projections by name even in a process/app that has no
|
|
12
|
+
// datasource to provision the physical tables into.
|
|
13
|
+
import { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_TABLE, } from "./instance-state-store.js";
|
|
14
|
+
import { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_TABLE, } from "./open-user-tasks-store.js";
|
|
15
|
+
import { projectionRegistry } from "../read-model.js";
|
|
16
|
+
/** The canonical projection name → physical `_urban_` table mappings this task owns. */
|
|
17
|
+
export const CANONICAL_PROJECTIONS = [
|
|
18
|
+
{ name: OPEN_USER_TASKS_PROJECTION, sqlTable: OPEN_USER_TASKS_TABLE },
|
|
19
|
+
{ name: INSTANCE_STATE_PROJECTION, sqlTable: INSTANCE_STATE_TABLE },
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
|
|
23
|
+
* process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
|
|
24
|
+
* `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
|
|
25
|
+
* resolve them to the `_urban_`-prefixed physical tables. Idempotent.
|
|
26
|
+
*/
|
|
27
|
+
export function registerCanonicalProjections(registry = projectionRegistry) {
|
|
28
|
+
for (const projection of CANONICAL_PROJECTIONS)
|
|
29
|
+
registry.register(projection);
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb } from "../host.ts";
|
|
2
|
+
import { type Clock } from "./lineage-store.ts";
|
|
3
|
+
/** The instance-state projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
|
|
4
|
+
* from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
|
|
5
|
+
export declare const INSTANCE_STATE_TABLE = "_urban_instance_state";
|
|
6
|
+
/** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
|
|
7
|
+
* `projectionRegistry`, mapped to {@link INSTANCE_STATE_TABLE}). The DSL name is unprefixed so a read
|
|
8
|
+
* model reads `EXISTS(urban_instance_state WHERE process_instance_key = base.process_instance_key)`. */
|
|
9
|
+
export declare const INSTANCE_STATE_PROJECTION = "urban_instance_state";
|
|
10
|
+
/**
|
|
11
|
+
* The canonical instance-state DDL — the single source of truth applied by
|
|
12
|
+
* {@link InstanceStateStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
13
|
+
* `db/migrations/007_urban_instance_state.sql` (a drift-guard test asserts they match).
|
|
14
|
+
*
|
|
15
|
+
* Forward-only and additive. Keyed by `process_instance_key` (one canonical state per instance) so a
|
|
16
|
+
* re-record upserts in place and is idempotent. `waiting_on_human` is 0/1 (SQLite has no boolean).
|
|
17
|
+
*/
|
|
18
|
+
export declare const INSTANCE_STATE_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS _urban_instance_state (\n process_instance_key TEXT NOT NULL,\n state TEXT NOT NULL,\n waiting_on_human INTEGER NOT NULL DEFAULT 0,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (process_instance_key)\n);";
|
|
19
|
+
export interface InstanceStateStoreOptions {
|
|
20
|
+
readonly clock?: Clock;
|
|
21
|
+
}
|
|
22
|
+
/** One projected instance-state row, as {@link InstanceStateStore.getState} returns it. */
|
|
23
|
+
export interface InstanceStateRow {
|
|
24
|
+
readonly processInstanceKey: string;
|
|
25
|
+
readonly state: ProcessInstanceState;
|
|
26
|
+
readonly waitingOnHuman: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The per-source sidecar behind the `urban_instance_state` projection. A thin, idempotent
|
|
30
|
+
* persistence + query layer over the engine's per-instance lifecycle truth.
|
|
31
|
+
*/
|
|
32
|
+
export declare class InstanceStateStore {
|
|
33
|
+
#private;
|
|
34
|
+
constructor(db: SqliteDb, options?: InstanceStateStoreOptions);
|
|
35
|
+
/** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
|
|
36
|
+
* `db/migrations/007_urban_instance_state.sql` do not need this; the runtime calls it so the store
|
|
37
|
+
* is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
38
|
+
ensureSchema(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Record an instance's canonical engine lifecycle state (and whether it is waiting on a human),
|
|
41
|
+
* upserting the single row keyed by `processInstanceKey`. Idempotent: re-recording the same
|
|
42
|
+
* `(state, waitingOnHuman)` is a no-op (returns `false`). A blank `processInstanceKey` is ignored.
|
|
43
|
+
*/
|
|
44
|
+
recordState(processInstanceKey: string, state: ProcessInstanceState, waitingOnHuman?: boolean): boolean;
|
|
45
|
+
/** Record from a {@link ProcessInstanceSnapshot} (the engine's `searchProcessInstances` row),
|
|
46
|
+
* carrying the optional waiting-on-human flag the reconciler probes separately. */
|
|
47
|
+
recordFromSnapshot(snapshot: ProcessInstanceSnapshot, waitingOnHuman?: boolean): boolean;
|
|
48
|
+
/** The projected canonical state for an instance, or `undefined` if none recorded. */
|
|
49
|
+
getState(processInstanceKey: string): InstanceStateRow | undefined;
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// instance-state-store — the canonical per-instance engine lifecycle-state projection (ADR 0065,
|
|
2
|
+
// proposal point #1). A framework-level, per-source sidecar (exactly like the lineage store) that
|
|
3
|
+
// records each tracked process instance's engine-truth lifecycle state (ACTIVE / COMPLETED /
|
|
4
|
+
// TERMINATED) plus whether it is waiting on a human, from the same `searchProcessInstances` /
|
|
5
|
+
// `openUserTasks` truth the `instanceTracking` reconciler already reads.
|
|
6
|
+
//
|
|
7
|
+
// A read model can then DERIVE an instance's status edge purely from this projection — e.g. the
|
|
8
|
+
// terminal edge (`onTerminated`, → `abandoned`) becomes an `EXISTS(urban_instance_state WHERE
|
|
9
|
+
// process_instance_key = … AND state = 'TERMINATED')` derivation, instead of a stored `status` write
|
|
10
|
+
// that tears from engine truth. This projection PROVIDES that engine-truth source; retiring the
|
|
11
|
+
// reconciler's writes is the downstream `writer-source-inversion` task.
|
|
12
|
+
//
|
|
13
|
+
// It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
|
|
14
|
+
// domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and NEVER
|
|
15
|
+
// app-written. Recording is idempotent: re-recording an unchanged state is a no-op.
|
|
16
|
+
import { systemClock } from "./lineage-store.js";
|
|
17
|
+
/** The instance-state projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
|
|
18
|
+
* from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
|
|
19
|
+
export const INSTANCE_STATE_TABLE = "_urban_instance_state";
|
|
20
|
+
/** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
|
|
21
|
+
* `projectionRegistry`, mapped to {@link INSTANCE_STATE_TABLE}). The DSL name is unprefixed so a read
|
|
22
|
+
* model reads `EXISTS(urban_instance_state WHERE process_instance_key = base.process_instance_key)`. */
|
|
23
|
+
export const INSTANCE_STATE_PROJECTION = "urban_instance_state";
|
|
24
|
+
/**
|
|
25
|
+
* The canonical instance-state DDL — the single source of truth applied by
|
|
26
|
+
* {@link InstanceStateStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
27
|
+
* `db/migrations/007_urban_instance_state.sql` (a drift-guard test asserts they match).
|
|
28
|
+
*
|
|
29
|
+
* Forward-only and additive. Keyed by `process_instance_key` (one canonical state per instance) so a
|
|
30
|
+
* re-record upserts in place and is idempotent. `waiting_on_human` is 0/1 (SQLite has no boolean).
|
|
31
|
+
*/
|
|
32
|
+
export const INSTANCE_STATE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${INSTANCE_STATE_TABLE} (
|
|
33
|
+
process_instance_key TEXT NOT NULL,
|
|
34
|
+
state TEXT NOT NULL,
|
|
35
|
+
waiting_on_human INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
updated_at TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (process_instance_key)
|
|
38
|
+
);`;
|
|
39
|
+
function normalizeState(raw) {
|
|
40
|
+
// Validate rather than coerce: silently mapping an unknown value to "ACTIVE" would let corrupted rows
|
|
41
|
+
// or an unhandled new engine state masquerade as ACTIVE and produce incorrect derived edges. Surface
|
|
42
|
+
// the bad data loudly instead.
|
|
43
|
+
if (raw === "ACTIVE" || raw === "COMPLETED" || raw === "TERMINATED")
|
|
44
|
+
return raw;
|
|
45
|
+
throw new Error(`${INSTANCE_STATE_TABLE} contains an unexpected state ${JSON.stringify(raw)} — refusing to coerce to ACTIVE (data corruption or an unhandled engine state)`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The per-source sidecar behind the `urban_instance_state` projection. A thin, idempotent
|
|
49
|
+
* persistence + query layer over the engine's per-instance lifecycle truth.
|
|
50
|
+
*/
|
|
51
|
+
export class InstanceStateStore {
|
|
52
|
+
#db;
|
|
53
|
+
#clock;
|
|
54
|
+
constructor(db, options = {}) {
|
|
55
|
+
this.#db = db;
|
|
56
|
+
this.#clock = options.clock ?? systemClock;
|
|
57
|
+
}
|
|
58
|
+
/** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
|
|
59
|
+
* `db/migrations/007_urban_instance_state.sql` do not need this; the runtime calls it so the store
|
|
60
|
+
* is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
61
|
+
ensureSchema() {
|
|
62
|
+
this.#db.exec(INSTANCE_STATE_SCHEMA_SQL);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Record an instance's canonical engine lifecycle state (and whether it is waiting on a human),
|
|
66
|
+
* upserting the single row keyed by `processInstanceKey`. Idempotent: re-recording the same
|
|
67
|
+
* `(state, waitingOnHuman)` is a no-op (returns `false`). A blank `processInstanceKey` is ignored.
|
|
68
|
+
*/
|
|
69
|
+
recordState(processInstanceKey, state, waitingOnHuman = false) {
|
|
70
|
+
if (!processInstanceKey)
|
|
71
|
+
return false;
|
|
72
|
+
const { changes } = this.#db.run(`INSERT INTO ${INSTANCE_STATE_TABLE}
|
|
73
|
+
(process_instance_key, state, waiting_on_human, updated_at)
|
|
74
|
+
VALUES (?, ?, ?, ?)
|
|
75
|
+
ON CONFLICT (process_instance_key) DO UPDATE SET
|
|
76
|
+
state = excluded.state,
|
|
77
|
+
waiting_on_human = excluded.waiting_on_human,
|
|
78
|
+
updated_at = excluded.updated_at
|
|
79
|
+
WHERE ${INSTANCE_STATE_TABLE}.state <> excluded.state
|
|
80
|
+
OR ${INSTANCE_STATE_TABLE}.waiting_on_human <> excluded.waiting_on_human`, [processInstanceKey, state, waitingOnHuman ? 1 : 0, new Date(this.#clock.now()).toISOString()]);
|
|
81
|
+
return changes > 0;
|
|
82
|
+
}
|
|
83
|
+
/** Record from a {@link ProcessInstanceSnapshot} (the engine's `searchProcessInstances` row),
|
|
84
|
+
* carrying the optional waiting-on-human flag the reconciler probes separately. */
|
|
85
|
+
recordFromSnapshot(snapshot, waitingOnHuman = false) {
|
|
86
|
+
return this.recordState(snapshot.processInstanceKey, snapshot.state, waitingOnHuman);
|
|
87
|
+
}
|
|
88
|
+
/** The projected canonical state for an instance, or `undefined` if none recorded. */
|
|
89
|
+
getState(processInstanceKey) {
|
|
90
|
+
const rows = this.#db.all(`SELECT process_instance_key, state, waiting_on_human
|
|
91
|
+
FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
92
|
+
const row = rows[0];
|
|
93
|
+
if (!row)
|
|
94
|
+
return undefined;
|
|
95
|
+
return {
|
|
96
|
+
processInstanceKey: row.process_instance_key,
|
|
97
|
+
state: normalizeState(row.state),
|
|
98
|
+
waitingOnHuman: row.waiting_on_human !== 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SqliteDb } from "../host.ts";
|
|
2
|
+
import type { UserTaskSummary } from "../host.ts";
|
|
3
|
+
import { type Clock } from "./lineage-store.ts";
|
|
4
|
+
/** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
|
|
5
|
+
* from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
|
|
6
|
+
export declare const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
|
|
7
|
+
/** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
|
|
8
|
+
* `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
|
|
9
|
+
* model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
|
|
10
|
+
export declare const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
|
|
11
|
+
/**
|
|
12
|
+
* The canonical open-user-tasks DDL — the single source of truth applied by
|
|
13
|
+
* {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
14
|
+
* `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
|
|
15
|
+
*
|
|
16
|
+
* Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
|
|
17
|
+
* `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
|
|
18
|
+
* process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
|
|
19
|
+
*/
|
|
20
|
+
export declare const OPEN_USER_TASKS_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS _urban_open_user_tasks (\n process_instance_key TEXT NOT NULL,\n user_task_key TEXT NOT NULL,\n element_id TEXT,\n created_at TEXT NOT NULL,\n PRIMARY KEY (user_task_key)\n);\nCREATE INDEX IF NOT EXISTS idx__urban_open_user_tasks_instance ON _urban_open_user_tasks (process_instance_key);";
|
|
21
|
+
export interface OpenUserTasksStoreOptions {
|
|
22
|
+
readonly clock?: Clock;
|
|
23
|
+
}
|
|
24
|
+
/** One projected open-user-task row, as {@link OpenUserTasksStore.openTasks} returns it. */
|
|
25
|
+
export interface OpenUserTaskRow {
|
|
26
|
+
readonly processInstanceKey: string;
|
|
27
|
+
readonly userTaskKey: string;
|
|
28
|
+
readonly elementId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
|
|
32
|
+
* persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
|
|
33
|
+
*/
|
|
34
|
+
export declare class OpenUserTasksStore {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(db: SqliteDb, options?: OpenUserTasksStoreOptions);
|
|
37
|
+
/** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
|
|
38
|
+
* `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
|
|
39
|
+
* is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
40
|
+
ensureSchema(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
|
|
43
|
+
* a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
|
|
44
|
+
* record an instance's WHOLE current open set (which also retires closed tasks); this single-row
|
|
45
|
+
* form is the idempotent building block.
|
|
46
|
+
*/
|
|
47
|
+
recordOpenTask(processInstanceKey: string, task: UserTaskSummary): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
|
|
50
|
+
* and delete any previously-projected task for the instance that is no longer open. This is the
|
|
51
|
+
* authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
|
|
52
|
+
* reconciler drives each poll — so an answered/closed task disappears and the derived
|
|
53
|
+
* "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
|
|
54
|
+
* open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
|
|
55
|
+
*/
|
|
56
|
+
syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void;
|
|
57
|
+
/** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
|
|
58
|
+
clearInstance(processInstanceKey: string): void;
|
|
59
|
+
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
60
|
+
hasOpenTask(processInstanceKey: string): boolean;
|
|
61
|
+
/** The projected open tasks for an instance, in insertion order. */
|
|
62
|
+
openTasks(processInstanceKey: string): OpenUserTaskRow[];
|
|
63
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// open-user-tasks-store — the canonical engine-truth projection of "which process instances are
|
|
2
|
+
// parked on a human" (ADR 0065, proposal point #1). A framework-level, per-source sidecar (exactly
|
|
3
|
+
// like the lineage store) that materialises the set of currently-open (CREATED-state) user tasks per
|
|
4
|
+
// process instance from engine truth — the same `openUserTasks` / `searchUserTasks({state:"CREATED"})`
|
|
5
|
+
// query the `instanceTracking` reconciler already runs.
|
|
6
|
+
//
|
|
7
|
+
// This ABSORBS nano-workforce's hand-rolled `user_tasks` table: it is the ONE authoritative projection
|
|
8
|
+
// a read model can `EXISTS`-over to derive the "waiting-on-human" status edge, instead of an app
|
|
9
|
+
// hand-maintaining a stored `awaiting_operator` column (the tearing surface of nano-workforce#422).
|
|
10
|
+
//
|
|
11
|
+
// It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
|
|
12
|
+
// domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and it is NEVER
|
|
13
|
+
// app-written. Recording is idempotent and authoritative-per-instance: {@link OpenUserTasksStore.syncInstance}
|
|
14
|
+
// replaces an instance's rows with exactly the engine's current open set, so an answered/closed task
|
|
15
|
+
// disappears on the next poll and the derived edge re-flips by construction (No Drift Surfaces).
|
|
16
|
+
import { systemClock } from "./lineage-store.js";
|
|
17
|
+
/** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
|
|
18
|
+
* from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
|
|
19
|
+
export const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
|
|
20
|
+
/** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
|
|
21
|
+
* `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
|
|
22
|
+
* model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
|
|
23
|
+
export const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
|
|
24
|
+
/**
|
|
25
|
+
* The canonical open-user-tasks DDL — the single source of truth applied by
|
|
26
|
+
* {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
27
|
+
* `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
|
|
28
|
+
*
|
|
29
|
+
* Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
|
|
30
|
+
* `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
|
|
31
|
+
* process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
|
|
32
|
+
*/
|
|
33
|
+
export const OPEN_USER_TASKS_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${OPEN_USER_TASKS_TABLE} (
|
|
34
|
+
process_instance_key TEXT NOT NULL,
|
|
35
|
+
user_task_key TEXT NOT NULL,
|
|
36
|
+
element_id TEXT,
|
|
37
|
+
created_at TEXT NOT NULL,
|
|
38
|
+
PRIMARY KEY (user_task_key)
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_${OPEN_USER_TASKS_TABLE}_instance ON ${OPEN_USER_TASKS_TABLE} (process_instance_key);`;
|
|
41
|
+
/**
|
|
42
|
+
* The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
|
|
43
|
+
* persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
|
|
44
|
+
*/
|
|
45
|
+
export class OpenUserTasksStore {
|
|
46
|
+
#db;
|
|
47
|
+
#clock;
|
|
48
|
+
constructor(db, options = {}) {
|
|
49
|
+
this.#db = db;
|
|
50
|
+
this.#clock = options.clock ?? systemClock;
|
|
51
|
+
}
|
|
52
|
+
/** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
|
|
53
|
+
* `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
|
|
54
|
+
* is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
55
|
+
ensureSchema() {
|
|
56
|
+
this.#db.exec(OPEN_USER_TASKS_SCHEMA_SQL);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
|
|
60
|
+
* a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
|
|
61
|
+
* record an instance's WHOLE current open set (which also retires closed tasks); this single-row
|
|
62
|
+
* form is the idempotent building block.
|
|
63
|
+
*/
|
|
64
|
+
recordOpenTask(processInstanceKey, task) {
|
|
65
|
+
if (!processInstanceKey || !task.userTaskKey)
|
|
66
|
+
return false;
|
|
67
|
+
const { changes } = this.#db.run(`INSERT OR IGNORE INTO ${OPEN_USER_TASKS_TABLE}
|
|
68
|
+
(process_instance_key, user_task_key, element_id, created_at)
|
|
69
|
+
VALUES (?, ?, ?, ?)`, [processInstanceKey, task.userTaskKey, task.elementId ?? null, new Date(this.#clock.now()).toISOString()]);
|
|
70
|
+
return changes > 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
|
|
74
|
+
* and delete any previously-projected task for the instance that is no longer open. This is the
|
|
75
|
+
* authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
|
|
76
|
+
* reconciler drives each poll — so an answered/closed task disappears and the derived
|
|
77
|
+
* "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
|
|
78
|
+
* open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
|
|
79
|
+
*/
|
|
80
|
+
syncInstance(processInstanceKey, openTasks) {
|
|
81
|
+
if (!processInstanceKey)
|
|
82
|
+
return;
|
|
83
|
+
// Wrap the insert+delete replace in a SAVEPOINT so the whole sync is atomic: no reader observes a
|
|
84
|
+
// transient mixed state mid-sync, and a mid-loop failure rolls back cleanly instead of leaving the
|
|
85
|
+
// instance with a torn open set. SAVEPOINT (not BEGIN) so this also composes when called inside an
|
|
86
|
+
// existing transaction, e.g. a reconciler poll that batches many instances.
|
|
87
|
+
this.#db.exec("SAVEPOINT urban_sync_open_tasks");
|
|
88
|
+
try {
|
|
89
|
+
const keep = new Set();
|
|
90
|
+
for (const task of openTasks) {
|
|
91
|
+
if (!task.userTaskKey)
|
|
92
|
+
continue;
|
|
93
|
+
keep.add(task.userTaskKey);
|
|
94
|
+
this.recordOpenTask(processInstanceKey, task);
|
|
95
|
+
}
|
|
96
|
+
const existing = this.#db.all(`SELECT user_task_key FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
97
|
+
for (const row of existing) {
|
|
98
|
+
if (!keep.has(row.user_task_key)) {
|
|
99
|
+
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE user_task_key = ?`, [row.user_task_key]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.#db.exec("RELEASE urban_sync_open_tasks");
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
this.#db.exec("ROLLBACK TO urban_sync_open_tasks");
|
|
106
|
+
this.#db.exec("RELEASE urban_sync_open_tasks");
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
|
|
111
|
+
clearInstance(processInstanceKey) {
|
|
112
|
+
if (!processInstanceKey)
|
|
113
|
+
return;
|
|
114
|
+
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
115
|
+
}
|
|
116
|
+
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
117
|
+
hasOpenTask(processInstanceKey) {
|
|
118
|
+
if (!processInstanceKey)
|
|
119
|
+
return false;
|
|
120
|
+
// Existence predicate: `SELECT 1 … LIMIT 1` short-circuits on the first matching row rather than
|
|
121
|
+
// counting every open task for the instance.
|
|
122
|
+
const rows = this.#db.all(`SELECT 1 AS one FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? LIMIT 1`, [processInstanceKey]);
|
|
123
|
+
return rows.length > 0;
|
|
124
|
+
}
|
|
125
|
+
/** The projected open tasks for an instance, in insertion order. */
|
|
126
|
+
openTasks(processInstanceKey) {
|
|
127
|
+
const rows = this.#db.all(`SELECT process_instance_key, user_task_key, element_id
|
|
128
|
+
FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? ORDER BY rowid`, [processInstanceKey]);
|
|
129
|
+
return rows.map((r) => ({
|
|
130
|
+
processInstanceKey: r.process_instance_key,
|
|
131
|
+
userTaskKey: r.user_task_key,
|
|
132
|
+
...(r.element_id ? { elementId: r.element_id } : {}),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -7,6 +7,10 @@ import { runLlmJob } from "./llm.js";
|
|
|
7
7
|
import { runInJobContext } from "../execContext.js";
|
|
8
8
|
import { readLineage } from "../lineage.js";
|
|
9
9
|
import { LineageStore } from "./lineage-store.js";
|
|
10
|
+
import { readModelRegistry } from "../read-model.js";
|
|
11
|
+
import { registerCanonicalProjections } from "./canonical-projections.js";
|
|
12
|
+
import { InstanceStateStore } from "./instance-state-store.js";
|
|
13
|
+
import { OpenUserTasksStore } from "./open-user-tasks-store.js";
|
|
10
14
|
import { defaultScheduler, schedulerClock } from "./scheduler.js";
|
|
11
15
|
/** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
|
|
12
16
|
* decision's JSON-string `output` into the value fed back to the LLM job. Validates the
|
|
@@ -95,6 +99,51 @@ function tryLineageStore(app, log) {
|
|
|
95
99
|
return undefined;
|
|
96
100
|
}
|
|
97
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Apply every managed read-model VIEW registered in the process-wide `readModelRegistry` to the app's
|
|
104
|
+
* DEFAULT data source (ADR 0065, surface #3). A `defineReadModel(...)` declaration compiles to a
|
|
105
|
+
* managed SQLite VIEW; provisioning it here — the same per-source boot path `tryLineageStore` uses —
|
|
106
|
+
* means an app (or a canonical-projection sidecar) that registers a read model gets its VIEW created
|
|
107
|
+
* for free, with no hand-written migration. The registry is empty until a sibling task registers into
|
|
108
|
+
* it, so this is a silent no-op in the base framework. Absent- and error-safe like lineage: an app
|
|
109
|
+
* with no datasource provisions nothing, and a VIEW-provisioning failure never breaks a worker mount.
|
|
110
|
+
*/
|
|
111
|
+
function tryProvisionReadModelViews(app, log) {
|
|
112
|
+
if (readModelRegistry.all().length === 0)
|
|
113
|
+
return;
|
|
114
|
+
if (!app.data.hasDefaultSource()) {
|
|
115
|
+
log("debug", "read-models: no default data source; managed VIEW provisioning skipped");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
readModelRegistry.ensureViews(app.data.source().db);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
log("warn", "read-models: failed to provision managed VIEWs", { error: String(err) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Provision the canonical engine-truth projection sidecars (ADR 0065, proposal point #1) on the app's
|
|
127
|
+
* DEFAULT data source — `_urban_open_user_tasks` and `_urban_instance_state`, the sources a read model
|
|
128
|
+
* `EXISTS`-derives its status edges from. Exactly the per-source boot path `tryLineageStore` uses: an
|
|
129
|
+
* app with no datasource provisions nothing, and a schema failure degrades to no projection tables
|
|
130
|
+
* rather than a failed worker mount. The DSL projection-name registration is done unconditionally by
|
|
131
|
+
* the caller (so read models compile even without a datasource); this only creates the physical tables.
|
|
132
|
+
*/
|
|
133
|
+
function tryProvisionCanonicalProjections(app, log) {
|
|
134
|
+
if (!app.data.hasDefaultSource()) {
|
|
135
|
+
log("debug", "canonical-projections: no default data source; projection tables not provisioned");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const { db } = app.data.source();
|
|
140
|
+
new OpenUserTasksStore(db).ensureSchema();
|
|
141
|
+
new InstanceStateStore(db).ensureSchema();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log("warn", "canonical-projections: failed to provision projection tables", { error: String(err) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
98
147
|
/** Record an activated job's lineage edge, never letting a projection write break the job. */
|
|
99
148
|
function recordJobLineage(store, job, log) {
|
|
100
149
|
try {
|
|
@@ -142,6 +191,18 @@ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
|
|
|
142
191
|
// edge its instance carries, so intent→progress lineage materialises for free. Absent when the
|
|
143
192
|
// app configures no default data source.
|
|
144
193
|
const lineageStore = tryLineageStore(app, ctx.host.log);
|
|
194
|
+
// The framework read-model VIEWs (ADR 0065): apply every registered managed VIEW to the app's
|
|
195
|
+
// default data source, the same per-source boot path lineage uses. A no-op until a sibling task
|
|
196
|
+
// registers a read model into `readModelRegistry`.
|
|
197
|
+
//
|
|
198
|
+
// Register the canonical engine-truth projections (`urban_open_user_tasks`, `urban_instance_state`)
|
|
199
|
+
// FIRST and unconditionally: a read model's managed VIEW resolves each projection name to its
|
|
200
|
+
// physical `_urban_` table at DDL-compile time, so the name→table mapping must exist before
|
|
201
|
+
// `tryProvisionReadModelViews` compiles any VIEW that `EXISTS`-references a projection. Then
|
|
202
|
+
// provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
|
|
203
|
+
registerCanonicalProjections();
|
|
204
|
+
tryProvisionCanonicalProjections(app, ctx.host.log);
|
|
205
|
+
tryProvisionReadModelViews(app, ctx.host.log);
|
|
145
206
|
// Cache module loads so a multi-type handler module is imported once.
|
|
146
207
|
const moduleCache = new Map();
|
|
147
208
|
const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
|