@nanobpm/urban 0.1.0 → 0.3.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.
Files changed (44) hide show
  1. package/README.md +93 -16
  2. package/package.json +16 -11
  3. package/src/cli.ts +6 -5
  4. package/src/index.ts +49 -0
  5. package/src/runtime/adapters/deno.ts +124 -0
  6. package/src/runtime/adapters/detect.ts +22 -0
  7. package/src/runtime/adapters/node.ts +119 -0
  8. package/src/runtime/core/context.ts +35 -0
  9. package/src/runtime/core/glob.test.ts +45 -0
  10. package/src/runtime/core/glob.ts +63 -0
  11. package/src/runtime/core/host.ts +130 -0
  12. package/src/runtime/core/manifest.test.ts +31 -0
  13. package/src/runtime/core/manifest.ts +112 -0
  14. package/src/runtime/core/modules/datasource.ts +220 -0
  15. package/src/runtime/core/modules/deploy.ts +41 -0
  16. package/src/runtime/core/modules/security.ts +53 -0
  17. package/src/runtime/core/modules/surfaces.test.ts +42 -0
  18. package/src/runtime/core/modules/surfaces.ts +103 -0
  19. package/src/runtime/core/modules/triggers.ts +143 -0
  20. package/src/runtime/core/modules/units.test.ts +22 -0
  21. package/src/runtime/core/modules/workers.ts +96 -0
  22. package/src/runtime/core/purity.test.ts +31 -0
  23. package/src/runtime/core/router.test.ts +16 -0
  24. package/src/runtime/core/router.ts +55 -0
  25. package/src/runtime/core/runtime.test.ts +258 -0
  26. package/src/runtime/core/runtime.ts +213 -0
  27. package/src/runtime/core/validate.test.ts +73 -0
  28. package/src/runtime/core/validate.ts +129 -0
  29. package/src/runtime/engine/nanosdk.test.ts +124 -0
  30. package/src/runtime/engine/nanosdk.ts +123 -0
  31. package/src/runtime/engine/rest.ts +243 -0
  32. package/src/runtime/index.ts +68 -0
  33. package/src/runtime/run.ts +91 -0
  34. package/src/runtime/schema/nano-app.schema.json +471 -0
  35. package/src/toolkit/artifact.ts +29 -0
  36. package/src/toolkit/derivers/migrations.test.ts +50 -0
  37. package/src/toolkit/derivers/migrations.ts +99 -0
  38. package/src/toolkit/derivers/worker-io.test.ts +75 -0
  39. package/src/toolkit/derivers/worker-io.ts +176 -0
  40. package/src/toolkit/fsio.test.ts +21 -0
  41. package/src/toolkit/fsio.ts +36 -0
  42. package/src/toolkit/gen.test.ts +85 -0
  43. package/src/toolkit/gen.ts +109 -0
  44. package/src/toolkit/index.ts +37 -0
