@aotter/mantle 0.2.0-alpha.9 → 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 (159) hide show
  1. package/README.md +9 -3
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +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 +2 -30
  34. package/dist/infrastructure/cli.js +25 -11
  35. package/dist/infrastructure/generate.js +9 -16
  36. package/dist/infrastructure/generateAuth.js +1 -9
  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 +0 -6
  42. package/dist/kernel/authorization.js +0 -1
  43. package/dist/kernel/binds.d.ts +16 -4
  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 +6 -10
  50. package/dist/kernel/contracts.js +5 -41
  51. package/dist/kernel/cron.js +0 -4
  52. package/dist/kernel/dialect/common.js +0 -3
  53. package/dist/kernel/dialect/postgres.js +0 -2
  54. package/dist/kernel/dialect/sqlite.js +1 -6
  55. package/dist/kernel/drain.js +2 -8
  56. package/dist/kernel/driver/bun-sqlite.js +0 -4
  57. package/dist/kernel/driver/d1.js +0 -1
  58. package/dist/kernel/driver/postgres.js +2 -17
  59. package/dist/kernel/driver/transaction-context.js +0 -1
  60. package/dist/kernel/email.js +0 -4
  61. package/dist/kernel/host-access.js +2 -3
  62. package/dist/kernel/host-caller.d.ts +1 -1
  63. package/dist/kernel/host-caller.js +3 -6
  64. package/dist/kernel/json-body.js +0 -2
  65. package/dist/kernel/mcp-apps.js +0 -5
  66. package/dist/kernel/mcp-call.js +0 -1
  67. package/dist/kernel/mcp-handler.js +2 -26
  68. package/dist/kernel/mcp.js +0 -5
  69. package/dist/kernel/media.js +0 -18
  70. package/dist/kernel/paging.js +1 -9
  71. package/dist/kernel/publishing.js +3 -4
  72. package/dist/kernel/r2-media.js +0 -7
  73. package/dist/kernel/rest-dispatch.js +0 -1
  74. package/dist/kernel/rest-router.js +2 -3
  75. package/dist/kernel/rest.js +0 -3
  76. package/dist/kernel/scalars.js +1 -9
  77. package/dist/kernel/search.js +0 -1
  78. package/dist/kernel/site.js +0 -2
  79. package/dist/kernel/store.d.ts +11 -1
  80. package/dist/kernel/store.js +21 -34
  81. package/dist/kernel/surface-failure.d.ts +10 -0
  82. package/dist/kernel/surface-failure.js +29 -0
  83. package/dist/kernel/surface.js +0 -3
  84. package/dist/kernel/sweep.js +0 -1
  85. package/dist/kernel/translations.js +0 -1
  86. package/dist/kernel/user-caller.js +2 -8
  87. package/dist/kernel/user-fields.d.ts +3 -2
  88. package/dist/kernel/user-fields.js +9 -9
  89. package/dist/kernel/validation.js +0 -8
  90. package/dist/kernel/web-gateway.js +0 -2
  91. package/dist/kernel/web-head.js +0 -1
  92. package/dist/kernel/web-index.js +0 -1
  93. package/dist/kernel/web-response.js +0 -1
  94. package/dist/kernel/web-serve.js +97 -0
  95. package/dist/kernel/web.d.ts +1 -1
  96. package/dist/kernel/web.js +10 -91
  97. package/dist/kernel/where.js +0 -6
  98. package/dist/usecase/adminBindings.js +30 -21
  99. package/dist/usecase/adminClient.js +0 -21
  100. package/dist/usecase/adminDescriptors.js +2 -9
  101. package/dist/usecase/generate.js +7 -12
  102. package/dist/usecase/handlers.js +0 -3
  103. package/dist/usecase/mcp-media.js +0 -1
  104. package/dist/usecase/mcp.js +0 -4
  105. package/dist/usecase/paging.js +1 -3
  106. package/dist/usecase/publishing.js +0 -4
  107. package/dist/usecase/rest.js +0 -1
  108. package/dist/usecase/site.js +0 -1
  109. package/dist/usecase/stores.js +4 -14
  110. package/dist/usecase/tool-version.js +0 -3
  111. package/dist/usecase/translations.js +0 -2
  112. package/dist/usecase/validation.js +0 -20
  113. package/docs/examples/README.md +13 -5
  114. package/docs/examples/app-shell/README.md +38 -7
  115. package/docs/examples/app-shell/accept.mjs +91 -0
  116. package/docs/examples/app-shell/gitignore +7 -0
  117. package/docs/examples/app-shell/package.json +7 -6
  118. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  119. package/docs/examples/app-shell/src/routes.ts +6 -3
  120. package/docs/examples/app-shell/src/service.ts +2 -1
  121. package/docs/examples/club-events/README.md +7 -2
  122. package/docs/examples/club-events/manifests/club-events.yaml +15 -0
  123. package/docs/examples/club-events/smoke.mjs +7 -1
  124. package/docs/examples/purchasing/README.md +12 -3
  125. package/docs/examples/purchasing/manifests/purchasing.yaml +21 -4
  126. package/docs/examples/purchasing/smoke.mjs +14 -0
  127. package/docs/examples/recipes/README.md +5 -3
  128. package/docs/handbook/cloudflare/service-entry.md +3 -0
  129. package/docs/handbook/examples/hub.md +1 -1
  130. package/docs/handbook/guides/agent-setup.md +15 -10
  131. package/docs/handbook/guides/agent-workflow-acceptance.md +113 -103
  132. package/docs/handbook/guides/local-testing.md +40 -0
  133. package/docs/handbook/guides/native-admin.md +25 -6
  134. package/docs/handbook/guides/native-execution.md +5 -4
  135. package/docs/handbook/guides/public-pages.md +44 -4
  136. package/docs/handbook/guides/sql-patterns.md +266 -0
  137. package/docs/handbook/guides/typed-queries.md +138 -0
  138. package/docs/handbook/navigation.json +16 -0
  139. package/docs/handbook/reference/admin-descriptors.md +4 -1
  140. package/docs/handbook/reference/authorization.md +1 -1
  141. package/docs/handbook/reference/diagnostics.md +6 -1
  142. package/docs/handbook/reference/procedure.md +5 -5
  143. package/docs/handbook/reference/schema.md +10 -4
  144. package/docs/handbook/reference/sql.md +64 -0
  145. package/docs/handbook/reference/surface.md +5 -2
  146. package/docs/handbook/reference/view.md +11 -3
  147. package/docs/handbook/releases/index.md +27 -6
  148. package/docs/handbook/start/agents.md +45 -0
  149. package/docs/handbook/start/project-and-cli.md +6 -0
  150. package/docs/handbook/start/quickstart-worker.md +1 -1
  151. package/docs/skills/develop/SKILL.md +6 -5
  152. package/docs/skills/update/SKILL.md +2 -0
  153. package/package.json +23 -3
  154. package/skills/README.md +1 -1
  155. package/skills/mantle/SKILL.md +39 -120
  156. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  157. package/skills/mantle/references/local-testing.md +0 -20
  158. package/skills/mantle/references/web.md +0 -41
  159. package/skills/mantle/references/website.md +0 -51
