@nanobpm/urban 0.77.4 → 0.78.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.
@@ -7,6 +7,7 @@ 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";
10
11
  import { defaultScheduler, schedulerClock } from "./scheduler.js";
11
12
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
12
13
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
@@ -95,6 +96,29 @@ function tryLineageStore(app, log) {
95
96
  return undefined;
96
97
  }
97
98
  }
99
+ /**
100
+ * Apply every managed read-model VIEW registered in the process-wide `readModelRegistry` to the app's
101
+ * DEFAULT data source (ADR 0065, surface #3). A `defineReadModel(...)` declaration compiles to a
102
+ * managed SQLite VIEW; provisioning it here — the same per-source boot path `tryLineageStore` uses —
103
+ * means an app (or a canonical-projection sidecar) that registers a read model gets its VIEW created
104
+ * for free, with no hand-written migration. The registry is empty until a sibling task registers into
105
+ * it, so this is a silent no-op in the base framework. Absent- and error-safe like lineage: an app
106
+ * with no datasource provisions nothing, and a VIEW-provisioning failure never breaks a worker mount.
107
+ */
108
+ function tryProvisionReadModelViews(app, log) {
109
+ if (readModelRegistry.all().length === 0)
110
+ return;
111
+ if (!app.data.hasDefaultSource()) {
112
+ log("debug", "read-models: no default data source; managed VIEW provisioning skipped");
113
+ return;
114
+ }
115
+ try {
116
+ readModelRegistry.ensureViews(app.data.source().db);
117
+ }
118
+ catch (err) {
119
+ log("warn", "read-models: failed to provision managed VIEWs", { error: String(err) });
120
+ }
121
+ }
98
122
  /** Record an activated job's lineage edge, never letting a projection write break the job. */
