@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
@@ -18,10 +18,6 @@ export type ApiVersion = typeof API_VERSION;
18
18
  /** Media-shaped `x-mcp-hint` values — the subset marking a field as
19
19
  * holding a media asset URL. */
20
20
  export type MediaMcpHint = "media" | "media-image" | "media-video" | "media-file";
21
- /** `<extension>/<contribution>`: how `uiSchema` names an Admin extension contribution (ADR-lite 1376). Whether it exists is
22
- * checked where the plan meets the extensions, in `createAdminSurface`. */
23
- export declare const UI_EXTENSION_REF: RegExp;
24
- export declare const isUiExtensionRef: (value: unknown) => value is string;
25
21
  export declare function isMediaMcpHint(value: unknown): value is MediaMcpHint;
26
22
  /** Loose JSON Schema shape — we don't constrain it at the type level.
27
23
  * Manifest authoring stays JSON Schema; runtime validators translate
@@ -117,6 +113,8 @@ export declare const MANTLE_BIND_KEYWORD: "x-mantle-bind";
117
113
  * with `resolveLocalizedText`.
118
114
  */
119
115
  export type LocalizedText = string | Readonly<Record<string, string>>;
116
+ /** A non-empty string or a non-empty `{ locale: non-empty string }` map. */
117
+ export declare const isLocalizedText: (v: unknown) => v is LocalizedText;
120
118
  /**
121
119
  * Resolve a `LocalizedText` value to a single displayable string for
122
120
  * `preferred` (typically the viewer's chosen admin language), falling
@@ -305,7 +303,8 @@ export interface ProcedureManifestSpec {
305
303
  * `{ sql }` (write statements, compiled to the plan's IR; ADR-0034). */
306
304
  readonly handler: HandlerBinding;
307
305
  /** MCP tool annotations the author declares because Core cannot infer
308
- * them for a `ref` handler (#972). Emitted verbatim on the tool;
306
+ * them for a `ref` handler (#972). They override the generated defaults
307
+ * (`procedureAnnotations`);
309
308
  * An `x-mcp-hint: idempotency-key` input does not infer an
310
309
  * `idempotentHint` annotation in 0.2. A `readOnlyHint: true` on a `sql`
311
310
  * handler, which always writes, is rejected at validation. */
