@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.9

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 (126) hide show
  1. package/README.md +27 -5
  2. package/dist/auth/adapter.js +9 -6
  3. package/dist/auth/caller.js +2 -1
  4. package/dist/auth/identity.d.ts +2 -2
  5. package/dist/auth/oauth.d.ts +33 -1
  6. package/dist/auth/oauth.js +121 -28
  7. package/dist/auth/routes.js +6 -4
  8. package/dist/build/compiler.d.ts +44 -122
  9. package/dist/build/compiler.js +202 -277
  10. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
  11. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  12. package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
  13. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  14. package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
  15. package/dist/domain/describe/index.js +1 -1
  16. package/dist/domain/describe/target-hash.js +1 -1
  17. package/dist/domain/describe/targets.d.ts +1 -1
  18. package/dist/domain/generated-tools.js +2 -0
  19. package/dist/domain/paging.js +41 -15
  20. package/dist/infrastructure/bundle.js +118 -15
  21. package/dist/infrastructure/cli.js +11 -15
  22. package/dist/infrastructure/generate.d.ts +1 -3
  23. package/dist/infrastructure/generate.js +11 -3
  24. package/dist/infrastructure/generateAuth.js +57 -0
  25. package/dist/infrastructure/inputs.js +8 -3
  26. package/dist/infrastructure/wrangler.js +50 -12
  27. package/dist/kernel/binds.d.ts +0 -3
  28. package/dist/kernel/contracts.d.ts +1 -1
  29. package/dist/kernel/contracts.js +4 -4
  30. package/dist/kernel/dialect/common.d.ts +0 -1
  31. package/dist/kernel/drain.d.ts +5 -0
  32. package/dist/kernel/drain.js +35 -0
  33. package/dist/kernel/driver/postgres.js +6 -2
  34. package/dist/kernel/host-caller.d.ts +1 -0
  35. package/dist/kernel/host-caller.js +1 -0
  36. package/dist/kernel/mcp-apps.d.ts +29 -9
  37. package/dist/kernel/mcp-apps.js +3 -3
  38. package/dist/kernel/mcp-handler.js +101 -18
  39. package/dist/kernel/mcp.d.ts +12 -3
  40. package/dist/kernel/mcp.js +31 -6
  41. package/dist/kernel/rest-router.js +3 -2
  42. package/dist/kernel/scalars.js +3 -2
  43. package/dist/kernel/site.d.ts +1 -3
  44. package/dist/kernel/site.js +2 -2
  45. package/dist/kernel/web-index.js +40 -0
  46. package/dist/kernel/web.d.ts +3 -1
  47. package/dist/kernel/web.js +6 -37
  48. package/dist/usecase/adminBindings.js +2 -3
  49. package/dist/usecase/adminClient.js +87 -0
  50. package/dist/usecase/adminDescriptors.js +19 -65
  51. package/dist/usecase/generate.js +5 -4
  52. package/dist/usecase/mcp-media.js +2 -2
  53. package/dist/usecase/mcp.js +16 -12
  54. package/dist/usecase/paging.js +2 -3
  55. package/dist/usecase/publishing.js +3 -3
  56. package/dist/usecase/site.js +0 -1
  57. package/dist/usecase/tool-version.js +9 -0
  58. package/dist/usecase/translations.js +1 -1
  59. package/dist/usecase/validation.js +128 -19
  60. package/dist/usecase/web.js +3 -2
  61. package/docs/examples/README.md +1 -1
  62. package/docs/examples/app-shell/README.md +45 -3
  63. package/docs/examples/app-shell/package.json +14 -6
  64. package/docs/examples/app-shell/smoke-kit.mjs +54 -0
  65. package/docs/examples/app-shell/src/auth.ts +6 -3
  66. package/docs/examples/app-shell/src/service.ts +3 -3
  67. package/docs/examples/app-shell/tsconfig.json +4 -1
  68. package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
  69. package/docs/examples/app-shell/wrangler.jsonc +5 -4
  70. package/docs/examples/club-events/manifests/club-events.yaml +7 -0
  71. package/docs/examples/club-events/src/handlers.ts +1 -1
  72. package/docs/examples/generated-web/README.md +6 -2
  73. package/docs/examples/generated-web/wrangler.jsonc +1 -0
  74. package/docs/examples/purchasing/README.md +1 -1
  75. package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
  76. package/docs/examples/purchasing/smoke.mjs +10 -4
  77. package/docs/examples/purchasing/src/admin.tsx +13 -0
  78. package/docs/examples/purchasing/src/handlers.ts +1 -1
  79. package/docs/examples/purchasing/wrangler.jsonc +5 -4
  80. package/docs/examples/recipes/wrangler.jsonc +5 -4
  81. package/docs/handbook/cloudflare/authentication.md +43 -6
  82. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  83. package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
  84. package/docs/handbook/cloudflare/media-r2.md +2 -3
  85. package/docs/handbook/cloudflare/service-entry.md +68 -31
  86. package/docs/handbook/concepts/authorization.md +1 -1
  87. package/docs/handbook/concepts/mcp-and-agents.md +19 -0
  88. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  89. package/docs/handbook/examples/hub.md +1 -1
  90. package/docs/handbook/guides/admin-ui.md +107 -1
  91. package/docs/handbook/guides/agent-setup.md +27 -9
  92. package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
  93. package/docs/handbook/guides/mcp-apps.md +522 -6
  94. package/docs/handbook/guides/native-admin.md +300 -51
  95. package/docs/handbook/guides/oauth.md +119 -5
  96. package/docs/handbook/guides/public-pages.md +2 -2
  97. package/docs/handbook/guides/size-and-startup.md +1 -1
  98. package/docs/handbook/guides/typed-queries.md +35 -0
  99. package/docs/handbook/navigation.json +0 -4
  100. package/docs/handbook/reference/admin-descriptors.md +0 -35
  101. package/docs/handbook/reference/procedure.md +6 -2
  102. package/docs/handbook/reference/schema.md +13 -3
  103. package/docs/handbook/reference/surface.md +129 -15
  104. package/docs/handbook/reference/trigger.md +43 -1
  105. package/docs/handbook/reference/view.md +1 -1
  106. package/docs/handbook/releases/index.md +74 -2
  107. package/docs/handbook/start/overview.md +1 -1
  108. package/docs/handbook/start/project-and-cli.md +16 -8
  109. package/docs/handbook/start/quickstart-worker.md +1 -1
  110. package/docs/skills/develop/SKILL.md +46 -18
  111. package/docs/skills/integrate/SKILL.md +4 -9
  112. package/docs/skills/media-gc/SKILL.md +7 -5
  113. package/docs/skills/provision/SKILL.md +18 -12
  114. package/docs/skills/update/SKILL.md +19 -18
  115. package/docs/upgrade-0.1-to-0.2.md +6 -6
  116. package/package.json +2 -1
  117. package/skills/README.md +11 -16
  118. package/skills/mantle/SKILL.md +39 -17
  119. package/skills/mantle/references/local-testing.md +9 -0
  120. package/skills/mantle/references/web.md +1 -1
  121. package/skills/mantle/references/website.md +26 -22
  122. package/docs/examples/app-shell/src/admin.ts +0 -10
  123. package/docs/examples/purchasing/src/admin.ts +0 -12
  124. package/docs/handbook/guides/admin-extensions.md +0 -20
  125. package/docs/skills/develop/mcp-app.md +0 -39
  126. package/skills/.keep +0 -0