@@ -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;
@@ -4,7 +4,6 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
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,23 +24,19 @@ 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
- /** The physical spelling of a path, including a nonexistent tail below its nearest existing ancestor. */
45
40
  const physical = (input) => {
46
41
  let found = resolve(input);
47
42
  const tail = [];
@@ -54,32 +49,23 @@ const physical = (input) => {
54
49
  }
55
50
  return resolve(realpathSync(found), ...tail);
56
51
  };
57
- /** Whether `inner` is `outer` or inside it. */
58
52
  const within = (outer, inner) => {
59
53
  const path = relative(physical(outer), physical(inner));
60
54
  return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
61
55
  };
62
- /** A directory a build empties must not be the working directory or hold the source entry. */
63
56
  function assertCleanable(label, dir, entry) {
64
57
  if (physical(dir) === physical(process.cwd()))
65
58
  throw new Error(`${label} must not be the working directory: it is cleaned before building.`);
66
59
  if (within(dir, entry))
67
60
  throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
68
61
  }
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
- */
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;
74
63
  export async function bundleApp(entry, outdir, assets = join(outdir, "client")) {
75
64
  const source = realpathSync(entry);
76
- // what is cleaned lies under these two, so neither may be the working directory or hold the source
77
65
  assertCleanable("Bundle outdir", outdir, source);
78
66
  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" });
67
+ const own = await ownModules(entry);
81
68
  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
69
  if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
84
70
  await bundleAdmin(source, outdir, join(assets, "admin"));
85
71
  else
@@ -87,7 +73,6 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"))
87
73
  return;
88
74
  }