@@ -434,5 +433,7 @@ export declare const RESERVED_ENTRY_COLUMNS: readonly ["id", "status", "version"
434
433
  * New reserved names need an ADR (ADR-0022).
435
434
  */
436
435
  export declare const EXPECTED_VERSION_PROPERTY: "expectedVersion";
436
+ /** Rules between an input object's fields: only View and Procedure input takes them, and AI tool catalogues advertise them as sentences. */
437
+ export declare const INPUT_RULE_KEYWORDS: readonly ["dependentRequired", "anyOf", "if", "then", "else"];
437
438
  export declare const RESERVED_PROCEDURE_INPUT_NAMES: readonly ["expectedVersion"];
438
439
  export {};
@@ -103,6 +103,8 @@ export declare function storageColumns(s: {
103
103
  }): Map<string, string>;
104
104
  /** The native columns storage declares NOT NULL (`status` only on a publishing Schema, the only one that has it); `author_id` is nullable. */
105
105
  export declare const NATIVE_NOT_NULL: ReadonlySet<string>;
106
+ /** Each native field a manifest names (in an index, say) -> its storage column, which SQL names. */
107
+ export declare const NATIVE_COLUMNS: Readonly<Record<string, string>>;
106
108
  /**
107
109
  * The columns an `operational` Schema's database refuses NULL in beyond the native ones: each field its JSON Schema requires and
108
110
  * does not allow null (a geo field as its two columns). A `publishing` Schema has none: its drafts may be incomplete. This is the one
@@ -1,5 +1,7 @@
1
1
  import type { RuntimePlan } from "../model/RuntimePlan.js";
2
2
  /** JSON with sorted keys, so equal plans serialize to equal text whatever built them. */
3
3
  export declare function canonical(v: unknown): string;
4
+ /** 32-bit FNV-1a (hex): a short deterministic name, not a security boundary. */
5
+ export declare const fnv: (s: string) => string;
4
6
  /** SHA-256 (hex) of the plan without its own fingerprint: it crosses a trust boundary, so it is cryptographic (ADR-0032 decision 10). */
5
7
  export declare function planFingerprint(plan: Omit<RuntimePlan, "fingerprint">): Promise<string>;
@@ -25,7 +25,7 @@ export type Phase = "validate" | "test" | "boot" | "runtime";
25
25
  * Single source of truth: the const array drives `DiagnosticCode`
26
26
  * (type), so adding a code is one edit.
27
27
  */
28
- export declare const DIAGNOSTIC_CODES: readonly ["AUTH_ROLE_REMOVED", "USER_FIELD_VALUES_UNENUMERATED", "USER_FIELD_INVALID", "USER_FIELD_CONFLICT", "USER_FIELD_POSITION", "USER_REF_SCOPE_ONLY", "USER_SCOPE_PUBLIC_ANON_EMPTY", "RESOURCE_EXHAUSTED", "RESOURCE_UNAVAILABLE", "RATE_LIMITED", "OUTCOME_UNKNOWN", "PARTIAL_FAILURE", "PRECONDITION_FAILED", "INVALID_MANIFEST_ENVELOPE", "DUPLICATE_NAME", "VIEW_INPUT_INVALID_SHAPE", "VIEW_INPUT_RESERVED_NAME", "VIEW_CACHE_INVALID", "VIEW_UI_INVALID", "REQUIRED_FIELD_UNKNOWN", "INVALID_PATTERN", "JSON_SCHEMA_UNSUPPORTED", "JSON_SCHEMA_REF_INVALID", "JSON_SCHEMA_LIMIT_EXCEEDED", "AUTH_PREDICATE_NOT_IN_ENUM", "GUARD_PROCEDURE_UNKNOWN", "GUARD_SELF_REFERENCE", "GUARD_PROCEDURE_NOT_REF", "GUARD_CHAIN_NOT_ALLOWED", "SCHEMA_INDEX_INVALID", "SCHEMA_INDEX_FIELD_UNKNOWN", "UNIQUE_INDEX_FIELD_UNKNOWN", "SCHEMA_SEARCH_INVALID", "SCHEMA_SEARCH_FIELD_UNKNOWN", "SCHEMA_TTL_INVALID", "SCHEMA_UI_INVALID", "UI_EXTENSION_INVALID", "UI_EXTENSION_UNKNOWN", "UI_EXTENSION_TARGET", "UI_EXTENSION_OPTIONS", "SCHEMA_NAME_CASE_COLLISION", "SCHEMA_READER_NAME_COLLISION", "FIELD_NAME_CASE_COLLISION", "MANIFEST_ROOT_NOT_FOUND", "MANIFEST_READ_FAILED", "GENERATE_FEATURE_DEPENDENCY_MISSING", "SQL_SYNTAX", "SQL_UNSUPPORTED", "SQL_FUNCTION", "SQL_RELATION", "SQL_COLUMN", "SQL_WRITE", "SQL_SHAPE", "SQL_TYPE", "FIXTURE_SCHEMA_VIOLATION", "HANDLER_NOT_REGISTERED", "HANDLER_NOT_DECLARED", "PLAN_FINGERPRINT_MISMATCH", "SCHEDULE_NOT_WIRED", "STORAGE_CHANGE_BLOCKED", "STORAGE_TABLE_NOT_OWNED", "INVOCATION_DEPTH_EXCEEDED", "TRIGGER_TARGET_PROCEDURE_UNKNOWN", "SCHEDULE_INPUT_INVALID", "SCHEDULE_AUTH_INVALID", "TRIGGER_PATH_COLLISION", "TRIGGER_PATH_INVALID", "MCP_TOOL_NAME_COLLISION", "MCP_TOOL_DESCRIPTION_MISSING", "MCP_TOOL_INPUT_UNION_AMBIGUOUS", "MCP_TOOL_INPUT_UNBOUNDED", "MANTLE_REF_INVALID", "PROCEDURE_TARGET_INVALID", "PROCEDURE_NOT_FOUND", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIFECYCLE_SCHEMA_UNKNOWN", "LIFECYCLE_HOOK_REJECTED", "LIFECYCLE_TARGET_NOT_REF", "SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES", "TRANSLATES_PARENT_UNKNOWN", "TRANSLATES_REQUIRES_LOCALIZED", "TRANSLATES_REQUIRES_CONTENT_FIELD", "TRANSLATES_FIELD_NOT_IN_PARENT", "TRANSLATES_FIELD_NOT_IN_CHILD", "TRANSLATES_PARENT_IS_LOCALIZED", "SCHEMA_TTL_TRANSLATION_UNSUPPORTED", "INPUT_VALIDATION_FAILED", "UNAUTHENTICATED", "AUTH_DENIED", "ENTITLEMENT_REQUIRED", "CONFLICT", "DISPATCHER_NOT_BUILT", "INTERNAL_ERROR", "OUTPUT_VALIDATION_FAILED", "INVALID_LOCALE", "MEDIA_NOT_CONFIGURED", "SITE_NOT_CONFIGURED", "MEDIA_UPLOAD_EXPIRED", "MEDIA_OBJECT_NOT_FOUND", "MEDIA_MIME_REJECTED", "MEDIA_SIZE_EXCEEDED", "MEDIA_SVG_REJECTED", "MEDIA_CHECKSUM_MISMATCH", "MEDIA_PURPOSE_REJECTED", "MEDIA_VARIANTS_INCOMPLETE", "MEDIA_VARIANT_SIZE_EXCEEDED", "MEDIA_VARIANTS_SUSPICIOUS_SIZE", "MEDIA_ASSET_NOT_FOUND"];
28
+ export declare const DIAGNOSTIC_CODES: readonly ["AUTH_ROLE_REMOVED", "USER_FIELD_VALUES_UNENUMERATED", "USER_FIELD_INVALID", "USER_FIELD_CONFLICT", "USER_FIELD_POSITION", "USER_REF_SCOPE_ONLY", "USER_SCOPE_PUBLIC_ANON_EMPTY", "RESOURCE_EXHAUSTED", "RESOURCE_UNAVAILABLE", "RATE_LIMITED", "OUTCOME_UNKNOWN", "PARTIAL_FAILURE", "PRECONDITION_FAILED", "INVALID_MANIFEST_ENVELOPE", "DUPLICATE_NAME", "VIEW_INPUT_INVALID_SHAPE", "VIEW_INPUT_RESERVED_NAME", "VIEW_CACHE_INVALID", "VIEW_UI_INVALID", "REQUIRED_FIELD_UNKNOWN", "INVALID_PATTERN", "JSON_SCHEMA_UNSUPPORTED", "JSON_SCHEMA_REF_INVALID", "JSON_SCHEMA_LIMIT_EXCEEDED", "AUTH_PREDICATE_NOT_IN_ENUM", "GUARD_PROCEDURE_UNKNOWN", "GUARD_SELF_REFERENCE", "GUARD_PROCEDURE_NOT_REF", "GUARD_CHAIN_NOT_ALLOWED", "SCHEMA_INDEX_INVALID", "SCHEMA_INDEX_FIELD_UNKNOWN", "UNIQUE_INDEX_FIELD_UNKNOWN", "SCHEMA_SEARCH_INVALID", "SCHEMA_SEARCH_FIELD_UNKNOWN", "SCHEMA_TTL_INVALID", "SCHEMA_UI_INVALID", "SCHEMA_NAME_CASE_COLLISION", "SCHEMA_READER_NAME_COLLISION", "FIELD_NAME_CASE_COLLISION", "MANIFEST_ROOT_NOT_FOUND", "MANIFEST_READ_FAILED", "GENERATE_FEATURE_DEPENDENCY_MISSING", "SQL_SYNTAX", "SQL_UNSUPPORTED", "SQL_FUNCTION", "SQL_RELATION", "SQL_COLUMN", "SQL_WRITE", "SQL_SHAPE", "SQL_TYPE", "FIXTURE_SCHEMA_VIOLATION", "HANDLER_NOT_REGISTERED", "HANDLER_NOT_DECLARED", "PLAN_FINGERPRINT_MISMATCH", "SCHEDULE_NOT_WIRED", "STORAGE_CHANGE_BLOCKED", "STORAGE_TABLE_NOT_OWNED", "INVOCATION_DEPTH_EXCEEDED", "TRIGGER_TARGET_PROCEDURE_UNKNOWN", "SCHEDULE_INPUT_INVALID", "SCHEDULE_AUTH_INVALID", "TRIGGER_PATH_COLLISION", "TRIGGER_PATH_INVALID", "MCP_TOOL_NAME_COLLISION", "MCP_TOOL_DESCRIPTION_MISSING", "MCP_TOOL_INPUT_UNION_AMBIGUOUS", "MCP_TOOL_INPUT_UNBOUNDED", "MANTLE_REF_INVALID", "PROCEDURE_TARGET_INVALID", "PROCEDURE_NOT_FOUND", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIFECYCLE_SCHEMA_UNKNOWN", "LIFECYCLE_HOOK_REJECTED", "LIFECYCLE_TARGET_NOT_REF", "SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES", "TRANSLATES_PARENT_UNKNOWN", "TRANSLATES_REQUIRES_LOCALIZED", "TRANSLATES_REQUIRES_CONTENT_FIELD", "TRANSLATES_FIELD_NOT_IN_PARENT", "TRANSLATES_FIELD_NOT_IN_CHILD", "TRANSLATES_PARENT_IS_LOCALIZED", "SCHEMA_TTL_TRANSLATION_UNSUPPORTED", "INPUT_VALIDATION_FAILED", "UNAUTHENTICATED", "AUTH_DENIED", "ENTITLEMENT_REQUIRED", "CONFLICT", "DISPATCHER_NOT_BUILT", "INTERNAL_ERROR", "OUTPUT_VALIDATION_FAILED", "INVALID_LOCALE", "MEDIA_NOT_CONFIGURED", "SITE_NOT_CONFIGURED", "MEDIA_UPLOAD_EXPIRED", "MEDIA_OBJECT_NOT_FOUND", "MEDIA_MIME_REJECTED", "MEDIA_SIZE_EXCEEDED", "MEDIA_SVG_REJECTED", "MEDIA_CHECKSUM_MISMATCH", "MEDIA_PURPOSE_REJECTED", "MEDIA_VARIANTS_INCOMPLETE", "MEDIA_VARIANT_SIZE_EXCEEDED", "MEDIA_VARIANTS_SUSPICIOUS_SIZE", "MEDIA_ASSET_NOT_FOUND"];
29
29
  export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
30
30
  export interface SourcePosition {
31
31
  readonly line: number;
@@ -11,11 +11,43 @@ function resolveLocalizedText(value, preferred, canonical2) {
11
11
  const firstKey = Object.keys(value)[0];
12
12
  return firstKey !== void 0 ? value[firstKey] : null;
13
13
  }
14
+ var INPUT_RULE_KEYWORDS = ["dependentRequired", "anyOf", "if", "then", "else"];
15
+
16
+ // src/build/spec/domain/service/RootRefChain.ts
17
+ function lookupLocalRef(root, ref) {
18
+ if (typeof ref !== "string" || !ref.startsWith("#/")) return void 0;
19
+ let value = root;
20
+ try {
21
+ for (const key of decodeURIComponent(ref.slice(2)).split("/").map((key2) => key2.replace(/~1/g, "/").replace(/~0/g, "~"))) value = value && typeof value === "object" && Object.hasOwn(value, key) ? value[key] : void 0;
22
+ } catch {
23
+ return void 0;
24
+ }
25
+ return value && typeof value === "object" ? value : void 0;
26
+ }
27
+ function rootRefChain(input) {
28
+ const chain = [];
29
+ for (let schema = input; schema !== void 0; schema = lookupLocalRef(input, schema.$ref)) {
30
+ if (chain.includes(schema)) return void 0;
31
+ chain.push(schema);
32
+ if (schema.$ref === void 0) return chain;
33
+ }
34
+ return void 0;
35
+ }
14
36
 
15
37
  // src/build/spec/domain/service/McpToolNaming.ts
16
38
  function mcpToolNameSegment(name) {
17
39
  return name.toLowerCase().replace(/-/g, "_");
18
40
  }
41
+ function toolTitle(name) {
42
+ const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_.\s]+/g, " ").trim().toLowerCase();
43
+ return words.charAt(0).toUpperCase() + words.slice(1);
44
+ }
45
+ var viewAnnotations = { readOnlyHint: true, destructiveHint: false };
46
+ function procedureAnnotations(p) {
47
+ const readOnlyHint = p.mcp?.readOnlyHint ?? false;
48
+ return { readOnlyHint, destructiveHint: !readOnlyHint && ("ref" in p.handler || overwrites(p.handler.sql.stmts)), ...p.mcp };
49
+ }
50
+ var overwrites = (node) => !!node && typeof node === "object" && ("DeleteStmt" in node || "UpdateStmt" in node || node.onConflictClause?.action === "ONCONFLICT_UPDATE" || Object.values(node).some(overwrites));
19
51
  function mcpTools(plan, surface, locale = "en") {
20
52
  const text = (t) => resolveLocalizedText(t, locale, "en") ?? void 0;
21
53
  const tools = /* @__PURE__ */ new Map();
@@ -32,33 +64,61 @@ function mcpTools(plan, surface, locale = "en") {
32
64
  name: mcpToolNameSegment(t.procedure),
33
65
  kind: "procedure",
34
66
  source: t.procedure,
35
- ...title ? { title } : {},
67
+ title: title ?? toolTitle(t.procedure),
36
68
  description: text(p.description) ?? title ?? t.procedure,
37
69
  inputSchema: p.input,
38
70
  // structuredContent must be an object
39
71
  ...p.output.type === "object" ? { outputSchema: p.output } : {},
40
- ...p.mcp ? { annotations: p.mcp } : {},
72
+ annotations: procedureAnnotations(p),
41
73
  requires: p.requires
42
74
  });
43
75
  }
44
76
  for (const [name, v] of Object.entries(plan.views)) {
45
77
  if (v.surface !== surface) continue;
46
- const props = v.input?.properties ?? {};
78
+ const chain = (v.input ? rootRefChain(v.input) ?? [v.input] : []).reverse();
79
+ const props = Object.assign({}, ...chain.map((s) => s.properties ?? {}));
80
+ const required = [...new Set(chain.flatMap((s) => s.required ?? []))];
47
81
  if ("limit" in props || "cursor" in props) throw new TypeError(`View '${name}' declares an input named limit or cursor; the MCP tool reserves both for paging.`);
82
+ const rules = chain.map((s) => Object.fromEntries(INPUT_RULE_KEYWORDS.flatMap((k) => s[k] === void 0 ? [] : [[k, s[k]]]))).filter((r) => Object.keys(r).length);
83
+ const defs = v.input ? referencedDefs(v.input, props) : {};
48
84
  const title = text(v.title);
49
85
  add({
50
86
  name: mcpToolNameSegment(name),
51
87
  kind: "view",
52
88
  source: name,
53
- ...title ? { title } : {},
89
+ title: title ?? toolTitle(name),
54
90
  description: text(v.description) ?? title ?? name,
55
- inputSchema: { type: "object", properties: { ...props, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } }, ...v.input?.required ? { required: v.input.required } : {} },
56
- annotations: { readOnlyHint: true },
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: { ...props, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } },
94
+ ...required.length ? { required } : {},
95
+ ...rules.length > 1 ? { allOf: rules } : rules[0],
96
+ ...Object.keys(defs).length ? { $defs: defs } : {}
97
+ },
98
+ annotations: viewAnnotations,
57
99
  requires: v.requires
58
100
  });
