@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1
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 +15 -5
- 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 +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- 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-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- 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 +27 -8
- 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 +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- 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 +34 -7
- package/dist/kernel/binds.js +5 -11
- 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 +13 -10
- package/dist/kernel/contracts.js +14 -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 +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- 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 +4 -27
- 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 +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- 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 +26 -2
- package/dist/kernel/store.js +69 -49
- 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 +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- 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 +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- 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 +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- 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 +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { forwarded, wire, ContractError } from "./contracts.js";
|
|
2
|
+
import { decode } from "./surface.js";
|
|
3
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
4
|
+
const notFound = () => new Response(null, { status: 404, headers: NO_STORE });
|
|
5
|
+
export function serve(options, views, pages, query) {
|
|
6
|
+
const origin = new URL(options.origin);
|
|
7
|
+
const context = async (request, env, ctx) => ({ db: options.db, caller: forwarded(ctx, options.gateway)
|
|
8
|
+
? (await import("./host-caller.js")).gatewayCaller(ctx, options.gateway, options.fields)
|
|
9
|
+
: typeof options.caller === "function" ? await options.caller(request, env, { readOnly: ["GET", "HEAD"].includes(request.method) }) : options.caller ?? { kind: "anonymous" } });
|
|
10
|
+
const urlFor = (path, row, view) => origin.origin + path.replace(/\{([^{}]+)\}/g, (_, key) => {
|
|
11
|
+
let value = row[key];
|
|
12
|
+
if (views.get(view).booleans?.includes(key) && (value === 0 || value === 1))
|
|
13
|
+
value = Boolean(value);
|
|
14
|
+
if (value === null || value === undefined)
|
|
15
|
+
throw new TypeError("List View must return every page parameter: " + key);
|
|
16
|
+
const segment = String(value);
|
|
17
|
+
if (segment === "." || segment === "..")
|
|
18
|
+
return "";
|
|
19
|
+
return encodeURIComponent(segment).replace(/[()]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
20
|
+
});
|
|
21
|
+
const validUrl = (path, row, view) => { if ([...path.matchAll(/\{([^{}]+)\}/g)].some(([, key]) => row[key] === "." || row[key] === ".."))
|
|
22
|
+
return; return urlFor(path, row, view); };
|
|
23
|
+
const match = (path) => {
|
|
24
|
+
for (const entry of pages) {
|
|
25
|
+
const hit = entry.pattern.exec(path);
|
|
26
|
+
if (!hit)
|
|
27
|
+
continue;
|
|
28
|
+
return { ...entry, params: new URLSearchParams(decode(entry.keys, hit, "Malformed URL parameter.", "web/path")) };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const read = async (entry, ctx) => (await query(entry.page.view, ctx.db, ctx.caller, entry.params)).rows[0];
|
|
32
|
+
const response = async (request, body, type, caller) => {
|
|
33
|
+
const { webResponse } = await import('./web-response.js');
|
|
34
|
+
return webResponse(request, body, type, caller.kind === "anonymous" ? (options.cacheControl ?? "public, max-age=60") : "private, no-store");
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
async fetch(request, env, execution) {
|
|
38
|
+
let caller;
|
|
39
|
+
try {
|
|
40
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
41
|
+
return;
|
|
42
|
+
const path = new URL(request.url).pathname, index = path === "/llms.txt" || path === "/sitemap.xml";
|
|
43
|
+
const entry = !index && path.endsWith(".md") ? match(path.slice(0, -3)) : undefined, markdown = entry?.page.markdown;
|
|
44
|
+
if (!index && !markdown)
|
|
45
|
+
return;
|
|
46
|
+
const ctx = await context(request, env, execution);
|
|
47
|
+
caller = ctx.caller;
|
|
48
|
+
if (index) {
|
|
49
|
+
const sitemap = path === "/sitemap.xml";
|
|
50
|
+
const body = await (await import("./web-index.js")).webIndex(sitemap, options, pages, (list, cursor) => query(list, ctx.db, ctx.caller, new URLSearchParams(), cursor), validUrl);
|
|
51
|
+
return response(request, body, sitemap ? "application/xml" : "text/plain", ctx.caller);
|
|
52
|
+
}
|
|
53
|
+
const exact = match(path);
|
|
54
|
+
if (exact)
|
|
55
|
+
try {
|
|
56
|
+
if (await read(exact, ctx))
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (wire(error).status !== 400)
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const row = await read(entry, ctx);
|
|
64
|
+
if (!row)
|
|
65
|
+
return notFound();
|
|
66
|
+
const body = row[markdown];
|
|
67
|
+
if (body == null)
|
|
68
|
+
return notFound();
|
|
69
|
+
if (typeof body !== "string")
|
|
70
|
+
throw new TypeError("Markdown field must return text.");
|
|
71
|
+
return response(request, body, "text/markdown", ctx.caller);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return (await import("./host-caller.js")).hostFailure(error, caller, "web");
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async head(request, env, execution) {
|
|
78
|
+
try {
|
|
79
|
+
const path = new URL(request.url).pathname, entry = match(path);
|
|
80
|
+
if (!entry)
|
|
81
|
+
return "";
|
|
82
|
+
const ctx = await context(request, env, execution), row = await read(entry, ctx);
|
|
83
|
+
if (!row)
|
|
84
|
+
return "";
|
|
85
|
+
const canonical = validUrl(entry.path, row, entry.page.view);
|
|
86
|
+
if (!canonical)
|
|
87
|
+
return "";
|
|
88
|
+
return (await import("./web-head.js")).headTags(canonical, row, !!entry.page.markdown, options.locales);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof ContractError && error.code === "HOST_CONTRACT")
|
|
92
|
+
throw error;
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/kernel/web.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Web output for a generated public View table: /llms.txt, /sitemap.xml, Markdown pages and head tags. SQL owns visibility. */
|
|
2
|
-
import {
|
|
2
|
+
import type { Caller, Gateway, RequestContext } from "./contracts.js";
|
|
3
3
|
import type { UserFields } from "./user-fields.js";
|
|
4
4
|
import { type Conversions } from "./surface.js";
|
|
5
5
|
/** An app's public Views: row type and input keys per View, and the Views a list may name (input that accepts no fields). */
|
package/dist/kernel/web.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { forwarded, wire, ContractError } from "./contracts.js";
|
|
3
|
-
import { convert, decode } from "./surface.js";
|
|
4
|
-
const NO_STORE = { "cache-control": "no-store" };
|
|
5
|
-
const notFound = () => new Response(null, { status: 404, headers: NO_STORE });
|
|
1
|
+
import { convert } from "./surface.js";
|
|
6
2
|
export function web(options, table) {
|
|
7
3
|
const views = new Map(table);
|
|
8
4
|
const query = (name, db, caller, params, cursor) => {
|
|
@@ -23,35 +19,6 @@ export function web(options, table) {
|
|
|
23
19
|
const pattern = path.split(/(\{[^{}]+\})/).map(part => part.startsWith("{") ? (keys.push(part.slice(1, -1)), "([^/]+)") : part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("");
|
|
24
20
|
return { path, page, keys, pattern: new RegExp("^" + pattern + "$") };
|
|
25
21
|
}).sort((a, b) => a.keys.length - b.keys.length);
|
|
26
|
-
const context = async (request, env, ctx) => ({ db: options.db, caller: forwarded(ctx, options.gateway)
|
|
27
|
-
? (await import("./host-caller.js")).gatewayCaller(ctx, options.gateway, options.fields)
|
|
28
|
-
: typeof options.caller === "function" ? await options.caller(request, env, { readOnly: ["GET", "HEAD"].includes(request.method) }) : options.caller ?? { kind: "anonymous" } });
|
|
29
|
-
const urlFor = (path, row, view) => origin.origin + path.replace(/\{([^{}]+)\}/g, (_, key) => {
|
|
30
|
-
let value = row[key];
|
|
31
|
-
if (views.get(view).booleans?.includes(key) && (value === 0 || value === 1))
|
|
32
|
-
value = Boolean(value);
|
|
33
|
-
if (value === null || value === undefined)
|
|
34
|
-
throw new TypeError("List View must return every page parameter: " + key);
|
|
35
|
-
const segment = String(value);
|
|
36
|
-
if (segment === "." || segment === "..")
|
|
37
|
-
return "";
|
|
38
|
-
return encodeURIComponent(segment).replace(/[()]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
39
|
-
});
|
|
40
|
-
const validUrl = (path, row, view) => { if ([...path.matchAll(/\{([^{}]+)\}/g)].some(([, key]) => row[key] === "." || row[key] === ".."))
|
|
41
|
-
return; return urlFor(path, row, view); };
|
|
42
|
-
const match = (path) => {
|
|
43
|
-
for (const entry of pages) {
|
|
44
|
-
const hit = entry.pattern.exec(path);
|
|
45
|
-
if (!hit)
|
|
46
|
-
continue;
|
|
47
|
-
return { ...entry, params: new URLSearchParams(decode(entry.keys, hit, "Malformed URL parameter.", "web/path")) };
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
const read = async (entry, ctx) => (await query(entry.page.view, ctx.db, ctx.caller, entry.params)).rows[0];
|
|
51
|
-
const response = async (request, body, type, caller) => {
|
|
52
|
-
const { webResponse } = await import('./web-response.js');
|
|
53
|
-
return webResponse(request, body, type, caller.kind === "anonymous" ? (options.cacheControl ?? "public, max-age=60") : "private, no-store");
|
|
54
|
-
};
|
|
55
22
|
if (options.gateway || options.authorize) {
|
|
56
23
|
let handle;
|
|
57
24
|
const load = async () => handle ??= (await import("./web-gateway.js")).gatewayWeb(options, table);
|
|
@@ -60,65 +27,17 @@ export function web(options, table) {
|
|
|
60
27
|
head: async (request, env, ctx) => (await load()).head(request, env, ctx),
|
|
61
28
|
};
|
|
62
29
|
}
|
|
30
|
+
let served;
|
|
31
|
+
const serve = async () => served ??= (await import("./web-serve.js")).serve(options, views, pages, query);
|
|
63
32
|
return {
|
|
64
33
|
async fetch(request, env, execution) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!index && !markdown)
|
|
72
|
-
return;
|
|
73
|
-
const ctx = await context(request, env, execution);
|
|
74
|
-
caller = ctx.caller;
|
|
75
|
-
if (index) {
|
|
76
|
-
const sitemap = path === "/sitemap.xml";
|
|
77
|
-
const body = await (await import("./web-index.js")).webIndex(sitemap, options, pages, (list, cursor) => query(list, ctx.db, ctx.caller, new URLSearchParams(), cursor), validUrl);
|
|
78
|
-
return response(request, body, sitemap ? "application/xml" : "text/plain", ctx.caller);
|
|
79
|
-
}
|
|
80
|
-
const exact = match(path);
|
|
81
|
-
if (exact)
|
|
82
|
-
try {
|
|
83
|
-
if (await read(exact, ctx))
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
if (wire(error).status !== 400)
|
|
88
|
-
throw error;
|
|
89
|
-
}
|
|
90
|
-
const row = await read(entry, ctx);
|
|
91
|
-
if (!row)
|
|
92
|
-
return notFound();
|
|
93
|
-
const body = row[markdown];
|
|
94
|
-
if (body == null)
|
|
95
|
-
return notFound();
|
|
96
|
-
if (typeof body !== "string")
|
|
97
|
-
throw new TypeError("Markdown field must return text.");
|
|
98
|
-
return response(request, body, "text/markdown", ctx.caller);
|
|
99
|
-
}
|
|
100
|
-
catch (error) {
|
|
101
|
-
return (await import("./host-caller.js")).hostFailure(error, caller, "web");
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
async head(request, env, execution) {
|
|
105
|
-
try {
|
|
106
|
-
const path = new URL(request.url).pathname, entry = match(path);
|
|
107
|
-
if (!entry)
|
|
108
|
-
return "";
|
|
109
|
-
const ctx = await context(request, env, execution), row = await read(entry, ctx);
|
|
110
|
-
if (!row)
|
|
111
|
-
return "";
|
|
112
|
-
const canonical = validUrl(entry.path, row, entry.page.view);
|
|
113
|
-
if (!canonical)
|
|
114
|
-
return "";
|
|
115
|
-
return (await import("./web-head.js")).headTags(canonical, row, !!entry.page.markdown, options.locales);
|
|
116
|
-
}
|
|
117
|
-
catch (error) {
|
|
118
|
-
if (error instanceof ContractError && error.code === "HOST_CONTRACT")
|
|
119
|
-
throw error;
|
|
120
|
-
return "";
|
|
121
|
-
}
|
|
34
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
35
|
+
return;
|
|
36
|
+
const path = new URL(request.url).pathname;
|
|
37
|
+
if (path !== "/llms.txt" && path !== "/sitemap.xml" && !path.endsWith(".md"))
|
|
38
|
+
return;
|
|
39
|
+
return (served ?? await serve()).fetch(request, env, execution);
|
|
122
40
|
},
|
|
41
|
+
head: async (request, env, execution) => (served ?? await serve()).head(request, env, execution),
|
|
123
42
|
};
|
|
124
43
|
}
|
package/dist/kernel/where.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
const COMPARE = { gt: ">", gte: ">=", lt: "<", lte: "<=" };
|
|
2
|
-
/**
|
|
3
|
-
* `column` is already quoted. `bind` registers one parameter and returns its placeholder. `insensitive` folds both sides of
|
|
4
|
-
* eq/ne/in/notIn with LOWER (text only) and picks the dialect's case-insensitive LIKE. A null eq/ne is IS [NOT] NULL.
|
|
5
|
-
* `like` takes a finished pattern (`escapeLike` the literal parts).
|
|
6
|
-
* @internal
|
|
7
|
-
*/
|
|
8
2
|
export function condition(d, column, operator, value, bind, insensitive = false) {
|
|
9
3
|
const lhs = insensitive ? `LOWER(${column})` : column;
|
|
10
4
|
const rhs = (v) => insensitive ? `LOWER(${bind(v)})` : bind(v);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { userReferences } from "../domain/userReferences.js";
|
|
2
|
-
/** Selected application bindings: compiled checks/native SQL; only the app depends on Admin adapters. */
|
|
3
2
|
import { isPrimaryCollection } from "../build/compiler.js";
|
|
4
3
|
import { enumOptions } from "../build/compiler.js";
|
|
5
4
|
import { databaseImport, isSqlite, literal as q } from "../domain/entry.js";
|
|
@@ -14,7 +13,6 @@ const wire = (fields, sqlite) => {
|
|
|
14
13
|
const geo = fields.filter(([, type]) => type === "geo").map(([field]) => `row[${q(field)}]=row[${q(field + "_lat")}] == null || row[${q(field + "_lng")}] == null ? null : {lat:row[${q(field + "_lat")}],lng:row[${q(field + "_lng")}]};delete row[${q(field + "_lat")}];delete row[${q(field + "_lng")}];`);
|
|
15
14
|
return `(raw:Row):Row=>{const row=${columns.length ? `wireRows([raw],${q(columns)})[0]!` : geo.length ? "{...raw}" : "raw"};${geo.join("")}return row;}`;
|
|
16
15
|
};
|
|
17
|
-
/** Core's titled string choices are enum checks; constrained or general oneOf remains refused. */
|
|
18
16
|
function adminProperty(property) {
|
|
19
17
|
const choices = enumOptions(property);
|
|
20
18
|
if (!property.oneOf || property.enum || !choices || !property.oneOf.every(branch => Object.entries(branch).every(([key, value]) => key === "const" || key === "title" || key === "type" && value === "string")) || new Set(choices.map(c => c.value)).size !== choices.length)
|
|
@@ -30,19 +28,31 @@ const allowed = (requires, path) => {
|
|
|
30
28
|
const lines = authLines(requires, path);
|
|
31
29
|
return lines.length ? `(caller:Caller)=>{try{${lines.join("")}return true;}catch{return false;}}` : "(_caller:Caller)=>true";
|
|
32
30
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
function sqlConstants(values) {
|
|
32
|
+
const counts = new Map(), seen = new Map(), pool = new Map(), constants = [];
|
|
33
|
+
let named = 0;
|
|
34
|
+
const key = (v) => typeof v === "string" ? v.length > 80 ? q(v) : undefined : v && typeof v === "object" ? (text => text.length > 80 ? text : undefined)(q(v)) : undefined;
|
|
35
|
+
const children = (v) => v && typeof v === "object" ? Object.values(v) : [];
|
|
36
|
+
const tally = (v, by = 1) => { const k = key(v); if (k) {
|
|
37
|
+
counts.set(k, (counts.get(k) ?? 0) + by);
|
|
38
|
+
seen.set(k, v);
|
|
39
|
+
} children(v).forEach(child => tally(child, by)); };
|
|
40
|
+
values.forEach(value => children(value).forEach(child => tally(child)));
|
|
41
|
+
for (const [k, v] of [...seen].sort(([a], [b]) => b.length - a.length)) {
|
|
42
|
+
const copies = counts.get(k);
|
|
43
|
+
if (copies > 1) {
|
|
44
|
+
pool.set(k, "");
|
|
45
|
+
children(v).forEach(child => tally(child, 1 - copies));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const print = (v) => Array.isArray(v) ? "[" + v.map(reference).join(",") + "]" : v && typeof v === "object" ? "{" + Object.entries(v).map(([name, child]) => `${name === "__proto__" ? `[${q(name)}]` : q(name)}:${reference(child)}`).join(",") + "}" : q(v);
|
|
49
|
+
const reference = (v) => { const k = key(v); if (!k || pool.get(k) === undefined)
|
|
50
|
+
return print(v); if (pool.get(k) === "") {
|
|
51
|
+
const name = `sql${named++}`;
|
|
52
|
+
pool.set(k, name);
|
|
53
|
+
constants.push(`const ${name}=${print(v)}${typeof v === "string" ? "" : " as const"};`);
|
|
54
|
+
} return pool.get(k); };
|
|
55
|
+
return { code: values.map(print), constants };
|
|
46
56
|
}
|
|
47
57
|
export function emitAdminBindings(input, refuse) {
|
|
48
58
|
if (!input.admin)
|
|
@@ -54,9 +64,7 @@ export function emitAdminBindings(input, refuse) {
|
|
|
54
64
|
const schemas = [], views = [], adminViews = [], operations = [], pageSpecs = [];
|
|
55
65
|
for (const [n, s] of Object.values(plan.schemas).entries()) {
|
|
56
66
|
const schemaPath = `schemas/${s.name}/admin`, declared = Object.keys(s.fields).filter(f => f !== s.scope), writable = declared.filter(f => scalar(s.fields[f]) || s.fields[f] === "json");
|
|
57
|
-
// readOnly properties are server-maintained: values() pins them to their default, and they have no Admin UPDATE.
|
|
58
67
|
const server = Object.fromEntries(writable.filter(f => serverOwned(s.schema, f)).map(f => [f, Object.entries(s.schema.properties ?? {}).find(([k]) => k.toLowerCase() === f)?.[1]?.default ?? null]));
|
|
59
|
-
// Experimental geo collections remain readable; edits require a declared Procedure.
|
|
60
68
|
const validators = emitValidation({ views: {}, procedures: { full: { input: checks(s, true) }, partial: { input: checks(s, false) } } }, (path, message) => refuse(schemaPath + "/" + path, message));
|
|
61
69
|
for (const mode of ["full", "partial"]) {
|
|
62
70
|
const name = `validation/admin/${encodeURIComponent(s.name)}-${mode}.ts`;
|
|
@@ -85,9 +93,9 @@ export function emitAdminBindings(input, refuse) {
|
|
|
85
93
|
const defaults = Object.fromEntries(Object.entries(checks(s, true).properties ?? {}).flatMap(([key, p]) => p.default !== undefined ? [[key, p.default]] : []));
|
|
86
94
|
const primary = isPrimaryCollection(s, Object.values(plan.schemas));
|
|
87
95
|
const schemaCode = `{
|
|
88
|
-
name:${q(s.name)},primary:${primary},descriptor:allCollections[${q(s.name)}],publishing:${!!s.publishing},readOnly:${s.schema.readOnly === true || declared.some(f => s.fields[f] === "geo")},fields:names${n},defaults:${q(defaults)},
|
|
96
|
+
name:${q(s.name)},primary:${primary},descriptor:allCollections[${q(s.name)}],publishing:${!!s.publishing},${s.liveEdits ? "liveEdits:true," : ""}readOnly:${s.schema.readOnly === true || declared.some(f => s.fields[f] === "geo")},fields:names${n},defaults:${q(defaults)},
|
|
89
97
|
binding:${entrySpec},
|
|
90
|
-
query:query${n},validate:(data,full)=>{const checked=Object.fromEntries(Object.entries(data).filter(([key,v])=>v!==null||${q(s.schema.required ?? [])}.includes(key)));(full?full${n}:partial${n})(checked);${sqlite ? "" : "nativeText(data);"}${yearZero}return data;},
|
|
98
|
+
query:query${n},validate:(data,full)=>{const checked=Object.fromEntries(Object.entries(data).filter(([key,v])=>v!==null||${s.publishing ? "full&&" : ""}${q(s.schema.required ?? [])}.includes(key)));(full?full${n}:partial${n})(checked);${sqlite ? "" : "nativeText(data);"}${yearZero}return data;},
|
|
91
99
|
values:values${n},row:${named},
|
|
92
100
|
${sqlite ? "" : "native:nativeText,"}
|
|
93
101
|
${s.publishing ? `transition:async(db,caller,verb,id,expectedVersion)=>{const run=await ({publish:()=>import("./procedures/${encodeURIComponent(s.name + ".publish")}.js"),unpublish:()=>import("./procedures/${encodeURIComponent(s.name + ".unpublish")}.js"),archive:()=>import("./procedures/${encodeURIComponent(s.name + ".archive")}.js")})[verb]();const version=wireRows([{version:expectedVersion}],[["version","int"]])[0]!.version;return run.default(db,caller,{id,expectedVersion:version} as Parameters<typeof run.default>[2]);},` : ""}
|
|
@@ -104,7 +112,8 @@ export function emitAdminBindings(input, refuse) {
|
|
|
104
112
|
imports.push(`const viewNames${n}=${q(rename)} as const;`);
|
|
105
113
|
const pageData = input.adminPages?.[name];
|
|
106
114
|
if (!pageData) {
|
|
107
|
-
|
|
115
|
+
if (!input.adminPages)
|
|
116
|
+
refuse(`views/${name}/admin`, "Admin list was not generated: no compiler-authorized matching pages were supplied for this View.");
|
|
108
117
|
continue;
|
|
109
118
|
}
|
|
110
119
|
const kinds = (v.uiSchema?.list ?? {});
|
|
@@ -121,7 +130,7 @@ export function emitAdminBindings(input, refuse) {
|
|
|
121
130
|
const filterReads = Object.entries(filters).map(([field, s]) => { const type = [s.type].flat().find(t => t && t !== "null"); return `if(params.has(${q("filter." + field)})&¶ms.get(${q("filter." + field)})!=="")filters[${q(field)}]=${type === "boolean" ? `params.get(${q("filter." + field)})==="true"?true:params.get(${q("filter." + field)})==="false"?false:invalid("Expected true or false.",${q("views/" + name)})` : ["number", "integer"].includes(type ?? "") ? `Number(params.get(${q("filter." + field)}))` : `params.get(${q("filter." + field)})!`};`; });
|
|
122
131
|
const cursorKinds = (input.paging?.[name]?.types ?? []).map(type => type === "bool" ? "bool" : type === "integer" || type === "int8" ? "int" : type === "int4" ? "int4" : type.startsWith("numeric(") ? "numeric" : type === "real" || type === "float8" ? "float" : type === "date" ? "date" : type === "timestamptz" ? "timestamp" : "text");
|
|
123
132
|
const modes = Object.fromEntries(Object.entries({ caller: pageData.caller, trusted: pageData.trusted }).map(([mode, p]) => [mode, { ...p, binds: recipesOf(p, Object.keys(v.input?.properties ?? {}), message => refuse(`views/${name}/admin`, message)) }]));
|
|
124
|
-
pageSpecs.push(
|
|
133
|
+
pageSpecs.push({ path: "views/" + name, cap: v.stmts[0]?.SelectStmt.limitCount?.A_Const?.ival?.ival ?? 500, cursorKinds, ...modes });
|
|
125
134
|
imports.push(`const allowsView${n}=${allowed(v.requires, `views/${name}`)};`);
|
|
126
135
|
if (v.surface === "admin")
|
|
127
136
|
adminViews.push(`view${n}`);
|
|
@@ -132,8 +141,8 @@ export function emitAdminBindings(input, refuse) {
|
|
|
132
141
|
imports.push(`import {validate as operation${n}} from "./validation/procedures/${encoded}.js";`);
|
|
133
142
|
operations.push(`{${input.description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? "readOnly:true," : ""}${user.used || p.trustedOnly ? `check:(caller)=>{${authLines(undefined, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), sqlite, p.trustedOnly).join("")}},` : ""}name:${q(name)},allows:${allowed(p.requires, `procedures/${name}`)},validate:operation${n},${converted ? `convert:${converted},` : ""}run:${ref ? `async(db,caller,input)=>(await import("./generated.js")).store(db,caller,{handlers:options.handlers}).procedures[${q(name)}](input)` : `async(db,caller,input)=>(await import("./procedures/${encoded}.js")).default(db,caller,input)`}}`);
|
|
134
143
|
}
|
|
135
|
-
const
|
|
136
|
-
imports.push(...views.map((view, n) => `const view${n}=${view} satisfies View<Database>;`), ...
|
|
144
|
+
const { code: [entrySpecs, ...pages], constants } = sqlConstants([input.adminEntries, ...pageSpecs]);
|
|
145
|
+
imports.push(...views.map((view, n) => `const view${n}=${view} satisfies View<Database>;`), ...constants, ...pages.map((page, n) => `const viewPage${n}=${page} as const;`), `const entrySpecs=${entrySpecs} as const;`);
|
|
137
146
|
imports.push(`export const statisticsView=(db:Database,caller:Caller,collection:keyof typeof entrySpecs,args:{from:number;to:number;bucketMs:number})=>nativeStatistics(entrySpecs,adapter,db,caller,collection,args);`);
|
|
138
147
|
const metadataGuards = [...Object.entries(plan.views).map(([name, v]) => `case ${q("view:" + name)}: return (${allowed(v.requires, `views/${name}`)})(caller);`), ...Object.entries(plan.procedures).map(([name, p]) => `case ${q("procedure:" + name)}: return (${allowed(p.requires, `procedures/${name}`)})(caller);`), ...Object.values(plan.schemas).filter(s => s.publishing).flatMap(s => ["publish", "unpublish", "archive"].map(verb => `case ${q("procedure:" + s.name + "." + verb)}: return isAdmin(caller);`))];
|
|
139
148
|
files["admin-bindings.ts"] = `${imports.filter(Boolean).join("\n")}\nexport function adminBindings${refs ? "<const H extends Handlers>(options:{handlers:H & Record<Exclude<keyof H,keyof Handlers>,never>})" : "(_options?:{handlers?:Handlers})"}{\nreturn {metadata:{...bootstrap,accessTargets:()=>import("./access-targets.js").then(m=>m.targets),${plan.access ? `userFields:${q(plan.access.userFields)} as const,` : ""}allows:(target:import("@aotter/mantle/contracts").Target,caller:Caller)=>{switch(target.kind+":"+target.name){${metadataGuards.join("")}default:return target.kind==="schema";}}},adapter,schemas:[${schemas.join(",\n")}] satisfies readonly EntrySchema<Database>[],views:[${adminViews.join(",")}],developerViews:[${views.map((_, n) => `view${n}`).join(",")}],operations:[${operations.join(",\n")}] satisfies readonly Operation<Database,${refs ? "Handlers" : "never"}>[]};\n}\n`;
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
/** `admin-client.ts`: the types Admin's data hooks bind to, for the application's browser-side `admin.tsx`. */
|
|
2
1
|
import { BUILT_IN_NAV_GROUPS, mcpTools } from "../build/compiler.js";
|
|
3
2
|
import { q } from "../domain/entry.js";
|
|
4
|
-
/**
|
|
5
|
-
* The compatibility test of the application's Admin MCP App (`node --test generated/admin-mcp-app.check.mjs`): it loads the App
|
|
6
|
-
* `mantle bundle` built in the official MCP Apps host and fails on a network request, a console error or an exception. It needs
|
|
7
|
-
* `playwright-core` (a devDependency) and Chrome: `npx playwright-core install chromium`, or `MANTLE_TEST_CHROMIUM=<executable>`.
|
|
8
|
-
* Node strips the types of `admin-descriptors.ts` (Node 22.18 or newer).
|
|
9
|
-
*/
|
|
10
3
|
const MCP_APP_TEST = `import { existsSync } from "node:fs";
|
|
11
4
|
import { test } from "node:test";
|
|
12
5
|
import { checkMcpApp } from "@aotter/mantle-admin/testing";
|
|
@@ -20,40 +13,29 @@ test("the Admin MCP App works in an MCP host", { timeout: 300_000 }, async () =>
|
|
|
20
13
|
await checkMcpApp({ catalogue: mcpAppCatalogues.admin, html });
|
|
21
14
|
});
|
|
22
15
|
`;
|
|
23
|
-
|
|
16
|
+
const nullable = (p) => p.nullable === true || [p.type].flat().includes("null") || (p.enum ?? []).includes(null);
|
|
24
17
|
const closed = (typeOf, schema) => !schema ? "{}" : schema.properties || schema.type === "object" && !schema["$ref"] ? typeOf({ ...schema, additionalProperties: false }) : "{ readonly [key: string]: unknown }";
|
|
25
|
-
/**
|
|
26
|
-
* Types are what the routes take and return, not the Store's rows: an entry's `data` keeps declared field names, an input is
|
|
27
|
-
* what the route validates, a View row is its wire row under the names the browser route gives its columns (an MCP App's
|
|
28
|
-
* `tools/call` returns the SQL names; the hooks rename them with `rowNames`), with `true` when it is an admin MCP tool, else null, which
|
|
29
|
-
* types `pages[].mcp.renders` (types only; nothing ships). The file imports `generated/*` as types only, so nothing of the
|
|
30
|
-
* Worker enters the browser bundle, and it is refused in the Worker's startup graph (`mantle bundle`).
|
|
31
|
-
*/
|
|
32
18
|
export function emitAdminClient(plan, typeOf) {
|
|
33
19
|
const tools = mcpTools(plan, "admin"), hasTool = (kind, source) => tools.some(t => t.kind === kind && t.source === source);
|
|
34
20
|
const views = Object.entries(plan.views).filter(([, v]) => v.surface === "admin").sort(([a], [b]) => a < b ? -1 : 1);
|
|
35
21
|
const operations = Object.entries(plan.procedures).filter(([name]) => hasTool("procedure", name)).sort(([a], [b]) => a < b ? -1 : 1);
|
|
36
22
|
const schemas = Object.values(plan.schemas).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
37
|
-
// the scope column belongs to the caller, so an entry's data has no such field
|
|
38
23
|
const dataOf = (s) => {
|
|
39
|
-
const scope = s.scope ? s.names[s.scope] ?? s.scope : undefined, { [scope ?? ""]: _scope, ...
|
|
40
|
-
|
|
24
|
+
const scope = s.scope ? s.names[s.scope] ?? s.scope : undefined, { [scope ?? ""]: _scope, ...declared } = s.schema.properties ?? {};
|
|
25
|
+
const certain = (name) => !s.publishing && s.schema.required?.includes(name);
|
|
26
|
+
const properties = Object.fromEntries(Object.entries(declared).map(([name, p]) => [name, certain(name) || nullable(p) ? p : { ...p, nullable: true }]));
|
|
41
27
|
const required = s.publishing ? [] : s.schema.required?.filter(f => f !== scope);
|
|
42
28
|
const schema = { ...s.schema, properties, ...(required ? { required } : {}) };
|
|
43
29
|
return s.translates ? typeOf(schema) : closed(typeOf, schema);
|
|
44
30
|
};
|
|
45
|
-
// the names the browser route gives a View's columns where they differ from the SQL output names, as it applies them
|
|
46
31
|
const renames = views.map(([, v]) => Object.fromEntries(Object.entries(v.columns ?? {}).flatMap(([key, c]) => {
|
|
47
32
|
const named = key === c.field ? plan.schemas[c.schema].names[c.field] ?? key : key;
|
|
48
33
|
return named !== key ? [[key, named]] : [];
|
|
49
34
|
})));
|
|
50
|
-
// every sidebar group an admin Schema or View places an item in; `defineAdmin` must declare the ones Admin does not have
|
|
51
|
-
// (the generate-time check of `uiSchema.nav.group` is this type)
|
|
52
35
|
const placed = (ui) => { const group = ui?.["nav"]?.group; return typeof group === "string" ? [group] : []; };
|
|
53
36
|
const used = [...new Set([...schemas.flatMap(s => placed(s.uiSchema)), ...views.flatMap(([, v]) => placed(v.uiSchema))])].sort();
|
|
54
37
|
const custom = used.filter(group => !BUILT_IN_NAV_GROUPS.has(group));
|
|
55
38
|
const union = (names) => names.length ? names.map(name => q(name)).join(" | ") : "never";
|
|
56
|
-
// whether an admin View is an MCP tool, which a page declared for an MCP App (`pages[].mcp.renders`) must have
|
|
57
39
|
const toolOf = (name) => hasTool("view", name) || null;
|
|
58
40
|
const rows = views.map(([name], n) => `import type { WireRow as View${n}Wire } from "./views/${encodeURIComponent(name)}.js";`);
|
|
59
41
|
const sql = operations.some(([, p]) => "sql" in p.handler);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Build-time projections only; generated exports are static, independently tree-shakable data. */
|
|
2
1
|
import { isPrimaryCollection, mcpTools, mcpToolNameSegment, toolTitle, collectionOf, developerConsole, rowPreconditions, appCatalog, entrySchema } from "../build/compiler.js";
|
|
3
2
|
import { q } from "../domain/entry.js";
|
|
4
3
|
import { advertised } from "./validation.js";
|
|
@@ -9,7 +8,6 @@ export function emitAdminDescriptors({ plan, admin = false }, diagnostics) {
|
|
|
9
8
|
const placed = [...schemaList.map(s => ["schemas/" + s.name, s.uiSchema, s]), ...Object.entries(plan.views).map(([name, v]) => ["views/" + name, v.uiSchema, null])];
|
|
10
9
|
for (const [path, ui, s] of placed) {
|
|
11
10
|
const nav = (ui?.["nav"] ?? {}), group = nav["group"];
|
|
12
|
-
// a child folded under its parent, or a translation on its parent's language tabs, has no sidebar entry to place
|
|
13
11
|
if (s && !isPrimaryCollection(s, schemaList))
|
|
14
12
|
for (const key of ["group", "order", "hidden"])
|
|
15
13
|
if (nav[key] !== undefined)
|
|
@@ -46,7 +44,6 @@ export function emitAdminDescriptors({ plan, admin = false }, diagnostics) {
|
|
|
46
44
|
const target = plan.procedures[t.source].target;
|
|
47
45
|
return target ? [[t.name, { path: "/admin/c/" + encodeURIComponent(plan.schemas[target.schema.toLowerCase()]?.name ?? target.schema), entry: true }]] : [];
|
|
48
46
|
})) };
|
|
49
|
-
// These are the actual generated transitions, not authored ref handlers or SQL IR.
|
|
50
47
|
for (const schema of schemas.filter(s => s.publishing))
|
|
51
48
|
for (const [verb] of publishingTransitions) {
|
|
52
49
|
const name = schema.name + "." + verb, tool = mcpToolNameSegment(verb + "_" + schema.name);
|
|
@@ -67,12 +64,9 @@ export function emitAdminDescriptors({ plan, admin = false }, diagnostics) {
|
|
|
67
64
|
snapshot.interfaces.callable.push({ kind: "procedure", name: tool, target: name, surface: "admin", audience: "admin", title: null, description, input: publishingInput, output: null, trigger: null });
|
|
68
65
|
}
|
|
69
66
|
const data = { allCollections, views, operations, fieldNames: Object.fromEntries(schemas.map(s => [s.name, s.names])), viewNames, authorizations, developerConsole: snapshot, mcpAppCatalogues: catalogues, webmcp };
|
|
70
|
-
return { "admin-descriptors.ts": "// Generated static Admin metadata. Import only the exports the application uses.\n" + literals(data) + "\nexport const collections = [" + schemas.filter(s => !s.translates).map(s => "allCollections[" + q(s.name) + "]").join(",") + "] as const;\nexport const bootstrap = {collections, allCollections, operations, views, webmcp, developerConsole} as const;\n"
|
|
67
|
+
return { "admin-descriptors.ts": "// Generated static Admin metadata. Import only the exports the application uses.\n" + literals(data) + "\nexport const collections = [" + schemas.filter(s => !s.translates).map(s => "allCollections[" + q(s.name) + "]").join(",") + "] as const;\nexport const bootstrap = {collections, allCollections, operations, views, webmcp, developerConsole: () => import(\"./admin-developer-console.js\").then(m => m.developerConsole)} as const;\n",
|
|
68
|
+
"admin-developer-console.ts": "// Generated developer snapshot, loaded by bootstrap.developerConsole.\n" + literals({ developerConsole: snapshot }) + "\n" };
|
|
71
69
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Each export as a JSON literal; a subtree of 40 or more bytes that would be printed more than once is printed once, as a
|
|
74
|
-
* module-private constant the exports reference (a bundler keeps only those an imported export reaches).
|
|
75
|
-
*/
|
|
76
70
|
function literals(exports) {
|
|
77
71
|
const data = JSON.parse(q(exports)), count = new Map(), hoisted = new Map(), constants = [];
|
|
78
72
|
const each = (value, visit) => { if (value && typeof value === "object")
|
|
@@ -83,7 +77,6 @@ function literals(exports) {
|
|
|
83
77
|
count.set(json, (count.get(json) ?? 0) + by); each(value, (json, child) => tally(json, child, by)); };
|
|
84
78
|
for (const value of Object.values(data))
|
|
85
79
|
each(value, tally);
|
|
86
|
-
// Longest first: once a subtree is printed once, what it contains occurs (copies - 1) times fewer per occurrence in it.
|
|
87
80
|
for (const json of [...count.keys()].sort((a, b) => b.length - a.length)) {
|
|
88
81
|
const copies = count.get(json);
|
|
89
82
|
if (copies > 1) {
|
package/dist/usecase/generate.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { userReferences } from "../domain/userReferences.js";
|
|
2
|
-
|
|
3
|
-
import { parseNumeric } from "../build/compiler.js";
|
|
2
|
+
import { parseNumeric, tableOutputs } from "../build/compiler.js";
|
|
4
3
|
import { boundaryType, nativeType, outputType, wireType } from "../domain/columns.js";
|
|
5
4
|
import { databaseImport, driverFor, entry, exportName, generatedFiles, isSqlite as sqlite, q } from "../domain/entry.js";
|
|
6
5
|
import { authLines as auth } from "../domain/input.js";
|
|
@@ -62,11 +61,10 @@ export function generate(input) {
|
|
|
62
61
|
if (!bind || bind.k !== "input")
|
|
63
62
|
return `readonly ${q(name)}${schema.required?.includes(name) ? "" : "?"}: ${typeOf(field)};`;
|
|
64
63
|
const type = String(bind.type);
|
|
65
|
-
const driverType = type === "json"
|
|
64
|
+
const driverType = type === "json" ? typeOf(field) : type === "date" ? "string" : type === "timestamptz" ? "string | Date" : isSqlite
|
|
66
65
|
? type === "bool" || parseNumeric(type) ? "number" : typeOf(field)
|
|
67
66
|
: parseNumeric(type) ? "number | string" : typeOf(field);
|
|
68
67
|
const nullable = field.nullable === true || Array.isArray(field.type) && field.type.includes("null") || !schema.required?.includes(name);
|
|
69
|
-
// D1 cannot bind undefined. Bound fields are explicit, null represents absence.
|
|
70
68
|
return `readonly ${q(name)}: ${driverType}${nullable ? " | null" : ""};`;
|
|
71
69
|
});
|
|
72
70
|
return `{ ${properties.join(" ")} ${schema.additionalProperties !== false ? "readonly [key:string]: unknown;" : ""} }`;
|
|
@@ -77,18 +75,18 @@ export function generate(input) {
|
|
|
77
75
|
refuse(path, "Input properties must have unique case-insensitive names. Rename input properties to be unique without regard to case.");
|
|
78
76
|
return fields;
|
|
79
77
|
};
|
|
80
|
-
// The dialect functions the bind recipes call, so a bundle carries only those (`binds` takes this object).
|
|
81
78
|
const values = new Set();
|
|
82
|
-
/** A statement as a constant: its text and how to bind it. */
|
|
83
79
|
const step = (s, path, fields, row = false) => {
|
|
84
|
-
const recipes = recipesOf(s, fields, message => refuse(path, message));
|
|
85
|
-
for (const r of recipes) {
|
|
80
|
+
const recipes = recipesOf(s, fields, message => refuse(path, message)), probe = row && s.probe ? recipesOf(s.probe, fields, message => refuse(path, message)) : [];
|
|
81
|
+
for (const r of [...recipes, ...probe]) {
|
|
86
82
|
if (r.k === "now" || r.k === "cutoff")
|
|
87
83
|
values.add(r.k);
|
|
88
84
|
else if (r.k === "input" && r.type)
|
|
89
85
|
values.add(r.type === "date" ? "date" : "timestamp");
|
|
90
86
|
}
|
|
91
|
-
|
|
87
|
+
const locks = Object.fromEntries((plan.schemas[s.schema ?? ""]?.locks ?? []).filter(l => l.on.includes(s.verb)).map(l => [l.name, l.message]));
|
|
88
|
+
const lock = row && s.probe ? `, lock: ${q({ sql: s.probe.sql, binds: probe, schema: plan.schemas[s.schema].name, verb: s.verb, locks })}` : "";
|
|
89
|
+
return `{ sql: ${q(s.sql)}, binds: ${q(recipes)}${row ? ", row: true" : s.onNoRows ? ', row: "some"' : ""}${lock} }`;
|
|
92
90
|
};
|
|
93
91
|
const declarations = [], views = [], procedures = [], refProcedures = [];
|
|
94
92
|
const entries = {};
|
|
@@ -109,15 +107,14 @@ export function generate(input) {
|
|
|
109
107
|
refuse(path, "Dynamic or repeated View outputs need an explicit generated row contract. Give each SELECT output a unique explicit alias.");
|
|
110
108
|
continue;
|
|
111
109
|
}
|
|
112
|
-
const output = [];
|
|
110
|
+
const output = [], direct = tableOutputs(view, plan.schemas);
|
|
113
111
|
for (const key of keys) {
|
|
114
|
-
const type = outputType(plan, name, key);
|
|
115
|
-
|
|
116
|
-
output.push(`readonly ${q(key)}: ${nativeType(type, isSqlite)};`);
|
|
112
|
+
const type = outputType(plan, name, key) ?? direct[key]?.type;
|
|
113
|
+
output.push(`readonly ${q(key)}: ${nativeType(type, isSqlite, direct[key]?.notNull)};`);
|
|
117
114
|
}
|
|
118
115
|
const fieldNames = inputFields(view.input, path + "/input");
|
|
119
116
|
const callerStep = step(c, path, fieldNames), trustedStep = mode === "caller" ? step(trusted, path, fieldNames) : callerStep;
|
|
120
|
-
declarations.push(`export type View${index}Input = ${queryInputType(view.input ?? { type: "object", additionalProperties: false }, [c, ...(trusted ? [trusted] : [])])};`, `export type View${index}Row = { ${output.join(" ")} };`, `export type View${index}WireRow = { ${keys.map(key => `readonly ${q(key)}: ${boundaryType(plan, name, key) ? wireType(boundaryType(plan, name, key)) : `View${index}Row[${q(key)}]`};`).join(" ")} };`, `const ${fn}Caller = ${callerStep} as const;`, ...(trustedStep !== callerStep ? [`const ${fn}Trusted = ${trustedStep} as const;`] : []), `async function ${fn}(db: ${isSqlite ? "Database" : "Unsafe"}, caller: Caller, raw: View${index}Input): Promise<View${index}Row[]> {`, ...auth(view.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ` const m = ${trustedStep !== callerStep ? `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : ` : ""}${fn}Caller;`, ` return DRIVER.read(db, ${user.used ? "USER.scoped(m.sql, caller)" : "m.sql"}, binds(D, m.binds, caller, raw));`, "}");
|
|
117
|
+
declarations.push(`export type View${index}Input = ${queryInputType(view.input ?? { type: "object", additionalProperties: false }, [c, ...(trusted ? [trusted] : [])])};`, `export type View${index}Row = { ${output.join(" ")} };`, `export type View${index}WireRow = { ${keys.map(key => `readonly ${q(key)}: ${boundaryType(plan, name, key) ? wireType(boundaryType(plan, name, key), direct[key]?.notNull) : `View${index}Row[${q(key)}]`};`).join(" ")} };`, `const ${fn}Caller = ${callerStep} as const;`, ...(trustedStep !== callerStep ? [`const ${fn}Trusted = ${trustedStep} as const;`] : []), `async function ${fn}(db: ${isSqlite ? "Database" : "Unsafe"}, caller: Caller, raw: View${index}Input): Promise<View${index}Row[]> {`, ...auth(view.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ` const m = ${trustedStep !== callerStep ? `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : ` : ""}${fn}Caller;`, ` return DRIVER.read(db, ${user.used ? "USER.scoped(m.sql, caller)" : "m.sql"}, binds(D, m.binds, caller, raw));`, "}");
|
|
121
118
|
views.push(`[${q(name)}]: ${fn}`);
|
|
122
119
|
declarations.push(`export { ${fn} as ${exportName("view", name)} };`);
|
|
123
120
|
entries[`views/${encodeURIComponent(name)}.ts`] = entry("view", name, `View${index}Input as Input, View${index}Row as Row, View${index}WireRow as WireRow`);
|
|
@@ -132,7 +129,7 @@ export function generate(input) {
|
|
|
132
129
|
continue;
|
|
133
130
|
}
|
|
134
131
|
if ([...modes.caller, ...modes.trusted].some(s => s.publish || s.schema && plan.schemas[s.schema]?.publishing))
|
|
135
|
-
refuse(path, "
|
|
132
|
+
refuse(path, "A SQL Procedure cannot write a publishing Schema: create and edit its rows from a ref handler through the Store (`ctx.store.collection(...)`), and change status with the generated `<schema>.publish` / `.unpublish` / `.archive` transitions.");
|
|
136
133
|
const fieldNames = inputFields(procedure.input, path + "/input");
|
|
137
134
|
const steps = (statements) => `[${statements.map(s => step(s, path, fieldNames, s.kind === "row")).join(", ")}] as const`;
|
|
138
135
|
const same = steps(modes.caller) === steps(modes.trusted);
|
|
@@ -142,8 +139,8 @@ export function generate(input) {
|
|
|
142
139
|
entries[`procedures/${encodeURIComponent(name)}.ts`] = entry("procedure", name, `Procedure${index}Input as Input`, `export type { Output } from "${base}contracts";\n`);
|
|
143
140
|
}
|
|
144
141
|
emitTtl(plan, isSqlite, declarations, procedures, entries, refuse);
|
|
145
|
-
emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse);
|
|
146
|
-
const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers);
|
|
142
|
+
emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse, input.locks);
|
|
143
|
+
const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers, input.locks);
|
|
147
144
|
const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, !!site), ...emitAdminDescriptors({ plan, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...(admin ? emitAdminClient(plan, typeOf) : {}), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
|
|
148
145
|
if (diagnostics.length)
|
|
149
146
|
return { ok: false, diagnostics };
|
package/dist/usecase/handlers.js
CHANGED
|
@@ -2,18 +2,15 @@ import { authLines as auth } from "../domain/input.js";
|
|
|
2
2
|
import { q } from "../domain/entry.js";
|
|
3
3
|
export function emitHandlers(plan, interactive, typeOf, declarations, procedures) {
|
|
4
4
|
const refs = Object.entries(plan.procedures).sort().map(([name, procedure], index) => ({ name, procedure, index })).filter(({ procedure }) => "ref" in procedure.handler);
|
|
5
|
-
// An empty contract keeps app wiring (`handlers: Handlers = {}`) identical whether or not any ref Procedure exists.
|
|
6
5
|
if (!refs.length) {
|
|
7
6
|
declarations.push("export interface Handlers {}");
|
|
8
7
|
return [];
|
|
9
8
|
}
|
|
10
|
-
declarations.push('export type HandlerStore = Pick<ReturnType<typeof makeStore>, "collection" | "views" | "procedures">;',
|
|
11
|
-
// D1 has no interactive transactions, so its handlers get no `transaction`: using one there fails typecheck.
|
|
9
|
+
declarations.push('export type HandlerStore = Pick<ReturnType<typeof makeStore>, "collection" | "batch" | "views" | "procedures">;',
|
|
12
10
|
`export interface HandlerContext { readonly caller: Caller; readonly db: Database; readonly store: HandlerStore;${interactive ? " readonly transaction: <R>(fn: (ctx: { readonly store: HandlerStore; readonly db: Database }) => Promise<R>) => Promise<R>;" : ""} }`, "export interface Handlers {", ...refs.map(({ name, index }) => ` readonly [${q(name)}]: (ctx: HandlerContext, input: Procedure${index}Input) => Promise<Procedure${index}Output>;`), "}");
|
|
13
11
|
for (const { name, procedure, index } of refs) {
|
|
14
12
|
const path = `procedures/${name}`, fn = `procedure${index}`;
|
|
15
13
|
declarations.push(`export type Procedure${index}Input = ${typeOf(procedure.input ?? { type: "object", additionalProperties: false })};`, `export type Procedure${index}Output = ${typeOf(procedure.output)};`, `async function ${fn}(db: Database, caller: Caller, raw: Procedure${index}Input, context: { handlers: Handlers; store: HandlerContext["store"] }): Promise<Procedure${index}Output> {`, ` if ("authenticatedAs" in caller && caller.authenticatedAs === "snapshot") (await import("@aotter/mantle/host-caller")).checkFresh(caller, false, ${q(path)});`, ...auth(procedure.requires, path), " const handlers = context?.handlers;", ` const handler = handlers && Object.hasOwn(handlers, ${q(name)}) ? handlers[${q(name)}] : undefined;`, ` if (!handler) throw new ContractError("HANDLER_NOT_REGISTERED", "Procedure handler is not registered.", ${q(path)});`,
|
|
16
|
-
// TODO #1470: REST/MCP use shared generated validators at the boundary; bare calls stay typed.
|
|
17
14
|
` return handler({ caller, db, store: context.store${interactive ? ", transaction: handlerTransaction(context.store, db)" : ""} }, raw);`, "}");
|
|
18
15
|
procedures.push(`[${q(name)}]: ${fn}`);
|
|
19
16
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { emitValidation } from "./validation.js";
|
|
2
2
|
import { mediaDefinitions } from "../domain/generated-tools.js";
|
|
3
3
|
export { mediaDefinitions };
|
|
4
|
-
/** `definitions` are the versioned media tools, computed once by the MCP emitter. */
|
|
5
4
|
export function emitMcpMedia(definitions, refuse) {
|
|
6
5
|
const inputs = Object.fromEntries(definitions.map(tool => [tool.name, { input: tool.inputSchema }]));
|
|
7
6
|
const checks = Object.fromEntries(Object.entries(emitValidation({ views: {}, procedures: inputs }, refuse)).map(([path, code]) => [path.replace("validation/procedures/", "validation/media/"), code]));
|