@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +27 -8
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +34 -7
- package/dist/kernel/binds.js +5 -11
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +13 -10
- package/dist/kernel/contracts.js +14 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +4 -27
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +26 -2
- package/dist/kernel/store.js +69 -49
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
|
@@ -85,21 +85,37 @@ All paths below start with `/admin/api` and require an admin session; mutations
|
|
|
85
85
|
also require the shared same-origin check. Mount `entries(...)` for collection
|
|
86
86
|
routes. Send JSON with `Content-Type: application/json`.
|
|
87
87
|
|
|
88
|
+
These routes serve Admin's console with its session cookie; they are not a client API. A REST client reads back what it
|
|
89
|
+
wrote through the app's own Views with HTTP Triggers (`mantle help trigger`).
|
|
90
|
+
|
|
88
91
|
| Method and path | Request / response |
|
|
89
92
|
| --- | --- |
|
|
90
93
|
| `POST /entries` | Body `{ collection, data }`; creates an entry. |
|
|
91
94
|
| `GET /entries/{id}?collection=<schema>` | Reads an entry and its current `entry.version`. |
|
|
92
|
-
| `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"`. |
|
|
93
96
|
| `POST /entries/{id}/publish?collection=<schema>` | Body `{ expectedVersion }`; validates required data before publishing. |
|
|
94
97
|
| `POST /entries/{id}/unpublish?collection=<schema>` | Body `{ expectedVersion }`; returns the entry to draft. |
|
|
95
|
-
| `POST /operations/{name}` | Body is the Procedure's input; runs an Admin operation (a Procedure with `requires: ctx.admin` or a `target`). |
|
|
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). |
|
|
96
99
|
| `GET /views/{name}` | Generated admin View query parameters plus `limit`/`cursor`; returns `{ rows, nextCursor? }`. |
|
|
97
100
|
| `GET /view-batch?names=a,b` | One to 20 comma-separated admin View names, deduplicated; reads the first row of each as the caller, with the single route's checks (`requires`, scope, TTL, `authorize`), and returns `{ views: { <name>: { rows } \| { unavailable: true } \| { error: true } } }`. A refused View and a missing one are the same `{ unavailable: true }`; a View that cannot answer with no input is `{ error: true }`. Neither fails the batch. Empty or more than 20 names is a 400, before the database is acquired. |
|
|
98
101
|
| `GET /me` | Returns `{ userId, role, admin, login, image }` for the signed-in caller. |
|
|
102
|
+
| `GET /admin` | Administrators and pending invitations, plus `adminRoles` (mounted with `identity`). |
|
|
103
|
+
| `GET /members?search=&limit=&cursor=&direction=` | Pages members (1–100), searching id, name and email. |
|
|
104
|
+
| `PATCH /admin/{id}/role` | Body `{ role }`: an admin role name grants Admin, `null` removes it. You cannot change your own role. |
|
|
105
|
+
| `POST /admin/invitations` | Body `{ email, role }`; invites an administrator by email. Returns `{ ok, userId, emailSent }`. |
|
|
106
|
+
| `DELETE /admin/invitations/{id}` | Revokes an invitation nobody has signed in to yet (otherwise 409). |
|
|
99
107
|
|
|
100
108
|
Entry create/read/patch/transitions return
|
|
101
109
|
`{ collection, entry, parentEntryId, parentEntryTitle, related }`; the entry has
|
|
102
|
-
`{ 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.
|
|
103
119
|
Transitions accept `{}` to use the current version; tests should send the
|
|
104
120
|
observed `expectedVersion` to catch stale writes. Generic writes reject native
|
|
105
121
|
columns inside `data`. See [publishing](../concepts/lifecycle-and-locales.md).
|
|
@@ -137,7 +153,7 @@ refused View or collection from a missing one. Showing or hiding a component gra
|
|
|
137
153
|
| Hook | Route | Surface |
|
|
138
154
|
| --- | --- | --- |
|
|
139
155
|
| `useView(name, input, { limit? })` -> `{ rows, status, hasMore, loadMore, loadingMore }` | `GET /views/{name}` | browser and MCP App |
|
|
140
|
-
| `useOperation(name)` -> `{ run, pending }`; success refreshes
|
|
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 |
|
|
141
157
|
| `useEntries(collection, { filter?, search?, limit? })` -> `{ items, status, hasMore, loadMore, loadingMore }` | `GET /entries` (one `filter_field`/`filter_value`) | browser only |
|
|
142
158
|
| `useEntry(collection, id)` | `GET /entries/{id}` | browser only |
|
|
143
159
|
| `useCollectionTotal(collection)` | `GET /collections/{name}/statistics`, its `total` | browser only |
|
|
@@ -150,6 +166,18 @@ options) starts again from the first page. A failed `loadMore()` leaves `status`
|
|
|
150
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
|
|
151
167
|
shows the button only when the host has `serverTools` (it calls the View's tool again with the same input and the `cursor`).
|
|
152
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
|
+
|
|
153
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`),
|
|
154
182
|
under the caller's MCP authorization; the browser-only hooks read `unavailable` there, so declare the data an App needs as an
|
|
155
183
|
admin View. Without the host's `serverTools`, `run` is `undefined`. `useHost()` is `null` in the browser and the host's API
|
|
@@ -205,14 +233,164 @@ collection's name, `<field>` one of its fields and `<column>` a column of an adm
|
|
|
205
233
|
| `list.actions:<schema>` | browser | `collection`, `selection` (empty in the toolbar, the selected ids in the bulk bar) |
|
|
206
234
|
| `cell:<schema>.<field>` | browser | `value`, `row` (the list's columns only, `null` when empty), `property`, `surface` |
|
|
207
235
|
| `cell:view:<view>.<column>` | both | `value`, `row`, `property`, `surface` |
|
|
208
|
-
| `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface` |
|
|
236
|
+
| `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface`, optional `onStatusChange`, `error`, `errorId` |
|
|
209
237
|
|
|
210
238
|
`record.aside:` and `record.actions:` render beside Admin's own side cards and actions, which they do not replace. A
|
|
211
|
-
record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
214
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.
|
|
215
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
|
+
|
|
216
394
|
`plugins` are fragments of `components`, applied in order: each one's `renderDefault` is the plugin before it, the first
|
|
217
395
|
plugin's is the built-in component, and the application's own `components` are outermost. A plugin is
|
|
218
396
|
`{ components: { ... } }`, so one can be written once and shared.
|
|
@@ -295,14 +473,18 @@ returns its one row on one page and has no cursor.
|
|
|
295
473
|
Generated full/partial input checks validate creates, edits, operations and
|
|
296
474
|
filters. Plain titled string `oneOf` choices use the same enum checks; general
|
|
297
475
|
or constrained alternatives remain refused. Draft creates can be incomplete;
|
|
298
|
-
publish checks required data. Publishing edits apply only to drafts
|
|
299
|
-
|
|
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
|
|
479
|
+
collections refuse generic creates, edits, deletes and transitions with 403 `AUTH_DENIED`
|
|
480
|
+
("This record is read-only; use its operations.") before any read or write, while
|
|
481
|
+
their declared Procedures can still run.
|
|
300
482
|
|
|
301
483
|
Writes use compiler-printed native Procedure frames, bind recipes and only the
|
|
302
484
|
supplied field fragments. They do not update omitted columns or run a schema
|
|
303
485
|
interpreter. PATCH retains its visible-row read and exact OCC check; the mutation
|
|
304
486
|
returns its row. Missing/excluded rows are 404; stale locks or failed state guards
|
|
305
|
-
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
|
|
306
488
|
without rounding, while unsafe numbers are rejected. The console keeps versions
|
|
307
489
|
outside its supported safe range read-only rather than casting them to numbers.
|
|
308
490
|
Full cross-target int64 output parity remains incomplete; bare driver values
|
|
@@ -321,6 +503,24 @@ from `@aotter/mantle/media`; other custom ContractErrors must carry an explicit
|
|
|
321
503
|
status, otherwise the boundary returns a redacted 500. Do not leak native
|
|
322
504
|
error messages, tokens or credentials to clients.
|
|
323
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
|
+
|
|
324
524
|
## Statistics
|
|
325
525
|
|
|
326
526
|
`GET /admin/api/collections/{name}/statistics?range=7d` supports primary or
|
|
@@ -371,7 +571,17 @@ field and stored value. The server resolves them for the caller with one page qu
|
|
|
371
571
|
target. A missing, deleted or unreadable target is absent, so the three look the same: Admin shows the exact ID with copy.
|
|
372
572
|
A resolved title links to the related record. Admin Views and the MCP App have no such step, since their rows come from the
|
|
373
573
|
View itself; a View that wants a title selects it (join the referenced table, within its own `requires`). `/bootstrap?collection=...` can include
|
|
374
|
-
the first requested list page.
|
|
574
|
+
the first requested list page.
|
|
575
|
+
In the entry editor and every operation form (including a `collectionAction` create), a string field with
|
|
576
|
+
`x-mantle-ref` to another Admin collection is a searchable picker: it lists the target through the same `/entries` route
|
|
577
|
+
(20 records per search, read as the caller), shows each record's title and stores the referenced value (`id`, or the
|
|
578
|
+
ref's unique field). A target the caller cannot list (403/404) or that is not an Admin collection keeps the plain text
|
|
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
|
|
375
585
|
`/views/{name}/export`; native browser downloads stream paged server output.
|
|
376
586
|
A failure after headers aborts the stream, so a download can fail after partial
|
|
377
587
|
bytes have arrived. Existing databases need an app-owned migration for the
|
|
@@ -418,7 +628,10 @@ tabs. The parent is the reference named by `Schema.spec.uiSchema.nav.parentField
|
|
|
418
628
|
the first required unique-field reference. A Schema with two or more required `id` references to
|
|
419
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
|
|
420
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
|
|
421
|
-
refused on a translation child and when its parent chain leads back to the Schema.
|
|
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
|
|
422
635
|
parent changes Admin navigation only: storage and authorization are unchanged.
|
|
423
636
|
|
|
424
637
|
Set `nav.standalone: true` to put a child with a required `id` reference at the top level instead;
|
|
@@ -516,7 +729,7 @@ placement on a Schema without a sidebar entry of its own: a translation, or a fo
|
|
|
516
729
|
[`standalone`](#collection-navigation-and-operation-placement).
|
|
517
730
|
The purchasing example (`node_modules/@aotter/mantle/docs/examples/purchasing/`) puts its approval queue and requests in an Approvals group.
|
|
518
731
|
|
|
519
|
-
Operational collections have no generic create or draft: officers add records through a Procedure placed with `collectionAction` (the list header shows its button and input form), so every write is a declared, validated program. For a user-field scoped Schema
|
|
732
|
+
Operational collections have no generic create or draft: officers add records through a Procedure placed with `collectionAction` (the list header shows its button and input form), so every write is a declared, validated program. For a user-field scoped Schema the Procedure can stay SQL; see [field scope](../reference/schema.md#field-scope) for the partition input.
|
|
520
733
|
|
|
521
734
|
Admin operations are Procedures exposed by an admin MCP Trigger. Declare
|
|
522
735
|
`Procedure.spec.uiSchema.collectionAction: <schema-name>` to show the operation
|
|
@@ -527,9 +740,22 @@ Names such as `approve` never determine placement. An operation without a row
|
|
|
527
740
|
target or collection action appears on the Operations page, whose sidebar link
|
|
528
741
|
appears whenever such operations exist.
|
|
529
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
|
+
|
|
752
|
+
A record's page shows its row operations as labeled buttons in the header (more than three: two buttons and a
|
|
753
|
+
"More actions" menu); list rows keep them in an "Actions for …" menu.
|
|
530
754
|
Generated row metadata includes simple AND-ed equality/IN literal conditions
|
|
531
|
-
from the target-row UPDATE/DELETE WHERE
|
|
532
|
-
|
|
755
|
+
from the target-row UPDATE/DELETE WHERE, and an operation whose conditions the
|
|
756
|
+
row does not meet is hidden: with `WHERE … AND orderStatus = 'approved'`, "Mark
|
|
757
|
+
fulfilled" appears only once the order is approved. Missing preview values and
|
|
758
|
+
unsupported predicates leave actions available. These are
|
|
533
759
|
presentation hints: the Procedure still enforces its WHERE and authorization.
|
|
534
760
|
Conflicts show a localized refresh message with the technical diagnostic in
|
|
535
761
|
Details. Successful actions show a short summary and changes, with raw output
|
|
@@ -25,8 +25,8 @@ query. Authored output names should be emitted into SQL where valid, avoiding
|
|
|
25
25
|
an extra per-row projection merely to repair names already known at generation.
|
|
26
26
|
|
|
27
27
|
Selected REST/MCP/Admin boundaries use static generated column lineage. Declared
|
|
28
|
-
integers (including aliased system versions) are `number | string
|
|
29
|
-
target: safe integers become numbers; unsafe integers remain exact decimal text.
|
|
28
|
+
integers (including aliased system versions) are `number | string` on every
|
|
29
|
+
target, `| null` unless the View proves the column NOT NULL ([nullability](../reference/view.md#results-and-paging)): safe integers become numbers; unsafe integers remain exact decimal text.
|
|
30
30
|
Selected SQL projections cast integers and hidden integer cursor keys to text
|
|
31
31
|
before the driver reads them, preserving stored int64 values on SQLite/D1 and
|
|
32
32
|
PostgreSQL. Values already rounded when written
|
|
@@ -34,7 +34,8 @@ PostgreSQL. Values already rounded when written
|
|
|
34
34
|
`views/<name>.ts` exports both driver-native `Row` and `WireRow` (declared wire fields agree across targets);
|
|
35
35
|
`paged/views/<name>.ts` keeps native `Row` rows; REST/MCP use separate
|
|
36
36
|
`paged/wire/views/<name>.ts` SQL and convert at their boundary. SQLite/D1 declared JSON
|
|
37
|
-
and boolean columns
|
|
37
|
+
and boolean columns, and computed outputs that are JSON documents by construction
|
|
38
|
+
(JSON aggregates and builders), become JSON values and booleans, preserving aliases and nulls;
|
|
38
39
|
PostgreSQL already returns those native values.
|
|
39
40
|
Declared real/float outputs remain numbers. Computed outputs without proved lineage,
|
|
40
41
|
SQL Procedures and app-owned ref results retain native values and types.
|
|
@@ -45,7 +46,7 @@ transport conversion or runtime schema interpreter.
|
|
|
45
46
|
Declared authorization, ownership/TTL SQL policy, explicit OCC and atomic writes
|
|
46
47
|
remain required. Generate them as the native application would implement them.
|
|
47
48
|
Transaction rollback and uncertain COMMIT handling must still preserve data-loss
|
|
48
|
-
protection and client disposal responsibility; native errors are rethrown (
|
|
49
|
+
protection and client disposal responsibility; native errors are rethrown (with the contract exceptions below), and
|
|
49
50
|
redaction before they reach HTTP users belongs to the application boundary through `wire` / `surfaceFailure`. Removing generic error
|
|
50
51
|
normalization does not authorize dropping transaction outcome evidence.
|
|
51
52
|
|
|
@@ -56,12 +57,12 @@ unique `id =`), so a failed count check after autocommit has changed nothing.
|
|
|
56
57
|
On PG it runs at the server's default isolation, not SERIALIZABLE: a concurrent
|
|
57
58
|
OCC loser re-evaluates its `version =` predicate, matches zero rows and returns
|
|
58
59
|
`CONFLICT` instead of SQLSTATE 40001. A server ErrorResponse (SQLSTATE and
|
|
59
|
-
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,
|
|
60
61
|
resource and shutdown classes (08, 28, 53, 57, 58, XX), which, like any failure
|
|
61
62
|
without a server response (lost socket), are `OUTCOME_UNKNOWN`. SQLSTATE is
|
|
62
63
|
`errno` on Bun.SQL and `code` on postgres.js. Multi-statement Procedures run in
|
|
63
64
|
`begin("isolation level serializable", ...)`: a failure before the callback
|
|
64
|
-
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.
|
|
65
66
|
|
|
66
67
|
**Date-time wire form:** a View output that is a declared `date-time` column
|
|
67
68
|
(including `created_at`/`updated_at`) returns the handbook's wire form, an ISO
|
|
@@ -77,14 +78,15 @@ microseconds and days while PostgreSQL receives the canonical text. Computed val
|
|
|
77
78
|
PostgreSQL through Bun.SQL that is a `Date`, which drops microseconds. Other
|
|
78
79
|
declared scalars are driver-native.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
JSON parameters are text on every target (a JavaScript array would otherwise be bound as a PostgreSQL array): an input field read as JSON takes its authored array or object, which the generated query serializes, and a string is bound as it is. Bare PG SQL declares each JSON parameter as text
|
|
81
82
|
(`CAST(CAST($n AS text) AS jsonb)`), so Bun.SQL sends the text verbatim instead of
|
|
82
83
|
JSON-encoding it again; generation refuses a JSON parameter used any other way. SQLite bool/date/timestamp/numeric parameters use their declared
|
|
83
84
|
storage representations. Bound optional fields become explicit required values,
|
|
84
85
|
with null representing absence; D1 cannot bind undefined. Native output keys are
|
|
85
86
|
SQL names rather than a post-query camel-case projection. PG uses the driver's
|
|
86
87
|
default type parsers (including Date and int8 strings); custom application parser
|
|
87
|
-
configuration changes application-owned behavior.
|
|
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
|
|
88
90
|
`{ results }`, not a claim of validated authored output.
|
|
89
91
|
|
|
90
92
|
The application resolves a verified Caller once at its entry, using its selected
|
|
@@ -11,6 +11,12 @@ here instead of repeating it.
|
|
|
11
11
|
- **App-owned pages** in `src/routes.ts`, when the page needs its own layout, JSON-LD or languages. The reference
|
|
12
12
|
implementation is `node_modules/@aotter/mantle/docs/examples/recipes` (`src/pages.ts`, `src/routes.ts`; `smoke.mjs` checks every item below).
|
|
13
13
|
|
|
14
|
+
## URLs
|
|
15
|
+
|
|
16
|
+
Mantle owns `/admin`, `/mcp`, `/api/auth/`, `/oauth/consent` and `/.well-known/oauth-*`, and the app's HTTP Triggers own
|
|
17
|
+
their declared paths. Give visitor pages their own prefix (`/{locale}/…`, `/teas/{slug}`), or make a root page (`/{slug}`)
|
|
18
|
+
return nothing for those paths; the app shell already keeps `/admin` and `/mcp` away from `src/routes.ts`.
|
|
19
|
+
|
|
14
20
|
## Every public page
|
|
15
21
|
|
|
16
22
|
- Server-rendered HTML per URL with the content in the body. A client-only SPA shell or metadata set by script is not
|
|
@@ -26,7 +32,9 @@ here instead of repeating it.
|
|
|
26
32
|
|
|
27
33
|
- JSON-LD of the matching schema.org type (`Recipe`, `Article`, `Event`, `Product`, ...), built from the same row as
|
|
28
34
|
the visible content: `name`, `description`, `url`, `inLanguage`, `datePublished`, `dateModified`, and `image` when
|
|
29
|
-
there is one (some rich results require it).
|
|
35
|
+
there is one (some rich results require it). A type that has no date or language properties (`Product`) gets them on
|
|
36
|
+
a page node instead: an `@graph` with an `ItemPage` (`inLanguage`, dates, `mainEntity`) pointing to the `Product`
|
|
37
|
+
(`offers` with `price` and `priceCurrency`). Escape `<` in the serialized JSON so stored text cannot close the
|
|
30
38
|
script element. Do not add facts the page does not show.
|
|
31
39
|
- A short answer block for answer engines: one question heading and a one-sentence answer from the row's data.
|
|
32
40
|
- A Markdown alternate at `<url>.md`, linked with `<link rel="alternate" type="text/markdown">`.
|
|
@@ -46,8 +54,40 @@ here instead of repeating it.
|
|
|
46
54
|
- `/llms.txt` ([llmstxt.org](https://llmstxt.org)): a title, a one-line summary and a link to each item's Markdown
|
|
47
55
|
alternate.
|
|
48
56
|
|
|
57
|
+
## A complete site
|
|
58
|
+
|
|
59
|
+
When the brief names a site, shop, catalogue or blog for visitors, these are part of the work without the user
|
|
60
|
+
listing them:
|
|
61
|
+
|
|
62
|
+
- Every item on this page, for every public page type the site has.
|
|
63
|
+
- Accessible, responsive pages with the app's own styling; the examples are unstyled on purpose.
|
|
64
|
+
- Content managed in Admin (draft, publish, edit, unpublish) shows on the site without a rebuild. Images go through
|
|
65
|
+
Admin's media library (`mantle help media-r2`); store committed asset IDs and never use an ID as an image URL.
|
|
66
|
+
- Members' pages, if any, send anonymous visitors to `/sign-in` (`signInRedirect` in the app shell's `src/sign-in.ts`).
|
|
67
|
+
- Who runs the site: an About/contact page and a footer with the business's name, location and contact, and on the
|
|
68
|
+
home page `Organization` (or `LocalBusiness`) JSON-LD with the same facts and the logo. Answer engines use these to
|
|
69
|
+
say whether the site is legitimate. Ask for the facts the brief lacks; never invent an address.
|
|
70
|
+
- A favicon, and `og:image` on the home and list pages too.
|
|
71
|
+
- A detail page leads to the action the brief sells (order, register, book), saying when it needs an account.
|
|
72
|
+
- A cache policy: `Last-Modified` (from the row's `updated_at`) or an `ETag` on pages, `.md`, sitemap and `llms.txt`,
|
|
73
|
+
so crawlers can revalidate; `no-store` everywhere is safe but makes every crawl a full fetch.
|
|
74
|
+
- Structured data and answer blocks state only what the page shows. Never promise ranking or AI citation.
|
|
75
|
+
|
|
49
76
|
## Check before you report done
|
|
50
77
|
|
|
51
|
-
Request
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
Request each page without running JavaScript (plain `fetch` in `smoke.mjs`; `mantle help local-testing`) and assert:
|
|
79
|
+
|
|
80
|
+
1. HTML content type and the visible content; `<title>`, description, canonical on `PUBLIC_ORIGIN`, Open Graph; on
|
|
81
|
+
detail pages JSON-LD that parses with the visible name, and the answer block.
|
|
82
|
+
2. Each `.md` alternate returns `text/markdown` with the current text.
|
|
83
|
+
3. Walk the list View's complete cursor chain: its rows equal the `llms.txt` entries and the sitemap's detail URLs
|
|
84
|
+
(below the generated `web()` caps: 5,000 rows for `llms.txt`, 50,000 for the sitemap). `/robots.txt` names the
|
|
85
|
+
sitemap.
|
|
86
|
+
4. An unknown URL, a malformed path, a draft and a row the visitor may not read are 404, also with an admin session.
|
|
87
|
+
5. Publish, edit and unpublish an item through Admin's API (or the Procedure Admin uses); after each, its page,
|
|
88
|
+
`.md`, sitemap and `llms.txt` follow.
|
|
89
|
+
6. Stored text with `<`, `&` and `</script>` renders escaped in HTML and inside JSON-LD.
|
|
90
|
+
|
|
91
|
+
Report a check you could not run (for example a production origin you do not have) as UNVERIFIED with the reason.
|
|
92
|
+
Then run [independent acceptance](agent-workflow-acceptance.md) with a visitor evaluator in a browser on the
|
|
93
|
+
`pnpm accept` URL.
|