59
101
  }
60
102
  return [...tools.values()];
61
103
  }
104
+ function referencedDefs(root, node, found = {}) {
105
+ if (!node || typeof node !== "object") return found;
106
+ const ref = node.$ref, [head, segment] = typeof ref === "string" && ref.startsWith("#/") ? decoded(ref.slice(2)).split("/") : [];
107
+ const key = head === "$defs" ? segment?.replace(/~1/g, "/").replace(/~0/g, "~") : void 0;
108
+ if (key !== void 0 && !Object.hasOwn(found, key) && root.$defs && Object.hasOwn(root.$defs, key)) {
109
+ found[key] = root.$defs[key];
110
+ referencedDefs(root, found[key], found);
111
+ }
112
+ for (const child of Object.values(node)) referencedDefs(root, child, found);
113
+ return found;
114
+ }
115
+ var decoded = (pointer) => {
116
+ try {
117
+ return decodeURIComponent(pointer);
118
+ } catch {
119
+ return "";
120
+ }
121
+ };
62
122
 
63
123
  // src/build/spec/domain/service/PlanFingerprint.ts
64
124
  function canonical(v) {
@@ -187,6 +247,7 @@ var adminRoutes = [
187
247
  "GET /bootstrap",
188
248
  "GET /views/:name/export",
189
249
  "GET /views/:name",
250
+ "GET /view-batch",
190
251
  "GET /developer/views/:name",
191
252
  "POST /operations/:name",
192
253
  "GET /developer-console",
@@ -310,9 +371,9 @@ function targetDefinitions(plan) {
310
371
  generated.push(d);
311
372
  }
312
373
  for (const route of adminRoutes) {
313
- const deps = route.includes("/views/:") ? declarations.filter((d2) => d2.kind === "view" && (route.includes("/developer/") || plan.views[d2.id.slice(5)]?.surface === "admin")) : route.includes("/operations/:") ? declarations.filter((d2) => adminOperations.has(d2.id)) : route === "POST /webmcp/:tool" ? declarations.filter((d2) => mcpTools(plan, "admin").some((t) => `${t.kind}:${t.source}` === d2.id)).concat(generated) : [];
374
+ const deps = route.includes("/views/:") || route === "GET /view-batch" ? declarations.filter((d2) => d2.kind === "view" && (route.includes("/developer/") || plan.views[d2.id.slice(5)]?.surface === "admin")) : route.includes("/operations/:") ? declarations.filter((d2) => adminOperations.has(d2.id)) : route === "POST /webmcp/:tool" ? declarations.filter((d2) => mcpTools(plan, "admin").some((t) => `${t.kind}:${t.source}` === d2.id)).concat(generated) : [];
314
375
  const generic = route.includes("/entries") || route.includes("/statistics") || route === "GET /bootstrap";
315
- addRoute(`admin:${route}`, deps, deps.length ? { ...objectInput, anyOf: deps.map((d2) => ({ type: "object", properties: { [route.includes("/webmcp/") ? "tool" : "name"]: { type: "string", const: route.includes("/webmcp/") ? d2.name : d2.id.slice(d2.kind === "view" ? 5 : 10) } } })) } : objectInput);
376
+ addRoute(`admin:${route}`, deps, deps.length && route !== "GET /view-batch" ? { ...objectInput, anyOf: deps.map((d2) => ({ type: "object", properties: { [route.includes("/webmcp/") ? "tool" : "name"]: { type: "string", const: route.includes("/webmcp/") ? d2.name : d2.id.slice(d2.kind === "view" ? 5 : 10) } } })) } : objectInput);
316
377
  const d = out.at(-1);
317
378
  if (generic) {
318
379
  const operation = route === "POST /entries" ? "create" : route === "DELETE /entries/:id" ? "delete" : route.startsWith("GET ") ? void 0 : route.endsWith("/publish") ? "publish" : "update";
@@ -3,7 +3,7 @@ import {
3
3
  hashDefinition,
4
4
  targetDefinitions,
5
5
  targetHash
6
- } from "./chunk-ZIQVLA7X.js";
6
+ } from "./chunk-ZVXBGP7Y.js";
7
7
 
8
8
  // src/domain/describe/index.ts
9
9
  function requirements(d, surface) {
@@ -3,7 +3,7 @@ import {
3
3
  hash,
4
4
  hashDefinition,
5
5
  targetHash
6
- } from "./chunk-ZIQVLA7X.js";
6
+ } from "./chunk-ZVXBGP7Y.js";
7
7
  export {
8
8
  canonical,
9
9
  hash,
@@ -36,5 +36,5 @@ export declare function inspectSql(value: unknown): {
36
36
  }[];
37
37
  };
38
38
  /** Built-in Admin API templates, relative to the configured Admin API base. */
39
- export declare const adminRoutes: readonly ["GET /me", "GET /site", "GET /collections", "GET /views-manifest", "GET /operations", "GET /webmcp", "POST /webmcp/:tool", "GET /bootstrap", "GET /views/:name/export", "GET /views/:name", "GET /developer/views/:name", "POST /operations/:name", "GET /developer-console", "GET /collections/:name/statistics", "GET /entries", "GET /entries/export", "GET /entries/:id", "POST /entries", "PATCH /entries/:id", "POST /entries/:id/publish", "POST /entries/:id/unpublish", "POST /entries/:id/archive", "DELETE /entries/:id", "GET /site-settings", "PATCH /site-settings", "POST /media/uploads", "POST /media/uploads/:groupId/commit", "GET /media", "GET /media/:id", "PATCH /media/:id", "DELETE /media/:id", "GET /admin", "GET /members", "PATCH /admin/:id/role", "POST /admin/invitations", "DELETE /admin/invitations/:id"];
39
+ export declare const adminRoutes: readonly ["GET /me", "GET /site", "GET /collections", "GET /views-manifest", "GET /operations", "GET /webmcp", "POST /webmcp/:tool", "GET /bootstrap", "GET /views/:name/export", "GET /views/:name", "GET /view-batch", "GET /developer/views/:name", "POST /operations/:name", "GET /developer-console", "GET /collections/:name/statistics", "GET /entries", "GET /entries/export", "GET /entries/:id", "POST /entries", "PATCH /entries/:id", "POST /entries/:id/publish", "POST /entries/:id/unpublish", "POST /entries/:id/archive", "DELETE /entries/:id", "GET /site-settings", "PATCH /site-settings", "POST /media/uploads", "POST /media/uploads/:groupId/commit", "GET /media", "GET /media/:id", "PATCH /media/:id", "DELETE /media/:id", "GET /admin", "GET /members", "PATCH /admin/:id/role", "POST /admin/invitations", "DELETE /admin/invitations/:id"];
40
40
  export declare function targetDefinitions(plan: RuntimePlan): TargetDefinition[];
@@ -1,4 +1,6 @@
1
1
  export const publishingTransitions = [["publish", ["draft"], "published"], ["unpublish", ["published", "archived"], "draft"], ["archive", ["draft", "published"], "archived"]];
2
+ /** Only archive withdraws an entry from both drafts and the public. */
3
+ export const publishingAnnotations = (verb) => ({ readOnlyHint: false, destructiveHint: verb === "archive" });
2
4
  export const publishingInput = { type: "object", properties: { id: { type: "string", minLength: 1 }, expectedVersion: { type: "integer", minimum: 1 } }, required: ["id", "expectedVersion"], additionalProperties: false };
3
5
  export function mediaDefinitions() {
4
6
  const define = (name, description, inputSchema, action, readOnlyHint = false) => ({ name, description, inputSchema, action, annotations: { readOnlyHint, destructiveHint: action === "delete", openWorldHint: true } });
@@ -1,8 +1,15 @@
1
1
  /** Build-time proofs for the compiler's keyset keys. No AST reaches a generated operation. */
2
- import { NATIVE_NOT_NULL, notNullColumns } from "../build/compiler.js";
2
+ import { NATIVE_COLUMNS, NATIVE_NOT_NULL, notNullColumns } from "../build/compiler.js";
3
3
  import { declaredType } from "./columns.js";
4
+ /** Storage column -> the native field a manifest index names. */
5
+ const NATIVE_FIELDS = new Map(Object.entries(NATIVE_COLUMNS).map(([field, column]) => [column, field]));
4
6
  const fieldName = (n) => n.ColumnRef?.fields?.at(-1)?.String?.sval;
5
7
  const scalar = (n) => !!(n.ParamRef || n.A_Const || n.TypeCast && scalar(n.TypeCast.arg));
8
+ // A user-field term lowers to the dialect's anyOf, `(col) IN (SELECT value FROM json_each((?)))` or `(col) = ANY(CAST(...(?)...))`.
9
+ const anyOf = (raw) => raw?.length === 8 && raw[1]?.ColumnRef && raw[5]?.ParamRef && /^ (IN \(SELECT value FROM json_each\(|= ANY\()/.test(raw[3]);
10
+ // A scope prints `/*s:field*/ kept /*|*/ kept OR 1 = 1 /*;*/`, so `{ all: true }` drops it; any other anyOf is a restrict, which it never lifts.
11
+ const scopeTerm = (raw) => typeof raw?.[0] === "string" && raw[0].startsWith("/*s:");
12
+ const restricts = (n) => !!n && typeof n === "object" && (anyOf(n.Raw?.parts) || !scopeTerm(n.Raw?.parts) && Object.values(n).some(restricts));
6
13
  const aggregate = (n) => !!n && !n.SubLink && !n.SelectStmt && (n.FuncCall && !n.FuncCall.over && ["count", "sum", "min", "max", "avg"].includes(n.FuncCall.funcname?.at(-1)?.String?.sval) || Object.values(n).some(v => v && typeof v === "object" && (Array.isArray(v) ? v.some(aggregate) : aggregate(v))));
7
14
  /** Only complete grouped tuples, base ids and non-fanning singleton joins are proved. */
8
15
  export function provePaging(plan, ast) {
@@ -59,7 +66,7 @@ export function provePaging(plan, ast) {
59
66
  const type = declaredType(schema.fields, name) ?? (name === "version" ? "integer" : "text");
60
67
  fields.set(name, { schema, name, type, notNull: NATIVE_NOT_NULL.has(name) || notNull.has(name), source });
61
68
  }
62
- fact = { fields, names: new Set(fields.keys()), single: false, unique: true, fixed: new Set() };
69
+ fact = { fields, names: new Set(fields.keys()), single: false, unique: true, fixed: new Set(), sets: new Set() };
63
70
  }
64
71
  else if (r?.mantle === "cte")
65
72
  fact = ctes.get(r.relname);
@@ -72,7 +79,7 @@ export function provePaging(plan, ast) {
72
79
  if (!sources.has(f.source))
73
80
  sources.set(f.source, {});
74
81
  return [name, { ...f, source: sources.get(f.source) }];
75
- })), fixed: new Set(fact.fixed) };
82
+ })), fixed: new Set(fact.fixed), sets: new Set(fact.sets) };
76
83
  aliases.set(r?.alias?.aliasname ?? r?.relname ?? sub.alias.aliasname, fact);
