@nanobpm/urban 0.28.0 → 0.29.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/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +1 -0
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -16,6 +16,12 @@ import type { SqliteDb } from "../host.ts";
|
|
|
16
16
|
|
|
17
17
|
export type Row = Record<string, unknown>;
|
|
18
18
|
|
|
19
|
+
/** Best-effort observer invoked after a successful {@link Table.insert}, with the table name and
|
|
20
|
+
* the new primary-key value. The DataLayer uses it to capture write-provenance. It MUST NOT
|
|
21
|
+
* throw — an app insert must never fail because provenance capture did — but {@link Table.insert}
|
|
22
|
+
* also wraps it defensively. */
|
|
23
|
+
export type InsertObserver = (table: string, pk: number | bigint) => void;
|
|
24
|
+
|
|
19
25
|
export interface ExecResult {
|
|
20
26
|
/** Rows changed by an INSERT/UPDATE/DELETE. */
|
|
21
27
|
changed: number;
|
|
@@ -92,11 +98,13 @@ export class Table<T extends object = Row> {
|
|
|
92
98
|
readonly name: string;
|
|
93
99
|
readonly pk: string;
|
|
94
100
|
readonly #src: DataSource;
|
|
101
|
+
readonly #onInsert?: InsertObserver;
|
|
95
102
|
|
|
96
|
-
constructor(src: DataSource, name: string, pk = "id") {
|
|
103
|
+
constructor(src: DataSource, name: string, pk = "id", onInsert?: InsertObserver) {
|
|
97
104
|
this.#src = src;
|
|
98
105
|
this.name = name;
|
|
99
106
|
this.pk = pk;
|
|
107
|
+
this.#onInsert = onInsert;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
|
|
@@ -124,6 +132,16 @@ export class Table<T extends object = Row> {
|
|
|
124
132
|
// silently returning 0, which a caller could mistake for a real primary key.
|
|
125
133
|
throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
|
|
126
134
|
}
|
|
135
|
+
if (this.#onInsert) {
|
|
136
|
+
// Provenance capture is strictly observational: never let it break the app insert, which
|
|
137
|
+
// executed successfully above. If this insert runs inside a transaction, commit/rollback
|
|
138
|
+
// semantics still belong to that surrounding transaction.
|
|
139
|
+
try {
|
|
140
|
+
this.#onInsert(this.name, r.lastInsertId);
|
|
141
|
+
} catch {
|
|
142
|
+
// swallow — an insert must never fail because provenance capture did
|
|
143
|
+
}
|
|
144
|
+
}
|
|
127
145
|
return r.lastInsertId;
|
|
128
146
|
}
|
|
129
147
|
|
|
@@ -202,8 +220,10 @@ export class Table<T extends object = Row> {
|
|
|
202
220
|
* are async to hold the app-facing contract; the work underneath is synchronous. */
|
|
203
221
|
class SqliteGateway implements DataSource {
|
|
204
222
|
readonly #db: SqliteDb;
|
|
205
|
-
|
|
223
|
+
readonly #onInsert?: InsertObserver;
|
|
224
|
+
constructor(db: SqliteDb, onInsert?: InsertObserver) {
|
|
206
225
|
this.#db = db;
|
|
226
|
+
this.#onInsert = onInsert;
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
async query<T extends object = Row>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
@@ -272,11 +292,13 @@ class SqliteGateway implements DataSource {
|
|
|
272
292
|
}
|
|
273
293
|
|
|
274
294
|
table<T extends object = Row>(name: string, pk = "id"): Table<T> {
|
|
275
|
-
return new Table<T>(this, name, pk);
|
|
295
|
+
return new Table<T>(this, name, pk, this.#onInsert);
|
|
276
296
|
}
|
|
277
297
|
}
|
|
278
298
|
|
|
279
|
-
/** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway.
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
/** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. An optional
|
|
300
|
+
* `onInsert` observer is invoked after every successful `Table.insert` (used for
|
|
301
|
+
* write-provenance capture); it is threaded to each `Table` this gateway creates. */
|
|
302
|
+
export function makeGateway(db: SqliteDb, onInsert?: InsertObserver): DataSource {
|
|
303
|
+
return new SqliteGateway(db, onInsert);
|
|
282
304
|
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
7
|
+
import { makeGateway } from "./gateway.ts";
|
|
8
|
+
import type { Table } from "./gateway.ts";
|
|
9
|
+
import { DataLayer, type ProvisionedSource } from "./datasource.ts";
|
|
10
|
+
import type { AppApi } from "../context.ts";
|
|
11
|
+
import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
|
|
12
|
+
import type { InstanceTracking } from "../manifest.ts";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_INSTANCE_TRACKING_POLL_MS,
|
|
15
|
+
mountInstanceTracking,
|
|
16
|
+
} from "./instance-tracking.ts";
|
|
17
|
+
import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
18
|
+
|
|
19
|
+
// A real-timer flush drains the entire pending microtask chain (find → engine → update)
|
|
20
|
+
// each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
|
|
21
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
22
|
+
const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
|
|
23
|
+
|
|
24
|
+
// A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
|
|
25
|
+
// timer in order, draining microtasks between fires so the async reconcile completes.
|
|
26
|
+
function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
|
|
27
|
+
let clock = 0;
|
|
28
|
+
let seq = 0;
|
|
29
|
+
const timers = new Map<number, { at: number; fn: () => void }>();
|
|
30
|
+
return {
|
|
31
|
+
now: () => clock,
|
|
32
|
+
setTimer: (fn, delayMs) => {
|
|
33
|
+
const id = ++seq;
|
|
34
|
+
timers.set(id, { at: clock + delayMs, fn });
|
|
35
|
+
return id;
|
|
36
|
+
},
|
|
37
|
+
clearTimer: (h) => {
|
|
38
|
+
if (typeof h === "number") timers.delete(h);
|
|
39
|
+
},
|
|
40
|
+
pending: () => timers.size,
|
|
41
|
+
advance: async (ms) => {
|
|
42
|
+
const target = clock + ms;
|
|
43
|
+
// deno-lint-ignore no-constant-condition
|
|
44
|
+
while (true) {
|
|
45
|
+
let nextId = -1;
|
|
46
|
+
let nextAt = Infinity;
|
|
47
|
+
for (const [id, t] of timers) {
|
|
48
|
+
if (t.at <= target && t.at < nextAt) {
|
|
49
|
+
nextAt = t.at;
|
|
50
|
+
nextId = id;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (nextId < 0) break;
|
|
54
|
+
const t = timers.get(nextId)!;
|
|
55
|
+
timers.delete(nextId);
|
|
56
|
+
clock = t.at;
|
|
57
|
+
t.fn();
|
|
58
|
+
await flush();
|
|
59
|
+
}
|
|
60
|
+
clock = target;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An EngineClient whose only live method is searchProcessInstances, driven by a fixed map of
|
|
66
|
+
* processInstanceKey → state. Records the keys asked for on each call. */
|
|
67
|
+
function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
|
|
68
|
+
engine: EngineClient;
|
|
69
|
+
queries: string[][];
|
|
70
|
+
} {
|
|
71
|
+
const queries: string[][] = [];
|
|
72
|
+
const notUsed = (m: string) => (): never => {
|
|
73
|
+
throw new Error(`fakeEngine.${m} is not exercised by this test`);
|
|
74
|
+
};
|
|
75
|
+
// Typed as EngineClient (not `as unknown as`) so the compiler enforces the full contract:
|
|
76
|
+
// if EngineClient gains/changes a method, this fake fails to compile instead of drifting.
|
|
77
|
+
const engine: EngineClient = {
|
|
78
|
+
async searchProcessInstances(filter?: {
|
|
79
|
+
processInstanceKeys?: string[];
|
|
80
|
+
state?: ProcessInstanceSnapshot["state"];
|
|
81
|
+
}): Promise<ProcessInstanceSnapshot[]> {
|
|
82
|
+
const keys = filter?.processInstanceKeys ?? [];
|
|
83
|
+
queries.push([...keys]);
|
|
84
|
+
const out: ProcessInstanceSnapshot[] = [];
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
const state = states[key];
|
|
87
|
+
if (state && (!filter?.state || filter.state === state)) {
|
|
88
|
+
out.push({ processInstanceKey: key, state });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
},
|
|
93
|
+
deployResources: notUsed("deployResources"),
|
|
94
|
+
createInstance: notUsed("createInstance"),
|
|
95
|
+
cancelInstance: notUsed("cancelInstance"),
|
|
96
|
+
publishMessage: notUsed("publishMessage"),
|
|
97
|
+
searchUserTasks: notUsed("searchUserTasks"),
|
|
98
|
+
completeUserTask: notUsed("completeUserTask"),
|
|
99
|
+
registerWorker: notUsed("registerWorker"),
|
|
100
|
+
close: notUsed("close"),
|
|
101
|
+
};
|
|
102
|
+
return { engine, queries };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Harness {
|
|
106
|
+
api: AppApi;
|
|
107
|
+
table: Table<PlanRow>;
|
|
108
|
+
dir: string;
|
|
109
|
+
close: () => Promise<void>;
|
|
110
|
+
logs: { level: string; msg: string }[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The row shape every test seeds (matches PLANS_DDL); typing the harness table with it lets
|
|
114
|
+
// `table.get(...)` return a typed row instead of the generic `object` constraint that
|
|
115
|
+
// `ReturnType<DataLayer["table"]>` collapses to.
|
|
116
|
+
interface PlanRow {
|
|
117
|
+
plan_key: string;
|
|
118
|
+
process_key: string | null;
|
|
119
|
+
status: string;
|
|
120
|
+
note: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function withHarness(
|
|
124
|
+
engine: EngineClient,
|
|
125
|
+
ddl: string,
|
|
126
|
+
seed: (t: Table<PlanRow>) => Promise<void>,
|
|
127
|
+
keyField = "process_key",
|
|
128
|
+
table = "plans",
|
|
129
|
+
): Promise<Harness> {
|
|
130
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-instance-tracking-"));
|
|
131
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
132
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
133
|
+
db.exec(ddl);
|
|
134
|
+
const source: ProvisionedSource = {
|
|
135
|
+
name: "app",
|
|
136
|
+
driver: "sqlite",
|
|
137
|
+
db,
|
|
138
|
+
source: makeGateway(db),
|
|
139
|
+
migrationsApplied: [],
|
|
140
|
+
close: () => db.close(),
|
|
141
|
+
};
|
|
142
|
+
const data = new DataLayer(new Map([["app", source]]), "app", {});
|
|
143
|
+
const tbl = data.table<PlanRow>(table, keyField);
|
|
144
|
+
await seed(tbl);
|
|
145
|
+
const logs: { level: string; msg: string }[] = [];
|
|
146
|
+
const api: AppApi = {
|
|
147
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
148
|
+
data,
|
|
149
|
+
engine,
|
|
150
|
+
env: () => undefined,
|
|
151
|
+
log: (level, msg) => logs.push({ level, msg }),
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
api,
|
|
155
|
+
table: tbl,
|
|
156
|
+
dir,
|
|
157
|
+
logs,
|
|
158
|
+
close: async () => {
|
|
159
|
+
db.close();
|
|
160
|
+
await rm(dir, { recursive: true, force: true });
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const PLANS_DDL =
|
|
166
|
+
"CREATE TABLE plans (plan_key TEXT PRIMARY KEY, process_key TEXT, status TEXT NOT NULL, note TEXT)";
|
|
167
|
+
|
|
168
|
+
const planBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
|
|
169
|
+
table: "plans",
|
|
170
|
+
keyField: "process_key",
|
|
171
|
+
statusField: "status",
|
|
172
|
+
activeStatuses: ["planning", "dispatched"],
|
|
173
|
+
onTerminated: { set: { status: "abandoned", note: null } },
|
|
174
|
+
pollMs: 1000,
|
|
175
|
+
...over,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("reconciles an active row whose instance is TERMINATED", async () => {
|
|
179
|
+
const { engine } = fakeEngine({ pi1: "TERMINATED" });
|
|
180
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
181
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
|
|
182
|
+
});
|
|
183
|
+
const sched = fakeScheduler();
|
|
184
|
+
const handle = mount(h, [planBinding()], sched);
|
|
185
|
+
await sched.advance(1000);
|
|
186
|
+
const row = await h.table.get("pi1");
|
|
187
|
+
assert.equal(row?.status, "abandoned");
|
|
188
|
+
assert.equal(row?.note, null);
|
|
189
|
+
await handle.stop();
|
|
190
|
+
await h.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("leaves an active row whose instance is still ACTIVE untouched", async () => {
|
|
194
|
+
const { engine } = fakeEngine({ pi1: "ACTIVE" });
|
|
195
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
196
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
|
|
197
|
+
});
|
|
198
|
+
const sched = fakeScheduler();
|
|
199
|
+
const handle = mount(h, [planBinding()], sched);
|
|
200
|
+
await sched.advance(1000);
|
|
201
|
+
const row = await h.table.get("pi1");
|
|
202
|
+
assert.equal(row?.status, "dispatched");
|
|
203
|
+
await handle.stop();
|
|
204
|
+
await h.close();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("does NOT reconcile a COMPLETED instance (a finalize worker owns that row)", async () => {
|
|
208
|
+
const { engine, queries } = fakeEngine({ pi1: "COMPLETED" });
|
|
209
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
210
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
|
|
211
|
+
});
|
|
212
|
+
const sched = fakeScheduler();
|
|
213
|
+
const handle = mount(h, [planBinding()], sched);
|
|
214
|
+
await sched.advance(1000);
|
|
215
|
+
const row = await h.table.get("pi1");
|
|
216
|
+
assert.equal(row?.status, "dispatched"); // untouched
|
|
217
|
+
// and it did ask the engine, filtered to TERMINATED
|
|
218
|
+
assert.deepEqual(queries, [["pi1"]]);
|
|
219
|
+
await handle.stop();
|
|
220
|
+
await h.close();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("only polls rows in an active status (skips already-terminal rows)", async () => {
|
|
224
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
225
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
226
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
|
|
227
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "abandoned", note: null }); // terminal
|
|
228
|
+
});
|
|
229
|
+
const sched = fakeScheduler();
|
|
230
|
+
const handle = mount(h, [planBinding()], sched);
|
|
231
|
+
await sched.advance(1000);
|
|
232
|
+
assert.deepEqual(queries, [["pi1"]]); // pi2 was already terminal, never polled
|
|
233
|
+
await handle.stop();
|
|
234
|
+
await h.close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("skips rows whose key column is null/empty", async () => {
|
|
238
|
+
const { engine, queries } = fakeEngine({});
|
|
239
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
240
|
+
await t.insert({ plan_key: "p1", process_key: null, status: "dispatched", note: null });
|
|
241
|
+
await t.insert({ plan_key: "p2", process_key: "", status: "dispatched", note: null });
|
|
242
|
+
});
|
|
243
|
+
const sched = fakeScheduler();
|
|
244
|
+
const handle = mount(h, [planBinding()], sched);
|
|
245
|
+
await sched.advance(1000);
|
|
246
|
+
assert.equal(queries.length, 0); // no keys to poll → no engine call
|
|
247
|
+
await handle.stop();
|
|
248
|
+
await h.close();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("re-arms after each tick and stops cleanly", async () => {
|
|
252
|
+
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
253
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
254
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
|
|
255
|
+
});
|
|
256
|
+
const sched = fakeScheduler();
|
|
257
|
+
const handle = mount(h, [planBinding()], sched);
|
|
258
|
+
await sched.advance(1000);
|
|
259
|
+
await sched.advance(1000);
|
|
260
|
+
await sched.advance(1000);
|
|
261
|
+
assert.equal(queries.length, 3); // polled once per interval
|
|
262
|
+
assert.equal(sched.pending(), 1); // one timer armed for the next tick
|
|
263
|
+
await handle.stop();
|
|
264
|
+
assert.equal(sched.pending(), 0); // stop() cleared it
|
|
265
|
+
await h.close();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("without statusField/activeStatuses, polls every row", async () => {
|
|
269
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
270
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
271
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "abandoned", note: null });
|
|
272
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "dispatched", note: null });
|
|
273
|
+
});
|
|
274
|
+
const sched = fakeScheduler();
|
|
275
|
+
const binding = planBinding({ statusField: undefined, activeStatuses: undefined });
|
|
276
|
+
const handle = mount(h, [binding], sched);
|
|
277
|
+
await sched.advance(1000);
|
|
278
|
+
assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]);
|
|
279
|
+
// both got reconciled (already-abandoned p1 is a no-op change, p2 flips)
|
|
280
|
+
assert.equal((await h.table.get("pi2"))?.status, "abandoned");
|
|
281
|
+
await handle.stop();
|
|
282
|
+
await h.close();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("defaults pollMs when the binding omits it", async () => {
|
|
286
|
+
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
287
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
288
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
|
|
289
|
+
});
|
|
290
|
+
const sched = fakeScheduler();
|
|
291
|
+
const binding = planBinding({ pollMs: undefined });
|
|
292
|
+
const handle = mount(h, [binding], sched);
|
|
293
|
+
await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
|
|
294
|
+
assert.equal(queries.length, 0); // not yet
|
|
295
|
+
await sched.advance(1);
|
|
296
|
+
assert.equal(queries.length, 1); // fired at the default interval
|
|
297
|
+
await handle.stop();
|
|
298
|
+
await h.close();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("clamps a pollMs beyond setTimeout's 32-bit range instead of firing immediately", async () => {
|
|
302
|
+
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
303
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
304
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
|
|
305
|
+
});
|
|
306
|
+
const sched = fakeScheduler();
|
|
307
|
+
// A pollMs past the 32-bit signed range would overflow setTimeout and fire at ~0 (a hot loop).
|
|
308
|
+
const handle = mount(h, [planBinding({ pollMs: MAX_TIMER_DELAY_MS * 3 })], sched);
|
|
309
|
+
await sched.advance(1000);
|
|
310
|
+
assert.equal(queries.length, 0); // did NOT fire immediately …
|
|
311
|
+
await sched.advance(MAX_TIMER_DELAY_MS);
|
|
312
|
+
assert.equal(queries.length, 1); // … it fired at the clamped delay
|
|
313
|
+
await handle.stop();
|
|
314
|
+
await h.close();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// A binding mounted without validation (or with a hand-built binding) must not become a
|
|
318
|
+
// 0-delay hot loop when pollMs is non-positive/NaN — it falls back to the default interval.
|
|
319
|
+
for (const bad of [0, -1000, Number.NaN]) {
|
|
320
|
+
test(`falls back to the default interval for a non-positive/NaN pollMs (${bad})`, async () => {
|
|
321
|
+
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
322
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
323
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
|
|
324
|
+
});
|
|
325
|
+
const sched = fakeScheduler();
|
|
326
|
+
const handle = mount(h, [planBinding({ pollMs: bad })], sched);
|
|
327
|
+
await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
|
|
328
|
+
assert.equal(queries.length, 0); // did NOT hot-loop at ~0 …
|
|
329
|
+
await sched.advance(1);
|
|
330
|
+
assert.equal(queries.length, 1); // … it fired at the default interval instead
|
|
331
|
+
await handle.stop();
|
|
332
|
+
await h.close();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
function mount(h: Harness, bindings: InstanceTracking[], sched: SchedulerDeps) {
|
|
338
|
+
h.api.manifest.instanceTracking = bindings;
|
|
339
|
+
return mountInstanceTracking(
|
|
340
|
+
// biome-ignore lint/plugin: RuntimeContext test double — mountInstanceTracking reads only manifest/engine/root; host is unused here.
|
|
341
|
+
{ manifest: h.api.manifest, host: {} as never, engine: h.api.engine, root: "." },
|
|
342
|
+
h.api,
|
|
343
|
+
sched,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// instance-tracking — the process-instance lifecycle reconciler (manifest `instanceTracking`).
|
|
2
|
+
//
|
|
3
|
+
// The problem it closes: an Urban app derives a row's status from the workers that run
|
|
4
|
+
// along a process's *happy path*. When an instance instead reaches a terminal state with
|
|
5
|
+
// no completion job — an operator/`cancelInstance` termination, or a crash — no worker
|
|
6
|
+
// runs, so the read-model row stays "active" forever and any UI derived from it lies.
|
|
7
|
+
//
|
|
8
|
+
// Each `instanceTracking` binding names a datasource table whose rows track an instance by
|
|
9
|
+
// a key column. This module polls the engine for those rows' instances and, when one is
|
|
10
|
+
// `TERMINATED`, applies the binding's `onTerminated.set` patch to the row. It deliberately
|
|
11
|
+
// reconciles only on `TERMINATED` — the state that by definition runs no completion worker
|
|
12
|
+
// — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
|
|
13
|
+
// (reconciling it here would race that worker and could clobber a legitimately-completed
|
|
14
|
+
// row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
|
|
15
|
+
|
|
16
|
+
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
17
|
+
import type { InstanceTracking } from "../manifest.ts";
|
|
18
|
+
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
19
|
+
|
|
20
|
+
/** Default poll interval when a binding does not set `pollMs`. */
|
|
21
|
+
export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
export interface InstanceTrackingHandle extends Mounted {
|
|
24
|
+
/** `table.keyField` labels of the armed bindings, for `inspect()`. */
|
|
25
|
+
readonly bindings: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Row = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
/** Poll one binding's instances once. Selects the active rows, asks the engine which of their
|
|
31
|
+
* instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
|
|
32
|
+
* scanned and how many rows were reconciled this tick. */
|
|
33
|
+
async function reconcileOnce(
|
|
34
|
+
api: AppApi,
|
|
35
|
+
binding: InstanceTracking,
|
|
36
|
+
): Promise<{ scanned: number; reconciled: number }> {
|
|
37
|
+
const table = api.data.table<Row>(binding.table, binding.keyField);
|
|
38
|
+
|
|
39
|
+
// Select the rows worth polling. With a statusField + activeStatuses, only rows in an
|
|
40
|
+
// active status are candidates, so a row already in a terminal status is skipped; without
|
|
41
|
+
// them every row is polled and a terminal instance's row is idempotently re-patched (the
|
|
42
|
+
// engine query returns only TERMINATED instances and the patch is a no-op once applied).
|
|
43
|
+
let candidates: Row[];
|
|
44
|
+
const statusField = binding.statusField;
|
|
45
|
+
if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
|
|
46
|
+
const perStatus = await Promise.all(
|
|
47
|
+
binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
|
|
48
|
+
);
|
|
49
|
+
candidates = perStatus.flat();
|
|
50
|
+
} else {
|
|
51
|
+
candidates = await table.all();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Map each active row to its tracked instance key. A row with no/empty key can't be
|
|
55
|
+
// reconciled (it never started an instance, or the column is unset) — skip it.
|
|
56
|
+
const keyToRows = new Map<string, Row[]>();
|
|
57
|
+
for (const row of candidates) {
|
|
58
|
+
const raw = row[binding.keyField];
|
|
59
|
+
if (raw == null || raw === "") continue;
|
|
60
|
+
const key = String(raw);
|
|
61
|
+
const list = keyToRows.get(key);
|
|
62
|
+
if (list) list.push(row);
|
|
63
|
+
else keyToRows.set(key, [row]);
|
|
64
|
+
}
|
|
65
|
+
const keys = [...keyToRows.keys()];
|
|
66
|
+
if (keys.length === 0) return { scanned: 0, reconciled: 0 };
|
|
67
|
+
|
|
68
|
+
const snapshots = await api.engine.searchProcessInstances({
|
|
69
|
+
processInstanceKeys: keys,
|
|
70
|
+
state: "TERMINATED",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const patch: Partial<Row> = binding.onTerminated.set;
|
|
74
|
+
let reconciled = 0;
|
|
75
|
+
for (const snap of snapshots) {
|
|
76
|
+
if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
|
|
77
|
+
if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
|
|
78
|
+
const changed = await table.update(snap.processInstanceKey, patch);
|
|
79
|
+
if (changed > 0) {
|
|
80
|
+
reconciled += changed;
|
|
81
|
+
api.log("info", "instanceTracking: reconciled terminated instance", {
|
|
82
|
+
table: binding.table,
|
|
83
|
+
keyField: binding.keyField,
|
|
84
|
+
processInstanceKey: snap.processInstanceKey,
|
|
85
|
+
rowsChanged: changed,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { scanned: keys.length, reconciled };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
|
|
94
|
+
* the active rows, asks the engine which of their instances are terminated, and applies the
|
|
95
|
+
* declared patch. `stop()` cancels every armed timer.
|
|
96
|
+
*/
|
|
97
|
+
export function mountInstanceTracking(
|
|
98
|
+
ctx: RuntimeContext,
|
|
99
|
+
api: AppApi,
|
|
100
|
+
sched: SchedulerDeps = defaultScheduler(),
|
|
101
|
+
): InstanceTrackingHandle {
|
|
102
|
+
const bindings = ctx.manifest.instanceTracking ?? [];
|
|
103
|
+
const timers = new Set<unknown>();
|
|
104
|
+
const armed: string[] = [];
|
|
105
|
+
let stopped = false;
|
|
106
|
+
|
|
107
|
+
for (const binding of bindings) {
|
|
108
|
+
// `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
|
|
109
|
+
// clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
|
|
110
|
+
// number and otherwise fall back to the default. (Manifest validation rejects bad values up
|
|
111
|
+
// front; this is belt-and-suspenders for a binding mounted without validation.)
|
|
112
|
+
const rawPoll = binding.pollMs;
|
|
113
|
+
const pollMs = typeof rawPoll === "number" && Number.isFinite(rawPoll) && rawPoll > 0
|
|
114
|
+
? rawPoll
|
|
115
|
+
: DEFAULT_INSTANCE_TRACKING_POLL_MS;
|
|
116
|
+
// A single setTimeout delay overflows its 32-bit signed range (~24.8 days) and fires
|
|
117
|
+
// immediately — which for a self-rescheduling poll would become a hot loop. Clamp through
|
|
118
|
+
// the one shared constant the cron trigger loop uses (derive-don't-duplicate).
|
|
119
|
+
const delayMs = Math.min(pollMs, MAX_TIMER_DELAY_MS);
|
|
120
|
+
const label = `${binding.table}.${binding.keyField}`;
|
|
121
|
+
armed.push(label);
|
|
122
|
+
|
|
123
|
+
const arm = (): void => {
|
|
124
|
+
if (stopped) return;
|
|
125
|
+
let handle: unknown;
|
|
126
|
+
handle = sched.setTimer(() => {
|
|
127
|
+
timers.delete(handle);
|
|
128
|
+
if (stopped) return;
|
|
129
|
+
void (async () => {
|
|
130
|
+
try {
|
|
131
|
+
await reconcileOnce(api, binding);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
api.log("error", `instanceTracking "${label}": reconcile failed`, {
|
|
134
|
+
error: String(err),
|
|
135
|
+
});
|
|
136
|
+
} finally {
|
|
137
|
+
arm(); // reschedule regardless of outcome (at-least-once, idempotent)
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
}, delayMs);
|
|
141
|
+
timers.add(handle);
|
|
142
|
+
};
|
|
143
|
+
arm();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
name: "instanceTracking",
|
|
148
|
+
bindings: armed,
|
|
149
|
+
async stop(): Promise<void> {
|
|
150
|
+
stopped = true;
|
|
151
|
+
for (const h of timers) sched.clearTimer(h);
|
|
152
|
+
timers.clear();
|
|
153
|
+
},
|
|
154
|
+
describe(): Record<string, unknown> {
|
|
155
|
+
return { bindings: armed };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -41,6 +41,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
|
41
41
|
},
|
|
42
42
|
async searchUserTasks() { return []; },
|
|
43
43
|
async completeUserTask() {},
|
|
44
|
+
async searchProcessInstances() { return []; },
|
|
44
45
|
async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
|
|
45
46
|
async close() {},
|
|
46
47
|
};
|