@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
@@ -92,7 +92,7 @@ wrote through the app's own Views with HTTP Triggers (`mantle help trigger`).
92
92
  | --- | --- |
93
93
  | `POST /entries` | Body `{ collection, data }`; creates an entry. |
94
94
  | `GET /entries/{id}?collection=<schema>` | Reads an entry and its current `entry.version`. |
95
- | `PATCH /entries/{id}?collection=<schema>` | Body `{ data, expectedVersion }`; sends the observed version, not the next one. Publishing entries must be drafts. |
95
+ | `PATCH /entries/{id}?collection=<schema>` | Body `{ data, expectedVersion }`; sends the observed version, not the next one. A key left out of `data` keeps its value and `null` clears it ([leave out, replace or clear](#leave-out-replace-or-clear)). Publishing entries must be drafts unless the Schema sets `publishing: { liveEdits: true }`; a [lock](../reference/schema.md#locks) that holds refuses it with 409 `kind: "lock"`. |
96
96
  | `POST /entries/{id}/publish?collection=<schema>` | Body `{ expectedVersion }`; validates required data before publishing. |
97
97
  | `POST /entries/{id}/unpublish?collection=<schema>` | Body `{ expectedVersion }`; returns the entry to draft. |
98
98
  | `POST /operations/{name}` | Body is the Procedure's input; runs an Admin operation (a Procedure with `requires: ctx.admin` or a `target`). Returns `{ ok: true, output }`, where `output` is what REST and MCP return (`{ results }` for a SQL Procedure). |
@@ -107,7 +107,15 @@ wrote through the app's own Views with HTTP Triggers (`mantle help trigger`).
107
107
 
108
108
  Entry create/read/patch/transitions return
109
109
  `{ collection, entry, parentEntryId, parentEntryTitle, related }`; the entry has
110
- `{ id, collection, locale, status, version, data, created_at, updated_at }`.
110
+ `{ id, collection, locale, status, version, data, created_at, updated_at }`. On a Schema with
111
+ [locks](../reference/schema.md#locks), each entry of the editor payload and of `GET /entries` also has
112
+ `locks: { update, delete }`: per verb, `{ lock, message }` of the first lock that holds for your generic write of that row,
113
+ or `false`. Admin disables Save, rename, Delete and transitions on such a row and shows the
114
+ message; the operational "Unlock editing" toggle does not override it. The write itself stays the authority: a refusal is
115
+ 409 `CONFLICT` with `kind: "lock"`. Every publishing entry has `locks` too: the draft-only rule (without `liveEdits`) and
116
+ "unpublish before deleting" are the implicit lock `lock: "publishing"`, named only when no declared lock holds for that verb.
117
+ It binds data writes only, so Publish, Unpublish and Archive stay available; with `liveEdits` a published entry's form is
118
+ enabled with a "changes go live on save" notice, and Save waits until its required fields are filled.
111
119
  Transitions accept `{}` to use the current version; tests should send the
112
120
  observed `expectedVersion` to catch stale writes. Generic writes reject native
113
121
  columns inside `data`. See [publishing](../concepts/lifecycle-and-locales.md).
@@ -145,7 +153,7 @@ refused View or collection from a missing one. Showing or hiding a component gra
145
153
  | Hook | Route | Surface |
146
154
  | --- | --- | --- |
147
155
  | `useView(name, input, { limit? })` -> `{ rows, status, hasMore, loadMore, loadingMore }` | `GET /views/{name}` | browser and MCP App |
148
- | `useOperation(name)` -> `{ run, pending }`; success refreshes Views, entries, entry editors and totals | `POST /operations/{name}` | browser and MCP App |
156
+ | `useOperation(name, { refresh? })` -> `{ run, pending }`; success refreshes every mounted View (each input, each loaded page), entries, entry editors, totals and badges once, `refresh` narrowing the Views ([below](#scoped-refresh)) | `POST /operations/{name}` | browser and MCP App |
149
157
  | `useEntries(collection, { filter?, search?, limit? })` -> `{ items, status, hasMore, loadMore, loadingMore }` | `GET /entries` (one `filter_field`/`filter_value`) | browser only |
150
158
  | `useEntry(collection, id)` | `GET /entries/{id}` | browser only |
151
159
  | `useCollectionTotal(collection)` | `GET /collections/{name}/statistics`, its `total` | browser only |
@@ -158,6 +166,18 @@ options) starts again from the first page. A failed `loadMore()` leaves `status`
158
166
  calling `loadMore()` again retries. Admin's own lists work the same way, under a "Load more" button, and so does the MCP App, which
159
167
  shows the button only when the host has `serverTools` (it calls the View's tool again with the same input and the `cursor`).
160
168
 
169
+ <a id="scoped-refresh"></a>By default an operation's success reads every shown View again, since Admin cannot know which a
170
+ Procedure changed: every mounted `useView`, for each input it is mounted with, and for each page it has loaded, one request
171
+ per page (a View with five loaded pages makes five requests). To keep that cheap, unmount what is collapsed (a closed
172
+ branch of a tree reads nothing, and reads again only when it is next shown), keep `limit` small so that fewer pages
173
+ accumulate, and prefer one wider View over many narrow ones. A page with many Views (say one per expanded branch of a
174
+ tree) can also name the ones to read again:
175
+ `useOperation("move", { refresh: ["children", { view: "children", input: { parent: id } }] })`. A name covers every input of
176
+ that View; `{ view, input }` covers the one `useView(view, input)` (any `limit`); `refresh: []` reads no View again. The names and inputs are typed like `useView`'s.
177
+ Views not named are marked stale rather than read again, so each reads again when it is next shown. Entries, open entries,
178
+ totals and badges still refresh: the Procedure may write any collection, and skipping those would show stale records with no
179
+ way to name them. Leave `refresh` out when unsure; a View you forget to name keeps showing its old rows until it is shown again.
180
+
161
181
  In an MCP host, `useView` and `useOperation` call the View's or operation's admin MCP tool (its name lowercased, kebab to snake) through the host (`tools/call`),
162
182
  under the caller's MCP authorization; the browser-only hooks read `unavailable` there, so declare the data an App needs as an
163
183
  admin View. Without the host's `serverTools`, `run` is `undefined`. `useHost()` is `null` in the browser and the host's API
@@ -213,14 +233,164 @@ collection's name, `<field>` one of its fields and `<column>` a column of an adm
213
233
  | `list.actions:<schema>` | browser | `collection`, `selection` (empty in the toolbar, the selected ids in the bulk bar) |
214
234
  | `cell:<schema>.<field>` | browser | `value`, `row` (the list's columns only, `null` when empty), `property`, `surface` |
215
235
  | `cell:view:<view>.<column>` | both | `value`, `row`, `property`, `surface` |
216
- | `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface` |
236
+ | `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface`, optional `onStatusChange`, `error`, `errorId` |
217
237
 
218
238
  `record.aside:` and `record.actions:` render beside Admin's own side cards and actions, which they do not replace. A
219
- record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A read-only field
220
- (a bound or `readOnly` property) has no `input:` component, and `readOnly` tells yours that the caller cannot edit the
221
- record. In the browser Admin, a View column that reads a Schema field also goes through that field's `cell:` component, with the column's own
239
+ record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A field the Schema
240
+ does not require is `T | null` in the generated types, and so is every field of a publishing collection, whose draft may lack a
241
+ required one: it reads `null` when unset, and `onChange(null)` clears it (Save sends `null`, which the entry PATCH stores as
242
+ NULL; a field left out keeps its value). The built-in controls clear the same way, and an emptied required field is `null` too:
243
+ an operational record refuses it with a validation error, and a draft keeps it empty until Publish, which refuses it. A read-only field
244
+ (a bound or `readOnly` property) never reaches the slot: it has no `input:` component and shows its value read-only.
245
+
246
+ `readOnly` is one flag for the whole form, not for the field: it is `true` whenever this caller cannot edit this record now,
247
+ which is when any of these holds:
248
+
249
+ - the caller is not an admin, which includes the moment while `/me` is still loading, so expect it to start `true`;
250
+ - the collection is read-only (`readOnly: true` at the root of its Schema), or the entry's version is outside the safe integer range;
251
+ - an operational record has not been unlocked with "Unlock editing";
252
+ - a publishing entry is not a draft: published without `publishing: { liveEdits: true }`, or archived, which stays read-only
253
+ even with `liveEdits`. With `liveEdits` a published entry's form is editable and `readOnly` is `false`;
254
+ - a declared [Schema lock](../reference/schema.md#locks) holds the row for `update` (`entry.locks.update`). No toggle overrides
255
+ it. The form shows the lock's message, unless the collection is read-only, whose read-only hint shows instead.
256
+
257
+ The built-in form wraps the fields in a `<fieldset disabled>` that also blocks the pointer. That disables native form controls
258
+ inside it, but not a custom element that is focusable on its own (a `contenteditable`, a `tabIndex` element, a third-party
259
+ widget's buttons): it can still take keyboard focus and call `onChange`. Honour `readOnly` in your component. The Save button
260
+ stays disabled either way, so such a change is never sent.
261
+
262
+ A custom input can report validation and uncommitted local text with the optional
263
+ `onStatusChange({ error, dirty })` callback. Each call replaces that mounted root field's report:
264
+ `error` is a message (or `null`), and `dirty` says the control holds unsaved text outside `onChange`.
265
+ An error disables Save even if another field changes; a dirty local draft uses the record's existing
266
+ navigation and `beforeunload` guard. These are independent: a valid but uncommitted draft can be dirty,
267
+ and a validity error need not claim a change. Pass `{}` or call with no argument to clear both.
268
+
269
+ Admin renders the error as an alert beside the input and passes back `error` and `errorId`.
270
+ Connect your control with `aria-invalid` and `aria-describedby`, and respect `readOnly`:
271
+
272
+ ```tsx
273
+ import * as React from "react";
274
+ import type { InputProps } from "@aotter/mantle-admin/app";
275
+
276
+ function JsonInput({ value, onChange, onStatusChange, error, errorId, readOnly }: InputProps<unknown>) {
277
+ const [text, setText] = React.useState(() => JSON.stringify(value));
278
+ return <textarea value={text} readOnly={readOnly} aria-invalid={Boolean(error)} aria-describedby={errorId}
279
+ onChange={event => {
280
+ const next = event.target.value;
281
+ setText(next);
282
+ try {
283
+ const parsed = JSON.parse(next);
284
+ onChange(parsed);
285
+ onStatusChange?.({}); // the form now owns the valid draft and protects it until Save
286
+ } catch {
287
+ onStatusChange?.({ error: "Enter valid JSON", dirty: true });
288
+ }
289
+ }} />;
290
+ }
291
+ ```
292
+
293
+ Do not emit invalid text through `onChange` merely to mark the form dirty. If valid text stays local
294
+ until an explicit Apply action, keep reporting `{ dirty: true }` until Apply calls `onChange`.
295
+ The server's generated checks remain authoritative: a parsed value the component does not validate
296
+ can still be refused on Save, with the error above the form.
297
+
298
+ Reports belong to mounted **root** `input:<schema>.<field>` slots, including an object or array as
299
+ one structured value; there are no per-array-index input slots. A report clears when that field
300
+ unmounts. Another record, a clean newer server version, an explicit **Load latest version**,
301
+ or a completed Save resets the inputs and their local state. A row becoming locked/read-only
302
+ preserves its local draft and dirty navigation protection; the reported error is hidden until it
303
+ becomes editable again. Reports never override `readOnly` or enable Save on a locked row. A related
304
+ re-read preserves a dirty local draft. A draft typed while Save is in flight is kept. A custom
305
+ component that conditionally hides its own editor while its field stays mounted should clear its
306
+ report when discarding that editor's draft. Procedure input forms do not use these record slots.
307
+
308
+ At runtime the component also receives `field` and `build`, which the built-in control uses to render itself. They are internal,
309
+ not part of `InputProps`, and may change in any release: do not read them. Pass the props you were given to `renderDefault`, as
310
+ with every slot.
311
+
312
+ In the browser Admin, a View column that reads a Schema field also goes through that field's `cell:` component, with the column's own
222
313
  `cell:view:` component outside it; the MCP App renders only `cell:view:`. The developer workspace (`/admin/dev`) is Admin's own tool and does not go through any slot. A record with folded child collections opens on a workbench at `/admin/c/<schema>/<id>`, which is not `RecordView`; the record form (and so `RecordView`, `record.aside:`, `record.actions:` and `input:`) is its `/edit` page. A `Shell` that does not call `renderDefault` has no status bar: the record form's Save and Publish bar then stays in the page.
223
314
 
315
+ Creating a parent opens its field editor first. Save the parent fields, then use the named workspace link or the child-collection links above the form to continue in its workbench. Missing publish requirements name their fields; a read-only requirement must be set by an available operation or related record rather than typed into the form. Workspace links use the same unsaved-changes guard as other Admin navigation.
316
+
317
+ While the record form has unsaved changes, leaving it asks first: Admin's own links, its in-app navigation and Back/Forward
318
+ show a confirm dialog, and a reload, closing the tab or another site show the browser's own prompt (`beforeunload`). Save,
319
+ "Load latest version" or confirming the leave clears it, and a `#hash` change on the same page never asks; a cancelled Back or
320
+ Forward leaves the browser history as it was; there is nothing to configure or opt out of. Custom pages and
321
+ operation forms have no such guard. An `input:` component's own state counts when it calls `onChange`
322
+ or reports `onStatusChange({ dirty: true })`; unreported local state is outside the form's guard.
323
+
324
+ ### Media fields
325
+
326
+ A string field with `x-mantle-ref: media_assets` holds the id of an asset in the media library (`media_assets`), never a
327
+ URL. The record form renders a built-in control for it: a thumbnail of the asset, the id in a text box, **Pick**, which opens
328
+ the library in a dialog, and **Upload**, which uploads an image and fills the id. It works at the top level and in the
329
+ properties of nested objects, including objects inside an array:
330
+
331
+ ```yaml
332
+ properties:
333
+ cover: { type: string, x-mantle-ref: media_assets }
334
+ gallery:
335
+ type: array
336
+ items:
337
+ type: object
338
+ properties:
339
+ image: { type: string, x-mantle-ref: media_assets }
340
+ caption: { type: string }
341
+ ```
342
+
343
+ An array of bare ids (`items: { type: string, x-mantle-ref: media_assets }`) gets a plain text box per item; wrap the id
344
+ in an object, as above, to get the picker. The control needs the media library mounted (`site` and `media` in `admin()`,
345
+ see [media on R2](../cloudflare/media-r2.md)). Upload chooses a media purpose from the site's purposes by the collection
346
+ and field names, and is disabled when the site declares none. Procedure and View input forms render the same control with
347
+ Upload disabled, so an operation's media input is picked from the library.
348
+
349
+ Only a top-level field goes through `input:`. To add to the built-in control, wrap it with `renderDefault`:
350
+
351
+ ```tsx
352
+ "input:products.cover": (props) => (
353
+ <div>
354
+ {props.renderDefault(props)}
355
+ <p className="text-xs text-muted-foreground">Use a 1200 x 630 image; it is also the social card.</p>
356
+ </div>
357
+ ),
358
+ ```
359
+
360
+ For a custom structured input or custom page, import the browser-only picker from the optional
361
+ `@aotter/mantle-admin/media` entry. It runs inside `<MantleAdmin>` and uses the same caller-authorized media library and
362
+ upload routes. Keep the picker import in the browser entry; when also building an MCP App, use a sibling
363
+ `admin.mcp.tsx` without it ([separate entries](mcp-apps.md#customising-admin-for-mcp-hosts)). The host build rejects browser
364
+ media routes. There is no public raw media HTTP client or upload function.
365
+
366
+ ```tsx
367
+ import * as React from "react";
368
+ import { MediaPicker } from "@aotter/mantle-admin/media";
369
+
370
+ type Card = { image?: string; caption?: string };
371
+
372
+ function CardImage({ value, onChange, readOnly }: { value: Card; onChange: (value: Card) => void; readOnly: boolean }) {
373
+ const [open, setOpen] = React.useState(false);
374
+ return <>
375
+ <button type="button" disabled={readOnly} onClick={() => setOpen(true)}>Choose image</button>
376
+ <MediaPicker open={open} onOpenChange={setOpen} readOnly={readOnly} uploadPurpose="post-cover"
377
+ onSelect={asset => onChange({ ...value, image: asset.id })} />
378
+ </>;
379
+ }
380
+ ```
381
+
382
+ `onSelect` receives only `{ id, primaryUrl: string | null }` after an explicit selection and closes the dialog via
383
+ `onOpenChange(false)`. Cancel never calls `onSelect` or clears a value. The application owns the structured value and
384
+ Save; selection only updates its local draft. Pass the input's `readOnly`, and key a reused custom input by its record
385
+ identity so an open picker does not follow navigation to another record.
386
+
387
+ `uploadPurpose` is an exact site media purpose, not an asset filter: existing assets remain selectable across purposes.
388
+ Omit it for selection only; an unknown purpose disables upload, and the upload helper refuses unknown explicit purposes
389
+ rather than choosing another policy. Upload uses the existing raster image variants and server permission checks.
390
+ Uploads commit to the library independently of selection: closing, cancelling, becoming read-only or unmounting never
391
+ selects a late upload and never deletes an already committed asset. Search stays in the dialog and preserves the page's
392
+ draft. Applications that do not import `/media` do not load this optional component.
393
+
224
394
  `plugins` are fragments of `components`, applied in order: each one's `renderDefault` is the plugin before it, the first
225
395
  plugin's is the built-in component, and the application's own `components` are outermost. A plugin is
226
396
  `{ components: { ... } }`, so one can be written once and shared.
@@ -303,7 +473,9 @@ returns its one row on one page and has no cursor.
303
473
  Generated full/partial input checks validate creates, edits, operations and
304
474
  filters. Plain titled string `oneOf` choices use the same enum checks; general
305
475
  or constrained alternatives remain refused. Draft creates can be incomplete;
306
- publish checks required data. Publishing edits apply only to drafts. Read-only
476
+ publish checks required data. Publishing edits apply only to drafts unless the Schema sets
477
+ `publishing: { liveEdits: true }`, which opens published (never archived) entries; a live edit of a published entry must still pass those publish checks
478
+ (`INPUT_VALIDATION_FAILED`), keeps `published_at` and goes live on save. Read-only
307
479
  collections refuse generic creates, edits, deletes and transitions with 403 `AUTH_DENIED`
308
480
  ("This record is read-only; use its operations.") before any read or write, while
309
481
  their declared Procedures can still run.
@@ -312,7 +484,7 @@ Writes use compiler-printed native Procedure frames, bind recipes and only the
312
484
  supplied field fragments. They do not update omitted columns or run a schema
313
485
  interpreter. PATCH retains its visible-row read and exact OCC check; the mutation
314
486
  returns its row. Missing/excluded rows are 404; stale locks or failed state guards
315
- are 409. Safe versions are numbers; exact native int64 strings can be bound
487
+ are 409 (a [lock](../reference/schema.md#locks) that holds is `kind: "lock"`). Safe versions are numbers; exact native int64 strings can be bound
316
488
  without rounding, while unsafe numbers are rejected. The console keeps versions
317
489
  outside its supported safe range read-only rather than casting them to numbers.
318
490
  Full cross-target int64 output parity remains incomplete; bare driver values
@@ -331,6 +503,24 @@ from `@aotter/mantle/media`; other custom ContractErrors must carry an explicit
331
503
  status, otherwise the boundary returns a redacted 500. Do not leak native
332
504
  error messages, tokens or credentials to clients.
333
505
 
506
+ ### Leave out, replace or clear
507
+
508
+ Each write path reads a field it is given differently. Pick the one whose rule fits the edit:
509
+
510
+ | Write path | Field left out | A value | `null` |
511
+ | --- | --- | --- | --- |
512
+ | Admin create (`POST /entries`) | unset, or the Schema's default | sets it | dropped, the same as leaving it out, so the default applies |
513
+ | Admin patch (`PATCH /entries/{id}`, the record form's Save) | keeps the stored value | replaces it | clears it to NULL; a required operational field refuses it, and so does a live edit (`liveEdits`) of a field `publish` requires (`INPUT_VALIDATION_FAILED`) |
514
+ | Store `update` (a ref handler's `ctx.store`) | keeps the stored value | replaces it | clears it to NULL; on a published row a field `publish` requires is refused (`INPUT_VALIDATION_FAILED`) |
515
+ | SQL Procedure (`input.x`) | binds NULL, so `SET note = input.note` clears it | replaces it | binds NULL too, where the input's schema accepts `null` |
516
+ | `ref` handler input | the key is absent | present | present, as `null`, where the input's schema accepts it |
517
+
518
+ A SQL Procedure cannot tell a field left out from one cleared, since both bind NULL. For a partial update, keep the stored
519
+ value with `COALESCE(input.note, note)`, and give clearing its own input, such as `clearNote: true` with
520
+ `CASE WHEN input.clearNote THEN NULL ELSE COALESCE(input.note, note) END`
521
+ ([SQL patterns](sql-patterns.md#change-only-the-fields-given-coalesce)). A ref handler sees the difference: test with
522
+ `Object.hasOwn(input, "note")`, and pass only the keys present to `store.collection(...).update`.
523
+
334
524
  ## Statistics
335
525
 
336
526
  `GET /admin/api/collections/{name}/statistics?range=7d` supports primary or
@@ -386,7 +576,12 @@ In the entry editor and every operation form (including a `collectionAction` cre
386
576
  `x-mantle-ref` to another Admin collection is a searchable picker: it lists the target through the same `/entries` route
387
577
  (20 records per search, read as the caller), shows each record's title and stores the referenced value (`id`, or the
388
578
  ref's unique field). A target the caller cannot list (403/404) or that is not an Admin collection keeps the plain text
389
- input. The picker is a convenience; the server still validates the value. CSV exports use `/entries/export` and
579
+ input. In an operation bound to a row (`target`), a picker whose Schema folds under that row's collection
580
+ (`nav.parentField`) lists only that row's children, filtered by `<parentField> = <the bound row's value>` as the folded
581
+ child list is: `switch-revision`'s `revision` on a post lists that post's revisions. There is no way to pick another
582
+ parent's child there: an operation that needs one binds to that other row instead, or is not bound (`collectionAction`
583
+ or the Operations page), where the picker lists the whole target. The picker is a convenience; the
584
+ server still validates the value. CSV exports use `/entries/export` and
390
585
  `/views/{name}/export`; native browser downloads stream paged server output.
391
586
  A failure after headers aborts the stream, so a download can fail after partial
392
587
  bytes have arrived. Existing databases need an app-owned migration for the
@@ -433,7 +628,10 @@ tabs. The parent is the reference named by `Schema.spec.uiSchema.nav.parentField
433
628
  the first required unique-field reference. A Schema with two or more required `id` references to
434
629
  existing Schemas (translations excluded) and no `parentField`, with or without `standalone`, is refused by `mantle generate` with `SCHEMA_UI_INVALID`; the diagnostic lists the
435
630
  candidates and the `nav: { parentField: <field> }` line that keeps the first one that is not a parent cycle. This applies to every build, with or without Admin, so a join table with two required `id` references needs it too. `parentField` is
436
- refused on a translation child and when its parent chain leads back to the Schema. Choosing the
631
+ refused on a translation child and when its parent chain leads back to the Schema. An explicit
632
+ `parentField` decides the fold direction: a required reference from that parent back to the child
633
+ (a post's required `current_revision`) is not an inferred fold and does not count as a cycle,
634
+ while two `parentField`s pointing at each other are still refused. Choosing the
437
635
  parent changes Admin navigation only: storage and authorization are unchanged.
438
636
 
439
637
  Set `nav.standalone: true` to put a child with a required `id` reference at the top level instead;
@@ -542,6 +740,15 @@ Names such as `approve` never determine placement. An operation without a row
542
740
  target or collection action appears on the Operations page, whose sidebar link
543
741
  appears whenever such operations exist.
544
742
 
743
+ A collection action of a folded child (one with a parent; see above) also shows in that child's panel of the parent's
744
+ workbench. Run from there, the input named exactly like the child's `nav.parentField` (the child's field holding the
745
+ parent's value) is filled with this parent's value and locked: the form hides it and the dialog lists it under the
746
+ bound values, like a row operation's `id`. The name is the rule because it is unambiguous (one input at most, the same
747
+ name as the column the action writes). The value takes the input's type (a numeric parent key binds a number); an
748
+ input with that name whose `x-mantle-ref` names another Schema or key, or whose type cannot hold the parent's value, is
749
+ left to the person, and so is every input outside a workbench. With `nav.parentField: post`, `new-revision { post }` placed with
750
+ `collectionAction: post_revisions` runs on the open post.
751
+
545
752
  A record's page shows its row operations as labeled buttons in the header (more than three: two buttons and a
546
753
  "More actions" menu); list rows keep them in an "Actions for …" menu.
547
754
  Generated row metadata includes simple AND-ed equality/IN literal conditions
@@ -46,7 +46,7 @@ transport conversion or runtime schema interpreter.
46
46
  Declared authorization, ownership/TTL SQL policy, explicit OCC and atomic writes
47
47
  remain required. Generate them as the native application would implement them.
48
48
  Transaction rollback and uncertain COMMIT handling must still preserve data-loss
49
- protection and client disposal responsibility; native errors are rethrown (one exception, below), and
49
+ protection and client disposal responsibility; native errors are rethrown (with the contract exceptions below), and
50
50
  redaction before they reach HTTP users belongs to the application boundary through `wire` / `surfaceFailure`. Removing generic error
51
51
  normalization does not authorize dropping transaction outcome evidence.
52
52
 
@@ -57,12 +57,12 @@ unique `id =`), so a failed count check after autocommit has changed nothing.
57
57
  On PG it runs at the server's default isolation, not SERIALIZABLE: a concurrent
58
58
  OCC loser re-evaluates its `version =` predicate, matches zero rows and returns
59
59
  `CONFLICT` instead of SQLSTATE 40001. A server ErrorResponse (SQLSTATE and
60
- severity) is a known rollback and is rethrown natively, except a unique violation (23505, SQLite's `UNIQUE constraint failed`), which, in a generated Procedure or a Store write, is `ContractError("CONFLICT")` with the native error as `cause` on every target, and except connection,
60
+ severity) is a known rollback and is rethrown natively, except a unique violation (23505, SQLite's `UNIQUE constraint failed`), which, in a generated Procedure or a Store write, is `ContractError("CONFLICT")` with the native error as `cause` on every target, and serialization/deadlock refusals (`40001` / `40P01`) with a definitive server response, which are `CONFLICT` with `kind: "serialization"` and the native `cause`, and except connection,
61
61
  resource and shutdown classes (08, 28, 53, 57, 58, XX), which, like any failure
62
62
  without a server response (lost socket), are `OUTCOME_UNKNOWN`. SQLSTATE is
63
63
  `errno` on Bun.SQL and `code` on postgres.js. Multi-statement Procedures run in
64
64
  `begin("isolation level serializable", ...)`: a failure before the callback
65
- returns was rolled back by `begin`; only a failed COMMIT is classified as above.
65
+ returns was rolled back by `begin`; only a failed COMMIT is classified as above. The same serialization/deadlock mapping applies to Store batches and interactive transactions, including a definitive refusal at COMMIT. No write is retried automatically; re-read before deciding whether it still applies.
66
66
 
67
67
  **Date-time wire form:** a View output that is a declared `date-time` column
68
68
  (including `created_at`/`updated_at`) returns the handbook's wire form, an ISO
@@ -85,7 +85,8 @@ storage representations. Bound optional fields become explicit required values,
85
85
  with null representing absence; D1 cannot bind undefined. Native output keys are
86
86
  SQL names rather than a post-query camel-case projection. PG uses the driver's
87
87
  default type parsers (including Date and int8 strings); custom application parser
88
- configuration changes application-owned behavior. Procedures expose raw
88
+ configuration changes application-owned behavior. The Store alone converts a row's
89
+ `version` to a number ([typed queries](typed-queries.md)). Procedures expose raw
89
90
  `{ results }`, not a claim of validated authored output.
90
91
 
91
92
  The application resolves a verified Caller once at its entry, using its selected