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