@nanobpm/urban 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/modules/dataops.d.ts +28 -4
- package/dist/runtime/core/modules/dataops.js +123 -5
- package/dist/runtime/core/modules/pages.js +108 -25
- package/dist/toolkit/derivers/domain.d.ts +18 -0
- package/dist/toolkit/derivers/domain.js +134 -0
- package/dist/toolkit/derivers/messages.d.ts +14 -0
- package/dist/toolkit/derivers/messages.js +37 -0
- package/dist/toolkit/derivers/shapes.d.ts +96 -0
- package/dist/toolkit/derivers/shapes.js +498 -0
- package/dist/toolkit/derivers/worker-io.d.ts +26 -0
- package/dist/toolkit/derivers/worker-io.js +101 -1
- package/dist/toolkit/index.d.ts +6 -4
- package/dist/toolkit/index.js +4 -3
- package/package.json +2 -2
- package/src/runtime/core/modules/dataops.test.ts +132 -9
- package/src/runtime/core/modules/dataops.ts +180 -11
- package/src/runtime/core/modules/pages.test.ts +42 -0
- package/src/runtime/core/modules/pages.ts +108 -25
- package/src/toolkit/derivers/bindings-parity.test.ts +66 -0
- package/src/toolkit/derivers/domain.ts +171 -0
- package/src/toolkit/derivers/messages.ts +38 -0
- package/src/toolkit/derivers/shapes.test.ts +171 -0
- package/src/toolkit/derivers/shapes.ts +602 -0
- package/src/toolkit/derivers/worker-io.ts +103 -1
- package/src/toolkit/index.ts +24 -0
|
@@ -14,6 +14,9 @@ export function byModelPath(a, b) {
|
|
|
14
14
|
}
|
|
15
15
|
/** Filename the console uses; kept identical so the artifact is a drop-in. */
|
|
16
16
|
export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
|
|
17
|
+
/** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
|
|
18
|
+
* `defineWorker` with a taskType-keyed typed signature. */
|
|
19
|
+
export const WORKER_BINDINGS_TS = "workers.ts";
|
|
17
20
|
/** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
|
|
18
21
|
export const DOMAIN_DTS = "domain-rows.d.ts";
|
|
19
22
|
const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
|
|
@@ -65,6 +68,22 @@ export function scanModelWorkers(xml) {
|
|
|
65
68
|
export function typeRefFor(id, declared) {
|
|
66
69
|
return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
|
|
67
70
|
}
|
|
71
|
+
/** The TS type expression for a worker's declared custom-header keys: an object type mapping each
|
|
72
|
+
* declared key to `string` (Zeebe headers are strings on the wire) plus a `string` index signature
|
|
73
|
+
* so undeclared headers stay accessible with the same honest wire type. Returns `undefined` when no
|
|
74
|
+
* keys are declared (the caller omits the entry so the taskType falls back to `WorkerHdrs`). */
|
|
75
|
+
function headerRefFor(keys) {
|
|
76
|
+
const clean = [
|
|
77
|
+
...new Set((keys ?? [])
|
|
78
|
+
.filter((k) => typeof k === "string")
|
|
79
|
+
.map((k) => k.trim())
|
|
80
|
+
.filter((k) => k.length > 0)),
|
|
81
|
+
];
|
|
82
|
+
if (clean.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const fields = clean.map((k) => `${JSON.stringify(k)}: string`).join("; ");
|
|
85
|
+
return `{ ${fields}; [key: string]: string }`;
|
|
86
|
+
}
|
|
68
87
|
/** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
|
|
69
88
|
export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
70
89
|
const declared = new Set(declaredTypeIds);
|
|
@@ -79,6 +98,7 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
79
98
|
: "string";
|
|
80
99
|
const inputs = [];
|
|
81
100
|
const outputs = [];
|
|
101
|
+
const headers = [];
|
|
82
102
|
for (const w of workers) {
|
|
83
103
|
if (typeof w?.taskType !== "string" || w.taskType.length === 0)
|
|
84
104
|
continue;
|
|
@@ -88,6 +108,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
88
108
|
const outRef = typeRefFor(w.outputType, declared);
|
|
89
109
|
if (outRef)
|
|
90
110
|
outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
|
|
111
|
+
const hdrRef = headerRefFor(w.headerKeys);
|
|
112
|
+
if (hdrRef)
|
|
113
|
+
headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
|
|
91
114
|
}
|
|
92
115
|
const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
|
|
93
116
|
"// The bridge from the process model to the worker type system: each declared\n" +
|
|
@@ -105,7 +128,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
105
128
|
const outputsIface = outputs.length > 0
|
|
106
129
|
? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
|
|
107
130
|
: `export interface WorkerOutputs {}\n`;
|
|
108
|
-
const headersIface =
|
|
131
|
+
const headersIface = headers.length > 0
|
|
132
|
+
? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
|
|
133
|
+
: `export interface WorkerHeaders {}\n`;
|
|
109
134
|
return `${header}\n` +
|
|
110
135
|
importTypes +
|
|
111
136
|
`\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
|
|
@@ -144,3 +169,78 @@ export const workerIoDeriver = {
|
|
|
144
169
|
describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
|
|
145
170
|
derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
|
|
146
171
|
};
|
|
172
|
+
/**
|
|
173
|
+
* Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
|
|
174
|
+
* for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
|
|
175
|
+
* `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
|
|
176
|
+
* did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
|
|
177
|
+
* preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
|
|
178
|
+
* console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
|
|
179
|
+
*/
|
|
180
|
+
export function overlayDerivedWorkerIo(manifest, derived) {
|
|
181
|
+
const byType = new Map();
|
|
182
|
+
for (const w of manifest) {
|
|
183
|
+
if (typeof w.taskType === "string")
|
|
184
|
+
byType.set(w.taskType, { ...w });
|
|
185
|
+
}
|
|
186
|
+
for (const d of derived) {
|
|
187
|
+
if (typeof d.taskType !== "string")
|
|
188
|
+
continue;
|
|
189
|
+
const existing = byType.get(d.taskType);
|
|
190
|
+
const merged = existing
|
|
191
|
+
? { ...existing, taskType: d.taskType }
|
|
192
|
+
: { taskType: d.taskType };
|
|
193
|
+
if (d.inputType)
|
|
194
|
+
merged.inputType = d.inputType;
|
|
195
|
+
else
|
|
196
|
+
delete merged.inputType;
|
|
197
|
+
if (d.outputType)
|
|
198
|
+
merged.outputType = d.outputType;
|
|
199
|
+
else
|
|
200
|
+
delete merged.outputType;
|
|
201
|
+
// Header keys are authored on the model, so the derived set is authoritative: replace (not
|
|
202
|
+
// union) the manifest's, and clear when the model declares none.
|
|
203
|
+
if (d.headerKeys && d.headerKeys.length > 0)
|
|
204
|
+
merged.headerKeys = [...d.headerKeys];
|
|
205
|
+
else
|
|
206
|
+
delete merged.headerKeys;
|
|
207
|
+
byType.set(d.taskType, merged);
|
|
208
|
+
}
|
|
209
|
+
return [...byType.values()];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
|
|
213
|
+
* overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
|
|
214
|
+
* type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
|
|
215
|
+
* `job.variables` and result are typed from the declared domain type; otherwise they fall back to
|
|
216
|
+
* `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
|
|
217
|
+
* never changes with the schema, so it is written verbatim (unlike the regenerated
|
|
218
|
+
* `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
|
|
219
|
+
*/
|
|
220
|
+
export function emitWorkerBindingsRuntime() {
|
|
221
|
+
return "// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\n" +
|
|
222
|
+
"// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
|
|
223
|
+
"// typed signature (job.variables, job.customHeaders + result typed from the\n" +
|
|
224
|
+
"// worker's declared input/output/header contract). Erased to a pass-through at\n" +
|
|
225
|
+
"// runtime. Do not edit.\n" +
|
|
226
|
+
"// eslint-disable\n\n" +
|
|
227
|
+
`import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
|
|
228
|
+
`import type { WorkerOptions } from "./worker-sdk.ts";\n` +
|
|
229
|
+
`import type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from "./${WORKER_BINDINGS_DTS}";\n\n` +
|
|
230
|
+
`export * from "./worker-sdk.ts";\n\n` +
|
|
231
|
+
`type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
|
|
232
|
+
`type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
|
|
233
|
+
`type HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\n\n` +
|
|
234
|
+
`/**\n` +
|
|
235
|
+
` * Typed \`defineWorker\`: \`type\` is constrained to the model's declared job types\n` +
|
|
236
|
+
` * (\`WorkerTaskType\`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\n` +
|
|
237
|
+
` * and the handler's \`job.variables\`, \`job.customHeaders\` + result are typed from\n` +
|
|
238
|
+
` * the worker's declared \`inputType\`/\`outputType\`/header keys. A declared job type\n` +
|
|
239
|
+
` * with no declared type falls back to WorkerVars / WorkerHdrs.\n` +
|
|
240
|
+
` */\n` +
|
|
241
|
+
`export function defineWorker<K extends WorkerTaskType>(\n` +
|
|
242
|
+
` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
|
|
243
|
+
`): void {\n` +
|
|
244
|
+
` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
|
|
245
|
+
`}\n`;
|
|
246
|
+
}
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -2,14 +2,16 @@ export type { DerivedArtifact, Deriver } from "./artifact.ts";
|
|
|
2
2
|
export { GENERATED_DIR, sortArtifacts } from "./artifact.ts";
|
|
3
3
|
export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, tableSchemaForType, groupTypesBySource, } from "./derivers/migrations.ts";
|
|
4
4
|
export type { ToolkitManifest, ToolkitType, ToolkitField, DerivedColumn, DerivedTable, } from "./derivers/migrations.ts";
|
|
5
|
-
export { deriveDomain, emitDomain, emitDomainModel, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, } from "./derivers/domain.ts";
|
|
6
|
-
export type { SourceSchema, DomainFieldDef, DomainTypeDef, DomainTypeRegistry, } from "./derivers/domain.ts";
|
|
7
|
-
export {
|
|
5
|
+
export { deriveDomain, emitDomain, emitDomainModel, emitDomainBindings, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, isPrimitiveKeyword, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, DOMAIN_BINDINGS, } from "./derivers/domain.ts";
|
|
6
|
+
export type { SourceSchema, DomainFieldDef, DomainTypeDef, DomainTypeRegistry, ColumnMeta, TableMeta, } from "./derivers/domain.ts";
|
|
7
|
+
export { resolveShapes, columnToField, emitDomainModelJson, DOMAIN_MODEL_JSON, } from "./derivers/shapes.ts";
|
|
8
|
+
export type { ShapeOp, ShapeDecl, ShapeDiagnostic, ShapeResolution, FusedMetaDecl, } from "./derivers/shapes.ts";
|
|
9
|
+
export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, DOMAIN_DTS, } from "./derivers/worker-io.ts";
|
|
8
10
|
export type { ModelSource, WorkerIo, WorkerBindingDecl } from "./derivers/worker-io.ts";
|
|
9
11
|
export { byModelPath, typeRefFor } from "./derivers/worker-io.ts";
|
|
10
12
|
export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.ts";
|
|
11
13
|
export type { MetaDecl } from "./derivers/meta.ts";
|
|
12
|
-
export { deriveMessageBindings, emitMessageBindings, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, } from "./derivers/messages.ts";
|
|
14
|
+
export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.ts";
|
|
13
15
|
export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
|
|
14
16
|
export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<{
|
|
15
17
|
models: import("./derivers/worker-io.ts").ModelSource[];
|
package/dist/toolkit/index.js
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
export { GENERATED_DIR, sortArtifacts } from "./artifact.js";
|
|
6
6
|
// Derivers
|
|
7
7
|
export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, tableSchemaForType, groupTypesBySource, } from "./derivers/migrations.js";
|
|
8
|
-
export { deriveDomain, emitDomain, emitDomainModel, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, } from "./derivers/domain.js";
|
|
9
|
-
export {
|
|
8
|
+
export { deriveDomain, emitDomain, emitDomainModel, emitDomainBindings, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, isPrimitiveKeyword, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, DOMAIN_BINDINGS, } from "./derivers/domain.js";
|
|
9
|
+
export { resolveShapes, columnToField, emitDomainModelJson, DOMAIN_MODEL_JSON, } from "./derivers/shapes.js";
|
|
10
|
+
export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, DOMAIN_DTS, } from "./derivers/worker-io.js";
|
|
10
11
|
export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
|
|
11
12
|
export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.js";
|
|
12
|
-
export { deriveMessageBindings, emitMessageBindings, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, } from "./derivers/messages.js";
|
|
13
|
+
export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.js";
|
|
13
14
|
// The registry of all derivers (for discovery / IDE migration).
|
|
14
15
|
import { migrationsDeriver } from "./derivers/migrations.js";
|
|
15
16
|
import { domainDeriver } from "./derivers/domain.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/
|
|
22
|
+
"url": "git+https://github.com/nanobpm/nano-ide.git",
|
|
23
23
|
"directory": "packages/urban"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
@@ -46,6 +46,11 @@ function expectRecords(value: unknown): Record<string, unknown>[] {
|
|
|
46
46
|
return value;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function expectString(value: unknown): string {
|
|
50
|
+
if (typeof value !== "string") throw new TypeError("expected a string");
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
test("sources lists declared datasources and the default", async () => {
|
|
50
55
|
const { dir, cleanup } = await fixture();
|
|
51
56
|
try {
|
|
@@ -225,15 +230,6 @@ test("migrations treats a missing migrations dir as an empty list", async () =>
|
|
|
225
230
|
}
|
|
226
231
|
});
|
|
227
232
|
|
|
228
|
-
test("domaintypes is not yet implemented and errors clearly", async () => {
|
|
229
|
-
const { dir, cleanup } = await fixture();
|
|
230
|
-
try {
|
|
231
|
-
await assert.rejects(run(dir, { op: "domaintypes" }), /not yet implemented/);
|
|
232
|
-
} finally {
|
|
233
|
-
await cleanup();
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
|
|
237
233
|
test("an unknown op and an unknown source error", async () => {
|
|
238
234
|
const { dir, cleanup } = await fixture();
|
|
239
235
|
try {
|
|
@@ -304,3 +300,130 @@ test("an absolute --manifest path is honoured as-is (not re-prefixed with root)"
|
|
|
304
300
|
await cleanup();
|
|
305
301
|
}
|
|
306
302
|
});
|
|
303
|
+
|
|
304
|
+
// --- domaintypes: live-schema reification + shape fuse (ADR 0029 §4/§6, ADR 0040 §9/§10) ---
|
|
305
|
+
//
|
|
306
|
+
// The op ported off the console's vendored data_cli (host dry-out nano-bpm#576). It migrates each
|
|
307
|
+
// sqlite source (write mode only), introspects the live schema, folds the manifest `types` + the
|
|
308
|
+
// model's composed shapes through the fuse, and RETURNS every artifact's content (the read-only
|
|
309
|
+
// host never writes files — the caller persists). These lock that contract.
|
|
310
|
+
|
|
311
|
+
test("domaintypes migrates, introspects the live schema, and returns every artifact", async () => {
|
|
312
|
+
const { dir, cleanup } = await fixture();
|
|
313
|
+
try {
|
|
314
|
+
const r = await run(dir, { op: "domaintypes" });
|
|
315
|
+
// Both migrations ran on the shared `_urban_migrations` ledger before introspection.
|
|
316
|
+
assert.deepEqual(r.migrated, { app: ["001_orders.sql", "002_customers.sql"] });
|
|
317
|
+
// Two tables reified from the live schema.
|
|
318
|
+
assert.equal(r.tables, 2);
|
|
319
|
+
// The domain `.d.ts` carries an interface per live table.
|
|
320
|
+
assert.equal(typeof r.text, "string");
|
|
321
|
+
assert.match(expectString(r.text), /export interface Orders\b/);
|
|
322
|
+
assert.match(expectString(r.text), /export interface Customers\b/);
|
|
323
|
+
// Persisting: every `*Path` is the app-relative nano-generated target.
|
|
324
|
+
assert.equal(r.path, "nano-generated/domain-rows.d.ts");
|
|
325
|
+
assert.equal(r.bindingsPath, "nano-generated/domain.ts");
|
|
326
|
+
assert.equal(r.workerBindingsPath, "nano-generated/worker-io.d.ts");
|
|
327
|
+
assert.equal(r.workerRuntimePath, "nano-generated/workers.ts");
|
|
328
|
+
assert.equal(r.messageBindingsPath, "nano-generated/message-io.d.ts");
|
|
329
|
+
assert.equal(r.messageRuntimePath, "nano-generated/messages.ts");
|
|
330
|
+
assert.equal(r.metaPath, "nano-generated/meta.ts");
|
|
331
|
+
assert.equal(r.domainModelPath, "nano-generated/domain.json");
|
|
332
|
+
// The Fused Domain Model is emitted and lists both live tables.
|
|
333
|
+
assert.equal(typeof r.domainModel, "string");
|
|
334
|
+
const model = JSON.parse(expectString(r.domainModel));
|
|
335
|
+
assert.deepEqual(
|
|
336
|
+
model.entities.map((e: { id: string }) => e.id).sort(),
|
|
337
|
+
["app.customers", "app.orders"],
|
|
338
|
+
);
|
|
339
|
+
// The static runtime wrappers are returned verbatim for the caller to write.
|
|
340
|
+
assert.equal(typeof r.workerRuntime, "string");
|
|
341
|
+
assert.equal(typeof r.messageRuntime, "string");
|
|
342
|
+
assert.deepEqual(r.shapeDiagnostics, []);
|
|
343
|
+
} finally {
|
|
344
|
+
await cleanup();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("domaintypes write:false is a side-effect-free preview (no migrate, null paths)", async () => {
|
|
349
|
+
const { dir, cleanup } = await fixture();
|
|
350
|
+
try {
|
|
351
|
+
const r = await run(dir, { op: "domaintypes", write: false });
|
|
352
|
+
// The preview never migrates — nothing applied, DB stays fresh.
|
|
353
|
+
assert.deepEqual(r.migrated, {});
|
|
354
|
+
// …and because this fixture's DB is fresh (unmigrated), introspecting the *current* live schema
|
|
355
|
+
// finds no tables. (An existing DB could legitimately report tables here; this count is scoped
|
|
356
|
+
// to the fresh fixture, not a general preview guarantee.)
|
|
357
|
+
assert.equal(r.tables, 0);
|
|
358
|
+
// Content is still returned (the composer preview needs `text`)…
|
|
359
|
+
assert.equal(typeof r.text, "string");
|
|
360
|
+
// …but every persistence target is null and the largest artifact is skipped.
|
|
361
|
+
assert.equal(r.path, null);
|
|
362
|
+
assert.equal(r.bindingsPath, null);
|
|
363
|
+
assert.equal(r.workerBindingsPath, null);
|
|
364
|
+
assert.equal(r.workerRuntimePath, null);
|
|
365
|
+
assert.equal(r.messageBindingsPath, null);
|
|
366
|
+
assert.equal(r.messageRuntimePath, null);
|
|
367
|
+
assert.equal(r.metaPath, null);
|
|
368
|
+
assert.equal(r.domainModelPath, null);
|
|
369
|
+
assert.equal(r.domainModel, null);
|
|
370
|
+
// A second preview did not leave a migrated DB behind for the next write.
|
|
371
|
+
const applied = await run(dir, { op: "migrations" });
|
|
372
|
+
assert.ok(expectRecords(applied.entries).every((e) => e.applied === false));
|
|
373
|
+
} finally {
|
|
374
|
+
await cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("domaintypes folds a composed shape into DomainTypes and reports its diagnostics", async () => {
|
|
379
|
+
const { dir, cleanup } = await fixture();
|
|
380
|
+
try {
|
|
381
|
+
const r = await run(dir, {
|
|
382
|
+
op: "domaintypes",
|
|
383
|
+
derivedShapes: [
|
|
384
|
+
{
|
|
385
|
+
id: "ReviewRound",
|
|
386
|
+
ops: [
|
|
387
|
+
{ op: "extend", name: "prUrl", type: "string" },
|
|
388
|
+
{ op: "extend", name: "approved", type: "boolean" },
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
// A broken shape: unresolved extend type → error diagnostic, omitted from the emit.
|
|
392
|
+
{ id: "Broken", ops: [{ op: "extend", name: "ref", type: "NoSuchType" }] },
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
// The good shape reifies into the `DomainTypes` registry surfaced by the domain `.d.ts`.
|
|
396
|
+
assert.match(expectString(r.text), /ReviewRound/);
|
|
397
|
+
assert.doesNotMatch(expectString(r.text), /Broken/);
|
|
398
|
+
// The broken shape's diagnostic is returned so the panel can surface it.
|
|
399
|
+
const diags = expectRecords(r.shapeDiagnostics);
|
|
400
|
+
assert.equal(diags.length, 1);
|
|
401
|
+
assert.equal(diags[0].shape, "Broken");
|
|
402
|
+
assert.equal(diags[0].severity, "error");
|
|
403
|
+
} finally {
|
|
404
|
+
await cleanup();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("domaintypes emits worker/message bindings from the injected model IO maps", async () => {
|
|
409
|
+
const { dir, cleanup } = await fixture();
|
|
410
|
+
try {
|
|
411
|
+
const r = await run(dir, {
|
|
412
|
+
op: "domaintypes",
|
|
413
|
+
derivedShapes: [
|
|
414
|
+
{ id: "Order", ops: [{ op: "extend", name: "total", type: "integer" }] },
|
|
415
|
+
],
|
|
416
|
+
derivedWorkers: [
|
|
417
|
+
{ taskType: "review", inputType: "Order", outputType: "Order", headerKeys: ["priority"] },
|
|
418
|
+
],
|
|
419
|
+
derivedMessages: [{ messageName: "approved", inputType: "Order" }],
|
|
420
|
+
});
|
|
421
|
+
// The worker binding names the task type and threads the declared header key.
|
|
422
|
+
assert.match(expectString(r.workerBindings), /review/);
|
|
423
|
+
assert.match(expectString(r.workerBindings), /priority/);
|
|
424
|
+
// The message binding names the message.
|
|
425
|
+
assert.match(expectString(r.messageBindings), /approved/);
|
|
426
|
+
} finally {
|
|
427
|
+
await cleanup();
|
|
428
|
+
}
|
|
429
|
+
});
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// op = "sources" | "schema" | "query" | "exec" | "script" | "migrations" | "migrate"
|
|
15
15
|
// | "domaintypes"
|
|
16
16
|
//
|
|
17
|
-
// `domaintypes` (live-schema reification + shape resolution)
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// `domaintypes` (live-schema reification + shape resolution) composes the domain reifier from the
|
|
18
|
+
// pure toolkit derivers (ported from the console's `domain_types.ts`, host dry-out #576); it never
|
|
19
|
+
// writes files (the host is read-only) — it returns every artifact's content and the caller
|
|
20
|
+
// persists it.
|
|
20
21
|
|
|
21
22
|
import type { HostContext, SqliteDb } from "../host.ts";
|
|
22
23
|
import { isRecord } from "../guards.ts";
|
|
@@ -25,11 +26,48 @@ import { loadManifest } from "../manifest.ts";
|
|
|
25
26
|
import { validateManifest } from "../validate.ts";
|
|
26
27
|
import { makeGateway, type DataSource as GatewayDataSource } from "./gateway.ts";
|
|
27
28
|
import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.ts";
|
|
29
|
+
// The `domaintypes` op composes the domain reifier from the pure toolkit derivers (single source of
|
|
30
|
+
// truth for the IDE's codegen, host dry-out nano-bpm#576). Imported directly from the deriver files
|
|
31
|
+
// (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free of `node:*`/Deno; the
|
|
32
|
+
// derivers' only core dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase.
|
|
33
|
+
import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
|
|
34
|
+
import {
|
|
35
|
+
DOMAIN_BINDINGS,
|
|
36
|
+
type DomainTypeDef,
|
|
37
|
+
type DomainTypeRegistry,
|
|
38
|
+
emitDomainBindings,
|
|
39
|
+
emitDomainModel,
|
|
40
|
+
registryFromManifest,
|
|
41
|
+
type SourceSchema,
|
|
42
|
+
} from "../../../toolkit/derivers/domain.ts";
|
|
43
|
+
import {
|
|
44
|
+
DOMAIN_MODEL_JSON,
|
|
45
|
+
emitDomainModelJson,
|
|
46
|
+
type FusedMetaDecl,
|
|
47
|
+
resolveShapes,
|
|
48
|
+
type ShapeDecl,
|
|
49
|
+
} from "../../../toolkit/derivers/shapes.ts";
|
|
50
|
+
import {
|
|
51
|
+
DOMAIN_DTS,
|
|
52
|
+
emitWorkerBindings,
|
|
53
|
+
emitWorkerBindingsRuntime,
|
|
54
|
+
overlayDerivedWorkerIo,
|
|
55
|
+
WORKER_BINDINGS_DTS,
|
|
56
|
+
WORKER_BINDINGS_TS,
|
|
57
|
+
type WorkerBindingDecl,
|
|
58
|
+
} from "../../../toolkit/derivers/worker-io.ts";
|
|
59
|
+
import {
|
|
60
|
+
emitMessageBindings,
|
|
61
|
+
emitMessageBindingsRuntime,
|
|
62
|
+
MESSAGE_BINDINGS_DTS,
|
|
63
|
+
MESSAGE_BINDINGS_TS,
|
|
64
|
+
type MessageBindingDecl,
|
|
65
|
+
} from "../../../toolkit/derivers/messages.ts";
|
|
66
|
+
import { emitMeta, META_TS, type MetaDecl } from "../../../toolkit/derivers/meta.ts";
|
|
28
67
|
|
|
29
|
-
/** The DB-manager op protocol's known op set
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* type-safety and catches typos at compile time. */
|
|
68
|
+
/** The DB-manager op protocol's known op set — every op (including `domaintypes`) is dispatched by
|
|
69
|
+
* `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers type-safety
|
|
70
|
+
* and catches typos at compile time. */
|
|
33
71
|
export type DataOp =
|
|
34
72
|
| "sources"
|
|
35
73
|
| "schema"
|
|
@@ -46,6 +84,27 @@ export interface DataRequest {
|
|
|
46
84
|
sql?: string;
|
|
47
85
|
params?: unknown[];
|
|
48
86
|
statements?: string[];
|
|
87
|
+
/**
|
|
88
|
+
* `domaintypes`: persist the derived artifacts (default true). A `write:false` request is the
|
|
89
|
+
* latency-sensitive composer preview — it introspects WITHOUT migrating the DB first and returns
|
|
90
|
+
* only the artifact *content* (never a DB side-effect per keystroke). `urban data` never writes
|
|
91
|
+
* files itself (the host is read-only); the caller persists the returned content when `write` is
|
|
92
|
+
* not false. This flag only gates the pre-introspection migration side-effect.
|
|
93
|
+
*/
|
|
94
|
+
write?: boolean;
|
|
95
|
+
/** `domaintypes`: the model-derived worker-IO map (`taskType → {in,out,headerKeys}`), scanned
|
|
96
|
+
* from the process models by the caller. Overlaid on the manifest `workers[]` (authoritative for
|
|
97
|
+
* the envelope). Absent → the manifest projection alone. */
|
|
98
|
+
derivedWorkers?: WorkerBindingDecl[];
|
|
99
|
+
/** `domaintypes`: the model-derived message-payload map (`messageName → {in,out}`), scanned from
|
|
100
|
+
* the models' `bpmn:message` envelopes. Authoritative (no manifest projection). */
|
|
101
|
+
derivedMessages?: MessageBindingDecl[];
|
|
102
|
+
/** `domaintypes`: the model-derived composed shapes (`nano:shape`). Each is resolved through the
|
|
103
|
+
* fuse into a flat domain type folded into the `types` registry. Absent → no shapes. */
|
|
104
|
+
derivedShapes?: ShapeDecl[];
|
|
105
|
+
/** `domaintypes`: the model-derived model-level metadata (`nano:meta`). Folded into `meta.ts` and
|
|
106
|
+
* the structured `domain.json`. Absent → no metadata. */
|
|
107
|
+
derivedMeta?: MetaDecl[];
|
|
49
108
|
}
|
|
50
109
|
|
|
51
110
|
/** One datasource the manifest declares, resolved against the environment (env templates in
|
|
@@ -281,10 +340,120 @@ export async function runDataOp(
|
|
|
281
340
|
}
|
|
282
341
|
}
|
|
283
342
|
case "domaintypes": {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
343
|
+
// ADR 0029 §4.1/§4.2/§6 + ADR 0040 §9/§10: reify the domain model from the *live* schema.
|
|
344
|
+
// Union every declared datasource (the table spine), fold in the manifest `types` registry and
|
|
345
|
+
// the model's composed `nano:shape`s (resolved through the fuse), and derive the worker/message
|
|
346
|
+
// typed accessors. Unlike the vendored data_cli, this op NEVER writes files (the host is
|
|
347
|
+
// read-only): it returns every artifact's content and the caller persists it. `write:false` is
|
|
348
|
+
// the latency-sensitive composer preview — it skips the pre-introspection migration so a
|
|
349
|
+
// debounced keystroke has no DB side-effect and pays only for `text` + `shapeDiagnostics`.
|
|
350
|
+
const { default: def, sources } = listSources(manifest);
|
|
351
|
+
const persist = req.write !== false;
|
|
352
|
+
// Introspect each sqlite source's live schema. When persisting, migrate first (the domain
|
|
353
|
+
// model is derived from `gw.schema()`, so a regen that raced ahead of `migrate` on a fresh DB
|
|
354
|
+
// would emit an empty `Domain`); the migration runs on the shared `_urban_migrations` ledger.
|
|
355
|
+
const migrated: Record<string, string[]> = {};
|
|
356
|
+
const schemas: SourceSchema[] = [];
|
|
357
|
+
for (const s of sources) {
|
|
358
|
+
if (String(s.driver) !== "sqlite") continue; // only sqlite is introspectable (see openGateway)
|
|
359
|
+
const { gw, db } = await openGateway(host, root, manifest, s.name);
|
|
360
|
+
try {
|
|
361
|
+
if (persist) {
|
|
362
|
+
const applied = await applyMigrations(
|
|
363
|
+
host,
|
|
364
|
+
db,
|
|
365
|
+
root,
|
|
366
|
+
s.migrations ?? DEFAULT_MIGRATIONS_DIR,
|
|
367
|
+
);
|
|
368
|
+
if (applied.length) migrated[s.name] = applied;
|
|
369
|
+
}
|
|
370
|
+
schemas.push({ source: s.name, tables: await gw.schema() });
|
|
371
|
+
} finally {
|
|
372
|
+
db.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const types = registryFromManifest(manifest);
|
|
377
|
+
// Snapshot the manifest `types` *before* folding the resolved shapes so `domain.json` can tag
|
|
378
|
+
// each entity with its true provenance (manifest vs model) — once folded they are
|
|
379
|
+
// indistinguishable in `types`.
|
|
380
|
+
const manifestTypesSnapshot: DomainTypeRegistry = { ...types };
|
|
381
|
+
// Composed motion shapes (ADR 0040 §9/§10): resolve each `nano:shape` against the leaf fuse
|
|
382
|
+
// into a flat domain type and fold it into the registry *before* it feeds the domain model and
|
|
383
|
+
// the worker/message bindings, so a composed shape becomes a first-class `DomainTypes` entry.
|
|
384
|
+
// Broken shapes are omitted; their diagnostics are returned so the panel can surface them.
|
|
385
|
+
const shapeResolution = resolveShapes(req.derivedShapes ?? [], types, schemas);
|
|
386
|
+
const shapeDeclById = new Map((req.derivedShapes ?? []).map((s) => [s.id, s]));
|
|
387
|
+
const shapeEntities: { decl: ShapeDecl; def: DomainTypeDef }[] = [];
|
|
388
|
+
for (const [id, shapeDef] of Object.entries(shapeResolution.types)) {
|
|
389
|
+
types[id] = shapeDef;
|
|
390
|
+
shapeEntities.push({ decl: shapeDeclById.get(id) ?? { id, ops: [] }, def: shapeDef });
|
|
391
|
+
}
|
|
392
|
+
const declaredIds = Object.keys(types);
|
|
393
|
+
|
|
394
|
+
const text = emitDomainModel(schemas, def, types);
|
|
395
|
+
const bindings = emitDomainBindings(schemas, def);
|
|
396
|
+
const derivedMeta: MetaDecl[] = req.derivedMeta ?? [];
|
|
397
|
+
const metaAccessor = emitMeta(derivedMeta);
|
|
398
|
+
// Worker-IO map (ADR 0033 §3): the model is authoritative for the envelope, so an injected
|
|
399
|
+
// `derivedWorkers` is overlaid on the manifest `workers[]` (which still carries non-IO
|
|
400
|
+
// bindings such as `llm`); absent, the manifest projection stands alone.
|
|
401
|
+
const manifestWorkers: WorkerBindingDecl[] = (manifest.workers ?? []).map((w) => ({
|
|
402
|
+
taskType: w.taskType,
|
|
403
|
+
inputType: w.inputType,
|
|
404
|
+
outputType: w.outputType,
|
|
405
|
+
}));
|
|
406
|
+
const workers = req.derivedWorkers
|
|
407
|
+
? overlayDerivedWorkerIo(manifestWorkers, req.derivedWorkers)
|
|
408
|
+
: manifestWorkers;
|
|
409
|
+
const workerBindings = emitWorkerBindings(workers, declaredIds);
|
|
410
|
+
const workerRuntime = emitWorkerBindingsRuntime();
|
|
411
|
+
// Message-payload registry (ADR 0040 slice 2): no manifest projection, so the model-derived
|
|
412
|
+
// map is authoritative and emitted directly.
|
|
413
|
+
const messages = req.derivedMessages ?? [];
|
|
414
|
+
const messageBindings = emitMessageBindings(messages, declaredIds);
|
|
415
|
+
const messageRuntime = emitMessageBindingsRuntime();
|
|
416
|
+
// The structured Fused Domain Model (`domain.json`, ADR 0040 §1): the largest artifact, so it
|
|
417
|
+
// is built only when persisting (the preview consumes only `text` + `shapeDiagnostics`).
|
|
418
|
+
const meta: FusedMetaDecl[] = derivedMeta;
|
|
419
|
+
const domainModel = persist
|
|
420
|
+
? emitDomainModelJson({
|
|
421
|
+
sources: schemas,
|
|
422
|
+
default: def,
|
|
423
|
+
manifestTypes: manifestTypesSnapshot,
|
|
424
|
+
shapes: shapeEntities,
|
|
425
|
+
meta,
|
|
426
|
+
diagnostics: shapeResolution.diagnostics,
|
|
427
|
+
})
|
|
428
|
+
: null;
|
|
429
|
+
|
|
430
|
+
const rel = (name: string): string => `${GENERATED_DIR}/${name}`;
|
|
431
|
+
const tables = schemas.reduce((n, s) => n + s.tables.length, 0);
|
|
432
|
+
// The returned `*Path` fields are the app-relative targets the caller writes to when
|
|
433
|
+
// persisting (null on a preview). `urban data` returns content only — persisting is the
|
|
434
|
+
// caller's job (host dry-out nano-bpm#576). The static runtime wrappers (`workers.ts`,
|
|
435
|
+
// `messages.ts`) are returned too so the caller can write them verbatim.
|
|
436
|
+
return {
|
|
437
|
+
path: persist ? rel(DOMAIN_DTS) : null,
|
|
438
|
+
text,
|
|
439
|
+
tables,
|
|
440
|
+
bindingsPath: persist ? rel(DOMAIN_BINDINGS) : null,
|
|
441
|
+
bindings,
|
|
442
|
+
workerBindingsPath: persist ? rel(WORKER_BINDINGS_DTS) : null,
|
|
443
|
+
workerBindings,
|
|
444
|
+
workerRuntimePath: persist ? rel(WORKER_BINDINGS_TS) : null,
|
|
445
|
+
workerRuntime,
|
|
446
|
+
messageBindingsPath: persist ? rel(MESSAGE_BINDINGS_DTS) : null,
|
|
447
|
+
messageBindings,
|
|
448
|
+
messageRuntimePath: persist ? rel(MESSAGE_BINDINGS_TS) : null,
|
|
449
|
+
messageRuntime,
|
|
450
|
+
metaPath: persist ? rel(META_TS) : null,
|
|
451
|
+
meta: metaAccessor,
|
|
452
|
+
domainModelPath: persist ? rel(DOMAIN_MODEL_JSON) : null,
|
|
453
|
+
domainModel,
|
|
454
|
+
migrated,
|
|
455
|
+
shapeDiagnostics: shapeResolution.diagnostics,
|
|
456
|
+
};
|
|
288
457
|
}
|
|
289
458
|
default:
|
|
290
459
|
throw new Error(`unknown op "${req.op}"`);
|
|
@@ -86,14 +86,56 @@ test("GET / serves the renderer shell with the home marker", async () => {
|
|
|
86
86
|
assert.equal(res.status, 200);
|
|
87
87
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
88
88
|
assert.match(res.body ?? "", /data-home="home"/);
|
|
89
|
+
// The runtime module is loaded by a *relative* src so it resolves against the
|
|
90
|
+
// document's mount path — works both at the origin root and under the Nano
|
|
91
|
+
// console's /console/app-view/<name>/ reverse proxy. A root-absolute
|
|
92
|
+
// "/app/runtime.js" would 404 against the console origin and never hydrate.
|
|
93
|
+
assert.match(res.body ?? "", /src="\.\/app\/runtime\.js"/);
|
|
94
|
+
assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the shell styles the app through the shared --nano-* token contract", async () => {
|
|
98
|
+
const res = await dispatch("GET", "/");
|
|
99
|
+
const html = res.body ?? "";
|
|
100
|
+
// Body colours resolve through the console's token vocabulary, not hardcoded hex.
|
|
101
|
+
assert.match(html, /background:var\(--nano-app\)/);
|
|
102
|
+
assert.match(html, /color:var\(--nano-text\)/);
|
|
103
|
+
assert.match(html, /--pc-accent:var\(--nano-accent\)/);
|
|
104
|
+
// Self-contained defaults for standalone (CLI) runs: dark base + a light
|
|
105
|
+
// palette gated on data-appearance, and prefers-color-scheme only until themed.
|
|
106
|
+
assert.match(html, /--nano-accent:#8b5cf6/);
|
|
107
|
+
assert.match(html, /\[data-appearance="light"\]/);
|
|
108
|
+
assert.match(html, /:root:not\(\[data-appearance\]\)/);
|
|
89
109
|
});
|
|
90
110
|
|
|
91
111
|
test("GET /app/runtime.js serves the renderer module", async () => {
|
|
92
112
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
93
113
|
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
94
114
|
assert.match(res.body ?? "", /pc:refresh/);
|
|
115
|
+
// Endpoints are rebased against the served module URL so the app works under a
|
|
116
|
+
// path-prefixed reverse proxy (Nano console embed), not just at the origin root.
|
|
117
|
+
assert.match(res.body ?? "", /new URL\("\.\.\/", import\.meta\.url\)/);
|
|
118
|
+
assert.match(res.body ?? "", /function apiUrl\(u\)/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the renderer bridges the console theme over postMessage", async () => {
|
|
122
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
123
|
+
const js = res.body ?? "";
|
|
124
|
+
// Announces readiness to the framing console (same-origin, posture A).
|
|
125
|
+
assert.match(js, /postMessage\(\{ type: "nano-app-ready" \}, window\.location\.origin\)/);
|
|
126
|
+
// Only trusts the framing parent, and only accepts nano-theme messages.
|
|
127
|
+
assert.match(js, /ev\.source === window\.parent/);
|
|
128
|
+
// Same-origin posture A: a cross-origin framer is rejected before applyTheme.
|
|
129
|
+
assert.match(js, /ev\.origin === window\.location\.origin/);
|
|
130
|
+
assert.match(js, /msg\.type !== "nano-theme"/);
|
|
131
|
+
// Mirrors the console appearance + lays --nano-* tokens onto :root; ignores
|
|
132
|
+
// any property outside the shared token namespace.
|
|
133
|
+
assert.match(js, /el\.dataset\.appearance = msg\.appearance/);
|
|
134
|
+
assert.match(js, /k\.startsWith\("--nano-"\)/);
|
|
135
|
+
assert.match(js, /el\.style\.setProperty\(k, v\)/);
|
|
95
136
|
});
|
|
96
137
|
|
|
138
|
+
|
|
97
139
|
test("renderer preserves grid row-detail expansion across refreshes", async () => {
|
|
98
140
|
// Regression: the 5s poll rebuilds the whole tbody, which used to collapse an open
|
|
99
141
|
// detail row (e.g. the escalation-answer form). The client now tracks open rowKeys
|