@aotter/mantle 0.2.0-alpha.8 → 0.2.0

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 (176) hide show
  1. package/README.md +26 -3
  2. package/dist/auth/adapter.js +10 -34
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +8 -153
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +3 -6896
  14. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  15. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  16. package/dist/bun/assets.js +0 -2
  17. package/dist/domain/adminOrder.js +0 -2
  18. package/dist/domain/columns.js +7 -20
  19. package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
  20. package/dist/domain/describe/index.js +1 -115
  21. package/dist/domain/describe/target-hash.js +1 -12
  22. package/dist/domain/entry.js +0 -14
  23. package/dist/domain/generated-tools.js +0 -1
  24. package/dist/domain/input.js +0 -2
  25. package/dist/domain/paging.js +0 -17
  26. package/dist/domain/recipes.js +10 -7
  27. package/dist/domain/userReferences.js +0 -1
  28. package/dist/domain/wireScalars.js +0 -6
  29. package/dist/infrastructure/adminEntries.js +10 -7
  30. package/dist/infrastructure/adminPaging.js +55 -57
  31. package/dist/infrastructure/artifacts.js +0 -4
  32. package/dist/infrastructure/authSchema.js +0 -9
  33. package/dist/infrastructure/bundle.js +17 -32
  34. package/dist/infrastructure/cli.js +25 -11
  35. package/dist/infrastructure/generate.js +18 -17
  36. package/dist/infrastructure/generateAuth.js +48 -5
  37. package/dist/infrastructure/help.js +55 -0
  38. package/dist/infrastructure/init.js +50 -0
  39. package/dist/infrastructure/inputs.js +0 -9
  40. package/dist/infrastructure/mounts.js +22 -0
  41. package/dist/infrastructure/wrangler.js +18 -9
  42. package/dist/kernel/authorization.js +0 -1
  43. package/dist/kernel/binds.d.ts +16 -7
  44. package/dist/kernel/binds.js +4 -10
  45. package/dist/kernel/caller-identity.d.ts +0 -3
  46. package/dist/kernel/caller-identity.js +0 -15
  47. package/dist/kernel/caller-mode.d.ts +4 -0
  48. package/dist/kernel/caller-mode.js +14 -0
  49. package/dist/kernel/contracts.d.ts +7 -11
  50. package/dist/kernel/contracts.js +8 -44
  51. package/dist/kernel/cron.js +0 -4
  52. package/dist/kernel/dialect/common.d.ts +0 -1
  53. package/dist/kernel/dialect/common.js +0 -3
  54. package/dist/kernel/dialect/postgres.js +0 -2
  55. package/dist/kernel/dialect/sqlite.js +1 -6
  56. package/dist/kernel/drain.js +2 -8
  57. package/dist/kernel/driver/bun-sqlite.js +0 -4
  58. package/dist/kernel/driver/d1.js +0 -1
  59. package/dist/kernel/driver/postgres.js +3 -17
  60. package/dist/kernel/driver/transaction-context.js +0 -1
  61. package/dist/kernel/email.js +0 -4
  62. package/dist/kernel/host-access.js +2 -3
  63. package/dist/kernel/host-caller.d.ts +1 -1
  64. package/dist/kernel/host-caller.js +3 -6
  65. package/dist/kernel/json-body.js +0 -2
  66. package/dist/kernel/mcp-apps.js +1 -6
  67. package/dist/kernel/mcp-call.js +0 -1
  68. package/dist/kernel/mcp-handler.js +3 -27
  69. package/dist/kernel/mcp.d.ts +5 -1
  70. package/dist/kernel/mcp.js +2 -6
  71. package/dist/kernel/media.js +0 -18
  72. package/dist/kernel/paging.js +1 -9
  73. package/dist/kernel/publishing.js +3 -4
  74. package/dist/kernel/r2-media.js +0 -7
  75. package/dist/kernel/rest-dispatch.js +0 -1
  76. package/dist/kernel/rest-router.js +2 -3
  77. package/dist/kernel/rest.js +0 -3
  78. package/dist/kernel/scalars.js +4 -11
  79. package/dist/kernel/search.js +0 -1
  80. package/dist/kernel/site.js +0 -2
  81. package/dist/kernel/store.d.ts +11 -1
  82. package/dist/kernel/store.js +21 -34
  83. package/dist/kernel/surface-failure.d.ts +10 -0
  84. package/dist/kernel/surface-failure.js +29 -0
  85. package/dist/kernel/surface.js +0 -3
  86. package/dist/kernel/sweep.js +0 -1
  87. package/dist/kernel/translations.js +0 -1
  88. package/dist/kernel/user-caller.js +2 -8
  89. package/dist/kernel/user-fields.d.ts +3 -2
  90. package/dist/kernel/user-fields.js +9 -9
  91. package/dist/kernel/validation.js +0 -8
  92. package/dist/kernel/web-gateway.js +0 -2
  93. package/dist/kernel/web-head.js +0 -1
  94. package/dist/kernel/web-index.js +39 -0
  95. package/dist/kernel/web-response.js +0 -1
  96. package/dist/kernel/web-serve.js +97 -0
  97. package/dist/kernel/web.d.ts +1 -1
  98. package/dist/kernel/web.js +10 -125
  99. package/dist/kernel/where.js +0 -6
  100. package/dist/usecase/adminBindings.js +30 -21
  101. package/dist/usecase/adminClient.js +0 -21
  102. package/dist/usecase/adminDescriptors.js +2 -9
  103. package/dist/usecase/generate.js +7 -12
  104. package/dist/usecase/handlers.js +0 -3
  105. package/dist/usecase/mcp-media.js +0 -1
  106. package/dist/usecase/mcp.js +0 -4
  107. package/dist/usecase/paging.js +1 -3
  108. package/dist/usecase/publishing.js +1 -5
  109. package/dist/usecase/rest.js +0 -1
  110. package/dist/usecase/site.js +0 -2
  111. package/dist/usecase/stores.js +4 -14
  112. package/dist/usecase/tool-version.js +0 -3
  113. package/dist/usecase/translations.js +0 -2
  114. package/dist/usecase/validation.js +8 -20
  115. package/docs/examples/README.md +13 -5
  116. package/docs/examples/app-shell/README.md +43 -8
  117. package/docs/examples/app-shell/accept.mjs +91 -0
  118. package/docs/examples/app-shell/gitignore +7 -0
  119. package/docs/examples/app-shell/package.json +7 -6
  120. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  121. package/docs/examples/app-shell/src/auth.ts +3 -2
  122. package/docs/examples/app-shell/src/routes.ts +6 -3
  123. package/docs/examples/app-shell/src/service.ts +4 -3
  124. package/docs/examples/club-events/README.md +7 -2
  125. package/docs/examples/club-events/manifests/club-events.yaml +22 -0
  126. package/docs/examples/club-events/smoke.mjs +7 -1
  127. package/docs/examples/club-events/src/handlers.ts +1 -1
  128. package/docs/examples/purchasing/README.md +12 -3
  129. package/docs/examples/purchasing/manifests/purchasing.yaml +36 -3
  130. package/docs/examples/purchasing/smoke.mjs +14 -0
  131. package/docs/examples/purchasing/src/handlers.ts +1 -1
  132. package/docs/examples/recipes/README.md +5 -3
  133. package/docs/handbook/cloudflare/authentication.md +21 -4
  134. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  135. package/docs/handbook/cloudflare/deploy-and-operate.md +24 -1
  136. package/docs/handbook/cloudflare/media-r2.md +2 -3
  137. package/docs/handbook/cloudflare/service-entry.md +17 -4
  138. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  139. package/docs/handbook/examples/hub.md +1 -1
  140. package/docs/handbook/guides/agent-setup.md +15 -10
  141. package/docs/handbook/guides/agent-workflow-acceptance.md +232 -0
  142. package/docs/handbook/guides/local-testing.md +40 -0
  143. package/docs/handbook/guides/mcp-apps.md +78 -1
  144. package/docs/handbook/guides/native-admin.md +29 -6
  145. package/docs/handbook/guides/native-execution.md +5 -4
  146. package/docs/handbook/guides/oauth.md +58 -0
  147. package/docs/handbook/guides/public-pages.md +44 -4
  148. package/docs/handbook/guides/sql-patterns.md +266 -0
  149. package/docs/handbook/guides/typed-queries.md +173 -0
  150. package/docs/handbook/navigation.json +16 -0
  151. package/docs/handbook/reference/admin-descriptors.md +4 -1
  152. package/docs/handbook/reference/authorization.md +1 -1
  153. package/docs/handbook/reference/diagnostics.md +6 -1
  154. package/docs/handbook/reference/procedure.md +5 -5
  155. package/docs/handbook/reference/schema.md +10 -4
  156. package/docs/handbook/reference/sql.md +64 -0
  157. package/docs/handbook/reference/surface.md +11 -4
  158. package/docs/handbook/reference/trigger.md +9 -0
  159. package/docs/handbook/reference/view.md +11 -3
  160. package/docs/handbook/releases/index.md +27 -6
  161. package/docs/handbook/start/agents.md +45 -0
  162. package/docs/handbook/start/project-and-cli.md +15 -1
  163. package/docs/handbook/start/quickstart-worker.md +1 -1
  164. package/docs/skills/develop/SKILL.md +44 -16
  165. package/docs/skills/integrate/SKILL.md +4 -9
  166. package/docs/skills/provision/SKILL.md +15 -12
  167. package/docs/skills/update/SKILL.md +21 -18
  168. package/package.json +24 -4
  169. package/skills/README.md +1 -1
  170. package/skills/mantle/SKILL.md +48 -107
  171. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  172. package/docs/skills/develop/mcp-app.md +0 -39
  173. package/skills/.keep +0 -0
  174. package/skills/mantle/references/local-testing.md +0 -15
  175. package/skills/mantle/references/web.md +0 -41
  176. package/skills/mantle/references/website.md +0 -49