77
84
  }
78
85
  return fact;
@@ -86,7 +93,7 @@ export function provePaging(plan, ast) {
86
93
  if (!base || !r.single)
87
94
  base = r;
88
95
  }
89
- base ??= { fields: new Map(), names: new Set(), single: true, unique: false, fixed: new Set() };
96
+ base ??= { fields: new Map(), names: new Set(), single: true, unique: false, fixed: new Set(), sets: new Set() };
90
97
  const targets = new Map((s.targetList ?? []).map((t) => [t.ResTarget.name ?? fieldName(t.ResTarget.val), t.ResTarget.val]));
91
98
  const resolve = (n, output = true) => {
92
99
  const fields = n?.ColumnRef?.fields;
@@ -110,16 +117,15 @@ export function provePaging(plan, ast) {
110
117
  const fixed = (n) => {
111
118
  if (n?.BoolExpr?.boolop === "AND_EXPR")
112
119
  n.BoolExpr.args.forEach(fixed);
113
- // `col IN auth.user(field)` (a scope or restrict) is fixed like an equality, fanning out over the caller's values:
114
- // ADR-0046 §Paging, cost elements × limit. A scope prints `/*s:field*/ kept /*|*/ kept OR 1 = 1 /*;*/`; `kept` is
115
- // the dialect's anyOf lowering, `(col) IN (SELECT value FROM json_each((?)))` or `(col) = ANY(CAST(...(?)...))`.
120
+ // `col IN auth.user(field)` (a scope or restrict) never fixes a prefix (#1644): the plan is prepared for a set, so
121
+ // native ORDER BY after it reads and sorts all the caller's rows. It is only remembered for the refusal's guidance.
116
122
  const raw = n?.Raw?.parts;
117
- if (raw && typeof raw[0] === "string" && raw[0].startsWith("/*s:"))
123
+ if (scopeTerm(raw))
118
124
  return fixed(raw[1]);
119
- if (raw?.length === 8 && raw[1]?.ColumnRef && raw[5]?.ParamRef && /^ (IN \(SELECT value FROM json_each\(|= ANY\()/.test(raw[3])) {
125
+ if (anyOf(raw)) {
120
126
  const col = resolve(raw[1], false);
121
127
  if (col && col.source === base.fields.get("id")?.source)
122
- base.fixed.add(col.name);
128
+ base.sets.add(col.name);
123
129
  return;
124
130
  }
125
131
  const e = n?.A_Expr;
@@ -162,19 +168,39 @@ export function provePaging(plan, ast) {
162
168
  const schema = order[0].schema;
163
169
  if (order.some(f => f.source !== base.fields.get("id")?.source))
164
170
  return;
165
- const indexed = [...(schema.indexes ?? []), ...(schema.unique ?? [])].some(index => {
171
+ const serving = (prefix) => [...(schema.indexes ?? []), ...(schema.unique ?? [])].find(index => {
166
172
  let i = 0;
167
- while (i < index.length && base.fixed.has(index[i]) && index[i] !== order[0].name)
173
+ while (i < index.length && prefix.has(index[i]) && index[i] !== order[0].name)
168
174
  i++;
169
175
  return order.every((f, n) => index[i + n] === f.name);
170
176
  });
171
- if (!indexed)
172
- return fail(`Missing index for ORDER BY (${order.map(f => f.name).join(", ")}): declare a Schema index with these ordered columns after any fixed equality prefix.`);
177
+ if (!serving(base.fixed)) {
178
+ // SQL names a column (`created_at`), a manifest index the field (`createdAt`).
179
+ const index = serving(new Set([...base.fixed, ...base.sets])), named = (c) => schema.names?.[c] ?? c, field = (c) => schema.names?.[c] ?? NATIVE_FIELDS.get(c) ?? c;
180
+ const columns = order.map(f => named(f.name)).join(", "), fields = order.map(f => field(f.name)).join(", ");
181
+ if (!index)
182
+ return fail(`Missing index for ORDER BY (${columns}): declare a Schema index with [${fields}] in this order after any fixed equality prefix.`);
183
+ const prefix = index.slice(0, index.indexOf(order[0].name)), set = prefix.filter(c => !base.fixed.has(c));
184
+ // The suggested order is the index from its first set-bound column: an equality-fixed column between stays in it.
185
+ const lead = prefix.slice(prefix.indexOf(set[0])), quoted = (cs) => cs.map(c => `'${field(c)}'`).join(", ");
186
+ // The scope-first order is offered only when SQL can name every added column and none can be null. The owner
187
+ // scope column is NOT NULL in storage, but a View cannot address it.
188
+ const notNull = notNullColumns(schema), owner = lead.filter(c => c === schema.scope), nullable = lead.filter(c => !NATIVE_NOT_NULL.has(c) && !notNull.has(c) && c !== schema.scope);
189
+ const dir = (f) => sorts[keys.indexOf(f)].SortBy.sortby_dir === "SORTBY_DESC" ? " DESC" : "";
190
+ const sorted = [...lead.map(c => named(c) + dir(order[0])), ...order.map(f => named(f.name) + dir(f))].join(", ");
191
+ const desc = order.some(f => dir(f)) ? ", read backwards; on PostgreSQL confirm with EXPLAIN that no Sort remains" : "";
192
+ // `{ all: true }` drops only scope terms: offer it an index on the View's equality-fixed columns, unless a restrict remains.
193
+ const eq = [...base.fixed].filter(c => c !== "id" && !order.some(f => f.name === c));
194
+ const ways = [...nullable.length || owner.length ? [] : [`ORDER BY ${sorted} (same index${desc})`], ...restricts(ast) ? [] : [`for { all: true } callers only, index [${[...eq.map(field), fields].join(", ")}, id]`]];
195
+ const why = [...owner.length ? [`${quoted(owner)} is the owner column, which SQL cannot name`] : [], ...nullable.length ? [`${quoted(nullable)} can be null`] : []];
196
+ const unavailable = why.length ? ` Scope-first ORDER BY unavailable: ${why.join("; ")}${ways.length ? "." : ""}` : "";
197
+ return fail(`ORDER BY (${columns}) needs index [${index.map(field).join(", ")}] past ${quoted(set)}, which a user-field scope or restrict binds to a set of values: every page would sort all of the caller's rows.${unavailable}${ways.length ? ` Bounded: ${ways.join("; ")}` : ""} (handbook: reference/schema.md#index-prefixes-for-paged-views).`);
198
+ }
173
199
  }
174
200
  }
175
201
  }
176
202
  const single = base.single || !group.length && (s.targetList ?? []).some((t) => aggregate(t.ResTarget.val)) || s.limitCount?.A_Const?.ival?.ival === 1 || base.unique && base.fixed.has("id");
177
- return { relation: { fields, names: new Set(targets.keys()), single, unique: !group.length && base.unique && !!same(fields.get("id"), base.fields.get("id")), fixed: base.fixed }, keys: keys };
203
+ return { relation: { fields, names: new Set(targets.keys()), single, unique: !group.length && base.unique && !!same(fields.get("id"), base.fields.get("id")), fixed: base.fixed, sets: base.sets }, keys: keys };
178
204
  };
179
205
  const result = select(ast.SelectStmt);
180
206
  return result ? { types: result.keys.map(f => f.type), single: result.relation.single } : reason ? { types: [], single: false, reason } : undefined;
@@ -1,7 +1,9 @@
1
- import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
2
- import { relative, resolve, sep } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
3
5
  import { build } from "esbuild";
4
- import { INPUTS_FILE, authTableSql, extraRoles, staleInputs } from "./inputs.js";
6
+ import { INPUTS_FILE, authTableSql, extraRoles, generatedTarget, staleInputs } from "./inputs.js";
5
7
  /** Only static imports run at startup; dynamic imports remain separate uploaded modules. */
6
8
  export function startupGraph(metafile, entry) {
7
9
  const visited = new Set();
@@ -25,6 +27,8 @@ const refused = [
25
27
  [/(?:^|\/)node_modules\/@modelcontextprotocol\//, "Load MCP through a request-time import(...)."],
26
28
  // the compiler: the package's own generator layers (installed, or workspace dist/source) and its parser, YAML and SQL dependencies
27
29
  [/(?:^|\/)node_modules\/(?:@aotter\/mantle\/dist\/(?:build|infrastructure)\/|yaml\/|pgsql-deparser\/|libpg-query\/)|(?:^|\/)mantle\/(?:dist|src)\/(?:build|infrastructure)\//, "Generator code must not run in a Worker; import runtime entry points such as @aotter/mantle/rest."],
30
+ // Admin's UI is built into <assets>/admin by the app's admin.tsx; the Worker serves it, never runs it.
31
+ [/(?:^|\/)node_modules\/@aotter\/mantle-admin\/dist\/admin-app\/|(?:^|\/)mantle-admin\/(?:dist\/admin-app|admin\/src)\//, "Admin's UI (@aotter/mantle-admin/app, /hooks) runs in the browser: import it only from src/admin.tsx, which `mantle bundle` builds into the assets directory."],
28
32
  ];
29
33
  export function guardStartup(metafile, entry) {
30
34
  for (const name of startupGraph(metafile, entry)) {
@@ -37,14 +41,118 @@ export function guardStartup(metafile, entry) {
37
41
  }
38
42
  }
39
43
  }
44
+ /** The physical spelling of a path, including a nonexistent tail below its nearest existing ancestor. */
45
+ const physical = (input) => {
46
+ let found = resolve(input);
47
+ const tail = [];
48
+ while (!existsSync(found)) {
49
+ const parent = dirname(found);
50
+ if (parent === found)
51
+ return found;
52
+ tail.unshift(basename(found));
53
+ found = parent;
54
+ }
55
+ return resolve(realpathSync(found), ...tail);
56
+ };
57
+ /** Whether `inner` is `outer` or inside it. */
58
+ const within = (outer, inner) => {
59
+ const path = relative(physical(outer), physical(inner));
60
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
61
+ };
62
+ /** A directory a build empties must not be the working directory or hold the source entry. */
63
+ function assertCleanable(label, dir, entry) {
64
+ if (physical(dir) === physical(process.cwd()))
65
+ throw new Error(`${label} must not be the working directory: it is cleaned before building.`);
66
+ if (within(dir, entry))
67
+ throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
68
+ }
69
+ /**
70
+ * `mantle bundle <entry> <outdir> [--assets <dir>]`: the Worker into `<outdir>/worker`, and Admin into `<assets>/admin`
71
+ * (default `<outdir>/client`) when the Worker serves it (`@aotter/mantle-admin/server`; `/mcp` and the other subpaths alone do not).
72
+ * A server whose generated output records a `bun-*` target runs as TypeScript, so only its Admin is built.
73
+ */
74
+ export async function bundleApp(entry, outdir, assets = join(outdir, "client")) {
75
+ const source = realpathSync(entry);
76
+ // what is cleaned lies under these two, so neither may be the working directory or hold the source
77
+ assertCleanable("Bundle outdir", outdir, source);
78
+ assertCleanable("The assets directory", assets, source);
79
+ // The app's own modules only: enough to find its generated output and whether it imports the Admin server.
80
+ const { metafile: own } = await build({ entryPoints: [entry], bundle: true, write: false, metafile: true, packages: "external", format: "esm", platform: "neutral", logLevel: "silent" });
81
+ if (generatedDirs(own).some(dir => generatedTarget(join(dir, "generated.ts"))?.startsWith("bun-"))) {
82
+ // Packages are external here, so only the app's own imports of the Admin server are seen; one reached through another package is not.
83
+ if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
84
+ await bundleAdmin(source, outdir, join(assets, "admin"));
85
+ else
86
+ console.warn("note: no Admin built: the entry's own modules do not import @aotter/mantle-admin/server (an import through another package is not seen).");
87
+ return;
88
+ }
89
+ const worker = join(outdir, "worker");
90
+ // Wrangler uploads every module under the Worker directory and every file under the assets directory.
91
+ if (within(worker, assets) || within(assets, worker))
92
+ throw new Error(`The assets directory ${assets} must not overlap the Worker directory ${worker}.`);
93
+ const metafile = await bundle(entry, worker);
94
+ if (Object.keys(metafile.inputs).some(input => /(?:^|\/)mantle-admin\/(?:dist|src)\/server\/index\.[jt]s$/.test(input.replaceAll("\\", "/")))) {
95
+ await bundleAdmin(source, outdir, join(assets, "admin"));
96
+ }
97
+ }
98
+ /**
99
+ * Admin from `<dirname(entry)>/admin.tsx`, or the built-in entry; an optional `admin.mcp.tsx` selects the host-safe graph.
100
+ * Uses `@aotter/mantle-admin/build` as the app installed
101
+ * it. The built-in entry is skipped when the installed Admin and its direct dependencies are unchanged since the last
102
+ * build; an application entry always builds, because what it imports is only known by building it.
103
+ */
104
+ async function bundleAdmin(entry, outdir, out) {
105
+ const source = join(dirname(entry), "admin.tsx"), custom = existsSync(source);
106
+ const mcpSource = join(dirname(entry), "admin.mcp.tsx"), customMcp = existsSync(mcpSource);
107
+ assertCleanable("The Admin output directory", out, entry);
108
+ const require = createRequire(entry);
109
+ let builder;
110
+ try {
111
+ builder = require.resolve("@aotter/mantle-admin/build");
112
+ }
113
+ catch {
114
+ throw new Error(`The Worker serves Admin, but @aotter/mantle-admin/build does not resolve from ${relative(process.cwd(), entry)}. Install @aotter/mantle-admin at the version of @aotter/mantle-admin/server.`);
115
+ }
116
+ const stamp = join(outdir, ".admin-inputs");
117
+ // the installed path too: a patched or linked Admin keeps its version but not its path
118
+ const manifest = realpathSync(lookup(require, "@aotter/mantle-admin") ?? require.resolve("@aotter/mantle-admin/package.json"));
119
+ const key = custom || customMcp ? undefined : JSON.stringify({ out: resolve(out), path: dirname(manifest), packages: installed(manifest) });
120
+ if (key && existsSync(join(out, "index.html")) && existsSync(join(out, "mcp-app.html")) && existsSync(stamp) && readFileSync(stamp, "utf8") === key) {
121
+ console.log(`Admin inputs unchanged; kept ${relative(process.cwd(), out)}.`);
122
+ return;
123
+ }
124
+ rmSync(stamp, { force: true });
125
+ const { buildAdmin } = await import(pathToFileURL(builder).href);
126
+ await buildAdmin({ entry: custom ? source : undefined, ...(customMcp ? { mcpEntry: mcpSource } : {}), outDir: out });
127
+ if (key)
128
+ writeFileSync(stamp, key);
129
+ }
130
+ /** The package.json a bare import of `name` finds, read from disk (`exports` may hide it, and require caches realpaths). */
131
+ const lookup = (require, name) => require.resolve.paths(name)?.map(dir => join(dir, name, "package.json")).find(file => existsSync(file));
132
+ /** The package's version and the installed version of each of its dependencies and peers (React comes from the application). */
133
+ function installed(manifest) {
134
+ const { name, version, dependencies = {}, peerDependencies = {} } = JSON.parse(readFileSync(manifest, "utf8"));
135
+ const require = createRequire(manifest);
136
+ const versions = { [name]: version };
137
+ for (const dependency of Object.keys({ ...peerDependencies, ...dependencies }).sort()) {
138
+ const found = lookup(require, dependency);
139
+ versions[dependency] = found ? JSON.parse(readFileSync(found, "utf8")).version : null;
140
+ }
141
+ return versions;
142
+ }
143
+ /** The generated directories among a build's inputs, refusing stale ones. */
144
+ function generatedDirs(metafile) {
145
+ const dirs = [...new Set(Object.keys(metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))))];
146
+ for (const dir of dirs) {
147
+ const input = resolve(dir, "generated.ts"), stale = staleInputs(input);
148
+ if (stale.length)
149
+ throw new Error(`Generated output in ${relative(process.cwd(), resolve(input, ".."))} is stale (${stale.join("; ")}). Run your generate script (mantle generate ...) and rebuild.`);
150
+ }
151
+ return dirs;
152
+ }
40
153
  export async function bundle(entry, outdir) {
41
154
  entry = realpathSync(entry);
42
- if (resolve(outdir) === process.cwd())
43
- throw new Error("Bundle outdir must not be the working directory: it is cleaned before building.");
44
- const fromOutput = relative(resolve(outdir), resolve(entry));
45
- if (fromOutput === "" || (!fromOutput.startsWith(`..${sep}`) && fromOutput !== "..")) {
46
- throw new Error("Bundle outdir must not contain the source entry: it is cleaned before building.");
47
- }
155
+ assertCleanable("Bundle outdir", outdir, entry);
48
156
  rmSync(outdir, { recursive: true, force: true });
49
157
  const result = await build({
50
158
  entryPoints: [entry], outdir, bundle: true, splitting: true, format: "esm", platform: "neutral",
@@ -64,12 +172,7 @@ export async function bundle(entry, outdir) {
64
172
  throw new Error("@aotter/mantle/testing is only for tests and cannot be bundled for production.");
65
173
  // A build is what `wrangler dev` and deploys run: generated code must match the manifests it came from.
66
174
  // Any bundled module from a generated directory finds its fingerprint (type-only imports leave generated.ts out).
67
- const generatedDirs = new Set(Object.keys(result.metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))));
68
- for (const dir of generatedDirs) {
69
- const input = resolve(dir, "generated.ts"), stale = staleInputs(input);
70
- if (stale.length)
71
- throw new Error(`Generated output in ${relative(process.cwd(), resolve(input, ".."))} is stale (${stale.join("; ")}). Run your generate script (mantle generate ...) and rebuild.`);
72
- }
175
+ generatedDirs(result.metafile);
73
176
  const appSources = Object.keys(result.metafile.inputs).filter(input => !/node_modules|(?:^|\/)generated\/|^\.\.\//.test(input) && /\.[cm]?[jt]s$/.test(input)).map(input => [input, readFileSync(input, "utf8")]);
74
177
  for (const [input, source] of appSources) {
75
178
  const tables = authTableSql(source);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
- import { resolve, relative } from "node:path";
4
+ import { join, resolve, relative } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { configureWrangler } from "./wrangler.js";
@@ -19,10 +19,10 @@ async function load(path) {
19
19
  }
20
20
  }
21
21
  const targets = ["bun-sqlite", "bun-postgres", "cloudflare-d1", "cloudflare-postgres"];
22
- const usage = "Usage: mantle generate <target> <output-directory> <manifest>... [--check] [--admin] [--site <module>] [--auth <module>]... (one per Better Auth instance) [--admin-extensions <module>] [--wrangler <path>] [--entry <worker entry>] [--outdir <bundle directory>]\n mantle bundle <entry> <outdir>\n mantle describe <target> <manifest>... [--diff <old.json>]";
22
+ const usage = "Usage: mantle generate <target> <output-directory> <manifest>... [--check] [--admin] [--site <module>] [--auth <module>]... (one per Better Auth instance) [--wrangler <path>] [--entry <worker entry>] [--outdir <bundle directory>] [--assets <static assets directory>]\n mantle bundle <entry> <outdir> [--assets <static assets directory>]\n mantle describe <target> <manifest>... [--diff <old.json>]";
23
23
  try {
24
24
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
25
- diff: { type: "string" }, check: { type: "boolean" }, admin: { type: "boolean" }, site: { type: "string" }, "admin-extensions": { type: "string" }, auth: { type: "string", multiple: true }, wrangler: { type: "string" }, entry: { type: "string", default: "src/worker.ts" }, outdir: { type: "string", default: "dist" },
25
+ diff: { type: "string" }, check: { type: "boolean" }, admin: { type: "boolean" }, site: { type: "string" }, auth: { type: "string", multiple: true }, wrangler: { type: "string" }, entry: { type: "string", default: "src/worker.ts" }, outdir: { type: "string", default: "dist" }, assets: { type: "string" },
26
26
  } });
27
27
  const command = positionals[0] === "bundle" || positionals[0] === "generate" || positionals[0] === "describe" ? positionals.shift() : "generate";
28
28
  if (command === "describe") {
@@ -45,9 +45,9 @@ try {
45
45
  }
46
46
  else if (command === "bundle") {
47
47
  const [entry, outdir] = positionals;
48
- if (!entry || !outdir || positionals.length !== 2 || values.admin || values.site || values.auth || values["admin-extensions"] || values.wrangler || values.check)
48
+ if (!entry || !outdir || positionals.length !== 2 || values.admin || values.site || values.auth || values.wrangler || values.check)
49
49
  throw new Error(usage);
50
- await (await import("./bundle.js")).bundle(entry, outdir);
50
+ await (await import("./bundle.js")).bundleApp(entry, outdir, values.assets);
51
51
  }
52
52
  else {
53
53
  const [target, destination, ...paths] = positionals;
@@ -66,23 +66,19 @@ try {
66
66
  const site = values.site ? (await load(values.site)).siteDefaults : undefined;
67
67
  if (values.site && !site)
68
68
  throw new Error(`${values.site} must export siteDefaults.`);
69
- const extensionModule = values["admin-extensions"] ? await load(values["admin-extensions"]) : undefined;
70
- // The Admin presentation module: extensions and/or the project's sidebar groups.
71
- const adminExtensions = extensionModule?.adminExtensions ?? extensionModule?.default ?? (extensionModule?.navGroups ? [] : undefined);
72
- const navGroups = extensionModule?.navGroups;
73
- if (values["admin-extensions"] && !Array.isArray(adminExtensions))
74
- throw new Error(`${values["admin-extensions"]} must export an adminExtensions array (named or default) or navGroups.`);
75
69
  const { generateFromSources } = await (auth.length ? import("./generateAuth.js") : import("./generate.js"));
76
- const result = await generateFromSources({ target: target, sources: { sources }, admin: values.admin, auth, site, adminExtensions, navGroups });
70
+ const result = await generateFromSources({ target: target, sources: { sources }, admin: values.admin, auth, site });
77
71
  if (!result.ok)
78
72
  throw new Error(JSON.stringify(result.diagnostics, null, 2));
79
73
  for (const warning of result.warnings ?? [])
80
74
  console.warn(`warning: ${warning}`);
81
- const read = [...paths, ...(values.auth ?? []), ...[values.site, values["admin-extensions"]].filter((path) => !!path)];
82
- const changed = await writeArtifacts(destination, { ...result.files, [INPUTS_FILE]: inputsFingerprint(destination, read) }, values.check);
75
+ const read = [...paths, ...(values.auth ?? []), ...(values.site ? [values.site] : [])];
76
+ const changed = await writeArtifacts(destination, { ...result.files, [INPUTS_FILE]: inputsFingerprint(destination, read, target) }, values.check);
83
77
  if (target.startsWith("cloudflare-")) {
84
78
  const wrangler = values.wrangler ?? ["wrangler.jsonc", "wrangler.json", "wrangler.toml"].find(existsSync) ?? "wrangler.jsonc";
85
- if (configureWrangler(wrangler, values.entry, values.outdir, destination, values.check))
79
+ const assets = values.admin ? values.assets ?? join(values.outdir, "client") : undefined;
80
+ const publicFetchRequired = auth.some(options => options.plugins?.some((plugin) => plugin.id === "oauth-provider" || plugin.id === "cimd"));
81
+ if (configureWrangler(wrangler, values.entry, values.outdir, destination, values.check, assets, publicFetchRequired))
86
82
  changed.push(wrangler);
87
83
  }
88
84
  if (values.check && changed.length)
@@ -1,5 +1,5 @@
1
1
  /** Build-time assembly only. None of these imports enter generated applications. */
2
- import { d1Dialect, type AdminExtension, type ManifestSourceSet } from "../build/compiler.js";
2
+ import { d1Dialect, type ManifestSourceSet } from "../build/compiler.js";
3
3
  import type { GenerateResult, Target } from "../domain/generation.js";
4
4
  /** Core's own new-entry-id lowering for a dialect, printed once as an SQL expression: the Store's inserts carry the same one its compiled INSERTs do. */
5
5
  export declare const coreNewId: (dialect: Pick<typeof d1Dialect, "print" | "lowering">, schemas: Parameters<NonNullable<typeof d1Dialect.print>>[1]) => string;
@@ -8,7 +8,5 @@ export interface GenerateRequest {
8
8
  readonly admin?: boolean;
9
9
  readonly sources: ManifestSourceSet; /** Select the optional generated site/media capability and deployment tables. */
10
10
  readonly site?: import("../build/compiler.js").SiteDefaults;
11
- readonly adminExtensions?: readonly AdminExtension[]; /** Project sidebar groups (`navGroups` of the `--admin-extensions` module). */
12
- readonly navGroups?: readonly import("../build/compiler.js").AdminNavGroup[];
13
11
  }
14
12
  export declare function generateFromSources(request: GenerateRequest, schema?: () => readonly string[]): Promise<GenerateResult>;