@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.
Files changed (180) hide show
  1. package/README.md +15 -5
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +6 -6896
  14. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  15. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  16. package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
  17. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  18. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  19. package/dist/bun/assets.js +0 -2
  20. package/dist/domain/adminOrder.js +0 -2
  21. package/dist/domain/columns.js +7 -20
  22. package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
  23. package/dist/domain/describe/index.d.ts +1 -0
  24. package/dist/domain/describe/index.js +1 -115
  25. package/dist/domain/describe/match.d.ts +19 -0
  26. package/dist/domain/describe/target-hash.js +1 -12
  27. package/dist/domain/entry.js +0 -14
  28. package/dist/domain/generated-tools.js +0 -1
  29. package/dist/domain/input.js +0 -2
  30. package/dist/domain/paging.js +0 -17
  31. package/dist/domain/recipes.js +10 -7
  32. package/dist/domain/userReferences.js +0 -1
  33. package/dist/domain/wireScalars.js +0 -6
  34. package/dist/infrastructure/adminEntries.js +27 -8
  35. package/dist/infrastructure/adminPaging.js +55 -57
  36. package/dist/infrastructure/artifacts.js +0 -4
  37. package/dist/infrastructure/authSchema.js +0 -9
  38. package/dist/infrastructure/bundle.js +45 -38
  39. package/dist/infrastructure/cli.js +26 -12
  40. package/dist/infrastructure/generate.js +26 -19
  41. package/dist/infrastructure/generateAuth.js +1 -9
  42. package/dist/infrastructure/help.js +55 -0
  43. package/dist/infrastructure/init.js +50 -0
  44. package/dist/infrastructure/inputs.js +0 -9
  45. package/dist/infrastructure/mounts.js +22 -0
  46. package/dist/infrastructure/wrangler.js +0 -6
  47. package/dist/kernel/authorization.js +0 -1
  48. package/dist/kernel/binds.d.ts +34 -7
  49. package/dist/kernel/binds.js +5 -11
  50. package/dist/kernel/caller-identity.d.ts +0 -3
  51. package/dist/kernel/caller-identity.js +0 -15
  52. package/dist/kernel/caller-mode.d.ts +4 -0
  53. package/dist/kernel/caller-mode.js +14 -0
  54. package/dist/kernel/contracts.d.ts +13 -10
  55. package/dist/kernel/contracts.js +14 -41
  56. package/dist/kernel/cron.js +0 -4
  57. package/dist/kernel/dialect/common.js +0 -3
  58. package/dist/kernel/dialect/postgres.js +0 -2
  59. package/dist/kernel/dialect/sqlite.js +1 -6
  60. package/dist/kernel/drain.js +2 -8
  61. package/dist/kernel/driver/bun-sqlite.js +3 -7
  62. package/dist/kernel/driver/d1.d.ts +1 -1
  63. package/dist/kernel/driver/d1.js +6 -4
  64. package/dist/kernel/driver/postgres.d.ts +2 -1
  65. package/dist/kernel/driver/postgres.js +12 -22
  66. package/dist/kernel/driver/transaction-context.js +0 -1
  67. package/dist/kernel/email.js +0 -4
  68. package/dist/kernel/host-access.js +2 -3
  69. package/dist/kernel/host-caller.d.ts +1 -1
  70. package/dist/kernel/host-caller.js +3 -6
  71. package/dist/kernel/json-body.js +0 -2
  72. package/dist/kernel/mcp-apps.js +0 -5
  73. package/dist/kernel/mcp-call.js +0 -1
  74. package/dist/kernel/mcp-handler.js +4 -27
  75. package/dist/kernel/mcp.js +0 -5
  76. package/dist/kernel/media.js +0 -18
  77. package/dist/kernel/paging.js +1 -9
  78. package/dist/kernel/publishing.js +3 -4
  79. package/dist/kernel/r2-media.js +0 -7
  80. package/dist/kernel/rest-dispatch.js +0 -1
  81. package/dist/kernel/rest-router.js +5 -8
  82. package/dist/kernel/rest.d.ts +2 -1
  83. package/dist/kernel/rest.js +1 -3
  84. package/dist/kernel/route-pattern.d.ts +26 -0
  85. package/dist/kernel/route-pattern.js +34 -0
  86. package/dist/kernel/scalars.js +1 -9
  87. package/dist/kernel/search.js +0 -1
  88. package/dist/kernel/site.js +0 -2
  89. package/dist/kernel/store.d.ts +26 -2
  90. package/dist/kernel/store.js +69 -49
  91. package/dist/kernel/surface-failure.d.ts +10 -0
  92. package/dist/kernel/surface-failure.js +29 -0
  93. package/dist/kernel/surface.js +0 -3
  94. package/dist/kernel/sweep.js +0 -1
  95. package/dist/kernel/translations.js +0 -1
  96. package/dist/kernel/user-caller.js +2 -8
  97. package/dist/kernel/user-fields.d.ts +3 -2
  98. package/dist/kernel/user-fields.js +9 -9
  99. package/dist/kernel/validation.js +0 -8
  100. package/dist/kernel/web-gateway.js +0 -2
  101. package/dist/kernel/web-head.js +0 -1
  102. package/dist/kernel/web-index.js +0 -1
  103. package/dist/kernel/web-response.js +0 -1
  104. package/dist/kernel/web-serve.js +97 -0
  105. package/dist/kernel/web.d.ts +1 -1
  106. package/dist/kernel/web.js +10 -91
  107. package/dist/kernel/where.js +0 -6
  108. package/dist/usecase/adminBindings.js +32 -23
  109. package/dist/usecase/adminClient.js +4 -22
  110. package/dist/usecase/adminDescriptors.js +2 -9
  111. package/dist/usecase/generate.js +14 -17
  112. package/dist/usecase/handlers.js +1 -4
  113. package/dist/usecase/mcp-media.js +0 -1
  114. package/dist/usecase/mcp.js +0 -4
  115. package/dist/usecase/paging.js +1 -3
  116. package/dist/usecase/publishing.js +20 -11
  117. package/dist/usecase/rest.js +5 -6
  118. package/dist/usecase/site.js +0 -1
  119. package/dist/usecase/stores.js +12 -17
  120. package/dist/usecase/tool-version.js +0 -3
  121. package/dist/usecase/translations.js +0 -2
  122. package/dist/usecase/validation.js +0 -20
  123. package/docs/examples/README.md +13 -5
  124. package/docs/examples/app-shell/README.md +91 -12
  125. package/docs/examples/app-shell/accept.mjs +91 -0
  126. package/docs/examples/app-shell/gitignore +7 -0
  127. package/docs/examples/app-shell/package.json +10 -9
  128. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  129. package/docs/examples/app-shell/src/routes.ts +6 -3
  130. package/docs/examples/app-shell/src/service.ts +2 -1
  131. package/docs/examples/club-events/README.md +7 -2
  132. package/docs/examples/club-events/manifests/club-events.yaml +17 -0
  133. package/docs/examples/club-events/smoke.mjs +7 -1
  134. package/docs/examples/generated-web/seed.sql +5 -4
  135. package/docs/examples/purchasing/README.md +12 -3
  136. package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
  137. package/docs/examples/purchasing/smoke.mjs +14 -0
  138. package/docs/examples/recipes/README.md +5 -3
  139. package/docs/handbook/cloudflare/media-r2.md +6 -3
  140. package/docs/handbook/cloudflare/service-entry.md +5 -0
  141. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  142. package/docs/handbook/examples/hub.md +1 -1
  143. package/docs/handbook/guides/admin-ui.md +1 -1
  144. package/docs/handbook/guides/agent-setup.md +20 -10
  145. package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
  146. package/docs/handbook/guides/local-testing.md +85 -0
  147. package/docs/handbook/guides/native-admin.md +242 -16
  148. package/docs/handbook/guides/native-execution.md +10 -8
  149. package/docs/handbook/guides/public-pages.md +44 -4
  150. package/docs/handbook/guides/revisions.md +584 -0
  151. package/docs/handbook/guides/size-and-startup.md +140 -0
  152. package/docs/handbook/guides/sql-patterns.md +312 -0
  153. package/docs/handbook/guides/typed-queries.md +146 -3
  154. package/docs/handbook/navigation.json +20 -0
  155. package/docs/handbook/reference/admin-descriptors.md +4 -1
  156. package/docs/handbook/reference/authorization.md +3 -1
  157. package/docs/handbook/reference/describe.md +42 -0
  158. package/docs/handbook/reference/diagnostics.md +16 -2
  159. package/docs/handbook/reference/procedure.md +6 -6
  160. package/docs/handbook/reference/schema.md +79 -9
  161. package/docs/handbook/reference/sql.md +65 -0
  162. package/docs/handbook/reference/surface.md +8 -4
  163. package/docs/handbook/reference/trigger.md +12 -0
  164. package/docs/handbook/reference/view.md +11 -3
  165. package/docs/handbook/releases/index.md +71 -7
  166. package/docs/handbook/start/agents.md +49 -0
  167. package/docs/handbook/start/project-and-cli.md +10 -0
  168. package/docs/handbook/start/quickstart-worker.md +1 -1
  169. package/docs/skills/develop/SKILL.md +19 -6
  170. package/docs/skills/integrate/SKILL.md +3 -1
  171. package/docs/skills/performance/SKILL.md +169 -0
  172. package/docs/skills/update/SKILL.md +10 -4
  173. package/docs/upgrade-0.1-to-0.2.md +151 -39
  174. package/package.json +31 -11
  175. package/skills/README.md +6 -2
  176. package/skills/mantle/SKILL.md +53 -120
  177. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  178. package/skills/mantle/references/local-testing.md +0 -20
  179. package/skills/mantle/references/web.md +0 -41
  180. package/skills/mantle/references/website.md +0 -51
