@aotter/mantle 0.2.0 → 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.
Files changed (82) hide show
  1. package/README.md +6 -2
  2. package/dist/build/compiler.d.ts +4 -4
  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-P6PP6VVV.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/target-hash.js +1 -1
  13. package/dist/infrastructure/adminEntries.js +19 -3
  14. package/dist/infrastructure/bundle.js +43 -8
  15. package/dist/infrastructure/cli.js +1 -1
  16. package/dist/infrastructure/generate.js +17 -3
  17. package/dist/kernel/binds.d.ts +18 -3
  18. package/dist/kernel/binds.js +1 -1
  19. package/dist/kernel/contracts.d.ts +7 -0
  20. package/dist/kernel/contracts.js +9 -0
  21. package/dist/kernel/driver/bun-sqlite.js +3 -3
  22. package/dist/kernel/driver/d1.d.ts +1 -1
  23. package/dist/kernel/driver/d1.js +6 -3
  24. package/dist/kernel/driver/postgres.d.ts +2 -1
  25. package/dist/kernel/driver/postgres.js +11 -6
  26. package/dist/kernel/mcp-handler.js +2 -1
  27. package/dist/kernel/rest-router.js +3 -5
  28. package/dist/kernel/rest.d.ts +2 -1
  29. package/dist/kernel/rest.js +1 -0
  30. package/dist/kernel/route-pattern.d.ts +26 -0
  31. package/dist/kernel/route-pattern.js +34 -0
  32. package/dist/kernel/store.d.ts +15 -1
  33. package/dist/kernel/store.js +51 -18
  34. package/dist/usecase/adminBindings.js +2 -2
  35. package/dist/usecase/adminClient.js +4 -1
  36. package/dist/usecase/generate.js +7 -5
  37. package/dist/usecase/handlers.js +1 -1
  38. package/dist/usecase/publishing.js +20 -7
  39. package/dist/usecase/rest.js +5 -5
  40. package/dist/usecase/stores.js +8 -3
  41. package/docs/examples/README.md +1 -1
  42. package/docs/examples/app-shell/README.md +58 -10
  43. package/docs/examples/app-shell/package.json +6 -6
  44. package/docs/examples/club-events/manifests/club-events.yaml +2 -0
  45. package/docs/examples/generated-web/seed.sql +5 -4
  46. package/docs/examples/purchasing/manifests/purchasing.yaml +3 -0
  47. package/docs/handbook/cloudflare/media-r2.md +6 -3
  48. package/docs/handbook/cloudflare/service-entry.md +2 -0
  49. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  50. package/docs/handbook/examples/hub.md +1 -1
  51. package/docs/handbook/guides/admin-ui.md +1 -1
  52. package/docs/handbook/guides/agent-setup.md +13 -8
  53. package/docs/handbook/guides/agent-workflow-acceptance.md +129 -19
  54. package/docs/handbook/guides/local-testing.md +50 -5
  55. package/docs/handbook/guides/native-admin.md +218 -11
  56. package/docs/handbook/guides/native-execution.md +5 -4
  57. package/docs/handbook/guides/revisions.md +584 -0
  58. package/docs/handbook/guides/size-and-startup.md +140 -0
  59. package/docs/handbook/guides/sql-patterns.md +47 -1
  60. package/docs/handbook/guides/typed-queries.md +8 -3
  61. package/docs/handbook/navigation.json +4 -0
  62. package/docs/handbook/reference/authorization.md +2 -0
  63. package/docs/handbook/reference/describe.md +42 -0
  64. package/docs/handbook/reference/diagnostics.md +12 -3
  65. package/docs/handbook/reference/procedure.md +4 -4
  66. package/docs/handbook/reference/schema.md +71 -7
  67. package/docs/handbook/reference/sql.md +2 -1
  68. package/docs/handbook/reference/surface.md +3 -2
  69. package/docs/handbook/reference/trigger.md +12 -0
  70. package/docs/handbook/releases/index.md +44 -1
  71. package/docs/handbook/start/agents.md +6 -2
  72. package/docs/handbook/start/project-and-cli.md +4 -0
  73. package/docs/handbook/start/quickstart-worker.md +1 -1
  74. package/docs/skills/develop/SKILL.md +15 -3
  75. package/docs/skills/integrate/SKILL.md +3 -1
  76. package/docs/skills/performance/SKILL.md +169 -0
  77. package/docs/skills/update/SKILL.md +9 -5
  78. package/docs/upgrade-0.1-to-0.2.md +151 -39
  79. package/package.json +9 -9
  80. package/skills/README.md +6 -2
  81. package/skills/mantle/SKILL.md +19 -5
  82. package/dist/domain/describe/chunk-SGMRVDGE.js +0 -1
