@aotter/mantle 0.2.0 → 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 +6 -2
- package/dist/build/compiler.d.ts +4 -4
- package/dist/build/compiler.js +6 -3
- 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 +30 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- 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 -1
- package/dist/domain/describe/match.d.ts +19 -0
- package/dist/domain/describe/target-hash.js +1 -1
- package/dist/infrastructure/adminEntries.js +19 -3
- package/dist/infrastructure/bundle.js +43 -8
- package/dist/infrastructure/cli.js +1 -1
- package/dist/infrastructure/generate.js +17 -3
- package/dist/kernel/binds.d.ts +18 -3
- package/dist/kernel/binds.js +1 -1
- package/dist/kernel/contracts.d.ts +7 -0
- package/dist/kernel/contracts.js +9 -0
- package/dist/kernel/driver/bun-sqlite.js +3 -3
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -3
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +11 -6
- package/dist/kernel/mcp-handler.js +2 -1
- package/dist/kernel/rest-router.js +3 -5
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -0
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/store.d.ts +15 -1
- package/dist/kernel/store.js +51 -18
- package/dist/usecase/adminBindings.js +2 -2
- package/dist/usecase/adminClient.js +4 -1
- package/dist/usecase/generate.js +7 -5
- package/dist/usecase/handlers.js +1 -1
- package/dist/usecase/publishing.js +20 -7
- package/dist/usecase/rest.js +5 -5
- package/dist/usecase/stores.js +8 -3
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +58 -10
- package/docs/examples/app-shell/package.json +6 -6
- package/docs/examples/club-events/manifests/club-events.yaml +2 -0
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/manifests/purchasing.yaml +3 -0
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +2 -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 +13 -8
- package/docs/handbook/guides/agent-workflow-acceptance.md +129 -19
- package/docs/handbook/guides/local-testing.md +50 -5
- package/docs/handbook/guides/native-admin.md +218 -11
- package/docs/handbook/guides/native-execution.md +5 -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 +47 -1
- package/docs/handbook/guides/typed-queries.md +8 -3
- package/docs/handbook/navigation.json +4 -0
- package/docs/handbook/reference/authorization.md +2 -0
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +12 -3
- package/docs/handbook/reference/procedure.md +4 -4
- package/docs/handbook/reference/schema.md +71 -7
- package/docs/handbook/reference/sql.md +2 -1
- package/docs/handbook/reference/surface.md +3 -2
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/releases/index.md +44 -1
- package/docs/handbook/start/agents.md +6 -2
- package/docs/handbook/start/project-and-cli.md +4 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +15 -3
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +9 -5
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +9 -9
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +19 -5
- package/dist/domain/describe/chunk-SGMRVDGE.js +0 -1
|
@@ -195,6 +195,11 @@ export interface SchemaManifestSpec {
|
|
|
195
195
|
* editable in place, and have no publish/unpublish transitions — the
|
|
196
196
|
* admin hides the content-lifecycle chrome for them. */
|
|
197
197
|
readonly lifecycle?: LifecycleMode;
|
|
198
|
+
/** `lifecycle: publishing` options (#1822). `liveEdits: true` lets a caller's generic patch edit a published row in place (an
|
|
199
|
+
* archived one stays draft-only); a published row must still pass `publish`'s checks. Default: generic patches edit drafts only. */
|
|
200
|
+
readonly publishing?: {
|
|
201
|
+
readonly liveEdits?: boolean;
|
|
202
|
+
};
|
|
198
203
|
/** Logical expiry policy. Physical removal requires an explicit sweep. */
|
|
199
204
|
readonly ttl?: {
|
|
200
205
|
readonly field: string;
|
|
@@ -203,6 +208,21 @@ export interface SchemaManifestSpec {
|
|
|
203
208
|
/** The Schema's row template (ADR-0046 amendment): AND-joined `column = auth.uid()` /
|
|
204
209
|
* `column IN auth.user('field')` terms, enforced on every read and write of the Schema. */
|
|
205
210
|
readonly scope?: string;
|
|
211
|
+
/** Data-state invariants (#1822): while a lock's `when` holds for the existing row, a caller's update or delete of it is
|
|
212
|
+
* refused, whatever the caller's role. Only a trusted (system) write bypasses it. */
|
|
213
|
+
readonly lock?: readonly SchemaLock[];
|
|
214
|
+
}
|
|
215
|
+
export declare const LOCK_VERBS: readonly ["update", "delete"];
|
|
216
|
+
export type LockVerb = (typeof LOCK_VERBS)[number];
|
|
217
|
+
export interface SchemaLock {
|
|
218
|
+
/** Unique within the Schema; named by the `kind: "lock"` conflict. */
|
|
219
|
+
readonly name: string;
|
|
220
|
+
readonly on: readonly LockVerb[];
|
|
221
|
+
/** A SQL boolean over the existing row: its columns, comparisons, AND/OR/NOT, IS [NOT] NULL and [NOT] EXISTS over declared
|
|
222
|
+
* Schemas. True (or NULL) means locked. */
|
|
223
|
+
readonly when: string;
|
|
224
|
+
/** Static text the refusal carries. */
|
|
225
|
+
readonly message: string;
|
|
206
226
|
}
|
|
207
227
|
export interface TranslatesBinding {
|
|
208
228
|
/** Name of the parent Schema this translation table joins to. Must
|
|
@@ -336,10 +356,28 @@ export interface HandlerRefBinding {
|
|
|
336
356
|
readonly ref: string;
|
|
337
357
|
}
|
|
338
358
|
/** One or more write statements (`INSERT`, `UPDATE`, `DELETE`), applied as
|
|
339
|
-
* one batch. Reads `input.<name>`, `auth.uid()`, `auth.role()`, `now()`.
|
|
359
|
+
* one batch. Reads `input.<name>`, `auth.uid()`, `auth.role()`, `now()`.
|
|
360
|
+
* Authored as `sql` (the program) or as `steps`, one statement each; the parser
|
|
361
|
+
* joins steps into `sql`, so everything that reads the program reads `sql`. */
|
|
340
362
|
export interface HandlerSqlBinding {
|
|
341
363
|
readonly sql: string;
|
|
364
|
+
/** Present when authored as `handler.steps` (ADR-0034 decision 4, #1874): statement i of the program is step i. */
|
|
365
|
+
readonly steps?: readonly HandlerStep[];
|
|
342
366
|
}
|
|
367
|
+
/** One statement of a `steps` program. `onNoRows: conflict`: a set-op write that writes no row is
|
|
368
|
+
* `CONFLICT` kind "row" and the whole program rolls back. */
|
|
369
|
+
export interface HandlerStep {
|
|
370
|
+
readonly sql: string;
|
|
371
|
+
readonly onNoRows?: "conflict";
|
|
372
|
+
}
|
|
373
|
+
export declare const HANDLER_STEP_KEYS: readonly ["sql", "onNoRows"];
|
|
374
|
+
/** The program `steps` compile as: the statements in order, each on its own lines. `lines[i]` is the 0-based line step i starts
|
|
375
|
+
* on, so a diagnostic in the program points back into its step. A step's own trailing `;` is dropped; after a step ending in a
|
|
376
|
+
* `--` comment the separator goes on the next line, where the comment cannot swallow it. */
|
|
377
|
+
export declare function stepProgram(steps: readonly HandlerStep[]): {
|
|
378
|
+
readonly sql: string;
|
|
379
|
+
readonly lines: readonly number[];
|
|
380
|
+
};
|
|
343
381
|
/** Closed predicate vocabulary. `ctx.user` and `ctx.auth` are bare
|
|
344
382
|
* strings; `ctx.admin` and `ctx.auth.scope` carry scalar data under
|
|
345
383
|
* literal object keys.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* The sealed plan (ADR-0032 decision 5, ADR-0034): what the CLI compiles from the manifests and Core executes.
|
|
3
3
|
* Every SQL source is already IR here, so no Worker parses SQL. Pure types and constants.
|
|
4
4
|
*/
|
|
5
|
-
import type { AuthorizationRequirements, JsonSchema, LocalizedText, ProcedureMcpAnnotations, ProcedureTarget, TriggerSource } from "./ManifestGrammar.js";
|
|
6
|
-
import type { SqlNode, SqlPlan } from "./SqlIr.js";
|
|
5
|
+
import type { AuthorizationRequirements, HandlerStep, JsonSchema, LocalizedText, ProcedureMcpAnnotations, ProcedureTarget, TriggerSource } from "./ManifestGrammar.js";
|
|
6
|
+
import type { SqlLock, SqlNode, SqlPlan } from "./SqlIr.js";
|
|
7
7
|
import type { UserScope, UserFieldSpec } from "./UserFields.js";
|
|
8
8
|
export declare const RUNTIME_PLAN_VERSION: 6;
|
|
9
9
|
/** A Schema as storage and Store see it. Keys of `fields` and `names` are lower case: SQL folds unquoted identifiers. */
|
|
@@ -20,6 +20,8 @@ export interface PlanSchema {
|
|
|
20
20
|
readonly ttl?: string;
|
|
21
21
|
readonly ttlSeconds?: number;
|
|
22
22
|
readonly publishing?: boolean;
|
|
23
|
+
/** `spec.publishing.liveEdits` (#1822): a caller's generic patch may edit a non-draft row in place. */
|
|
24
|
+
readonly liveEdits?: true;
|
|
23
25
|
/** column -> Mantle type: text integer real bool json timestamptz date numeric(p,s) geo */
|
|
24
26
|
readonly fields: Readonly<Record<string, string>>;
|
|
25
27
|
/** lower-cased column -> the name the JSON Schema declares */
|
|
@@ -28,6 +30,8 @@ export interface PlanSchema {
|
|
|
28
30
|
readonly schema: JsonSchema;
|
|
29
31
|
/** Boolean expressions over the row's own columns, as IR. */
|
|
30
32
|
readonly checks?: readonly SqlNode[];
|
|
33
|
+
/** `spec.lock` (#1822): each `when` as IR, its subqueries reading stored rows. A caller's update or delete gets `AND NOT (when)`. */
|
|
34
|
+
readonly locks?: readonly SqlLock[];
|
|
31
35
|
/** A translation publishes only once the parent entry that shares `on` is published (ADR-0010). Names as declared. */
|
|
32
36
|
readonly translates?: {
|
|
33
37
|
readonly parent: string;
|
|
@@ -75,12 +79,14 @@ export interface PlanProcedure {
|
|
|
75
79
|
readonly requires?: AuthorizationRequirements;
|
|
76
80
|
readonly target?: ProcedureTarget;
|
|
77
81
|
readonly mcp?: ProcedureMcpAnnotations;
|
|
78
|
-
/** An inline handler carries its SQL as authored
|
|
82
|
+
/** An inline handler carries its SQL (`source`: as authored, or its `steps` joined), which Admin shows; the IR is what runs.
|
|
83
|
+
* `steps` is the authored form when the handler was written as steps (#1874): statement i is step i. */
|
|
79
84
|
readonly handler: {
|
|
80
85
|
readonly ref: string;
|
|
81
86
|
} | {
|
|
82
87
|
readonly sql: SqlPlan;
|
|
83
88
|
readonly source: string;
|
|
89
|
+
readonly steps?: readonly HandlerStep[];
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
export interface PlanTrigger {
|
|
@@ -97,6 +103,8 @@ export interface LoweredStatement {
|
|
|
97
103
|
readonly kind: "read" | "row" | "set";
|
|
98
104
|
readonly schema?: string;
|
|
99
105
|
readonly verb?: "insert" | "update" | "delete";
|
|
106
|
+
/** A set op whose step says `onNoRows: conflict`: writing no row is CONFLICT (#1874). Omitted otherwise. */
|
|
107
|
+
readonly onNoRows?: "conflict";
|
|
100
108
|
/** Omitted when false. */
|
|
101
109
|
readonly hooked?: true;
|
|
102
110
|
/** Omitted when false. */
|
|
@@ -105,6 +113,11 @@ export interface LoweredStatement {
|
|
|
105
113
|
readonly returns?: true;
|
|
106
114
|
/** A write's table, lower case (D1's unique-conflict op mapping). */
|
|
107
115
|
readonly target?: string;
|
|
116
|
+
/** A row write a Schema lock guards: the read that tells a lock from the row conflict (#1822). */
|
|
117
|
+
readonly probe?: {
|
|
118
|
+
readonly sql: string;
|
|
119
|
+
readonly binds: LoweredStatement["binds"];
|
|
120
|
+
};
|
|
108
121
|
}
|
|
109
122
|
/** A paged statement of a View under one request shape, printed. The page size is a bind (`limit`), so one statement serves every page size. */
|
|
110
123
|
export interface LoweredPage {
|
|
@@ -34,6 +34,8 @@ export interface SqlSchemaDef {
|
|
|
34
34
|
/** lower-cased column -> the name the JSON Schema declares (the plan has it; `notNullColumns` reads it with `schema`) */
|
|
35
35
|
readonly names?: Readonly<Record<string, string>>;
|
|
36
36
|
/** the Schema's JSON Schema, of which storage reads only what is required and nullable */
|
|
37
|
+
/** `spec.lock` (#1822): `when` is the IR of its boolean over the existing row, whose subqueries read stored rows (`system`). */
|
|
38
|
+
readonly locks?: readonly SqlLock[];
|
|
37
39
|
readonly schema?: {
|
|
38
40
|
readonly required?: readonly string[];
|
|
39
41
|
readonly properties?: Readonly<Record<string, {
|
|
@@ -46,6 +48,12 @@ export interface SqlSchemaDef {
|
|
|
46
48
|
}>>;
|
|
47
49
|
};
|
|
48
50
|
}
|
|
51
|
+
export interface SqlLock {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly on: readonly ("update" | "delete")[];
|
|
54
|
+
readonly when: SqlNode;
|
|
55
|
+
readonly message: string;
|
|
56
|
+
}
|
|
49
57
|
/**
|
|
50
58
|
* What the allowlist reads off every Schema of a context: computed from `schemas` (`schemaColumns`), once per plan by a caller that
|
|
51
59
|
* checks many programs against one plan, or per program when absent.
|
|
@@ -127,3 +135,25 @@ export declare const CHECK_FUNCTIONS: ReadonlyMap<string, {
|
|
|
127
135
|
* its own way. Operand types beyond the functions' arguments are the database's to check at boot.
|
|
128
136
|
*/
|
|
129
137
|
export declare function checkShapeProblem(v: unknown, columns: ReadonlyMap<string, string>): string | undefined;
|
|
138
|
+
/**
|
|
139
|
+
* Why a Schema lock's `when` (the compiled WHERE of a read of `table`) is outside the lock subset, or undefined (#1822). It names
|
|
140
|
+
* the locked row's columns (unqualified, or qualified with the Schema's name) through comparisons, AND/OR/NOT and IS [NOT] NULL,
|
|
141
|
+
* and `[NOT] EXISTS (SELECT … FROM <declared Schema> [alias] WHERE …)`. Inside a subquery every column is qualified, so an
|
|
142
|
+
* unqualified name can never silently read the subquery's table instead of the locked row. No function call (`now()`, `auth.*`,
|
|
143
|
+
* aggregates, windows), cast, parameter or write.
|
|
144
|
+
*/
|
|
145
|
+
export declare function lockShapeProblem(where: SqlNode, schemas: Readonly<Record<string, SqlSchemaDef>>, table: string): string | undefined;
|
|
146
|
+
/**
|
|
147
|
+
* Whether every EXISTS of a lock (already `lockShapeProblem`-free) is index-served (#1822): its equality predicates (top-level AND
|
|
148
|
+
* terms `col = <constant or outer column>` on the subquery's own Schema) must include the first column of a declared index, unique
|
|
149
|
+
* index or `id` of that Schema. Each lock runs on every guarded write, so an unindexed EXISTS would scan its table each time.
|
|
150
|
+
* Returns the first unproved subquery: its Schema and the columns an index could lead with.
|
|
151
|
+
*/
|
|
152
|
+
export declare function lockIndexProblem(where: SqlNode, schemas: Readonly<Record<string, SqlSchemaDef & {
|
|
153
|
+
readonly unique?: readonly (readonly string[])[];
|
|
154
|
+
readonly indexes?: readonly (readonly string[])[];
|
|
155
|
+
}>>): {
|
|
156
|
+
schema: string;
|
|
157
|
+
columns: string[];
|
|
158
|
+
or?: true;
|
|
159
|
+
} | undefined;
|
|
@@ -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", "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"];
|
|
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_LOCK_INVALID", "LOCK_INDEX_MISSING", "SCHEMA_PUBLISHING_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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function resolveLocalizedText(value,preferred,canonical2){if(value==null)return null;if(typeof value=="string")return value;if(Object.prototype.hasOwnProperty.call(value,preferred))return value[preferred];if(canonical2&&Object.prototype.hasOwnProperty.call(value,canonical2))return value[canonical2];let firstKey=Object.keys(value)[0];return firstKey!==void 0?value[firstKey]:null}var INPUT_RULE_KEYWORDS=["dependentRequired","anyOf","if","then","else"];function lookupLocalRef(root,ref){if(typeof ref!="string"||!ref.startsWith("#/"))return;let value=root;try{for(let 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}catch{return}return value&&typeof value=="object"?value:void 0}function rootRefChain(input){let chain=[];for(let schema=input;schema!==void 0;schema=lookupLocalRef(input,schema.$ref)){if(chain.includes(schema))return;if(chain.push(schema),schema.$ref===void 0)return chain}}function mcpToolNameSegment(name){return name.toLowerCase().replace(/-/g,"_")}function toolTitle(name){let words=name.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/[-_.\s]+/g," ").trim().toLowerCase();return words.charAt(0).toUpperCase()+words.slice(1)}var viewAnnotations={readOnlyHint:!0,destructiveHint:!1};function procedureAnnotations(p){let readOnlyHint=p.mcp?.readOnlyHint??!1;return{readOnlyHint,destructiveHint:!readOnlyHint&&("ref"in p.handler||overwrites(p.handler.sql.stmts)),...p.mcp}}var overwrites=node=>!!node&&typeof node=="object"&&("DeleteStmt"in node||"UpdateStmt"in node||node.onConflictClause?.action==="ONCONFLICT_UPDATE"||Object.values(node).some(overwrites));function mcpTools(plan,surface,locale="en"){let text=t=>resolveLocalizedText(t,locale,"en")??void 0,tools=new Map,add=tool=>{let prior=tools.get(tool.name);if(prior&&(prior.kind!==tool.kind||prior.source!==tool.source))throw new TypeError(`MCP tool '${tool.name}' comes from both '${prior.source}' and '${tool.source}'.`);tools.set(tool.name,tool)};for(let t of Object.values(plan.triggers)){if(t.source.kind!=="mcp"||t.source.surface!==surface)continue;let p=plan.procedures[t.procedure],title=text(p.title);add({name:mcpToolNameSegment(t.procedure),kind:"procedure",source:t.procedure,title:title??toolTitle(t.procedure),description:text(p.description)??title??t.procedure,inputSchema:p.input,...p.output.type==="object"?{outputSchema:p.output}:{},annotations:procedureAnnotations(p),requires:p.requires})}for(let[name,v]of Object.entries(plan.views)){if(v.surface!==surface)continue;let chain=(v.input?rootRefChain(v.input)??[v.input]:[]).reverse(),props=Object.assign({},...chain.map(s=>s.properties??{})),required=[...new Set(chain.flatMap(s=>s.required??[]))];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.`);let rules=chain.map(s=>Object.fromEntries(INPUT_RULE_KEYWORDS.flatMap(k=>s[k]===void 0?[]:[[k,s[k]]]))).filter(r=>Object.keys(r).length),defs=v.input?referencedDefs(v.input,props):{},title=text(v.title);add({name:mcpToolNameSegment(name),kind:"view",source:name,title:title??toolTitle(name),description:text(v.description)??title??name,inputSchema:{type:"object",properties:{...props,limit:{type:"integer",minimum:1,maximum:500},cursor:{type:"string"}},...required.length?{required}:{},...rules.length>1?{allOf:rules}:rules[0],...Object.keys(defs).length?{$defs:defs}:{}},annotations:viewAnnotations,requires:v.requires})}return[...tools.values()]}function referencedDefs(root,node,found={}){if(!node||typeof node!="object")return found;let ref=node.$ref,[head,segment]=typeof ref=="string"&&ref.startsWith("#/")?decoded(ref.slice(2)).split("/"):[],key=head==="$defs"?segment?.replace(/~1/g,"/").replace(/~0/g,"~"):void 0;key!==void 0&&!Object.hasOwn(found,key)&&root.$defs&&Object.hasOwn(root.$defs,key)&&(found[key]=root.$defs[key],referencedDefs(root,found[key],found));for(let child of Object.values(node))referencedDefs(root,child,found);return found}var decoded=pointer=>{try{return decodeURIComponent(pointer)}catch{return""}};function canonical(v){return Array.isArray(v)?`[${v.map(canonical).join(",")}]`:v&&typeof v=="object"?`{${Object.keys(v).sort().filter(k=>v[k]!==void 0).map(k=>`${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`:JSON.stringify(v)??"null"}var publishingTransitions=[["publish",["draft"],"published"],["unpublish",["published","archived"],"draft"],["archive",["draft","published"],"archived"]];var publishingInput={type:"object",properties:{id:{type:"string",minLength:1},expectedVersion:{type:"integer",minimum:1}},required:["id","expectedVersion"],additionalProperties:!1};function mediaDefinitions(){let define=(name,description,inputSchema,action,readOnlyHint=!1)=>({name,description,inputSchema,action,annotations:{readOnlyHint,destructiveHint:action==="delete",openWorldHint:!0}}),string={type:"string"},texts={alt:{type:"string",maxLength:1e3},caption:{type:"string",maxLength:1e3}},input=(properties,required=[])=>({type:"object",properties,required,additionalProperties:!1});return[define("get_media_upload_policy","Read declared image purpose MIME slots and byte caps before preparing variants or requesting an upload. Optimization runs locally, never in the host.",input({}),"policy",!0),define("create_media_upload","Create short-lived PUT capabilities for image variants. Read site media purpose policy first; optimize images locally. Obtain attachment bytes yourself, then PUT each variant with its requiredHeaders and call commit_media_upload. Never store upload URLs as published images or ask the user to run terminal commands.",input({filename:{type:"string",maxLength:255},purpose:string,variants:{type:"array",minItems:1,items:input({mimeType:string,byteSize:{type:"integer",minimum:1},role:{type:"string",enum:["primary","alternate","fallback"]}},["mimeType","byteSize","role"])},...texts},["filename","purpose","variants"]),"create"),define("commit_media_upload","Verify all uploaded variants and commit one permanent media asset. Store the returned asset id in fields referencing media_assets.id, never a temporary upload URL. Unknown or expired upload groups require a new create_media_upload.",input({uploadGroupId:string,...texts},["uploadGroupId"]),"commit"),define("list_media_assets","List committed image assets, newest first, with their permanent variants and alt/caption. Search matches id, alt or caption; reuse only a returned cursor.",input({limit:{type:"integer",minimum:1,maximum:500},cursor:string,search:string}),"list",!0),define("get_media_asset","Read one committed media asset and its permanent variant URLs.",input({id:string},["id"]),"get",!0),define("update_media_asset","Update committed asset alt or caption; omitted fields remain unchanged and empty strings clear text.",input({id:string,...texts},["id"]),"update"),define("delete_media_asset","Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id.",input({id:string},["id"]),"delete")]}var ADMIN_API_BASE="/admin/api",pathTemplate=path=>path.replace(/\{([^}]+)\}/g,":$1"),routePath=template=>template.split("/").map(segment=>segment.startsWith(":")?`{${segment.slice(1)}}`:segment).join("/");function routeSource(template,base="",suffix=!1){let keys=[],path=template.split("/").map(segment=>{let rest=suffix?/^\{([a-zA-Z]\w*)\*\}$/.exec(segment):null,one=/^\{([^}]+)\}$/.exec(segment);return rest||one?(keys.push((rest??one)[1]),rest?"(.*)":"([^/]+)"):encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}).join("/");return{source:`^${base}${path}/?$`,keys}}function canonicalPath(pathname){try{return pathname.split("/").map(segment=>encodeURIComponent(decodeURIComponent(segment))).join("/")}catch{return}}var isParameter=segment=>/^\{[^}]+\}$/.test(segment);function routeOrder(a,b){let x=a.split("/"),y=b.split("/"),count=segments=>segments.filter(isParameter).length;if(count(x)!==count(y))return count(x)-count(y);for(let i=0;i<Math.min(x.length,y.length);i++)if(isParameter(x[i])!==isParameter(y[i]))return isParameter(x[i])?1:-1;return a<b?-1:a>b?1:0}function inspectSql(value){let schemas=new Set,readSchemas=new Set,writes=[],readOnly=!0,walk=v=>{if(!v||typeof v!="object")return;let n=v;n.RangeVar?.mantle==="table"&&(schemas.add(n.RangeVar.relname.toLowerCase()),readSchemas.add(n.RangeVar.relname.toLowerCase()));for(let kind of["InsertStmt","UpdateStmt","DeleteStmt"])n[kind]&&(readOnly=!1,n[kind].relation?.relname&&writes.push({schema:n[kind].relation.relname.toLowerCase(),operation:kind==="InsertStmt"?"create":kind==="UpdateStmt"?"update":"delete"}),n[kind].relation?.relname&&(schemas.add(n[kind].relation.relname.toLowerCase()),kind!=="InsertStmt"&&readSchemas.add(n[kind].relation.relname.toLowerCase())));Object.values(n).forEach(walk)};return walk(value),{schemas:[...schemas].sort(),readSchemas:[...readSchemas].sort(),readOnly,writes}}function mergeRestrict(policies,conjunctive){return Object.fromEntries([...new Set(policies.flatMap(p=>Object.keys(p)))].sort().map(schema=>{let rows=policies.map(p=>p[schema]??{terms:[]}),terms=[...new Map(rows.flatMap(p=>p.terms).map(t=>[canonical(t),t])).values()].filter(t=>conjunctive||rows.every(p=>p.terms.some(v=>canonical(v)===canonical(t)))),owner=rows.find(p=>p.owner)?.owner;return[schema,{...owner&&(conjunctive||rows.every(p=>p.owner===owner))?{owner}:{},terms}]}))}function inspectBehavior(plan,ir,opaque,requires,restrict,writes){let inspected=inspectSql(ir);writes&&(inspected.writes=[...writes],inspected.schemas.push(...writes.map(w=>w.schema)),inspected.readSchemas.push(...writes.filter(w=>w.operation!=="create").map(w=>w.schema)));let dependencies=[],policies=[restrict??{}],procedures=new Set,hooks=new Set,visit=name=>{if(procedures.has(name))return;procedures.add(name);let p=plan.procedures[name];if(!p)throw new TypeError(`Unknown dependency '${name}'.`);dependencies.push({procedure:name,declaration:p}),policies.push(p.restrict??{}),opaque||="ref"in p.handler;let g=inspectSql("sql"in p.handler?p.handler.sql.stmts:void 0);inspected.schemas.push(...g.schemas),inspected.readSchemas.push(...g.readSchemas),inspected.writes.push(...g.writes),inspected.readOnly&&=g.readOnly,p.requires?.guard&&visit(p.requires.guard.procedure)};for(requires?.guard&&visit(requires.guard.procedure);;){let before=hooks.size;for(let[id,t]of Object.entries(plan.triggers).sort(([a],[b])=>a<b?-1:a>b?1:0)){if(t.source.kind!=="lifecycle"||hooks.has(id))continue;let source=t.source;!opaque&&!inspected.writes.some(w=>w.schema===source.schema.toLowerCase()&&source.on.some(on=>on.endsWith(`_${w.operation}`)))||(hooks.add(id),dependencies.push({trigger:id,source}),visit(t.procedure))}if(hooks.size===before)break}let related=names=>{let all=new Set(opaque?Object.keys(plan.schemas):names);for(let name of all){let parent=plan.schemas[name]?.translates?.parent.toLowerCase();parent&&all.add(parent)}return[...all].sort()};return{...inspected,schemas:related(inspected.schemas),readSchemas:related(inspected.readSchemas),opaque,restrict:mergeRestrict(policies,!0),dependencies}}var objectInput={type:"object",properties:{}},adminRoutes=["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"];function targetDefinitions(plan){let out=[],tools=[...mcpTools(plan,"public"),...mcpTools(plan,"admin")];for(let[kind,entries]of[["view",plan.views],["procedure",plan.procedures]])for(let[name,p]of Object.entries(entries).sort(([a],[b])=>a<b?-1:a>b?1:0)){let view=kind==="view"?plan.views[name]:void 0,proc=kind==="procedure"?plan.procedures[name]:void 0,ir=view?.stmts??(proc&&"sql"in proc.handler?proc.handler.sql.stmts:void 0),inspected=inspectBehavior(plan,ir,!!proc&&"ref"in proc.handler,p.requires,p.restrict),tool=tools.find(t=>t.kind===kind&&t.source===name),title=resolveLocalizedText(p.title,"en","en")??name,triggers=Object.entries(plan.triggers).filter(([,t])=>kind==="procedure"&&t.procedure===name).sort(([a],[b])=>a<b?-1:a>b?1:0).map(([id,t])=>({id,source:t.source})),surfaces=[...new Set([...view?[view.surface,"admin",...view.surface==="public"?["rest"]:[]]:[],...triggers.map(t=>t.source.kind==="mcp"?t.source.surface:t.source.kind==="http"?"rest":t.source.kind)])].sort();surfaces.length||surfaces.push("internal"),out.push({id:`${kind}:${name}`,name:tool?.name??mcpToolNameSegment(name),kind,surface:view?.surface??(surfaces.includes("public")||surfaces.includes("rest")?"public":surfaces.includes("admin")?"admin":"internal"),surfaces,triggers,title,description:resolveLocalizedText(p.description,"en","en")??title,inputSchema:tool?.inputSchema??view?.input??proc?.input??objectInput,annotations:tool?.annotations??{},requires:p.requires,restrict:inspected.restrict,schemas:inspected.schemas,readSchemas:inspected.readSchemas,readOnly:!inspected.opaque&&inspected.readOnly,opaque:inspected.opaque,trustedOnly:!!proc?.trustedOnly,variants:[],behavior:{declaration:p,dependencies:inspected.dependencies}})}let addRoute=(id,dependencies,inputSchema=objectInput)=>{let admin=id.startsWith("admin:");out.push({id,name:id.replace(/[^a-zA-Z0-9_-]/g,"_"),title:id,description:id,kind:admin?"admin":"rest",surface:admin?"admin":"public",surfaces:[admin?"admin":"rest"],triggers:dependencies.flatMap(d=>d.triggers),inputSchema,annotations:{},requires:admin?{auth:{all:["ctx.admin"]}}:dependencies[0]?.requires,restrict:mergeRestrict(dependencies.map(d=>d.restrict),!1),schemas:[...new Set(dependencies.flatMap(d=>d.schemas))].sort(),readSchemas:[...new Set(dependencies.flatMap(d=>d.readSchemas))].sort(),readOnly:dependencies.length>0&&dependencies.every(d=>d.readOnly)&&(!admin||id.startsWith("admin:GET ")||id==="admin:POST /operations/:name"||id==="admin:POST /webmcp/:tool"),opaque:dependencies.some(d=>d.opaque),trustedOnly:dependencies.some(d=>d.trustedOnly),variants:dependencies,behavior:dependencies.map(d=>({id:d.id,behavior:d.behavior,surfaces:d.surfaces,triggers:d.triggers,input:d.inputSchema,requires:d.requires,restrict:d.restrict,trustedOnly:d.trustedOnly}))})},declarations=[...out],adminOperations=new Set(mcpTools(plan,"admin").filter(t=>t.kind==="procedure").map(t=>`procedure:${t.source}`));for(let d of declarations)d.kind==="view"&&d.surfaces.includes("public")&&addRoute(`rest:GET /api/views/${d.id.slice(5)}`,[d],d.inputSchema);for(let[,t]of Object.entries(plan.triggers).sort(([a],[b])=>a<b?-1:a>b?1:0))if(t.source.kind==="http"){let d=declarations.find(d2=>d2.id===`procedure:${t.procedure}`);addRoute(`rest:${t.source.method} ${pathTemplate(t.source.path)}`,[d],d.inputSchema)}let generated=[];for(let[schema,s]of Object.entries(plan.schemas).sort(([a],[b])=>a<b?-1:a>b?1:0))if(s.publishing)for(let[verb,from,to]of publishingTransitions){addRoute(`procedure:${s.name}.${verb}`,[],publishingInput);let d=out.at(-1),inspected=inspectBehavior(plan,void 0,!1,void 0,void 0,[{schema,operation:verb==="publish"?"publish":"update"}]);Object.assign(d,{kind:"procedure",name:mcpToolNameSegment(`${verb}_${s.name}`),surface:"admin",surfaces:["admin"],requires:{auth:{all:["ctx.admin"]}},schemas:[...new Set([schema,...inspected.schemas])].sort(),readSchemas:[...new Set([schema,...inspected.readSchemas])].sort(),opaque:inspected.opaque,behavior:{schema,verb,from,to,dependencies:inspected.dependencies}}),generated.push(d)}for(let tool of mediaDefinitions()){addRoute(`admin:tool:${tool.name}`,[],tool.inputSchema);let d=out.at(-1);Object.assign(d,{name:tool.name,readOnly:tool.annotations.readOnlyHint,opaque:!tool.annotations.readOnlyHint,annotations:tool.annotations,behavior:{tool,schemas:plan.schemas}}),generated.push(d)}for(let route of adminRoutes){let 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):[],generic=route.includes("/entries")||route.includes("/statistics")||route==="GET /bootstrap";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);let d=out.at(-1);if(generic){let operation=route==="POST /entries"?"create":route==="DELETE /entries/:id"?"delete":route.startsWith("GET ")?void 0:route.endsWith("/publish")?"publish":"update",inspected=inspectBehavior(plan,void 0,!1,void 0,void 0,operation?Object.keys(plan.schemas).map(schema=>({schema,operation})):[]);d.schemas=Object.keys(plan.schemas).sort(),d.readSchemas=d.schemas,d.opaque=inspected.opaque,d.behavior={schemas:plan.schemas,dependencies:inspected.dependencies}}}return[...new Map(out.map(d=>[d.id,d])).values()].sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0)}async function hash(value){let digest=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(canonical(value)));return[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,"0")).join("")}function hashDefinition(plan,d){return hash({id:d.id,surfaces:d.surfaces,triggers:d.triggers,behavior:d.behavior,input:d.inputSchema,requires:d.requires,restrict:d.restrict,dialect:plan.dialect,schemas:Object.fromEntries(d.schemas.map(name=>[name,plan.schemas[name]])),userFields:plan.access?.userFields,readOnly:d.readOnly,opaque:d.opaque,trustedOnly:d.trustedOnly})}async function targetHash(plan,targetId){let target=targetDefinitions(plan).find(d=>d.id===targetId);if(!target)throw new TypeError(`Unknown target '${targetId}'.`);return hashDefinition(plan,target)}export{canonical,ADMIN_API_BASE,routePath,routeSource,canonicalPath,routeOrder,adminRoutes,targetDefinitions,hash,hashDefinition,targetHash};
|
|
@@ -3,6 +3,7 @@ import type { PlanRestrict } from "../../build/spec/domain/model/RuntimePlan.js"
|
|
|
3
3
|
import type { UserFieldSpec } from "../../build/spec/domain/model/UserFields.js";
|
|
4
4
|
import { type TargetDefinition } from "./targets.js";
|
|
5
5
|
export { targetHash } from "./target-hash.js";
|
|
6
|
+
export { matchTarget, type TargetRequest } from "./match.js";
|
|
6
7
|
export interface DescribeTarget {
|
|
7
8
|
name: string;
|
|
8
9
|
title: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{canonical,hashDefinition,targetDefinitions,targetHash}from"./chunk-
|
|
1
|
+
import{ADMIN_API_BASE,adminRoutes,canonical,canonicalPath,hashDefinition,routeOrder,routePath,routeSource,targetDefinitions,targetHash}from"./chunk-P6PP6VVV.js";var tables=new WeakMap,route=/^(rest|admin):([A-Z]+) (\/.*)$/,normalized=new Set(["DELETE","GET","HEAD","OPTIONS","POST","PUT"]);function compile(describe){let table={rest:[],admin:[],tools:[]};for(let{name,_meta:meta}of describe.targets){let[,surface,method,template]=route.exec(meta.id)??[];if(surface==="rest")table.rest.push({method,pattern:new RegExp(routeSource(routePath(template)).source),view:meta.variants[0]?.id.startsWith("view:")===!0,meta});else if(surface==="admin")table.admin.push({method,pattern:new RegExp(routeSource(routePath(template),ADMIN_API_BASE).source),meta});else{let s=meta.surfaces,endpoints=meta.id.startsWith("admin:tool:")?["admin"]:meta.id.startsWith("view:")?s.includes("public")?["public"]:s.includes("internal")?[]:["admin"]:meta.id.startsWith("procedure:")?s.filter(x=>x==="public"||x==="admin"):[];endpoints.length&&table.tools.push({name,endpoints,meta})}}let order=id=>adminRoutes.indexOf(id.slice(6))>>>0,path=id=>routePath(id.slice(id.indexOf(" ")+1));return table.rest.sort((a,b)=>routeOrder(path(a.meta.id),path(b.meta.id))),table.admin.sort((a,b)=>order(a.meta.id)-order(b.meta.id)),table}function matchTarget(describe,request){let table=tables.get(describe);if(table||tables.set(describe,table=compile(describe)),request.surface==="mcp"){let path2=request.path?.replace(/\/+$/,""),endpoint=path2===void 0?void 0:path2==="/mcp"?"public":path2==="/mcp/admin"?"admin":null,found=table.tools.filter(t=>t.name===request.tool&&(endpoint===void 0||t.endpoints.includes(endpoint)));return endpoint!==null&&found.length&&found.every(t=>t.meta.id===found[0].meta.id)?found[0].meta:null}let method=normalized.has(request.method.toUpperCase())?request.method.toUpperCase():request.method,path=canonicalPath(request.path);return path===void 0?null:request.surface==="admin"?request.path!==ADMIN_API_BASE&&!request.path.startsWith(ADMIN_API_BASE+"/")?null:table.admin.find(r=>r.method===method&&r.pattern.test(path))?.meta??null:table.rest.find(r=>r.pattern.test(path)&&(r.method===method||method==="HEAD"&&r.method==="GET"&&r.view))?.meta??null}function requirements(d,surface){let all=d.requires?.auth?.all??[];return{all:surface==="admin"&&!all.includes("ctx.admin")?["ctx.admin",...all]:all,...d.requires?.guard?{guard:d.requires.guard.procedure}:{}}}async function describeApp(plan){let targets=await Promise.all(targetDefinitions(plan).map(async d=>{let scope=Object.fromEntries(d.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}));return{name:d.name,title:d.title,description:d.description,inputSchema:d.inputSchema,annotations:{...d.annotations,readOnlyHint:d.readOnly},_meta:{surfaces:d.surfaces,triggers:d.triggers,id:d.id,targetHash:await hashDefinition(plan,d),readOnly:d.readOnly,scope,restrict:d.restrict,opaque:d.opaque,unscopedReads:d.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),trustedOnly:d.trustedOnly,variants:d.variants.map(v=>({id:v.id,restrict:v.restrict,requires:requirements(v,d.surface),inputSchema:v.inputSchema,readOnly:v.readOnly,trustedOnly:v.trustedOnly,opaque:v.opaque,unscopedReads:v.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),scope:Object.fromEntries(v.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}))})),requires:requirements(d,d.surface)}}}));return JSON.parse(canonical({schemaVersion:1,access:plan.access?.userFields??{},targets}))}var securityFlags=["readOnly","trustedOnly","opaque","unscopedReads"],flagDirection=(field,value)=>(field==="readOnly"||field==="trustedOnly"?!value:value)?"widened":"narrowed",inclusion=(before,after)=>before.some(t=>!after.includes(t))?"widened":after.some(t=>!before.includes(t))?"narrowed":"changed",exposureDirection=(before,after)=>after.some(t=>!before.includes(t)&&t!==canonical("internal"))?"widened":before.some(t=>!after.includes(t)&&t!==canonical("internal"))?"narrowed":"changed",combine=directions=>directions.includes("widened")?"widened":directions.length&&directions.every(d=>d==="narrowed")?"narrowed":"changed",terms=p=>[...p.owner?[canonical({owner:p.owner})]:[],...p.terms.map(canonical)];function policyDirection(before,after,scope){let directions=[];for(let schema of new Set([...Object.keys(before),...Object.keys(after)])){let x=before[schema],y=after[schema];canonical(x)!==canonical(y)&&directions.push(scope&&(!x||!y)?x?"narrowed":"widened":inclusion(x?terms(x):[],y?terms(y):[]))}return combine(directions)}function requiresDirection(x,y){let directions=[];return canonical(x.all)!==canonical(y.all)&&directions.push(inclusion(x.all.map(canonical),y.all.map(canonical))),x.guard!==y.guard&&directions.push(y.guard?x.guard?"changed":"narrowed":"widened"),combine(directions)}function diffApp(a,b){if(a.schemaVersion!==1||b.schemaVersion!==1)throw new TypeError("Unsupported describe schemaVersion.");let changes=[],old=new Map(a.targets.map(t=>[t._meta.id,t])),next=new Map(b.targets.map(t=>[t._meta.id,t]));for(let id of[...new Set([...old.keys(),...next.keys()])].sort()){let before=old.get(id),after=next.get(id);if(!before||!after){changes.push({id,field:"target",direction:before?"narrowed":"widened"});continue}let x=before._meta,y=after._meta,start=changes.length,record=(field,left,right,direction)=>{canonical(left)!==canonical(right)&&changes.push({id,field,direction,before:left,after:right})};record("scope",x.scope,y.scope,policyDirection(x.scope,y.scope,!0)),record("restrict",x.restrict,y.restrict,policyDirection(x.restrict,y.restrict,!1)),record("requires",x.requires,y.requires,requiresDirection(x.requires,y.requires));for(let field of["surfaces","triggers"])record(field,x[field],y[field],exposureDirection(x[field].map(canonical),y[field].map(canonical)));for(let field of securityFlags)record(field,x[field],y[field],flagDirection(field,y[field]));record("inputSchema",before.inputSchema,after.inputSchema,"changed");let directions=[],variants=new Map(x.variants.map(v=>[v.id,v])),added=new Map(y.variants.map(v=>[v.id,v]));for(let name of new Set([...variants.keys(),...added.keys()])){let left=variants.get(name),right=added.get(name);if(canonical(left)!==canonical(right))if(!left||!right)directions.push(left?"narrowed":"widened");else{canonical(left.scope)!==canonical(right.scope)&&directions.push(policyDirection(left.scope,right.scope,!0)),canonical(left.restrict)!==canonical(right.restrict)&&directions.push(policyDirection(left.restrict,right.restrict,!1)),canonical(left.requires)!==canonical(right.requires)&&directions.push(requiresDirection(left.requires,right.requires)),canonical(left.inputSchema)!==canonical(right.inputSchema)&&directions.push("changed");for(let field of securityFlags)left[field]!==right[field]&&directions.push(flagDirection(field,right[field]))}}record("variants",x.variants,y.variants,combine(directions)),changes.length===start&&x.targetHash!==y.targetHash&&changes.push({id,field:"behavior",direction:"changed"})}return changes}export{describeApp,diffApp,matchTarget,targetHash};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppDescription, DescribeTarget } from "./index.js";
|
|
2
|
+
/** A request as a gateway sees it: a REST or Admin method and URL pathname, or an MCP `tools/call` tool name and optional endpoint path. */
|
|
3
|
+
export type TargetRequest = {
|
|
4
|
+
readonly surface: "rest" | "admin";
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly surface: "mcp";
|
|
9
|
+
readonly tool: string;
|
|
10
|
+
readonly path?: string;
|
|
11
|
+
};
|
|
12
|
+
type Meta = DescribeTarget["_meta"];
|
|
13
|
+
/**
|
|
14
|
+
* The access target a generated router resolves for a request, from `mantle describe` JSON alone: no application, database
|
|
15
|
+
* or network. REST and Admin take the URL pathname (Admin's under `/admin/api`) and follow the routers' path, method and
|
|
16
|
+
* precedence rules; MCP takes the `tools/call` name and, optionally, the `/mcp` or `/mcp/admin` path. Unserved is null.
|
|
17
|
+
*/
|
|
18
|
+
export declare function matchTarget(describe: Pick<AppDescription, "targets">, request: TargetRequest): Meta | null;
|
|
19
|
+
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{canonical,hash,hashDefinition,targetHash}from"./chunk-
|
|
1
|
+
import{canonical,hash,hashDefinition,targetHash}from"./chunk-P6PP6VVV.js";export{canonical,hash,hashDefinition,targetHash};
|
|
@@ -5,7 +5,7 @@ import { adminOrder } from "../domain/adminOrder.js";
|
|
|
5
5
|
import { declaredType, serverOwned } from "../domain/columns.js";
|
|
6
6
|
import { wireScalars, wireReturning } from "../domain/wireScalars.js";
|
|
7
7
|
import { recipesOf } from "../domain/recipes.js";
|
|
8
|
-
import { refChecks } from "../build/compiler.js";
|
|
8
|
+
import { lockCondition, refChecks } from "../build/compiler.js";
|
|
9
9
|
import { renderTerm } from "../kernel/store.js";
|
|
10
10
|
const ref = (...names) => ({ ColumnRef: { fields: names.map(sval => ({ String: { sval } })) } });
|
|
11
11
|
const op = (lexpr, rexpr) => ({ A_Expr: { kind: "AEXPR_OP", name: [{ String: { sval: "=" } }], lexpr, rexpr } });
|
|
@@ -36,6 +36,10 @@ export async function adminEntries(plan, dialect, wire, refusals) {
|
|
|
36
36
|
schemas[relation.name] = relation;
|
|
37
37
|
relation = relation.translates ? plan.schemas[relation.translates.parent.toLowerCase()] : undefined;
|
|
38
38
|
}
|
|
39
|
+
const lockReads = (n) => { if (!n || typeof n !== "object")
|
|
40
|
+
return; const v = n.RangeVar; if (v && plan.schemas[v.relname])
|
|
41
|
+
schemas[v.relname] ??= plan.schemas[v.relname]; Object.values(n).forEach(lockReads); };
|
|
42
|
+
lockReads(s.locks);
|
|
39
43
|
const ctx = (kind, trusted, inputs) => ({ dialect, schemas, inputs, kind, mode: trusted ? "trusted" : "admin", uncheckedRefs: true });
|
|
40
44
|
const refs = (child) => Object.fromEntries(refChecks(s, plan.schemas).filter(r => writable.includes(r.column)).map(r => {
|
|
41
45
|
const holes = [], hole = (value) => { const token = wire.param(marker + 1 + holes.length); holes.push([token, value]); return token; };
|
|
@@ -58,6 +62,17 @@ export async function adminEntries(plan, dialect, wire, refusals) {
|
|
|
58
62
|
const usedNames = new Set([...columns, ...(parent ? fields(parent) : [])].map(name => name.toLowerCase()));
|
|
59
63
|
const cursorAliases = [0, 1].map(i => { let name = "_mantle_admin_key" + i; while (usedNames.has(name.toLowerCase()))
|
|
60
64
|
name += "_"; usedNames.add(name.toLowerCase()); return { name, sql: wire.ident(name) }; });
|
|
65
|
+
const lockColumns = s.locks?.length ? ["update", "delete"].map(verb => {
|
|
66
|
+
let name = "_mantle_admin_lock_" + verb;
|
|
67
|
+
while (usedNames.has(name.toLowerCase()))
|
|
68
|
+
name += "_";
|
|
69
|
+
usedNames.add(name.toLowerCase());
|
|
70
|
+
const qualify = (n) => { if (!n || typeof n !== "object")
|
|
71
|
+
return; const f = n.ColumnRef?.fields; if (f?.length === 1)
|
|
72
|
+
f.unshift({ String: { sval: s.name } }); Object.values(n).forEach(qualify); };
|
|
73
|
+
const held = s.locks.filter(l => l.on.includes(verb)).map(l => { const when = structuredClone(l.when); qualify(when); return { CaseWhen: { expr: { BooleanTest: { arg: lockCondition(when, plan.schemas, dialect.lowering), booltesttype: "IS_NOT_FALSE" } }, result: { A_Const: { sval: { sval: l.name } } } } }; });
|
|
74
|
+
return { name, sql: (held.length ? expr({ CaseExpr: { args: held } }) : "NULL") + " AS " + wire.ident(name) };
|
|
75
|
+
}) : undefined;
|
|
61
76
|
const ordered = adminOrder(s), fieldFacts = Object.create(null);
|
|
62
77
|
for (const column of [...system(s), ...writable]) {
|
|
63
78
|
const type = declaredType(s.fields, column) ?? (column === "version" ? "integer" : "text");
|
|
@@ -137,7 +152,8 @@ export async function adminEntries(plan, dialect, wire, refusals) {
|
|
|
137
152
|
if (v && typeof v === "object")
|
|
138
153
|
Array.isArray(v) ? v.forEach(rewrite) : rewrite(v); };
|
|
139
154
|
rewrite(rewritten);
|
|
140
|
-
|
|
155
|
+
const verb = c.verb === "update" || c.verb === "delete" ? c.verb : undefined, locks = Object.fromEntries((s.locks ?? []).filter(l => verb && l.on.includes(verb)).map(l => [l.name, l.message]));
|
|
156
|
+
return { ast: rewritten, binds: recipesOf({ binds: fixed }, ["id"], message => { throw Error(message); }), ...(c.probe && verb ? { lock: { sql: print(c.probe.ast), binds: recipesOf(c.probe, ["id"], message => { throw Error(message); }), schema: s.name, verb, locks } } : {}) };
|
|
141
157
|
};
|
|
142
158
|
const create = write(insertAst), patch = write(updateAst), remove = write(deleteAst), entryFields = Object.create(null);
|
|
143
159
|
const insert = create.ast.InsertStmt, update = patch.ast.UpdateStmt;
|
|
@@ -145,7 +161,7 @@ export async function adminEntries(plan, dialect, wire, refusals) {
|
|
|
145
161
|
entryFields[field] = { column: wire.ident(field), insert: expr(insert.selectStmt.SelectStmt.fromClause[0].RangeSubselect.subquery.SelectStmt.valuesLists[0].List.items[i]), ...(!protectedColumns(s).includes(field) && !serverOwned(s.schema, field) ? { update: wire.ident(field) + " = " + expr(update.targetList[updateFields.indexOf(field)].ResTarget.val) } : {}) };
|
|
146
162
|
const returnSQL = (ast) => " RETURNING " + ast.returningClause.exprs.map((t) => expr(t.ResTarget.val) + (t.ResTarget.name ? " AS " + wire.ident(t.ResTarget.name) : "")).join(",");
|
|
147
163
|
const created = trusted ? {} : refs((column, hole) => hole(column)), patched = trusted ? {} : refs(column => wire.ident(s.name) + "." + wire.ident(column));
|
|
148
|
-
return { projection, from, policy: policy ? expr(policy) : "", binds: recipesOf(compiled, [], message => { throw Error(message); }), fields: entryFields, create: { ...(Object.keys(created).length ? { refs: created } : {}), prefix: print(create.ast).slice(0, print(create.ast).indexOf(" (")) + " (", between: ") VALUES (", suffix: ")", binds: create.binds, columns: insert.cols.slice(writable.length).map((t) => wire.ident(t.ResTarget.name)), values: insert.selectStmt.SelectStmt.targetList.slice(writable.length ? 1 : 0).map((t) => expr(t.ResTarget.val)), returning: returnSQL(insert) }, patch: { ...(Object.keys(patched).length ? { refs: patched } : {}), prefix: print(patch.ast).slice(0, print(patch.ast).indexOf(" SET ")) + " SET ", binds: patch.binds, sets: update.targetList.slice(updateFields.length).map((t) => wire.ident(t.ResTarget.name) + " = " + expr(t.ResTarget.val)), where: " WHERE " + expr(update.whereClause), returning: returnSQL(update) }, remove: { sql: print(remove.ast), binds: remove.binds, row: true } };
|
|
164
|
+
return { projection, from, policy: policy ? expr(policy) : "", ...(lockColumns && !trusted ? { locks: { projection: lockColumns.map(c => c.sql).join(", "), update: lockColumns[0].name, delete: lockColumns[1].name, messages: Object.fromEntries(s.locks.map(l => [l.name, l.message])) } } : {}), binds: recipesOf(compiled, [], message => { throw Error(message); }), fields: entryFields, create: { ...(Object.keys(created).length ? { refs: created } : {}), prefix: print(create.ast).slice(0, print(create.ast).indexOf(" (")) + " (", between: ") VALUES (", suffix: ")", binds: create.binds, columns: insert.cols.slice(writable.length).map((t) => wire.ident(t.ResTarget.name)), values: insert.selectStmt.SelectStmt.targetList.slice(writable.length ? 1 : 0).map((t) => expr(t.ResTarget.val)), returning: returnSQL(insert) }, patch: { ...(Object.keys(patched).length ? { refs: patched } : {}), ...(patch.lock ? { lock: patch.lock } : {}), prefix: print(patch.ast).slice(0, print(patch.ast).indexOf(" SET ")) + " SET ", binds: patch.binds, sets: update.targetList.slice(updateFields.length).map((t) => wire.ident(t.ResTarget.name) + " = " + expr(t.ResTarget.val)), where: " WHERE " + expr(update.whereClause), returning: returnSQL(update) }, remove: { sql: print(remove.ast), binds: remove.binds, row: true, ...(remove.lock ? { lock: remove.lock } : {}) } };
|
|
149
165
|
}));
|
|
150
166
|
result[s.name] = { ...(s.userScopes?.length ? { userScopes: s.userScopes } : {}), cursorAliases, scoped: !!s.scope, path: "schemas/" + s.name + "/admin", marker: markerText, id: wire.ident(s.name) + "." + wire.ident("id"), fields: fieldFacts, orderFields: Object.keys(ordered), search: `(${wire.ident(s.name)}.${wire.ident("id")} = ${markerText}${s.search?.length ? ` OR ${wire.search(s.name, s.search, wire.ident(s.name), markerText)}` : ""})`, statistics: { rowsAlias, column: wire.ident(s.name) + "." + wire.ident("created_at"), time: postgres ? `extract(epoch FROM ${wire.ident(s.name)}.${wire.ident("created_at")}) * 1000000` : wire.ident(s.name) + "." + wire.ident("created_at"), subtype }, caller: modes[0], trusted: modes[1] };
|
|
151
167
|
}
|
|
@@ -60,22 +60,47 @@ function assertCleanable(label, dir, entry) {
|
|
|
60
60
|
throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
|
|
61
61
|
}
|
|
62
62
|
export const ownModules = async (entry) => (await build({ entryPoints: [entry], bundle: true, write: false, metafile: true, packages: "external", format: "esm", platform: "neutral", logLevel: "silent", absWorkingDir: process.cwd() })).metafile;
|
|
63
|
-
export async function bundleApp(entry, outdir, assets = join(outdir, "client")) {
|
|
63
|
+
export async function bundleApp(entry, outdir, assets = join(outdir, "client"), options = {}) {
|
|
64
64
|
const source = realpathSync(entry);
|
|
65
65
|
assertCleanable("Bundle outdir", outdir, source);
|
|
66
66
|
assertCleanable("The assets directory", assets, source);
|
|
67
67
|
const own = await ownModules(entry);
|
|
68
|
-
|
|
68
|
+
const worker = join(outdir, "worker");
|
|
69
|
+
const apart = () => {
|
|
70
|
+
if (within(worker, assets) || within(assets, worker))
|
|
71
|
+
throw new Error(`The assets directory ${assets} must not overlap the Worker directory ${worker}.`);
|
|
72
|
+
};
|
|
73
|
+
const refuse = (error) => {
|
|
74
|
+
if (!options.watch || !(error instanceof StaleGeneratedError))
|
|
75
|
+
throw error;
|
|
76
|
+
apart();
|
|
77
|
+
rmSync(worker, { recursive: true, force: true });
|
|
78
|
+
console.error(`${error.summary} Run your generate script (mantle generate ...). ${worker} is removed, so Wrangler reports its entry missing; a running wrangler dev holds requests and rebuilds when ${error.dir} changes.`);
|
|
79
|
+
};
|
|
80
|
+
let dirs;
|
|
81
|
+
try {
|
|
82
|
+
dirs = generatedDirs(own);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
refuse(error);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (dirs.some(dir => generatedTarget(join(dir, "generated.ts"))?.startsWith("bun-"))) {
|
|
69
89
|
if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
|
|
70
90
|
await bundleAdmin(source, outdir, join(assets, "admin"));
|
|
71
91
|
else
|
|
72
92
|
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).");
|
|
73
93
|
return;
|
|
74
94
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
apart();
|
|
96
|
+
let metafile;
|
|
97
|
+
try {
|
|
98
|
+
metafile = await bundle(entry, worker);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
refuse(error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
79
104
|
if (Object.keys(metafile.inputs).some(input => /(?:^|\/)mantle-admin\/(?:dist|src)\/server\/index\.[jt]s$/.test(input.replaceAll("\\", "/")))) {
|
|
80
105
|
await bundleAdmin(source, outdir, join(assets, "admin"));
|
|
81
106
|
}
|
|
@@ -116,12 +141,22 @@ function installed(manifest) {
|
|
|
116
141
|
}
|
|
117
142
|
return versions;
|
|
118
143
|
}
|
|
144
|
+
export class StaleGeneratedError extends Error {
|
|
145
|
+
dir;
|
|
146
|
+
summary;
|
|
147
|
+
constructor(dir, reasons) {
|
|
148
|
+
const summary = `Generated output in ${dir} is stale (${reasons.join("; ")}).`;
|
|
149
|
+
super(`${summary} Run your generate script (mantle generate ...) and rebuild.`);
|
|
150
|
+
this.dir = dir;
|
|
151
|
+
this.summary = summary;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
119
154
|
function generatedDirs(metafile) {
|
|
120
155
|
const dirs = [...new Set(Object.keys(metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))))];
|
|
121
156
|
for (const dir of dirs) {
|
|
122
|
-
const
|
|
157
|
+
const stale = staleInputs(resolve(dir, "generated.ts"));
|
|
123
158
|
if (stale.length)
|
|
124
|
-
throw new
|
|
159
|
+
throw new StaleGeneratedError(relative(process.cwd(), dir), stale);
|
|
125
160
|
}
|
|
126
161
|
return dirs;
|
|
127
162
|
}
|
|
@@ -60,7 +60,7 @@ try {
|
|
|
60
60
|
const [entry, outdir] = positionals;
|
|
61
61
|
if (!entry || !outdir || positionals.length !== 2 || values.admin || values.site || values.auth || values.wrangler || values.check)
|
|
62
62
|
throw new Error(usage);
|
|
63
|
-
await (await import("./bundle.js")).bundleApp(entry, outdir, values.assets);
|
|
63
|
+
await (await import("./bundle.js")).bundleApp(entry, outdir, values.assets, { watch: process.env.WRANGLER_COMMAND === "dev" });
|
|
64
64
|
}
|
|
65
65
|
else {
|
|
66
66
|
const [target, destination, ...paths] = positionals;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describeApp } from "../domain/describe/index.js";
|
|
2
|
-
import { compilePlan, viewOutputs, sqliteCompiler, postgresCompiler, d1Dialect, postgresDialect, lowerPlan, sqliteSchema, postgresSchema } from "../build/compiler.js";
|
|
2
|
+
import { compilePlan, lockCondition, viewOutputs, sqliteCompiler, postgresCompiler, d1Dialect, postgresDialect, lowerPlan, sqliteSchema, postgresSchema } from "../build/compiler.js";
|
|
3
3
|
import { wireReturning, wireScalars } from "../domain/wireScalars.js";
|
|
4
4
|
import { boundaryType, outputType } from "../domain/columns.js";
|
|
5
5
|
import { castCursor, provePaging } from "../domain/paging.js";
|
|
@@ -11,6 +11,10 @@ import { publishingSql } from "../usecase/publishing.js";
|
|
|
11
11
|
import { searchDiagnostics } from "../usecase/search.js";
|
|
12
12
|
import { generate } from "../usecase/generate.js";
|
|
13
13
|
import { generatedFiles } from "../domain/entry.js";
|
|
14
|
+
const coreLocks = (dialect, schemas) => Object.fromEntries(Object.entries(schemas).flatMap(([name, s]) => s.locks?.length ? [[name, s.locks.map(l => ({
|
|
15
|
+
name: l.name, on: l.on, message: l.message,
|
|
16
|
+
sql: dialect.print({ SelectStmt: { targetList: [{ ResTarget: { val: lockCondition(l.when, schemas, dialect.lowering) } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, schemas).replace(/^SELECT /, ""),
|
|
17
|
+
}))]] : []));
|
|
14
18
|
export const coreNewId = (dialect, schemas) => dialect.print({ SelectStmt: { targetList: [{ ResTarget: { val: dialect.lowering.newId() } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, schemas).replace(/^SELECT /, "");
|
|
15
19
|
const CORE_CAST = { json: "jsonb", timestamptz: "timestamptz", date: "date" };
|
|
16
20
|
function castInputs(statement, path, refusals) {
|
|
@@ -38,6 +42,14 @@ function adminCreateWarnings(plan) {
|
|
|
38
42
|
.filter(([name, property]) => property?.readOnly === true && (s.schema.required ?? []).includes(name) && property.default === undefined)
|
|
39
43
|
.map(([name]) => `Schema '${s.name}' field '${name}' is required and readOnly without a default, so Admin's generic create cannot fill it. Give it a default (Admin stores it on create), or create the records through a Procedure with uiSchema.collectionAction: ${s.name}.`)));
|
|
40
44
|
}
|
|
45
|
+
function adminSearchWarnings(plan) {
|
|
46
|
+
return Object.values(plan.schemas).filter(s => !s.translates && !s.search?.length).flatMap(s => {
|
|
47
|
+
const properties = (s.schema.properties ?? {});
|
|
48
|
+
const primary = s.uiSchema?.["list"]?.primaryField;
|
|
49
|
+
const field = [primary, "title"].find((f) => typeof f === "string" && [properties[f]?.type].flat().includes("string") && !["date", "date-time"].includes(String(properties[f]?.format)) && properties[f]?.["x-mantle-ref"] == null);
|
|
50
|
+
return field ? [`Schema '${s.name}' declares no searchableFields, so Admin's list search matches the entry id only and a search for its '${field}' finds nothing. Add searchableFields: [${field}] to search it.`] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
41
53
|
function outputNameWarnings(plan) {
|
|
42
54
|
const warnings = [];
|
|
43
55
|
const inspect = (kind, name, stmts) => {
|
|
@@ -139,6 +151,8 @@ export async function generateFromSources(request, schema) {
|
|
|
139
151
|
if (!seen.has(statement)) {
|
|
140
152
|
seen.add(statement);
|
|
141
153
|
castInputs(statement, `${kind}/${name}`, refusals);
|
|
154
|
+
if (statement.probe)
|
|
155
|
+
castInputs(statement.probe, `${kind}/${name}`, refusals);
|
|
142
156
|
if ("paged" in statement)
|
|
143
157
|
for (const page of Object.values(statement.paged)) {
|
|
144
158
|
const inputPage = { ...statement, sql: page.sql };
|
|
@@ -153,7 +167,7 @@ export async function generateFromSources(request, schema) {
|
|
|
153
167
|
const adminReads = request.admin ? await adminBindingsOf(compiled.plan, dialect, paging, wire, refusals) : undefined;
|
|
154
168
|
const { adminPages, adminEntries } = adminReads ?? {};
|
|
155
169
|
const report = await describeApp(compiled.plan);
|
|
156
|
-
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), paging });
|
|
170
|
+
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), locks: coreLocks(dialect, compiled.plan.schemas), paging });
|
|
157
171
|
if (refusals.length) {
|
|
158
172
|
const diagnostics = result.ok ? [] : result.diagnostics.filter(d => !searchProblems.some(p => p.path === d.path) ||
|
|
159
173
|
!["Every reachable visibility mode must have canonical lowered SQL.", "Both caller and trusted SQL must be lowered."].includes(d.message));
|
|
@@ -171,5 +185,5 @@ export async function generateFromSources(request, schema) {
|
|
|
171
185
|
const gatewayFiles = generatedFiles({ "access-targets.ts": `import type { AccessTarget } from "@aotter/mantle/contracts";\nexport const targets: Readonly<Record<string, AccessTarget>> = ${JSON.stringify(accessTargets)};\n` });
|
|
172
186
|
const ddl = [...(postgres ? postgresSchema : sqliteSchema)(compiled.plan.schemas), ...publishingSql(compiled.plan.schemas, !postgres), ...(request.admin ? adminIndexes(compiled.plan.schemas, !postgres) : []), ...(schema?.() ?? [])];
|
|
173
187
|
const { "site-schema.sql": siteSql = "", ...files } = result.files;
|
|
174
|
-
return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
188
|
+
return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? [...adminCreateWarnings(compiled.plan), ...adminSearchWarnings(compiled.plan)] : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
175
189
|
}
|
package/dist/kernel/binds.d.ts
CHANGED
|
@@ -32,16 +32,31 @@ export type Recipe = {
|
|
|
32
32
|
readonly name: string; /** Only a date-time, a date or JSON is converted, so only those carry a type. */
|
|
33
33
|
readonly type?: "timestamptz" | "date" | "json";
|
|
34
34
|
};
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* How a row write a Schema lock guards tells, when it changed no row, a lock from the row conflict (#1822): `sql` re-reads that
|
|
37
|
+
* row as its caller sees it (version and locks aside) and outputs `lock`, the name of a lock that holds, or NULL. `locks` maps
|
|
38
|
+
* each lock on this verb to its message.
|
|
39
|
+
*/
|
|
40
|
+
export interface LockProbe<B = readonly Recipe[]> {
|
|
41
|
+
readonly sql: string;
|
|
42
|
+
readonly binds: B;
|
|
43
|
+
readonly schema: string;
|
|
44
|
+
readonly verb: "update" | "delete";
|
|
45
|
+
readonly locks: Readonly<Record<string, string>>; /** The probe can also answer `publishing.required` (a Store update of a published row): this message. */
|
|
46
|
+
readonly required?: string;
|
|
47
|
+
}
|
|
48
|
+
/** One printed statement and how to bind it. `row`: it must affect exactly one row; `"some"`: at least one (a step with `onNoRows: conflict`). `lock`: only on a row op. */
|
|
36
49
|
export interface Step {
|
|
37
50
|
readonly sql: string;
|
|
38
51
|
readonly binds: readonly Recipe[];
|
|
39
|
-
readonly row?: boolean;
|
|
52
|
+
readonly row?: boolean | "some";
|
|
53
|
+
readonly lock?: LockProbe;
|
|
40
54
|
}
|
|
41
55
|
export type Bound = {
|
|
42
56
|
readonly sql: string;
|
|
43
57
|
readonly binds: unknown[];
|
|
44
|
-
readonly row?: boolean;
|
|
58
|
+
readonly row?: boolean | "some";
|
|
59
|
+
readonly lock?: LockProbe<unknown[]>;
|
|
45
60
|
};
|
|
46
61
|
/** The dialect functions the recipes in use call; generated code passes only those. */
|
|
47
62
|
export type Values = Partial<Pick<Dialect, "now" | "cutoff" | "timestamp" | "date">> & {
|
package/dist/kernel/binds.js
CHANGED
|
@@ -35,5 +35,5 @@ export function binds(d, recipes, caller, input, ms = 0) {
|
|
|
35
35
|
}
|
|
36
36
|
export function bound(d, steps, caller, input) {
|
|
37
37
|
const ms = Date.now();
|
|
38
|
-
return steps.map(s => ({ sql: s.sql, binds: binds(d, s.binds, caller, input, ms), row: s.row }));
|
|
38
|
+
return steps.map(s => ({ sql: s.sql, binds: binds(d, s.binds, caller, input, ms), row: s.row, ...(s.lock ? { lock: { ...s.lock, binds: binds(d, s.lock.binds, caller, input, ms) } } : {}) }));
|
|
39
39
|
}
|
|
@@ -112,6 +112,13 @@ export declare function deny(caller: Caller, allowed: boolean, path: string): vo
|
|
|
112
112
|
export declare const ROW_CONFLICT = "No row matched this change: it does not exist or is not visible to you, it changed since it was read, or its state no longer allows this change. Read it again before deciding whether to retry.";
|
|
113
113
|
/** CONFLICT for a write the database refused: a unique-index clash (`fields.kind` "unique"), or a row statement that did not change exactly one row ("row"). */
|
|
114
114
|
export declare const conflict: (path: string, unique: boolean, cause?: unknown) => ContractError;
|
|
115
|
+
/** A guarded row write that changed no row (#1822): CONFLICT `kind: "lock"` when its probe found the row visible and held by a lock, else the row conflict, which reveals nothing. */
|
|
116
|
+
export declare const lockConflict: (path: string, probe: {
|
|
117
|
+
readonly schema: string;
|
|
118
|
+
readonly verb: string;
|
|
119
|
+
readonly locks: Readonly<Record<string, string>>;
|
|
120
|
+
readonly required?: string;
|
|
121
|
+
}, rows: readonly Row[]) => ContractError;
|
|
115
122
|
/** A Schema `checks` violation (Mantle's SQLite check trigger, or PostgreSQL SQLSTATE 23514) is a CONFLICT naming the check. */
|
|
116
123
|
export declare const checkConflict: (path: string, error: unknown) => ContractError | undefined;
|
|
117
124
|
/** One message on every target, built from the parsed fields; the driver's own text stays in `cause`. */
|
package/dist/kernel/contracts.js
CHANGED
|
@@ -46,6 +46,15 @@ export function deny(caller, allowed, path) {
|
|
|
46
46
|
}
|
|
47
47
|
export const ROW_CONFLICT = "No row matched this change: it does not exist or is not visible to you, it changed since it was read, or its state no longer allows this change. Read it again before deciding whether to retry.";
|
|
48
48
|
export const conflict = (path, unique, cause) => new ContractError("CONFLICT", unique ? "A unique constraint of the Schema was violated." : ROW_CONFLICT, path, { cause, fields: { kind: unique ? "unique" : "row" } });
|
|
49
|
+
export const lockConflict = (path, probe, rows) => {
|
|
50
|
+
const lock = rows[0]?.lock;
|
|
51
|
+
if (lock === "publishing.required" && probe.required)
|
|
52
|
+
return new ContractError("INPUT_VALIDATION_FAILED", probe.required, path);
|
|
53
|
+
if (typeof lock !== "string" || !Object.hasOwn(probe.locks, lock))
|
|
54
|
+
return conflict(path, false);
|
|
55
|
+
const message = probe.locks[lock];
|
|
56
|
+
return new ContractError("CONFLICT", message, path, { fields: { kind: "lock", schema: probe.schema, verb: probe.verb, lock, message } });
|
|
57
|
+
};
|
|
49
58
|
export const checkConflict = (path, error) => {
|
|
50
59
|
const message = error?.message ?? "", marker = /MANTLE_CHECK ([^:\n]+): (.+)/.exec(message);
|
|
51
60
|
if (!/MANTLE_CHECK /.test(message))
|