@aotter/mantle 0.2.0 → 0.2.1-alpha.2

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 (89) hide show
  1. package/README.md +6 -2
  2. package/dist/build/compiler.d.ts +6 -6
  3. package/dist/build/compiler.js +6 -3
  4. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  5. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  6. package/dist/build/spec/domain/model/SqlIr.d.ts +30 -0
  7. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  8. package/dist/domain/describe/chunk-CW7X5Q5T.js +1 -0
  9. package/dist/domain/describe/index.d.ts +1 -0
  10. package/dist/domain/describe/index.js +1 -1
  11. package/dist/domain/describe/match.d.ts +19 -0
  12. package/dist/domain/describe/public.d.ts +3 -0
  13. package/dist/domain/describe/public.js +1 -0
  14. package/dist/infrastructure/adminEntries.js +19 -3
  15. package/dist/infrastructure/bundle.js +43 -8
  16. package/dist/infrastructure/cli.js +1 -1
  17. package/dist/infrastructure/generate.js +17 -3
  18. package/dist/kernel/binds.d.ts +18 -3
  19. package/dist/kernel/binds.js +1 -1
  20. package/dist/kernel/contracts.d.ts +7 -0
  21. package/dist/kernel/contracts.js +9 -0
  22. package/dist/kernel/driver/bun-sqlite.js +3 -3
  23. package/dist/kernel/driver/d1.d.ts +1 -1
  24. package/dist/kernel/driver/d1.js +6 -3
  25. package/dist/kernel/driver/postgres.d.ts +2 -1
  26. package/dist/kernel/driver/postgres.js +11 -6
  27. package/dist/kernel/mcp-apps.d.ts +3 -2
  28. package/dist/kernel/mcp-apps.js +7 -2
  29. package/dist/kernel/mcp-handler.js +22 -9
  30. package/dist/kernel/mcp.d.ts +18 -0
  31. package/dist/kernel/mcp.js +1 -0
  32. package/dist/kernel/rest-router.js +3 -5
  33. package/dist/kernel/rest.d.ts +2 -1
  34. package/dist/kernel/rest.js +1 -0
  35. package/dist/kernel/route-pattern.d.ts +26 -0
  36. package/dist/kernel/route-pattern.js +34 -0
  37. package/dist/kernel/store.d.ts +15 -1
  38. package/dist/kernel/store.js +51 -18
  39. package/dist/usecase/adminBindings.js +2 -2
  40. package/dist/usecase/adminClient.js +4 -1
  41. package/dist/usecase/generate.js +7 -5
  42. package/dist/usecase/handlers.js +1 -1
  43. package/dist/usecase/publishing.js +20 -7
  44. package/dist/usecase/rest.js +5 -5
  45. package/dist/usecase/stores.js +8 -3
  46. package/docs/examples/README.md +1 -1
  47. package/docs/examples/app-shell/README.md +58 -10
  48. package/docs/examples/app-shell/package.json +6 -6
  49. package/docs/examples/club-events/manifests/club-events.yaml +2 -0
  50. package/docs/examples/generated-web/seed.sql +5 -4
  51. package/docs/examples/purchasing/manifests/purchasing.yaml +3 -0
  52. package/docs/handbook/cloudflare/media-r2.md +6 -3
  53. package/docs/handbook/cloudflare/service-entry.md +2 -0
  54. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  55. package/docs/handbook/examples/hub.md +1 -1
  56. package/docs/handbook/guides/admin-ui.md +1 -1
  57. package/docs/handbook/guides/agent-setup.md +13 -8
  58. package/docs/handbook/guides/agent-workflow-acceptance.md +129 -19
  59. package/docs/handbook/guides/local-testing.md +50 -5
  60. package/docs/handbook/guides/mcp-apps.md +40 -0
  61. package/docs/handbook/guides/native-admin.md +218 -11
  62. package/docs/handbook/guides/native-execution.md +5 -4
  63. package/docs/handbook/guides/revisions.md +584 -0
  64. package/docs/handbook/guides/size-and-startup.md +140 -0
  65. package/docs/handbook/guides/sql-patterns.md +47 -1
  66. package/docs/handbook/guides/typed-queries.md +8 -3
  67. package/docs/handbook/navigation.json +4 -0
  68. package/docs/handbook/reference/authorization.md +2 -0
  69. package/docs/handbook/reference/describe.md +56 -4
  70. package/docs/handbook/reference/diagnostics.md +12 -3
  71. package/docs/handbook/reference/procedure.md +4 -4
  72. package/docs/handbook/reference/schema.md +71 -7
  73. package/docs/handbook/reference/sql.md +2 -1
  74. package/docs/handbook/reference/surface.md +13 -2
  75. package/docs/handbook/reference/trigger.md +12 -0
  76. package/docs/handbook/releases/index.md +75 -1
  77. package/docs/handbook/start/agents.md +6 -2
  78. package/docs/handbook/start/project-and-cli.md +4 -0
  79. package/docs/handbook/start/quickstart-worker.md +1 -1
  80. package/docs/skills/develop/SKILL.md +15 -3
  81. package/docs/skills/integrate/SKILL.md +3 -1
  82. package/docs/skills/performance/SKILL.md +169 -0
  83. package/docs/skills/update/SKILL.md +9 -5
  84. package/docs/upgrade-0.1-to-0.2.md +151 -39
  85. package/package.json +10 -11
  86. package/skills/README.md +6 -2
  87. package/skills/mantle/SKILL.md +19 -5
  88. package/dist/domain/describe/chunk-SGMRVDGE.js +0 -1
  89. package/dist/domain/describe/target-hash.js +0 -1
