@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
@@ -1,4 +1,3 @@
1
- /** Emit the selected MCP boundary; the official transport loads on its first request. */
2
1
  import { mcpToolNameSegment, procedureAnnotations, toolTitle, viewAnnotations } from "../build/compiler.js";
3
2
  import { emitMcpMedia, mediaDefinitions } from "./mcp-media.js";
4
3
  import { advertised, emitValidation } from "./validation.js";
@@ -37,7 +36,6 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
37
36
  const module = `./${outputConversions(plan, name, target).some(([, type]) => type === "int") ? "paged/wire/views" : "paged/views"}/${file}.js`;
38
37
  imports.push(`const run${index}: typeof import(${q(module)}).default = async (...args) => (await import(${q(module)})).default(...args);`);
39
38
  }
40
- // The kernel adds a View tool's `limit` and `cursor` paging properties.
41
39
  const { inputSchema, description: about } = advertised(kind === "views" ? operation.input ?? { type: "object", additionalProperties: false } : operation.input, operation.description ?? operation.title ?? name), schema = literal(inputSchema);
42
40
  if (kind === "views" && ("limit" in (operation.input?.properties ?? {}) || "cursor" in (operation.input?.properties ?? {})))
43
41
  refuse(`views/${name}`, "MCP View input reserves limit and cursor for paging.");
@@ -49,7 +47,6 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
49
47
  const output = kind === "procedures" ? ref ? (procedure?.output.type === "object" ? procedure.output : undefined) : { type: "object", properties: { results: { type: "array", items: { type: "array", items: { type: "object" } } } }, required: ["results"] } : undefined;
50
48
  const annotations = kind === "views" ? viewAnnotations : procedure ? procedureAnnotations(procedure) : operation.mcp;
51
49
  const wire = kind === "views" ? outputConversions(plan, name, target) : [];
52
- // Hashed as listed: the kernel adds a View tool's paging properties exactly so.
53
50
  const listed = versioned({ name: tool, title: operation.title ?? toolTitle(name), description: about, inputSchema: kind === "views" ? { ...inputSchema, properties: { ...inputSchema?.properties, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } } } : inputSchema, outputSchema: output, annotations });
54
51
  hasWire ||= wire.length > 0;
55
52
  const fields = [...(description.targets.find(t => t._meta.id === `${kind === "views" ? "view" : "procedure"}:${name}`)?._meta.readOnly ? ["readOnly: true"] : []), `target: ${q({ kind: kind === "views" ? "view" : "procedure", name })}${guard(operation.requires, `${kind}/${name}`, binds, procedure?.trustedOnly)}`, `name: ${q(tool)}`, `surface: ${q(surface)}`, `title: ${q(listed.title)}`, `description: ${q(listed.description)}`, "inputSchema", ...(output ? [`outputSchema: ${literal(output)}`] : []), ...(annotations ? [`annotations: ${q(annotations)}`] : []), ...((operation.requires?.auth?.all.length ?? 0) > 0 ? ["closed: true"] : []), ...(scopes.length ? [`scopes: ${q(scopes)}`] : []), `validate: ${kind === "views" ? `validate${index}` : `async (raw) => (await import("./validation/procedures/${file}.js")).validate(raw)`}`, ...(convert ? [`convert: ${convert}`] : []),
@@ -77,7 +74,6 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
77
74
  }
78
75
  if (hasWire)
79
76
  imports.push('import { wireRows } from "@aotter/mantle/contracts";');
80
- // An input schema several tools share is one constant.
81
77
  const uses = new Map(), shared = new Map();
82
78
  for (const { schema } of tools)
83
79
  uses.set(schema, (uses.get(schema) ?? 0) + 1);
@@ -1,5 +1,4 @@
1
1
  import { userReferences } from "../domain/userReferences.js";
2
- /** Selected View boundary: Core prints keyset SQL once; bare operations retain native rows. */
3
2
  import { parseNumeric } from "../build/compiler.js";
4
3
  import { outputConversions } from "../domain/columns.js";
5
4
  import { authLines } from "../domain/input.js";