@@ -0,0 +1,91 @@
1
+ // Independent acceptance setup (mantle help agent-workflow-acceptance): hands each role to a fresh evaluator without a credential.
2
+ // node accept.mjs [--fixtures fixtures.mjs] [--user member@example.test]
3
+ // Starts the app on a fresh local D1 (schema.sql, then seed.sql), signs the admin in with email OTP, runs the optional
4
+ // fixtures module (default export async ({ call, mcp, signIn, sql, sessions, user }) => {...}: realistic records through
5
+ // the app's own REST/MCP as real users, e.g. other members' orders and more than one page), signs the user in, then
6
+ // serves one local proxy per role that adds that role's session to every request. Give an evaluator only its role's URL
7
+ // (a visitor gets the app's own origin):
8
+ // MCP at <proxy>/mcp (user) or <proxy>/mcp/admin (admin), Admin at <proxy>/admin, pages and REST at <proxy>/.
9
+ // The proxy refuses /api/auth/* so the evaluator cannot sign out, sign in or mint credentials. Ctrl-C stops everything.
10
+ // node accept.mjs mcp <endpoint> [tool [json-arguments]]
11
+ // A minimal MCP client for an evaluator without one: lists the tools, or calls one and prints the raw result.
12
+ import { createServer } from "node:http";
13
+ import { Readable } from "node:stream";
14
+ import { parseArgs } from "node:util";
15
+ import { pathToFileURL } from "node:url";
16
+ import { resolve } from "node:path";
17
+
18
+ if (process.argv[2] === "mcp") await client(process.argv[3], process.argv[4], process.argv[5]).catch(error => { console.error(error.message); process.exitCode = 1; });
19
+ else await setup();
20
+
21
+ async function setup() {
22
+ const { values } = parseArgs({ args: process.argv.slice(2).filter(arg => arg !== "--"), options: { fixtures: { type: "string" }, user: { type: "string", default: "member@example.test" } } });
23
+ const { startApp } = await import("./smoke-kit.mjs");
24
+ const app = await startApp();
25
+ const stop = async () => { await app.stop(); process.exit(0); };
26
+ process.on("SIGINT", stop);
27
+ process.on("SIGTERM", stop);
28
+ try {
29
+ // Fixtures run between the two sign-ins, so an invite-only app can admit the user first.
30
+ const sessions = { admin: await app.signIn(app.owner) };
31
+ if (values.fixtures) await (await import(pathToFileURL(resolve(values.fixtures)).href)).default({ ...app, sessions, user: values.user });
32
+ sessions.user = await app.signIn(values.user);
33
+ const lines = [`visitor (anonymous): ${app.origin}`];
34
+ for (const [role, cookie] of Object.entries(sessions)) {
35
+ const url = await proxy(app.origin, cookie);
36
+ lines.push(`${role} (${role === "admin" ? app.owner : values.user}): ${url} MCP ${url}/mcp${role === "admin" ? "/admin" : ""}${role === "admin" ? ` Admin ${url}/admin` : ""}`);
37
+ }
38
+ console.log(lines.join("\n") + "\nReady. Ctrl-C stops the app and deletes its data.");
39
+ } catch (error) {
40
+ console.error(app.logs().slice(-4000));
41
+ await app.stop();
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /** A local origin that forwards to the app with one role's session; sliding Set-Cookie updates stay inside the proxy. */
47
+ function proxy(origin, cookie) {
48
+ const jar = new Map(cookie.split("; ").map(pair => [pair.slice(0, pair.indexOf("=")), pair]));
49
+ const server = createServer(async (request, response) => {
50
+ const url = new URL(request.url, origin);
51
+ if (url.pathname.startsWith("/api/auth/")) { response.writeHead(403, { "content-type": "text/plain" }).end("Sign-in is handled by setup."); return; }
52
+ const chunks = [];
53
+ for await (const chunk of request) chunks.push(chunk);
54
+ const headers = new Headers();
55
+ for (const [name, value] of Object.entries(request.headers)) if (!["host", "cookie", "origin", "connection", "content-length"].includes(name)) headers.set(name, [value].flat().join(", "));
56
+ headers.set("cookie", [...jar.values()].join("; "));
57
+ headers.set("origin", origin);
58
+ const upstream = await fetch(url, { method: request.method, headers, redirect: "manual", body: chunks.length ? Buffer.concat(chunks) : undefined });
59
+ for (const set of upstream.headers.getSetCookie()) { const pair = set.split(";")[0]; jar.set(pair.slice(0, pair.indexOf("=")), pair); }
60
+ const out = {};
61
+ upstream.headers.forEach((value, name) => { if (!["set-cookie", "content-encoding", "content-length", "transfer-encoding"].includes(name)) out[name] = value; });
62
+ if (out.location) out.location = out.location.replace(origin, `http://${request.headers.host}`);
63
+ response.writeHead(upstream.status, out);
64
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end();
65
+ });
66
+ return new Promise(done => server.listen(0, "127.0.0.1", () => done(`http://127.0.0.1:${server.address().port}`)));
67
+ }
68
+
69
+ async function client(endpoint, tool, json) {
70
+ if (!endpoint) throw new Error("usage: node accept.mjs mcp <endpoint> [tool [json-arguments]]");
71
+ const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
72
+ let id = 0;
73
+ const rpc = async (method, params) => {
74
+ const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", ...(method.startsWith("notifications/") ? {} : { id: ++id }), method, params }) });
75
+ const session = response.headers.get("mcp-session-id");
76
+ if (session) headers["mcp-session-id"] = session;
77
+ const text = await response.text();
78
+ if (!response.ok && response.status !== 202) throw new Error(`MCP ${method}: HTTP ${response.status} ${text}`);
79
+ const data = text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5)).join("\n") || text;
80
+ return data ? JSON.parse(data) : null;
81
+ };
82
+ const init = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "mantle-accept", version: "1" } });
83
+ headers["mcp-protocol-version"] = init.result?.protocolVersion ?? "2025-11-25";
84
+ await rpc("notifications/initialized");
85
+ if (!tool) {
86
+ const tools = [];
87
+ let cursor;
88
+ do { const page = (await rpc("tools/list", cursor ? { cursor } : {})).result; tools.push(...page.tools); cursor = page.nextCursor; } while (cursor);
89
+ console.log(JSON.stringify(tools, null, 2));
90
+ } else console.log(JSON.stringify(await rpc("tools/call", { name: tool, arguments: json ? JSON.parse(json) : {} }), null, 2));
91
+ }
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .wrangler/
3
+ .dev.vars
4
+ .env
5
+ *.sqlite
6
+ *.sqlite-shm
7
+ *.sqlite-wal
@@ -5,36 +5,37 @@
5
5
  "scripts": {
6
6
  "generate": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin",
7
7
  "types": "wrangler types --include-runtime=false",
8
- "db:init": "wrangler d1 execute mantle-app --local --file generated/schema.sql",
9
- "db:seed": "wrangler d1 execute mantle-app --local --file seed.sql",
8
+ "db:init": "wrangler d1 execute DB --local --file generated/schema.sql",
9
+ "db:seed": "wrangler d1 execute DB --local --file seed.sql",
10
10
  "build": "mantle bundle src/index.ts dist",
11
11
  "test": "node --test generated/admin-mcp-app.check.mjs",
12
12
  "dev": "wrangler dev --local --ip 127.0.0.1 --port 8787",
13
13
  "check": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin --check && wrangler types --include-runtime=false --check && tsc --noEmit",
14
- "smoke": "node smoke.mjs"
14
+ "smoke": "node smoke.mjs",
15
+ "accept": "node accept.mjs"
15
16
  },