89
75
  const worker = join(outdir, "worker");
90
- // Wrangler uploads every module under the Worker directory and every file under the assets directory.
91
76
  if (within(worker, assets) || within(assets, worker))
92
77
  throw new Error(`The assets directory ${assets} must not overlap the Worker directory ${worker}.`);
93
78
  const metafile = await bundle(entry, worker);
@@ -95,12 +80,6 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"))
95
80
  await bundleAdmin(source, outdir, join(assets, "admin"));
96
81
  }
97
82
  }
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
83
  async function bundleAdmin(entry, outdir, out) {
105
84
  const source = join(dirname(entry), "admin.tsx"), custom = existsSync(source);
106
85
  const mcpSource = join(dirname(entry), "admin.mcp.tsx"), customMcp = existsSync(mcpSource);
@@ -114,7 +93,6 @@ async function bundleAdmin(entry, outdir, out) {
114
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.`);
115
94
  }
116
95
  const stamp = join(outdir, ".admin-inputs");
117
- // the installed path too: a patched or linked Admin keeps its version but not its path
118
96
  const manifest = realpathSync(lookup(require, "@aotter/mantle-admin") ?? require.resolve("@aotter/mantle-admin/package.json"));
119
97
  const key = custom || customMcp ? undefined : JSON.stringify({ out: resolve(out), path: dirname(manifest), packages: installed(manifest) });
120
98
  if (key && existsSync(join(out, "index.html")) && existsSync(join(out, "mcp-app.html")) && existsSync(stamp) && readFileSync(stamp, "utf8") === key) {
@@ -127,9 +105,7 @@ async function bundleAdmin(entry, outdir, out) {
127
105
  if (key)
128
106
  writeFileSync(stamp, key);
129
107
  }
130
- /** The package.json a bare import of `name` finds, read from disk (`exports` may hide it, and require caches realpaths). */
131
108
  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
109
  function installed(manifest) {
134
110
  const { name, version, dependencies = {}, peerDependencies = {} } = JSON.parse(readFileSync(manifest, "utf8"));
135
111
  const require = createRequire(manifest);
@@ -140,7 +116,6 @@ function installed(manifest) {
140
116
  }
141
117
  return versions;
142
118
  }
143
- /** The generated directories among a build's inputs, refusing stale ones. */
144
119
  function generatedDirs(metafile) {
145
120
  const dirs = [...new Set(Object.keys(metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))))];
146
121
  for (const dir of dirs) {
@@ -163,15 +138,12 @@ export async function bundle(entry, outdir) {
163
138
  if (args.pluginData?.optionalPeer)
164
139
  return;
165
140
  const resolved = await builder.resolve(args.path, { importer: args.importer, resolveDir: args.resolveDir, kind: args.kind, pluginData: { optionalPeer: true } });
166
- // Unselected request paths may reference absent peers; installed peers still bundle normally.
167
141
  return resolved.errors.length ? { path: args.path, external: true } : resolved;
168
142
  });
169
143
  } }],
170
144
  });
171
145
  if (Object.keys(result.metafile.inputs).some(input => /(?:^|\/)mantle\/(?:src|dist)\/testing\//.test(resolve(input).replaceAll("\\", "/"))))
172
146
  throw new Error("@aotter/mantle/testing is only for tests and cannot be bundled for production.");
173
- // A build is what `wrangler dev` and deploys run: generated code must match the manifests it came from.
174
- // Any bundled module from a generated directory finds its fingerprint (type-only imports leave generated.ts out).
175
147
  generatedDirs(result.metafile);
176
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")]);
177
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) => {
@@ -113,8 +105,6 @@ export async function generateFromSources(request, schema) {
113
105
  const refusals = [], paging = {};
114
106
  const lower = (boundary) => lowerPlan(compiled.plan, dialect, {
115
107
  pageQuery(ast, name) {
116
- // Core visits public/caller before trusted. Prove the real caller equality prefixes;
117
- // a trusted all-scope read does not acquire a fictional scope predicate.
118
108
  if (!Object.hasOwn(paging, name))
119
109
  paging[name] = provePaging(compiled.plan, ast);
120
110
  return ast;
@@ -170,7 +160,10 @@ export async function generateFromSources(request, schema) {
170
160
  return { ok: false, diagnostics: [...refusals, ...diagnostics] };
171
161
  }
172
162
  if (!result.ok)
173
- 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
+ }) };
174
167
  const accessTargets = Object.fromEntries(report.targets.map(({ _meta: m }) => {
175
168
  const policy = ({ opaque, requires }) => ({ opaque, ...(requires.guard ? { guard: requires.guard } : {}) });
176
169
  return [m.id, { targetHash: m.targetHash, ...policy(m), ...(m.variants.length ? { variants: m.variants.map(v => ({ id: v.id, ...policy(v) })) } : {}) }];
@@ -1,7 +1,6 @@
1
1
  import { generateFromSources as generate } from "./generate.js";
2
2
  import { admin, anonymous, jwt, phoneNumber, siwe, twoFactor } from "better-auth/plugins";
3
3
  import { authSchemaSql } from "./authSchema.js";
4
- /** One or more Better Auth instances (each with its own table names); their DDL is merged. */
5
4
  export function generateFromSources(request) {
6
5
  const instances = request.auth ? [request.auth].flat() : [];
7
6
  return generate(request, instances.length ? () => authSql(instances, request.target.endsWith("postgres") ? "postgres" : "sqlite") : undefined);
@@ -9,29 +8,23 @@ export function generateFromSources(request) {
9
8
  function authSql(instances, dialect) {
10
9
  for (const options of instances) {
11
10
  const ids = (options.plugins ?? []).map(plugin => plugin.id);
12
- // Offline OAuth grants outlive sessions; without the revocation plugin a password reset or ban would not end them. mcp() spreads oauthProvider, id included.
13
- 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") )
14
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.");
15
13
  }
16
14
  if (instances.length > 1)
17
15
  refuseSharedPluginMappings(instances);
18
16
  const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
19
- // CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
20
- // Identical statements are already merged, so a repeated table name means two different definitions.
21
17
  const tables = statements.map(statement => /^CREATE TABLE IF NOT EXISTS ("(?:[^"]|"")+")/.exec(statement)?.[1]).filter(Boolean);
22
18
  const twice = tables.find((table, i) => tables.indexOf(table) !== i);
23
19
  if (twice)
24
20
  throw new Error(`Better Auth instances define table ${twice} differently; give each instance its own table names (user.modelName, session.modelName, ...).`);
25
21
  return statements;
26
22
  }
27
- // Plugins whose factory hands back one module-level schema object (mergeSchema writes `schema` options into it); no options leaves it untouched.
28
23
  const sharedSchema = {
29
24
  jwt: () => jwt().schema, admin: () => admin().schema, anonymous: () => anonymous().schema,
30
25
  "phone-number": () => phoneNumber({}).schema, siwe: () => siwe({}).schema, "two-factor": () => twoFactor().schema,
31
26
  };
32
- // Plugins that share field objects (device-authorization) or copy per call (oauth-provider, listed on request) are only checked for equal resolved names.
33
27
  const checkedPlugins = new Set([...Object.keys(sharedSchema), "device-authorization", "oauth-provider"]);
34
- /** Names a plugin's tables and fields resolve to, keyed `table` / `table.field`; only mappings that differ from the key are listed. */
35
28
  function mapped(schema) {
36
29
  const names = new Map();
37
30
  for (const [table, model] of Object.entries(schema)) {
@@ -45,7 +38,6 @@ function mapped(schema) {
45
38
  }
46
39
  return names;
47
40
  }
48
- /** Better Auth plugin factories share their schema object, so differing plugin field/model mappings across instances in one Worker override each other. */
49
41
  function refuseSharedPluginMappings(instances) {
50
42
  const byPlugin = new Map();
51
43
  instances.forEach((options, index) => (options.plugins ?? []).forEach(plugin => {
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
5
+ export function docsRoot() {
6
+ const root = [resolve(packageRoot, "docs"), resolve(packageRoot, "../../docs")].find(path => existsSync(resolve(path, "handbook/navigation.json")));
7
+ if (!root)
8
+ throw new Error(`Mantle docs are missing from ${packageRoot}; reinstall @aotter/mantle.`);
9
+ return root;
10
+ }
11
+ export function topics(root = docsRoot()) {
12
+ const nav = JSON.parse(readFileSync(resolve(root, "handbook/navigation.json"), "utf8"));
13
+ const pages = nav.groups.flatMap(group => group.items.map(item => ({ name: item.link.slice(1), title: item.text, file: resolve(root, `handbook${item.link}.md`), group: group.text })));
14
+ const listed = new Set(pages.map(page => page.file));
15
+ const unlisted = readdirSync(resolve(root, "handbook"), { recursive: true }).filter(path => path.endsWith(".md")).sort()
16
+ .map(path => resolve(root, "handbook", path)).filter(file => !listed.has(file))
17
+ .map(file => ({ name: relative(resolve(root, "handbook"), file).replaceAll("\\", "/").slice(0, -3), title: readFileSync(file, "utf8").match(/^# (.+)$/m)?.[1] ?? basename(file), file, group: "More pages" }));
18
+ const skills = (existsSync(resolve(root, "skills")) ? readdirSync(resolve(root, "skills")) : []).filter(name => existsSync(resolve(root, "skills", name, "SKILL.md")))
19
+ .map(name => ({ name: `skills/${name}`, title: `Package skill: ${name}`, file: resolve(root, "skills", name, "SKILL.md"), group: "Package skills" }));
20
+ const examples = resolve(root, "examples");
21
+ const shipped = readdirSync(examples, { withFileTypes: true }).filter(entry => entry.isDirectory() && existsSync(resolve(examples, entry.name, "README.md")));
22
+ return [...pages, ...unlisted, ...skills, { name: "examples", title: "Examples and how to combine them", file: resolve(examples, "README.md"), group: "Examples" },
23
+ ...shipped.map(entry => ({ name: `examples/${entry.name}`, title: `Example: ${entry.name}`, file: resolve(examples, entry.name, "README.md"), group: "Examples" }))];
24
+ }
25
+ function find(all, name) {
26
+ const exact = all.find(topic => topic.name === name);
27
+ if (exact)
28
+ return exact;
29
+ const tail = all.filter(topic => basename(topic.name) === name);
30
+ return tail.length === 1 ? tail[0] : undefined;
31
+ }
32
+ export function help(name, root = docsRoot()) {
33
+ const all = topics(root);
34
+ const topic = find(all, name ?? "start/agents");
35
+ if (!topic) {
36
+ const near = all.filter(t => name && t.name.includes(name)).map(t => ` mantle help ${t.name}`);
37
+ throw new Error(`No help topic ${JSON.stringify(name)}.${near.length ? `\nDid you mean:\n${near.join("\n")}` : ""}\nRun mantle help topics for the list.`);
38
+ }
39
+ const text = readFileSync(topic.file, "utf8").replace(/^---\n[\s\S]*?\n---\n/, "").trim();
40
+ const linked = [...new Set([...text.matchAll(/\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/g)].map(match => resolve(dirname(topic.file), match[1])))]
41
+ .flatMap(file => all.filter(t => t.file === file && t !== topic));
42
+ const index = all.indexOf(topic), next = all[index + 1]?.group === topic.group ? all[index + 1] : undefined;
43
+ const lines = [text, "", `Source: ${relative(process.cwd(), topic.file) || topic.file}`];
44
+ if (next)
45
+ lines.push(`Next: mantle help ${next.name} (${next.title})`);
46
+ if (linked.length)
47
+ lines.push("Linked:", ...linked.map(t => ` mantle help ${t.name} (${t.title})`));
48
+ if (!name)
49
+ lines.push("", topicList(all));
50
+ return lines.join("\n");
51
+ }
52
+ export function topicList(all = topics()) {
53
+ const groups = [...new Set(all.map(topic => topic.group))];
54
+ return ["Topics:", ...groups.flatMap(group => [` ${group}`, ...all.filter(t => t.group === group).map(t => ` ${t.name.padEnd(34)} ${t.title}`)])].join("\n");
55
+ }