@@ -17,10 +16,9 @@ export function emitPaging(plan, lowered, target, refuse, proofs, boundary = fal
17
16
  const proof = proofs?.[name];
18
17
  if (boundary && (!proof || !proof.types.length && !proof.single))
19
18
  continue;
20
- // Internal bare queries remain available even when they cannot be safely paged.
21
19
  if (!proof || !proof.types.length && !proof.single) {
22
20
  if (view.surface !== "internal")
23
- refuse(path, proof?.reason ?? "Paged Views need a complete typed NOT NULL key tuple, indexed Schema order and a non-fanning relation, or a proved singleton.");
21
+ refuse(path, proof?.reason ?? "Paged Views need a complete typed NOT NULL key tuple, indexed Schema order and a non-fanning relation, or a proved singleton. For a lookup by a unique field, end ORDER BY with the Schema's id (`ORDER BY t.id`).");
24
22
  continue;
25
23
  }
26
24
  const mode = view.surface === "public" ? "public" : "caller";
@@ -1,4 +1,3 @@
1
- /** Opt-in lifecycle: one atomic, version-guarded statement per transition, with Core's row rules. */
2
1
  import { rowPolicy } from "../build/compiler.js";
3
2
  import { dialect as sqlite } from "../kernel/dialect/sqlite.js";
4
3
  import { dialect as postgres } from "../kernel/dialect/postgres.js";
@@ -7,7 +6,9 @@ import { publishedParent } from "./translations.js";
7
6
  import { entry, exportName, q } from "../domain/entry.js";
8
7
  import { publishingAnnotations, publishingInput, publishingTransitions } from "../domain/generated-tools.js";
9
8
  export { publishingAnnotations, publishingInput, publishingTransitions };
10
- export function emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse) {
9
+ export const publishRequired = (s) => (s.schema.required ?? []).flatMap(field => Object.keys(s.fields).filter(c => (s.names?.[c] ?? c) === field).slice(0, 1)
10
+ .flatMap(c => s.fields[c] === "geo" ? [[`${c}_lat`, field], [`${c}_lng`, field]] : [[c, field]]));
11
+ export function emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse, locks = {}) {
11
12
  const d = isSqlite ? sqlite : postgres;
12
13
  const translations = Object.values(plan.schemas).some(s => s.translates);
13
14
  if (translations)
@@ -32,21 +33,30 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
32
33
  const schema = plan.schemas[s.name];
33
34
  const recipes = [{ k: "now" }, { k: "input", name: "id" }, { k: "input", name: "expectedVersion" }];
34
35
  const conditions = [`id = ${d.param(2)}`, `version = ${d.param(3)}`, `status IN (${from.map(v => `'${v}'`).join(", ")})`];
35
- // Same visibility as Store and Admin writes, translation parent included (a child under an out-of-set parent is missing);
36
- // the status term is replaced by the transition's own `status IN (...)`.
37
36
  for (const term of rowPolicy(schema, trusted ? "trusted" : "caller", plan.schemas))
38
37
  if (term[1] !== "admin")
39
38
  conditions.push(renderTerm(d, term, term[1] === "parent" ? d.ident(s.name) + "." : "", recipe => (recipes.push(recipe), d.param(recipes.length))));
40
39
  if (verb === "publish")
41
- for (const field of s.schema.required ?? []) {
42
- const column = Object.keys(s.fields).find(c => (s.names?.[c] ?? c) === field);
43
- if (column)
44
- conditions.push(`${d.ident(column)} IS NOT NULL`);
45
- }
40
+ for (const [column] of publishRequired(s))
41
+ conditions.push(`${d.ident(column)} IS NOT NULL`);
46
42
  if (verb === "publish" && s.translates)
47
43
  conditions.push(publishedParent(plan, schema, d, recipes, trusted));
48
44
  const clock = d.param(1);
49
- return { sql: `UPDATE ${d.ident(s.name)} SET status = '${to}', published_at = ${verb === "publish" ? clock : "NULL"}, version = version + 1, updated_at = ${clock} WHERE ${conditions.join(" AND ")} RETURNING id, version, status, ${d.wireTimestamp(d.ident("published_at")).join("")} AS published_at`, binds: recipes, row: true };
45
+ const held = trusted ? [] : (locks[s.name.toLowerCase()] ?? []).filter(l => l.on.includes("update"));
46
+ const lock = held.length ? (() => {
47
+ const probe = [], number = new Map(), renumber = (sql) => sql.replace(isSqlite ? /\?(\d+)/g : /\$(\d+)/g, (_, n) => {
48
+ const i = Number(n);
49
+ if (!number.has(i)) {
50
+ probe.push(recipes[i - 1]);
51
+ number.set(i, d.param(probe.length));
52
+ }
53
+ return number.get(i);
54
+ });
55
+ const where = renumber(conditions.filter(c => c !== `version = ${d.param(3)}`).join(" AND "));
56
+ return { sql: `SELECT CASE ${held.map(l => `WHEN (${l.sql}) IS NOT FALSE THEN '${l.name}'`).join(" ")} END AS ${d.ident("lock")} FROM ${d.ident(s.name)} WHERE ${where}`, binds: probe, schema: s.name, verb: "update", locks: Object.fromEntries(held.map(l => [l.name, l.message])) };
57
+ })() : undefined;
58
+ conditions.push(...held.map(l => `NOT (${l.sql})`));
59
+ return { sql: `UPDATE ${d.ident(s.name)} SET status = '${to}', published_at = ${verb === "publish" ? clock : "NULL"}, version = version + 1, updated_at = ${clock} WHERE ${conditions.join(" AND ")} RETURNING id, version, status, ${d.wireTimestamp(d.ident("published_at")).join("")} AS published_at`, binds: recipes, row: true, ...(lock ? { lock } : {}) };
50
60
  };
51
61
  values.add("now");
52
62
  const caller = q([statement(false)]), trusted = q([statement(true)]);
@@ -56,7 +66,6 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
56
66
  }
57
67
  }
58
68
  }