16
17
  "dependencies": {
17
- "@aotter/mantle": "0.2.0-alpha.9",
18
- "@aotter/mantle-ui": "0.2.0-alpha.9",
18
+ "@aotter/mantle": "0.2.1-alpha.1",
19
+ "@aotter/mantle-ui": "0.2.1-alpha.1",
19
20
  "@better-auth/cimd": "1.7.7",
20
21
  "@better-auth/core": "1.7.7",
21
22
  "@better-auth/mcp": "1.7.7",
22
23
  "@better-auth/oauth-provider": "1.7.7",
23
24
  "@better-auth/utils": "0.4.2",
24
25
  "@modelcontextprotocol/ext-apps": "2.0.0",
25
- "@modelcontextprotocol/server": "2.2.0",
26
+ "@modelcontextprotocol/server": "2.3.1",
26
27
  "better-auth": "1.7.7",
27
- "@aotter/mantle-admin": "0.2.0-alpha.9",
28
+ "@aotter/mantle-admin": "0.2.1-alpha.1",
28
29
  "react": "19.3.0",
29
30
  "react-dom": "19.3.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@cloudflare/workers-types": "5.20260904.1",
33
+ "@cloudflare/workers-types": "5.20261027.1",
33
34
  "@types/node": "^26",
34
35
  "@types/react": "^19.3.0",
35
36
  "playwright-core": "1.62.1",
36
37
  "typescript": "^6.0.3",
37
- "wrangler": "4.129.0"
38
+ "wrangler": "4.153.0"
38
39
  },
