@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
|
@@ -2,37 +2,45 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The problem it closes: an Urban app derives a row's status from the workers that run
|
|
4
4
|
// along a process's *happy path*. When an instance instead reaches an engine-truth state with
|
|
5
|
-
// no worker to record it, the read-model row lies. Two such edges are
|
|
5
|
+
// no worker to record it, the read-model row lies. Two such edges are derived from engine truth:
|
|
6
6
|
//
|
|
7
7
|
// - The **terminal** edge (`onTerminated`): each binding's poll asks the engine which of its
|
|
8
|
-
// active instances are `TERMINATED
|
|
9
|
-
//
|
|
8
|
+
// active instances are `TERMINATED`. A terminated instance runs no completion worker, so nothing
|
|
9
|
+
// else would ever close the row.
|
|
10
10
|
//
|
|
11
11
|
// - The **wait-on-human** edge (`onWaitingHuman`, issue #355): the symmetric primitive for
|
|
12
12
|
// "this instance is parked waiting on a human." That state is authoritatively knowable — an
|
|
13
13
|
// instance is waiting on a human *iff* it has an open user task (`openUserTasks` =
|
|
14
|
-
// `searchUserTasks({state:"CREATED"})`) — for every process, forever, with no per-element
|
|
15
|
-
// registry. On each poll, an active, non-terminated row whose instance has any open user task
|
|
16
|
-
// has `onWaitingHuman.set` applied (typically `status = "awaiting_operator"`). This retires the
|
|
17
|
-
// per-subject imperative pattern (workers writing the status on escalate/answer plus a bespoke
|
|
18
|
-
// self-heal poller): the parked status becomes a derived view over the one user-task
|
|
19
|
-
// projection, so there is no second surface to drift (No Drift Surfaces).
|
|
14
|
+
// `searchUserTasks({state:"CREATED"})`) — for every process, forever, with no per-element registry.
|
|
20
15
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
16
|
+
// SOURCE, NOT WRITER (ADR 0065, surface #1; #439-L1 / #318). This reconciler used to *write* those
|
|
17
|
+
// derived edges — `reconcileTerminatedKey` / `reconcileWaitingHumanKey` applied `onTerminated.set` /
|
|
18
|
+
// `onWaitingHuman.set` as `table.update` patches on the app's base row. A stored derived value tears
|
|
19
|
+
// from its inputs (nano-workforce#422: an answered escalation still showed a stale ⚠). The inversion
|
|
20
|
+
// makes the reconciler a SOURCE: each poll FEEDS engine truth into the canonical projections
|
|
21
|
+
// (`urban_instance_state` gets the `TERMINATED` fact; `urban_open_user_tasks` gets the instance's
|
|
22
|
+
// current open-task set), and the two edges are DERIVED — recomputed on every read — by a
|
|
23
|
+
// `defineReadModel` VIEW over those projections (see `instance-status-read-model.ts`). Because the
|
|
24
|
+
// wait-on-human edge is an `EXISTS` over the live open-task projection, retiring an answered task via
|
|
25
|
+
// `OpenUserTasksStore.syncInstance` makes the edge go false on the next read by construction, so the
|
|
26
|
+
// #422 staleness cannot recur.
|
|
25
27
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// are deterministic.
|
|
28
|
+
// Precedence (terminated wins over waiting-human) is preserved in the derivation, not in a write
|
|
29
|
+
// order. It deliberately records the terminal source only on `TERMINATED` — the state that by
|
|
30
|
+
// definition runs no completion worker — and never on `COMPLETED`, whose terminal row-write the app's
|
|
31
|
+
// own finalize worker owns. The wait-on-human edge reads *open* user tasks via `openUserTasks` (never
|
|
32
|
+
// a bare `searchUserTasks`, which lags a completion). Real business-outcome writes owned by app workers
|
|
33
|
+
// (e.g. a finalize worker's terminal outcome) are untouched — only the derivable edges are inverted.
|
|
34
|
+
// The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
|
|
33
35
|
|
|
34
36
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
37
|
+
import type { UserTaskSummary } from "../host.ts";
|
|
35
38
|
import { type InstanceTracking, isConfiguredStatusSelector } from "../manifest.ts";
|
|
39
|
+
import { assertSqlIdentifier, lookupColumn, readModelRegistry } from "../read-model.ts";
|
|
40
|
+
import { registerCanonicalProjections } from "./canonical-projections.ts";
|
|
41
|
+
import { defineInstanceTrackingReadModel, instanceTrackingReadModelTarget, TERMINATED_STATE } from "./instance-status-read-model.ts";
|
|
42
|
+
import { INSTANCE_STATE_PROJECTION, InstanceStateStore } from "./instance-state-store.ts";
|
|
43
|
+
import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
|
|
36
44
|
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
37
45
|
|
|
38
46
|
/** Default poll interval when a binding does not set `pollMs`. */
|
|
@@ -52,94 +60,143 @@ export interface InstanceTrackingHandle extends Mounted {
|
|
|
52
60
|
|
|
53
61
|
type Row = Record<string, unknown>;
|
|
54
62
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The canonical engine-truth projections the reconciler feeds as a SOURCE (ADR 0065, the writer→source
|
|
65
|
+
* inversion): the instance-state projection (feeds the terminal edge) and the open-user-tasks projection
|
|
66
|
+
* (feeds the wait-on-human edge). Both are sidecars on the app's own default data source; the derived
|
|
67
|
+
* status read model `EXISTS`-derives its edges from them.
|
|
68
|
+
*/
|
|
69
|
+
export interface InstanceProjections {
|
|
70
|
+
readonly instanceState: InstanceStateStore;
|
|
71
|
+
readonly openUserTasks: OpenUserTasksStore;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Construct the canonical projection stores over the app's default data source and ensure their schema
|
|
76
|
+
* (idempotent), exactly the per-source boot path `tryLineageStore` / `tryProvisionCanonicalProjections`
|
|
77
|
+
* use. Absent-safe: with no default source there is nothing to feed, so return `undefined` at `debug`
|
|
78
|
+
* (the expected sidecar-absent case). A genuine provisioning fault (missing named source, schema error)
|
|
79
|
+
* degrades to `undefined` at `warn` rather than breaking the mount — a projection feed must never break
|
|
80
|
+
* a job.
|
|
81
|
+
*/
|
|
82
|
+
export function tryInstanceProjections(api: Pick<AppApi, "data" | "log">): InstanceProjections | undefined {
|
|
83
|
+
if (!api.data.hasDefaultSource()) {
|
|
84
|
+
api.log("debug", "instanceTracking: no default data source; projection feed disabled");
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const { db } = api.data.source();
|
|
89
|
+
const instanceState = new InstanceStateStore(db);
|
|
90
|
+
const openUserTasks = new OpenUserTasksStore(db);
|
|
91
|
+
instanceState.ensureSchema();
|
|
92
|
+
openUserTasks.ensureSchema();
|
|
93
|
+
return { instanceState, openUserTasks };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
api.log("warn", "instanceTracking: failed to provision projection stores; feed disabled", {
|
|
96
|
+
error: String(err),
|
|
97
|
+
});
|
|
98
|
+
return undefined;
|
|
80
99
|
}
|
|
81
|
-
return reconciled;
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
/** Record engine-truth that an instance is TERMINATED into the canonical `urban_instance_state`
|
|
103
|
+
* projection and retire its open-user-task rows — the writer→source replacement for the old
|
|
104
|
+
* `onTerminated.set` `table.update` patch (ADR 0065; #439-L1 / #318). The derived terminal status edge
|
|
105
|
+
* (a VIEW / function over the projection) reflects this on the next read, so there is no stored-derived
|
|
106
|
+
* column to tear from its inputs (nano-workforce#422). This is the SINGLE place that records the terminal
|
|
107
|
+
* source: both the poll reconciler (`reconcileOnce`, once per TERMINATED key it discovers) and the cancel
|
|
108
|
+
* primitive (`cancel.ts`, immediately when it terminates an instance) route through it, so the two can
|
|
109
|
+
* never drift. Absent-safe (no projections / blank key ⇒ 0) and never throws through — a projection write
|
|
110
|
+
* must never break a job or a cancel. Returns 1 when it recorded the terminal source, else 0. */
|
|
111
|
+
export function reconcileTerminatedKey(
|
|
112
|
+
api: Pick<AppApi, "log">,
|
|
113
|
+
projections: InstanceProjections | undefined,
|
|
114
|
+
processInstanceKey: string,
|
|
115
|
+
): number {
|
|
116
|
+
const key = String(processInstanceKey ?? "");
|
|
117
|
+
if (!projections || !key) return 0;
|
|
118
|
+
try {
|
|
119
|
+
// Retire the open tasks BEFORE recording TERMINATED so no reader can observe a terminated instance
|
|
120
|
+
// that still looks parked on a human (terminated wins over waiting-human, at the source as in the
|
|
121
|
+
// derivation). Both writes are idempotent.
|
|
122
|
+
projections.openUserTasks.clearInstance(key);
|
|
123
|
+
// `recordState` returns false when the state is unchanged (an already-recorded TERMINATED re-fed on a
|
|
124
|
+
// later tick or by the cancel path). Only log/count a REAL transition: without this, every repeated
|
|
125
|
+
// terminal poll of a settled instance emits an info log and inflates `reconciled` as if it wrote,
|
|
126
|
+
// producing one spurious "recorded terminated instance" line per settled instance per tick.
|
|
127
|
+
const changed = projections.instanceState.recordState(key, TERMINATED_STATE, false);
|
|
128
|
+
if (!changed) return 0;
|
|
129
|
+
api.log("info", "instanceTracking: recorded terminated instance", {
|
|
130
|
+
projection: INSTANCE_STATE_PROJECTION,
|
|
131
|
+
processInstanceKey: key,
|
|
132
|
+
});
|
|
133
|
+
return 1;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
api.log("warn", "instanceTracking: failed to record terminated instance", {
|
|
136
|
+
processInstanceKey: key,
|
|
137
|
+
error: String(err),
|
|
138
|
+
});
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
91
141
|
}
|
|
92
142
|
|
|
93
|
-
/**
|
|
94
|
-
* — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
/** Feed an instance's CURRENT open-user-task set into the canonical `urban_open_user_tasks` projection
|
|
144
|
+
* — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). `syncInstance` is
|
|
145
|
+
* authoritative-per-instance: it inserts the still-open tasks and RETIRES any previously-projected task
|
|
146
|
+
* that is no longer open, so an answered/closed escalation makes the derived wait-on-human edge go false
|
|
147
|
+
* on the next read by construction (nano-workforce#422 — no stored column to leave stale). Passing an
|
|
148
|
+
* EMPTY `openTasks` clears the instance, which is exactly the answered-escalation transition; callers
|
|
149
|
+
* MUST therefore feed every probed non-terminated key, including those with no open task, or a retired
|
|
150
|
+
* task would linger and the edge would tear. Absent-safe (no projections / blank key ⇒ 0) and never
|
|
151
|
+
* throws through. Returns 1 when it fed the projection, else 0. */
|
|
152
|
+
export function reconcileWaitingHumanKey(
|
|
153
|
+
api: Pick<AppApi, "log">,
|
|
154
|
+
projections: InstanceProjections | undefined,
|
|
103
155
|
processInstanceKey: string,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
const changed = await table.update(key, onWaitingHuman.set);
|
|
121
|
-
if (changed > 0) {
|
|
122
|
-
reconciled += changed;
|
|
123
|
-
api.log("info", "instanceTracking: reconciled instance awaiting operator", {
|
|
124
|
-
table: binding.table,
|
|
125
|
-
keyField: binding.keyField,
|
|
126
|
-
processInstanceKey: key,
|
|
127
|
-
rowsChanged: changed,
|
|
128
|
-
});
|
|
156
|
+
openTasks: readonly UserTaskSummary[],
|
|
157
|
+
): number {
|
|
158
|
+
const key = String(processInstanceKey ?? "");
|
|
159
|
+
if (!projections || !key) return 0;
|
|
160
|
+
try {
|
|
161
|
+
// Re-check the terminal source IMMEDIATELY before the sync. `openTasks` was probed with an `await`
|
|
162
|
+
// that yields the event loop, so a concurrent cancel (or another binding's poll) can record this
|
|
163
|
+
// instance TERMINATED and retire its tasks WHILE the probe is in flight. Terminated wins, so
|
|
164
|
+
// re-inserting the (now stale) open tasks here would strand a row `reconcileTerminatedKey` already
|
|
165
|
+
// cleared — the key is then classified settled and never re-probed, leaving a permanent stale
|
|
166
|
+
// projection row. If it went terminal, ensure the tasks stay retired and skip the sync.
|
|
167
|
+
if (projections.instanceState.getState(key)?.state === TERMINATED_STATE) {
|
|
168
|
+
projections.openUserTasks.clearInstance(key);
|
|
169
|
+
return 0;
|
|
129
170
|
}
|
|
171
|
+
projections.openUserTasks.syncInstance(key, openTasks);
|
|
172
|
+
return 1;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
api.log("warn", "instanceTracking: failed to record open user tasks", {
|
|
175
|
+
processInstanceKey: key,
|
|
176
|
+
error: String(err),
|
|
177
|
+
});
|
|
178
|
+
return 0;
|
|
130
179
|
}
|
|
131
|
-
return reconciled;
|
|
132
180
|
}
|
|
133
181
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
182
|
+
|
|
183
|
+
/** Poll one binding's instances once. Selects the active rows, asks the engine which of their instances
|
|
184
|
+
* are TERMINATED, and FEEDS that terminal fact into the canonical `urban_instance_state` projection;
|
|
185
|
+
* then, for the binding's wait-on-human edge (`onWaitingHuman`, issue #355), FEEDS each non-terminated
|
|
186
|
+
* active row's current open-user-task set into `urban_open_user_tasks`. It writes NO derivable status
|
|
187
|
+
* column on the base row — the two edges are DERIVED from the projections by the managed read-model VIEW
|
|
188
|
+
* (ADR 0065). Returns how many keys were scanned and how many projection sources were fed this tick. */
|
|
139
189
|
async function reconcileOnce(
|
|
140
190
|
api: AppApi,
|
|
141
191
|
binding: InstanceTracking,
|
|
192
|
+
projections: InstanceProjections | undefined,
|
|
142
193
|
): Promise<{ scanned: number; reconciled: number }> {
|
|
194
|
+
// A binding mounted without a default data source (e.g. `mount.data: false`) has no table to poll and
|
|
195
|
+
// no VIEW to derive: `provisionInstanceTrackingViews`/`tryInstanceProjections` already degraded to
|
|
196
|
+
// no-ops at mount. Return the honest zero-count no-op here rather than falling through to
|
|
197
|
+
// `api.data.table(...)`, which THROWS with no default source and would log `reconcile failed` on every
|
|
198
|
+
// interval (the timer is still armed) — noise masquerading as a real failure.
|
|
199
|
+
if (!api.data.hasDefaultSource()) return { scanned: 0, reconciled: 0 };
|
|
143
200
|
const table = api.data.table<Row>(binding.table, binding.keyField);
|
|
144
201
|
|
|
145
202
|
// Select the rows worth polling. Three modes, in precedence order:
|
|
@@ -147,8 +204,8 @@ async function reconcileOnce(
|
|
|
147
204
|
// a terminal value. A newly-added non-terminal status is polled by default, so it can't
|
|
148
205
|
// silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
|
|
149
206
|
// - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
|
|
150
|
-
// - neither: poll every row;
|
|
151
|
-
//
|
|
207
|
+
// - neither: poll every row; the engine query returns only TERMINATED instances and the projection
|
|
208
|
+
// feed is idempotent, so re-feeding a settled row is a harmless no-op.
|
|
152
209
|
// `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
|
|
153
210
|
// both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
|
|
154
211
|
// selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
|
|
@@ -160,7 +217,7 @@ async function reconcileOnce(
|
|
|
160
217
|
if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
|
|
161
218
|
const terminal = new Set(binding.terminalStatuses);
|
|
162
219
|
const all = await table.all();
|
|
163
|
-
candidates = all.filter((row) => !terminal.has(String(row
|
|
220
|
+
candidates = all.filter((row) => !terminal.has(String(lookupColumn(row, statusField))));
|
|
164
221
|
} else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
|
|
165
222
|
const perStatus = await Promise.all(
|
|
166
223
|
binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
|
|
@@ -171,10 +228,14 @@ async function reconcileOnce(
|
|
|
171
228
|
}
|
|
172
229
|
|
|
173
230
|
// Map each active row to its tracked instance key. A row with no/empty key can't be
|
|
174
|
-
// reconciled (it never started an instance, or the column is unset) — skip it.
|
|
231
|
+
// reconciled (it never started an instance, or the column is unset) — skip it. Resolve `keyField`
|
|
232
|
+
// with the SAME case-insensitive column lookup the read-model VIEW compiler uses (SQLite folds
|
|
233
|
+
// identifier case, so a binding `keyField: "Process_Key"` over a `process_key` column correlates in
|
|
234
|
+
// the derived VIEW): a plain case-sensitive `row[binding.keyField]` would read `undefined` here and
|
|
235
|
+
// feed NO keys into either projection, so neither terminal nor waiting-human status could derive.
|
|
175
236
|
const keyToRows = new Map<string, Row[]>();
|
|
176
237
|
for (const row of candidates) {
|
|
177
|
-
const raw = row
|
|
238
|
+
const raw = lookupColumn(row, binding.keyField);
|
|
178
239
|
if (raw == null || raw === "") continue;
|
|
179
240
|
const key = String(raw);
|
|
180
241
|
const list = keyToRows.get(key);
|
|
@@ -182,44 +243,95 @@ async function reconcileOnce(
|
|
|
182
243
|
else keyToRows.set(key, [row]);
|
|
183
244
|
}
|
|
184
245
|
const keys = [...keyToRows.keys()];
|
|
185
|
-
if (keys.length === 0) return { scanned: 0, reconciled: 0 };
|
|
186
246
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
247
|
+
// NB: do NOT early-return on an empty active set. A key that LEFT the active selector (its task was
|
|
248
|
+
// answered and its worker flipped it to a non-active status) still has a stale open-task projection to
|
|
249
|
+
// retire below — returning early here would strand it, re-tearing the derived edge (ADR 0065). The
|
|
250
|
+
// engine query and human probe both degrade to no-ops on an empty set, so falling through is cheap.
|
|
251
|
+
|
|
252
|
+
// Skip keys already recorded TERMINATED in the canonical projection: a settled instance never leaves
|
|
253
|
+
// TERMINATED, and the base row keeps its (unchanged) `statusField`, so with `activeStatuses`/no-selector
|
|
254
|
+
// it would pass the selector on every tick and be re-queried + re-fed forever (the old `table.update`
|
|
255
|
+
// moved it out of the active set; the inversion deliberately does not). The projection is the
|
|
256
|
+
// authoritative settled-set, so exclude those keys from both the engine query and the wait-on-human
|
|
257
|
+
// probe below — a pure efficiency win (the re-feed was already an idempotent no-op).
|
|
258
|
+
// `settled` drives a projection `getState` — a real SQL round trip (SELECT … WHERE
|
|
259
|
+
// process_instance_key = ?) — and is consulted for the same key up to three times per pass (the
|
|
260
|
+
// live-key filter here, then the wait-on-human `humanKeySet` build and its projected-open scan). The
|
|
261
|
+
// settled set cannot change mid-pass except for keys THIS pass terminates, and those are already
|
|
262
|
+
// excluded downstream via `terminatedKeys`, so caching the first result per key is behaviour-preserving
|
|
263
|
+
// and caps the projection at one query per distinct key (bounding a large-backlog pass's SQL fan-out).
|
|
264
|
+
const settledCache = new Map<string, boolean>();
|
|
265
|
+
const settled = (key: string): boolean => {
|
|
266
|
+
const cached = settledCache.get(key);
|
|
267
|
+
if (cached !== undefined) return cached;
|
|
268
|
+
const value = projections?.instanceState.getState(key)?.state === TERMINATED_STATE;
|
|
269
|
+
settledCache.set(key, value);
|
|
270
|
+
return value;
|
|
271
|
+
};
|
|
272
|
+
const liveKeys = keys.filter((key) => !settled(key));
|
|
273
|
+
|
|
274
|
+
const snapshots = liveKeys.length > 0
|
|
275
|
+
? await api.engine.searchProcessInstances({ processInstanceKeys: liveKeys, state: "TERMINATED" })
|
|
276
|
+
: [];
|
|
191
277
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
278
|
+
// Feed the terminal fact through the shared `reconcileTerminatedKey` — the one place that records the
|
|
279
|
+
// terminal source into `urban_instance_state` — so the poll path and the cancel primitive can never
|
|
280
|
+
// drift (No Drift Surfaces). No base-row write: the terminal status edge is DERIVED from the projection.
|
|
195
281
|
let reconciled = 0;
|
|
196
282
|
const terminatedKeys = new Set<string>();
|
|
197
283
|
for (const snap of snapshots) {
|
|
198
284
|
if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
|
|
199
285
|
if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
|
|
200
286
|
terminatedKeys.add(snap.processInstanceKey);
|
|
201
|
-
reconciled +=
|
|
287
|
+
reconciled += reconcileTerminatedKey(api, projections, snap.processInstanceKey);
|
|
202
288
|
}
|
|
203
289
|
|
|
204
|
-
// Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a
|
|
205
|
-
//
|
|
206
|
-
// it has an open user task — the authoritative engine truth — so
|
|
207
|
-
// bare `searchUserTasks`, which lags a completion and would latch
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
290
|
+
// Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a key
|
|
291
|
+
// recorded as terminated above (or already settled) is excluded here (its open tasks were retired). An
|
|
292
|
+
// instance is "waiting on a human" iff it has an open user task — the authoritative engine truth — so
|
|
293
|
+
// read `openUserTasks` (never a bare `searchUserTasks`, which lags a completion and would latch a closed
|
|
294
|
+
// task; issue #355 "instance B") and FEED that current open set into the projection via
|
|
295
|
+
// `reconcileWaitingHumanKey`. Feed EVERY probed key, including those with NO open task: an empty set
|
|
296
|
+
// RETIRES a just-answered task so the derived edge re-flips to non-parked by construction — this is the
|
|
297
|
+
// nano-workforce#422 fix (a stored write would have left `awaiting_operator` stale). Probe in
|
|
298
|
+
// bounded-parallel batches (`WAITING_HUMAN_PROBE_CONCURRENCY`) so a pass over a large active backlog
|
|
299
|
+
// scales with max latency rather than N×latency.
|
|
214
300
|
if (binding.onWaitingHuman) {
|
|
215
|
-
const
|
|
301
|
+
const humanKeySet = new Set(keys.filter((key) => !terminatedKeys.has(key) && !settled(key)));
|
|
302
|
+
// Retire stale projections INDEPENDENTLY of the active-status selector. With `activeStatuses`, a key
|
|
303
|
+
// can be projected while its task is open and then move to a non-active worker status when the task is
|
|
304
|
+
// answered — it then never passes the selector again, so its `urban_open_user_tasks` row would linger
|
|
305
|
+
// and the VIEW keep deriving `awaiting_operator` (a fresh #422 tear). Re-probe every key STILL
|
|
306
|
+
// projected as open so an answered/closed one is retired (`syncInstance([])`). The projection is
|
|
307
|
+
// app-wide, but re-syncing a key another binding owns is a harmless idempotent no-op against engine
|
|
308
|
+
// truth, so probing the whole projected set is always safe.
|
|
309
|
+
if (projections) {
|
|
310
|
+
for (const key of projections.openUserTasks.instanceKeys()) {
|
|
311
|
+
if (!terminatedKeys.has(key) && !settled(key)) humanKeySet.add(key);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const humanKeys = [...humanKeySet];
|
|
216
315
|
for (let i = 0; i < humanKeys.length; i += WAITING_HUMAN_PROBE_CONCURRENCY) {
|
|
217
316
|
const batch = humanKeys.slice(i, i + WAITING_HUMAN_PROBE_CONCURRENCY);
|
|
218
317
|
const perKey = await Promise.all(
|
|
219
318
|
batch.map(async (key): Promise<number> => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
319
|
+
// Isolate each key's probe: one engine `openUserTasks` rejection must NOT reject the whole
|
|
320
|
+
// `Promise.all` batch. Batches are awaited sequentially, so a single permanently-failing key in
|
|
321
|
+
// an early batch would otherwise starve every key in later batches on every tick, letting their
|
|
322
|
+
// stale open-task projections persist indefinitely. On a per-key failure, log and skip (count 0)
|
|
323
|
+
// — the projection is left as-is and re-probed next pass — so one bad engine response cannot
|
|
324
|
+
// stall the rest of the backlog.
|
|
325
|
+
try {
|
|
326
|
+
const openTasks = await api.engine.openUserTasks({ processInstanceKey: key });
|
|
327
|
+
return reconcileWaitingHumanKey(api, projections, key, openTasks);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
api.log("warn", "instanceTracking: openUserTasks probe failed; key skipped this pass", {
|
|
330
|
+
key,
|
|
331
|
+
error: String(err),
|
|
332
|
+
});
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
223
335
|
}),
|
|
224
336
|
);
|
|
225
337
|
for (const n of perKey) reconciled += n;
|
|
@@ -228,10 +340,228 @@ async function reconcileOnce(
|
|
|
228
340
|
return { scanned: keys.length, reconciled };
|
|
229
341
|
}
|
|
230
342
|
|
|
343
|
+
/** The subset of the SQLite source handle the VIEW provisioner needs. */
|
|
344
|
+
type ProvisionDb = { exec(sql: string): void; all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] };
|
|
345
|
+
|
|
346
|
+
/** Is there a NON-view object (a real table) named `name` in the database? SQLite shares one namespace
|
|
347
|
+
* for tables and views, so a real table shadows a managed VIEW: `DROP VIEW IF EXISTS` won't remove it
|
|
348
|
+
* and `CREATE VIEW IF NOT EXISTS` then silently no-ops. Provisioning must refuse rather than appear to
|
|
349
|
+
* succeed while a page reads the wrong object. The name match is `COLLATE NOCASE`: SQLite resolves
|
|
350
|
+
* object names case-insensitively, so a real table `plans__tracking` shadows a configured view
|
|
351
|
+
* `Plans__tracking` at DROP/CREATE time — a binary `=` here would miss it and defeat this guard. */
|
|
352
|
+
function nonViewObjectExists(db: ProvisionDb, name: string): boolean {
|
|
353
|
+
const rows = db.all<{ type: string }>(`SELECT type FROM sqlite_master WHERE name = ? COLLATE NOCASE AND type <> 'view'`, [name]);
|
|
354
|
+
return rows.length > 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Retire (DROP) a previously-managed tracking VIEW by name. Used on every skip path (a binding with no
|
|
358
|
+
* `statusField`, a base-column collision) so a manifest change or a schema drift cannot leave a stale,
|
|
359
|
+
* still-readable derived surface behind. Only ever drops a VIEW (never a real table of the same name),
|
|
360
|
+
* and never throws through — a retire failure must not break the mount. */
|
|
361
|
+
function retireManagedTrackingView(api: Pick<AppApi, "log">, db: ProvisionDb, viewName: string, table: string): void {
|
|
362
|
+
try {
|
|
363
|
+
db.exec(`DROP VIEW IF EXISTS main."${viewName}";`);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
api.log("warn", `instanceTracking: failed to retire stale derived VIEW "${viewName}"`, {
|
|
366
|
+
table,
|
|
367
|
+
error: String(err),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Retire (DROP) every managed tracking VIEW for these bindings, used when the projection feed is
|
|
373
|
+
* unavailable (`tryInstanceProjections` returned undefined because projection-store provisioning
|
|
374
|
+
* failed). Without this, a VIEW an earlier successful boot published is left readable and keeps
|
|
375
|
+
* deriving a value over projection tables this mount has no coherent source for — a silent stale
|
|
376
|
+
* surface. Retiring turns that into an honest "no such object" instead. Absent- and error-safe: with
|
|
377
|
+
* no default data source there is no db and nothing managed to retire, and a retire failure never
|
|
378
|
+
* breaks the mount. Deduplicates by folded VIEW name (a status-bearing + status-less binding on one
|
|
379
|
+
* table share the default name) so we DROP each once. */
|
|
380
|
+
function retireInstanceTrackingViews(api: AppApi, bindings: readonly InstanceTracking[]): void {
|
|
381
|
+
if (bindings.length === 0) return;
|
|
382
|
+
if (!api.data.hasDefaultSource()) return;
|
|
383
|
+
let db: ProvisionDb;
|
|
384
|
+
try {
|
|
385
|
+
db = api.data.source().db;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
api.log("warn", "instanceTracking: failed to resolve default source; stale derived VIEWs not retired", {
|
|
388
|
+
error: String(err),
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const retired = new Set<string>();
|
|
393
|
+
for (const binding of bindings) {
|
|
394
|
+
let viewName: string;
|
|
395
|
+
try {
|
|
396
|
+
viewName = assertSqlIdentifier("instanceTracking.readModel.view", instanceTrackingReadModelTarget(binding).view);
|
|
397
|
+
} catch {
|
|
398
|
+
// A non-identifier view name was never provisionable, so there is nothing managed to retire.
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const folded = viewName.toLowerCase();
|
|
402
|
+
if (retired.has(folded)) continue;
|
|
403
|
+
retired.add(folded);
|
|
404
|
+
retireManagedTrackingView(api, db, viewName, binding.table);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Provision each binding's DERIVED read-model VIEW on the app's default data source (ADR 0065, the
|
|
409
|
+
* writer→source inversion). The VIEW re-exports `base.*` plus a derived effective-status column computed
|
|
410
|
+
* by a `defineReadModel` derivation over the canonical projections, so the operator page reads the
|
|
411
|
+
* derived status with no stored write. Registers the canonical projection NAME→physical-table mapping
|
|
412
|
+
* first (idempotent) so the VIEW's `EXISTS` resolves to the `_urban_*` tables even if the worker mount
|
|
413
|
+
* did not run. Truly MANAGED — DROP+CREATE — so a changed derivation always replaces a stale VIEW body.
|
|
414
|
+
* Absent- and error-safe like lineage: no default source provisions nothing, and a VIEW failure never
|
|
415
|
+
* breaks the mount. A binding with no `statusField` has no derivable status, so no VIEW is provisioned
|
|
416
|
+
* (its projections are still fed). */
|
|
417
|
+
function provisionInstanceTrackingViews(api: AppApi, bindings: readonly InstanceTracking[]): void {
|
|
418
|
+
if (bindings.length === 0) return;
|
|
419
|
+
// Make the DSL projection names (`urban_instance_state` / `urban_open_user_tasks`) resolve to their
|
|
420
|
+
// physical `_urban_*` tables. Idempotent; safe even when the worker mount already registered them.
|
|
421
|
+
registerCanonicalProjections();
|
|
422
|
+
if (!api.data.hasDefaultSource()) {
|
|
423
|
+
api.log("debug", "instanceTracking: no default data source; derived VIEW provisioning skipped");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let db: ProvisionDb;
|
|
427
|
+
try {
|
|
428
|
+
db = api.data.source().db;
|
|
429
|
+
} catch (err) {
|
|
430
|
+
api.log("warn", "instanceTracking: failed to resolve default source; derived VIEWs not provisioned", {
|
|
431
|
+
error: String(err),
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// View names claimed by a CURRENT status-bearing binding (folded — SQLite folds identifiers). A
|
|
436
|
+
// status-less binding shares its base table's default `<table>__tracking` name with a status-bearing
|
|
437
|
+
// binding on the same table, and its retire path (below) would otherwise DROP the view that binding
|
|
438
|
+
// just created (iteration-order dependent). Validation guarantees no two status-bearing bindings share
|
|
439
|
+
// a view name, so this set is unambiguous. Retirement consults it so it only drops UNCLAIMED names.
|
|
440
|
+
const claimedByStatusful = new Set<string>();
|
|
441
|
+
for (const b of bindings) {
|
|
442
|
+
if (typeof b.statusField !== "string" || b.statusField.length === 0) continue;
|
|
443
|
+
claimedByStatusful.add(instanceTrackingReadModelTarget(b).view.toLowerCase());
|
|
444
|
+
}
|
|
445
|
+
for (const binding of bindings) {
|
|
446
|
+
// Resolve the managed VIEW name (config override or `<table>__tracking`) up front — we need it even
|
|
447
|
+
// on the skip paths below to RETIRE a previously-managed VIEW, so a manifest change (statusField
|
|
448
|
+
// dropped) or a collision cannot leave a stale, still-readable derived surface behind.
|
|
449
|
+
const { view: targetView, statusColumn } = instanceTrackingReadModelTarget(binding);
|
|
450
|
+
let viewName: string;
|
|
451
|
+
try {
|
|
452
|
+
viewName = assertSqlIdentifier("instanceTracking.readModel.view", targetView);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
api.log("warn", "instanceTracking: invalid read-model view name; derived VIEW skipped", {
|
|
455
|
+
table: binding.table,
|
|
456
|
+
error: String(err),
|
|
457
|
+
});
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let model: ReturnType<typeof defineInstanceTrackingReadModel>;
|
|
462
|
+
try {
|
|
463
|
+
model = defineInstanceTrackingReadModel(binding);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
api.log("warn", "instanceTracking: invalid read-model config; derived VIEW skipped", {
|
|
466
|
+
table: binding.table,
|
|
467
|
+
error: String(err),
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!model) {
|
|
472
|
+
// No statusField ⇒ nothing derivable to project. Retire any VIEW an earlier boot managed under
|
|
473
|
+
// this name — UNLESS a current status-bearing binding claims the same name (a status-bearing +
|
|
474
|
+
// status-less binding on one table share the default view name; retiring here would drop the view
|
|
475
|
+
// the owner provisions). Without the retire, dropping `statusField` from a binding would otherwise
|
|
476
|
+
// leave the old derived surface discoverable/readable while the runtime believes it provisioned
|
|
477
|
+
// nothing.
|
|
478
|
+
if (!claimedByStatusful.has(viewName.toLowerCase())) {
|
|
479
|
+
retireManagedTrackingView(api, db, viewName, binding.table);
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// Base-column collision guard (ADR 0065): the VIEW selects `base.*` and appends the derived status as
|
|
484
|
+
// `${statusColumn} AS ...`. If the base table ALREADY has a column of that name (the default
|
|
485
|
+
// `derived_status`, or an override that happens to match a real column), SQLite keeps the base column
|
|
486
|
+
// under that name and auto-renames the derived one — provisioning "succeeds" but a page querying
|
|
487
|
+
// `statusColumn` reads the STALE stored value. Detect it and skip (loudly) rather than publish a VIEW
|
|
488
|
+
// that silently defeats the derivation. `readModel.statusColumn === statusField` is already rejected at
|
|
489
|
+
// manifest validation; this catches collisions with ANY other base column, which needs the live schema.
|
|
490
|
+
try {
|
|
491
|
+
const cols = db.all<{ name: string }>(`PRAGMA table_info("${assertSqlIdentifier("instanceTracking.table", binding.table)}")`);
|
|
492
|
+
const folded = statusColumn.toLowerCase();
|
|
493
|
+
if (cols.some((c) => typeof c.name === "string" && c.name.toLowerCase() === folded)) {
|
|
494
|
+
api.log(
|
|
495
|
+
"warn",
|
|
496
|
+
`instanceTracking: derived status column "${statusColumn}" collides with an existing "${binding.table}" column; derived VIEW skipped (set readModel.statusColumn to a non-colliding name)`,
|
|
497
|
+
{ table: binding.table, statusColumn },
|
|
498
|
+
);
|
|
499
|
+
// Retire any stale VIEW managed under this name — a collision means we can't publish a CORRECT
|
|
500
|
+
// derivation, and leaving an earlier boot's VIEW in place would keep serving a stale surface.
|
|
501
|
+
retireManagedTrackingView(api, db, viewName, binding.table);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
api.log("warn", "instanceTracking: failed to introspect base columns; derived VIEW skipped", {
|
|
506
|
+
table: binding.table,
|
|
507
|
+
error: String(err),
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// A real (non-view) object of this name shares SQLite's table/view namespace: `DROP VIEW IF EXISTS`
|
|
512
|
+
// won't remove it and `CREATE VIEW IF NOT EXISTS` then silently no-ops, so a page configured for this
|
|
513
|
+
// view would read the wrong object with no derived column while the mount looks successful. Refuse
|
|
514
|
+
// explicitly rather than pretend to provision. (Cross-binding name/table collisions are rejected at
|
|
515
|
+
// manifest validation; this catches an undeclared app table that happens to share the name.)
|
|
516
|
+
try {
|
|
517
|
+
if (nonViewObjectExists(db, viewName)) {
|
|
518
|
+
api.log(
|
|
519
|
+
"warn",
|
|
520
|
+
`instanceTracking: managed VIEW name "${viewName}" is taken by an existing non-view object; derived VIEW skipped (choose a distinct readModel.view)`,
|
|
521
|
+
{ table: binding.table, view: viewName },
|
|
522
|
+
);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
} catch (err) {
|
|
526
|
+
api.log("warn", "instanceTracking: failed to inspect existing objects; derived VIEW skipped", {
|
|
527
|
+
table: binding.table,
|
|
528
|
+
error: String(err),
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
// A registered read model (seam (a): `readModelRegistry`) already OWNS a VIEW of this name —
|
|
533
|
+
// `mountWorkers` provisioned it before this mount. Our unconditional `DROP VIEW … ; CREATE VIEW …`
|
|
534
|
+
// below would silently clobber that registered derivation (and a later worker mount could clobber
|
|
535
|
+
// ours back), with neither side detecting the conflict the registry itself rejects for two registered
|
|
536
|
+
// models. Refuse rather than fight over the name, mirroring the `nonViewObjectExists` guard above.
|
|
537
|
+
if (readModelRegistry.get(viewName)) {
|
|
538
|
+
api.log(
|
|
539
|
+
"warn",
|
|
540
|
+
`instanceTracking: managed VIEW name "${viewName}" collides with a registered read model; derived VIEW skipped (choose a distinct readModel.view)`,
|
|
541
|
+
{ table: binding.table, view: viewName },
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
// Qualify the DROP to `main.` for the same reason `ReadModelRegistry.ensureViews` does: an
|
|
547
|
+
// unqualified DROP resolves a stray TEMP view of the same name first (e.g. leaked from a parity
|
|
548
|
+
// run), leaving the managed `main` view stale.
|
|
549
|
+
db.exec(`DROP VIEW IF EXISTS main."${viewName}";`);
|
|
550
|
+
db.exec(model.viewDdl());
|
|
551
|
+
} catch (err) {
|
|
552
|
+
api.log("warn", `instanceTracking: failed to provision derived VIEW "${model.decl.name}"`, {
|
|
553
|
+
error: String(err),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
231
559
|
/**
|
|
232
|
-
* Mount the `instanceTracking` reconciler:
|
|
233
|
-
* the
|
|
234
|
-
*
|
|
560
|
+
* Mount the `instanceTracking` reconciler: provision each binding's derived read-model VIEW, construct
|
|
561
|
+
* the canonical projection stores once, and arm one poll loop per binding. Each loop selects the active
|
|
562
|
+
* rows, asks the engine which of their instances are terminated / parked on a human, and FEEDS that
|
|
563
|
+
* engine truth into the canonical projections (never a base-row write — the status edges are DERIVED;
|
|
564
|
+
* ADR 0065). `stop()` cancels every armed timer.
|
|
235
565
|
*/
|
|
236
566
|
export function mountInstanceTracking(
|
|
237
567
|
ctx: RuntimeContext,
|
|
@@ -243,6 +573,19 @@ export function mountInstanceTracking(
|
|
|
243
573
|
const armed: string[] = [];
|
|
244
574
|
let stopped = false;
|
|
245
575
|
|
|
576
|
+
// Ensure the canonical projection tables (`_urban_*`) exist BEFORE creating the derived VIEWs that
|
|
577
|
+
// SELECT over them: a VIEW built on missing sidecar tables is created fine (SQLite does not validate a
|
|
578
|
+
// view body) but then reads `no such table`, so the mount would look successful while the page fails.
|
|
579
|
+
// Both are absent-safe: with no default data source `projections` is undefined and no VIEW is
|
|
580
|
+
// provisioned, so each poll degrades to a pure engine query that feeds nothing (never throwing). When
|
|
581
|
+
// projection setup FAILS after an earlier boot succeeded (undefined despite a default source), there is
|
|
582
|
+
// nothing coherent to derive over, so instead of leaving a prior boot's VIEW readable over an
|
|
583
|
+
// unavailable/stale projection we RETIRE the managed VIEWs — an honest missing surface, not a silent
|
|
584
|
+
// stale one.
|
|
585
|
+
const projections = tryInstanceProjections(api);
|
|
586
|
+
if (projections) provisionInstanceTrackingViews(api, bindings);
|
|
587
|
+
else retireInstanceTrackingViews(api, bindings);
|
|
588
|
+
|
|
246
589
|
for (const binding of bindings) {
|
|
247
590
|
// `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
|
|
248
591
|
// clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
|
|
@@ -267,7 +610,7 @@ export function mountInstanceTracking(
|
|
|
267
610
|
if (stopped) return;
|
|
268
611
|
void (async () => {
|
|
269
612
|
try {
|
|
270
|
-
await reconcileOnce(api, binding);
|
|
613
|
+
await reconcileOnce(api, binding, projections);
|
|
271
614
|
} catch (err) {
|
|
272
615
|
api.log("error", `instanceTracking "${label}": reconcile failed`, {
|
|
273
616
|
error: String(err),
|