99
123
  function recordJobLineage(store, job, log) {
100
124
  try {
@@ -142,6 +166,10 @@ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
142
166
  // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
143
167
  // app configures no default data source.
144
168
  const lineageStore = tryLineageStore(app, ctx.host.log);
169
+ // The framework read-model VIEWs (ADR 0065): apply every registered managed VIEW to the app's
170
+ // default data source, the same per-source boot path lineage uses. A no-op until a sibling task
171
+ // registers a read model into `readModelRegistry`.
172
+ tryProvisionReadModelViews(app, ctx.host.log);
145
173
  // Cache module loads so a multi-type handler module is imported once.
146
174
  const moduleCache = new Map();
147
175
  const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
@@ -0,0 +1,267 @@
1
+ /** The comparison operators the DSL supports, in both backends. */
2
+ export type CompareOp = "eq" | "neq" | "lt" | "lte" | "gt" | "gte";
3
+ /** A DSL literal value. Kept to the SQL/JS-portable scalars so both backends agree. */
4
+ export type Literal = string | number | boolean | null;
5
+ /**
6
+ * The closed expression AST — a discriminated union, NOT arbitrary strings or SQL fragments. This
7
+ * closedness is the whole point: one declaration compiles to two backends that cannot drift. Every
8
+ * node kind is handled exhaustively by both {@link compileToSqlSelect} and {@link compileToFn}.
9
+ *
10
+ * `col` references a column of the BASE row. `pcol` references a column of the CORRELATED projection
11
+ * row and is only meaningful inside an {@link ExistsExpr} `where` predicate.
12
+ */
13
+ export type Expr = LitExpr | ColExpr | ProjColExpr | CompareExpr | AndExpr | OrExpr | NotExpr | CaseExpr | ExistsExpr;
14
+ export interface LitExpr {
15
+ readonly kind: "lit";
16
+ readonly value: Literal;
17
+ }
18
+ export interface ColExpr {
19
+ readonly kind: "col";
20
+ readonly name: string;
21
+ }
22
+ export interface ProjColExpr {
23
+ readonly kind: "pcol";
24
+ readonly name: string;
25
+ }
26
+ export interface CompareExpr {
27
+ readonly kind: "compare";
28
+ readonly op: CompareOp;
29
+ readonly left: Expr;
30
+ readonly right: Expr;
31
+ }
32
+ export interface AndExpr {
33
+ readonly kind: "and";
34
+ readonly clauses: readonly Expr[];
35
+ }
36
+ export interface OrExpr {
37
+ readonly kind: "or";
38
+ readonly clauses: readonly Expr[];
39
+ }
40
+ export interface NotExpr {
41
+ readonly kind: "not";
42
+ readonly expr: Expr;
43
+ }
44
+ export interface WhenClause {
45
+ readonly when: Expr;
46
+ readonly then: Expr;
47
+ }
48
+ export interface CaseExpr {
49
+ readonly kind: "case";
50
+ readonly whens: readonly WhenClause[];
51
+ readonly else: Expr;
52
+ }
53
+ export interface ExistsExpr {
54
+ readonly kind: "exists";
55
+ /** The projection name referenced (resolved to a physical table via {@link ProjectionRegistry}). */
56
+ readonly projection: string;
57
+ /** The correlation predicate; may mix {@link pcol} (projection) and {@link col} (base) refs. */
58
+ readonly where: Expr;
59
+ }
60
+ /** A literal scalar. */
61
+ export declare const lit: (value: Literal) => LitExpr;
62
+ /** A reference to a BASE-row column. */
63
+ export declare const col: (name: string) => ColExpr;
64
+ /** A reference to a correlated PROJECTION-row column (only valid inside {@link exists}). */
65
+ export declare const pcol: (name: string) => ProjColExpr;
66
+ export declare const eq: (left: Expr, right: Expr) => CompareExpr;
67
+ export declare const neq: (left: Expr, right: Expr) => CompareExpr;
68
+ export declare const lt: (left: Expr, right: Expr) => CompareExpr;
69
+ export declare const lte: (left: Expr, right: Expr) => CompareExpr;
70
+ export declare const gt: (left: Expr, right: Expr) => CompareExpr;
71
+ export declare const gte: (left: Expr, right: Expr) => CompareExpr;
72
+ export declare const and: (...clauses: Expr[]) => AndExpr;
73
+ export declare const or: (...clauses: Expr[]) => OrExpr;
74
+ export declare const not: (expr: Expr) => NotExpr;
75
+ /** Build a `CASE WHEN … THEN … [WHEN …] ELSE … END` expression. */
76
+ export declare const caseWhen: (whens: WhenClause[], otherwise: Expr) => CaseExpr;
77
+ /** Sugar for a single `{ when, then }` clause used by {@link caseWhen}. */
78
+ export declare const when: (whenExpr: Expr, thenExpr: Expr) => WhenClause;
79
+ /** `EXISTS (SELECT 1 FROM <projection> WHERE <where>)` — a correlated existence test. */
80
+ export declare const exists: (projection: string, where: Expr) => ExistsExpr;
81
+ /**
82
+ * A canonical projection the DSL's {@link exists} can reference by name. A sidecar (the
83
+ * `canonical-projections` task) provides one per engine-truth projection so the SQL backend knows
84
+ * which physical table/view to read and the TS backend can be handed matching rows.
85
+ */
86
+ export interface ProjectionSource {
87
+ /** The stable DSL name used in `exists(name, …)`. */
88
+ readonly name: string;
89
+ /**
90
+ * The physical SQLite table/view the SQL backend reads for `EXISTS`. Defaults to {@link name} when
91
+ * the stable DSL name already matches the physical relation; a sidecar sets it explicitly when the
92
+ * two differ (e.g. a stable DSL name like `urban_instance_state` mapped onto an `_urban_`-prefixed
93
+ * engine-truth table).
94
+ */
95
+ readonly sqlTable?: string;
96
+ }
97
+ /** The in-memory rows the TS backend sees for one evaluation, keyed by projection name. */
98
+ export type ProjectionRows = Record<string, ReadonlyArray<Record<string, unknown>>>;
99
+ /** A base row fed to the TS backend / parity guard. */
100
+ export type BaseRow = Record<string, unknown>;
101
+ /**
102
+ * The projection-name registry (seam (b)). A canonical engine-truth projection sidecar registers its
103
+ * name here so a `defineReadModel` authored anywhere can `exists(name, …)` over it. Advisory/idempotent:
104
+ * re-registering the same name with a matching `sqlTable` is a no-op; a conflicting `sqlTable` throws
105
+ * so two sidecars cannot silently claim one name.
106
+ */
107
+ export declare class ProjectionRegistry {
108
+ #private;
109
+ register(source: ProjectionSource): void;
110
+ /** The physical SQL table/view for a projection name; falls back to the name itself if unregistered
111
+ * (so a read model compiles against a not-yet-landed sidecar, which supplies the same-named table). */
112
+ sqlTableFor(name: string): string;
113
+ has(name: string): boolean;
114
+ names(): string[];
115
+ /** Reset all registrations. Mirrors {@link ReadModelRegistry.clear} so tests and dev harnesses can
116
+ * restore this process-wide singleton to a deterministic empty state (isolation across test runs). */
117
+ clear(): void;
118
+ }
119
+ /**
120
+ * Process-wide projection registry — the concrete registration point sidecars import and call. The
121
+ * `canonical-projections` task registers `urban_open_user_tasks` and `urban_instance_state` here.
122
+ */
123
+ export declare const projectionRegistry: ProjectionRegistry;
124
+ /** Options for {@link compileToSqlSelect}. */
125
+ export interface SqlCompileOptions {
126
+ /** The alias the managed VIEW gives the base table (so `col("x")` → `<baseAlias>."x"`). */
127
+ readonly baseAlias?: string;
128
+ /** Resolve a projection NAME to its physical SQL table/view. Defaults to {@link projectionRegistry}. */
129
+ readonly resolveProjectionTable?: (name: string) => string;
130
+ }
131
+ export declare function assertSqlIdentifier(kind: string, name: string): string;
132
+ /**
133
+ * Compile ONE derivation expression to a SQLite select-list expression string — the derived column's
134
+ * body inside the managed VIEW. Both backends walk the SAME AST, so the SQL here and the function
135
+ * {@link compileToFn} produces cannot diverge.
136
+ */
137
+ export declare function compileToSqlSelect(expr: Expr, options?: SqlCompileOptions): string;
138
+ /** The compiled runtime derivation: same value as the SQL VIEW, computed in-process. */
139
+ export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows) => unknown;
140
+ /**
141
+ * Compile ONE derivation expression to a TS function computing the SAME value as {@link compileToSqlSelect}.
142
+ * The function reads the base row for `col`, and — for `exists` — the `projections` argument for the
143
+ * named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
144
+ */
145
+ export declare function compileToFn(expr: Expr): DerivationFn;
146
+ /** The declarative read-model input passed to {@link defineReadModel}. */
147
+ export interface ReadModelDecl {
148
+ /** The managed VIEW name (also the read-model identifier). */
149
+ readonly name: string;
150
+ /** The base table the VIEW selects from; the derived columns are functions of its rows. */
151
+ readonly baseTable: string;
152
+ /**
153
+ * The derived columns: name → derivation expression. Each is authored ONCE and compiled to both a
154
+ * SQLite select-list expression and a TS function.
155
+ */
156
+ readonly derive: Record<string, Expr>;
157
+ /**
158
+ * Optional: whether the managed VIEW re-exports the base columns (`base.*`) alongside the derived
159
+ * ones. Defaults to true (a read model is usually the base row PLUS its derived columns).
160
+ */
161
+ readonly selectBaseColumns?: boolean;
162
+ }
163
+ /** A compiled read model: the declaration plus its derived SQL, derived functions, and managed VIEW. */
164
+ export interface ReadModel {
165
+ readonly decl: ReadModelDecl;
166
+ /** Projection names referenced by any derived column's `exists` (for registry/validation). */
167
+ readonly projectionNames: string[];
168
+ /** The SQLite select-list expression for one derived column. */
169
+ sqlSelectFor(column: string, options?: SqlCompileOptions): string;
170
+ /** The TS derivation function for one derived column. */
171
+ fnFor(column: string): DerivationFn;
172
+ /** Evaluate ALL derived columns for a base row in-process (the TS backend of the whole model). */
173
+ evaluate(baseRow: BaseRow, projections?: ProjectionRows): Record<string, unknown>;
174
+ /** The managed `CREATE VIEW` DDL (seam (a) applies this at boot). Pass `{ temp: true }` to emit a
175
+ * `CREATE TEMP VIEW` in SQLite's TEMP schema (used by the parity guard so it never touches the
176
+ * application's real objects); the SELECT body is byte-identical either way. */
177
+ viewDdl(options?: SqlCompileOptions, viewOptions?: ViewDdlOptions): string;
178
+ }
179
+ /** Shape modifiers for {@link ReadModel.viewDdl} (as opposed to the SQL compile options for its body). */
180
+ export interface ViewDdlOptions {
181
+ /** Emit `CREATE TEMP VIEW` (SQLite TEMP schema) instead of a plain `CREATE VIEW`. */
182
+ readonly temp?: boolean;
183
+ }
184
+ /**
185
+ * Declare a derived read model ONCE, as a pure derivation over a base row plus named canonical
186
+ * projections. The returned {@link ReadModel} exposes both backends (SQL select-list + TS function)
187
+ * and the managed VIEW DDL, all driven from the same AST so surface #2 (double-authoring) is closed.
188
+ * Register it in the {@link readModelRegistry} to have the runtime apply its VIEW at boot (surface #3).
189
+ */
190
+ export declare function defineReadModel(decl: ReadModelDecl): ReadModel;
191
+ /** Convenience alias for {@link ReadModel.viewDdl} — the framework-derived managed VIEW DDL. */
192
+ export declare function deriveReadModelViewDdl(model: ReadModel, options?: SqlCompileOptions): string;
193
+ /**
194
+ * The read-model registry (seam (a)). Sidecars/apps register a {@link ReadModel}; the runtime boot
195
+ * path applies every registered model's managed VIEW to the app's data source (see
196
+ * `core/modules/workers.ts`). Idempotent: re-registering the same name whose VIEW DDL matches is a
197
+ * no-op; a conflicting redefinition throws so two definitions cannot silently claim one name.
198
+ */
199
+ export declare class ReadModelRegistry {
200
+ #private;
201
+ register(model: ReadModel): void;
202
+ all(): ReadModel[];
203
+ get(name: string): ReadModel | undefined;
204
+ /** Apply every registered model's managed VIEW to a database (the boot-path entry point). Safe to
205
+ * call repeatedly and it is truly MANAGED: each VIEW is dropped and recreated so a changed
206
+ * read-model definition in code always replaces a stale VIEW body (a plain `CREATE VIEW IF NOT
207
+ * EXISTS` would leave the SQL backend running an old definition, reintroducing backend drift).
208
+ * Referenced base/projection tables need not exist yet — SQLite only resolves a VIEW's body when
209
+ * it is queried.
210
+ *
211
+ * The DROP is `main.`-qualified on purpose: an unqualified `DROP VIEW IF EXISTS "name"` resolves
212
+ * TEMP first, so a stray TEMP view of the same name (e.g. leaked from a parity-guard run on a
213
+ * long-lived handle) would be dropped INSTEAD of the managed `main` view — and the following
214
+ * `CREATE VIEW IF NOT EXISTS` would then no-op against the surviving `main` view, leaving a stale
215
+ * managed body in production. Qualifying to `main` drops exactly the managed view we recreate. */
216
+ ensureViews(db: {
217
+ exec(sql: string): void;
218
+ }, options?: SqlCompileOptions): void;
219
+ clear(): void;
220
+ }
221
+ /**
222
+ * Process-wide read-model registry — the concrete registration point. The `writer-source-inversion`
223
+ * task registers its derived status-edge read model here so the runtime provisions its VIEW at boot.
224
+ */
225
+ export declare const readModelRegistry: ReadModelRegistry;
226
+ /** A single parity check case: a base row plus the projection rows in scope for it. */
227
+ export interface ParitySample {
228
+ readonly baseRow: BaseRow;
229
+ readonly projections?: ProjectionRows;
230
+ }
231
+ /** The seam a parity guard needs to run the SQL backend: a SQLite handle it can create/query/drop. */
232
+ export interface ParityDb {
233
+ exec(sql: string): void;
234
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
235
+ run(sql: string, params?: unknown[]): {
236
+ changes: number;
237
+ lastInsertRowid: number | bigint;
238
+ };
239
+ }
240
+ /** Options for {@link assertReadModelParity}. */
241
+ export interface ParityOptions {
242
+ /** Only check these derived columns (defaults to all). */
243
+ readonly columns?: string[];
244
+ /** A failure sink; defaults to throwing an `Error`. */
245
+ readonly onMismatch?: (message: string) => never;
246
+ /**
247
+ * The SAME SQL compile options used to provision the runtime VIEW (e.g. a custom
248
+ * `resolveProjectionTable` or `baseAlias`). Threaded through so the guard builds fixtures and
249
+ * materialises the VIEW against the same physical projection tables the runtime uses — otherwise a
250
+ * caller with a custom resolver would test a different VIEW than production and get false mismatches.
251
+ */
252
+ readonly sql?: SqlCompileOptions;
253
+ }
254
+ /**
255
+ * The framework PARITY GUARD (surface #3). Given a compiled read model and sample base+projection
256
+ * rows, it materialises the managed VIEW over throwaway in-memory tables, reads the SQL-derived
257
+ * value for each column, computes the TS-function value for the same inputs, and asserts they agree —
258
+ * so an app no longer hand-writes a bespoke parity test per projection. Throws (or calls
259
+ * `onMismatch`) on the first divergence, naming the column, sample index, and both values.
260
+ *
261
+ * The caller supplies a SQLite handle (e.g. an in-memory DB from the node/deno host). The guard
262
+ * builds minimal fixture tables for the base table and each referenced projection from the sample
263
+ * rows' own keys — entirely in SQLite's TEMP schema (`CREATE TEMP TABLE`/`CREATE TEMP VIEW`, dropped
264
+ * `temp.`-qualified) — so it needs no pre-existing schema AND can never clobber real application
265
+ * tables/views the caller's DB happens to hold under the same names.
266
+ */
267
+ export declare function assertReadModelParity(model: ReadModel, db: ParityDb, samples: ParitySample[], options?: ParityOptions): void;