@@ -0,0 +1,87 @@
1
+ /** `admin-client.ts`: the types Admin's data hooks bind to, for the application's browser-side `admin.tsx`. */
2
+ import { BUILT_IN_NAV_GROUPS, mcpTools } from "../build/compiler.js";
3
+ import { q } from "../domain/entry.js";
4
+ /**
5
+ * The compatibility test of the application's Admin MCP App (`node --test generated/admin-mcp-app.check.mjs`): it loads the App
6
+ * `mantle bundle` built in the official MCP Apps host and fails on a network request, a console error or an exception. It needs
7
+ * `playwright-core` (a devDependency) and Chrome: `npx playwright-core install chromium`, or `MANTLE_TEST_CHROMIUM=<executable>`.
8
+ * Node strips the types of `admin-descriptors.ts` (Node 22.18 or newer).
9
+ */
10
+ const MCP_APP_TEST = `import { existsSync } from "node:fs";
11
+ import { test } from "node:test";
12
+ import { checkMcpApp } from "@aotter/mantle-admin/testing";
13
+ import { mcpAppCatalogues } from "./admin-descriptors.ts";
14
+
15
+ // \`mantle bundle <entry> dist\` writes the App here; MANTLE_MCP_APP_HTML names another file
16
+ const html = process.env.MANTLE_MCP_APP_HTML ?? "dist/client/admin/mcp-app.html";
17
+
18
+ test("the Admin MCP App works in an MCP host", { timeout: 300_000 }, async () => {
19
+ if (!existsSync(html)) throw new Error(\`\${html} is missing: run \\\`npm run build\\\` first.\`);
20
+ await checkMcpApp({ catalogue: mcpAppCatalogues.admin, html });
21
+ });
22
+ `;
23
+ /** A Schema, View input or Procedure input as a closed object type; one that is only a `$ref` stays open. */
24
+ const closed = (typeOf, schema) => !schema ? "{}" : schema.properties || schema.type === "object" && !schema["$ref"] ? typeOf({ ...schema, additionalProperties: false }) : "{ readonly [key: string]: unknown }";
25
+ /**
26
+ * Types are what the routes take and return, not the Store's rows: an entry's `data` keeps declared field names, an input is
27
+ * what the route validates, a View row is its wire row under the names the browser route gives its columns (an MCP App's
28
+ * `tools/call` returns the SQL names; the hooks rename them with `rowNames`), with `true` when it is an admin MCP tool, else null, which
29
+ * types `pages[].mcp.renders` (types only; nothing ships). The file imports `generated/*` as types only, so nothing of the
30
+ * Worker enters the browser bundle, and it is refused in the Worker's startup graph (`mantle bundle`).
31
+ */
32
+ export function emitAdminClient(plan, typeOf) {
33
+ const tools = mcpTools(plan, "admin"), hasTool = (kind, source) => tools.some(t => t.kind === kind && t.source === source);
34
+ const views = Object.entries(plan.views).filter(([, v]) => v.surface === "admin").sort(([a], [b]) => a < b ? -1 : 1);
35
+ const operations = Object.entries(plan.procedures).filter(([name]) => hasTool("procedure", name)).sort(([a], [b]) => a < b ? -1 : 1);
36
+ const schemas = Object.values(plan.schemas).sort((a, b) => a.name < b.name ? -1 : 1);
37
+ // the scope column belongs to the caller, so an entry's data has no such field
38
+ const dataOf = (s) => {
39
+ const scope = s.scope ? s.names[s.scope] ?? s.scope : undefined, { [scope ?? ""]: _scope, ...properties } = s.schema.properties ?? {};
40
+ // a draft of a publishing collection is only partly validated, so none of its fields is certain to be there
41
+ const required = s.publishing ? [] : s.schema.required?.filter(f => f !== scope);
42
+ const schema = { ...s.schema, properties, ...(required ? { required } : {}) };
43
+ return s.translates ? typeOf(schema) : closed(typeOf, schema);
44
+ };
45
+ // the names the browser route gives a View's columns where they differ from the SQL output names, as it applies them
46
+ const renames = views.map(([, v]) => Object.fromEntries(Object.entries(v.columns ?? {}).flatMap(([key, c]) => {
47
+ const named = key === c.field ? plan.schemas[c.schema].names[c.field] ?? key : key;
48
+ return named !== key ? [[key, named]] : [];
49
+ })));
50
+ // every sidebar group an admin Schema or View places an item in; `defineAdmin` must declare the ones Admin does not have
51
+ // (the generate-time check of `uiSchema.nav.group` is this type)
52
+ const placed = (ui) => { const group = ui?.["nav"]?.group; return typeof group === "string" ? [group] : []; };
53
+ const used = [...new Set([...schemas.flatMap(s => placed(s.uiSchema)), ...views.flatMap(([, v]) => placed(v.uiSchema))])].sort();
54
+ const custom = used.filter(group => !BUILT_IN_NAV_GROUPS.has(group));
55
+ const union = (names) => names.length ? names.map(name => q(name)).join(" | ") : "never";
56
+ // whether an admin View is an MCP tool, which a page declared for an MCP App (`pages[].mcp.renders`) must have
57
+ const toolOf = (name) => hasTool("view", name) || null;
58
+ const rows = views.map(([name], n) => `import type { WireRow as View${n}Wire } from "./views/${encodeURIComponent(name)}.js";`);
59
+ const sql = operations.some(([, p]) => "sql" in p.handler);
60
+ return { "admin-mcp-app.check.mjs": MCP_APP_TEST, "admin-client.ts": [
61
+ "// Browser only: import this from src/admin.tsx, never from the Worker (`mantle bundle` refuses it there).",
62
+ 'import type { AdminMeta } from "@aotter/mantle-admin/app";',
63
+ 'import { createAdminHooks } from "@aotter/mantle-admin/hooks";',
64
+ ...(sql ? ['import type { Output } from "@aotter/mantle/contracts";'] : []),
65
+ ...rows,
66
+ "",
67
+ "type Renamed<Row, Names extends { readonly [from: string]: string }> = { [K in keyof Row as K extends keyof Names ? Names[K] : K]: Row[K] };",
68
+ ...views.map((_, n) => `type View${n}Row = Renamed<View${n}Wire, ${q(renames[n])}>;`),
69
+ "",
70
+ "/** Every sidebar group the manifest places an item in. */",
71
+ `export type ManifestNavGroup = ${union(used)};`,
72
+ "",
73
+ "/** The input and row of each admin View, the `data` of each collection's entries, and the input and output of each admin operation. */",
74
+ "export interface AppAdminTypes {",
75
+ ` readonly views: { ${views.map(([name, v], n) => `readonly ${q(name)}: { readonly input: ${closed(typeOf, v.input)}; readonly row: View${n}Row; readonly mcp: ${q(toolOf(name))} }`).join("; ")} };`,
76
+ ` readonly collections: { ${schemas.map(s => `readonly ${q(s.name)}: { readonly data: ${dataOf(s)} }`).join("; ")} };`,
77
+ ` readonly operations: { ${operations.map(([name, p]) => `readonly ${q(name)}: { readonly input: ${closed(typeOf, p.input)}; readonly output: ${"sql" in p.handler ? "Output" : "unknown"} }`).join("; ")} };`,
78
+ ` readonly navGroups: ${union(custom)};`,
79
+ "}",
80
+ "",
81
+ "/** Pass to `defineAdmin(admin)`: the column names a View's rows carry in the browser, for an MCP App to match. */",
82
+ `export const admin: AdminMeta<AppAdminTypes> = { rowNames: ${q(Object.fromEntries(views.flatMap(([name], n) => Object.keys(renames[n]).length ? [[name, renames[n]]] : [])))} };`,
83
+ "",
84
+ "export const { useView, useEntries, useEntry, useOperation, useCollectionTotal, useMe, useBadges } = createAdminHooks<AppAdminTypes>();",
85
+ "",
86
+ ].join("\n") };
87
+ }
@@ -1,36 +1,19 @@
1
1
  /** Build-time projections only; generated exports are static, independently tree-shakable data. */
