@impetik/xeer-mcp 0.2.5 → 0.2.6
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 +4 -1
- package/dist/dev-session.d.ts +1 -1
- package/dist/network-policy.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +4 -4
- package/dist/test-run.d.ts +1 -1
- package/dist/xeer-cli.d.ts +1 -1
- package/package.json +8 -5
- package/vendor/spec/actions.d.ts +1250 -0
- package/vendor/spec/actions.js +805 -0
- package/vendor/spec/admin-sql.d.ts +59 -0
- package/vendor/spec/admin-sql.js +147 -0
- package/vendor/spec/admin.d.ts +110 -0
- package/vendor/spec/admin.js +58 -0
- package/vendor/spec/canonical.d.ts +3 -0
- package/vendor/spec/canonical.js +36 -0
- package/vendor/spec/diagnostics.d.ts +49 -0
- package/vendor/spec/diagnostics.js +500 -0
- package/vendor/spec/docs.d.ts +21 -0
- package/vendor/spec/docs.js +57 -0
- package/vendor/spec/events.d.ts +8 -0
- package/vendor/spec/events.js +21 -0
- package/vendor/spec/identity-keys.d.ts +36 -0
- package/vendor/spec/identity-keys.js +72 -0
- package/vendor/spec/index.d.ts +20 -0
- package/vendor/spec/index.js +20 -0
- package/vendor/spec/local-identity.d.ts +69 -0
- package/vendor/spec/local-identity.js +132 -0
- package/vendor/spec/network-policy.d.ts +16 -0
- package/vendor/spec/network-policy.js +50 -0
- package/vendor/spec/public-assets.d.ts +153 -0
- package/vendor/spec/public-assets.js +166 -0
- package/vendor/spec/review.d.ts +82 -0
- package/vendor/spec/review.js +175 -0
- package/vendor/spec/route.d.ts +43 -0
- package/vendor/spec/route.js +87 -0
- package/vendor/spec/schema-lifecycle.d.ts +6 -0
- package/vendor/spec/schema-lifecycle.js +59 -0
- package/vendor/spec/schema-plan.d.ts +98 -0
- package/vendor/spec/schema-plan.js +194 -0
- package/vendor/spec/schema.d.ts +166 -0
- package/vendor/spec/schema.js +409 -0
- package/vendor/spec/sql-expression.d.ts +91 -0
- package/vendor/spec/sql-expression.js +650 -0
- package/vendor/spec/state-export.d.ts +143 -0
- package/vendor/spec/state-export.js +341 -0
- package/vendor/spec/storage.d.ts +61 -0
- package/vendor/spec/storage.js +120 -0
- package/vendor/spec/table-ddl.d.ts +162 -0
- package/vendor/spec/table-ddl.js +508 -0
- package/vendor/spec/types.d.ts +275 -0
- package/vendor/spec/types.js +11 -0
- package/vendor/spec/value.d.ts +22 -0
- package/vendor/spec/value.js +72 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CAPABILITIES, SOURCE_FORMAT, } from './types.js';
|
|
3
|
+
import { STORAGE_DEFAULT_READ_BYTES, STORAGE_DEFAULT_WRITE_BYTES, STORAGE_MAX_OBJECT_BYTES, STORAGE_MAX_READ_BYTES, STORAGE_MAX_WRITE_BYTES, } from './storage.js';
|
|
4
|
+
import { SQL_EXPRESSION_MAX_LENGTH } from './sql-expression.js';
|
|
5
|
+
import { databaseDdl, indexColumns, referenceCycle, RESERVED_TABLE_PREFIXES, reservedTableName } from './table-ddl.js';
|
|
6
|
+
const identifier = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'must be an identifier');
|
|
7
|
+
const sourcePath = z.string().min(1).refine((value) => !value.includes('\\'), 'must use forward slashes');
|
|
8
|
+
const title = z.string().min(1).max(120)
|
|
9
|
+
.refine((value) => value === value.trim(), 'must not have leading or trailing whitespace')
|
|
10
|
+
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'must not contain control characters');
|
|
11
|
+
const language = z.string().max(35)
|
|
12
|
+
.regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/, 'must be a constrained BCP 47 language tag');
|
|
13
|
+
const description = z.string().max(300)
|
|
14
|
+
.refine((value) => value === value.trim(), 'must not have leading or trailing whitespace')
|
|
15
|
+
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'must not contain control characters');
|
|
16
|
+
const favicon = z.string().min(2).max(256)
|
|
17
|
+
.regex(/^\/[A-Za-z0-9._~!$&'()*+,;=:@/-]+$/, 'must be a root-relative public asset path')
|
|
18
|
+
.refine((value) => !value.includes('//'), 'must not contain empty path segments')
|
|
19
|
+
.refine((value) => value.split('/').slice(1).every((segment) => segment !== '.' && segment !== '..'), 'must not contain dot path segments');
|
|
20
|
+
/**
|
|
21
|
+
* A SQL expression, bounded but otherwise unexamined here: what may appear inside one is decided by
|
|
22
|
+
* `sql-expression.ts`, and checking it needs the table's field list, which a field cannot see. The
|
|
23
|
+
* table and manifest refinements below run the real check by generating the DDL.
|
|
24
|
+
*/
|
|
25
|
+
const expression = z.string().min(1).max(SQL_EXPRESSION_MAX_LENGTH);
|
|
26
|
+
const collation = z.enum(['binary', 'nocase', 'rtrim']);
|
|
27
|
+
const referentialAction = z.enum(['noAction', 'restrict', 'cascade', 'setNull', 'setDefault']);
|
|
28
|
+
const LOWERCASE_HEX = /^(?:[0-9a-f]{2})*$/;
|
|
29
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
30
|
+
/** The types a `default` is written in, one per field type. */
|
|
31
|
+
const DEFAULT_JS_TYPE = Object.freeze({
|
|
32
|
+
string: 'string', datetime: 'string', json: 'string', bytes: 'string', ref: 'string',
|
|
33
|
+
number: 'number', boolean: 'boolean',
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* A default has to satisfy the column it defaults, and SQLite will not say so. `CREATE TABLE` accepts
|
|
37
|
+
* a DEFAULT that every CHECK on the column rejects, and only fails once a row actually uses it — so
|
|
38
|
+
* a schema can be applied cleanly and then refuse the first insert that omits the field.
|
|
39
|
+
*/
|
|
40
|
+
function refineDefault(field, ctx) {
|
|
41
|
+
const value = field.default;
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return;
|
|
44
|
+
const issue = (message) => ctx.addIssue({ code: 'custom', path: ['default'], message });
|
|
45
|
+
if (field.generated)
|
|
46
|
+
return issue('is not valid on a generated column, which computes its own value');
|
|
47
|
+
const expected = DEFAULT_JS_TYPE[field.type];
|
|
48
|
+
if (expected !== undefined && typeof value !== expected) {
|
|
49
|
+
return issue(`must be a ${expected} for a ${field.type} field`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
return;
|
|
53
|
+
if (field.type === 'bytes' && !LOWERCASE_HEX.test(value)) {
|
|
54
|
+
return issue('must be an even number of lowercase hex digits for a bytes field');
|
|
55
|
+
}
|
|
56
|
+
if (field.type === 'datetime' && (!ISO_TIMESTAMP.test(value) || Number.isNaN(Date.parse(value)))) {
|
|
57
|
+
return issue('must be an ISO-8601 UTC timestamp for a datetime field');
|
|
58
|
+
}
|
|
59
|
+
if (field.type === 'json') {
|
|
60
|
+
try {
|
|
61
|
+
JSON.parse(value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return issue('must be valid JSON for a json field');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (field.enum !== undefined && !field.enum.includes(value)) {
|
|
68
|
+
return issue('is not one of the declared enum values, so no row could ever hold it');
|
|
69
|
+
}
|
|
70
|
+
if (field.maxLength !== undefined && value.length > field.maxLength) {
|
|
71
|
+
return issue(`is longer than the declared maxLength of ${field.maxLength}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const fieldSchema = z.strictObject({
|
|
75
|
+
type: z.enum(['string', 'number', 'boolean', 'datetime', 'bytes', 'json', 'ref']),
|
|
76
|
+
optional: z.boolean().optional(),
|
|
77
|
+
maxLength: z.number().int().positive().optional(),
|
|
78
|
+
unique: z.boolean().optional(),
|
|
79
|
+
enum: z.array(z.string().max(1_000)).min(1).max(1_000).optional(),
|
|
80
|
+
default: z.union([z.string().max(1_000), z.number(), z.boolean()]).optional(),
|
|
81
|
+
collate: collation.optional(),
|
|
82
|
+
table: identifier.optional(),
|
|
83
|
+
onDelete: referentialAction.optional(),
|
|
84
|
+
onUpdate: referentialAction.optional(),
|
|
85
|
+
generated: z.strictObject({ expression, stored: z.boolean().optional() }).optional(),
|
|
86
|
+
}).superRefine((field, ctx) => {
|
|
87
|
+
const only = (key, types, message) => {
|
|
88
|
+
if (field[key] !== undefined && !types.includes(field.type)) {
|
|
89
|
+
ctx.addIssue({ code: 'custom', path: [key], message });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
only('maxLength', ['string', 'bytes'], 'is only valid for string or bytes fields');
|
|
93
|
+
only('enum', ['string'], 'is only valid for a string field');
|
|
94
|
+
only('collate', ['string'], 'is only valid for a string field');
|
|
95
|
+
only('table', ['ref'], 'is only valid for a ref field');
|
|
96
|
+
only('onDelete', ['ref'], 'is only valid for a ref field');
|
|
97
|
+
only('onUpdate', ['ref'], 'is only valid for a ref field');
|
|
98
|
+
if (field.type === 'ref' && field.table === undefined) {
|
|
99
|
+
ctx.addIssue({ code: 'custom', path: ['table'], message: 'is required: a ref must name the table it references' });
|
|
100
|
+
}
|
|
101
|
+
if (field.enum !== undefined) {
|
|
102
|
+
for (const [position, value] of field.enum.entries()) {
|
|
103
|
+
if (field.enum.indexOf(value) !== position) {
|
|
104
|
+
ctx.addIssue({ code: 'custom', path: ['enum', position], message: `lists ${JSON.stringify(value)} twice` });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Both actions are accepted by CREATE TABLE and then fail every delete or update that fires them.
|
|
109
|
+
for (const [key, action] of [['onDelete', field.onDelete], ['onUpdate', field.onUpdate]]) {
|
|
110
|
+
if (action === 'setNull' && field.optional !== true) {
|
|
111
|
+
ctx.addIssue({ code: 'custom', path: [key], message: 'needs the field to be optional, because it writes NULL' });
|
|
112
|
+
}
|
|
113
|
+
if (action === 'setDefault' && field.default === undefined) {
|
|
114
|
+
ctx.addIssue({ code: 'custom', path: [key], message: 'needs the field to declare a default, because it writes one' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
refineDefault(field, ctx);
|
|
118
|
+
});
|
|
119
|
+
const indexTermSchema = z.union([
|
|
120
|
+
identifier,
|
|
121
|
+
z.strictObject({ column: identifier, collate: collation.optional(), desc: z.boolean().optional() }),
|
|
122
|
+
z.strictObject({ expression, desc: z.boolean().optional() }),
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* An index is a bare list of columns or the full form. The shorthand covers most indexes and reads
|
|
126
|
+
* better than the object it means, so it stays rather than being migrated away from.
|
|
127
|
+
*/
|
|
128
|
+
const indexSchema = z.union([
|
|
129
|
+
z.array(identifier).min(1),
|
|
130
|
+
z.strictObject({
|
|
131
|
+
columns: z.array(indexTermSchema).min(1),
|
|
132
|
+
unique: z.boolean().optional(),
|
|
133
|
+
where: expression.optional(),
|
|
134
|
+
}),
|
|
135
|
+
]);
|
|
136
|
+
const tableSchema = z.strictObject({
|
|
137
|
+
fields: z.record(identifier, fieldSchema),
|
|
138
|
+
indexes: z.record(identifier, indexSchema).optional(),
|
|
139
|
+
unique: z.array(z.array(identifier).min(1)).optional(),
|
|
140
|
+
checks: z.record(identifier, expression).optional(),
|
|
141
|
+
}).superRefine((table, ctx) => {
|
|
142
|
+
for (const [indexName, definition] of Object.entries(table.indexes ?? {})) {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const [position, field] of indexColumns(definition).entries()) {
|
|
145
|
+
if (!(field in table.fields)) {
|
|
146
|
+
ctx.addIssue({
|
|
147
|
+
code: 'custom',
|
|
148
|
+
path: ['indexes', indexName, position],
|
|
149
|
+
message: `references unknown field ${JSON.stringify(field)}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (seen.has(field)) {
|
|
153
|
+
ctx.addIssue({
|
|
154
|
+
code: 'custom',
|
|
155
|
+
path: ['indexes', indexName, position],
|
|
156
|
+
message: `duplicates field ${JSON.stringify(field)}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
seen.add(field);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [position, tuple] of (table.unique ?? []).entries()) {
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const [member, field] of tuple.entries()) {
|
|
165
|
+
if (!(field in table.fields)) {
|
|
166
|
+
ctx.addIssue({ code: 'custom', path: ['unique', position, member],
|
|
167
|
+
message: `references unknown field ${JSON.stringify(field)}` });
|
|
168
|
+
}
|
|
169
|
+
if (seen.has(field)) {
|
|
170
|
+
ctx.addIssue({ code: 'custom', path: ['unique', position, member],
|
|
171
|
+
message: `duplicates field ${JSON.stringify(field)}` });
|
|
172
|
+
}
|
|
173
|
+
seen.add(field);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const reserved of ['id', 'createdAt', 'updatedAt']) {
|
|
177
|
+
if (reserved in table.fields) {
|
|
178
|
+
ctx.addIssue({ code: 'custom', path: ['fields', reserved], message: 'is reserved by the runtime' });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* The rules a single table cannot check for itself, plus the generator as a backstop.
|
|
184
|
+
*
|
|
185
|
+
* A `ref` names another table, which only the whole schema can confirm; and every expression in the
|
|
186
|
+
* table — a CHECK, a generated column, an index predicate — is only checkable against the field list
|
|
187
|
+
* the generator already has. Rather than restate those rules, this runs `databaseDdl` and reports
|
|
188
|
+
* what it refuses. That makes the two impossible to disagree: a manifest passes `xeer check` exactly
|
|
189
|
+
* when the DDL for it can be generated, which is what `table-ddl.ts` exists to guarantee.
|
|
190
|
+
*/
|
|
191
|
+
function refineDatabaseTables(tables, ctx, base) {
|
|
192
|
+
for (const tableName of Object.keys(tables)) {
|
|
193
|
+
// A declared table is created under its own plain name, so `SELECT * FROM posts` is what an
|
|
194
|
+
// operator types. That is only safe if a declaration cannot claim a name the platform already
|
|
195
|
+
// uses: an application table called `xeer_logs` would otherwise shadow the runtime's own.
|
|
196
|
+
if (reservedTableName(tableName)) {
|
|
197
|
+
ctx.addIssue({
|
|
198
|
+
code: 'custom',
|
|
199
|
+
path: [...base, tableName],
|
|
200
|
+
message: `is a reserved table name; ${RESERVED_TABLE_PREFIXES.join(', ')} are reserved for the platform`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const fields = tables[tableName].fields ?? {};
|
|
204
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
205
|
+
if (field.type !== 'ref' || field.table === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
if (!Object.hasOwn(tables, field.table)) {
|
|
208
|
+
ctx.addIssue({
|
|
209
|
+
code: 'custom',
|
|
210
|
+
path: [...base, tableName, 'fields', fieldName, 'table'],
|
|
211
|
+
message: `references ${JSON.stringify(field.table)}, which this schema does not declare`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// A reference cycle has no order that can drop its tables; see `referenceCycle`.
|
|
217
|
+
const cycle = referenceCycle({ version: 1, tables: tables });
|
|
218
|
+
if (cycle) {
|
|
219
|
+
ctx.addIssue({
|
|
220
|
+
code: 'custom',
|
|
221
|
+
path: [...base, cycle[0]],
|
|
222
|
+
message: `takes part in a reference cycle (${cycle.join(' → ')}); the tables in one cannot be created or dropped in any order`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const tableName of Object.keys(tables)) {
|
|
226
|
+
try {
|
|
227
|
+
databaseDdl({ version: 1, tables: { [tableName]: tables[tableName] } });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
ctx.addIssue({
|
|
231
|
+
code: 'custom',
|
|
232
|
+
path: [...base, tableName],
|
|
233
|
+
message: error instanceof Error ? error.message : String(error),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** The normalized database fragment embedded in artifacts and review metadata. */
|
|
239
|
+
export const normalizedDatabaseSchema = z.strictObject({
|
|
240
|
+
version: z.number().int().positive(),
|
|
241
|
+
tables: z.record(identifier, tableSchema),
|
|
242
|
+
}).superRefine((schema, ctx) => refineDatabaseTables(schema.tables, ctx, ['tables']));
|
|
243
|
+
export const applicationManifestSchema = z.strictObject({
|
|
244
|
+
$schema: z.string().url().optional(),
|
|
245
|
+
format: z.literal(SOURCE_FORMAT),
|
|
246
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{1,62}$/, 'must be a lowercase slug between 2 and 63 characters'),
|
|
247
|
+
entrypoints: z.strictObject({
|
|
248
|
+
client: sourcePath,
|
|
249
|
+
server: sourcePath,
|
|
250
|
+
}),
|
|
251
|
+
app: z.strictObject({
|
|
252
|
+
spa: z.boolean().optional(),
|
|
253
|
+
title: title.optional(),
|
|
254
|
+
language: language.optional(),
|
|
255
|
+
description: description.optional(),
|
|
256
|
+
favicon: favicon.optional(),
|
|
257
|
+
}).optional(),
|
|
258
|
+
database: z.strictObject({
|
|
259
|
+
version: z.number().int().positive().optional(),
|
|
260
|
+
tables: z.record(identifier, tableSchema),
|
|
261
|
+
}).optional(),
|
|
262
|
+
/**
|
|
263
|
+
* The `storage` capability's declared limits (#29). Every field is capped at the platform ceiling
|
|
264
|
+
* here rather than clamped silently at call time: a manifest that asks for more than the platform
|
|
265
|
+
* gives is a repairable diagnostic, and an application whose declared limit is a lie is worse than
|
|
266
|
+
* one that will not build.
|
|
267
|
+
*/
|
|
268
|
+
storage: z.strictObject({
|
|
269
|
+
maxObjectBytes: z.number().int().positive().max(STORAGE_MAX_OBJECT_BYTES).optional(),
|
|
270
|
+
readBytes: z.number().int().positive().max(STORAGE_MAX_READ_BYTES).optional(),
|
|
271
|
+
writeBytes: z.number().int().positive().max(STORAGE_MAX_WRITE_BYTES).optional(),
|
|
272
|
+
}).optional(),
|
|
273
|
+
capabilities: z.array(z.enum(CAPABILITIES)).optional(),
|
|
274
|
+
budgets: z.strictObject({
|
|
275
|
+
queryRows: z.number().int().positive().max(10_000).optional(),
|
|
276
|
+
mutationWrites: z.number().int().positive().max(1_000).optional(),
|
|
277
|
+
requestBytes: z.number().int().positive().max(4 * 1024 * 1024).optional(),
|
|
278
|
+
responseBytes: z.number().int().positive().max(4 * 1024 * 1024).optional(),
|
|
279
|
+
// Zero is a declaration, not an omission: it says the application wants no server push at all,
|
|
280
|
+
// and the runtime answers `live_disabled` for it rather than a retryable budget refusal. The
|
|
281
|
+
// bound is `min(0)` and not `positive()` so the editor schema publishes `minimum: 0`.
|
|
282
|
+
liveConnections: z.number().int().min(0).max(1_000).optional(),
|
|
283
|
+
}).optional(),
|
|
284
|
+
}).superRefine((manifest, ctx) => {
|
|
285
|
+
// Caught here, at `xeer check` time, rather than as a failure when the schema is applied.
|
|
286
|
+
refineDatabaseTables(manifest.database?.tables ?? {}, ctx, ['database', 'tables']);
|
|
287
|
+
const capabilities = new Set(manifest.capabilities ?? []);
|
|
288
|
+
for (const [index, name] of (manifest.capabilities ?? []).entries()) {
|
|
289
|
+
if ((manifest.capabilities ?? []).indexOf(name) !== index) {
|
|
290
|
+
ctx.addIssue({ code: 'custom', path: ['capabilities', index], message: `is declared twice: ${JSON.stringify(name)}` });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (manifest.database && !capabilities.has('database')) {
|
|
294
|
+
ctx.addIssue({ code: 'custom', path: ['capabilities'], message: 'must include "database" when a database is declared' });
|
|
295
|
+
}
|
|
296
|
+
if (!manifest.database && capabilities.has('database')) {
|
|
297
|
+
ctx.addIssue({ code: 'custom', path: ['database'], message: 'must be declared when the database capability is enabled' });
|
|
298
|
+
}
|
|
299
|
+
// The same two-way rule, for the same reason: a config block nobody granted is dead configuration,
|
|
300
|
+
// and a granted capability with no block is a limit set nobody declared. Unlike `database`, whose
|
|
301
|
+
// block carries the whole schema, an empty `"storage": {}` is legitimate — it means "the platform
|
|
302
|
+
// defaults" — so the block must be *present*, not non-empty.
|
|
303
|
+
if (manifest.storage && !capabilities.has('storage')) {
|
|
304
|
+
ctx.addIssue({ code: 'custom', path: ['capabilities'], message: 'must include "storage" when a storage block is declared' });
|
|
305
|
+
}
|
|
306
|
+
if (!manifest.storage && capabilities.has('storage')) {
|
|
307
|
+
ctx.addIssue({ code: 'custom', path: ['storage'],
|
|
308
|
+
message: 'must be declared when the storage capability is enabled; use {} for the platform defaults' });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
/** Stable editor-facing schema location emitted into every scaffolded `xeer.app.json`. */
|
|
312
|
+
export const APPLICATION_MANIFEST_SCHEMA_URL = 'https://docs.xeer.run/application-v0.schema.json';
|
|
313
|
+
/**
|
|
314
|
+
* JSON Schema generated from the same Zod object that validates manifests at runtime. The few
|
|
315
|
+
* cross-field rules below mirror `superRefine` constraints that JSON Schema can express, while
|
|
316
|
+
* compiler diagnostics remain authoritative for rules such as index fields referring to a declared
|
|
317
|
+
* table field.
|
|
318
|
+
*/
|
|
319
|
+
const generatedApplicationManifestJsonSchema = z.toJSONSchema(applicationManifestSchema);
|
|
320
|
+
const generatedManifestProperties = generatedApplicationManifestJsonSchema.properties;
|
|
321
|
+
const describeProperty = (name, description) => ({
|
|
322
|
+
...generatedManifestProperties[name],
|
|
323
|
+
description,
|
|
324
|
+
});
|
|
325
|
+
export const applicationManifestJsonSchema = Object.freeze({
|
|
326
|
+
...generatedApplicationManifestJsonSchema,
|
|
327
|
+
$id: APPLICATION_MANIFEST_SCHEMA_URL,
|
|
328
|
+
title: 'Xeer application source manifest v0',
|
|
329
|
+
description: 'The complete declarative application manifest read by the Xeer compiler.',
|
|
330
|
+
properties: {
|
|
331
|
+
...generatedManifestProperties,
|
|
332
|
+
$schema: describeProperty('$schema', 'Editor schema hint. Xeer does not fetch this URL during validation.'),
|
|
333
|
+
format: describeProperty('format', 'Manifest protocol discriminator; exactly xeer.application-source.v0.'),
|
|
334
|
+
name: describeProperty('name', 'Lowercase application slug, between 2 and 63 characters.'),
|
|
335
|
+
entrypoints: describeProperty('entrypoints', 'Project-relative client and server source entrypoints.'),
|
|
336
|
+
app: describeProperty('app', 'Browser metadata and SPA behavior.'),
|
|
337
|
+
database: describeProperty('database', 'Typed application database schema. Requires the database capability.'),
|
|
338
|
+
storage: describeProperty('storage', 'Private object-storage limits. Requires the storage capability.'),
|
|
339
|
+
capabilities: {
|
|
340
|
+
...describeProperty('capabilities', 'Powers granted to the application. Duplicate names are invalid.'),
|
|
341
|
+
uniqueItems: true,
|
|
342
|
+
},
|
|
343
|
+
budgets: describeProperty('budgets', 'Per-operation resource ceilings, each lowerable from platform defaults.'),
|
|
344
|
+
},
|
|
345
|
+
allOf: [
|
|
346
|
+
{
|
|
347
|
+
if: { required: ['database'] },
|
|
348
|
+
then: { required: ['capabilities'], properties: {
|
|
349
|
+
capabilities: { type: 'array', contains: { const: 'database' } },
|
|
350
|
+
} },
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
if: { required: ['capabilities'], properties: {
|
|
354
|
+
capabilities: { type: 'array', contains: { const: 'database' } },
|
|
355
|
+
} },
|
|
356
|
+
then: { required: ['database'] },
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
if: { required: ['storage'] },
|
|
360
|
+
then: { required: ['capabilities'], properties: {
|
|
361
|
+
capabilities: { type: 'array', contains: { const: 'storage' } },
|
|
362
|
+
} },
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
if: { required: ['capabilities'], properties: {
|
|
366
|
+
capabilities: { type: 'array', contains: { const: 'storage' } },
|
|
367
|
+
} },
|
|
368
|
+
then: { required: ['storage'] },
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
export function parseApplicationManifest(value) {
|
|
373
|
+
return applicationManifestSchema.parse(value);
|
|
374
|
+
}
|
|
375
|
+
export function normalizeApplicationManifest(manifest) {
|
|
376
|
+
return {
|
|
377
|
+
format: SOURCE_FORMAT,
|
|
378
|
+
name: manifest.name,
|
|
379
|
+
entrypoints: { ...manifest.entrypoints },
|
|
380
|
+
app: {
|
|
381
|
+
spa: manifest.app?.spa ?? true,
|
|
382
|
+
title: manifest.app?.title ?? manifest.name,
|
|
383
|
+
language: manifest.app?.language ?? 'en',
|
|
384
|
+
description: manifest.app?.description ?? '',
|
|
385
|
+
favicon: manifest.app?.favicon ?? null,
|
|
386
|
+
},
|
|
387
|
+
database: { version: manifest.database?.version ?? 1, tables: manifest.database?.tables ?? {} },
|
|
388
|
+
// Normalized only when declared. The schema above has already rejected a block without the
|
|
389
|
+
// capability, so `manifest.storage` being present *is* the capability being granted.
|
|
390
|
+
storage: manifest.storage
|
|
391
|
+
? {
|
|
392
|
+
maxObjectBytes: manifest.storage.maxObjectBytes ?? STORAGE_MAX_OBJECT_BYTES,
|
|
393
|
+
readBytes: manifest.storage.readBytes ?? STORAGE_DEFAULT_READ_BYTES,
|
|
394
|
+
writeBytes: manifest.storage.writeBytes ?? STORAGE_DEFAULT_WRITE_BYTES,
|
|
395
|
+
}
|
|
396
|
+
: null,
|
|
397
|
+
capabilities: [...(manifest.capabilities ?? [])].sort(),
|
|
398
|
+
budgets: {
|
|
399
|
+
queryRows: manifest.budgets?.queryRows ?? 1_000,
|
|
400
|
+
mutationWrites: manifest.budgets?.mutationWrites ?? 100,
|
|
401
|
+
requestBytes: manifest.budgets?.requestBytes ?? 1_048_576,
|
|
402
|
+
responseBytes: manifest.budgets?.responseBytes ?? 1_048_576,
|
|
403
|
+
// Off unless the application asks for it. One browser tab holding a live query pins the
|
|
404
|
+
// application's Durable Object continuously, so a positive default charged every application
|
|
405
|
+
// for a feature most never used. Declare a positive value to turn it on.
|
|
406
|
+
liveConnections: manifest.budgets?.liveConnections ?? 0,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL text primitives every generated statement is built out of, and the one place an
|
|
3
|
+
* author-written expression is allowed to become SQL.
|
|
4
|
+
*
|
|
5
|
+
* A manifest may carry expressions — a table CHECK, a generated column, a partial index's WHERE, an
|
|
6
|
+
* index over `lower(title)`. There is no way to bind those as parameters: they are DDL, not values,
|
|
7
|
+
* so they reach SQLite as text. That makes them the only part of the manifest that could smuggle
|
|
8
|
+
* syntax into a statement, and the reason this module exists is to make "expression" a closed
|
|
9
|
+
* vocabulary rather than a passthrough.
|
|
10
|
+
*
|
|
11
|
+
* The expression is lexed into tokens and re-emitted from them. Nothing survives that the lexer did
|
|
12
|
+
* not recognize, which buys three things at once:
|
|
13
|
+
*
|
|
14
|
+
* - **Escaping.** A column reference is re-emitted through {@link sqlIdentifier} and a string
|
|
15
|
+
* through {@link sqlStringLiteral}, so quoting is structural rather than a rule someone has to
|
|
16
|
+
* remember. There is no path by which raw author text reaches the statement.
|
|
17
|
+
* - **Scope.** Every bare word is either a keyword, an allowlisted function, or a column of *this*
|
|
18
|
+
* table. A statement separator, a comment, a bind parameter, a qualified `other.column`, and the
|
|
19
|
+
* word `SELECT` all have no token, so a subquery cannot be written at all.
|
|
20
|
+
* - **Determinism.** Whitespace and keyword case are normalized on the way out, so the same
|
|
21
|
+
* expression always produces byte-identical DDL.
|
|
22
|
+
*
|
|
23
|
+
* The function allowlist holds only *deterministic* functions. That is not caution: SQLite refuses a
|
|
24
|
+
* non-deterministic function in a CHECK, a generated column, and an index, so `datetime('now')` in
|
|
25
|
+
* any of those positions is an error at CREATE time. Rejecting it here turns a failure when the
|
|
26
|
+
* schema is applied into a diagnostic when it is written.
|
|
27
|
+
*/
|
|
28
|
+
/** Every function an expression may call. Deterministic only — see the module comment. */
|
|
29
|
+
export declare const SQL_EXPRESSION_FUNCTIONS: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* How many arguments each allowlisted function takes, as `[minimum, maximum]`.
|
|
32
|
+
*
|
|
33
|
+
* SQLite reports a bad count as `wrong number of arguments to function lower()` when the statement
|
|
34
|
+
* is prepared, which for DDL means when the schema is applied rather than when it is written. The
|
|
35
|
+
* table is exhaustive over {@link SQL_EXPRESSION_FUNCTIONS} and a test holds it that way, because a
|
|
36
|
+
* function reachable without an entry would be a function nobody counts.
|
|
37
|
+
*
|
|
38
|
+
* `max` and `min` are the reason this is not merely tidiness. Both are scalar with two or more
|
|
39
|
+
* arguments and *aggregates* with one, and SQLite answers the aggregate form with `misuse of
|
|
40
|
+
* aggregate function max()` in a CHECK, a generated column and an index alike. Requiring two
|
|
41
|
+
* arguments keeps `max(position, 0)` and turns `max(position)` into a diagnostic.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SQL_FUNCTION_ARITY: Readonly<Record<string, readonly [number, number]>>;
|
|
44
|
+
/**
|
|
45
|
+
* The longest expression a manifest may declare. An expression is copied verbatim into the schema
|
|
46
|
+
* hash, the DDL, and every diagnostic that quotes it, so it is bounded for the same reason a field
|
|
47
|
+
* name is.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SQL_EXPRESSION_MAX_LENGTH = 1000;
|
|
50
|
+
/**
|
|
51
|
+
* The most interior nodes an expression may build.
|
|
52
|
+
*
|
|
53
|
+
* Cloudflare's SQLite refuses an expression tree deeper than 100. Measured in workerd by bisection:
|
|
54
|
+
* a chain of 100 bare terms — that is, 99 `AND` operators — parses, and 101 terms (100 operators)
|
|
55
|
+
* fails with "Expression tree is too large (maximum depth 100)". The limit counts operators rather
|
|
56
|
+
* than terms. The character cap does not stand in for it: 1000 characters buys well over 100 terms,
|
|
57
|
+
* so without a node budget a manifest could pass `xeer check` and then fail to apply.
|
|
58
|
+
*
|
|
59
|
+
* Every interior node of the tree is built by exactly one operator, function call, or grammar
|
|
60
|
+
* keyword, and a root-to-leaf path visits each of its nodes once — so depth never exceeds the count
|
|
61
|
+
* of those tokens. Counting them is therefore a sound upper bound, and one that stays cheap: it
|
|
62
|
+
* needs no parser, only the tokens the lexer already produced.
|
|
63
|
+
*
|
|
64
|
+
* What is deliberately *not* counted is anything that costs no depth. An `IN` list is a single node
|
|
65
|
+
* however many values it holds — 2000 of them parse, and 1000 of them still reject a non-member —
|
|
66
|
+
* so a long enum is free.
|
|
67
|
+
* Parentheses are free too: workerd parsed 200 nested ones without complaint.
|
|
68
|
+
*/
|
|
69
|
+
export declare const SQL_EXPRESSION_MAX_NODES = 90;
|
|
70
|
+
export declare class SqlExpressionError extends Error {
|
|
71
|
+
constructor(message: string);
|
|
72
|
+
}
|
|
73
|
+
/** `title` → `"title"`. Doubling the quote is the whole of SQLite's identifier escaping. */
|
|
74
|
+
export declare function sqlIdentifier(name: string): string;
|
|
75
|
+
/** `O'Brien` → `'O''Brien'`. Doubling the apostrophe is the whole of SQLite's string escaping. */
|
|
76
|
+
export declare function sqlStringLiteral(value: string): string;
|
|
77
|
+
/** `00ff` → `X'00ff'`. Lowercase hex only, so one byte sequence has exactly one spelling. */
|
|
78
|
+
export declare function sqlBlobLiteral(hex: string): string;
|
|
79
|
+
/** SQL has no spelling for NaN or an infinity, so a number that is not finite has no literal. */
|
|
80
|
+
export declare function sqlNumberLiteral(value: number): string;
|
|
81
|
+
export interface CompiledSqlExpressionV0 {
|
|
82
|
+
/** The canonical SQL text, safe to embed in a statement. */
|
|
83
|
+
readonly sql: string;
|
|
84
|
+
/** Every column the expression reads, sorted and deduplicated. */
|
|
85
|
+
readonly columns: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validate an author-written expression against the columns it is allowed to read, and return the
|
|
89
|
+
* canonical SQL for it. Throws {@link SqlExpressionError} with a message naming the rule it broke.
|
|
90
|
+
*/
|
|
91
|
+
export declare function compileSqlExpression(source: string, columns: readonly string[]): CompiledSqlExpressionV0;
|