39
40
  "engines": {
40
41
  "node": ">=22.22"
@@ -3,6 +3,7 @@
3
3
  // import { smoke } from "./smoke-kit.mjs";
4
4
  // await smoke(async ({ call, mcp, signIn, owner, step }) => { const admin = await signIn(owner); ... step("..."); });
5
5
  // Extra Worker variables: await smoke(scenario, { vars: { SUPPLIER_API_URL: "http://127.0.0.1:9999" } });
6
+ // accept.mjs uses startApp() to keep the app running for fresh evaluators.
6
7
  import assert from "node:assert/strict";
7
8
  import { execFile, spawn, spawnSync } from "node:child_process";
8
9
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -64,11 +65,23 @@ export async function assertMcpDiscovery(endpoint, { tools, headers = {} }) {
64
65
  return names;
65
66
  }
66
67
 
67
- const database = "mantle-app"; // wrangler.jsonc d1_databases[0].database_name
68
+ const database = "DB"; // the D1 binding (wrangler.jsonc d1_databases[0].binding), so renaming the database changes nothing here
68
69
  const env = { ...process.env, WRANGLER_SEND_METRICS: "false" };
69
70
 
70
- export async function smoke(scenario, { vars = {} } = {}) {
71
- const port = 18000 + Math.floor(Math.random() * 1000), origin = `http://127.0.0.1:${port}`, owner = "owner@example.test";
71
+ export async function smoke(scenario, options) {
72
+ const app = await startApp(options);
73
+ try {
74
+ await scenario(app);
75
+ console.log("smoke passed");
76
+ } catch (error) {
77
+ console.error(app.logs().slice(-4000));
78
+ throw error;
79
+ } finally { await app.stop(); }
80
+ }
81
+
82
+ /** Start the app on a fresh local D1 (schema.sql, then seed.sql when present); stop() ends wrangler and deletes the state. */
83
+ export async function startApp({ vars = {}, port = 18000 + Math.floor(Math.random() * 1000) } = {}) {
84
+ const origin = `http://127.0.0.1:${port}`, owner = "owner@example.test";
72
85
  const state = mkdtempSync(join(tmpdir(), "mantle-smoke-"));
73
86
  // Deployment DDL is explicit: nothing at runtime creates tables.
74
87
  for (const file of ["generated/schema.sql", ...(existsSync("seed.sql") ? ["seed.sql"] : [])]) {
@@ -128,6 +141,17 @@ export async function smoke(scenario, { vars = {} } = {}) {
128
141
  assert.fail(`no log line matching ${pattern}`);
129
142
  };
130
143
  const step = name => console.log(`ok - ${name}`);
144
+ const stop = async () => {
145
+ const kill = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
146
+ kill("SIGTERM");
147
+ await new Promise(resolve => {
148
+ if (wrangler.exitCode !== null) return resolve();
149
+ const timer = setTimeout(() => { kill("SIGKILL"); resolve(); }, 5_000);
150
+ wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
151
+ });
152
+ kill("SIGKILL");
153
+ rmSync(state, { recursive: true, force: true });
154
+ };
131
155
  try {
132
156
  for (let attempt = 0; ; attempt++) {
133
157
  if (wrangler.exitCode !== null) throw new Error(`wrangler exited ${wrangler.exitCode}`);
@@ -135,20 +159,10 @@ export async function smoke(scenario, { vars = {} } = {}) {
135
159
  if (attempt > 240) throw new Error("the Worker did not start");
136
160
  await new Promise(resolve => setTimeout(resolve, 250));
137
161
  }
138
- await scenario({ origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs });
139
- console.log("smoke passed");
140
162
  } catch (error) {
141
163
  console.error(logs.slice(-4000));
164
+ await stop();
142
165
  throw error;
143
- } finally {
144
- const stop = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
145
- stop("SIGTERM");
146
- await new Promise(resolve => {
147
- if (wrangler.exitCode !== null) return resolve();
148
- const timer = setTimeout(() => { stop("SIGKILL"); resolve(); }, 5_000);
149
- wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
150
- });
151
- stop("SIGKILL");
152
- rmSync(state, { recursive: true, force: true });
153
166
  }
167
+ return { origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs, stop };
154
168
  }
@@ -2,10 +2,13 @@ import type { Caller } from "@aotter/mantle/contracts";
2
2
  import { signInPage } from "./sign-in.js";
3
3
 
4
4
  /**
5
- * App-owned routes, tried before Admin, MCP and REST. Return undefined to fall through.
5
+ * App-owned routes, tried before REST. Return undefined to fall through. Paths under /admin and /mcp never reach here, and
6
+ * neither do Better Auth's (/api/auth/*, /oauth/consent*, /.well-known/oauth-*). A root catch-all page (/:slug) must
7
+ * still return undefined for the paths of the app's own HTTP Triggers (REST), or it shadows them.
6
8
  * GET /sign-in: member sign-in (src/sign-in.ts); a member page sends anonymous visitors there with signInRedirect.
7
- * AGENT: add visitor pages (HTML, SEO, feeds) and callbacks/webhooks here. A callback must verify the raw Request
8
- * (signature, timestamp) before it reads the body as JSON or calls a generated Procedure.
9
+ * AGENT: add visitor pages and callbacks/webhooks here. Visitor pages carry everything in `mantle help public-pages`
10
+ * (canonical, Open Graph, JSON-LD, answer block, .md alternates, sitemap.xml, robots.txt, llms.txt; recipes example).
11
+ * A callback must verify the raw Request (signature, timestamp) before it reads the body as JSON or calls a generated Procedure.
9
12
  */
10
13
  export async function appRoutes(request: Request, _env: Env, _context: { caller: () => Promise<Caller>; waitUntil: (task: Promise<unknown>) => void }): Promise<Response | undefined> {
11
14
  if (new URL(request.url).pathname === "/sign-in" && request.method === "GET") return signInPage();
@@ -43,7 +43,8 @@ export async function fetchService(request: Request, env: Env, context: Executio
43
43
  const ownAuth = await withAuthTasks(context.waitUntil.bind(context), () => authRoutes(auth!, { methods: [{ kind: "email-otp" }], resolver: caller })(request));
44
44
  if (ownAuth) return respond(ownAuth);
45
45
  const url = new URL(request.url);
46
- const own = await appRoutes(request, env, { caller, waitUntil: context.waitUntil.bind(context) });
46
+ // /admin and /mcp belong to Admin and MCP: a root catch-all page in src/routes.ts (/:slug) never shadows them.
47
+ const own = /^\/(?:admin|mcp)(?:\/|$)/.test(url.pathname) ? undefined : await appRoutes(request, env, { caller, waitUntil: context.waitUntil.bind(context) });
47
48
  if (own) return respond(own);
48
49
  let response: Response | undefined;
49
50
  if (url.pathname === "/admin/api" || url.pathname.startsWith("/admin/api/")) {
@@ -23,6 +23,9 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
23
23
  Schema check; no View or Trigger exposes it). `src/signup.ts` replaces the shell's allow-all `allowSignUp` with a
24
24
  Store lookup as a system caller, so any other email gets 403 at sign-in and no account. To invite another officer
25
25
  through Admin, list their email in `members` first.
26
+ - **An event with its attendees in one read**: the admin View `event-roster` returns each event with an `attendees`
27
+ JSON array built by `json_group_array(json_set('{}', ...))` in a correlated subquery, so an event nobody joined has
28
+ `[]`. PostgreSQL writes it with `jsonb_agg(jsonb_build_object(...))`; see `mantle help sql-patterns`.
26
29
  - **Member MCP tool**: the `register-mcp` Trigger (`surface: public`) exposes `register` on `/mcp`, so the member's AI
27
30
  registers with the member's own sign-in.
28
31
 
@@ -36,9 +39,11 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
36
39
 
37
40
  ## Run it
38
41
 
42
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
43
+
39
44
  ```sh
40
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
41
- cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/
45
+ npx mantle init my-app
46
+ cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # run the example as is
42
47
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
43
48
  cp .dev.vars.example .dev.vars && pnpm db:init && pnpm dev
44
49
  pnpm smoke
@@ -9,6 +9,7 @@ spec:
9
9
  # It also refuses an officer lowering capacity below the seats already taken.
10
10
  checks: ["seats_taken <= capacity"]
11
11
  indexes: [[startsAt, id]]
12
+ searchableFields: [title]
12
13
  uiSchema: { list: { primaryField: title, columns: [startsAt, capacity, seats_taken, closed] } }
13
14
  schema:
14
15
  type: object
@@ -54,6 +55,7 @@ spec:
54
55
  uniqueIndexes: [[email]]
55
56
  # Better Auth lowercases emails; the check refuses a mixed-case entry that would never match.
56
57
  checks: ["email = lower(email)"]
58
+ searchableFields: [email, name]
57
59
  uiSchema: { list: { primaryField: email, columns: [name] } }
58
60
  schema:
59
61
  type: object
@@ -86,6 +88,21 @@ spec:
86
88
  FROM registrations r JOIN events e ON e.id = r.eventId
87
89
  ORDER BY r.created_at DESC, r.id DESC
88
90
  ---
91
+ # Officers' roster: each event with its attendees as one JSON array, so one read answers "who is coming".
92
+ # The correlated subquery gives [] for an event nobody registered for (a LEFT JOIN + GROUP BY would give one empty object).
93
+ # SQLite/D1 builds the objects with json_set; on PostgreSQL write jsonb_agg(jsonb_build_object('memberName', r.memberName)).
94
+ apiVersion: cms.mantle.aotter.net/v2
95
+ kind: View
96
+ metadata: { name: event-roster }
97
+ spec:
98
+ title: Event roster
99
+ surface: admin
100
+ requires: { auth: { all: [ctx.admin] } }
101
+ sql: |
102
+ SELECT e.id, e.title, e.startsAt AS "startsAt", e.seats_taken AS "seatsTaken",
103
+ (SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id) AS attendees
104
+ FROM events e ORDER BY e.startsAt, e.id
105
+ ---
89
106
  # The member-facing entry: a ref handler checks the event, then runs register-write.
90
107
  apiVersion: cms.mantle.aotter.net/v2
91
108
  kind: Procedure
@@ -105,7 +105,13 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
105
105
  assert.equal((await call("GET", "/admin/api/entries?collection=registrations", { cookie: bob })).status, 403);
106
106
  assert.equal((await call("GET", "/admin/api/me", { cookie: bob })).status, 403);
107
107
  assert.ok(all.body.items.every(r => r.data.memberName && r.data.memberName !== r.data.member), JSON.stringify(all.body.items));
108
- step("officers see every member's registrations, with names, through Admin's API; a member gets 403 on Admin");
108
+ const roster = await call("GET", "/admin/api/views/event-roster", { cookie: admin });
109
+ assert.equal(roster.status, 200, JSON.stringify(roster.body));
110
+ const attendees = row => (typeof row.attendees === "string" ? JSON.parse(row.attendees) : row.attendees).map(a => a.memberName).sort();
111
+ for (const row of roster.body.rows) assert.equal(attendees(row).length, row.seatsTaken, JSON.stringify(row));
112
+ assert.ok(roster.body.rows.some(row => attendees(row).length > 0), JSON.stringify(roster.body.rows));
113
+ assert.equal((await call("GET", "/admin/api/views/event-roster", { cookie: bob })).status, 403);
114
+ step("officers see every member's registrations, with names, through Admin's API; and every event with its attendees as one JSON array (event-roster); a member gets 403 on Admin");
109
115
 
110
116
  const entry = async id => (await call("GET", `/admin/api/entries/${id}?collection=events`, { cookie: admin })).body.entry;
111
117
  const before = await seatsOf(hike.id), read = await entry(hike.id);
@@ -1,6 +1,7 @@
1
1
  -- Local fixture only. Production publishing goes through the generated Procedures.
2
+ -- Every system column is set: timestamps are microseconds since the Unix epoch (2026-01-01T00:00:00Z); 0 would show as 1970.
2
3
  INSERT INTO posts (id, version, created_at, updated_at, title, body, status, published_at) VALUES
3
- ('home', 1, 0, 0, 'Posts', '# Posts — browse the HTML index for articles.', 'published', 0),
4
- ('hello', 1, 0, 0, 'Hello <world>', '# Hello world', 'published', 0),
5
- ('second', 1, 0, 0, 'Second post', '# Second post', 'published', 0),
6
- ('draft', 1, 0, 0, 'Hidden draft', '# Draft', 'draft', NULL);
4
+ ('home', 1, 1767225600000000, 1767225600000000, 'Posts', '# Posts — browse the HTML index for articles.', 'published', 1767225600000000),
5
+ ('hello', 1, 1767225600000000, 1767225600000000, 'Hello <world>', '# Hello world', 'published', 1767225600000000),
6
+ ('second', 1, 1767225600000000, 1767225600000000, 'Second post', '# Second post', 'published', 1767225600000000),
7
+ ('draft', 1, 1767225600000000, 1767225600000000, 'Hidden draft', '# Draft', 'draft', NULL);
@@ -14,6 +14,13 @@ that have been waiting too long.
14
14
  `change-amount`, `mark-ordered`, `confirm-order`). The column is `requestStatus` because `status` is reserved for
15
15
  publishing Schemas. `requests` and `ledger` set `readOnly: true`, so Admin's generic editor cannot bypass the state
16
16
  machine (for example by moving an ordered request back to `submitted` and approving it again).
17
+ - **A status chosen at submission**: `submit-request` uses `CASE WHEN input.amount <= 100 THEN 'approved' ELSE
18
+ 'submitted' END`. A small request also spends the budget and writes its ledger row in the same program, so
19
+ `remaining >= 0` refuses all three when it would overspend. Those two statements find the request by
20
+ `created_at = now()` (the instant of this program) and `NOT EXISTS` a ledger row, so the threshold is written only in
21
+ the `CASE`. Larger requests wait for an admin. An auto-approved request sends no approval email, and the daily cron
22
+ orders it from the supplier (it already retries every `approved` request); route small requests through a `ref`
23
+ handler like `approve-request` if they must be ordered and announced at once.
17
24
  - **Admin-only operations**: `approve-request` and `reject-request` declare `requires: ctx.admin` and a `target`, so
18
25
  Admin shows them as operations on a request. Their `kind: mcp, surface: admin` Triggers make them
19
26
  `approve_request` / `reject_request` tools on `/mcp/admin`. They are not listed on `/mcp`.
@@ -56,11 +63,13 @@ scheme.
56
63
  - Need another approval flow? Copy the pattern: a ref Procedure (admin-only, side effects) that wraps a guarded SQL
57
64
  write Procedure (one transaction).
58
65
 
59
- ## Copy
66
+ ## Run it
67
+
68
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
60
69
 
61
70
  ```sh
62
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
63
- cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/
71
+ npx mantle init my-app
72
+ cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/ # run the example as is
64
73
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
65
74
  cp .dev.vars.example .dev.vars # then set SUPPLIER_API_URL and SUPPLIER_WEBHOOK_SECRET
66
75
  pnpm db:init && pnpm db:seed && pnpm dev
@@ -9,6 +9,7 @@ spec:
9
9
  uniqueIndexes: [[department]]
10
10
  # An approval that would overspend fails this check and the whole approval rolls back.
11
11
  checks: ["remaining >= 0"]
12
+ searchableFields: [department]
12
13
  uiSchema: { list: { primaryField: department, columns: [remaining] } }
13
14
  schema:
14
15
  type: object
@@ -25,6 +26,7 @@ spec:
25
26
  title: Purchase requests
26
27
  lifecycle: operational
27
28
  indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id]]
29
+ searchableFields: [title, department]
28
30
  # Sidebar: in the Approvals group (src/admin.tsx), after the queue of requests waiting for a decision.
29
31
  uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { group: approvals, order: 2 } }
30
32
  schema:
@@ -48,10 +50,13 @@ spec:
48
50
  title: Budget ledger
49
51
  lifecycle: operational
50
52
  indexes: [[createdAt, id]]
53
+ # One row per spending request.
54
+ uniqueIndexes: [[requestId]]
55
+ searchableFields: [department]
51
56
  uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
52
57
  schema:
53
58
  type: object
54
- readOnly: true # written only by approve-request-write
59
+ readOnly: true # written only by approve-request-write and submit-request
55
60
  additionalProperties: false
56
61
  required: [requestId, department, amount, remainingAfter]
57
62
  properties:
@@ -117,7 +122,7 @@ kind: Procedure
117
122
  metadata: { name: submit-request }
118
123
  spec:
119
124
  title: Submit a purchase request
120
- description: Submit a purchase request against an existing department budget.
125
+ description: Submit a purchase request against an existing department budget; a request of 100 or less is approved at once and spends the budget.
121
126
  requires: { auth: { all: [ctx.user] } }
122
127
  input:
123
128
  type: object
@@ -128,11 +133,26 @@ spec:
128
133
  amount: { type: integer, minimum: 1 }
129
134
  department: { type: string }
130
135
  output: { type: object }
136
+ # One program. CASE picks the status: up to 100 is approved now, more waits for an admin. The budget and ledger
137
+ # statements then act only on "the request this program approved": the caller's, created by this program
138
+ # (created_at = now(), the same instant Mantle writes into created_at), approved, and with no ledger row yet. So the
139
+ # threshold lives only in the CASE, and remaining >= 0 refuses all three statements when a small request would
140
+ # overspend. A pending request matches nothing there, which a set form (IN (SELECT ...), INSERT ... SELECT) allows.
131
141
  handler:
132
142
  sql: |
133
143
  INSERT INTO requests (title, amount, department, requestStatus)
134
- SELECT input.title, input.amount, b.department, 'submitted' FROM budgets b WHERE b.department = input.department
135
- RETURNING id, version
144
+ SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END
145
+ FROM budgets b WHERE b.department = input.department
146
+ RETURNING id, version, requestStatus AS "requestStatus";
147
+ UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.department = budgets.department
148
+ AND r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
149
+ AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id))
150
+ WHERE department IN (SELECT r.department FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
151
+ AND r.requestStatus = 'approved' AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id));
152
+ INSERT INTO ledger (requestId, department, amount, remainingAfter)
153
+ SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.department = r.department
154
+ WHERE r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
155
+ AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id)
136
156
  ---