2
- import { DiagnosticError, BUILT_IN_NAV_GROUPS, navGroupProblems, formSchemaProblem, mcpTools, mcpToolNameSegment, collectionOf, developerConsole, rowPreconditions, checkPlanUiExtensions, planUiExtensionRefs, appCatalog, entrySchema } from "../build/compiler.js";
2
+ import { isPrimaryCollection, mcpTools, mcpToolNameSegment, toolTitle, collectionOf, developerConsole, rowPreconditions, appCatalog, entrySchema } from "../build/compiler.js";
3
3
  import { q } from "../domain/entry.js";
4
- import { emitValidation } from "./validation.js";
5
- import { publishingInput, publishingTransitions } from "./publishing.js";
6
- const adminEnum = (p) => { if (!p.oneOf)
7
- return p; const { oneOf, ...rest } = p; return { ...rest, enum: oneOf.map(c => c.const) }; };
8
- export function emitAdminDescriptors({ plan, adminExtensions = [], navGroups = [], admin = false }, diagnostics) {
9
- // Sidebar placement: every nav.group names a built-in group or a declared navGroups id (presentation only).
10
- for (const [path, message] of navGroupProblems(navGroups))
11
- diagnostics.push({ code: "SCHEMA_UI_INVALID", path, message });
12
- const groupIds = new Set([...Object.keys(BUILT_IN_NAV_GROUPS), ...(Array.isArray(navGroups) ? navGroups.map(g => g.id) : [])]);
13
- const placed = [...Object.values(plan.schemas).map(s => ["schemas/" + s.name, s.uiSchema]), ...Object.entries(plan.views).map(([name, v]) => ["views/" + name, v.uiSchema])];
14
- for (const [path, ui] of placed) {
15
- const group = ui?.["nav"]?.group;
16
- if (typeof group === "string" && !groupIds.has(group))
17
- diagnostics.push({ code: "SCHEMA_UI_INVALID", path: path + "/uiSchema/nav/group", message: `Unknown nav group '${group}': use content, operations, reports or more, or declare it in navGroups of the --admin-extensions module.` });
18
- }
19
- for (const e of adminExtensions)
20
- for (const p of e.contributes.pages ?? []) {
21
- const group = p.nav?.group;
22
- if (group !== undefined && group !== "settings" && !groupIds.has(group))
23
- diagnostics.push({ code: "UI_EXTENSION_INVALID", path: `admin/extensions/${e.id}/pages/${p.id}/nav/group`, message: `Unknown nav group '${group}'.` });
24
- }
25
- try {
26
- checkPlanUiExtensions(plan, adminExtensions);
27
- }
28
- catch (error) {
29
- if (!(error instanceof DiagnosticError))
30
- throw error;
31
- const { code, path, message } = error.diagnostic;
32
- diagnostics.push({ code, path, message });
33
- return {};
4
+ import { advertised } from "./validation.js";
5
+ import { publishingAnnotations, publishingInput, publishingTransitions } from "./publishing.js";
6
+ import { versioned } from "./tool-version.js";
7
+ export function emitAdminDescriptors({ plan, admin = false }, diagnostics) {
8
+ const schemaList = Object.values(plan.schemas);
9
+ const placed = [...schemaList.map(s => ["schemas/" + s.name, s.uiSchema, s]), ...Object.entries(plan.views).map(([name, v]) => ["views/" + name, v.uiSchema, null])];
10
+ for (const [path, ui, s] of placed) {
11
+ const nav = (ui?.["nav"] ?? {}), group = nav["group"];
12
+ // a child folded under its parent, or a translation on its parent's language tabs, has no sidebar entry to place
13
+ if (s && !isPrimaryCollection(s, schemaList))
14
+ for (const key of ["group", "order", "hidden"])
15
+ if (nav[key] !== undefined)
16
+ diagnostics.push({ code: "SCHEMA_UI_INVALID", path: `${path}/uiSchema/nav/${key}`, message: s.translates ? `Schema '${s.name}' has no sidebar entry to place: it is a translation, shown on its parent's language tabs.` : `Schema '${s.name}' has no sidebar entry to place: it is folded under its parent${collectionOf(s, schemaList).parent?.parentField === "id" ? "; nav.standalone: true lists it in the sidebar." : " and opens from its parent's workbench (standalone needs a required id reference)."}` });
34
17
  }
35
18
  if (diagnostics.length)
36
19
  return {};
@@ -45,7 +28,7 @@ export function emitAdminDescriptors({ plan, adminExtensions = [], navGroups = [
45
28
  return [output, { schema: schema.name, field }];
46
29
  }));
47
30
  const list = (v.uiSchema?.list ?? {});
48
- return { name, title: v.title ?? null, description: v.description ?? null, input: v.input ?? null, list: { columns: list.columns ?? [], searchFields: list.searchFields ?? [], filterFields: list.filterFields ?? [], cells: list.cells ?? {} }, columns, ...(v.uiSchema?.nav ? { nav: v.uiSchema.nav } : {}) };
31
+ return { name, title: v.title ?? null, description: v.description ?? null, input: v.input ?? null, list: { columns: list.columns ?? [], searchFields: list.searchFields ?? [], filterFields: list.filterFields ?? [] }, columns, ...(v.uiSchema?.nav ? { nav: v.uiSchema.nav } : {}) };
49
32
  });
50
33
  const adminTools = mcpTools(plan, "admin"), toolNames = new Set(adminTools.map(t => t.name));
51
34
  const operations = adminTools.filter(t => t.kind === "procedure").map(t => {
@@ -57,7 +40,7 @@ export function emitAdminDescriptors({ plan, adminExtensions = [], navGroups = [
57
40
  const adminCatalogue = appCatalog(plan, "admin");
58
41
  const catalogues = { public: appCatalog(plan, "public"), admin: { views: Object.fromEntries(Object.entries(adminCatalogue.views).map(([name, v]) => [name, { ...v, actions: [...v.actions] }])), actions: { ...adminCatalogue.actions } } };
59
42
  const snapshot = developerConsole(plan);
60
- const webmcp = { tools: adminTools.map(({ name, source, kind, title, description, inputSchema, outputSchema, annotations }) => ({ name, target: { kind, name: source }, ...(title ? { title: (kind === "view" ? plan.views[source] : plan.procedures[source]).title ?? title } : {}), description: (kind === "view" ? plan.views[source] : plan.procedures[source]).description ?? description, inputSchema, ...(outputSchema ? { outputSchema } : {}), ...(annotations ? { annotations } : {}) })), routes: Object.fromEntries(adminTools.flatMap(t => {
43
+ const webmcp = { tools: adminTools.map(({ name, source, kind, title, description, inputSchema, outputSchema, annotations }) => ({ target: { kind, name: source }, ...versioned({ name, ...(title ? { title: (kind === "view" ? plan.views[source] : plan.procedures[source]).title ?? title } : {}), ...advertised(inputSchema, (kind === "view" ? plan.views[source] : plan.procedures[source]).description ?? description), ...(outputSchema ? { outputSchema } : {}), ...(annotations ? { annotations } : {}) }) })), routes: Object.fromEntries(adminTools.flatMap(t => {
61
44
  if (t.kind === "view")
62
45
  return [[t.name, { path: "/admin/views/" + encodeURIComponent(t.source) }]];
63
46
  const target = plan.procedures[t.source].target;
@@ -79,41 +62,12 @@ export function emitAdminDescriptors({ plan, adminExtensions = [], navGroups = [
79
62
  catalogues.admin.views[t.name].actions.push(tool);
80
63
  authorizations.procedures[name] = { auth: { all: ["ctx.admin"] } };
81
64
  const description = verb + " an entry in " + schema.name + ".";
82
- webmcp.tools.push({ name: tool, target: { kind: "procedure", name }, description, inputSchema: publishingInput, annotations: { readOnlyHint: false, destructiveHint: verb === "archive" } });
65
+ webmcp.tools.push({ target: { kind: "procedure", name }, ...versioned({ name: tool, title: toolTitle(verb + "_" + schema.name), description, inputSchema: publishingInput, annotations: publishingAnnotations(verb) }) });
83
66
  webmcp.routes[tool] = { path: "/admin/c/" + encodeURIComponent(schema.name), entry: true };
84
67
  snapshot.interfaces.callable.push({ kind: "procedure", name: tool, target: name, surface: "admin", audience: "admin", title: null, description, input: publishingInput, output: null, trigger: null });
85
68
  }
86
- const extensionIds = new Set();
87
- const extensionMetadata = adminExtensions.map(extension => {
88
- if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(extension.id) || extensionIds.has(extension.id) || extension.apiVersion !== undefined && extension.apiVersion !== 1)
89
- diagnostics.push({ code: "UI_EXTENSION_INVALID", path: "admin/extensions/" + extension.id, message: "Extensions need unique kebab-case ids and apiVersion 1." });
90
- extensionIds.add(extension.id);
91
- const { source, handlers: _handlers, bodyLimit: _bodyLimit, ...declaration } = extension;
92
- if (source !== undefined)
93
- diagnostics.push({ code: "GENERATE_UNSUPPORTED", path: "admin/extensions/" + extension.id, message: "Native Admin uses host-served module paths; source modules are not served. Set module and install its integrity in the host shell import map." });
94
- if (extension.module && (!extension.module.startsWith("/") || extension.module.startsWith("//") || /[\\?#]/.test(extension.module)))
95
- diagnostics.push({ code: "UI_EXTENSION_INVALID", path: "admin/extensions/" + extension.id, message: "module must be a same-origin absolute path." });
96
- return { id: extension.id, declaration: JSON.stringify(declaration), public: declaration, ...(extension.module ? { module: extension.module } : {}), ...(extension.integrity ? { integrity: extension.integrity } : {}), source: false, settings: (extension.contributes.settings ?? []).map(s => s.id), actions: (extension.contributes.actions ?? []).map(a => ({ id: a.id, target: a.target, ...(a.when?.schema ? { schema: a.when.schema } : {}) })) };
97
- });
98
- const checks = [], validationFiles = {};
99
- for (const extension of adminExtensions)
100
- for (const settings of extension.contributes.settings ?? []) {
101
- const key = extension.id + "/" + settings.id, problem = formSchemaProblem(settings.schema);
102
- if (problem) {
103
- diagnostics.push({ code: "UI_EXTENSION_INVALID", path: "admin/extensions/" + key, message: problem });
104
- continue;
105
- }
106
- const input = { ...settings.schema, additionalProperties: false, properties: Object.fromEntries(Object.entries(settings.schema.properties ?? {}).map(([name, p]) => [name, adminEnum(p)])) };
107
- const file = extension.id + "__" + settings.id; // ponytail: no "/" so Node ESM can import the emitted module
108
- const generated = emitValidation({ views: {}, procedures: { [file]: { input } } }, (path, message) => diagnostics.push({ code: "GENERATE_UNSUPPORTED", path, message }));
109
- Object.assign(validationFiles, generated);
110
- const alias = "check" + checks.length;
111
- checks.push(`import {validate as ${alias}} from "./validation/procedures/${encodeURIComponent(file)}.js";`);
112
- }
113
- const settingsKeys = adminExtensions.flatMap(e => (e.contributes.settings ?? []).map(s => e.id + "/" + s.id));
114
- const bindings = "import {extensionOptions} from '@aotter/mantle-admin/server/extensions';\nimport type {AdminExtension} from '@aotter/mantle-admin/server/extensions';\nimport {extensionMetadata} from './admin-descriptors.js';\nimport {ContractError} from '@aotter/mantle/contracts';\n" + checks.join("\n") + "\nconst checks={" + settingsKeys.map((key, n) => `${q(key)}:(value:unknown)=>{try{check${n}(value);return {};}catch(error){if(!(error instanceof ContractError))throw error;return {[(error.path.split('/input/')[1]??'').replace(/~1/g,'/').replace(/~0/g,'~')]:error.message};}}`).join(",") + "};\nexport const extensionBindings=<Env,DB>(definitions:readonly AdminExtension<Env,DB>[])=>extensionOptions<Env,DB>(extensionMetadata,checks,definitions);\n";
115
- const data = { allCollections, views, operations, navGroups, fieldNames: Object.fromEntries(schemas.map(s => [s.name, s.names])), viewNames, authorizations, developerConsole: snapshot, mcpAppCatalogues: catalogues, webmcp, extensionBindings: planUiExtensionRefs(plan), extensionMetadata };
116
- return { ...validationFiles, ...(adminExtensions.length && admin ? { "admin-extensions.ts": bindings } : {}), "admin-descriptors.ts": "// Generated static Admin metadata. Import only the exports the application uses.\n" + literals(data) + "\nexport const collections = [" + schemas.filter(s => !s.translates).map(s => "allCollections[" + q(s.name) + "]").join(",") + "] as const;\nexport const bootstrap = {collections, allCollections, operations, views, navGroups, webmcp, developerConsole} as const;\n" };
69
+ const data = { allCollections, views, operations, fieldNames: Object.fromEntries(schemas.map(s => [s.name, s.names])), viewNames, authorizations, developerConsole: snapshot, mcpAppCatalogues: catalogues, webmcp };
70
+ return { "admin-descriptors.ts": "// Generated static Admin metadata. Import only the exports the application uses.\n" + literals(data) + "\nexport const collections = [" + schemas.filter(s => !s.translates).map(s => "allCollections[" + q(s.name) + "]").join(",") + "] as const;\nexport const bootstrap = {collections, allCollections, operations, views, webmcp, developerConsole} as const;\n" };
117
71
  }
118
72
  /**
119
73
  * Each export as a JSON literal; a subtree of 40 or more bytes that would be printed more than once is printed once, as a
@@ -15,6 +15,7 @@ import { emitValidation } from "./validation.js";
15
15
  import { refuseDeclarations } from "./refusals.js";
16
16
  import { emitHandlers } from "./handlers.js";
17
17
  import { emitAdminDescriptors } from "./adminDescriptors.js";
18
+ import { emitAdminClient } from "./adminClient.js";
18
19
  import { emitSite } from "./site.js";
19
20
  import { emitTranslations } from "./translations.js";
20
21
  import { emitAdminBindings } from "./adminBindings.js";
@@ -42,7 +43,7 @@ const typeOf = (s) => {
42
43
  }
43
44
  };
44
45
  export function generate(input) {
45
- const { target, plan, lowered, viewKeys, newId, adminExtensions, site, admin, paging } = input;
46
+ const { target, plan, lowered, viewKeys, newId, site, admin, paging } = input;
46
47
  const diagnostics = [];
47
48
  refuseDeclarations({ plan }, diagnostics);
48
49
  const refuse = (path, message) => { diagnostics.push({ code: "GENERATE_UNSUPPORTED", path, message }); };
@@ -116,7 +117,7 @@ export function generate(input) {
116
117
  }
117
118
  const fieldNames = inputFields(view.input, path + "/input");
118
119
  const callerStep = step(c, path, fieldNames), trustedStep = mode === "caller" ? step(trusted, path, fieldNames) : callerStep;
119
- declarations.push(`export type View${index}Input = ${queryInputType(view.input ?? { type: "object", additionalProperties: false }, [c, ...(trusted ? [trusted] : [])])};`, `export type View${index}Row = { ${output.join(" ")} };`, `export type View${index}WireRow = { ${keys.map(key => `readonly ${q(key)}: ${boundaryType(plan, name, key) ? wireType(boundaryType(plan, name, key)) : `View${index}Row[${q(key)}]`};`).join(" ")} };`, `const ${fn}Caller = ${callerStep} as const;`, ...(trustedStep !== callerStep ? [`const ${fn}Trusted = ${trustedStep} as const;`] : []), `async function ${fn}(db: Database, caller: Caller, raw: View${index}Input): Promise<View${index}Row[]> {`, ...auth(view.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ` const m = ${trustedStep !== callerStep ? `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : ` : ""}${fn}Caller;`, ` return DRIVER.read(db, ${user.used ? "USER.scoped(m.sql, caller)" : "m.sql"}, binds(D, m.binds, caller, raw));`, "}");
120
+ declarations.push(`export type View${index}Input = ${queryInputType(view.input ?? { type: "object", additionalProperties: false }, [c, ...(trusted ? [trusted] : [])])};`, `export type View${index}Row = { ${output.join(" ")} };`, `export type View${index}WireRow = { ${keys.map(key => `readonly ${q(key)}: ${boundaryType(plan, name, key) ? wireType(boundaryType(plan, name, key)) : `View${index}Row[${q(key)}]`};`).join(" ")} };`, `const ${fn}Caller = ${callerStep} as const;`, ...(trustedStep !== callerStep ? [`const ${fn}Trusted = ${trustedStep} as const;`] : []), `async function ${fn}(db: ${isSqlite ? "Database" : "Unsafe"}, caller: Caller, raw: View${index}Input): Promise<View${index}Row[]> {`, ...auth(view.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ` const m = ${trustedStep !== callerStep ? `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : ` : ""}${fn}Caller;`, ` return DRIVER.read(db, ${user.used ? "USER.scoped(m.sql, caller)" : "m.sql"}, binds(D, m.binds, caller, raw));`, "}");
120
121
  views.push(`[${q(name)}]: ${fn}`);
121
122
  declarations.push(`export { ${fn} as ${exportName("view", name)} };`);
122
123
  entries[`views/${encodeURIComponent(name)}.ts`] = entry("view", name, `View${index}Input as Input, View${index}Row as Row, View${index}WireRow as WireRow`);
@@ -143,7 +144,7 @@ export function generate(input) {
143
144
  emitTtl(plan, isSqlite, declarations, procedures, entries, refuse);
144
145
  emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse);
145
146
  const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers);
146
- const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, !!site), ...emitAdminDescriptors({ plan, adminExtensions, navGroups: input.navGroups, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
147
+ const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, !!site), ...emitAdminDescriptors({ plan, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...(admin ? emitAdminClient(plan, typeOf) : {}), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
147
148
  if (diagnostics.length)
148
149
  return { ok: false, diagnostics };
149
150
  const storeProcedures = hasHandlers ? "storeProcedures" : "procedures";
@@ -155,7 +156,7 @@ export function generate(input) {
155
156
  `export function store${hasHandlers ? "<const H extends Handlers>" : ""}(db: Database, caller: Caller${hasHandlers ? ", options: { handlers: H & Record<Exclude<keyof H, keyof Handlers>, never> }" : ", _options?: { handlers?: Handlers }"}): ReturnType<typeof makeStore> { return (makeStore ??= initStore())(db, caller${hasHandlers ? ", options" : ""}); }`,
156
157
  ] : [])].join("\n");
157
158
  const dialect = `${base}dialect/${isSqlite ? "sqlite" : "postgres"}`;
158
- const contracts = [...(target.endsWith("postgres") ? ["type Postgres as Database"] : []), "type Caller", ...(hasHandlers ? ["ContractError"] : []), ...(/^ deny\(/m.test(code) ? ["deny", ...(/\bisAdmin\(/.test(code) ? ["isAdmin"] : []), ...(/\bisUser\(/.test(code) ? ["isUser"] : [])] : []), ...(procedures.length ? ["type Output"] : [])];
159
+ const contracts = [...(target.endsWith("postgres") ? ["type Postgres as Database", ...(views.length ? ["type Unsafe"] : [])] : []), "type Caller", ...(hasHandlers ? ["ContractError"] : []), ...(/^ deny\(/m.test(code) ? ["deny", ...(/\bisAdmin\(/.test(code) ? ["isAdmin"] : []), ...(/\bisUser\(/.test(code) ? ["isUser"] : [])] : []), ...(procedures.length ? ["type Output"] : [])];
159
160
  const dbType = isSqlite ? databaseImport(target) : "";
160
161
  return { ok: true, refProcedures: refNames, files: generatedFiles({ ...surfaces, ...entries, "generated.ts": ["// Generated native application SQL. No runtime plan or interpreter.", user.code, dbType, `import { ${contracts.join(", ")} } from "${base}contracts";`,
161
162
  ...(values.size || stores ? [`import * as DIALECT from "${dialect}";`] : []), `import * as DRIVER from "${base}driver/${driver}";`, ...((names => names.length ? [`import { ${names.join(", ")} } from "${base}store";`] : [])([...(stores ? ["createStore"] : []), ...(hasHandlers && target !== "cloudflare-d1" ? ["handlerTransaction"] : [])])),
@@ -1,8 +1,8 @@
1
1
  import { emitValidation } from "./validation.js";
2
2
  import { mediaDefinitions } from "../domain/generated-tools.js";
3
3
  export { mediaDefinitions };
4
- export function emitMcpMedia(refuse) {
5
- const definitions = mediaDefinitions();
4
+ /** `definitions` are the versioned media tools, computed once by the MCP emitter. */
5
+ export function emitMcpMedia(definitions, refuse) {
6
6
  const inputs = Object.fromEntries(definitions.map(tool => [tool.name, { input: tool.inputSchema }]));
7
7
  const checks = Object.fromEntries(Object.entries(emitValidation({ views: {}, procedures: inputs }, refuse)).map(([path, code]) => [path.replace("validation/procedures/", "validation/media/"), code]));
8
8
  const invoke = { policy: 'async (db,caller) => ({purposes:(await site(db,caller).read()).media.purposes})', create: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).createUpload(input)', commit: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).commitUpload(input.uploadGroupId as string,input)', list: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).list(input)', get: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).get(input.id as string)', update: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).update(input.id as string,input)', delete: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).delete(input.id as string)' };
@@ -1,19 +1,21 @@
1
1
  /** Emit the selected MCP boundary; the official transport loads on its first request. */
2
- import { mcpToolNameSegment } from "../build/compiler.js";
2
+ import { mcpToolNameSegment, procedureAnnotations, toolTitle, viewAnnotations } from "../build/compiler.js";
3
3
  import { emitMcpMedia, mediaDefinitions } from "./mcp-media.js";
4
- import { emitValidation } from "./validation.js";
5
- import { databaseImport, q } from "../domain/entry.js";
4
+ import { advertised, emitValidation } from "./validation.js";
5
+ import { databaseImport, literal, q } from "../domain/entry.js";
6
6
  import { authLines, conversions } from "../domain/input.js";
7
- import { publishingInput } from "./publishing.js";
7
+ import { publishingAnnotations, publishingInput } from "./publishing.js";
8
+ import { versioned } from "./tool-version.js";
8
9
  import { boundaryPage, outputConversions } from "../domain/columns.js";
9
10
  export function emitMcp(plan, lowered, target, description, refuse, media = false) {
10
11
  if (!Object.values(plan.views).some(view => view.surface !== "internal") && !Object.values(plan.triggers).some(trigger => trigger.source.kind === "mcp"))
11
12
  return {};
12
13
  const reads = [];
13
14
  const imports = plan.access ? ['import { userBinds } from "@aotter/mantle/user-fields";'] : [], tools = [];
14
- const files = media ? emitMcpMedia(refuse) : {};
15
+ const mediaTools = media ? mediaDefinitions().map(({ action, ...tool }) => ({ action, ...versioned({ ...tool, title: toolTitle(tool.name) }) })) : [];
16
+ const files = media ? emitMcpMedia(mediaTools, refuse) : {};
15
17
  let hasRef = false, hasWire = false;
16
- const names = new Set(media ? mediaDefinitions().map(tool => "admin:" + tool.name) : []);
18
+ const names = new Set(mediaTools.map(tool => "admin:" + tool.name));
17
19
  const guard = (requires, path, binds = [], trustedOnly = false) => {
18
20
  const lines = authLines(requires, path, binds, !target.endsWith("postgres"), trustedOnly);
19
21
  return lines.length ? `, check: (caller) => {${lines.join("")}}` : "";
@@ -36,7 +38,7 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
36
38
  imports.push(`const run${index}: typeof import(${q(module)}).default = async (...args) => (await import(${q(module)})).default(...args);`);
37
39
  }
38
40
  // The kernel adds a View tool's `limit` and `cursor` paging properties.
39
- const schema = q(kind === "views" ? operation.input ?? { type: "object", additionalProperties: false } : operation.input);
41
+ const { inputSchema, description: about } = advertised(kind === "views" ? operation.input ?? { type: "object", additionalProperties: false } : operation.input, operation.description ?? operation.title ?? name), schema = literal(inputSchema);
40
42
  if (kind === "views" && ("limit" in (operation.input?.properties ?? {}) || "cursor" in (operation.input?.properties ?? {})))
41
43
  refuse(`views/${name}`, "MCP View input reserves limit and cursor for paging.");
42
44
  if (names.has(surface + ":" + tool))
@@ -45,12 +47,14 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
45
47
  const convert = conversions(operation.input?.properties, binds, target), scopes = (operation.requires?.auth?.all ?? []).flatMap(p => typeof p === "object" && "ctx.auth.scope" in p ? [p["ctx.auth.scope"]] : []);
46
48
  const procedure = kind === "procedures" ? plan.procedures[name] : undefined;
47
49
  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;
48
- const annotations = kind === "views" ? { readOnlyHint: true } : procedure?.mcp;
50
+ const annotations = kind === "views" ? viewAnnotations : procedure ? procedureAnnotations(procedure) : operation.mcp;
49
51
  const wire = kind === "views" ? outputConversions(plan, name, target) : [];
52
+ // Hashed as listed: the kernel adds a View tool's paging properties exactly so.
53
+ 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 });
50
54
  hasWire ||= wire.length > 0;
51
- 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)}`, ...(operation.title ? [`title: ${q(operation.title)}`] : []), `description: ${q(operation.description ?? operation.title ?? name)}`, "inputSchema", ...(output ? [`outputSchema: ${q(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}`] : []),
55
+ 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}`] : []),
52
56
  ...(kind === "views" ? [`path: ${q(`views/${name}`)}`, `page: ${boundaryPage(`run${index}`, wire)}`] : [`run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `async (db, caller, input) => (await import("./procedures/${file}.js")).default(db, caller, input)`}`])];
53
- tools.push({ fields, schema });
57
+ tools.push({ fields: [...fields, `version: ${q(listed.version)}`], schema });
54
58
  };
55
59
  for (const [name, view] of Object.entries(plan.views))
56
60
  if (view.surface !== "internal")
@@ -69,7 +73,7 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
69
73
  for (const verb of ["publish", "unpublish", "archive"]) {
70
74
  const name = `${schema.name}.${verb}`, input = publishingInput;
71
75
  Object.assign(files, emitValidation({ views: {}, procedures: { [name]: { input } } }, refuse));
72
- add(name, "procedures", "admin", { input, requires: { auth: { all: ["ctx.admin"] } } }, mcpToolNameSegment(`${verb}_${schema.name}`));
76
+ add(name, "procedures", "admin", { input, title: toolTitle(`${verb}_${schema.name}`), requires: { auth: { all: ["ctx.admin"] } }, mcp: publishingAnnotations(verb) }, mcpToolNameSegment(`${verb}_${schema.name}`));
73
77
  }
74
78
  if (hasWire)
75
79
  imports.push('import { wireRows } from "@aotter/mantle/contracts";');
@@ -90,7 +94,7 @@ export const tools: readonly Tool<Database, Handlers>[] = [
90
94
  ${tools.map(({ fields, schema }) => ` { ${fields.map(field => field === "inputSchema" ? `inputSchema: ${shared.get(schema) ?? schema}` : field).join(", ")} },`).join("\n")}
91
95
  ];
92
96
  export function mcp<Env = unknown>(options: Options<Env>): (request: Request, env: Env, ctx?: import("@aotter/mantle/contracts").RequestContext) => Promise<Response | undefined> {
93
- return serve<Env, Database, Handlers>(${media ? `async (request, env) => [...tools, ...(options.media && new URL(request.url).pathname.replace(/\\/+$/, "") === "/mcp/admin" ? (await import("./mcp-media.js")).mediaTools<Handlers>(() => options.media!(env)) : [])]` : "tools"}, ${plan.access ? `{...options, fields: ${q(plan.access.userFields)}, accessTargets: () => import("./access-targets.js").then(m => m.targets)}` : '{...options, accessTargets: () => import("./access-targets.js").then(m => m.targets)}'}, ${media ? `[...${q(reads)}, ...(options.media ? ${q(mediaDefinitions().filter(tool => tool.annotations.readOnlyHint).map(tool => ({ name: tool.name, surface: "admin" })))} : [])]` : q(reads)});
97
+ return serve<Env, Database, Handlers>(${media ? `async (request, env) => [...tools, ...(options.media && new URL(request.url).pathname.replace(/\\/+$/, "") === "/mcp/admin" ? (await import("./mcp-media.js")).mediaTools<Handlers>(() => options.media!(env)) : [])]` : "tools"}, ${plan.access ? `{...options, fields: ${q(plan.access.userFields)}, accessTargets: () => import("./access-targets.js").then(m => m.targets)}` : '{...options, accessTargets: () => import("./access-targets.js").then(m => m.targets)}'}, ${media ? `[...${q(reads)}, ...(options.media ? ${q(mediaTools.filter(tool => tool.annotations.readOnlyHint).map(tool => ({ name: tool.name, surface: "admin" })))} : [])]` : q(reads)});
94
98
  }
95
99
  ` };
96
100
  }
@@ -58,9 +58,8 @@ export function emitPaging(plan, lowered, target, refuse, proofs, boundary = fal
58
58
  const auth = authLines(view.requires, path);
59
59
  const spec = [`path: ${q(path)}`, `cap: ${cap}`, `sqlite: ${sqlite}`, `cursor: ${q(kinds)}`, `keys: ${c.nkeys}`, `values: {${user.used ? [...values].sort().concat(user.values).join(", ") : values.size ? ` ${[...values].sort().join(", ")} ` : ""}}`, "read", `caller: ${variants[0]}`, ...(trusted ? [`trusted: ${variants[1]}`] : []), ...(auth.length ? [`auth: (caller: Caller) => {\n${auth.join("\n")}\n }`] : [])];
60
60
  files[`paged/${boundary ? "wire/" : ""}views/${encodeURIComponent(name)}.ts`] = `${user.code ? user.code + "\n" : ""}import { page, type Paged } from "${base}paging";
61
- import { ${auth.length ? "deny, isAdmin, isUser, " : ""}type Caller } from "${base}contracts";
62
- ${databaseImport(target)}
63
- import { read } from "${base}driver/${driver}";
61
+ import { ${auth.length ? "deny, isAdmin, isUser, " : ""}type Caller${sqlite ? "" : ", type Unsafe as Database"} } from "${base}contracts";
62
+ ${sqlite ? databaseImport(target) + "\n" : ""}import { read } from "${base}driver/${driver}";
64
63
  ${values.size ? `import { ${[...values].sort().join(", ")} } from "${base}dialect/${sqlite ? "sqlite" : "postgres"}";\n` : ""}import type { Input, Row } from "${boundary ? "../../../" : "../../"}views/${encodeURIComponent(name)}.js";
65
64
  const spec: Paged<Database> = {
66
65
  ${spec.join(",\n ")},
@@ -5,8 +5,8 @@ import { dialect as postgres } from "../kernel/dialect/postgres.js";
5
5
  import { renderTerm } from "../kernel/store.js";
6
6
  import { publishedParent } from "./translations.js";
7
7
  import { entry, exportName, q } from "../domain/entry.js";
8
- import { publishingInput, publishingTransitions } from "../domain/generated-tools.js";
9
- export { publishingInput, publishingTransitions };
8
+ import { publishingAnnotations, publishingInput, publishingTransitions } from "../domain/generated-tools.js";
9
+ export { publishingAnnotations, publishingInput, publishingTransitions };
10
10
  export function emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse) {
11
11
  const d = isSqlite ? sqlite : postgres;
12
12
  const translations = Object.values(plan.schemas).some(s => s.translates);
@@ -36,7 +36,7 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
36
36
  // the status term is replaced by the transition's own `status IN (...)`.
37
37
  for (const term of rowPolicy(schema, trusted ? "trusted" : "caller", plan.schemas))
38
38
  if (term[1] !== "admin")
39
- conditions.push(renderTerm(d, term, "", recipe => (recipes.push(recipe), d.param(recipes.length))));
39
+ conditions.push(renderTerm(d, term, term[1] === "parent" ? d.ident(s.name) + "." : "", recipe => (recipes.push(recipe), d.param(recipes.length))));
40
40
  if (verb === "publish")
41
41
  for (const field of s.schema.required ?? []) {
42
42
  const column = Object.keys(s.fields).find(c => (s.names?.[c] ?? c) === field);
@@ -32,7 +32,6 @@ export function site(db: Database, caller: Caller) { return createSite({read:(sq
32
32
  `, "site-seed.sql": seed + "\n", "site-schema.sql": `CREATE TABLE site_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
33
33
  CREATE TABLE media_assets (id TEXT PRIMARY KEY, created_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL, owner_id TEXT, alt TEXT, caption TEXT, variants TEXT NOT NULL, metadata TEXT);
34
34
  CREATE INDEX media_assets_by_created_id ON media_assets (created_at DESC, id DESC);
35
- CREATE INDEX media_assets_by_owner_created ON media_assets (owner_id, created_at DESC);
36
35
  CREATE TABLE pending_media_uploads (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL);
37
36
  CREATE INDEX pending_media_uploads_expires_at ON pending_media_uploads (expires_at);
38
37
  ${seed}
@@ -0,0 +1,9 @@
1
+ /** Generate-time tool versions: a stale host copy shows the model an older version than results carry. */
2
+ 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
+ export const versionOf = (value) => fnv(canonical(value));
5
+ /** A listed tool: its version, and its description (each locale's) ending with that version. */
6
+ export function versioned(tool) {
7
+ const v = versionOf(tool), mark = (text) => `${text} (tool version ${v})`;
8
+ return { ...tool, description: typeof tool.description === "string" ? mark(tool.description) : Object.fromEntries(Object.entries(tool.description).map(([locale, text]) => [locale, mark(text)])), version: v };
9
+ }
@@ -44,7 +44,7 @@ export function emitTranslations(plan, target) {
44
44
  declarations.push(`${q(schema.name)}: Pick<Tables[${q(schema.name)}]["row"],${["id", ...columns.filter(c => c !== schema.scope)].map(value => q(value)).join("|")}>${schema.scope && columns.includes(schema.scope) ? ` & {readonly ${q(schema.scope)}?:string|null}` : ""};`);
45
45
  descriptors.push(`${q(schema.name)}:[${entries.join(",")}]`);
46
46
  }
47
- return { "translations.ts": `${user.code ? user.code + "\n" : ""}import {ContractError,type Caller${driver === "postgres" ? ",type Postgres as Database" : ""}} from "@aotter/mantle/contracts";
47
+ return { "translations.ts": `${user.code ? user.code + "\n" : ""}import {ContractError,type Caller${driver === "postgres" ? ",type Unsafe as Database" : ""}} from "@aotter/mantle/contracts";
48
48
  ${driver !== "postgres" ? `import type {Handle as Database} from "@aotter/mantle/driver/${driver}";` : ""}
49
49
  import {read} from "@aotter/mantle/driver/${driver}";
50
50
  import {binds,encode} from "@aotter/mantle/binds";