59
- /** Publishing lists get one index, prefixed by scope when present. */
60
69
  export function publishingSql(schemas, isSqlite) {
61
70
  const d = isSqlite ? sqlite : postgres;
62
71
  return Object.values(schemas).filter(s => s.publishing).map(s => `CREATE INDEX ${d.ident(`_mantle_ix_${s.name}_published`)} ON ${d.ident(s.name)} (${[...(s.scope ? [s.scope] : []), ...(s.userScopes ?? []).map(u => u.column), "published_at", "id"].map(d.ident).join(", ")})`);
@@ -1,6 +1,7 @@
1
1
  import { databaseImport, q, viewImports } from "../domain/entry.js";
2
2
  import { authLines, conversions } from "../domain/input.js";
3
3
  import { boundaryPage, outputConversions } from "../domain/columns.js";
4
+ import { pathTemplate as template, routeOrder, routeSource } from "../kernel/route-pattern.js";
4
5
  export function emitRest(plan, lowered, target, description) {
5
6
  const guard = (requires, path, binds = [], trustedOnly = false) => {
6
7
  const lines = authLines(requires, path, binds, !target.endsWith("postgres"), trustedOnly);
@@ -14,11 +15,9 @@ export function emitRest(plan, lowered, target, description) {
14
15
  const views = [], procedures = [];
15
16
  let hasWire = false;
16
17
  const convert = (code) => code ? `, convert: ${code}` : "";
17
- // Each segment matches in its encoded form; `{name}` segments capture.
18
18
  const route = (method, path) => {
19
- const keys = [];
20
- const source = path.split("/").map(segment => segment.startsWith("{") && segment.endsWith("}") ? (keys.push(segment.slice(1, -1)), "([^/]+)") : encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("/");
21
- return `method: ${q(method)}, pattern: new RegExp(${q("^" + source + "/?$")})${keys.length ? `, keys: ${q(keys)}` : ""}`;
19
+ const { source, keys } = routeSource(path);
20
+ return `method: ${q(method)}, pattern: new RegExp(${q(source)})${keys.length ? `, keys: ${q(keys)}` : ""}`;
22
21
  };
23
22
  for (const [name, view] of Object.entries(plan.views)) {
24
23
  if (view.surface !== "public")
@@ -29,7 +28,7 @@ export function emitRest(plan, lowered, target, description) {
29
28
  hasWire ||= wire.length > 0;
30
29
  views.push(`{ ${route("GET", `/api/views/${name}`)}, target: ${q({ kind: "view", name })}${guard(view.requires, `views/${name}`, (lowered.views[name]?.public ?? lowered.views[name]?.caller)?.binds)}, query: query${id}, page: ${boundaryPage(`page${id}`, wire)}${convert(conversions(view.input?.properties, (lowered.views[name]?.public ?? lowered.views[name]?.caller)?.binds ?? [], target))} }`);
31
30
  }
32
- const triggers = Object.values(plan.triggers).filter(t => t.source.kind === "http").sort((a, b) => a.source.kind === "http" && b.source.kind === "http" ? (a.source.path.match(/\{/g)?.length ?? 0) - (b.source.path.match(/\{/g)?.length ?? 0) : 0);
31
+ const triggers = Object.values(plan.triggers).filter(t => t.source.kind === "http").sort((a, b) => a.source.kind === "http" && b.source.kind === "http" ? routeOrder(a.source.path, b.source.path) : 0);
33
32
  for (const trigger of triggers) {
34
33
  if (trigger.source.kind !== "http")
35
34
  continue;
@@ -41,7 +40,7 @@ export function emitRest(plan, lowered, target, description) {
41
40
  const type = [procedure.input?.properties?.[key]?.type].flat().find(t => t && t !== "null");
42
41
  return type === "integer" || type === "number" ? [[key, "number"]] : type === "boolean" ? [[key, "boolean"]] : [];
43
42
  }));
44
- procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${path.replace(/\{([^}]+)\}/g, ":$1")}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `procedure${id}`} }`);
43
+ procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${template(path)}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `procedure${id}`} }`);
45
44
  }
46
45
  if (hasWire)
47
46
  imports.push('import { wireRows } from "@aotter/mantle/contracts";');
@@ -1,4 +1,3 @@
1
- /** Generate the optional site/media module and deployment DDL, never runtime preparation. */
2
1
  import { assertSiteDefaultsCanonical, canonicalizeLocaleList, expandPolicyRequired } from "../build/compiler.js";
3
2
  import { driverFor, isSqlite, q } from "../domain/entry.js";
4
3
  export function emitSite(target, defaults, refuse, schemaNames) {
@@ -1,25 +1,16 @@
1
- /**
2
- * One typed collection per Schema, over the generic `createStore`. Core's row rules (`rowPolicy`, `writePolicy`) are evaluated
3
- * here, at build time, ONCE for a placeholder scope and TTL into the shared `Rules`; a Schema adds only a `Fact` (scope, TTL,
4
- * publishing, its date-time and date columns). Generation is refused if a Schema's expanded rules differ from Core's own for it
5
- * (caller and trusted rows, insert fills, update sets and lock, delete lock).
6
- * Column names, value types, writable columns and `orderBy` are TypeScript types only: nothing at runtime knows them.
7
- */
8
1
  import { NATIVE_NOT_NULL, notNullColumns, rowPolicy, writePolicy } from "../build/compiler.js";
9
2
  import { declaredType, inputType, nativeType } from "../domain/columns.js";
10
3
  import { q } from "../domain/entry.js";
11
4
  import { expand, SCOPE, TTL } from "../kernel/store.js";
12
- import { userScopeTerm } from '../build/compiler.js';
13
- // Types the typed Store compares and writes as plain bind values. Fixed numerics are scaled integers on SQLite; geo is two columns.
5
+ import { refChecks, userScopeTerm } from '../build/compiler.js';
6
+ import { publishRequired } from "./publishing.js";
14
7
  const SCALAR = new Set(["text", "real", "float8", "integer", "int4", "int8", "bool", "timestamptz", "date"]);
15
- /** Core's own rules for a Schema, in the shape of `Rules` without the id expression (a delete's lock is compared separately). */
16
8
  const core = (s, schemas) => {
17
9
  const update = writePolicy(s, "update", { lock: true });
18
10
  return { caller: rowPolicy(s, "caller", schemas), trusted: rowPolicy(s, "trusted", schemas), fills: writePolicy(s, "insert").fills, sets: update.sets, lock: update.lock };
19
11
  };
20
12
  const probe = { scope: SCOPE, ttl: TTL, ttlSeconds: 0, publishing: true };
21
- /** @internal */
22
- export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
13
+ export function emitStores(schemas, isSqlite, newId, refuse, required = false, locks = {}) {
23
14
  const all = Object.values(schemas).sort((a, b) => a.name < b.name ? -1 : 1);
24
15
  if (!all.length && !required)
25
16
  return undefined;
@@ -28,7 +19,7 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
28
19
  for (const s of all) {
29
20
  const type = (column) => declaredType(s.fields, column) ?? (column === "version" ? "integer" : column === "published_at" ? "timestamptz" : "text");
30
21
  const declared = Object.keys(s.fields).filter(f => f !== s.scope && s.fields[f] !== "geo");
31
- const geo = Object.keys(s.fields).filter(f => s.fields[f] === "geo").flatMap(f => [`${f}_lat`, `${f}_lng`]); // `*` returns a geo field's two columns
22
+ const geo = Object.keys(s.fields).filter(f => s.fields[f] === "geo").flatMap(f => [`${f}_lat`, `${f}_lng`]);
32
23
  const names = [...["id", "version", "created_at", "updated_at", "author_id", ...(s.publishing ? ["status", "published_at"] : [])], ...declared];
33
24
  const required = new Set(s.schema.required ?? []), notNull = notNullColumns(s);
34
25
  const nonNull = (column) => NATIVE_NOT_NULL.has(column) || notNull.has(column);
@@ -36,18 +27,22 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
36
27
  const parent = s.translates && schemas[s.translates.parent.toLowerCase()];
37
28
  const inherited = parent ? Object.keys(parent.fields).filter(c => c !== parent.scope && !Object.hasOwn(s.fields, c)).flatMap(c => parent.fields[c] === "geo" ? [`${c}_lat`, `${c}_lng`] : [c]) : [];
38
29
  const inheritedType = (c) => parent?.fields[c] ?? "real";
30
+ const ref = refChecks(s, schemas);
39
31
  const fact = {
40
32
  ...(s.userScopes?.length ? { u: s.userScopes.map(u => userScopeTerm(u, true)) } : {}),
41
- ...(s.scope ? { s: s.scope } : {}), ...(s.ttl ? { ttl: [s.ttl, s.ttlSeconds ?? 0] } : {}), ...(s.publishing ? { pub: 1 } : {}),
33
+ ...(s.scope ? { s: s.scope } : {}), ...(s.ttl ? { ttl: [s.ttl, s.ttlSeconds ?? 0] } : {}), ...(s.publishing ? { pub: 1, ...(publishRequired(s).length ? { req: Object.fromEntries(publishRequired(s)) } : {}) } : {}),
42
34
  ...(ts.length ? { ts } : {}), ...(dates.length ? { d: dates } : {}),
43
35
  ...(s.search?.length ? { search: s.search } : {}),
36
+ ...(ref.length ? { ref } : {}),
37
+ ...(locks[s.name.toLowerCase()]?.length ? { lock: locks[s.name.toLowerCase()] } : {}),
44
38
  ...(parent ? { parent: { on: s.translates.on.toLowerCase(), ...(parent.scope ? { scope: [parent.scope, s.scope] } : {}), table: s.translates.parent.toLowerCase(), visible: rowPolicy(s, "caller", schemas).find(t => t[1] === "parent"), caller: rowPolicy(parent, "caller", schemas), trusted: rowPolicy(parent, "trusted", schemas), fields: inherited, ts: inherited.filter(c => parent.fields[c] === "timestamptz"), d: inherited.filter(c => parent.fields[c] === "date") } } : {}),
45
39
  };
46
- // the shared rules, with this Schema's scope and TTL put in, are Core's rules for it: or the runtime would enforce something else
47
40
  const expected = q([core(s, schemas), writePolicy(s, "delete", { lock: true }).lock]), got = q([expand(rules, fact), rules.lock]);
41
+ for (const verb of ["update", "delete"])
42
+ if (q(writePolicy(s, verb).locks.map(l => l[0])) !== q((fact.lock ?? []).filter(l => l.on.includes(verb)).map(l => l.name)))
43
+ refuse(`schemas/${s.name}`, `Core's ${verb} locks for this Schema differ from the Store's.`);
48
44
  if (expected !== got)
49
45
  refuse(`schemas/${s.name}`, `Core's row rules for this Schema differ from the shared rules (${expected} vs ${got}); the generic Store cannot serve it.`);
50
- // ORDER BY is allowed where the caller's scoped read can walk an index (its first column, or the one after the scope column) and the column is NOT NULL.
51
46
  const indexed = new Set([...(s.publishing ? [[...(s.scope ? [s.scope] : []), "published_at", "id"]] : []), ...(s.indexes ?? []), ...(s.unique ?? [])].map(i => i[0] === s.scope ? i[1] : i[0]).filter((c) => !!c));
52
47
  const key = (c) => /^[A-Za-z_]\w*$/.test(c) ? c : q(c);
53
48
  const of = (columns, value) => `{ ${columns.map(c => `${key(c)}: ${value(c)};`).join(" ")} }`;
@@ -55,7 +50,7 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
55
50
  const scalar = (c) => SCALAR.has(type(c));
56
51
  const writable = declared.filter(c => !(s.userScopes ?? []).some(u => u.column === c) && (scalar(c) || type(c) === "json"));
57
52
  const input = (c) => `${inputType(type(c))}${nonNull(c) ? "" : " | null"}`;
58
- tables.push(`${key(s.name)}: { row: ${of([...names, ...geo], c => c === "id" ? "string" : nativeType(geo.includes(c) ? "real" : type(c), isSqlite, nonNull(c)))}${inherited.length ? ` & { ${inherited.map(c => `${key(c)}?: ${nativeType(inheritedType(c), isSqlite)};`).join(" ")} }` : ""}; fields: ${of(names.filter(scalar), c => inputType(type(c)))}; set: ${of(writable, input)}${s.userScopes?.length ? `; insert: ${of([...writable, ...s.userScopes.map(u => u.column)], input)}` : ""}; required: ${union(s.publishing ? [] : writable.filter(c => required.has(s.names?.[c] ?? c)))}; order: ${union(["id", ...names.filter(c => c !== "id" && scalar(c) && indexed.has(c) && (nonNull(c) || s.publishing && c === "published_at"))])} }`);
53
+ tables.push(`${key(s.name)}: { row: ${of([...names, ...geo], c => c === "id" ? "string" : c === "version" ? "number" : nativeType(geo.includes(c) ? "real" : type(c), isSqlite, nonNull(c)))}${inherited.length ? ` & { ${inherited.map(c => `${key(c)}?: ${nativeType(inheritedType(c), isSqlite)};`).join(" ")} }` : ""}; fields: ${of(names.filter(scalar), c => inputType(type(c)))}; set: ${of(writable, input)}${s.userScopes?.length ? `; insert: ${of([...writable, ...s.userScopes.map(u => u.column)], input)}` : ""}; required: ${union(s.publishing ? [] : writable.filter(c => required.has(s.names?.[c] ?? c)))}; order: ${union(["id", ...names.filter(c => c !== "id" && scalar(c) && indexed.has(c) && (nonNull(c) || s.publishing && c === "published_at"))])} }`);
59
54
  facts.push(`${q(s.name)}: ${q(fact)}`);
60
55
  }
61
56
  return { tables: `{ ${tables.join("; ")} }`, rules: q(rules), facts: `{ ${facts.join(", ")} }` };
@@ -1,8 +1,5 @@
1
- /** Generate-time tool versions: a stale host copy shows the model an older version than results carry. */
2
1
  import { canonical, fnv } from "../build/compiler.js";
3
- /** A hash of what a catalogue lists, so an unchanged definition keeps its version across deploys. */
4
2
  export const versionOf = (value) => fnv(canonical(value));
5
- /** A listed tool: its version, and its description (each locale's) ending with that version. */
6
3
  export function versioned(tool) {
7
4
  const v = versionOf(tool), mark = (text) => `${text} (tool version ${v})`;
8
5
  return { ...tool, description: typeof tool.description === "string" ? mark(tool.description) : Object.fromEntries(Object.entries(tool.description).map(([locale, text]) => [locale, mark(text)])), version: v };
@@ -1,12 +1,10 @@
1
1
  import { userReferences } from "../domain/userReferences.js";
2
- /** Translation helpers use generated SQL policy, never a request-time manifest. */
3
2
  import { rowPolicy } from "../build/compiler.js";
4
3
  import { renderTerm } from "../kernel/store.js";
5
4
  import { dialect as SQLITE } from "../kernel/dialect/sqlite.js";
6
5
  import { dialect as POSTGRES } from "../kernel/dialect/postgres.js";
7
6
  import { declaredType } from "../domain/columns.js";
8
7
  import { driverFor, isSqlite, q } from "../domain/entry.js";
9
- /** The parent is checked by the same atomic UPDATE that publishes the child. */
10
8
  export function publishedParent(plan, child, d, recipes, trusted) {
11
9
  const tr = child.translates, parent = plan.schemas[tr.parent.toLowerCase()], on = tr.on.toLowerCase();
12
10
  const conditions = [`p.${d.ident(on)} = ${d.ident(child.name)}.${d.ident(on)}`];
@@ -1,4 +1,3 @@
1
- /** Compile boundary checks into each operation; no schema enters the request bundle. */
2
1
  import { INPUT_RULE_KEYWORDS, lookupLocalRef as lookup, rootRefChain } from "../build/compiler.js";
3
2
  import { literal, q } from "../domain/entry.js";
4
3
  import { publishingInput } from "./publishing.js";
@@ -6,14 +5,9 @@ import { equal } from "../kernel/validation.js";
6
5
  const pointer = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
7
6
  const supported = new Set([..."$defs $ref $schema $id type nullable properties required additionalProperties items enum const format pattern minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minItems maxItems title description default readOnly writeOnly examples deprecated x-mantle-ref x-mcp-hint".split(" "), ...INPUT_RULE_KEYWORDS]);
8
7
  const shapes = "see docs/handbook/reference/surface.md";
9
- // YAML .inf and .nan parse to numbers that JSON writes as null.
10
8
  const finite = (value) => typeof value === "number" ? Number.isFinite(value) : !!value && typeof value === "object" ? Object.values(value).every(finite) : true;
11
9
  const has = (key) => `Object.hasOwn(object,${q(key)})`;
12
10
  const exactly = (value, keys) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join(" ") === keys;
13
- /**
14
- * An object schema's rules, each failing `when` (JavaScript on `object`) with its sentence at the missing field `key`, or the
15
- * reason a rule has an unsupported shape.
16
- */
17
11
  function rules(schema) {
18
12
  const declared = (names) => Array.isArray(names) && names.length > 0 && names.every(name => typeof name === "string" && Object.hasOwn(schema.properties ?? {}, name));
19
13
  const list = (names) => names.map(name => `'${name}'`).join(" and ");
@@ -34,7 +28,6 @@ function rules(schema) {
34
28
  if (condition !== undefined || then !== undefined || otherwise !== undefined) {
35
29
  const [key] = exactly(condition, "properties required") ? Object.keys(condition.properties) : [];
36
30
  const value = key === undefined ? undefined : condition.properties[key];
37
- // Without required: [key], an absent key satisfies `if`.
38
31
  if (key === undefined || !exactly(condition.properties, key) || !exactly(value, "const") || !(["string", "boolean"].includes(typeof value.const) || Number.isFinite(value.const) || value.const === null) || q(condition.required) !== q([key]) || !declared([key]))
39
32
  return `if is supported only as exactly { properties: { <field>: { const: <string, finite number, boolean or null> } }, required: [<field>] }, so an absent field does not satisfy it; ${shapes}.`;
40
33
  if (then === undefined && otherwise === undefined || [then, otherwise].some(branch => branch !== undefined && !(exactly(branch, "required") && declared(branch.required))))
@@ -47,19 +40,12 @@ function rules(schema) {
47
40
  }
48
41
  return found;
49
42
  }
50
- /** Whether an input schema accepts `{}`: a list View is read with no input, so web indexes need this. */
51
43
  export function acceptsEmpty(input) {
52
44
  if (input === undefined)
53
45
  return true;
54
- // Validation applies a root `$ref`'s target too, so each schema along the chain must accept `{}`; an unresolved or cyclic chain does not.
55
- // dependentRequired needs a present key; an if needs its field, so `{}` meets only an else. An enum or const must admit `{}`.
56
46
  return rootRefChain(input)?.every(schema => !schema.required?.length && schema.anyOf === undefined && !schema.else?.required?.length
57
47
  && (schema.enum === undefined || schema.enum.some(option => equal({}, option))) && (schema.const === undefined || equal({}, schema.const)) && (schema.type === undefined || [schema.type].flat().includes("object"))) ?? false;
58
48
  }
59
- /**
60
- * An input schema as an AI tool catalogue advertises it: some model APIs refuse rules (a root `anyOf`, `if`), so each rule leaves
61
- * the schema and becomes a sentence of the description.
62
- */
63
49
  export function advertised(input, description) {
64
50
  if (!input)
65
51
  return { inputSchema: input, description };
@@ -68,7 +54,6 @@ export function advertised(input, description) {
68
54
  if (!node || typeof node !== "object")
69
55
  return;
70
56
  const schema = node;
71
- // An MCP View tool keeps each rule set of its root `$ref` chain as an `allOf` branch over the merged properties.
72
57
  const branches = Array.isArray(schema.allOf) ? schema.allOf : [];
73
58
  for (const branch of [schema, ...branches.map(branch => ({ ...branch, properties: schema.properties }))]) {
74
59
  const found = rules(branch);
@@ -111,9 +96,7 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
111
96
  files[file] = `import { transition } from "@aotter/mantle/validation";\nexport const validate = (raw: unknown) => transition(${q(base)}, raw);\n`;
112
97
  continue;
113
98
  }
114
- // Only a local `$ref`, or more than 100 nested objects and arrays, can reach the nesting limit: only then is `depth` passed.
115
99
  const recursive = json.includes('"$ref"') || json.split(/[[{]/).length > 101, depth = recursive ? ",depth+1" : "";
116
- // Identical bodies share one function.
117
100
  const nodes = new Map(), bodies = new Map(), functions = [], formats = new Set(), patterns = new Map();
118
101
  let equality = false;
119
102
  const resolve = (ref) => {
@@ -142,7 +125,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
142
125
  refuse(base, `Unsupported input type '${type}'.`);
143
126
  if (types.length)
144
127
  lines.push(typeCheck(types));
145
- // After a single type check `fail` (never) has narrowed `value`: that type's guard is implied.
146
128
  const only = types.length === 1 ? types[0] : undefined;
147
129
  const guard = (type, test = `(${condition(type)})`) => only === type || type === "number" && only === "integer" ? "" : test + " && ";
148
130
  if (schema.enum) {
@@ -197,7 +179,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
197
179
  }
198
180
  if (schema.items)
199
181
  lines.push(`${only === "array" ? "" : "if (Array.isArray(value)) "}for (let i=0;i<value.length;i++) ${emit(schema.items)}(value[i],path+"/"+i${depth});`);
200
- // Several keys that need no JSON-pointer escaping are checked by one loop, in the same order.
201
182
  const required = schema.required ?? [], checks = Object.entries(schema.properties ?? {}).map(([key, child]) => [key, emit(child)]);
202
183
  const loop = (keys) => keys.length > 1 && keys.every(key => pointer(key) === key);
203
184
  const object = loop(required) ? [`for (const key of ${q(required)}) if (!Object.hasOwn(object,key)) fail(path+"/"+key,"Required field is missing.");`] : required.map(key => `if (!Object.hasOwn(object,${q(key)})) fail(path+${q("/" + pointer(key))},"Required field is missing.");`);
@@ -217,7 +198,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
217
198
  if (object.length)
218
199
  lines.push(`${only === "object" ? "" : `if (${condition("object")}) `}{ const object = value as Record<string,unknown>;\n${object.join("\n")}\n}`);
219
200
  const body = lines.join("\n");
220
- // Without recursion nothing calls a check before its body is complete, so an identical body's function serves it.
221
201
  const fn = recursive ? named : bodies.get(body) ?? named;
222
202
  if (fn === named) {
223
203
  bodies.set(body, fn);
@@ -1,7 +1,8 @@
1
1
  # Examples
2
2
 
3
- Start from the **app shell**, then copy the example closest to your app on top of it. Each example shows a few
4
- capabilities the others do not; combine them for anything else.
3
+ Start an app with `mantle init`, which writes the **app shell** once. The examples are references: each shows a few
4
+ capabilities the others do not. Find each need in **Combining** below, read that example's manifest and source, and
5
+ take the pattern into your own manifests and `src/`. Do not copy an example directory over your app.
5
6
 
6
7
  | Example | Shows |
7
8
  |---|---|
@@ -16,20 +17,27 @@ capabilities the others do not; combine them for anything else.
16
17
  | You need | Take |
17
18
  |---|---|
18
19
  | Stock that never oversells (orders, tickets, bookings) | club-events' capacity: a Schema `checks` constraint plus the decrement in the same program |
20
+ | An order, cart or import with several rows that must all commit or none | one SQL Procedure over `json_each(input.items)` with a counted check that refuses unknown lines (`mantle help typed-queries`, "Several rows that commit together") |
19
21
  | Employees or customers who see only their own records | club-events' `restrict: { <schema>: "author_id = auth.uid()" }` |
20
22
  | An approval step, or any state machine | purchasing's guarded `UPDATE ... WHERE requestStatus = ...` Procedures |
23
+ | Approve first, then reserve or spend | purchasing's `approve-request-write`: the guarded status change and the budget deduction in one program (`mantle help sql-patterns`, "Approve before reserving") |
24
+ | A status chosen while writing (auto-approve small requests) | purchasing's `submit-request`: `CASE` in the INSERT ("Choose a status while writing: CASE") |
25
+ | Edit only the fields given | `COALESCE(input.field, field)` ("Change only the fields given") |
26
+ | Create or add (restock, counters) | `INSERT ... ON CONFLICT (...) DO UPDATE` ("Create or add") |
27
+ | Refuse while something still refers to it | a `NOT EXISTS` guard in the UPDATE (purchasing's `submit-request` ledger row; "Refuse while something refers to it") |
28
+ | A record with its children in one read | club-events' `event-roster`: a JSON array per row ("A row with its children in one read") |
29
+ | The latest row per group, rankings | `row_number() OVER (PARTITION BY ...)` ("The latest row of each group") |
21
30
  | A third-party API that answers later (webhooks, callbacks) | purchasing's `src/routes.ts`: verify the raw request, then call a Procedure |
22
31
  | Public, multilingual, search- and AI-friendly pages | recipes' `src/routes.ts` |
23
32
  | Invite-only or members-only sign-up | club-events' `members` allowlist + `src/signup.ts` |
24
33
  | Notifications and reminders | purchasing's `EmailSender` call and `src/schedule.ts` |
25
34
 
26
- When you copy an example only for one of its patterns, delete what your app does not need. Copying club-events, for
27
- example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
35
+ If you do copy an example's files, delete what your app does not need. Copying club-events, for example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
28
36
  shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchasing brings its supplier callback
29
37
  (`src/routes.ts`, `src/supplier.ts`), email (`src/notify.ts`), cron (`src/schedule.ts` and `triggers.crons`) and its
30
38
  `wrangler.jsonc` secrets. `pnpm generate` and `tsc` point at leftovers that reference removed Schemas.
31
39
 
32
40
  Every example uses two roles: `admin` (the admin layer) and `user`.
33
41
 
34
- [Reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.9/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
42
+ [Reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.1/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
35
43
  Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
@@ -2,24 +2,59 @@
2
2
 
3
3
  The wiring every Mantle Cloudflare app needs, ready to copy: Better Auth (email OTP, admin + user roles),
4
4
  one Worker fetch (auth routes → your routes → Admin → MCP → generated REST), the Admin console assets, and a
5
- local acceptance kit. It has no business manifests: add your own, or copy one of the examples on top of it.
5
+ local acceptance kit. It has no business manifests: add your own, taking patterns from the examples (`mantle help examples`).
6
6
 
7
7
  ## Start an app
8
8
 
9
9
  ```sh
10
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/ # or from this repository
11
- cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # optional: an example on top
10
+ npx @aotter/mantle@<exact version> init my-app # writes this shell once, Mantle packages pinned to that version
12
11
  cd my-app
13
- # package.json: pin the exact installed Mantle version for @aotter/mantle, -admin and -ui
14
12
  pnpm install
13
+ # add manifests/*.yaml (mantle help four-atoms; patterns: mantle help examples)
15
14
  pnpm generate # writes generated/ and lists any `handler: { ref }` to implement in src/handlers.ts
16
- pnpm types && pnpm check
15
+ pnpm types && pnpm check # each builds once (see Which steps build)
17
16
  cp .dev.vars.example .dev.vars
18
- pnpm db:init && pnpm db:seed # db:seed only when the example has seed.sql
19
- pnpm dev # one-time codes print to this log
20
- pnpm smoke # when the copied example has smoke.mjs
17
+ pnpm db:init # optional seed.sql for static reference data, then pnpm db:seed (see Seed data)
18
+ pnpm dev # builds, rebuilds on change; one-time codes print to this log
19
+ pnpm smoke # builds; your smoke.mjs on smoke-kit.mjs (see Files you edit)
20
+ pnpm accept # builds; hands each role to a fresh evaluator (see Independent acceptance)
21
+ pnpm test # Admin MCP App check of the last build: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
21
22
  ```
22
23
 
24
+ ### Which steps build
25
+
26
+ `wrangler.jsonc` sets `mantle bundle src/index.ts dist` as Wrangler's custom build. These steps run it (marked
27
+ **builds**); the others do not:
28
+
29
+ | Step | Builds? |
30
+ |---|---|
31
+ | `pnpm generate`, `pnpm db:init`, `pnpm db:seed` | no |
32
+ | `pnpm test` | no; it tests the last build |
33
+ | `pnpm types` (`wrangler types`) | **builds**: Wrangler reads the built entry's exports for Durable Object and service binding types |
34
+ | `pnpm check` | **builds** once, in `wrangler types --check`; `generate --check` and `tsc` do not |
35
+ | `pnpm build` | **builds** |
36
+ | `pnpm dev` | **builds** at start, then again on every change under `src/` or `generated/` |
37
+ | `pnpm smoke`, `pnpm accept` | **build** once each: both start `wrangler dev` |
38
+
39
+ Wrangler has no option to skip the custom build for `wrangler types`. The Admin console is rebuilt only when the
40
+ installed Admin packages change or the app has `src/admin.tsx`, so a repeated build mostly rebuilds the Worker.
41
+
42
+ First run, after `pnpm install` and your manifests: `pnpm generate`, `pnpm types`, `pnpm check`,
43
+ `cp .dev.vars.example .dev.vars` (`pnpm dev` needs the secrets `wrangler.jsonc` requires), `pnpm db:init`, then
44
+ `pnpm dev` or `pnpm smoke`. Right after `pnpm types`, `pnpm check` repeats its build; run it anyway, because it is the
45
+ same check CI and hand-off use.
46
+
47
+ Routine edit, with `pnpm dev` running:
48
+
49
+ 1. Edit `manifests/*.yaml`, then `pnpm generate`. Wrangler does not watch `manifests/`, so the last build keeps
50
+ serving until something under `src/` or `generated/` changes. From the next change under `src/` until you
51
+ generate, each change prints one line, `Generated output in generated is stale (...)`, and the dev server holds
52
+ requests instead of running stale code. Writing `generated/` rebuilds. If you undo the manifest edit instead,
53
+ nothing rebuilds until the next change under `src/` or a restart of `pnpm dev`.
54
+ 2. `pnpm types` only when `generate` or you changed `wrangler.jsonc` (bindings, secrets, vars).
55
+ 3. Edits under `src/` rebuild by themselves.
56
+ 4. Before handing off: `pnpm check`, then `pnpm smoke`.
57
+
23
58
  npm works too (`npm install`, `npm run …`). `package.json` pins `@better-auth/core` and `@better-auth/utils` even though
24
59
  the shell does not import them: they are the exact versions the SDK's Better Auth peers require, and without them a fresh
25
60
  install resolves newer ones (npm ERESOLVE, or a second Better Auth type tree under pnpm). Keep every Better Auth package
@@ -38,6 +73,7 @@ on one version.
38
73
  | `src/signup.ts` | `allowSignUp(env, email)`: who may create an account (allow-all by default). `auth.ts` calls it before Better Auth creates a user; `ADMIN_EMAIL` is always allowed. |
39
74
  | `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
40
75
  | `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
76
+ | `accept.mjs` | Independent acceptance setup: one URL per role that carries its session, plus a minimal MCP client. Add a fixtures module for realistic data. |
41
77
 
42
78
  `src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
43
79
  chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
@@ -45,16 +81,59 @@ HTTP-only Procedures will keep generating with a warning.
45
81
 
46
82
  Search for `AGENT:` to find what an app must fill in.
47
83
 
84
+ ## Seed data
85
+
86
+ Create test records through the app: your `smoke.mjs` or an `accept.mjs` fixtures module calls the app's own REST, MCP
87
+ or Admin operations as a signed-in user, so Mantle fills `id`, `version`, `created_at`, `updated_at`, `author_id`
88
+ and the publishing `status`/`published_at`. Keep `seed.sql` for static reference data no surface writes; native SQL
89
+ must set those columns itself, timestamps as integer microseconds on D1. An omitted `updated_at` stores `0`, which
90
+ Admin shows as 1970. `mantle help local-testing` (Seed data) has a complete seed:
91
+
92
+ ```sql
93
+ INSERT INTO categories (id, version, created_at, updated_at, name) VALUES
94
+ ('books', 1, 1767225600000000, 1767225600000000, 'Books');
95
+ ```
96
+
48
97
  ## Local testing notes
49
98
 
50
99
  - Better Auth allows 3 OTP requests per minute per client IP. `smoke-kit.mjs` gives each user its own
51
100
  `cf-connecting-ip`; when testing by hand, wait a minute or vary that header.
52
101
  - `ADMIN_EMAIL`'s first verified sign-in becomes `admin` while no admin exists. The sign-in response may still show
53
102
  the previous role; `/admin/api/me` shows the current one.
103
+ - More administrators: in Admin, **More → Administrators** invites by email and **More → Members** grants Admin to an
104
+ existing member. `docs/handbook/cloudflare/authentication.md` (Identity helpers) has the same operations as an API.
54
105
  - Admin HTTP routes are listed in `docs/handbook/guides/native-admin.md`.
55
106
 
107
+ `mantle help local-testing` lists what the smoke covers: every Procedure at its edges, each caller's isolation,
108
+ visitor pages without JavaScript, MCP discovery and Admin's MCP App.
56
109
  Scripted smoke is not independent acceptance; before reporting a user-facing
57
- deliverable done, follow the [independent acceptance guide](../../handbook/guides/agent-workflow-acceptance.md).
110
+ deliverable done, follow the independent acceptance guide: `mantle help agent-workflow-acceptance`. Hand off with
111
+ its layered report ("Report done-ness in layers"): smoke and `pnpm test` are layer 2, `pnpm accept` rounds layer 3.
112
+
113
+ ### Independent acceptance
114
+
115
+ `pnpm accept` (optionally `pnpm accept --fixtures fixtures.mjs --user member@example.test`; with npm, `npm run accept -- --fixtures …`) starts the app on a fresh local D1,
116
+ signs the admin in, runs your fixtures module, signs the user in and prints one local URL per role:
117
+
118
+ ```text
119
+ visitor (anonymous): http://127.0.0.1:18741
120
+ admin (owner@example.test): http://127.0.0.1:46467 MCP http://127.0.0.1:46467/mcp/admin Admin http://127.0.0.1:46467/admin
121
+ user (member@example.test): http://127.0.0.1:42607 MCP http://127.0.0.1:42607/mcp
122
+ ```
123
+
124
+ Each URL forwards to the app with that role's session; the evaluator never sees a cookie, and the URL refuses
125
+ `/api/auth/*`, so it cannot sign out, sign in as someone else or mint credentials. Give an evaluator only its role's URL:
126
+
127
+ - MCP: configure the URL as a streamable HTTP MCP server, or, without an MCP client,
128
+ `node accept.mjs mcp <url>/mcp` lists the tools and `node accept.mjs mcp <url>/mcp <tool> '<json>'` calls one.
129
+ - Admin and member pages: open the URL in a browser (a person's, or Playwright for an agent); it is already
130
+ signed in.
131
+ - REST: send requests to the URL with no credential.
132
+
133
+ The fixtures module's default export receives `{ call, mcp, signIn, sql, sessions, user }` and runs between the two
134
+ sign-ins, so an invite-only app can admit the user first. Create records through the app's own REST/MCP as real
135
+ users (other members' records, several states, more than one page); `sql` is for what no surface may write, and returns storage column names (lower case unless aliased).
136
+ Ctrl-C stops the app and deletes its data. To accept an external MCP host's OAuth connection instead, see the guide.
58
137
 
59
138
  ### MCP acceptance
60
139
 
@@ -84,7 +163,7 @@ and compares exact names without calling business tools. JSON/SSE protocol failu
84
163
  unexpected/missing tools and repeated/excessive pagination fail; it never obtains credentials or mounts routes.
85
164
 
86
165
  This local cookie test does not prove OAuth Bearer or an external client's connection. Test those separately
87
- with supplied credentials and record their results. For each declared interface, the existing handoff/README/test
88
- report must say verified (command, identity and result), intentionally not exposed, or deferred/unverified with a
89
- reason. WebMCP-only applications remain valid; an interrupted preview must retain the remote acceptance item
166
+ with supplied credentials and record their results as layer 4 of the layered report. For each declared interface,
167
+ the existing handoff/README/test report must say verified (command, identity and result), intentionally not exposed,
168
+ or deferred/unverified with a reason. WebMCP-only applications remain valid; an interrupted preview must retain the remote acceptance item
90
169
  as deferred rather than silently claiming completion.