@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
|
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.
|