@@ -7,3 +7,143 @@ Workers use `mantle bundle` to emit split ESM. Keep Better Auth behind `authLoad
7
7
  Contributor checks use tracked fixtures to compare runtime/startup graphs against `size-budgets.json` and inspect the published allowlist in `check:package`. Package packed/unpacked bytes are reported separately from request startup and query costs. Do not infer latency from bundle bytes.
8
8
 
9
9
  Cloudflare startup_time_ms needs an actual upload/deployment; offline dry runs do not measure it. Measure on the selected host and report first use and warm work separately. Applications own deployment authorization and resources.
10
+
11
+ ## Size metrics
12
+
13
+ Three metrics, one definition each. The [performance skill](../../skills/performance/SKILL.md)
14
+ and the SDK's own checks use these names. Measure the production build that
15
+ `mantle bundle` writes, never a dev server.
16
+
17
+ "gzip per file" means: compress each file, or each inline script, on its own with
18
+ Node's `zlib.gzipSync` at its default level, then add the results. A host may
19
+ serve Brotli or another level; that is a different number, so name it when you
20
+ report it.
21
+
22
+ | Metric | What counts | Unit |
23
+ |---|---|---|
24
+ | First-load JavaScript | Every script a page's HTML loads: `<script src>` files and inline `<script>` text, classic or `type="module"`, plus every file they import statically, recursively. A dynamic `import()` is not followed. CSS, fonts, images and scripts from another origin do not count. | gzip per file |
25
+ | MCP App HTML document | The whole built App document as served: its inline JavaScript, CSS and markup in one file. | raw bytes and gzip of the file |
26
+ | Worker entry static graph | The Worker entry (Wrangler's `main`, such as `dist/worker/worker.js`) plus every chunk it imports statically, recursively. A dynamic `import()` is not followed. | gzip per file; raw bytes beside it |
27
+
28
+ The 100 KB budget in the performance skill is first-load JavaScript. It applies to
29
+ an MCP App too: `mantle bundle` builds the App as one HTML file with its
30
+ JavaScript inline, so its first-load JavaScript is its inline script text. The
31
+ MCP App HTML document also holds CSS and markup, so do not compare the document
32
+ with that budget.
33
+
34
+ ### Measure
35
+
36
+ This script measures first-load JavaScript and the Worker entry static graph. It
37
+ follows static imports in the minified ESM that Vite and `mantle bundle` emit,
38
+ and reproduces the SDK checks' graphs below. Save it outside `src/`:
39
+
40
+ ```js
41
+ // first-load.mjs — node first-load.mjs <page.html | entry.js> [assets-directory]
42
+ import { readFileSync } from "node:fs";
43
+ import { dirname, join, resolve } from "node:path";
44
+ import { gzipSync } from "node:zlib";
45
+
46
+ const [target, root = dirname(target)] = process.argv.slice(2);
47
+ const seen = new Set();
48
+ let scripts = 0, raw = 0, gzip = 0;
49
+ // `import … from "./x.js"`, `import "./x.js"`, `export … from "./x.js"`; a dynamic `import("./x.js")` is not followed.
50
+ const imports = /(?:^|[;}\s])(?:import|export)\s*(?:[\w$*{},\s]*?from\s*)?["'](\.{1,2}\/[^"']+)["']/g;
51
+ function count(code, directory) {
52
+ scripts += 1;
53
+ raw += code.length;
54
+ gzip += gzipSync(code).length;
55
+ for (const [, path] of code.toString().matchAll(imports)) visit(resolve(directory, path));
56
+ }
57
+ function visit(file) {
58
+ if (seen.has(file)) return;
59
+ seen.add(file);
60
+ count(readFileSync(file), dirname(file));
61
+ }
62
+ if (target.endsWith(".html")) {
63
+ // Every script the page loads: `<script src>` files and inline `<script>` text, classic or module.
64
+ const html = readFileSync(target, "utf8");
65
+ for (const [, attributes, body] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
66
+ const type = /\btype="([^"]*)"/i.exec(attributes)?.[1] ?? "";
67
+ if (!["", "module", "text/javascript"].includes(type)) continue; // JSON data and import maps are not JavaScript
68
+ const src = /\bsrc="([^"]+)"/i.exec(attributes)?.[1];
69
+ if (!src) count(Buffer.from(body), dirname(resolve(target)));
70
+ else if (!/^(?:[a-z]+:)?\/\//i.test(src)) visit(src.startsWith("/") ? join(resolve(root), src) : resolve(dirname(target), src));
71
+ }
72
+ } else visit(resolve(target));
73
+ console.log(JSON.stringify({ scripts, raw, gzip }));
74
+ ```
75
+
76
+ First-load JavaScript of a page: pass its HTML, and the assets directory when the
77
+ page loads scripts by absolute path. Built-in Admin loads the blocking `theme.js`
78
+ and the module entry:
79
+
80
+ ```sh
81
+ pnpm exec mantle bundle src/worker.ts dist
82
+ node first-load.mjs dist/client/admin/index.html dist/client
83
+ ```
84
+
85
+ First-load JavaScript of an MCP App: pass its HTML document.
86
+
87
+ ```sh
88
+ node first-load.mjs dist/client/admin/mcp-app.html
89
+ ```
90
+
91
+ Worker entry static graph: pass Wrangler's `main`.
92
+
93
+ ```sh
94
+ node first-load.mjs dist/worker/worker.js
95
+ ```
96
+
97
+ MCP App HTML document:
98
+
99
+ ```sh
100
+ node -e 'const b=require("fs").readFileSync(process.argv[1]);console.log(JSON.stringify({raw:b.length,gzip:require("zlib").gzipSync(b).length}))' dist/client/admin/mcp-app.html
101
+ ```
102
+
103
+ ## What the SDK's checks measure
104
+
105
+ The SDK's own checks run on fixtures and the built-in Admin entry, not on your
106
+ application. Where they differ from the definitions above:
107
+
108
+ | Check | Reports | Difference |
109
+ |---|---|---|
110
+ | `check:ui-size`, `admin:entry-gzip` | Admin's module entry and its static graph, gzip per file | Leaves out the blocking classic `theme.js` (about 0.8 KB gzip). Reported, not gating. |
111
+ | `check:ui-size`, `admin:mcp-html` | Raw bytes of `mcp-app.html` | No gzip figure and no inline JavaScript figure. Reported, not gating. |
112
+ | `check:startup` and `check-size.ts`, `startup:<surface>` | The static graph of a fixture Worker mounting each surface, from esbuild's metafile | Raw minified bytes, not gzip. Gates against `size-budgets.json`. |
113
+ | `check-size.ts`, `direct-op` | The static graph of one direct operation import | Raw minified bytes. Gates below 4 KiB. |
114
+ | `check-size.ts`, `startupBytes` of `cloudflare-d1` and `cloudflare-postgres` | The fixture Worker entry's static graph | Raw minified bytes. Reported, not gating. |
115
+ | `check-size.ts`, `totalBytes` and `gzipBytes` | Every `.js` file of a fixture Worker bundle; `gzipBytes` is gzip per file | Includes lazy chunks, so it is the whole bundle, not the static graph. Only `totalBytes` warns above its reference; neither gates. |
116
+
117
+ `check:ui-size` also gates that lazy features and locales stay out of Admin's
118
+ startup graph, and `check:startup` runs it with `--startup-only`. Package bytes
119
+ (`packed`, `unpacked`) are a separate measure of what installs.
120
+
121
+ ## Native Admin baselines
122
+
123
+ Built-in Admin is SDK-existing cost: both its page and its MCP App. The 0.2.0
124
+ size pass, [#1554](https://github.com/aotter/mantle/issues/1554), shrank them and
125
+ is closed; the misses below are what remains. Measured with the commands above on
126
+ the built-in entry. The figures move with any change to Admin or its
127
+ dependencies, so they hold only for the version and commit named.
128
+
129
+ | SDK | Source | Admin page first-load JavaScript | MCP App first-load JavaScript | MCP App HTML document |
130
+ |---|---|---|---|---|
131
+ | 0.2.1 | `develop` at `6dafb812`, before the release | 212,102 B gzip in 5 files (211,338 B without `theme.js`) | 193,535 B gzip, inline | 694,170 B raw / 200,457 B gzip |
132
+
133
+ Neither the built-in Admin page nor its MCP App meets the 100 KB first-load
134
+ budget. Both misses are SDK cost, not your application's: report them as
135
+ SDK-existing, link #1554, and keep them out of app-added cost.
136
+
137
+ ## Report size
138
+
139
+ - Report app-added cost and SDK-existing cost separately. App-added cost is your
140
+ measurement minus the baseline for the installed SDK version. When your figures
141
+ for an unchanged built-in Admin differ from the table, measure the baseline
142
+ again on your install and subtract that.
143
+ - When the installed SDK and Admin versions are unchanged and Admin uses the
144
+ built-in entry, the baseline still holds: do not repeat that experiment each
145
+ round. An `admin.tsx` entry or a custom MCP App changes what you measure.
146
+ - A target still missed is PARTIAL, even when the SDK's cost causes it. Never
147
+ relabel PARTIAL as PASS and never raise a threshold to pass.
148
+ - Do not patch vendor or SDK code, remove native Admin features or build a
149
+ general benchmark framework to pass. Report the miss and its owner.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Seven SQL patterns most apps need, each a complete manifest that compiles on every target, with the PostgreSQL spelling and what each refuses.
2
+ description: Eight SQL patterns most apps need, each a complete manifest that compiles on every target, with the PostgreSQL spelling and what each refuses.
3
3
  ---
4
4
  # SQL patterns
5
5
 
@@ -211,6 +211,52 @@ spec:
211
211
  RETURNING id
212
212
  ```
213
213
 
