@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.
- package/README.md +9 -3
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +3 -6896
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +10 -7
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +2 -30
- package/dist/infrastructure/cli.js +25 -11
- package/dist/infrastructure/generate.js +9 -16
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +16 -4
- package/dist/kernel/binds.js +4 -10
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +6 -10
- package/dist/kernel/contracts.js +5 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +0 -4
- package/dist/kernel/driver/d1.js +0 -1
- package/dist/kernel/driver/postgres.js +2 -17
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +2 -26
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +2 -3
- package/dist/kernel/rest.js +0 -3
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +11 -1
- package/dist/kernel/store.js +21 -34
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +30 -21
- package/dist/usecase/adminClient.js +0 -21
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +7 -12
- package/dist/usecase/handlers.js +0 -3
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +0 -4
- package/dist/usecase/rest.js +0 -1
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +4 -14
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +38 -7
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +7 -6
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +15 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +21 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/service-entry.md +3 -0
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/agent-setup.md +15 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +113 -103
- package/docs/handbook/guides/local-testing.md +40 -0
- package/docs/handbook/guides/native-admin.md +25 -6
- package/docs/handbook/guides/native-execution.md +5 -4
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/sql-patterns.md +266 -0
- package/docs/handbook/guides/typed-queries.md +138 -0
- package/docs/handbook/navigation.json +16 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/diagnostics.md +6 -1
- package/docs/handbook/reference/procedure.md +5 -5
- package/docs/handbook/reference/schema.md +10 -4
- package/docs/handbook/reference/sql.md +64 -0
- package/docs/handbook/reference/surface.md +5 -2
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +27 -6
- package/docs/handbook/start/agents.md +45 -0
- package/docs/handbook/start/project-and-cli.md +6 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +6 -5
- package/docs/skills/update/SKILL.md +2 -0
- package/package.json +23 -3
- package/skills/README.md +1 -1
- package/skills/mantle/SKILL.md +39 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- 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.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
first.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
marker
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
node.ParamRef
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
89
|
-
console.log(`Ref
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|