@@ -0,0 +1,471 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://nanobpm.io/spec-app/nano-app.schema.json",
4
+ "title": "AppManifest",
5
+ "description": "Urban App manifest (nano.app.json) — the declared-data binding of an Urban RAD application (ADR 0027). Owns the envelope + cross-reference rules; each block's detail is owned by its ADR (data=0024, triggers=0025, surfaces=0026, security=0028, workers/llm=0022 §E). This file is the source of truth: TypeScript types are generated from it (scripts/generate-app-manifest.sh) and it doubles as the $schema editors use for nano.app.json autocompletion.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "id", "name"],
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string",
12
+ "description": "Optional editor hint pointing at this schema for autocompletion."
13
+ },
14
+ "schemaVersion": {
15
+ "const": 1,
16
+ "description": "Manifest schema version for forward-compat. Currently always 1."
17
+ },
18
+ "id": {
19
+ "$ref": "#/$defs/slug",
20
+ "description": "Stable App identifier (slug). Required."
21
+ },
22
+ "name": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "description": "Human-readable App name. Required."
26
+ },
27
+ "codename": {
28
+ "type": "string",
29
+ "description": "Informational codename, surfaced as App.CODENAME (ADR 0015). Optional."
30
+ },
31
+ "runtime": { "$ref": "#/$defs/runtime" },
32
+ "models": { "$ref": "#/$defs/models" },
33
+ "data": { "$ref": "#/$defs/data" },
34
+ "types": {
35
+ "type": "object",
36
+ "description": "The domain type registry (ADR 0029 §4, ADR 0031). Named record types keyed by a stable id — the *nominal* identity every reference resolves against. A type's fields project onto three shapes: form field (face), process variable (motion) and datasource row (rest); the Process-Relational Mapper (ADR 0031) generates the mapping. Types here are the transient/declared source; a datasource table is the other (ADR 0029 §4).",
37
+ "additionalProperties": { "$ref": "#/$defs/domainType" }
38
+ },
39
+ "triggers": {
40
+ "type": "array",
41
+ "items": { "$ref": "#/$defs/trigger" },
42
+ "description": "Event sources bound to engine actions (ADR 0025)."
43
+ },
44
+ "bindings": {
45
+ "type": "array",
46
+ "items": { "$ref": "#/$defs/binding" },
47
+ "description": "Declares the domain type in scope for a model's FEEL (ADR 0029 §5): a form's default-value expressions and a decision's input expressions autocomplete + validate against the bound type's fields. The same 'typed reference replaces a free-string id' move as trigger.bodyType, applied to forms and decisions."
48
+ },
49
+ "connections": {
50
+ "type": "object",
51
+ "description": "Named connections (credentials/endpoint) referenced by triggers/workers by id, so configs carry no inline secrets (ADR 0025 §1).",
52
+ "additionalProperties": { "$ref": "#/$defs/connection" }
53
+ },
54
+ "surfaces": { "$ref": "#/$defs/surfaces" },
55
+ "workers": {
56
+ "type": "array",
57
+ "items": { "$ref": "#/$defs/worker" },
58
+ "description": "Service-task handlers: referenced files or an llm binding (ADR 0022 §E)."
59
+ },
60
+ "llm": {
61
+ "type": "object",
62
+ "description": "Named LLM bindings usable as workers or as a chat surface agent (ADR 0022 §E).",
63
+ "additionalProperties": { "$ref": "#/$defs/llmBinding" }
64
+ },
65
+ "security": { "$ref": "#/$defs/security" }
66
+ },
67
+ "$defs": {
68
+ "slug": {
69
+ "type": "string",
70
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
71
+ "description": "Lowercase kebab-case slug."
72
+ },
73
+ "domainType": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "required": ["fields"],
77
+ "description": "A named domain record type (ADR 0029 §4, ADR 0031). Its map key is the stable id; matching is nominal (by id), consistent with model reference pickers.",
78
+ "properties": {
79
+ "name": {
80
+ "type": "string",
81
+ "description": "Human-readable label. The map key remains the stable id every reference uses."
82
+ },
83
+ "match": {
84
+ "enum": ["nominal", "structural"],
85
+ "default": "nominal",
86
+ "description": "Identity discipline. `nominal` (default): references resolve by this type's id. `structural` is a reserved escape hatch (match by field shape) — declared here but not yet honoured by the validator/mapper."
87
+ },
88
+ "table": {
89
+ "$ref": "#/$defs/slug",
90
+ "description": "Optional datasource table this type binds to as its rest projection (ADR 0031 rest bank). Absent = transient / non-persisted (ADR 0029 §4.2). Table existence is validated once the datasource schema() runtime (ADR 0024) lands; the shape is checked now."
91
+ },
92
+ "fields": {
93
+ "type": "object",
94
+ "minProperties": 1,
95
+ "additionalProperties": { "$ref": "#/$defs/domainField" },
96
+ "description": "Field name → field definition. Field names are the keys the form field, the variable path and the datasource column share (ADR 0029 §4)."
97
+ }
98
+ }
99
+ },
100
+ "domainField": {
101
+ "type": "object",
102
+ "additionalProperties": false,
103
+ "required": ["type"],
104
+ "description": "A single field of a domain type.",
105
+ "properties": {
106
+ "type": {
107
+ "description": "A primitive type, or the id of another domain type in the registry (nominal reference). Primitive ids take precedence over an identically named type.",
108
+ "anyOf": [
109
+ { "enum": ["string", "number", "integer", "boolean", "date", "datetime", "json"] },
110
+ { "$ref": "#/$defs/slug" }
111
+ ]
112
+ },
113
+ "optional": {
114
+ "type": "boolean",
115
+ "default": false,
116
+ "description": "Whether the field may be absent."
117
+ },
118
+ "list": {
119
+ "type": "boolean",
120
+ "default": false,
121
+ "description": "Whether the field is a list of `type` rather than a single value."
122
+ }
123
+ }
124
+ },
125
+ "envTemplate": {
126
+ "type": "string",
127
+ "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$",
128
+ "description": "A ${VAR} or ${VAR:-default} boot-time substitution reference (ADR 0027 §5). Resolved at App boot / IDE Run, never persisted. The validator checks the reference shape, not the resolved value."
129
+ },
130
+ "feel": {
131
+ "type": "string",
132
+ "description": "A FEEL expression (conventionally prefixed with '='), evaluated over the trigger event body."
133
+ },
134
+ "runtime": {
135
+ "type": "object",
136
+ "additionalProperties": false,
137
+ "description": "The shipping topology of the compiled App (ADR 0005). Distinct from the IDE dev-loop deployTarget, which lives in nanobpm.project.json (ADR 0027 §1).",
138
+ "properties": {
139
+ "engine": {
140
+ "enum": ["embedded", "remote", "cluster"],
141
+ "default": "embedded",
142
+ "description": "How the App reaches the engine at runtime."
143
+ },
144
+ "node": {
145
+ "enum": ["single", "cluster"],
146
+ "default": "single"
147
+ }
148
+ }
149
+ },
150
+ "models": {
151
+ "type": "object",
152
+ "additionalProperties": false,
153
+ "description": "Glob references to the models the editors produce (ADR 0027 §2). Each glob must resolve to at least one file (cross-reference rule, ADR 0027 §4).",
154
+ "properties": {
155
+ "processes": { "$ref": "#/$defs/globList", "description": "BPMN process globs." },
156
+ "decisions": { "$ref": "#/$defs/globList", "description": "DMN decision globs." },
157
+ "forms": { "$ref": "#/$defs/globList", "description": "form-js form globs." }
158
+ }
159
+ },
160
+ "globList": {
161
+ "type": "array",
162
+ "items": { "type": "string", "minLength": 1 }
163
+ },
164
+ "data": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "required": ["sources"],
168
+ "description": "Named datasources — the BDE-alias abstraction (ADR 0024). Consumers bind by name, never by driver, so the same bundle runs on SQLite in the IDE and Postgres in production by flipping env only.",
169
+ "properties": {
170
+ "default": {
171
+ "type": "string",
172
+ "description": "Name of the datasource used when a consumer names none."
173
+ },
174
+ "sources": {
175
+ "type": "object",
176
+ "minProperties": 1,
177
+ "additionalProperties": { "$ref": "#/$defs/dataSource" }
178
+ }
179
+ }
180
+ },
181
+ "dataSource": {
182
+ "type": "object",
183
+ "additionalProperties": false,
184
+ "required": ["driver", "url"],
185
+ "properties": {
186
+ "driver": {
187
+ "description": "Driver id. May be an env template so deployment flips SQLite to Postgres without a source change (ADR 0024 §1).",
188
+ "anyOf": [
189
+ { "enum": ["sqlite", "postgres"] },
190
+ { "$ref": "#/$defs/envTemplate" }
191
+ ]
192
+ },
193
+ "url": {
194
+ "type": "string",
195
+ "description": "Connection URL, typically an env template (e.g. file:./app.db or ${NANO_APP_DB_URL:-file:./app.db})."
196
+ },
197
+ "migrations": {
198
+ "type": "string",
199
+ "description": "Path to a migrations directory."
200
+ }
201
+ }
202
+ },
203
+ "trigger": {
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "required": ["id", "type", "action"],
207
+ "properties": {
208
+ "id": { "$ref": "#/$defs/slug" },
209
+ "type": {
210
+ "type": "string",
211
+ "description": "Source kind. Core (in-binary): cron | webhook | file. Pack sources add imap, mqtt, cloud, … (ADR 0025 §1)."
212
+ },
213
+ "spec": {
214
+ "type": "string",
215
+ "description": "cron: the crontab spec (e.g. '0 6 * * *'). 5 fields, evaluated in UTC (ADR 0025 §2)."
216
+ },
217
+ "onMissed": {
218
+ "type": "string",
219
+ "enum": ["skip", "once", "all"],
220
+ "default": "skip",
221
+ "description": "cron catch-up policy for fires missed while the App was down (ADR 0025 §Open questions): skip them, fire once for the whole span, or enqueue every missed instant (dedup keys keep it idempotent)."
222
+ },
223
+ "config": {
224
+ "type": "object",
225
+ "description": "Source-kind-specific settings. Core: file may set { pollMs }. Pack sources (nano-ide-trigger-*, ADR 0025 §6) read their declared config fields from here.",
226
+ "additionalProperties": true
227
+ },
228
+ "path": {
229
+ "type": "string",
230
+ "description": "webhook: the HTTP path served on the App backend (e.g. /hooks/temp)."
231
+ },
232
+ "connection": {
233
+ "type": "string",
234
+ "description": "Name of a connections[] entry supplying this source's credentials (e.g. imap mailbox)."
235
+ },
236
+ "auth": {
237
+ "type": "string",
238
+ "description": "Inbound auth policy for a webhook, e.g. 'hmac:sensors' referencing a connection (ADR 0025)."
239
+ },
240
+ "bodyType": {
241
+ "$ref": "#/$defs/slug",
242
+ "description": "Optional: id of a `types` registry domain type declaring this source's event body shape. It puts a type in scope for the action's FEEL expressions (variables/correlationKey), so `body.<field>` autocompletes and wrong paths become diagnostics (ADR 0029 §5)."
243
+ },
244
+ "action": { "$ref": "#/$defs/triggerAction" }
245
+ }
246
+ },
247
+ "triggerAction": {
248
+ "type": "object",
249
+ "additionalProperties": false,
250
+ "description": "Maps an event to exactly one engine call (ADR 0025 §1): start a process, or publish a CorrelateMessage.",
251
+ "oneOf": [
252
+ { "required": ["start"] },
253
+ { "required": ["message"] }
254
+ ],
255
+ "properties": {
256
+ "start": {
257
+ "type": "string",
258
+ "description": "Process id/name to start; variables seeded by a FEEL expression over the event body."
259
+ },
260
+ "variables": {
261
+ "$ref": "#/$defs/feel",
262
+ "description": "FEEL over the event body producing the started instance's variables."
263
+ },
264
+ "message": {
265
+ "type": "string",
266
+ "description": "messageName to publish as a CorrelateMessage (correlates to a message-start subscription to start a new instance, or to a running-instance catch to feed a token — ADR 0025 §5)."
267
+ },
268
+ "correlationKey": {
269
+ "$ref": "#/$defs/feel",
270
+ "description": "FEEL over the event body producing the correlationKey (message actions)."
271
+ }
272
+ }
273
+ },
274
+ "binding": {
275
+ "type": "object",
276
+ "additionalProperties": false,
277
+ "required": ["type"],
278
+ "oneOf": [{ "required": ["form"] }, { "required": ["decision"] }, { "required": ["process"] }],
279
+ "description": "Binds one model — a form, a decision, OR a process — to the domain type in scope for its FEEL (ADR 0029 §5, ADR 0030). Exactly one of form/decision/process.",
280
+ "properties": {
281
+ "form": {
282
+ "type": "string",
283
+ "description": "form-js form id (schema.id) whose default-value FEEL is scoped to `type`."
284
+ },
285
+ "decision": {
286
+ "type": "string",
287
+ "description": "DMN decision id whose input-expression FEEL is scoped to `type`."
288
+ },
289
+ "process": {
290
+ "type": "string",
291
+ "description": "BPMN process id whose FEEL (component/service-task inputs, conditions) is scoped to `type` — the process as the motion of a typed domain object (ADR 0030)."
292
+ },
293
+ "type": {
294
+ "$ref": "#/$defs/slug",
295
+ "description": "Declared domain type id in scope for the bound model's FEEL."
296
+ }
297
+ }
298
+ },
299
+ "connection": {
300
+ "type": "object",
301
+ "description": "A named connection (credentials/endpoint). Shape is source-specific; secrets should be env templates, never inline literals (ADR 0025 §1).",
302
+ "required": ["type"],
303
+ "properties": {
304
+ "type": {
305
+ "type": "string",
306
+ "description": "Connection kind (e.g. imap, mqtt, hmac)."
307
+ }
308
+ }
309
+ },
310
+ "surfaces": {
311
+ "type": "object",
312
+ "additionalProperties": false,
313
+ "description": "Batteries-included human surfaces generated from the manifest (ADR 0026).",
314
+ "properties": {
315
+ "taskInbox": { "$ref": "#/$defs/taskInboxSurface" },
316
+ "chat": { "$ref": "#/$defs/chatSurface" }
317
+ }
318
+ },
319
+ "taskInboxSurface": {
320
+ "type": "object",
321
+ "additionalProperties": false,
322
+ "description": "Generic task inbox: lists open user tasks and renders their .form to claim/complete (ADR 0026).",
323
+ "properties": {
324
+ "enabled": { "type": "boolean", "default": false },
325
+ "path": { "type": "string", "default": "/tasks" }
326
+ }
327
+ },
328
+ "chatSurface": {
329
+ "type": "object",
330
+ "additionalProperties": false,
331
+ "description": "Conversational surface whose LLM agent drives the action API via its tools (ADR 0026).",
332
+ "properties": {
333
+ "enabled": { "type": "boolean", "default": false },
334
+ "path": { "type": "string", "default": "/chat" },
335
+ "agent": {
336
+ "type": "string",
337
+ "description": "Name of an llm[] binding backing this chat (cross-reference rule, ADR 0027 §4)."
338
+ }
339
+ }
340
+ },
341
+ "worker": {
342
+ "type": "object",
343
+ "additionalProperties": false,
344
+ "required": ["taskType"],
345
+ "description": "A service-task worker: either a referenced handler file or an llm binding (ADR 0022 §E).",
346
+ "oneOf": [
347
+ { "required": ["handler"] },
348
+ { "required": ["llm"] }
349
+ ],
350
+ "properties": {
351
+ "taskType": { "type": "string", "minLength": 1 },
352
+ "handler": {
353
+ "type": "string",
354
+ "description": "Path to a handler file (language via ADR 0008 packs)."
355
+ },
356
+ "llm": {
357
+ "type": "string",
358
+ "description": "Name of an llm[] binding used as the worker (LLM-as-worker)."
359
+ },
360
+ "inputType": {
361
+ "$ref": "#/$defs/slug",
362
+ "description": "Declared domain type this worker reads from `job.variables` — the incoming process-variable payload (ADR 0033 §3). Binds to the worker via `taskType`; declaring `inputType` types the handler's `job.variables` while authoring (the generated typed `defineWorker` keys off `taskType`), erased at runtime."
363
+ },
364
+ "outputType": {
365
+ "$ref": "#/$defs/slug",
366
+ "description": "Declared domain type this worker writes as its result (ADR 0033 §3). A component's `zeebe:taskDefinition:type` binds to this worker via `taskType`; declaring `outputType` types the process variables its output mappings write, so downstream FEEL autocompletes on the result's fields."
367
+ }
368
+ }
369
+ },
370
+ "llmBinding": {
371
+ "type": "object",
372
+ "additionalProperties": false,
373
+ "required": ["provider", "model"],
374
+ "properties": {
375
+ "provider": {
376
+ "type": "string",
377
+ "description": "LLM provider selector (e.g. 'env' to resolve from environment)."
378
+ },
379
+ "model": {
380
+ "type": "string",
381
+ "description": "Model id, typically an env template (e.g. ${NANO_APP_LLM_MODEL})."
382
+ },
383
+ "output": {
384
+ "type": "object",
385
+ "additionalProperties": false,
386
+ "description": "Constrains the model's structured output (e.g. to a DMN decision).",
387
+ "properties": {
388
+ "decision": {
389
+ "type": "string",
390
+ "description": "DMN decision id constraining the output shape."
391
+ }
392
+ }
393
+ },
394
+ "tools": {
395
+ "type": "array",
396
+ "items": { "type": "string" },
397
+ "description": "Action-API tools the agent may call (e.g. start-process, complete-task, query-data)."
398
+ }
399
+ }
400
+ },
401
+ "security": {
402
+ "type": "object",
403
+ "additionalProperties": false,
404
+ "description": "App-user auth/identity/authorization policy (ADR 0028). Default (block absent) is single-user, unsecured. Secrets are env templates resolved at boot, never persisted.",
405
+ "properties": {
406
+ "mode": {
407
+ "description": "Enabled auth tier(s): none (default), local (username/password), oidc (social). A list combines them.",
408
+ "anyOf": [
409
+ { "$ref": "#/$defs/securityMode" },
410
+ { "type": "array", "items": { "$ref": "#/$defs/securityMode" } }
411
+ ]
412
+ },
413
+ "providers": {
414
+ "type": "array",
415
+ "items": { "$ref": "#/$defs/securityProvider" }
416
+ },
417
+ "roles": {
418
+ "type": "array",
419
+ "items": { "type": "string" },
420
+ "description": "Role names; the maker may add domain roles beyond admin/user."
421
+ },
422
+ "rules": {
423
+ "type": "object",
424
+ "additionalProperties": false,
425
+ "description": "Role-based authorization: which roles may reach each action/surface/datasource (ADR 0028).",
426
+ "properties": {
427
+ "actions": { "$ref": "#/$defs/roleMap" },
428
+ "surfaces": { "$ref": "#/$defs/roleMap" },
429
+ "data": {
430
+ "type": "object",
431
+ "additionalProperties": {
432
+ "type": "object",
433
+ "additionalProperties": { "type": "array", "items": { "type": "string" } }
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+ },
440
+ "securityMode": { "enum": ["none", "local", "oidc"] },
441
+ "securityProvider": {
442
+ "type": "object",
443
+ "required": ["id", "type"],
444
+ "properties": {
445
+ "id": { "type": "string", "minLength": 1 },
446
+ "type": {
447
+ "enum": ["oidc", "password"],
448
+ "description": "oidc: social/generic OIDC (Authorization Code + PKCE). password: local username/password."
449
+ },
450
+ "preset": {
451
+ "type": "string",
452
+ "description": "OIDC preset shorthand (e.g. google, github, auth0)."
453
+ },
454
+ "clientId": { "type": "string" },
455
+ "clientSecret": { "type": "string" },
456
+ "signup": {
457
+ "enum": ["open", "invite", "closed"],
458
+ "description": "password providers: self sign-up policy."
459
+ }
460
+ }
461
+ },
462
+ "roleMap": {
463
+ "type": "object",
464
+ "description": "Maps a pattern (e.g. 'start/*') to the list of roles permitted.",
465
+ "additionalProperties": {
466
+ "type": "array",
467
+ "items": { "type": "string" }
468
+ }
469
+ }
470
+ }
471
+ }
@@ -0,0 +1,29 @@
1
+ // The unit of derivation output: a file the derivers produce. Everything a deriver emits is a
2
+ // (path, content) pair. Derivers are pure — inputs in, artifacts out, no IO — so they are
3
+ // trivially testable and deterministic (the property that makes `urban gen --check` a reliable
4
+ // drift gate). IO (reading models, writing artifacts, comparing on --check) lives in `gen.ts`.
5
+
6
+ /** A single generated file. `path` is relative to the app root. */
7
+ export interface DerivedArtifact {
8
+ path: string;
9
+ content: string;
10
+ }
11
+
12
+ /**
13
+ * A deriver: a named, pure function from some input to artifacts. The IDE and the `urban gen`
14
+ * CLI are peer callers of the same derivers (ADR: derivation is a shared library).
15
+ */
16
+ export interface Deriver<I> {
17
+ readonly id: string;
18
+ readonly describe: string;
19
+ derive(input: I): DerivedArtifact[];
20
+ }
21
+
22
+ /** Where all derived output lands — the console's `nano-generated/` dir, so the toolkit is a
23
+ * drop-in for the IDE's own codegen (ADR 0053). One drift domain, gitignored, never committed. */
24
+ export const GENERATED_DIR = "nano-generated";
25
+
26
+ /** Stable ordering so artifact lists compare deterministically. */
27
+ export function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[] {
28
+ return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
29
+ }
@@ -0,0 +1,50 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTableSql, deriveMigrations, sqlType } from "./migrations.ts";
4
+
5
+ test("sqlType maps domain types to SQLite affinities", () => {
6
+ assert.equal(sqlType("string"), "TEXT");
7
+ assert.equal(sqlType("datetime"), "TEXT");
8
+ assert.equal(sqlType("int"), "INTEGER");
9
+ assert.equal(sqlType("boolean"), "INTEGER");
10
+ assert.equal(sqlType("number"), "REAL");
11
+ assert.equal(sqlType("blob"), "BLOB");
12
+ assert.equal(sqlType(undefined), "TEXT");
13
+ });
14
+
15
+ test("createTableSql emits id PK, NOT NULL by default, nullable when optional", () => {
16
+ const sql = createTableSql("greeting", {
17
+ table: "greetings",
18
+ fields: { who: { type: "string" }, when: { type: "datetime", optional: true } },
19
+ });
20
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS greetings/);
21
+ assert.match(sql, /id INTEGER PRIMARY KEY AUTOINCREMENT/);
22
+ assert.match(sql, /who TEXT NOT NULL/);
23
+ assert.match(sql, /when TEXT(?! NOT NULL)/);
24
+ });
25
+
26
+ test("createTableSql rejects a reserved 'id' field and non-identifier names", () => {
27
+ assert.throws(
28
+ () => createTableSql("t", { table: "ts", fields: { id: { type: "string" } } }),
29
+ /field "id" is reserved/,
30
+ );
31
+ assert.throws(
32
+ () => createTableSql("t", { table: "bad-name", fields: { x: { type: "string" } } }),
33
+ /invalid table name/,
34
+ );
35
+ });
36
+
37
+ test("deriveMigrations groups by datasource and is deterministic", () => {
38
+ const manifest = {
39
+ data: { default: "app" },
40
+ types: {
41
+ a: { table: "as", fields: { x: { type: "string" } } },
42
+ b: { table: "bs", fields: { y: { type: "int" } }, source: "audit" },
43
+ },
44
+ };
45
+ const a1 = deriveMigrations(manifest);
46
+ const a2 = deriveMigrations(manifest);
47
+ assert.deepEqual(a1, a2, "same manifest ⇒ identical artifacts");
48
+ const paths = a1.map((x) => x.path).sort();
49
+ assert.deepEqual(paths, ["nano-generated/app.schema.sql", "nano-generated/audit.schema.sql"]);
50
+ });
@@ -0,0 +1,99 @@
1
+ // Deriver: manifest `types` → SQL schema. Turns the declared domain model into a canonical
2
+ // CREATE TABLE script per datasource, so the schema is derived from the model instead of
3
+ // hand-maintained. Deterministic: same manifest ⇒ byte-identical SQL.
4
+
5
+ import type { DerivedArtifact, Deriver } from "../artifact.ts";
6
+ import { GENERATED_DIR } from "../artifact.ts";
7
+
8
+ export interface ToolkitField {
9
+ type?: string;
10
+ optional?: boolean;
11
+ }
12
+ export interface ToolkitType {
13
+ table?: string;
14
+ fields?: Record<string, ToolkitField>;
15
+ /** Optional datasource id; defaults to data.default. */
16
+ source?: string;
17
+ }
18
+ export interface ToolkitManifest {
19
+ id?: string;
20
+ data?: { default?: string; sources?: Record<string, unknown> };
21
+ types?: Record<string, ToolkitType>;
22
+ }
23
+
24
+ /** Map a domain field type to a SQLite column type. Unknown types fall back to TEXT. */
25
+ export function sqlType(t: string | undefined): string {
26
+ switch ((t ?? "string").toLowerCase()) {
27
+ case "int":
28
+ case "integer":
29
+ case "bool":
30
+ case "boolean":
31
+ return "INTEGER";
32
+ case "float":
33
+ case "double":
34
+ case "number":
35
+ return "REAL";
36
+ case "blob":
37
+ case "bytes":
38
+ return "BLOB";
39
+ default:
40
+ // string, datetime, date, time, json, uuid, enum, … all serialise as TEXT.
41
+ return "TEXT";
42
+ }
43
+ }
44
+
45
+ /** A safe unquoted SQL identifier. We interpolate table/column names directly, so
46
+ * reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
47
+ const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
48
+ export function assertSqlIdent(kind: string, name: string): string {
49
+ if (!SQL_IDENT.test(name)) {
50
+ throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
51
+ }
52
+ return name;
53
+ }
54
+
55
+ /** Build the CREATE TABLE statement for one type. */
56
+ export function createTableSql(typeName: string, def: ToolkitType): string {
57
+ const table = assertSqlIdent("table name", def.table ?? typeName);
58
+ const cols: string[] = [" id INTEGER PRIMARY KEY AUTOINCREMENT"];
59
+ const fields = def.fields ?? {};
60
+ for (const [name, f] of Object.entries(fields)) {
61
+ assertSqlIdent("field name", name);
62
+ if (name === "id") {
63
+ throw new Error(`type "${typeName}": field "id" is reserved (the table's implicit primary key)`);
64
+ }
65
+ const notNull = f.optional ? "" : " NOT NULL";
66
+ cols.push(` ${name} ${sqlType(f.type)}${notNull}`);
67
+ }
68
+ return `CREATE TABLE IF NOT EXISTS ${table} (\n${cols.join(",\n")}\n);`;
69
+ }
70
+
71
+ export function deriveMigrations(manifest: ToolkitManifest): DerivedArtifact[] {
72
+ const types = manifest.types ?? {};
73
+ const defaultSource = manifest.data?.default ?? "app";
74
+
75
+ // Group types by their datasource.
76
+ const bySource = new Map<string, Array<[string, ToolkitType]>>();
77
+ for (const [name, def] of Object.entries(types)) {
78
+ const src = def.source ?? defaultSource;
79
+ const list = bySource.get(src) ?? [];
80
+ list.push([name, def]);
81
+ bySource.set(src, list);
82
+ }
83
+
84
+ const artifacts: DerivedArtifact[] = [];
85
+ for (const [src, list] of bySource) {
86
+ const header =
87
+ `-- Derived from manifest \`types\` by @nanobpm/urban (urban gen). Do not edit.\n` +
88
+ `-- Datasource: ${src}\n\n`;
89
+ const body = list.map(([name, def]) => createTableSql(name, def)).join("\n\n");
90
+ artifacts.push({ path: `${GENERATED_DIR}/${src}.schema.sql`, content: `${header}${body}\n` });
91
+ }
92
+ return artifacts;
93
+ }
94
+
95
+ export const migrationsDeriver: Deriver<ToolkitManifest> = {
96
+ id: "types->migrations",
97
+ describe: "Derive SQL schema (CREATE TABLE) from manifest domain types.",
98
+ derive: deriveMigrations,
99
+ };