@@ -4,69 +4,67 @@ import { boundaryType } from "../domain/columns.js";
4
4
  import { viewOutputs, compileProgram } from "../build/compiler.js";
5
5
  import { pagedOf } from "../build/compiler.js";
6
6
  import { castCursor } from "../domain/paging.js";
7
- export function adminPaging(plan, dialect, proofs, project) {
7
+ export function adminPaging(plan, dialect, proofs, project, refuse) {
8
8
  const all = Object.create(null);
9
9
  for (const [name, v] of Object.entries(plan.views).filter(([, v]) => v.surface !== "internal")) {
10
10
  const proof = proofs[name];
11
- if (!proof || proof.reason)
12
- throw Error(proof?.reason ?? `Unproved Admin paging: ${name}`);
13
- const list = (v.uiSchema?.list ?? {});
14
- const modes = v.surface === "public" ? ["public", "public"] : ["caller", "trusted"];
15
- const pages = modes.map(mode => {
16
- const [c] = compileProgram(v.stmts, { dialect, schemas: plan.schemas, inputs: v.inputs, kind: "view", mode, restrict: v.restrict });
17
- if (!c)
18
- throw Error(`No compiled Admin View: ${name}`);
19
- const keys = c.ast.SelectStmt.sortClause?.length ?? 0;
20
- if (keys !== proof.types.length)
21
- throw Error(`Incomplete Admin cursor proof: ${name}`);
22
- const first = pagedOf({ schemas: plan.schemas, dialect }, c, undefined, !keys && (list.filterFields?.length || list.searchFields?.length) ? { eq: [{ column: (list.filterFields ?? list.searchFields)[0], value: "" }] } : undefined), next = keys ? pagedOf({ schemas: plan.schemas, dialect }, c, Array(keys).fill(0), undefined) : undefined;
23
- if (!keys && !first.flat)
24
- first.ast.SelectStmt.whereClause = undefined;
25
- const reserved = JSON.stringify([c.ast, v.source, list, Object.values(plan.schemas).map(s => s.fields)]);
26
- let marker = 32767;
27
- while (reserved.includes(String(marker)))
28
- marker++;
29
- const print = (ast) => dialect.print(ast, plan.schemas);
30
- const read = (p) => { const typed = castCursor(p.ast, p.sources, proof.types, !!dialect.nativeOrder); return { prefix: print(project({ SelectStmt: { ...typed.SelectStmt, whereClause: undefined, sortClause: undefined, limitCount: undefined, limitOption: "LIMIT_OPTION_DEFAULT" } }, p.flat ? viewOutputs(v, plan.schemas).keys ?? [] : p.names, keys, name)) + (typed.SelectStmt.whereClause ? ` WHERE (${expression(typed.SelectStmt.whereClause)})` : ""), extra: p.sources.filter(x => "cursor" in x).map(x => x.cursor) }; };
31
- const suffixAst = { SelectStmt: { targetList: [{ ResTarget: { val: { A_Const: { ival: { ival: 1 } } } } }], sortClause: keys ? first.ast.SelectStmt.sortClause : undefined, limitCount: { ParamRef: { number: marker } }, limitOption: "LIMIT_OPTION_COUNT", op: "SETOP_NONE" } };
32
- const tail = print(suffixAst).replace(/^SELECT 1/, "");
33
- const expression = (ast) => print({ SelectStmt: { targetList: [{ ResTarget: { val: ast } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }).replace(/^SELECT /, "");
34
- const fragment = (match, type) => {
35
- const p = pagedOf({ schemas: plan.schemas, dialect }, c, undefined, match);
36
- const ast = structuredClone(p.ast.SelectStmt.whereClause);
37
- const renumber = (node) => { if (node.ParamRef) {
38
- node.ParamRef.number = marker;
39
- if (dialect.nativeOrder && (type === "date" || type === "timestamptz")) {
40
- delete node.ParamRef;
41
- node.Raw = { parts: ["CAST(CAST(", { ParamRef: { number: marker } }, ` AS text) AS ${type})`] };
42
- return;
43
- }
44
- } for (const child of Object.values(node))
45
- if (child && typeof child === "object")
46
- Array.isArray(child) ? child.forEach(renumber) : renumber(child); };
47
- renumber(ast);
48
- return expression(ast);
49
- };
50
- return { marker: dialect.nativeOrder ? `$${marker}` : `?${marker}`, binds: c.binds, keys, first: read(first), ...(next ? { next: read(next) } : {}), tail, eq: Object.fromEntries((list.filterFields ?? []).map(field => { const col = v.columns?.[field] ?? v.columns?.[field.toLowerCase()], type = col && plan.schemas[col.schema]?.fields[col.field]; return [field, fragment({ eq: [{ column: field, value: "" }] }, type)]; })), ...(list.searchFields?.length ? { search: fragment({ search: { columns: list.searchFields, text: "x" } }) } : {}) };
51
- });
52
- all[name] = { caller: pages[0], trusted: pages[1] };
11
+ if (!proof || !proof.types.length && !proof.single)
12
+ continue;
13
+ try {
14
+ const list = (v.uiSchema?.list ?? {});
15
+ const modes = v.surface === "public" ? ["public", "public"] : ["caller", "trusted"];
16
+ const pages = modes.map(mode => {
17
+ const [c] = compileProgram(v.stmts, { dialect, schemas: plan.schemas, inputs: v.inputs, kind: "view", mode, restrict: v.restrict });
18
+ if (!c)
19
+ throw Error(`No compiled Admin View: ${name}`);
20
+ const keys = c.ast.SelectStmt.sortClause?.length ?? 0;
21
+ if (keys !== proof.types.length)
22
+ throw Error(`Incomplete Admin cursor proof: ${name}`);
23
+ const first = pagedOf({ schemas: plan.schemas, dialect }, c, undefined, !keys && (list.filterFields?.length || list.searchFields?.length) ? { eq: [{ column: (list.filterFields ?? list.searchFields)[0], value: "" }] } : undefined), next = keys ? pagedOf({ schemas: plan.schemas, dialect }, c, Array(keys).fill(0), undefined) : undefined;
24
+ if (!keys && !first.flat)
25
+ first.ast.SelectStmt.whereClause = undefined;
26
+ const reserved = JSON.stringify([c.ast, v.source, list, Object.values(plan.schemas).map(s => s.fields)]);
27
+ let marker = 32767;
28
+ while (reserved.includes(String(marker)))
29
+ marker++;
30
+ const print = (ast) => dialect.print(ast, plan.schemas);
31
+ const read = (p) => { const typed = castCursor(p.ast, p.sources, proof.types, !!dialect.nativeOrder); return { prefix: print(project({ SelectStmt: { ...typed.SelectStmt, whereClause: undefined, sortClause: undefined, limitCount: undefined, limitOption: "LIMIT_OPTION_DEFAULT" } }, p.flat ? viewOutputs(v, plan.schemas).keys ?? [] : p.names, keys, name)) + (typed.SelectStmt.whereClause ? ` WHERE (${expression(typed.SelectStmt.whereClause)})` : ""), extra: p.sources.filter(x => "cursor" in x).map(x => x.cursor) }; };
32
+ const suffixAst = { SelectStmt: { targetList: [{ ResTarget: { val: { A_Const: { ival: { ival: 1 } } } } }], sortClause: keys ? first.ast.SelectStmt.sortClause : undefined, limitCount: { ParamRef: { number: marker } }, limitOption: "LIMIT_OPTION_COUNT", op: "SETOP_NONE" } };
33
+ const tail = print(suffixAst).replace(/^SELECT 1/, "");
34
+ const expression = (ast) => print({ SelectStmt: { targetList: [{ ResTarget: { val: ast } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }).replace(/^SELECT /, "");
35
+ const fragment = (match, type) => {
36
+ const p = pagedOf({ schemas: plan.schemas, dialect }, c, undefined, match);
37
+ const ast = structuredClone(p.ast.SelectStmt.whereClause);
38
+ const renumber = (node) => { if (node.ParamRef) {
39
+ node.ParamRef.number = marker;
40
+ if (dialect.nativeOrder && (type === "date" || type === "timestamptz")) {
41
+ delete node.ParamRef;
42
+ node.Raw = { parts: ["CAST(CAST(", { ParamRef: { number: marker } }, ` AS text) AS ${type})`] };
43
+ return;
44
+ }
45
+ } for (const child of Object.values(node))
46
+ if (child && typeof child === "object")
47
+ Array.isArray(child) ? child.forEach(renumber) : renumber(child); };
48
+ renumber(ast);
49
+ return expression(ast);
50
+ };
51
+ return { marker: dialect.nativeOrder ? `$${marker}` : `?${marker}`, binds: c.binds, keys, first: read(first), ...(next ? { next: read(next) } : {}), tail, eq: Object.fromEntries((list.filterFields ?? []).map(field => { const col = v.columns?.[field] ?? v.columns?.[field.toLowerCase()], type = col && plan.schemas[col.schema]?.fields[col.field]; return [field, fragment({ eq: [{ column: field, value: "" }] }, type)]; })), ...(list.searchFields?.length ? { search: fragment({ search: { columns: list.searchFields, text: "x" } }) } : {}) };
52
+ });
53
+ all[name] = { caller: pages[0], trusted: pages[1] };
54
+ }
55
+ catch (error) {
56
+ refuse(`views/${name}/admin`, error instanceof Error ? error.message : String(error));
57
+ }
53
58
  }
54
59
  return all;
55
60
  }
56
- /** Only selected build-time Admin work; the ordinary generator remains unchanged. */
57
61
  export async function adminBindingsOf(plan, dialect, paging, wire, refusals) {
58
- let adminPages;
59
- try {
60
- adminPages = adminPaging(plan, dialect, paging, (ast, names, nkeys, name) => {
61
- const keys = [...names, ...Array.from({ length: nkeys }, (_, i) => `_k${i}`)];
62
- const projected = wireScalars(ast, keys, [...names.map(key => boundaryType(plan, name, key)), ...(paging[name]?.types ?? [])], wire, true);
63
- if (typeof projected === "string")
64
- throw Error(projected);
65
- return projected;
66
- });
67
- }
68
- catch (error) {
69
- refusals.push({ code: "GENERATE_UNSUPPORTED", path: "admin/views", message: error instanceof Error ? error.message : String(error) });
70
- }
62
+ const adminPages = adminPaging(plan, dialect, paging, (ast, names, nkeys, name) => {
63
+ const keys = [...names, ...Array.from({ length: nkeys }, (_, i) => `_k${i}`)];
64
+ const projected = wireScalars(ast, keys, [...names.map(key => boundaryType(plan, name, key)), ...(paging[name]?.types ?? [])], wire, true);
65
+ if (typeof projected === "string")
66
+ throw Error(projected);
67
+ return projected;
68
+ }, (path, message) => refusals.push({ code: "GENERATE_UNSUPPORTED", path, message }));
71
69
  return { ok: true, adminPages, adminEntries: await adminEntries(plan, dialect, wire, refusals) };
72
70
  }
@@ -1,7 +1,6 @@
1
1
  import { readFile, readdir, mkdir, rm, writeFile, lstat } from "node:fs/promises";
2
2
  import { dirname, join, resolve, relative, sep, isAbsolute } from "node:path";
3
3
  import { GENERATED } from "../domain/entry.js";
4
- /** Only marked generated files (and pre-header operation entries) can be removed. */
5
4
  async function ownedFiles(directory, prefix = "") {
6
5
  const entries = await readdir(join(directory, prefix), { withFileTypes: true }).catch(error => {
7
6
  if (error.code === "ENOENT")
@@ -22,15 +21,12 @@ async function ownedFiles(directory, prefix = "") {
22
21
  }
23
22
  return files;
24
23
  }
25
- /** Check never writes; normal generation removes obsolete SDK-owned files only. */
26
24
  export async function writeArtifacts(directory, files, check = false) {
27
25
  const root = resolve(directory);
28
26
  const rootStat = await lstat(root).catch(error => { if (error.code === "ENOENT")
29
27
  return undefined; throw error; });
30
28
  if (rootStat?.isSymbolicLink())
31
29
  throw new Error(`Generated output cannot follow an application-owned symlink: ${root}`);
32
- // Preflight all destinations before writing any: a symlink belongs to the app,
33
- // never to generated output (including links to somewhere inside the tree).
34
30
  for (const name of Object.keys(files)) {
35
31
  const child = relative(root, resolve(root, name));
36
32
  if (!child || isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`))
@@ -3,7 +3,6 @@ import { getSchema } from "better-auth/db";
3
3
  import { ident } from "../kernel/dialect/common.js";
4
4
  function columnType(field, dialect) {
5
5
  const pg = dialect === "postgres";
6
- // Anything else, including the arrays the factory stores as JSON text, is text (Bun.SQL would encode that text into jsonb as a JSON string).
7
6
  switch (field.type) {
8
7
  case "number": return field.bigint ? "bigint" : "integer";
9
8
  case "boolean": return pg ? "boolean" : "integer";
@@ -12,12 +11,6 @@ function columnType(field, dialect) {
12
11
  default: return "text";
13
12
  }
14
13
  }
15
- /**
16
- * Better Auth's name for a field-level index (`<table>_<column>_idx`; past PostgreSQL's 63-byte identifier limit the middle is
17
- * cut and an FNV-1a hash of the full name is spliced in). Its helper lives in `@better-auth/core/db/internal`, which is not public
18
- * API, so the same rule is restated here: a database already migrated by Better Auth's CLI keeps matching names, and
19
- * `IF NOT EXISTS` skips the index instead of duplicating it.
20
- */
21
14
  export function fieldIndexName(table, column) {
22
15
  const full = `${table}_${column}_idx`;
23
16
  if (new TextEncoder().encode(full).length <= 63)
@@ -28,7 +21,6 @@ export function fieldIndexName(table, column) {
28
21
  const suffix = `_${(hash >>> 0).toString(16).padStart(8, "0")}_idx`;
29
22
  return full.slice(0, 63 - suffix.length) + suffix;
30
23
  }
31
- /** Better Auth's own `order`, then referenced tables before the tables that reference them. */
32
24
  function orderedTables(options) {
33
25
  const byOrder = Object.entries(getSchema(options)).filter(([, table]) => !table.disableMigrations).sort((a, b) => a[1].order - b[1].order);
34
26
  const ordered = [];
@@ -51,7 +43,6 @@ export function authSchemaSql(options, dialect) {
51
43
  const generateId = options.advanced?.database?.generateId;
52
44
  const idType = generateId === "serial" ? "integer" : generateId === "uuid" && dialect === "postgres" ? "uuid" : "text";
53
45
  const idColumn = generateId === "serial" ? (dialect === "postgres" ? "integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : "integer PRIMARY KEY") : `${idType} PRIMARY KEY NOT NULL${idType === "uuid" ? " DEFAULT gen_random_uuid()" : ""}`;
54
- // `references.field` is a logical field name; the column it points at may be renamed (`fields: { email: "email_address" }`).
55
46
  const tables = getAuthTables(options);
56
47
  const logical = Object.values(tables);
57
48
  const referenced = (ref) => logical.find(t => t.modelName === ref.model)?.fields[ref.field]?.fieldName ?? ref.field;
@@ -1,10 +1,9 @@
1
1
  import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { build } from "esbuild";
6
6
  import { INPUTS_FILE, authTableSql, extraRoles, generatedTarget, staleInputs } from "./inputs.js";
7
- /** Only static imports run at startup; dynamic imports remain separate uploaded modules. */
8
7
  export function startupGraph(metafile, entry) {
9
8
  const visited = new Set();
10
9
  const visit = (name) => {
@@ -25,48 +24,48 @@ export function startupGraph(metafile, entry) {
25
24
  const refused = [
26
25
  [/(?:^|\/)node_modules\/(?:better-auth(?:\/|$)|@better-auth\/)/, "Load it through authLoader(() => import(...)) to keep Better Auth out of startup."],
27
26
  [/(?:^|\/)node_modules\/@modelcontextprotocol\//, "Load MCP through a request-time import(...)."],
28
- // the compiler: the package's own generator layers (installed, or workspace dist/source) and its parser, YAML and SQL dependencies
29
27
  [/(?:^|\/)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
28
  [/(?:^|\/)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."],
32
29
  ];
33
30
  export function guardStartup(metafile, entry) {
34
31
  for (const name of startupGraph(metafile, entry)) {
35
32
  const output = metafile.outputs[name];
36
33
  for (const input of Object.keys(output.inputs)) {
37
- // shortcut: vendored auth outside node_modules is not recognized; use package metadata if vendoring is supported.
38
34
  const hit = refused.find(([pattern]) => pattern.test(input.replaceAll("\\", "/")));
39
35
  if (hit)
40
36
  throw new Error(`Startup module ${name} contains ${input}. ${hit[1]}`);
41
37
  }
42
38
  }
43
39
  }
44
- /** Whether `inner` is `outer` or inside it. */
40
+ const physical = (input) => {
41
+ let found = resolve(input);
42
+ const tail = [];
43
+ while (!existsSync(found)) {
44
+ const parent = dirname(found);
45
+ if (parent === found)
46
+ return found;
47
+ tail.unshift(basename(found));
48
+ found = parent;
49
+ }
50
+ return resolve(realpathSync(found), ...tail);
51
+ };
45
52
  const within = (outer, inner) => {
46
- const path = relative(resolve(outer), resolve(inner));
53
+ const path = relative(physical(outer), physical(inner));
47
54
  return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
48
55
  };
49
- /** A directory a build empties must not be the working directory or hold the source entry. */
50
56
  function assertCleanable(label, dir, entry) {
51
- if (resolve(dir) === process.cwd())
57
+ if (physical(dir) === physical(process.cwd()))
52
58
  throw new Error(`${label} must not be the working directory: it is cleaned before building.`);
53
59
  if (within(dir, entry))
54
60
  throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
55
61
  }
56
- /**
57
- * `mantle bundle <entry> <outdir> [--assets <dir>]`: the Worker into `<outdir>/worker`, and Admin into `<assets>/admin`
58
- * (default `<outdir>/client`) when the Worker serves it (`@aotter/mantle-admin/server`; `/mcp` and the other subpaths alone do not).
59
- * A server whose generated output records a `bun-*` target runs as TypeScript, so only its Admin is built.
60
- */
62
+ export const ownModules = async (entry) => (await build({ entryPoints: [entry], bundle: true, write: false, metafile: true, packages: "external", format: "esm", platform: "neutral", logLevel: "silent", absWorkingDir: process.cwd() })).metafile;
61
63
  export async function bundleApp(entry, outdir, assets = join(outdir, "client")) {
62
64
  const source = realpathSync(entry);
63
- // what is cleaned lies under these two, so neither may be the working directory or hold the source
64
65
  assertCleanable("Bundle outdir", outdir, source);
65
66
  assertCleanable("The assets directory", assets, source);
66
- // The app's own modules only: enough to find its generated output and whether it imports the Admin server.
67
- const { metafile: own } = await build({ entryPoints: [entry], bundle: true, write: false, metafile: true, packages: "external", format: "esm", platform: "neutral", logLevel: "silent" });
67
+ const own = await ownModules(entry);
68
68
  if (generatedDirs(own).some(dir => generatedTarget(join(dir, "generated.ts"))?.startsWith("bun-"))) {
69
- // Packages are external here, so only the app's own imports of the Admin server are seen; one reached through another package is not.
70
69
  if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
71
70
  await bundleAdmin(source, outdir, join(assets, "admin"));
72
71
  else
@@ -74,7 +73,6 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"))
74
73
  return;
75
74
  }
76
75
  const worker = join(outdir, "worker");
77
- // Wrangler uploads every module under the Worker directory and every file under the assets directory.
78
76
  if (within(worker, assets) || within(assets, worker))
79
77
  throw new Error(`The assets directory ${assets} must not overlap the Worker directory ${worker}.`);
80
78
  const metafile = await bundle(entry, worker);
@@ -82,12 +80,6 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"))
82
80
  await bundleAdmin(source, outdir, join(assets, "admin"));
83
81
  }
84
82
  }
85
- /**
86
- * Admin from `<dirname(entry)>/admin.tsx`, or the built-in entry; an optional `admin.mcp.tsx` selects the host-safe graph.
87
- * Uses `@aotter/mantle-admin/build` as the app installed
88
- * it. The built-in entry is skipped when the installed Admin and its direct dependencies are unchanged since the last
89
- * build; an application entry always builds, because what it imports is only known by building it.
90
- */
91
83
  async function bundleAdmin(entry, outdir, out) {
92
84
  const source = join(dirname(entry), "admin.tsx"), custom = existsSync(source);
93
85
  const mcpSource = join(dirname(entry), "admin.mcp.tsx"), customMcp = existsSync(mcpSource);
@@ -101,7 +93,6 @@ async function bundleAdmin(entry, outdir, out) {
101
93
  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.`);
102
94
  }
103
95
  const stamp = join(outdir, ".admin-inputs");
104
- // the installed path too: a patched or linked Admin keeps its version but not its path
105
96
  const manifest = realpathSync(lookup(require, "@aotter/mantle-admin") ?? require.resolve("@aotter/mantle-admin/package.json"));
106
97
  const key = custom || customMcp ? undefined : JSON.stringify({ out: resolve(out), path: dirname(manifest), packages: installed(manifest) });
107
98
  if (key && existsSync(join(out, "index.html")) && existsSync(join(out, "mcp-app.html")) && existsSync(stamp) && readFileSync(stamp, "utf8") === key) {
@@ -114,9 +105,7 @@ async function bundleAdmin(entry, outdir, out) {
114
105
  if (key)
115
106
  writeFileSync(stamp, key);
116
107
  }
117
- /** The package.json a bare import of `name` finds, read from disk (`exports` may hide it, and require caches realpaths). */
118
108
  const lookup = (require, name) => require.resolve.paths(name)?.map(dir => join(dir, name, "package.json")).find(file => existsSync(file));
119
- /** The package's version and the installed version of each of its dependencies and peers (React comes from the application). */
120
109
  function installed(manifest) {
121
110
  const { name, version, dependencies = {}, peerDependencies = {} } = JSON.parse(readFileSync(manifest, "utf8"));
122
111
  const require = createRequire(manifest);
@@ -127,7 +116,6 @@ function installed(manifest) {
127
116
  }
128
117
  return versions;
129
118
  }
130
- /** The generated directories among a build's inputs, refusing stale ones. */
131
119
  function generatedDirs(metafile) {
132
120
  const dirs = [...new Set(Object.keys(metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))))];
133
121
  for (const dir of dirs) {
@@ -150,15 +138,12 @@ export async function bundle(entry, outdir) {
150
138
  if (args.pluginData?.optionalPeer)
151
139
  return;
152
140
  const resolved = await builder.resolve(args.path, { importer: args.importer, resolveDir: args.resolveDir, kind: args.kind, pluginData: { optionalPeer: true } });
153
- // Unselected request paths may reference absent peers; installed peers still bundle normally.
154
141
  return resolved.errors.length ? { path: args.path, external: true } : resolved;
155
142
  });
156
143
  } }],
157
144
  });
158
145
  if (Object.keys(result.metafile.inputs).some(input => /(?:^|\/)mantle\/(?:src|dist)\/testing\//.test(resolve(input).replaceAll("\\", "/"))))
159
146
  throw new Error("@aotter/mantle/testing is only for tests and cannot be bundled for production.");
160
- // A build is what `wrangler dev` and deploys run: generated code must match the manifests it came from.
161
- // Any bundled module from a generated directory finds its fingerprint (type-only imports leave generated.ts out).
162
147
  generatedDirs(result.metafile);
163
148
  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")]);
164
149
  for (const [input, source] of appSources) {
@@ -7,7 +7,6 @@ import { parseArgs } from "node:util";
7
7
  import { configureWrangler } from "./wrangler.js";
8
8
  import { writeArtifacts } from "./artifacts.js";
9
9
  import { INPUTS_FILE, inputsFingerprint } from "./inputs.js";
10
- /** Option modules are often .ts, which older Node rejects with a bare "Unknown file extension". */
11
10
  async function load(path) {
12
11
  try {
13
12
  return await import(pathToFileURL(resolve(path)).href);
@@ -19,13 +18,27 @@ async function load(path) {
19
18
  }
20
19
  }
21
20
  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) [--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>]";
21
+ const usage = "Usage: mantle help [topic] (start here: the installed docs, one topic at a time)\n mantle init [directory] [--dry-run]\n 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
22
  try {
24
23
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
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" },
24
+ 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" }, "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" },
26
25
  } });
27
- const command = positionals[0] === "bundle" || positionals[0] === "generate" || positionals[0] === "describe" ? positionals.shift() : "generate";
28
- if (command === "describe") {
26
+ const named = ["bundle", "generate", "describe", "help", "init"].includes(positionals[0]) ? positionals.shift() : undefined;
27
+ const command = values.help ? "usage" : named ?? (process.argv.length > 2 ? "generate" : "help");
28
+ if (command === "usage")
29
+ console.log(`${usage}\n\nStart with mantle help: the installed docs, one topic at a time.`);
30
+ else if (command === "help") {
31
+ const { help, topicList } = await import("./help.js");
32
+ if (positionals.length > 1)
33
+ throw new Error(usage);
34
+ console.log(positionals[0] === "topics" ? topicList() : help(positionals[0]));
35
+ }
36
+ else if (command === "init") {
37
+ if (positionals.length > 1)
38
+ throw new Error(usage);
39
+ console.log((await import("./init.js")).init(positionals[0] ?? ".", values["dry-run"]));
40
+ }
41
+ else if (command === "describe") {
29
42
  const target = positionals.shift();
30
43
  if (!target || !targets.includes(target) || !positionals.length)
31
44
  throw new Error(usage);
@@ -53,9 +66,10 @@ try {
53
66
  const [target, destination, ...paths] = positionals;
54
67
  if (!target || !targets.includes(target) || !destination || !paths.length)
55
68
  throw new Error(usage);
69
+ for (const path of paths)
70
+ if (/[*?]/.test(path) && !existsSync(path))
71
+ throw new Error(`No manifest matches ${path}. Add one (mantle help four-atoms, mantle help manifest), then run generate again.`);
56
72
  const sources = await Promise.all(paths.map(async (path) => ({ sourceId: path, text: await readFile(path, "utf8") })));
57
- // Read plain options without constructing Better Auth, which can touch the database.
58
- // One --auth per Better Auth instance; their tables are merged into one migration.
59
73
  const auth = await Promise.all((values.auth ?? []).map(async (path) => {
60
74
  const loaded = await load(path);
61
75
  const options = loaded.authOptions?.(new Proxy({}, { get: () => undefined })) ?? loaded.auth?.options;
@@ -74,6 +88,8 @@ try {
74
88
  console.warn(`warning: ${warning}`);
75
89
  const read = [...paths, ...(values.auth ?? []), ...(values.site ? [values.site] : [])];
76
90
  const changed = await writeArtifacts(destination, { ...result.files, [INPUTS_FILE]: inputsFingerprint(destination, read, target) }, values.check);
91
+ for (const warning of await (await import("./mounts.js")).unmountedWarnings(values.entry, destination, result.files))
92
+ console.warn(`warning: ${warning}`);
77
93
  if (target.startsWith("cloudflare-")) {
78
94
  const wrangler = values.wrangler ?? ["wrangler.jsonc", "wrangler.json", "wrangler.toml"].find(existsSync) ?? "wrangler.jsonc";
79
95
  const assets = values.admin ? values.assets ?? join(values.outdir, "client") : undefined;
@@ -85,10 +101,8 @@ try {
85
101
  throw new Error(`Generated output is stale. Run mantle generate with the same options:\n${changed.join("\n")}`);
86
102
  if (!values.check)
87
103
  console.log(`Commit generated output and Wrangler config edits; keep dist/ untracked. Add to .gitattributes: ${JSON.stringify(relative(process.cwd(), resolve(destination)).replaceAll("\\", "/") + "/**")} linguist-generated=true`);
88
- for (const name of result.refProcedures)
89
- console.log(`Ref Procedure ${JSON.stringify(name)}: implement Handlers[${JSON.stringify(name)}].`);
90
- if (result.refProcedures.length)
91
- console.log("Next: run tsc --noEmit; pass the handlers at store(db, caller, { handlers }).");
104
+ if (!values.check && result.refProcedures.length)
105
+ console.log(`Ref Procedures need these Handlers keys (tsc --noEmit checks them; pass them at store(db, caller, { handlers })): ${result.refProcedures.map(name => JSON.stringify(name)).join(", ")}`);
92
106
  }
93
107
  }
94
108
  catch (error) {
@@ -1,5 +1,4 @@
1
1
  import { describeApp } from "../domain/describe/index.js";
2
- /** Build-time assembly only. None of these imports enter generated applications. */
3
2
  import { compilePlan, viewOutputs, sqliteCompiler, postgresCompiler, d1Dialect, postgresDialect, lowerPlan, sqliteSchema, postgresSchema } from "../build/compiler.js";
4
3
  import { wireReturning, wireScalars } from "../domain/wireScalars.js";
5
4
  import { boundaryType, outputType } from "../domain/columns.js";
@@ -12,12 +11,7 @@ import { publishingSql } from "../usecase/publishing.js";
12
11
  import { searchDiagnostics } from "../usecase/search.js";
13
12
  import { generate } from "../usecase/generate.js";
14
13
  import { generatedFiles } from "../domain/entry.js";
15
- /** 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. */
16
14
  export const coreNewId = (dialect, schemas) => dialect.print({ SelectStmt: { targetList: [{ ResTarget: { val: dialect.lowering.newId() } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, schemas).replace(/^SELECT /, "");
17
- // JSON, date-time and date inputs are driver-ready text. Declaring their parameter as text makes every driver send it verbatim:
18
- // Bun.SQL would JSON-encode a string bound to jsonb a second time, and postgres.js turns one bound to timestamptz or date into a
19
- // JavaScript Date, which loses microseconds. Core prints `CAST($n AS <type>)`; the PostgreSQL dialect spells what replaces it (the
20
- // same cast the Store writes with). Any other use of such a parameter is refused.
21
15
  const CORE_CAST = { json: "jsonb", timestamptz: "timestamptz", date: "date" };
22
16
  function castInputs(statement, path, refusals) {
23
17
  let sql = statement.sql;
@@ -32,20 +26,18 @@ function castInputs(statement, path, refusals) {
32
26
  }
33
27
  Object.assign(statement, { sql });
34
28
  }
35
- /** Operational records are added in Admin only through a Procedure placed with `uiSchema.collectionAction`; name the Schemas left without one. */
36
29
  function adminCreateWarnings(plan) {
37
30
  const placed = new Set(Object.values(plan.procedures).map(p => p.uiSchema?.["collectionAction"]).filter((name) => typeof name === "string").map(name => name.toLowerCase()));
38
31
  return Object.values(plan.schemas).filter(s => {
39
32
  const required = new Set(s.schema.required ?? []);
40
- // Folded children open from their parent; translations from their language tabs.
41
- const folded = Object.entries(s.schema.properties ?? {}).some(([name, property]) => required.has(name) && typeof property?.["x-mantle-ref"] === "string");
42
- // Field-scoped rows belong to their members, who create them.
33
+ const folded = Object.entries(s.schema.properties ?? {}).some(([name, property]) => required.has(name) && property?.["x-mantle-ref"] != null);
43
34
  return !s.publishing && s.schema.readOnly !== true && !s.scope && !s.translates && !folded && !placed.has(s.name.toLowerCase());
44
- }).map(s => `Schema '${s.name}' is operational and writable, but no Procedure places a create action on it, so Admin cannot add its records. Add a Procedure with uiSchema.collectionAction: ${s.name} and an admin MCP Trigger, or mark the Schema readOnly.`).concat(Object.values(plan.schemas).flatMap(s => Object.entries(s.schema.properties ?? {})
35
+ }).map(s => `Schema '${s.name}' is operational and writable, but no Procedure places a create action on it, so Admin cannot add its records. Add a Procedure with uiSchema.collectionAction: ${s.name} and an admin MCP Trigger, or mark the Schema readOnly.${s.userScopes?.length ? ` That Procedure takes ${s.userScopes.map(u => `'${u.column}'`).join(', ')} as an optional input for callers holding { all: true } (field scope in the Schema reference).` : ''}`).concat(Object.values(plan.schemas)
36
+ .filter(s => s.schema.readOnly !== true && !placed.has(s.name.toLowerCase()))
37
+ .flatMap(s => Object.entries(s.schema.properties ?? {})
45
38
  .filter(([name, property]) => property?.readOnly === true && (s.schema.required ?? []).includes(name) && property.default === undefined)
46
- .map(([name]) => `Schema '${s.name}' field '${name}' is required and readOnly without a default, so Admin cannot create its records. Give it a default (Admin stores it on create).`)));
39
+ .map(([name]) => `Schema '${s.name}' field '${name}' is required and readOnly without a default, so Admin's generic create cannot fill it. Give it a default (Admin stores it on create), or create the records through a Procedure with uiSchema.collectionAction: ${s.name}.`)));
47
40
  }
48
- /** Warn about SQL's case folding without changing the authored output names. */
49
41
  function outputNameWarnings(plan) {
50
42
  const warnings = [];
51
43
  const inspect = (kind, name, stmts) => {
@@ -90,6 +82,14 @@ function outputNameWarnings(plan) {
90
82
  inspect("Procedure", name, procedure.handler.sql.stmts);
91
83
  return [...new Set(warnings)];
92
84
  }
85
+ function missingMcpWarnings(plan) {
86
+ const mcp = new Set(Object.values(plan.triggers).flatMap(trigger => trigger.source.kind === "mcp" ? [trigger.procedure] : []));
87
+ const http = new Set(Object.values(plan.triggers).flatMap(trigger => trigger.source.kind === "http" ? [trigger.procedure] : []));
88
+ return [...http].filter(name => !mcp.has(name)).map(name => {
89
+ const surface = plan.procedures[name]?.requires?.auth?.all.includes("ctx.admin") ? "admin" : "public";
90
+ return `Procedure '${name}' has an http Trigger but no mcp Trigger, so the user's AI cannot reach this operation. Add a Trigger with spec.source: { kind: mcp, surface: ${surface} } and spec.target: { procedure: ${name} }.`;
91
+ });
92
+ }
93
93
  export async function generateFromSources(request, schema) {
94
94
  const postgres = request.target.endsWith("postgres");
95
95
  const compiler = postgres ? postgresCompiler : sqliteCompiler;
@@ -105,8 +105,6 @@ export async function generateFromSources(request, schema) {
105
105
  const refusals = [], paging = {};
106
106
  const lower = (boundary) => lowerPlan(compiled.plan, dialect, {
107
107
  pageQuery(ast, name) {
108
- // Core visits public/caller before trusted. Prove the real caller equality prefixes;
109
- // a trusted all-scope read does not acquire a fictional scope predicate.
110
108
  if (!Object.hasOwn(paging, name))
111
109
  paging[name] = provePaging(compiled.plan, ast);
112
110
  return ast;
@@ -162,7 +160,10 @@ export async function generateFromSources(request, schema) {
162
160
  return { ok: false, diagnostics: [...refusals, ...diagnostics] };
163
161
  }
164
162
  if (!result.ok)
165
- return result;
163
+ return { ...result, diagnostics: result.diagnostics.map(d => {
164
+ const why = d.message === "Both caller and trusted SQL must be lowered." ? warnings.find(w => w.includes(`${d.path} (caller)`) || w.includes(`${d.path} (admin)`)) : undefined;
165
+ return why ? { ...d, message: `${d.message} ${why.slice(why.indexOf("): ") + 3)}` } : d;
166
+ }) };
166
167
  const accessTargets = Object.fromEntries(report.targets.map(({ _meta: m }) => {
167
168
  const policy = ({ opaque, requires }) => ({ opaque, ...(requires.guard ? { guard: requires.guard } : {}) });
168
169
  return [m.id, { targetHash: m.targetHash, ...policy(m), ...(m.variants.length ? { variants: m.variants.map(v => ({ id: v.id, ...policy(v) })) } : {}) }];
@@ -170,5 +171,5 @@ export async function generateFromSources(request, schema) {
170
171
  const gatewayFiles = generatedFiles({ "access-targets.ts": `import type { AccessTarget } from "@aotter/mantle/contracts";\nexport const targets: Readonly<Record<string, AccessTarget>> = ${JSON.stringify(accessTargets)};\n` });
171
172
  const ddl = [...(postgres ? postgresSchema : sqliteSchema)(compiled.plan.schemas), ...publishingSql(compiled.plan.schemas, !postgres), ...(request.admin ? adminIndexes(compiled.plan.schemas, !postgres) : []), ...(schema?.() ?? [])];
172
173
  const { "site-schema.sql": siteSql = "", ...files } = result.files;
173
- return { ...result, warnings: [...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
174
+ return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
174
175
  }
@@ -1,6 +1,6 @@
1
1
  import { generateFromSources as generate } from "./generate.js";
2
+ import { admin, anonymous, jwt, phoneNumber, siwe, twoFactor } from "better-auth/plugins";
2
3
  import { authSchemaSql } from "./authSchema.js";
3
- /** One or more Better Auth instances (each with its own table names); their DDL is merged. */
4
4
  export function generateFromSources(request) {
5
5
  const instances = request.auth ? [request.auth].flat() : [];
6
6
  return generate(request, instances.length ? () => authSql(instances, request.target.endsWith("postgres") ? "postgres" : "sqlite") : undefined);
@@ -8,16 +8,59 @@ export function generateFromSources(request) {
8
8
  function authSql(instances, dialect) {
9
9
  for (const options of instances) {
10
10
  const ids = (options.plugins ?? []).map(plugin => plugin.id);
11
- // Offline OAuth grants outlive sessions; without the revocation plugin a password reset or ban would not end them. mcp() spreads oauthProvider, id included.
12
- if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") /* oauthGrantRevocation().id */)
11
+ if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") )
13
12
  throw new Error("Better Auth has an OAuth provider (oauthProvider or mcp) without oauthGrantRevocation(): add it from @aotter/mantle/auth/oauth to the plugins, so a password reset, an admin ban or session revocation ends that user's OAuth grants.");
14
13
  }
14
+ if (instances.length > 1)
15
+ refuseSharedPluginMappings(instances);
15
16
  const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
16
- // CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
17
- // Identical statements are already merged, so a repeated table name means two different definitions.
18
17
  const tables = statements.map(statement => /^CREATE TABLE IF NOT EXISTS ("(?:[^"]|"")+")/.exec(statement)?.[1]).filter(Boolean);
19
18
  const twice = tables.find((table, i) => tables.indexOf(table) !== i);
20
19
  if (twice)
21
20
  throw new Error(`Better Auth instances define table ${twice} differently; give each instance its own table names (user.modelName, session.modelName, ...).`);
22
21
  return statements;
23
22
  }
23
+ const sharedSchema = {
24
+ jwt: () => jwt().schema, admin: () => admin().schema, anonymous: () => anonymous().schema,
25
+ "phone-number": () => phoneNumber({}).schema, siwe: () => siwe({}).schema, "two-factor": () => twoFactor().schema,
26
+ };
27
+ const checkedPlugins = new Set([...Object.keys(sharedSchema), "device-authorization", "oauth-provider"]);
28
+ function mapped(schema) {
29
+ const names = new Map();
30
+ for (const [table, model] of Object.entries(schema)) {
31
+ if (model.modelName && model.modelName !== table)
32
+ names.set(table, model.modelName);
33
+ for (const [field, type] of Object.entries(model.fields ?? {})) {
34
+ const name = type.fieldName;
35
+ if (name && name !== field)
36
+ names.set(`${table}.${field}`, name);
37
+ }
38
+ }
39
+ return names;
40
+ }
41
+ function refuseSharedPluginMappings(instances) {
42
+ const byPlugin = new Map();
43
+ instances.forEach((options, index) => (options.plugins ?? []).forEach(plugin => {
44
+ const schema = plugin.schema;
45
+ if (schema && checkedPlugins.has(plugin.id))
46
+ byPlugin.set(plugin.id, [...(byPlugin.get(plugin.id) ?? []), { index, schema }]);
47
+ }));
48
+ for (const [id, uses] of byPlugin) {
49
+ const names = uses.map(use => mapped(use.schema));
50
+ if (uses.length < 2 || !names.some(own => own.size))
51
+ continue;
52
+ const fail = (reason, which) => { throw new Error(`Better Auth plugin ${id} is used by instances ${which.join(", ")} (--auth order, from 0) and ${reason}. Plugin factories share their schema object, so differing plugin field/model mappings across instances in one Worker override each other: pass an explicit \`schema\` mapping to the ${id} plugin in every instance, with names that differ between instances, and give each instance its own copy right after constructing it, \`{ ...plugin, schema: structuredClone(plugin.schema) }\`; or keep the ${id} plugin at its defaults and separate the instances by core table names.`); };
53
+ const shared = sharedSchema[id]?.();
54
+ const holders = uses.filter(use => use.schema === shared).map(use => use.index);
55
+ if (holders.length)
56
+ fail("a non-default mapping exists while an instance holds the plugin's shared schema object (no own copy)", holders);
57
+ for (const [i, a] of uses.entries())
58
+ for (const [j, b] of uses.entries()) {
59
+ if (j <= i)
60
+ continue;
61
+ for (const [key, name] of names[i])
62
+ if (names[j].get(key) === name)
63
+ fail(`both resolve ${key} to "${name}"; non-default plugin names must differ between instances, because generate cannot tell an identical mapping from an inherited one`, [a.index, b.index]);
64
+ }
65
+ }
66
+ }