214
+ ## Create only if the parent exists: onNoRows
215
+
216
+ `place-order` above writes no order for an unknown or discontinued product and still succeeds: its result is
217
+ `results[0] = []`, and the caller must look. That is right when "nothing to do" is a normal outcome. When writing no
218
+ row means the request failed, say so: write the handler as `steps`, one statement each, and give the statement that
219
+ must write `onNoRows: conflict`. A step marked so that writes no row fails the call with `CONFLICT` (409) kind
220
+ `"row"`, as a row-classified statement does, and the whole program rolls back, including steps that ran before it.
221
+
222
+ ```yaml
223
+ apiVersion: cms.mantle.aotter.net/v2
224
+ kind: Procedure
225
+ metadata: { name: request-order }
226
+ spec:
227
+ description: Ask for an order of a product that is still sold; it waits for approval. Refused (409) for an unknown or discontinued product.
228
+ requires: { auth: { all: [ctx.user] } }
229
+ input:
230
+ type: object
231
+ additionalProperties: false
232
+ required: [productId, qty]
233
+ properties:
234
+ productId: { type: string }
235
+ qty: { type: integer, minimum: 1 }
236
+ output: { type: object }
237
+ handler:
238
+ steps:
239
+ - sql: |
240
+ INSERT INTO orders (productId, qty, total, orderStatus)
241
+ SELECT p.id, input.qty, input.qty * p.price, 'pending_approval'
242
+ FROM products p WHERE p.id = input.productId AND p.active
243
+ RETURNING id
244
+ onNoRows: conflict
245
+ ```
246
+
247
+ `steps` is the same program as `sql`, one statement per step, run in order in the same batch with the same checks:
248
+ `handler: { sql }` and `handler: { steps }` are alternatives, and a step without `onNoRows` behaves exactly as that
249
+ statement does in `sql`. A further step, such as `place-order`'s conditional reservation, is another list item
250
+ (`- sql: UPDATE products SET reserved = …`) without `onNoRows` when writing nothing is fine.
251
+
252
+ `WHERE EXISTS (SELECT 1 FROM products WHERE id = input.productId)` after a `SELECT` of input values works the same
253
+ way when the new row needs nothing from the parent. Use `onNoRows` on a set op only: an `INSERT … SELECT`, an
254
+ `INSERT … ON CONFLICT`, or an UPDATE or DELETE that does not pin `id`. A one-row `INSERT … VALUES` or a
255
+ `WHERE id = input.id` statement already must change exactly one row, and generation refuses `onNoRows` on it. The
256
+ check is "at least one", so a statement that writes several rows (`json_each(input.items)`, an UPDATE over a set)
257
+ passes; give an array input `minItems: 1` when an empty one is a mistake. A ref handler throws its own
258
+ `ContractError` instead.
259
+
214
260
  ## A row with its children in one read
215
261
 
216
262
  An admin, or an AI client, reads each product with its orders in one call instead of one call per product. A
@@ -83,11 +83,16 @@ CREATE INDEX articles_title_trgm ON articles USING gin (title gin_trgm_ops);
83
83
 
84
84
  Index each searched field as needed and inspect EXPLAIN for the workload. Case folding follows the engine.
85
85
 
86
- Writes are lazy and execute once when awaited. Insert/update return native rows; delete returns void. `update(id, set, { lock })` and `delete(id, { lock })` require the version the caller reviewed. A stale lock, excluded row or unique clash is `CONFLICT`. `batch` atomically executes unused Writes from one Store in input order; a step cannot use a prior result.
86
+ Writes are lazy and execute once when awaited. Insert/update return native rows; delete returns void. Store rows carry `version` as a number on every target: PostgreSQL's int8 text is converted, and a value beyond `Number.MAX_SAFE_INTEGER` is `SQL_TYPE` rather than rounded. `update(id, set, { lock })` and `delete(id, { lock })` require the version the caller reviewed, as a number or an exact integer string. A stale lock, excluded row or unique clash is `CONFLICT`. A definitive PostgreSQL serialization failure (`40001`) or deadlock (`40P01`) is also `CONFLICT`, with `kind: "serialization"` and the native error as `cause`; the write was rolled back. This includes a server refusal at COMMIT. A missing/uncertain response remains `OUTCOME_UNKNOWN`. Re-read before deciding whether to retry; there is no automatic retry. `batch` atomically executes unused Writes from one Store in input order; a step cannot use a prior result.
87
87
 
88
- Interactive `transaction(async tx => …)` exists on bun:sqlite and PostgreSQL, with collections, Views and Procedures bound to the transaction. D1 supports batches only. PostgreSQL defaults to serializable, with an explicit isolation override; SQLite uses BEGIN IMMEDIATE. Use `tx` inside the callback, not the outer Store or handle. Nested transactions and `tx.batch` are refused; there is no automatic retry.
88
+ Interactive `transaction(async tx => …)` exists on bun:sqlite and PostgreSQL, with collections, Views and Procedures bound to the transaction. D1 supports batches only. PostgreSQL defaults to serializable, with an explicit isolation override; SQLite uses BEGIN IMMEDIATE. Use `tx` inside the callback, not the outer Store or handle. Nested transactions are refused; there is no automatic retry. `tx.batch` joins the outer transaction, with no savepoint.
89
+ A rejected batch marks the whole outer transaction rollback-only, including when its error is caught: the transaction
90
+ rejects with the first batch failure and rolls back all its writes. This includes invalid/reused/foreign Writes and
91
+ errors while building or executing the batch.
89
92
 
90
- Ref handlers receive `{ caller, db, store }` bound to the same Caller and transaction. Implement the generated `Handlers` interface and pass it to `store(db, caller, { handlers })`. Direct Procedure modules expose SQL Procedures; ref Procedures are reached through the bound Store or selected surfaces.
93
+ Ref handlers receive `{ caller, db, store }` bound to the same Caller and transaction. `ctx.store.batch` is available
94
+ on every target: a native atomic batch standalone, or part of the same outer transaction when one exists. Use it for
95
+ known multi-writes rather than constructing another Store inside a handler. Implement the generated `Handlers` interface and pass it to `store(db, caller, { handlers })`. Direct Procedure modules expose SQL Procedures; ref Procedures are reached through the bound Store or selected surfaces.
91
96
 
92
97
  See [native execution](native-execution.md) for values and write outcomes and [migrations](../concepts/runtime-and-adapters.md#schema-installation-and-migrations) before changing a Schema.
93
98
 
@@ -45,6 +45,10 @@
45
45
  "text": "SQL patterns",
46
46
  "link": "/guides/sql-patterns"
47
47
  },