137
157
  # The requester may change the amount while the request is still 'submitted'. The version moves on, so an admin
138
158
  # approving the version they reviewed before the change gets a 409 and looks again.
@@ -171,6 +171,20 @@ try {
171
171
  assert.deepEqual(received.map(order => order.reference), [laptop, projector]);
172
172
  assert.deepEqual(await statusAndOrder(projector), { status: "ordered", supplierOrderId: "SUP-2" });
173
173
  step("daily cron retries the failed supplier order (system caller) and emails every admin about requests pending more than 3 days, and only those");
174
+
175
+ const small = await call("POST", "/api/requests", { cookie: bob, body: { title: "Pens", amount: 50, department: "Engineering" } });
176
+ assert.equal(small.status, 200, JSON.stringify(small.body));
177
+ const pens = small.body.results[0][0];
178
+ assert.equal(pens.requestStatus, "approved");
179
+ const spent = await remaining("Engineering");
180
+ assert.deepEqual((await ledgerRows()).filter(r => r.requestId === pens.id), [{ requestId: pens.id, amount: 50, remainingAfter: spent }]);
181
+ await query(`UPDATE budgets SET remaining = 20 WHERE department = 'Design'`);
182
+ const ledgerBefore = (await ledgerRows()).length, requestsBefore = (await query("SELECT count(*) AS n FROM requests"))[0].n;
183
+ assert.equal((await call("POST", "/api/requests", { cookie: alice, body: { title: "Tape", amount: 30, department: "Design" } })).status, 409);
184
+ assert.equal(await remaining("Design"), 20);
185
+ assert.equal((await ledgerRows()).length, ledgerBefore);
186
+ assert.equal((await query("SELECT count(*) AS n FROM requests"))[0].n, requestsBefore);
187
+ step("CASE approves a request of 100 or less at submission: the status, the budget and the ledger row commit together, and one that would overspend is refused with nothing written");
174
188
  }, { vars: { EMPLOYEE_DOMAIN: "example.test", SUPPLIER_API_URL: `http://127.0.0.1:${supplier.address().port}/orders`, SUPPLIER_WEBHOOK_SECRET: SECRET } });