@@ -0,0 +1,584 @@
1
+ ---
2
+ description: Correct published content without taking it offline - a publishing parent that points at one of its operational revisions, an atomic switch Procedure, a preview route, rollback and pruning.
3
+ ---
4
+ # Edit published content without downtime
5
+
6
+ Admin's record form edits a `publishing` row only while it is a draft, so correcting a published post through it means
7
+ unpublish, edit, republish, and the page is gone in between. This page splits the post into a stable parent and its
8
+ revisions, so the public page keeps showing one revision while an editor prepares the next, previews it, and switches
9
+ to it with one atomic batch. A [Schema lock](../reference/schema.md#locks) permanently seals a selected revision
10
+ together with the switch, for every caller. Everything is manifests, one ref handler and one app route; nothing depends on
11
+ generated storage internals.
12
+
13
+ `packages/mantle/tests/revisions.test.ts` runs these manifests, the handler and the route on bun:sqlite and PostgreSQL
14
+ (the yaml blocks below are its fixture, and the docs check generates them and typechecks the TypeScript), and
15
+ `packages/mantle-admin/tests/native/revisions.test.ts` drives the same app through Admin's routes.
16
+
17
+ ## When to use it
18
+
19
+ Use it when a published page must stay up while its replacement is written and reviewed: articles, product copy, legal
20
+ text, anything with inbound links or search traffic. It also supports reviewing the content that a successful switch will select, and rollback. A preview URL may
21
+ change while its revision is still editable; the custom Admin page freezes the reviewed content and binds its version.
22
+
23
+ Without revisions, keep one `publishing` Schema. A personal blog whose admins correct typos can opt into
24
+ `publishing: { liveEdits: true }`: Admin's record form then edits a published post in place, every save goes live on
25
+ commit with no preview, and the post must still pass `publish`'s required-field checks
26
+ ([lifecycle](../concepts/lifecycle-and-locales.md)). Without `liveEdits`, unpublish, edit and republish; that is fine for
27
+ content nobody minds being offline for a minute. Both are less to build than this page.
28
+
29
+ ## The shape
30
+
31
+ - **`posts`** (`lifecycle: publishing`) is the stable identity: the `slug`, and anything else that makes up the URL,
32
+ plus `current_revision`, a reference to the revision the page shows. Its publishing status alone decides whether the
33
+ URL is live.
34
+ - **`post_revisions`** (operational) holds the content (`title`, `summary`, `body`) and a `post` reference.
35
+ `nav.parentField: post` folds them under their post in Admin. A revision edits in place until it is selected for a
36
+ switch; then the `sealed_revision` lock permanently refuses every update and delete of it, whoever asks (admins
37
+ included): Admin's routes and the MCP clients that use them, Procedures over REST or MCP, and caller-bound Stores.
38
+ Historical revisions stay sealed so they remain safe rollback targets.
39
+ - **Public Views** read a post joined to its current revision. Public Views only see published rows, so a draft or
40
+ unpublished post shows nothing.
41
+ - **`switch-revision`** is an admin-only ref Procedure. It checks the revision belongs to the post and is complete, then
42
+ binds the revision version seen in preview and atomically seals the candidate and writes `current_revision`
43
+ through `ctx.store.batch`, with OCC on both rows. Readers see the old revision until that parent write commits and the new one after. A
44
+ stale candidate or parent version is `CONFLICT`; PostgreSQL serialization/deadlock aborts are also
45
+ `CONFLICT`, with `kind: "serialization"`. It is a ref handler
46
+ because a SQL Procedure cannot write a `publishing` Schema.
47
+ - **`new-revision`** copies the current revision into a new row to edit. **`prune-revision`** deletes a revision that is
48
+ not sealed. **`revision-preview`** reads any revision for the preview route.
49
+
50
+ ## Schemas
51
+
52
+ `current_revision`, `post` and `sealed` are `readOnly`: Admin's record form shows them but never writes them, so only
53
+ `switch-revision` moves a post to another revision and seals its candidate, and a revision never moves to another post.
54
+ snake_case field names keep the manifest name and the Store's storage name the same (`current_revision`, not
55
+ `currentrevision`).
56
+
57
+ `current_revision` is `required`, so `publish` refuses a post without one (a draft may still lack it). A new revision
58
+ starts with `sealed: false`; `switch-revision` changes it to true with the revision version it validated. The lock is
59
+ on the revision's own stored row, so the database checks it together with OCC when competing writers reach that row.
60
+ Unlike an `EXISTS` check on another table's current pointer, it cannot admit an in-flight edit using an older parent
61
+ snapshot. A same-row predicate selected by `id` needs no extra index.
62
+
63
+ ```yaml
64
+ apiVersion: cms.mantle.aotter.net/v2
65
+ kind: Schema
66
+ metadata: { name: posts }
67
+ spec:
68
+ title: Posts
69
+ lifecycle: publishing
70
+ uniqueIndexes: [[slug]]
71
+ schema:
72
+ type: object
73
+ additionalProperties: false
74
+ required: [slug, current_revision]
75
+ properties:
76
+ slug: { type: string, pattern: "^[a-z0-9-]+$", maxLength: 100 }
77
+ # Written only by switch-revision: Admin's record form leaves it alone.
78
+ current_revision: { type: string, x-mantle-ref: post_revisions, readOnly: true }
79
+ ---
80
+ apiVersion: cms.mantle.aotter.net/v2
81
+ kind: Schema
82
+ metadata: { name: post_revisions }
83
+ spec:
84
+ title: Post revisions
85
+ lifecycle: operational
86
+ indexes: [[post, createdAt, id]]
87
+ lock:
88
+ - name: sealed_revision
89
+ on: [update, delete]
90
+ when: sealed = true
91
+ message: "This revision is sealed. Start a New revision from its post, edit and preview it, then Switch revision."
92
+ uiSchema:
93
+ nav: { parentField: post }
94
+ list: { primaryField: title, columns: [note] }
95
+ fields: { body: { widget: textarea } }
96
+ schema:
97
+ type: object
98
+ additionalProperties: false
99
+ required: [post, sealed, title, summary, body]
100
+ properties:
101
+ # Set once by new-revision; a revision never moves to another post.
102
+ post: { type: string, x-mantle-ref: posts, readOnly: true }
103
+ sealed: { type: boolean, readOnly: true }
104
+ title: { type: string }
105
+ summary: { type: string }
106
+ body: { type: string }
107
+ note: { type: string }
108
+ ```
109
+
110
+ The required reference does not fold `posts` under `post_revisions`: the revisions' explicit `nav.parentField` decides
111
+ the direction. An editor's checklist is "switch a revision in, then publish": Admin refuses publishing first with
112
+ `INPUT_VALIDATION_FAILED` naming `current_revision`, and the generated `posts.publish` Procedure with `CONFLICT`.
113
+
114
+ ## Views
115
+
116
+ The join also requires `r.post = p.id`, so a post never shows another post's revision even if its reference was
117
+ written by other code. A lookup by a unique field ends its `ORDER BY` with the Schema's `id`, which generation needs to
118
+ prove it returns one row. `published_at` is the first publication; `switched_at` (the post's `updated_at`, which the
119
+ switch moves) and `edited_at` give the page's last-modified date.
120
+
121
+ ```yaml
122
+ apiVersion: cms.mantle.aotter.net/v2
123
+ kind: View
124
+ metadata: { name: post-page }
125
+ spec:
126
+ description: A published post with its current revision, by slug.
127
+ surface: public
128
+ input:
129
+ type: object
130
+ additionalProperties: false
131
+ required: [slug]
132
+ properties:
133
+ slug: { type: string }
134
+ sql: |
135
+ SELECT p.slug, r.title, r.summary, r.body, p.published_at, p.updated_at AS switched_at, r.updated_at AS edited_at
136
+ FROM posts p JOIN post_revisions r ON r.id = p.current_revision AND r.post = p.id
137
+ WHERE p.slug = input.slug
138
+ ORDER BY p.id
139
+ ---
140
+ apiVersion: cms.mantle.aotter.net/v2
141
+ kind: View
142
+ metadata: { name: published-posts }
143
+ spec:
144
+ description: Every published post with the title of its current revision, for the list page and the sitemap.
145
+ surface: public
146
+ sql: |
147
+ SELECT p.id, p.slug, r.title, r.summary
148
+ FROM posts p JOIN post_revisions r ON r.id = p.current_revision AND r.post = p.id
149
+ ORDER BY p.id
150
+ ---
151
+ apiVersion: cms.mantle.aotter.net/v2
152
+ kind: View
153
+ metadata: { name: revision-preview }
154
+ spec:
155
+ description: Any revision as its post's page would show it, for the preview route.
156
+ surface: admin
157
+ requires: { auth: { all: [ctx.admin] } }
158
+ uiSchema: { nav: { hidden: true } }
159
+ input:
160
+ type: object
161
+ additionalProperties: false
162
+ required: [revision]
163
+ properties:
164
+ revision: { type: string }
165
+ # A new query identity for each explicit preview; it does not change the SQL result.
166
+ request: { type: string }
167
+ sql: |
168
+ SELECT r.id, r.version, r.sealed, p.id AS post, p.version AS post_version, p.current_revision, p.slug, r.title, r.summary, r.body, r.updated_at AS edited_at
169
+ FROM post_revisions r JOIN posts p ON p.id = r.post
170
+ WHERE r.id = input.revision
171
+ ORDER BY r.id
172
+ ```
173
+
174
+ `revision-preview` reads drafts and unpublished posts too, because its caller is an admin. It is hidden from the
175
+ sidebar, since it needs an input; the admin MCP endpoint and the preview route still reach it.
176
+
177
+ ## Start a new revision
178
+
179
+ One SQL statement copies the current revision (or starts empty when the post has none). `target` puts a "New revision"
180
+ button on the post's page in Admin with the post filled in; `collectionAction` also puts it in the revisions list
181
+ header: in the post's Post revisions tab the post is filled in (the input is named like `nav.parentField`), and in the
182
+ standalone list the editor picks it. It runs as one statement on every target, D1 included.
183
+
184
+ ```yaml
185
+ apiVersion: cms.mantle.aotter.net/v2
186
+ kind: Procedure
187
+ metadata: { name: new-revision }
188
+ spec:
189
+ title: New revision
190
+ description: Start a new revision of this post from its current one; a post without one starts empty.
191
+ requires: { auth: { all: [ctx.admin] } }
192
+ target: { schema: posts, id: post }
193
+ uiSchema: { collectionAction: post_revisions }
194
+ input:
195
+ type: object
196
+ additionalProperties: false
197
+ required: [post]
198
+ properties:
199
+ post: { type: string }
200
+ output: { type: object }
201
+ handler:
202
+ sql: |
203
+ INSERT INTO post_revisions (post, sealed, title, summary, body, note)
204
+ SELECT p.id, false, COALESCE(r.title, ''), COALESCE(r.summary, ''), COALESCE(r.body, ''), CASE WHEN r.id IS NULL THEN 'First revision' ELSE 'Copied from the current revision' END
205
+ FROM posts p LEFT JOIN post_revisions r ON r.id = p.current_revision AND r.post = p.id
206
+ WHERE p.id = input.post
207
+ RETURNING id, version
208
+ ```
209
+
210
+ `INSERT … SELECT` is a set write: an unknown post inserts nothing and returns `results: [[]]` rather than an error.
211
+
212
+ ## Switch, and roll back
213
+
214
+ The Procedure is bound to the post (`target` with `version: expectedVersion`), so Admin shows "Switch revision" on the
215
+ post's page with the parent version the editor reviewed; `expectedRevisionVersion` comes from the preview and `revision` is a picker over that post's revisions (it folds
216
+ under the post, so Admin lists only the bound post's children).
217
+
218
+ ```yaml
219
+ apiVersion: cms.mantle.aotter.net/v2
220
+ kind: Procedure
221
+ metadata: { name: switch-revision }
222
+ spec:
223
+ title: Switch revision
224
+ description: Make one of this post's complete revisions the one its page shows. Also rolls back to an earlier revision.
225
+ requires: { auth: { all: [ctx.admin] } }
226
+ target: { schema: posts, id: post, version: expectedVersion }
227
+ input:
228
+ type: object
229
+ additionalProperties: false
230
+ required: [post, revision, expectedVersion, expectedRevisionVersion]
231
+ properties:
232
+ post: { type: string }
233
+ revision: { type: string, x-mantle-ref: post_revisions }
234
+ expectedVersion: { type: integer }
235
+ expectedRevisionVersion: { type: integer }
236
+ output: { type: object }
237
+ handler: { ref: switch-revision }
238
+ ```
239
+
240
+ The handler, in `src/handlers.ts`. "Complete" here means every text field is non-blank: an operational `required`
241
+ field is NOT NULL but may be `''`, which `new-revision` writes for a post's first revision. Put your own checks (a
242
+ minimum length, a reviewed flag) in the same place. A revision of another post, or a missing one, is
243
+ `INPUT_VALIDATION_FAILED` (400); a stale parent or preview version is `CONFLICT` (409).
244
+
245
+ ```ts
246
+ import { ContractError } from "@aotter/mantle/contracts";
247
+ import type { Handlers } from "../generated/generated.js";
248
+
249
+ export const handlers: Handlers = {
250
+ "switch-revision": async ({ store }, { post, revision, expectedVersion, expectedRevisionVersion }) => {
251
+ const next = await store.collection("post_revisions").findById(revision);
252
+ if (!next || next.post !== post) throw new ContractError("INPUT_VALIDATION_FAILED", "That revision is not one of this post's revisions.", "procedures/switch-revision");
253
+ if (next.version !== expectedRevisionVersion) throw new ContractError("CONFLICT", "The revision changed since preview. Preview it again before switching.", "procedures/switch-revision", { fields: { kind: "row" } });
254
+ if (![next.title, next.summary, next.body].every(text => text.trim())) throw new ContractError("INPUT_VALIDATION_FAILED", "The revision is incomplete: it needs a title, a summary and a body.", "procedures/switch-revision");
255
+ const parent = store.collection("posts").update(post, { current_revision: revision }, { lock: expectedVersion });
256
+ // A sealed revision is immutable; otherwise the revision CAS and parent CAS commit or roll back together.
257
+ const row = next.sealed ? await parent : (await store.batch([
258
+ store.collection("post_revisions").update(revision, { sealed: true }, { lock: expectedRevisionVersion }),
259
+ parent,
260
+ ]))[1]!;
261
+ return { id: row.id, version: row.version, current_revision: row.current_revision };
262
+ },
263
+ };
264
+ ```
265
+
266
+ The preview's revision `id` and `version` are the switch's `revision` and `expectedRevisionVersion`. The handler
267
+ refuses a changed revision rather than silently publishing newer text. A same-row CAS seals the exact previewed
268
+ content and the parent's CAS selects it in one `store.batch`: D1 uses its native atomic batch with row assertions;
269
+ SQLite and PostgreSQL use their native transactions. Inside `store.transaction`, `ctx.store.batch` joins that same
270
+ transaction; it does not open a nested transaction. A failed batch prevents the outer transaction from committing,
271
+ even if application code catches the error. A parent conflict rolls back the seal too, leaving the draft
272
+ editable. A revision edit or delete that wins first makes the seal fail and leaves the public page unchanged. Once
273
+ the seal wins, caller updates and deletes fail, even if they started earlier. PostgreSQL serialization/deadlock aborts become `CONFLICT` with
274
+ `kind: "serialization"`; neither write commits. Re-read and preview again rather than automatically retrying.
275
+ A lost connection during COMMIT can instead be `OUTCOME_UNKNOWN`: reconcile the stored state before another attempt.
276
+
277
+ The generic operation form remains available to tools, but the [custom Admin page below](#custom-admin-preview-and-switch)
278
+ automatically submits both versions from one reviewed snapshot. Do not re-read and substitute
279
+ a newer revision version on submit, or automatically retry a conflict: require another preview. A preview URL itself
280
+ is mutable until the switch; the guarantee is that a successful switch selects the observed version. The recipe adds
281
+ no separate Prepare step. A stale parent version needs an explicit retry with its fresh version and the same reviewed
282
+ revision version. On a failure or unknown outcome, reconcile the stored parent pointer and both versions before retrying.
283
+
284
+ A transaction around a read-and-switch handler alone is insufficient on PostgreSQL when other edits remain
285
+ autocommit. The revision CAS participates in the atomic write, so the same-row seal avoids that mixed-isolation race.
286
+ Sealed history stays immutable for safe rollback and cannot be pruned through caller paths; retain it, or use an
287
+ explicitly reviewed system maintenance policy outside this editor workflow.
288
+
289
+ **Upgrading an existing app:** generated `schema.sql` initializes an empty database; it is not a migration. Rehearse a
290
+ native migration that adds and backfills `sealed` (true for every currently selected revision, and for historical
291
+ revisions you retain as immutable rollback targets; false for editable drafts), then enforces NOT NULL. Coordinate
292
+ the rollout so old switch handlers cannot select an unsealed revision while the new invariant is being installed.
293
+ Pause editor writes or use an equivalent deployment boundary for the migration and handler change. Existing generic
294
+ clients must adopt the new lock projection; the server still enforces the seal for stale clients. See
295
+ [database ownership](../concepts/runtime-and-adapters.md#schema-installation-and-migrations).
296
+
297
+ Rolling back is the same call with an earlier revision. Wire the handlers as every app does
298
+ (`adminBindings({ handlers })`, `rest(...)`, `mcp(...)` options; see `mantle help native-admin`).
299
+
300
+ ## Prune
301
+
302
+ Unsealed drafts can be deleted; sealed content history stays immutable. The Procedure is a plain `DELETE`: the
303
+ `sealed_revision` lock adds its condition to the statement, so deleting any sealed revision deletes nothing and is
304
+ `CONFLICT` with `kind: "lock"` and the lock's message. Admin shows "Delete revision" on each revision. Its own Delete
305
+ button on a revision's row (in the Post revisions list and tab) is disabled for sealed revisions, with the lock's
306
+ message, and the server refuses it the same way if it is sent anyway. The Procedure also gives AI clients on
307
+ `/mcp/admin` a delete tool (below).
308
+
309
+ ```yaml
310
+ apiVersion: cms.mantle.aotter.net/v2
311
+ kind: Procedure
312
+ metadata: { name: prune-revision }
313
+ spec:
314
+ title: Delete revision
315
+ description: Delete an editable revision that has never been sealed.
316
+ requires: { auth: { all: [ctx.admin] } }
317
+ target: { schema: post_revisions, id: id }
318
+ input:
319
+ type: object
320
+ additionalProperties: false
321
+ required: [id]
322
+ properties:
323
+ id: { type: string }
324
+ output: { type: object }
325
+ handler:
326
+ sql: |
327
+ DELETE FROM post_revisions WHERE id = input.id RETURNING id
328
+ ```
329
+
330
+ Only a trusted write bypasses the lock: the system caller, a system Store or the TTL sweep, for a migration or a
331
+ maintenance script.
332
+ That is the escape hatch, visible in code review, not the editor's path.
333
+
334
+ ## Admin operations
335
+
336
+ Admin lists an operation only when an admin MCP Trigger exposes it; the same Triggers give an AI client on `/mcp/admin`
337
+ the same three tools.
338
+
339
+ ```yaml
340
+ apiVersion: cms.mantle.aotter.net/v2
341
+ kind: Trigger
342
+ metadata: { name: new-revision-admin }
343
+ spec: { source: { kind: mcp, surface: admin }, target: { procedure: new-revision } }
344
+ ---
345
+ apiVersion: cms.mantle.aotter.net/v2
346
+ kind: Trigger
347
+ metadata: { name: switch-revision-admin }
348
+ spec: { source: { kind: mcp, surface: admin }, target: { procedure: switch-revision } }
349
+ ---
350
+ apiVersion: cms.mantle.aotter.net/v2
351
+ kind: Trigger
352
+ metadata: { name: prune-revision-admin }
353
+ spec: { source: { kind: mcp, surface: admin }, target: { procedure: prune-revision } }
354
+ ```
355
+
356
+ An editor's flow:
357
+
358
+ 1. Create the post in Posts (a draft with its slug), then **New revision** on its page (or in its Post revisions tab,
359
+ with the post filled in) and write the revision in the record form. Use **Revisions** in the sidebar to select and preview it, then **Switch to reviewed revision**, then **Publish** the post. Publishing before the switch is refused, naming
360
+ `current_revision`.
361
+ 2. To correct it later: open the live revision and Admin shows "This record is locked." with the lock's message, and
362
+ Save is disabled; so is the Delete button on its row in the Post revisions list. So: **New revision** (a copy of what is live), edit the copy, then use **Revisions** to preview and switch it; the picker lists only this post's revisions. The page shows the old text until the switch commits;
363
+ then the copy is the frozen one and the old revision stays sealed for rollback.
364
+ 3. To roll back: select and preview the earlier one in **Revisions**, then **Switch to reviewed revision**. **Delete revision** on unsealed drafts you no longer want; sealed history is retained.
365
+
366
+ A client that skips Admin's form gets the same answer: a patch or delete of a sealed revision through Admin's routes
367
+ (the Admin MCP App and WebMCP use them), a SQL Procedure's `UPDATE` or `DELETE` of it over REST or MCP, or a ref
368
+ handler's `ctx.store` update is `CONFLICT` with `kind: "lock"` and the lock's message.
369
+
370
+ ## Custom Admin preview and switch
371
+
372
+ Use an application-owned page instead of asking editors to copy version numbers into the generic operation form.
373
+ Generate with `--admin`, then add this `src/admin.tsx`. **Revisions** appears in the sidebar at `/admin/p/revisions`;
374
+ the post's edit form also gets a link with that post selected. The post workspace remains the place to create a new
375
+ revision, edit its draft and publish the post for the first time. Returning to this page does not discard a saved draft.
376
+
377
+ Each **Preview selected revision** uses a new `request` UUID in the View input. This is an application query identity,
378
+ not a stored field: `useView` caches by input and has no imperative refetch API, so remounting alone could reuse a cached
379
+ preview while a fresh read is in flight. The View reads the revision content, its version, and the parent's version/current
380
+ selection in one statement. The component freezes that result; related-operation refreshes cannot silently replace what was reviewed.
381
+ Versions are converted only after a safe-integer check, including PostgreSQL's int8 text. The explicit switch submits
382
+ those two versions unchanged. A conflict disables the old approval and requires another explicit preview; an uncertain
383
+ outcome also requires reading and reviewing the current selection before another attempt. Nothing retries a write.
384
+
385
+ ```tsx
386
+ // src/admin.tsx
387
+ import * as React from "react";
388
+ import { AdminLink, defineAdmin } from "@aotter/mantle-admin/app";
389
+ import { admin, useEntries, useEntry, useOperation, useView } from "../generated/admin-client.js";
390
+
391
+ type Review = { revision: string; post: string; current: string | null; slug: string | null;
392
+ title: string; summary: string; body: string; expectedVersion: number; expectedRevisionVersion: number };
393
+ const version = (value: unknown): number => {
394
+ const n = Number(value);
395
+ if (!Number.isSafeInteger(n) || n < 1) throw Error("This version cannot be represented safely.");
396
+ return n;
397
+ };
398
+
399
+ function Preview({ input }: { input: { revision: string; request: string } }) {
400
+ const { rows, status } = useView("revision-preview", input);
401
+ const change = useOperation("switch-revision", { refresh: ["revision-preview"] });
402
+ const [review, setReview] = React.useState<Review>();
403
+ const [phase, setPhase] = React.useState<"loading" | "ready" | "switching" | "failed" | "done">("loading");
404
+ const [message, setMessage] = React.useState("");
405
+ React.useEffect(() => {
406
+ if (phase !== "loading" || status !== "ready") return;
407
+ const row = rows[0];
408
+ try {
409
+ if (!row) throw Error("This revision is unavailable.");
410
+ setReview({ revision: row.id, post: row.post, current: row.current_revision, slug: row.slug,
411
+ title: row.title, summary: row.summary, body: row.body,
412
+ expectedVersion: version(row.post_version), expectedRevisionVersion: version(row.version) });
413
+ setPhase("ready");
414
+ } catch (error) { setMessage(String(error)); setPhase("failed"); }
415
+ }, [rows, status, phase]);
416
+ const busy = React.useRef(false);
417
+ const switchRevision = async () => {
418
+ if (!review || !change.run || busy.current || phase !== "ready") return;
419
+ busy.current = true; setPhase("switching");
420
+ try {
421
+ await change.run({ post: review.post, revision: review.revision,
422
+ expectedVersion: review.expectedVersion, expectedRevisionVersion: review.expectedRevisionVersion });
423
+ setMessage("Revision selected. The post's publishing status is unchanged."); setPhase("done");
424
+ } catch (error) {
425
+ // This browser page inspects the failed HTTP response body; do not import Admin's internal client.
426
+ const response = error as { body?: { error?: { code?: string }; diagnostic?: { code?: string } } };
427
+ const code = response.body?.error?.code ?? response.body?.diagnostic?.code;
428
+ setMessage(code === "CONFLICT" ? "The post or revision changed. Preview again before switching."
429
+ : code === "INPUT_VALIDATION_FAILED" ? "The revision is incomplete. Edit it, then preview again."
430
+ : "Switch outcome is unconfirmed. Preview current state and review its selected revision before another attempt.");
431
+ setPhase("failed");
432
+ } finally { busy.current = false; }
433
+ };
434
+ return <section aria-label="Frozen revision preview">
435
+ {phase === "loading" && status === "loading" && <p>Loading preview…</p>}
436
+ {phase === "loading" && (status === "error" || status === "unavailable") && <p role="alert">Preview unavailable. Try another explicit preview.</p>}
437
+ {review && <>
438
+ <p>Reviewed revision {review.revision}, version {review.expectedRevisionVersion}; post version {review.expectedVersion}.</p>
439
+ <p>Selected when previewed: {review.current ?? "none"}.</p>
440
+ <article><h2>{review.title}</h2><p>{review.summary}</p>
441
+ {review.body.split(/\n{2,}/).map((text, i) => <p key={i}>{text}</p>)}</article>
442
+ <button type="button" disabled={phase !== "ready" || !change.run || review.current === review.revision}
443
+ onClick={() => void switchRevision()}>Switch to reviewed revision</button>
444
+ {review.slug && <a href={`/posts/${encodeURIComponent(review.slug)}`} target="_blank" rel="noreferrer">Open public page</a>}
445
+ </>}
446
+ {message && <p role={phase === "failed" ? "alert" : "status"}>{message}</p>}
447
+ </section>;
448
+ }
449
+
450
+ function PostRevisions({ post, current }: { post: string; current: string | null }) {
451
+ const list = useEntries("post_revisions", { filter: { field: "post", value: post } });
452
+ const [selected, setSelected] = React.useState("");
453
+ const [input, setInput] = React.useState<{ revision: string; request: string }>();
454
+ const draft = list.items.find(item => item.id === selected);
455
+ return <>
456
+ <AdminLink href={`/admin/c/posts/${encodeURIComponent(post)}?child=post_revisions`}>Open post workspace</AdminLink>
457
+ <label>Revision <select aria-label="Revision" value={selected} onChange={event => { setSelected(event.target.value); setInput(undefined); }}>
458
+ <option value="">Choose a revision</option>
459
+ {list.items.map(item => <option key={item.id} value={item.id}>{item.data.title || "Untitled"} · {new Date(item.updated_at).toLocaleString()} · {item.id.slice(0, 8)}
460
+ {item.id === current ? " — current" : item.data.sealed ? " — sealed history" : " — editable draft"}</option>)}
461
+ </select></label>
462
+ {list.status !== "ready" && <p>Revision list: {list.status}</p>}
463
+ {list.hasMore && <button type="button" disabled={list.loadingMore} onClick={list.loadMore}>More revisions</button>}
464
+ {draft && !draft.data.sealed && <AdminLink href={`/admin/c/post_revisions/${encodeURIComponent(selected)}/edit`}>Edit draft</AdminLink>}
465
+ <button type="button" disabled={!selected} onClick={() => setInput({ revision: selected, request: crypto.randomUUID() })}>Preview selected revision</button>
466
+ {input && <Preview key={input.request} input={input} />}
467
+ </>;
468
+ }
469
+
470
+ function SelectedPost({ id }: { id: string }) {
471
+ const { entry, status } = useEntry("posts", id);
472
+ return entry ? <><h2>Post: {entry.data.slug || entry.id}</h2>
473
+ <PostRevisions key={id} post={id} current={entry.data.current_revision ?? null} /></>
474
+ : <p>Post: {status}</p>;
475
+ }
476
+
477
+ function RevisionsPage() {
478
+ const posts = useEntries("posts");
479
+ const [id, setId] = React.useState(() => new URLSearchParams(window.location.search).get("post") ?? "");
480
+ return <><h1>Revisions</h1><label>Post <select aria-label="Post" value={id} onChange={event => setId(event.target.value)}>
481
+ <option value="">Choose a post</option>
482
+ {id && !posts.items.some(item => item.id === id) && <option value={id}>Selected post {id}</option>}
483
+ {posts.items.map(item => <option key={item.id} value={item.id}>{item.data.slug || item.id}</option>)}
484
+ </select></label>
485
+ {posts.status !== "ready" && <p>Post list: {posts.status}</p>}
486
+ {posts.hasMore && <button type="button" disabled={posts.loadingMore} onClick={posts.loadMore}>More posts</button>}
487
+ {id && <SelectedPost key={id} id={id} />}
488
+ </>;
489
+ }
490
+
491
+ export default defineAdmin(admin, {
492
+ pages: [{ path: "revisions", title: "Revisions", element: <RevisionsPage /> }],
493
+ components: { "record.actions:posts": ({ entry }) =>
494
+ <AdminLink href={`/admin/p/revisions?post=${encodeURIComponent(entry.id)}`}>Preview and switch revisions</AdminLink> },
495
+ });
496
+ ```
497
+
498
+ This page uses browser-only entry hooks and location state. Keep it out of the MCP App graph with a sibling
499
+ `src/admin.mcp.tsx`; AI clients still have the same three authorized operations and their required version inputs.
500
+
501
+ ```tsx
502
+ // src/admin.mcp.tsx
503
+ import { defineAdmin } from "@aotter/mantle-admin/app";
504
+ import { admin } from "../generated/admin-client.js";
505
+ export default defineAdmin(admin);
506
+ ```
507
+
508
+ Run `mantle bundle` as usual: it picks up these two entries. The example adds no Core workflow or nested transaction.
509
+ The checked UI uses the same content fields as the public template below; put shared presentation in an application
510
+ component if the real site has richer styling. For rollback, select a sealed historical revision, preview it and use the
511
+ same switch button. The current revision cannot be switched to again from this page.
512
+
513
+ `packages/mantle-admin/test/revisions-example.test.ts` generates and typechecks these exact entries;
514
+ `revisions-browser.test.ts` drives their preview/switch/rollback and conflict recovery against actual native Admin routes.
515
+
516
+ ## Preview route and the public page
517
+
518
+ Both pages go through one template, so the preview is what will go live. The public route reads as an anonymous
519
+ visitor even when the request carries an admin session; the preview route needs an admin and answers 404 to everyone
520
+ else, so a guessed revision id reveals nothing.
521
+
522
+ ```ts
523
+ import { isAdmin, type Caller } from "@aotter/mantle/contracts";
524
+ import type { Handle } from "@aotter/mantle/driver/d1";
525
+ import postPage from "../generated/views/post-page.js";
526
+ import revisionPreview from "../generated/views/revision-preview.js";
527
+
528
+ const visitor: Caller = { kind: "anonymous" };
529
+ const escape = (value: unknown) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
530
+
531
+ /** The one template. The canonical URL is always the post's; a preview adds noindex and a banner. */
532
+ export function postHtml(origin: string, post: { slug: string | null; title: string; summary: string; body: string; id?: string; version?: unknown }, preview: boolean): string {
533
+ const canonical = `${origin}/posts/${post.slug}`;
534
+ const head = `<title>${escape(post.title)}</title><meta name="description" content="${escape(post.summary)}">`
535
+ + `<link rel="canonical" href="${escape(canonical)}">`
536
+ + (preview ? `<meta name="robots" content="noindex, nofollow">`
537
+ : `<meta property="og:type" content="article"><meta property="og:url" content="${escape(canonical)}">`
538
+ + `<meta property="og:title" content="${escape(post.title)}"><meta property="og:description" content="${escape(post.summary)}">`);
539
+ const body = (preview ? `<p role="status">Preview: revision ${escape(post.id)}, version ${escape(post.version)}. This revision may differ from the live page.</p>` : "")
540
+ + `<article><h1>${escape(post.title)}</h1>${post.body.split(/\n{2,}/).map(text => `<p>${escape(text)}</p>`).join("")}</article>`;
541
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">${head}</head><body>${body}</body></html>`;
542
+ }
543
+
544
+ const html = (status: number, body: string, headers: Record<string, string>) =>
545
+ new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...headers } });
546
+
547
+ /** GET /posts/{slug} for visitors and GET /preview/{revisionId} for admins; any other path falls through. */
548
+ export async function postRoutes(request: Request, db: Handle, origin: string, caller: () => Promise<Caller>): Promise<Response | undefined> {
549
+ const path = new URL(request.url).pathname;
550
+ const page = /^\/posts\/([a-z0-9-]+)$/.exec(path), preview = /^\/preview\/([^/]+)$/.exec(path);
551
+ if (!page && !preview) return undefined;
552
+ if (request.method !== "GET") return new Response(null, { status: 405, headers: { allow: "GET" } });
553
+ if (page) {
554
+ const [post] = await postPage(db, visitor, { slug: page[1]! });
555
+ return post ? html(200, postHtml(origin, post, false), { "cache-control": "no-cache" }) : html(404, "Not found", { "cache-control": "no-store" });
556
+ }
557
+ const who = await caller(), hidden = { "cache-control": "private, no-store", "x-robots-tag": "noindex" };
558
+ if (!isAdmin(who)) return html(404, "Not found", hidden);
559
+ const [revision] = await revisionPreview(db, who, { revision: preview![1]! });
560
+ return revision ? html(200, postHtml(origin, revision, true), hidden) : html(404, "Not found", hidden);
561
+ }
562
+ ```
563
+
564
+ Call it from `src/routes.ts`: `return postRoutes(request, env.DB, env.PUBLIC_ORIGIN, context.caller)`. The app shell's
565
+ `/sign-in` page can replace the 404 for signed-out editors if you prefer a redirect.
566
+
567
+ SEO notes:
568
+
569
+ - The canonical URL, Open Graph URL, sitemap and `llms.txt` entries name the post (`/posts/{slug}`), never a revision.
570
+ Switching revisions changes the content under the same URL, so links and rankings carry over.
571
+ - Preview responses carry `<meta name="robots" content="noindex, nofollow">`, `X-Robots-Tag: noindex` and
572
+ `Cache-Control: private, no-store`, and keep the post's canonical URL. Add `Disallow: /preview/` to `robots.txt`.
573
+ - JSON-LD `datePublished` is `published_at`; `dateModified` and the `Last-Modified` header are the later of
574
+ `switched_at` and `edited_at`. A CDN in front of the page needs purging on switch, as on publish.
575
+ - Everything else a public page carries is in [public pages](public-pages.md); this example shows only what the
576
+ revisions change.
577
+
578
+ ## History and audit
579
+
580
+ The revisions are the content history: who may read them, switch and prune is the Procedures' `requires`. A record of
581
+ who changed what and when, across the whole app, is an observability concern rather than part of this content model;
582
+ it is planned outside the SDK, with Mantle Cloud's observability or the app's own logging when self-hosted, and the
583
+ SDK's part is being discussed as a way to observe writes (aotter/mantle#1796). Until then, a revision's `author_id`
584
+ and `created_at` say who started it and when, and the switch handler is the place to log a switch.