48
+ {
49
+ "text": "Edit published content without downtime",
50
+ "link": "/guides/revisions"
51
+ },
48
52
  {
49
53
  "text": "Native Admin",
50
54
  "link": "/guides/native-admin"
@@ -52,6 +52,8 @@ Snapshots may read only: SQL writes, Store/Admin mutations, publishing transitio
52
52
 
53
53
  Pins use [describe](describe.md)'s `_meta.id`: `rest:<METHOD> <path>`, `admin:<METHOD> <path template>`, or the View/Procedure target for MCP and web. The route checks `target` and the generated target hash before execution: mismatches return 412 `TARGET_MISMATCH` or `TARGET_HASH_MISMATCH`. The generated `access-targets.ts` map loads only for gateway requests. Manually composed boundaries can supply an `accessTargets` loader returning the same metadata.
54
54
 
55
+ A gateway computes the pin from the request with [`matchTarget(describe, request)`](describe.md#matching-a-request-to-its-target), which applies the routers' own path normalisation, `HEAD` handling, Admin mount and precedence to the saved `mantle describe` JSON. It returns the target's `_meta`, including `targetHash` and the `readOnly` flag that the freshness rule reads, or `null` when the application serves no such route or tool.
56
+
55
57
  MCP host callers may use `initialize` and `tools/call` of their pinned tool; batches stay refused and `tools/list` returns 403 because the host provides its catalogue.
56
58
 
57
59
  `dryRun: true` runs target/hash checks, input validation, generated caller checks, `authorize` and freshness, without SQL or side effects. It returns 200 `{ dryRun: true, event }`; MCP carries this result in the SDK tool response. Ref handlers, custom guards, uploads and opaque work return 412 `DRY_RUN_UNSUPPORTED`. Dry runs validate authority and input; they do not verify database state, row existence or OCC conflicts.
@@ -5,10 +5,19 @@ An explicit target is required; use the generation target because hashes
5
5
  include the dialect. The command writes no project files. `--diff <old.json>` prints a JSON
6
6
  array of changes against a saved report.
7
7
 
8
- `describeApp(plan)` and `diffApp(a, b)` are exported by
9
- `@aotter/mantle/describe`. The `AppDescription` TypeScript type is the contract.
10
- `targetHash(plan, id)` is also exported by `@aotter/mantle/target-hash`.
11
- These exports use no Node APIs and load no SQL parser.
8
+ This command's output is the supported way to get a description outside the
9
+ CLI. Run it in Node.js or Bun, for example in a deploy step, from the
10
+ committed manifests, and give the saved JSON to whatever needs it. A Worker
11
+ cannot compile manifests that contain SQL, because the SQL parser's WASM needs
12
+ the whole isolate memory (ADR-0034); do not import compiler files from `dist`.
13
+
14
+ ```sh
15
+ mantle describe cloudflare-d1 manifests/*.yaml > describe.json
16
+ ```
17
+
18
+ `@aotter/mantle/describe` exports `matchTarget` and the `AppDescription`,
19
+ `DescribeTarget` and `TargetRequest` types; `AppDescription` is the contract.
20
+ It loads no compiler, SQL parser or Node API.
12
21
 
13
22
  The schemaVersion 1 report contains:
14
23
 
@@ -46,6 +55,49 @@ Each target’s `_meta` contains:
46
55
  Descriptions contain no SQL or rows. They describe declarations; application
47
56
  router additions and optional module mounting remain application-owned.
48
57
 
58
+ ## Matching a request to its target
59
+
60
+ `matchTarget(describe, request)` from `@aotter/mantle/describe` returns the
61
+ `_meta` of the target that the generated routers select for a request, or
62
+ `null` when they serve none. It reads only the JSON this command prints: it
63
+ imports no application, opens no database and makes no network call, so a
64
+ gateway can compute `props.mantle.target` and `targetHash` before forwarding.
65
+
66
+ ```ts
67
+ import { matchTarget, type AppDescription } from "@aotter/mantle/describe";
68
+ import saved from "./describe.json";
69
+ const describe = saved as AppDescription, url = new URL(request.url);
70
+ matchTarget(describe, { surface: "rest", method: request.method, path: url.pathname }); // rest:GET /api/views/orders
71
+ matchTarget(describe, { surface: "admin", method: request.method, path: url.pathname }); // admin:GET /entries/:id
72
+ matchTarget(describe, { surface: "mcp", tool: params.name, path: url.pathname }); // view:orders, admin:tool:<name>
73
+ ```
74
+
75
+ The matcher compiles describe ids with the routers' own pattern grammar and
76
+ precedence, and a conformance test checks it against the generated REST, MCP
77
+ and Admin routers for every route of the example apps:
78
+
79
+ - Paths are the URL pathname. Each segment is percent-decoded and re-encoded,
80
+ so `/api/views/%6Frders` matches `/api/views/orders`; a malformed escape
81
+ matches nothing. One trailing slash is optional. Repeated slashes and a
82
+ different letter case do not match.
83
+ - Methods are compared as `Request.method` reports them. `HEAD` selects a
84
+ public View's `GET` route; Admin and Procedure routes do not serve `HEAD`.
85
+ - REST precedence is the generated router's order (see
86
+ [http Triggers](trigger.md#route-precedence)). Admin precedence is Admin's
87
+ declared route order, so `/entries/export` wins over `/entries/:id`.
88
+ - Admin paths start with `/admin/api`, where Admin mounts its API. Admin ids
89
+ are relative to it.
90
+ - MCP matches the `tools/call` name. Pass the `/mcp` or `/mcp/admin` path to
91
+ limit the match to that endpoint's tools. Without a path, a name that the two
92
+ endpoints map to different targets matches nothing.
93
+
94
+ Media tools (`admin:tool:<name>`) and the optional Admin developer and
95
+ statistics routes match even when the application does not mount them; the
96
+ application then answers `NOT_FOUND`. Routes and tools the application adds
97
+ itself, including Admin `routes` it passes to `admin()`, are not in describe and
98
+ return `null`. Web and site pages (pinned by `view:<name>`) and auth routes are
99
+ outside `matchTarget`.
100
+
49
101
  Diff records contain `id`, `field`, `direction` and optional `before`/`after`.
50
102
  Removing a scope or restrict term widens access; adding one narrows it.
51
103
  Reading another Schema, dropping requirements, adding surfaces or Triggers,
@@ -11,16 +11,25 @@ Bare queries preserve native errors except required conflict/uncertain-write cla
11
11
  | AUTH_DENIED | 403 | Predicate, role or session-origin refusal; a generic Admin write to a read-only Schema |
12
12
  | NOT_FOUND | 404 | Missing or excluded resource |
13
13
  | METHOD_NOT_ALLOWED | 405 | Path exists for another method |
14
- | CONFLICT | 409 | Unique clash, stale lock or failed row/state guard |
14
+ | CONFLICT | 409 | Unique clash, stale lock, failed row/state guard or a PostgreSQL transaction race |
15
15
  | OUTCOME_UNKNOWN | 503 | Write may have committed; reconcile without automatic retry |
16
16
 
17
- A `CONFLICT` carries `kind`: `"row"` when a row statement changed no row (missing or not visible, a stale version, or a
17
+ A `CONFLICT` carries `kind`: `"row"` when a row statement, or a Procedure step marked `onNoRows: conflict`, changed no row (missing or not visible, a stale version, or a
18
18
  state its WHERE no longer allows; one message covers all three, so it never reveals whether the row exists), `"unique"`
19
- for a unique-index clash and `"check"` for a Schema check (with `schema` and `check`). Match `kind`, not the message.
19
+ for a unique-index clash, `"serialization"` for a definitive PostgreSQL serialization failure (`40001`) or deadlock (`40P01`), `"check"` for a Schema check (with `schema` and `check`) and `"lock"` when a [Schema lock](schema.md#locks)
20
+ holds the row (with `schema`, `verb`, `lock` and `message`, the lock's static text; the row is visible to the caller, so
21
+ naming the lock reveals nothing new). Match `kind`, not the message. A `"lock"` refusal never succeeds on retry: change the
22
+ state that locks the row first (for a live revision, switch the post to another revision).
20
23
  To recover from `"row"`, read the record again and decide from its current state: retry with its current version only when the change still applies, and never retry blindly. A Procedure can name a precise reason itself (a ref handler that reads the row first, or a RETURNING the caller checks).
21
24
 
25
+ A `"serialization"` conflict means PostgreSQL refused and rolled back the write, whether the refusal happened during a statement or COMMIT. The native error remains attached as the in-process `cause`, never exposed on the wire. Re-read the relevant state before deciding whether to retry; Mantle never retries automatically. A lost COMMIT response or missing server evidence stays `OUTCOME_UNKNOWN`, not `CONFLICT`.
26
+
22
27
  Unknown errors map to redacted `INTERNAL_ERROR` (500). A custom `ContractError` outside the core set must carry an explicit status. Media uses its selected `mediaError` contract. Never expose SQL errors, tokens or credentials.
23
28
 
24
29
  The runtime runs no DDL or schema checks. Schema installation and failures belong to [the native migration tool](../concepts/runtime-and-adapters.md#schema-installation-and-migrations), not a boot diagnostic.
25
30
 
31
+ `Schema.spec.publishing` that is malformed or on a Schema that is not `lifecycle: publishing` is `SCHEMA_PUBLISHING_INVALID`. A Store update that sets a field `publish` requires to null on a published row is `INPUT_VALIDATION_FAILED`, naming the field, for every caller. A malformed `Schema.spec.lock` entry is `SCHEMA_LOCK_INVALID`; a lock `when` outside the [lock subset](schema.md#locks) is `SQL_SHAPE` (or the SQL compiler's own code), and an `EXISTS` no declared index serves is `LOCK_INDEX_MISSING`, whose message names the index to declare.
32
+
33
+ A Procedure `handler` with other than exactly one of `sql`, `steps` and `ref`, an empty `steps` list, a step key other than `sql` and `onNoRows`, an empty step `sql` or an `onNoRows` other than `conflict` is `INVALID_MANIFEST_ENVELOPE` at that pointer. A step that holds several statements or none, and `onNoRows` on a row-classified statement, are `SQL_SHAPE` at `/spec/handler/steps/<i>/sql` and `/spec/handler/steps/<i>/onNoRows`; any other SQL refusal in a step points at that step, its line counted from the step's first line.
34
+
26
35
  User-field generation diagnostics are `AUTH_ROLE_REMOVED` (`auth.role` / `auth.hasRole`), `USER_REF_SCOPE_ONLY` (`auth.user` outside `scope` / `restrict`), `USER_FIELD_INVALID` (literal name/value or template grammar), `USER_FIELD_POSITION` (SET of a partition) and `USER_FIELD_CONFLICT` (column type/enum disagreement, or a reference to a field-scoped Schema without its scope terms). Warnings are `USER_FIELD_VALUES_UNENUMERATED` (no enum list), `USER_SCOPE_PUBLIC_ANON_EMPTY` (anonymous public reads are refused), `USER_SCOPE_ADMIN_VALUES` (an admin target reads field-scoped rows) and `USER_SCOPE_JOIN_DROPS` (a join of Schemas scoped by different fields). A generated app supplies the literal USER_REFS_V1 bind capability version; a mismatch throws `USER_REFS_VERSION` before native SQL.
@@ -1,14 +1,14 @@
1
1
  # Procedure
2
2
 
3
- A Procedure requires `input`, `output` JSON Schemas and a `handler` containing SQL or a ref name. Optional fields include title, description, requires, target, MCP annotations and Admin uiSchema. See [manifest envelope](manifest.md).
3
+ A Procedure requires `input`, `output` JSON Schemas and a `handler` with exactly one of `sql` (the program), `steps` (the same program, one statement per step: `[{ sql, onNoRows? }]`) or `ref`. Optional fields include title, description, requires, target, MCP annotations and Admin uiSchema. See [manifest envelope](manifest.md).
4
4
 
5
- SQL uses declared Schemas, `input.<name>`, `auth.uid()` and the expressions [the SQL reference](sql.md) lists for the selected dialect; [SQL patterns](../guides/sql-patterns.md) shows conditional status, approval, partial updates, upserts and guards. Compiler policy supplies scope and system fields. Do not assign scope, version, status, published_at, timestamps or author columns yourself. A SQL Procedure cannot write a `publishing` Schema: create and edit its rows from a ref handler through the Store, and change status with the generated publish/unpublish/archive transitions. `restrict` narrows the rows the statements read and write, as on a [View](view.md#restrict); it is refused on ref handlers.
5
+ SQL uses declared Schemas, `input.<name>`, `auth.uid()` and the expressions [the SQL reference](sql.md) lists for the selected dialect; [SQL patterns](../guides/sql-patterns.md) shows conditional status, approval, partial updates, upserts, guards and creating a row only when its parent exists. Compiler policy supplies scope and system fields. Do not assign scope, version, status, published_at, timestamps or author columns yourself. A SQL Procedure cannot write a `publishing` Schema: create and edit its rows from a ref handler through the Store, and change status with the generated publish/unpublish/archive transitions. `restrict` narrows the rows the statements read and write, as on a [View](view.md#restrict); it is refused on ref handlers.
6
6
 
7
- A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error: check the returned rows when zero means failure. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409) with `kind` `"unique"`, `"check"` or `"row"` ([diagnostics](diagnostics.md)); statements run atomically. A check failure keeps that code, and its message is `A check of the Schema failed: <schema>: <check>` on every target, with the driver's own error only in `cause`; `ContractError.fields` is `{ kind: "check", schema, check }`. `check` is the expression on SQLite/D1 and the generated constraint name on PostgreSQL. Results are raw `{ results }`: one array of RETURNING rows per statement, in order (`results[0][0].id` is the first statement's first row), not runtime-validated authored outputs, with driver-native values: a SQLite/D1 boolean is `1`/`0` here, while a View returns a declared boolean column as `true`/`false`; read a row back through a View when a client needs the typed form. An array input is a row source: `json_each(input.items)` on SQLite/D1 and `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL write any number of rows in the same atomic program. Callers pass the array itself (from a handler, `ctx.store.procedures["place-order"]({ orderRef, items })`); the generated input type is the authored one and the generated query serializes a JSON input for the driver, so no `JSON.stringify` is needed (a string is bound as it is). See [several rows that commit together](../guides/typed-queries.md#several-rows-that-commit-together). RETURNING uses SQL output names; quote an explicit alias to preserve declared case (`RETURNING reqStatus AS "reqStatus"`). Generation warns about unaliased direct fields whose declared name differs from storage.
7
+ A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error. When zero means failure, write the handler as `steps` and give that statement `onNoRows: conflict` (`steps: [{ sql: "INSERT … SELECT …", onNoRows: conflict }]`): it must write at least one row, or the call is `CONFLICT` kind `"row"` and the whole program rolls back ([create only if the parent exists](../guides/sql-patterns.md#create-only-if-the-parent-exists-onnorows)). `steps` compile exactly as the same statements in `sql`, in order and in one batch; each step must be exactly one statement. Generation refuses a step with several statements or none, `onNoRows` on a row-classified statement (it already must change exactly one row) and any `onNoRows` value but `conflict`; a ref handler has no steps. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409) with `kind` `"unique"`, `"check"` or `"row"` ([diagnostics](diagnostics.md)); statements run atomically. A check failure keeps that code, and its message is `A check of the Schema failed: <schema>: <check>` on every target, with the driver's own error only in `cause`; `ContractError.fields` is `{ kind: "check", schema, check }`. `check` is the expression on SQLite/D1 and the generated constraint name on PostgreSQL. Results are raw `{ results }`: one array of RETURNING rows per statement, in order (`results[0][0].id` is the first statement's first row), not runtime-validated authored outputs, with driver-native values: a SQLite/D1 boolean is `1`/`0` here, while a View returns a declared boolean column as `true`/`false`; read a row back through a View when a client needs the typed form. Likewise `RETURNING version` is a number on SQLite/D1 and int8 text (`"2"`) on PostgreSQL; the Store converts `version` to a number, a Procedure result does not, so use `Number(version)` or read the row back through the Store. An array input is a row source: `json_each(input.items)` on SQLite/D1 and `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL write any number of rows in the same atomic program. Callers pass the array itself (from a handler, `ctx.store.procedures["place-order"]({ orderRef, items })`); the generated input type is the authored one and the generated query serializes a JSON input for the driver, so no `JSON.stringify` is needed (a string is bound as it is). See [several rows that commit together](../guides/typed-queries.md#several-rows-that-commit-together). RETURNING uses SQL output names; quote an explicit alias to preserve declared case (`RETURNING reqStatus AS "reqStatus"`). Generation warns about unaliased direct fields whose declared name differs from storage.
8
8
 
9
9
  A row-classified UPDATE/DELETE on an excluded or missing id affects zero rows and returns HTTP 409 (`CONFLICT`), just like a stale version; reads return not found. Native Admin's visible-row pre-read returns 404 for excluded/missing ids.
10
10
 
11
- A ref handler is application code called as `(ctx, input)`. Generation lists the `Handlers` keys the app must provide (every run, whether or not they exist yet); `tsc --noEmit` checks the implementation against the generated `Handlers` type. Wire them at `store(db, caller, { handlers })` and selected surfaces. The context has the Caller, the native db and a Store for that caller. A handler is not one transaction by itself: when it runs inside a caller's `store.transaction`, its db and Store are that transaction's; otherwise each write commits on its own. On PostgreSQL and bun:sqlite, `ctx.transaction(async ({ store, db }) => …)` makes writes that must commit together atomic; it joins the transaction the handler already runs in. A joined transaction has no savepoint, so let an error inside `ctx.transaction` propagate: a handler that catches it and carries on keeps the writes made before the error. Keep external calls (payments, supplier APIs) outside it, so a callback never waits for an uncommitted row. D1 has no interactive transactions, so its `HandlerContext` has no `transaction`: put writes that must commit together in one SQL Procedure, whose statements commit together, and call it through `ctx.store.procedures`. It is not a sandbox; the app owns external side effects and input/output handling. The context has no `env`: on Workers read bindings and secrets with `import { env } from "cloudflare:workers"`. The Caller carries no name or email; `userOf(caller)` from `@aotter/mantle/auth` returns the Better Auth user record it was resolved from, for session, OAuth and service credentials alike (undefined for system callers).
11
+ A ref handler is application code called as `(ctx, input)`. Generation lists the `Handlers` keys the app must provide (every run, whether or not they exist yet); `tsc --noEmit` checks the implementation against the generated `Handlers` type. Wire them at `store(db, caller, { handlers })` and selected surfaces. The context has the Caller, the native db and a Store for that caller. A handler is not one transaction by itself: when it runs inside a caller's `store.transaction`, its db and Store are that transaction's; otherwise each awaited write commits on its own. For known multi-writes, `ctx.store.batch([...])` is available on every target, including D1: it commits atomically standalone and joins the same outer transaction when one exists. A batch step cannot consume a previous step's result. A rejected batch marks that outer transaction rollback-only, even if the handler catches the error: the transaction rejects with the first batch failure and rolls back all writes. No nested BEGIN or savepoint is added. On PostgreSQL and bun:sqlite, `ctx.transaction(async ({ store, db }) => …)` supports dependent writes and joins the transaction the handler already runs in. A joined transaction has no savepoint; application errors outside a batch must propagate to roll back its writes. Keep external calls (payments, supplier APIs) outside it, so a callback never waits for an uncommitted row. D1 has no interactive transactions, so its `HandlerContext` has no `transaction`; use `ctx.store.batch` or a multi-statement SQL Procedure through `ctx.store.procedures` for atomic writes. It is not a sandbox; the app owns external side effects and input/output handling. The context has no `env`: on Workers read bindings and secrets with `import { env } from "cloudflare:workers"`. The Caller carries no name or email; `userOf(caller)` from `@aotter/mantle/auth` returns the Better Auth user record it was resolved from, for session, OAuth and service credentials alike (undefined for system callers).
12
12
 
13
13
  `target: { schema, id, version? }` binds an Admin operation to a row. SQL targets can be inferred for a single locked row operation; declare a target for a ref operation when needed. Input fields must have the required shapes.
14
14
 
@@ -1,6 +1,6 @@
1
1
  # Schema
2
2
 
3
- A Schema declares one table and its typed Store collection. Required keys are title and an object JSON Schema. Optional keys include lifecycle, scope, TTL, indexes, uniqueIndexes, checks, searchableFields, localized, translates and uiSchema. Unknown keys fail validation.
3
+ A Schema declares one table and its typed Store collection. Required keys are title and an object JSON Schema. Optional keys include lifecycle, scope, lock, TTL, indexes, uniqueIndexes, checks, searchableFields, localized, translates and uiSchema. Unknown keys fail validation.
4
4
 
5
5
  ```yaml
6
6
  apiVersion: cms.mantle.aotter.net/v2
@@ -46,10 +46,57 @@ Generated types constrain Store fields and writes; Store does not interpret a JS
46
46
 
47
47
  ## Row rules
48
48
 
49
- Schemas default to operational. Publishing opts into draft/published/archived and generated admin/system transitions. Store cannot assign system columns or scope; generated policy fills them. See [lifecycle and locales](../concepts/lifecycle-and-locales.md).
49
+ Schemas default to operational. Publishing opts into draft/published/archived and generated admin/system transitions. Store cannot assign system columns or scope; generated policy fills them. `publishing: { liveEdits: true }` (only with `lifecycle: publishing`) lets Admin's generic patch edit a published entry in place; without it, generic patches edit drafts only, an implicit lock named `publishing` that never binds the transitions. No update clears a field `publish` requires from a published row (`INPUT_VALIDATION_FAILED`): a Store update that sets one to null is refused, while Admin's live edit validates the whole merged entry as `publish` does. See [lifecycle and locales](../concepts/lifecycle-and-locales.md).
50
50
 
51
51
  Owner and field columns prefix every unique index in that order. User reads/writes see their own rows; anonymous reads see none. System callers bypass scope. TTL uses `{ field, expireAfterSeconds }` over a date-time field; expired rows become invisible but remain stored until a system sweep. TTL is not allowed on translations.
52
52
 
53
+ ### Locks
54
+
55
+ `lock` freezes a row while a condition on it holds. It is a data-state invariant, not an authorization rule: it binds every caller, admins included.
56
+
57
+ ```yaml
58
+ apiVersion: cms.mantle.aotter.net/v2
59
+ kind: Schema
60
+ metadata: { name: posts }
61
+ spec:
62
+ title: Posts
63
+ lifecycle: publishing
64
+ indexes: [[current_revision]]
65
+ schema:
66
+ type: object
67
+ properties:
68
+ slug: { type: string }
69
+ current_revision: { type: string, x-mantle-ref: post_revisions }
70
+ ---
71
+ apiVersion: cms.mantle.aotter.net/v2
72
+ kind: Schema
73
+ metadata: { name: post_revisions }
74
+ spec:
75
+ title: Post revisions
76
+ lifecycle: operational
77
+ lock:
78
+ - name: live_revision
79
+ on: [update, delete]
80
+ when: EXISTS (SELECT 1 FROM posts p WHERE p.current_revision = post_revisions.id)
81
+ message: "This revision is live. Create a new revision and switch to it."
82
+ schema:
83
+ type: object
84
+ required: [post, body]
85
+ properties:
86
+ post: { type: string, x-mantle-ref: posts }
87
+ body: { type: string }
88
+ ```
89
+
90
+ - `when` true means locked. It reads the existing row only (like RLS `USING`); new rows stay with `checks`. `on` lists `update` and/or `delete`; names are unique per Schema.
91
+ - A caller's update or delete of the Schema gets `AND NOT (<when>)` in its WHERE, beside scope and the version check, so the write stays one atomic statement. Several locks are ANDed. A NULL `when` refuses the write: a lock fails closed. So `when: body = 'keep'` also locks a row whose `body` is NULL; write `body IS NOT NULL AND body = 'keep'` to lock only the rows that hold the value. An `ON CONFLICT DO UPDATE` carries the `update` locks too, as a set op: an upsert whose conflicting row is locked leaves it unchanged and returns no row for it, like `DO NOTHING`; use a row-classified `UPDATE` to get `CONFLICT` `kind: "lock"`.
92
+ - It applies to every caller-facing write: Admin's generic patch and delete (which the Admin MCP App and WebMCP reach through the same routes), caller-mode SQL Procedures' `UPDATE`/`DELETE` of the locked Schema (over REST, MCP or a direct call), the generated publishing transitions (`<schema>.publish` / `.unpublish` / `.archive`, which carry the `update` locks) and Stores bound to a caller (`store(db, caller)`, a ref handler's `ctx.store`). Only a trusted write (the system caller, a system Store, the TTL sweep) bypasses it: the maintenance escape hatch, visible in code review.
93
+ - A refused write is `CONFLICT` with `kind: "lock"` and `{ schema, verb, lock, message }` ([diagnostics](diagnostics.md)). When the write changed no row, one read of the row as the caller sees it classifies the failure: a missing or invisible row stays `kind: "row"`.
94
+ - `when` allows the Schema's own columns, constants, comparisons (`=`, `<>`, `<`, `>`, `<=`, `>=`, `IN (…)`), `AND`/`OR`/`NOT`, `IS [NOT] NULL` and `[NOT] EXISTS (SELECT … FROM <declared Schema> [alias] WHERE …)`. It calls no function (`now()`, `auth.*`, aggregates, windows) and reads no `input.*`, so it holds the same for every caller and request. Inside a subquery every column is qualified: `p.current_revision` for the subquery's Schema, `post_revisions.id` for the locked row (an unqualified `id` would silently read `posts.id`). A subquery reads one Schema, without join, `WITH`, `GROUP BY`, `ORDER BY`, `LIMIT` or `DISTINCT`, and its alias may not hide the locked Schema's name. `when` is one expression: a trailing `ORDER BY`, `GROUP BY` or `LIMIT` is refused. The compiler gives each subquery relation a reserved `_mantle_lock_<n>` alias, so the alias of a write the lock guards can never capture its references (relation aliases starting with `_mantle` are refused in authored SQL).
95
+ - Subqueries read the stored rows, without the reader's scope, publishing filter or TTL: a referencing row the caller cannot see still locks.
96
+ - **Concurrency.** The lock is a predicate on the write's database snapshot, not a lock on every row its subquery reads. On PostgreSQL READ COMMITTED, a concurrent change in another Schema can make a cross-row `EXISTS` condition true after an UPDATE has already passed that check. For a publication guarantee across editors, the [revisions recipe](../guides/revisions.md) first CAS-seals the revision's own row and locks on that stored seal; its parent pointer changes only after sealing succeeds. A transaction around the pointer change alone does not serialize unrelated autocommit edits.
97
+ - Admin knows before the edit: its editor and list reads evaluate each lock per row, and each entry carries `locks: { update, delete }` (per verb, the holding lock's `{ lock, message }` or `false`), so Save, Delete and transitions are disabled with the message ([native Admin](../guides/native-admin.md)). On a publishing Schema the draft-only rule shows there too, as the implicit lock `publishing`, after any declared lock on the same verb. The write's WHERE remains the authority.
98
+ - **Index rule.** Locks are index-proven reads, like [paged Views](#index-prefixes-for-paged-views): every guarded write runs them. Each `EXISTS` needs an equality (`=` to the locked row's column, an outer subquery's column or a literal, or `IN (<literals>)`) among its top-level `AND` terms on the first column of a declared index, unique index or `id` of its Schema. The example needs `indexes: [[current_revision]]` on `posts`; otherwise generation fails with `LOCK_INDEX_MISSING`, naming the index to declare. A condition on the locked row's own columns needs no index. An `OR` of correlations is not index-proven: split it into separate locks.
99
+
53
100
  `localized` and `translates: { parent, on }` declare a translation child. Parent visibility and publication are retained, with matching scope where needed. Locale values stay plain strings.
54
101
 
55
102
  ## Indexes, checks and search
@@ -70,7 +117,7 @@ Declaring both indexes does not make one View bounded for every caller. System (
70
117
 
71
118
  Publishing fields may be NULL in drafts even when required, so they cannot be View paging keys. Store accepts `published_at` ordering, but View paging rejects it. Use `ORDER BY id`, or keep the business date in an operational Schema as an indexed, required non-null field. See [View paging](view.md#results-and-paging).
72
119
 
73
- `checks` are restricted SQL expressions over the table's columns, emitted in initial DDL. Native databases enforce them; bare database errors are not automatically mapped to input-validation failures. Searchable fields must be plain string fields. SQLite/D1 emit FTS5 trigram tables and maintenance triggers; PostgreSQL uses ILIKE. Short SQLite terms use LIKE. Store search additionally matches exact id and combines with row policy.
120
+ `checks` are restricted SQL expressions over the table's columns, emitted in initial DDL. Native databases enforce them; bare database errors are not automatically mapped to input-validation failures. `searchableFields` defaults to none: Store and Admin list search then match the exact id only, and Admin's search box says so. Name the fields people search by, such as the list `primaryField` or a `title`; with Admin selected, `mantle generate` warns when a Schema has no `searchableFields` but has such a field that could be one (a plain string, not a date, date-time or reference). Searchable fields must be plain string fields. SQLite/D1 emit FTS5 trigram tables and maintenance triggers; PostgreSQL uses ILIKE. Short SQLite terms use LIKE. Store search additionally matches exact id and combines with row policy.
74
121
 
75
122
  The runtime never creates, alters or checks tables. Initial DDL is for an empty database. Changed fields, checks, indexes and search DDL require [agent-owned native migrations](../concepts/runtime-and-adapters.md#schema-installation-and-migrations), including explicit data migration for destructive/type changes. Mantle drops nothing.
76
123
 
@@ -80,7 +127,7 @@ A property marked `readOnly: true` in the JSON Schema is server-maintained (a co
80
127
 
81
128
  `default` does not make a column NOT NULL; `required` does (in an `operational` Schema). For a server-maintained value that is never NULL, list it in both, `required: [name, total]` with `total: {type: integer, readOnly: true, default: 0}`: initial DDL declares `total` NOT NULL, Store and View row types read it as `number` rather than `number | null`, Admin's create still fills the default when the client omits it, and the Store's insert type requires server code to pass it. Without `required` the column stays nullable, and an INSERT that omits it stores NULL. Adding `required` to an existing table is a [migration](../concepts/runtime-and-adapters.md#schema-installation-and-migrations): backfill NULLs, then add NOT NULL.
82
129
 
83
- `fields.<name>.widget` selects a built-in control (`textarea`); `list.primaryField`, `list.columns` and enum `filterField` select list presentation (operational Schemas only; a publishing Schema's list shows its title field and status); `nav.parentField` chooses the parent a child folds under (required when two or more required `id` references exist) and `nav.standalone` lists the child in the sidebar; `nav.group/order/hidden` place it in the sidebar ([sidebar](../guides/native-admin.md#sidebar)). `list.columns` must not repeat `list.primaryField` or itself (`SCHEMA_UI_INVALID`). Generation validates these against declared fields/indexes. Components that render a field, cell or record aside are not manifest references: they live in `defineAdmin` ([customising Admin](../guides/native-admin.md#customising-admin)), and a manifest that still names one (`widget: <extension>/<contribution>`, `widget` `options`, `list.cells`, `panels`) is refused. JSON Schema root `schema.readOnly: true` disables generic Admin writes, not declared Procedures; `uiSchema.readOnly` is rejected with `SCHEMA_UI_INVALID`. `x-mantle-ref` relates a field to another Schema id or single-field unique field. It is not a foreign key: the database does not check that the referenced row exists, and deleting that row leaves the reference dangling. Check existence in the write (a JOIN or `EXISTS` in the Procedure) and guard deletes with `NOT EXISTS` ([SQL patterns](../guides/sql-patterns.md)).
130
+ `fields.<name>.widget` selects a built-in control (`textarea`); `list.primaryField`, `list.columns` and enum `filterField` select list presentation (operational Schemas only; a publishing Schema's list shows its title field and status); `nav.parentField` chooses the parent a child folds under (required when two or more required `id` references exist) and `nav.standalone` lists the child in the sidebar; `nav.group/order/hidden` place it in the sidebar ([sidebar](../guides/native-admin.md#sidebar)). `list.columns` must not repeat `list.primaryField` or itself (`SCHEMA_UI_INVALID`). Generation validates these against declared fields/indexes. Components that render a field, cell or record aside are not manifest references: they live in `defineAdmin` ([customising Admin](../guides/native-admin.md#customising-admin)), and a manifest that still names one (`widget: <extension>/<contribution>`, `widget` `options`, `list.cells`, `panels`) is refused. JSON Schema root `schema.readOnly: true` disables generic Admin writes, not declared Procedures; `uiSchema.readOnly` is rejected with `SCHEMA_UI_INVALID`. `x-mantle-ref` relates a field to another Schema id or single-field unique field. It is not a foreign key: the database does not check that the referenced row exists, and deleting that row leaves the reference dangling. Check existence in the write (a JOIN or `EXISTS` in the Procedure, with `onNoRows: conflict` on its step when a missing parent must be a 409) and guard deletes with `NOT EXISTS` ([SQL patterns](../guides/sql-patterns.md)).
84
131
 
85
132
  See [Admin](../guides/admin-ui.md) and [diagnostics](diagnostics.md).
86
133
 
@@ -94,7 +141,7 @@ For inserts, one allowed partition autofills a missing value; multiple values re
94
141
 
95
142
  #### References to a scoped Schema
96
143
 
97
- A non-null `x-mantle-ref` value that points to a scoped Schema (field or owner scope) must name a row of that Schema the caller can read, matched on `id` or on the object form's `field`, and that row must have the same value as the child for every scope field the two share (`ADR-0034`: a reference between scoped Schemas stays inside one owner or partition). A Store insert or update, a Procedure `INSERT … VALUES`, `UPDATE … SET` or `DO UPDATE SET`, and native Admin's generic create and patch check it in the same statement; a missing, unreadable or other-partition parent behaves like a missing row: a single-row statement returns HTTP 409 (`CONFLICT`), and an `ON CONFLICT` insert or a set update writes nothing for that row. A caller holding several values or `{ all: true }` can still not point a child in one partition at a parent in another. `INSERT … SELECT` that writes such a reference runs only for system callers. A caller's `UPDATE … SET` or `DO UPDATE SET` of such a reference takes no subquery, which could read the parent's columns as its own. Trusted writes (system callers, `runtime.store`) are not checked, like the other row rules. NULL is always allowed; deleting a parent does not touch its children.
144
+ A non-null `x-mantle-ref` value that points to a scoped Schema (field or owner scope) must name a row of that Schema the caller can read, matched on `id` or on the object form's `field`, and that row must have the same value as the child for every scope field the two share (`ADR-0034`: a reference between scoped Schemas stays inside one owner or partition). A Store insert or update, a Procedure `INSERT … VALUES`, `UPDATE … SET` or `DO UPDATE SET`, and native Admin's generic create and patch check it in the same statement; a missing, unreadable or other-partition parent behaves like a missing row: a single-row statement returns HTTP 409 (`CONFLICT`), and an `ON CONFLICT` insert or a set update writes nothing for that row. A caller holding several values or `{ all: true }` can still not point a child in one partition at a parent in another. `INSERT … SELECT` that writes such a reference runs only for system callers. A caller's `UPDATE … SET` or `DO UPDATE SET` of such a reference takes no subquery, which could read the parent's columns as its own. Trusted writes (`store(db, caller)` or a ref handler's `ctx.store` with a system caller, `{ kind: "system", reason }`) are not checked, like the other row rules. NULL is always allowed; deleting a parent does not touch its children.
98
145
 
99
146
  ## Reserved entry columns
100
147
 
@@ -106,5 +153,22 @@ the publishing lifecycle; `version` backs `expectedVersion`; `author_id` is the
106
153
  `restrict: { <schema>: "author_id = auth.uid()" }`.
107
154
 
108
155
  A seed or other SQL that inserts rows outside Mantle (`wrangler d1 execute --file seed.sql`) sets the native columns
109
- itself: `id` (text, unique) and `created_at` (integer microseconds since the Unix epoch, e.g. `1767225600000000`);
110
- `updated_at` and `version` have defaults. Procedures and the Store fill them for you.
156
+ itself. Procedures and the Store fill every one of them for you; prefer them for fixtures. The Store accepts no
157
+ value for these columns or the scope column, for any caller: it sets them itself, so SQL is also the only way to keep
158
+ their existing values.
159
+
160
+ | Column | SQLite and D1 | PostgreSQL | If the SQL omits it |
161
+ |---|---|---|---|
162
+ | `id` | text, unique | text, unique | the insert fails |
163
+ | `version` | integer, `1` for a new row | `int8` | `1` |
164
+ | `created_at` | integer microseconds since the Unix epoch (`1767225600000000` is 2026-01-01T00:00:00Z) | `timestamptz` | the insert fails |
165
+ | `updated_at` | integer microseconds, like `created_at` | `timestamptz` | SQLite and D1 store `0`, which Admin shows as 1970-01-01; PostgreSQL stores `now()` |
166
+ | `author_id` | the creator's Better Auth user id, or `NULL` | the same | `NULL`: `restrict: { <schema>: "author_id = auth.uid()" }` shows the row to no member |
167
+ | `status` (publishing) | `'draft'`, `'published'` or `'archived'` | the same | `'draft'` |
168
+ | `published_at` (publishing) | integer microseconds when `status` is `'published'`, otherwise `NULL` | `timestamptz` or `NULL` | `NULL` |
169
+ | scope column (owner or field scope) | the field the `scope` names (`owner` in `owner = auth.uid()`), text, not null | the same | the insert fails |
170
+
171
+ Units differ by table: entry timestamps (`created_at`, `updated_at`, `published_at` and declared `date-time` fields)
172
+ are microseconds on SQLite and D1, while the optional media library's `media_assets.created_at` is epoch
173
+ **milliseconds** on every dialect ([media](../cloudflare/media-r2.md)). A declared `date` field is integer days since
174
+ 1970-01-01 on SQLite and D1. [Seed data](../guides/local-testing.md#seed-data) shows a complete seed for each dialect.
@@ -60,5 +60,6 @@ An integer `CAST` of anything but a literal is refused (`SQL_TYPE`): SQLite trun
60
60
 
61
61
  Two write rules come from Mantle, not the dialect ([Procedure](procedure.md)). A row-classified `UPDATE`/`DELETE`
62
62
  (`WHERE id = input.id …`) must change exactly one row, otherwise `CONFLICT`. A set form (`WHERE id IN (SELECT …)`,
63
- `INSERT … SELECT`) may change none, so write a conditional write that may do nothing in that form. Scope, version,
63
+ `INSERT … SELECT`) may change none, so write a conditional write that may do nothing in that form; a Procedure step with
64
+ `onNoRows: conflict` turns "none" into `CONFLICT`. Scope, version,
64
65
  timestamps and author columns are assigned by Mantle, never by your SQL.
@@ -215,6 +215,16 @@ by declared identity, scope and `check` requirements. Without `authorize`, the
215
215
  complete endpoint catalogue is not a permission-filtered catalogue. Operation
216
216
  validation, guards and credential checks still apply to calls.
217
217
 
218
+ `toolsPageSize` pages `tools/list`: each page holds at most that many tools and
219
+ carries an opaque `nextCursor` while more remain; pass it back as `cursor`. A
220
+ cursor is valid only for the catalogue version that issued it; an unknown or
221
+ stale cursor is answered with JSON-RPC error `-32602`, and the client lists
222
+ again from the first page. Without the option, one page lists every tool and
223
+ carries no `nextCursor`.
224
+
225
+ A hand-built tool's `meta` and a `run` returning `withResultMeta(data, meta)`
226
+ add listed and result `_meta`; see [MCP Apps](../guides/mcp-apps.md#tool-metadata-and-result-_meta).
227
+
218
228
  The app resolves bearer tokens with `resolveCaller(request, auth, { audience:
219
229
  new URL('/mcp', request.url).href })` (both surfaces use `<origin>/mcp`). The app
220
230
  must also mount Better Auth's `/.well-known/oauth-protected-resource/mcp`
@@ -360,8 +370,9 @@ Malformed persisted JSON becomes a redacted internal failure. The conversion
360
370
  adds no database operations and does not mutate native query rows.
361
371
 
362
372
  Bare Views, Store rows, native SQL Procedure `{ results }` and application-owned
363
- ref outputs retain their native contracts. Use the generated `rest()` / `mcp()`
364
- module for this client wire contract; do not duplicate JSON/boolean decoding in
373
+ ref outputs retain their native contracts, except that a Store row's `version` is a
374
+ number on every target, so a ref handler returning it outputs the same type
375
+ everywhere. Use the generated `rest()` / `mcp()` module for this client wire contract; do not duplicate JSON/boolean decoding in
365
376
  application route handlers or decode PostgreSQL JSON strings a second time.
366
377
 
367
378
  Declared integer columns (including a selected system `version`) on every target
@@ -35,6 +35,18 @@ spec:
35
35
 
36
36
  The target names a declared Procedure. `http` sources use POST/PUT/PATCH/DELETE and an `/api/` path. Path parameters bind to corresponding declared input fields and override JSON-body values. The generated REST module retains the Procedure's authorization and validates boundary input.
37
37
 
38
+ A path parameter is a whole `{name}` segment. Other segments are literal and may not start with `:` or contain braces, since [describe](describe.md) writes `{name}` as `:name` (`TRIGGER_PATH_INVALID`). Two Triggers with the same method and the same path apart from parameter names are refused (`TRIGGER_PATH_COLLISION`).
39
+
40
+ ### Route precedence
41
+
42
+ When several http Triggers with the same method match a request, the generated router picks the most specific one, independent of manifest order:
43
+
44
+ 1. fewer `{name}` parameters first;
45
+ 2. then, comparing segments from the left, a literal segment before a parameter;
46
+ 3. then the path text.
47
+
48
+ So `POST /api/items/export` wins over `POST /api/items/{id}`, and for `/api/items/latest` the route `POST /api/items/{id}` wins over `POST /api/{kind}/latest`. A public View's `GET /api/views/<name>` route comes before every Trigger. Gateways resolve the same order from [`matchTarget`](describe.md#matching-a-request-to-its-target).
49
+
38
50
  An MCP source is `{ kind: mcp, surface: public }` or admin. The selected MCP module exposes the Procedure as a snake-case tool with its description, [tool hints](procedure.md#mcp-tool-hints) and requires checks.
39
51
 
40
52
  By default, pair every HTTP Trigger with an MCP Trigger so the same operation is reachable by the user's AI. `mantle generate` warns when a Procedure has HTTP exposure but no MCP exposure, suggesting `surface: admin` for an admin-only Procedure and `public` otherwise. Triggers still decide every Procedure surface: intentional asymmetric exposure is allowed and generation succeeds.