175
189
  } finally {
176
190
  supplier.close();
@@ -25,11 +25,13 @@ Search for `AGENT:` for what an app must still provide: layout and styling, cach
25
25
  - Need members to see or edit only their own rows? Add club-events' `restrict: { recipes: "author_id = auth.uid()" }` on a View.
26
26
  - Need review before publishing, or an AI that approves? Add purchasing's guarded status transitions and admin MCP.
27
27
 
28
- ## Copy
28
+ ## Run it
29
+
30
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
29
31
 
30
32
  ```sh
31
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
32
- cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/
33
+ npx mantle init my-app
34
+ cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/ # run the example as is
33
35
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
34
36
  ```
35
37
 
@@ -44,8 +44,11 @@ An existing S3 signer (for example an application already using aws4fetch) fits
44
44
  this port. This package adds no AWS SDK. The signer must return a SigV4
45
45
  query-signed PUT URL and preserve `X-Amz-Expires`; never give its credentials to
46
46
  the client. Configure bucket CORS to allow PUT from the application's origins
47
- and the returned Content-Type header. Serve the `committed/` prefix on the public domain; these keys differ from 0.2's `{purpose}/{group}/{role}` keys. Keep `uploads/` private/unserved on the
48
- public domain and add a bucket lifecycle rule for abandoned upload keys.
47
+ and the returned Content-Type header. Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
48
+ to `committed/<group>/<attempt>/<role>.<ext>`. Serve the `committed/` prefix on the public domain. Keep `uploads/`
49
+ private/unserved on the public domain and add a bucket lifecycle rule for abandoned upload keys. Mantle 0.1.x wrote
50
+ `<purpose>/<group>/<role>.<ext>` keys (`<group>/<role>.<ext>` when the purpose was not a plain slug); an asset reads its keys from the stored `storageKey` and `publicUrl` of each
51
+ variant, so 0.1.x assets keep working while their prefixes stay served.
49
52
 
50
53
  Commit checks every object's MIME and size, then rechecks the exact GET
51
54
  response used for the copy. Public keys cannot be reached by an upload URL. Each commit attempt has a separate
@@ -54,7 +57,7 @@ loser can neither overwrite nor remove the winner's objects. After SQL selects
54
57
  the durable asset, the losing attempt removes only its own unselected copies.
55
58
  Upload sources remain until the database commit succeeds, so an uncertain DB
56
59
  write can be reconciled/retried with the same UUID. Failed object deletion keeps
57
- the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` is the upload-group creation time (epoch milliseconds), not the later commit time.
60
+ the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` (`media_assets.created_at`) is the upload-group creation time in epoch milliseconds, not the later commit time; entry timestamps on D1 are microseconds ([reserved entry columns](../reference/schema.md#reserved-entry-columns)).
58
61
  No R2 upload or deployment is performed by generation.
59
62
 
60
63
  A positively identified native rollback deletes only that attempt's copied
@@ -22,6 +22,8 @@ under `secrets.required`, run `pnpm exec wrangler types --include-runtime=false`
22
22
  add `worker-configuration.d.ts` to `tsconfig.json` and commit it.
23
23
  `@cloudflare/workers-types` still supplies runtime types. Rerun after every binding
24
24
  change; `wrangler types --include-runtime=false --check` fails on drift.
25
+ Both run the custom build (`mantle bundle`), because Wrangler reads the built entry's exports
26
+ for Durable Object and service binding types; Wrangler has no option to skip it.
25
27
  Once `secrets.required` is set, `wrangler dev` loads only those keys from
26
28
  `.dev.vars`/`.env`, so declare every secret the app reads.
27
29
 
@@ -81,6 +83,9 @@ export default {
81
83
  if (shell) return shell;
82
84
  // Your public frontend/web.ts routes can use an anonymous Caller without
83
85
  // loading auth; their generated SQL still controls publication visibility.
86
+ // Mounted here, after every Mantle surface, a root catch-all page (/:slug)
87
+ // cannot shadow /admin, /mcp or REST. Mount them earlier only behind a
88
+ // guard that skips those paths, as the app shell does.
84
89
  return new Response('Not found', { status: 404, headers: { 'cache-control': 'no-store' } });
85
90
  } catch (error) { return surfaceFailure(error); }
86
91
  });
@@ -2,7 +2,27 @@
2
2
 
3
3
  Schemas default to `operational`: rows are live immediately and edit in place. Opt-in `publishing` adds `status` (draft/published/archived), nullable `published_at` and a scope-aware `(published_at, id)` index.
4
4
 
5
- Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. Admin permits generic publishing edits only on drafts; Store does not add that separate Admin editing rule.
5
+ Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. By default Admin's generic patch edits drafts only (published and archived entries are unpublished first), and a published entry is unpublished before it is deleted; both are an implicit lock named `publishing` that Admin shows before the edit, and neither applies to the transitions. A Store update may edit a published row in place but cannot clear a field `publish` requires: setting one to null is `INPUT_VALIDATION_FAILED` for every caller. Fields it leaves alone are not re-checked, so adding a field to `required` later does not block updates of posts published before it.
6
+
7
+ To let admins correct a published entry in place, opt in with `publishing: { liveEdits: true }`. A personal blog needs nothing more than one Schema:
8
+
9
+ ```yaml
10
+ apiVersion: cms.mantle.aotter.net/v2
11
+ kind: Schema
12
+ metadata: { name: posts }
13
+ spec:
14
+ title: Posts
15
+ lifecycle: publishing
16
+ publishing: { liveEdits: true }
17
+ schema:
18
+ type: object
19
+ required: [title, body]
20
+ properties:
21
+ title: { type: string }
22
+ body: { type: string }
23
+ ```
24
+
25
+ Admin then edits a published post with a "changes go live on save" notice. The save goes live on commit, `published_at` is kept and `updated_at` moves. The merged post must still pass `publish`'s checks, so emptying its title is refused with `INPUT_VALIDATION_FAILED`. An archived post stays draft-only: restore it to edit it. Deleting a published post still requires unpublishing, and [declared locks](../reference/schema.md#locks) still apply. `publishing` is refused on an operational Schema, which already edits in place. To prepare changes to published content without exposing them, split it into a publishing parent and operational revisions instead ([edit published content without downtime](../guides/revisions.md)).
6
26
 
7
27
  Public Views see published rows only. Other reads expose drafts only to admin/system. This policy applies to joins and nested queries too.
8
28
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
4
4
 
5
- The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.9/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
5
+ The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
@@ -6,7 +6,7 @@ Schema `uiSchema` selects fields, widgets, list columns, enum tabs and child nav
6
6
 
7
7
  Admin Views provide custom read lists; their SELECT outputs, column lineage and `uiSchema.list` define display/search/filter metadata. Procedures and MCP admin Triggers define operations with declared authorization, input checks and optional row targets. A hidden button never authorizes an operation.
8
8
 
9
- Generic publishing edits apply to drafts; publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
9
+ Generic publishing edits apply to drafts unless the Schema sets `publishing: { liveEdits: true }` ([lifecycle](../concepts/lifecycle-and-locales.md)); publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
10
10
 
11
11
  Use [`defineAdmin` components](native-admin.md#customising-admin) for app-owned cells, inputs, record asides and actions; server work is a Procedure, a View or an application route. Keep exact conflict/uncertain-outcome handling in the shared UI controller.
12
12