@impetik/xeer-mcp 0.2.16 → 0.2.18
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/package.json +2 -2
- package/vendor/spec/actions.d.ts +5 -5
- package/vendor/spec/actions.js +11 -6
- package/vendor/spec/admin.d.ts +7 -5
- package/vendor/spec/admin.js +7 -21
- package/vendor/spec/diagnostics.js +99 -10
- package/vendor/spec/import-policy.d.ts +172 -0
- package/vendor/spec/import-policy.js +216 -0
- package/vendor/spec/index.d.ts +2 -0
- package/vendor/spec/index.js +2 -0
- package/vendor/spec/local-identity.d.ts +83 -8
- package/vendor/spec/local-identity.js +135 -11
- package/vendor/spec/page-cursor.d.ts +26 -0
- package/vendor/spec/page-cursor.js +45 -0
- package/vendor/spec/public-assets.d.ts +9 -6
- package/vendor/spec/public-assets.js +4 -3
- package/vendor/spec/review.js +2 -0
- package/vendor/spec/route.d.ts +13 -0
- package/vendor/spec/route.js +33 -0
- package/vendor/spec/schema-lifecycle.d.ts +10 -4
- package/vendor/spec/schema-lifecycle.js +32 -4
- package/vendor/spec/schema-plan.d.ts +10 -1
- package/vendor/spec/schema-plan.js +22 -3
- package/vendor/spec/schema.d.ts +16 -0
- package/vendor/spec/schema.js +44 -0
- package/vendor/spec/state-export.d.ts +22 -0
- package/vendor/spec/state-export.js +42 -2
- package/vendor/spec/type-check-profile.d.ts +67 -0
- package/vendor/spec/type-check-profile.js +78 -0
- package/vendor/spec/types.d.ts +134 -1
- package/vendor/spec/types.js +10 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyset page cursor shared by every paged read the platform serves.
|
|
3
|
+
*
|
|
4
|
+
* Every declared table is ordered by `(created_at, id)` and `id` is unique, so the pair is a total
|
|
5
|
+
* order and a cursor names an *exact position* in it rather than a count of rows to skip. That is
|
|
6
|
+
* the whole reason this is a cursor and not an `offset`: a row inserted or deleted between two
|
|
7
|
+
* pages shifts an offset and silently duplicates or skips a row, while a keyset position stays
|
|
8
|
+
* where it was pointing.
|
|
9
|
+
*
|
|
10
|
+
* Opaque by contract, and deliberately **not** signed. It encodes a position in data the caller
|
|
11
|
+
* already had to authenticate to reach and that the next page would have shown anyway, so a forged
|
|
12
|
+
* one discloses nothing — it merely starts the scan somewhere else in the caller's own rows. What
|
|
13
|
+
* it must do is *fail closed on garbage*, which is why decoding returns `null` rather than throwing
|
|
14
|
+
* or silently resetting to the first page: a caller that hands back a truncated or hand-written
|
|
15
|
+
* token gets a refusal it can act on, not page 1 wearing page 4's clothes.
|
|
16
|
+
*/
|
|
17
|
+
/** Mints the token that resumes a scan immediately after `(createdAt, id)`. */
|
|
18
|
+
export function encodePageCursor(createdAt, id) {
|
|
19
|
+
const text = JSON.stringify([createdAt, id]);
|
|
20
|
+
return btoa(String.fromCharCode(...new TextEncoder().encode(text)))
|
|
21
|
+
.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
22
|
+
}
|
|
23
|
+
/** The inverse, or `null` when the text is not a cursor this version wrote. */
|
|
24
|
+
export function decodePageCursor(cursor) {
|
|
25
|
+
try {
|
|
26
|
+
const padded = cursor.replaceAll('-', '+').replaceAll('_', '/');
|
|
27
|
+
const bytes = Uint8Array.from(atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
28
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
29
|
+
if (!Array.isArray(parsed) || parsed.length !== 2)
|
|
30
|
+
return null;
|
|
31
|
+
const [createdAt, id] = parsed;
|
|
32
|
+
if (typeof createdAt !== 'string' || typeof id !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
// A position is only usable if its `created_at` compares the way the column does. Lexicographic
|
|
35
|
+
// order over ISO-8601 text *is* chronological order, but only for well-formed text: a token
|
|
36
|
+
// carrying `"whenever"` would otherwise seek to a position the index can never reach and return
|
|
37
|
+
// an empty page forever, which reads as "no more rows" rather than as the bad token it is.
|
|
38
|
+
if (Number.isNaN(Date.parse(createdAt)) || id.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
return { createdAt, id };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -43,11 +43,12 @@ export declare const IMMUTABLE_ASSET_HASH_LENGTH = 64;
|
|
|
43
43
|
/**
|
|
44
44
|
* Upper bound on {@link PublicAssetsV0.retainedGenerations}.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
46
|
+
* Two, because two is what the control plane implements. A wider range was declared first and that
|
|
47
47
|
* was a promise the code did not keep: an artifact could ask for four generations and silently get
|
|
48
|
-
* one. A contract that can express a value nothing honours is worse than a narrow contract
|
|
48
|
+
* one. A contract that can express a value nothing honours is worse than a narrow contract, so this
|
|
49
|
+
* bound moves only in lockstep with the retention fold in the control plane's deployment service.
|
|
49
50
|
*/
|
|
50
|
-
export declare const MAX_RETAINED_ASSET_GENERATIONS =
|
|
51
|
+
export declare const MAX_RETAINED_ASSET_GENERATIONS = 2;
|
|
51
52
|
/**
|
|
52
53
|
* What a declared entry is an alias *of*. Kept as a string rather than a structured pair because its
|
|
53
54
|
* only consumers are humans reading an artifact and a diagnostic naming the file that produced a
|
|
@@ -95,9 +96,11 @@ export interface PublicAssetsV0 {
|
|
|
95
96
|
/**
|
|
96
97
|
* How many *previous* deployments' immutable assets keep serving alongside this one.
|
|
97
98
|
*
|
|
98
|
-
* The value is `0` or `
|
|
99
|
-
* the deployment this artifact replaces keeps answering for its content-addressed paths;
|
|
100
|
-
*
|
|
99
|
+
* The value is `0`, `1`, or `2` and nothing else — see {@link MAX_RETAINED_ASSET_GENERATIONS}.
|
|
100
|
+
* `1` means the deployment this artifact replaces keeps answering for its content-addressed paths;
|
|
101
|
+
* `2` extends that to the deployment before it, which is what an artifact carrying package-derived
|
|
102
|
+
* subresources asks for so a document cached across two deploys still resolves its assets; `0`
|
|
103
|
+
* means nothing is retained, and a client holding an older document gets a 404.
|
|
101
104
|
*
|
|
102
105
|
* Worth recording honestly: this is the *incoming* artifact selecting an obligation that is owed to
|
|
103
106
|
* documents emitted by the *outgoing* one, and deployment cadence is a control-plane concern rather
|
|
@@ -43,11 +43,12 @@ export const IMMUTABLE_ASSET_HASH_LENGTH = 64;
|
|
|
43
43
|
/**
|
|
44
44
|
* Upper bound on {@link PublicAssetsV0.retainedGenerations}.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
46
|
+
* Two, because two is what the control plane implements. A wider range was declared first and that
|
|
47
47
|
* was a promise the code did not keep: an artifact could ask for four generations and silently get
|
|
48
|
-
* one. A contract that can express a value nothing honours is worse than a narrow contract
|
|
48
|
+
* one. A contract that can express a value nothing honours is worse than a narrow contract, so this
|
|
49
|
+
* bound moves only in lockstep with the retention fold in the control plane's deployment service.
|
|
49
50
|
*/
|
|
50
|
-
export const MAX_RETAINED_ASSET_GENERATIONS =
|
|
51
|
+
export const MAX_RETAINED_ASSET_GENERATIONS = 2;
|
|
51
52
|
const IMMUTABLE_PATH = /^\/_xa\/[0-9a-f]{64}\/[A-Za-z0-9](?:[A-Za-z0-9._-]{0,63})$/u;
|
|
52
53
|
const ASSET_HASH = /^sha256:[0-9a-f]{64}$/u;
|
|
53
54
|
/** The URL a blob of bytes is served at, and the only sanctioned way to mint one. */
|
package/vendor/spec/review.js
CHANGED
|
@@ -60,6 +60,8 @@ const schemaPlanStep = z.discriminatedUnion('kind', [
|
|
|
60
60
|
fields: z.array(z.string().min(1)) }),
|
|
61
61
|
z.strictObject({ kind: z.literal('index.change'), table: z.string().min(1), index: z.string().min(1),
|
|
62
62
|
from: z.array(z.string().min(1)), to: z.array(z.string().min(1)) }),
|
|
63
|
+
z.strictObject({ kind: z.literal('table.idSource'), table: z.string().min(1),
|
|
64
|
+
from: z.enum(['runtime', 'application']), to: z.enum(['runtime', 'application']) }),
|
|
63
65
|
z.strictObject({ kind: z.literal('unknown.change'), path: z.string().min(1) }),
|
|
64
66
|
]);
|
|
65
67
|
const schemaPlan = z.strictObject({
|
package/vendor/spec/route.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ export interface EndpointRoute {
|
|
|
10
10
|
export declare function parseEndpointRoute(key: string): EndpointRoute | null;
|
|
11
11
|
export declare function endpointRouteIdentity(route: Pick<EndpointRoute, 'method' | 'shape'>): string;
|
|
12
12
|
export declare function endpointRouteMatches(route: Pick<EndpointRoute, 'method' | 'path'>, method: string, pathname: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The path parameters a matched request carries, keyed by the names the route declared.
|
|
15
|
+
*
|
|
16
|
+
* This is the value half of what `Params<Key>` in the SDK describes at the type level, and it is
|
|
17
|
+
* derived from the same route the dispatcher matched with — not from a second pattern written by
|
|
18
|
+
* hand. A route with no dynamic segments yields an empty object rather than `null`, because an
|
|
19
|
+
* endpoint always has parameters; a static one simply has none.
|
|
20
|
+
*
|
|
21
|
+
* Segments are percent-decoded, and a segment that is not valid percent-encoding is handed over
|
|
22
|
+
* verbatim instead of throwing: a malformed URL is the caller's problem to answer with a status,
|
|
23
|
+
* not a reason for the runtime to turn the request into a 500.
|
|
24
|
+
*/
|
|
25
|
+
export declare function endpointRouteParams(route: Pick<EndpointRoute, 'path'>, pathname: string): Readonly<Record<string, string>>;
|
|
13
26
|
export declare function normalizeEndpointRoutes(keys: Iterable<string>): EndpointRoute[];
|
|
14
27
|
/**
|
|
15
28
|
* The reserved runtime route that serves the live invalidation stream.
|
package/vendor/spec/route.js
CHANGED
|
@@ -30,6 +30,39 @@ export function endpointRouteMatches(route, method, pathname) {
|
|
|
30
30
|
const actual = pathname.split('/');
|
|
31
31
|
return expected.length === actual.length && expected.every((part, index) => part.startsWith(':') ? (actual[index]?.length ?? 0) > 0 : part === actual[index]);
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The path parameters a matched request carries, keyed by the names the route declared.
|
|
35
|
+
*
|
|
36
|
+
* This is the value half of what `Params<Key>` in the SDK describes at the type level, and it is
|
|
37
|
+
* derived from the same route the dispatcher matched with — not from a second pattern written by
|
|
38
|
+
* hand. A route with no dynamic segments yields an empty object rather than `null`, because an
|
|
39
|
+
* endpoint always has parameters; a static one simply has none.
|
|
40
|
+
*
|
|
41
|
+
* Segments are percent-decoded, and a segment that is not valid percent-encoding is handed over
|
|
42
|
+
* verbatim instead of throwing: a malformed URL is the caller's problem to answer with a status,
|
|
43
|
+
* not a reason for the runtime to turn the request into a 500.
|
|
44
|
+
*/
|
|
45
|
+
export function endpointRouteParams(route, pathname) {
|
|
46
|
+
const expected = route.path.split('/');
|
|
47
|
+
const actual = pathname.split('/');
|
|
48
|
+
// No prototype: under the open-record fallback a caller-supplied name must never resolve to
|
|
49
|
+
// `Object.prototype.toString` where the types promise `string | undefined`.
|
|
50
|
+
const params = Object.create(null);
|
|
51
|
+
for (const [index, part] of expected.entries()) {
|
|
52
|
+
if (!part.startsWith(':'))
|
|
53
|
+
continue;
|
|
54
|
+
params[part.slice(1)] = decodeSegment(actual[index] ?? '');
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze(params);
|
|
57
|
+
}
|
|
58
|
+
function decodeSegment(value) {
|
|
59
|
+
try {
|
|
60
|
+
return decodeURIComponent(value);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
33
66
|
export function normalizeEndpointRoutes(keys) {
|
|
34
67
|
const routes = [];
|
|
35
68
|
const identities = new Map();
|
|
@@ -18,10 +18,16 @@ export declare function schemaIdentityReference(): NormalizedDatabaseSchemaV0;
|
|
|
18
18
|
* one to upgrade.
|
|
19
19
|
*
|
|
20
20
|
* Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
|
|
21
|
-
* `normalizedSchemaPayload` moves this hash in the same commit that moves it, because
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* `normalizedSchemaPayload` moves this hash in the same commit that moves it, because the reference
|
|
22
|
+
* declares a value for every key — and, for a key the projection omits when it is defaulted, one table
|
|
23
|
+
* holding the default beside one that does not. What it cannot see is a change to how a value is
|
|
24
|
+
* *derived* for a shape the reference does not declare — index normalization has its own spellings, and
|
|
25
|
+
* only the ones written here are covered.
|
|
26
|
+
*
|
|
27
|
+
* This value moving between releases is expected and costs nothing on its own: it is quoted in a
|
|
28
|
+
* refusal, never compared for equality. What a release must not do is move an *application's*
|
|
29
|
+
* `schemaHash` without that application changing, which is a different question and the reason
|
|
30
|
+
* `idSource` is omitted when defaulted.
|
|
25
31
|
*/
|
|
26
32
|
export declare function schemaIdentityRevision(): `sha256:${string}`;
|
|
27
33
|
export declare function planSchemaChange(application: string, fromSchema: NormalizedDatabaseSchemaV0, toSchema: NormalizedDatabaseSchemaV0): SchemaPlanV0;
|
|
@@ -44,6 +44,19 @@ function normalizedSchemaPayload(schema) {
|
|
|
44
44
|
unique: (table.unique ?? []).map((tuple) => [...tuple]),
|
|
45
45
|
checks: Object.fromEntries(Object.keys(table.checks ?? {}).sort()
|
|
46
46
|
.map((checkName) => [checkName, table.checks[checkName]])),
|
|
47
|
+
// The one key here that is *omitted* when it holds its default, rather than materialized as
|
|
48
|
+
// every key above is. Both conventions are right for what they cover, and which one applies
|
|
49
|
+
// is decided by whether the key existed when the schemas being compared were written.
|
|
50
|
+
//
|
|
51
|
+
// A materialized default is what lets two spellings of the same declaration hash alike:
|
|
52
|
+
// `optional: false` and an absent `optional` are one column, and writing `false` for both
|
|
53
|
+
// says so. That works because every schema, of every age, has an answer for `optional`.
|
|
54
|
+
// `idSource` does not: a schema written before decision 0006 has no opinion about it, and
|
|
55
|
+
// materializing `"runtime"` for those would move the hash of every application that never
|
|
56
|
+
// asked for the capability — which is the `storage?` rule in `artifact-v0.md` read from the
|
|
57
|
+
// other side. Adding a capability must not change the identity of applications that do not
|
|
58
|
+
// use it, so a table that does not use this one contributes exactly the bytes it did before.
|
|
59
|
+
...(table.idSource === undefined || table.idSource === 'runtime' ? {} : { idSource: table.idSource }),
|
|
47
60
|
}];
|
|
48
61
|
})),
|
|
49
62
|
};
|
|
@@ -70,6 +83,13 @@ export function applicationSchemaIdentity(schema) {
|
|
|
70
83
|
* would each let a projection that read only the first of them hash this schema exactly as a correct
|
|
71
84
|
* one does.
|
|
72
85
|
*
|
|
86
|
+
* **Both values of a key the projection omits when it is defaulted.** `idSource` is the one key that is
|
|
87
|
+
* absent from the payload when it holds its default, so a single spelling of it would leave half the
|
|
88
|
+
* ways the projection can change invisible. `reference` declares `"application"`, which a build that
|
|
89
|
+
* stopped reading the key at all would drop; `beta` declares `"runtime"`, which contributes nothing
|
|
90
|
+
* today and would start contributing the moment a build stopped omitting the default. One of each is
|
|
91
|
+
* what makes both directions move this hash.
|
|
92
|
+
*
|
|
73
93
|
* **Every order-preserving array declared in an order sorting would change.** Object keys are sorted by
|
|
74
94
|
* canonical JSON and cannot carry order, but four arrays can and all four are semantic: an enum's
|
|
75
95
|
* values, the outer and inner arrays of a composite UNIQUE, and an index's columns — `UNIQUE (a, b)`
|
|
@@ -107,12 +127,14 @@ const SCHEMA_IDENTITY_REFERENCE = {
|
|
|
107
127
|
},
|
|
108
128
|
unique: [['plain', 'number'], ['optional', 'maxLength']],
|
|
109
129
|
checks: { positive: 'number > 0', bounded: 'maxLength IS NOT NULL' },
|
|
130
|
+
idSource: 'application',
|
|
110
131
|
},
|
|
111
132
|
beta: {
|
|
112
133
|
fields: { second: { type: 'string' }, first: { type: 'number' } },
|
|
113
134
|
indexes: { by_second: ['second'], by_first: ['first'] },
|
|
114
135
|
unique: [['second', 'first']],
|
|
115
136
|
checks: { nonzero: 'first <> 0', named: 'second <> \'\'' },
|
|
137
|
+
idSource: 'runtime',
|
|
116
138
|
},
|
|
117
139
|
},
|
|
118
140
|
};
|
|
@@ -133,10 +155,16 @@ export function schemaIdentityReference() {
|
|
|
133
155
|
* one to upgrade.
|
|
134
156
|
*
|
|
135
157
|
* Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
|
|
136
|
-
* `normalizedSchemaPayload` moves this hash in the same commit that moves it, because
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
158
|
+
* `normalizedSchemaPayload` moves this hash in the same commit that moves it, because the reference
|
|
159
|
+
* declares a value for every key — and, for a key the projection omits when it is defaulted, one table
|
|
160
|
+
* holding the default beside one that does not. What it cannot see is a change to how a value is
|
|
161
|
+
* *derived* for a shape the reference does not declare — index normalization has its own spellings, and
|
|
162
|
+
* only the ones written here are covered.
|
|
163
|
+
*
|
|
164
|
+
* This value moving between releases is expected and costs nothing on its own: it is quoted in a
|
|
165
|
+
* refusal, never compared for equality. What a release must not do is move an *application's*
|
|
166
|
+
* `schemaHash` without that application changing, which is a different question and the reason
|
|
167
|
+
* `idSource` is omitted when defaulted.
|
|
140
168
|
*/
|
|
141
169
|
export function schemaIdentityRevision() {
|
|
142
170
|
return applicationSchemaHash(SCHEMA_IDENTITY_REFERENCE);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApplicationSchemaIdentityV0, NormalizedApplicationManifestV0, ScalarType } from './types.js';
|
|
1
|
+
import type { ApplicationSchemaIdentityV0, NormalizedApplicationManifestV0, ScalarType, TableDefinition } from './types.js';
|
|
2
2
|
export declare const SCHEMA_PROTOCOL: "xeer.schema.v0";
|
|
3
3
|
export type NormalizedDatabaseSchemaV0 = NormalizedApplicationManifestV0['database'];
|
|
4
4
|
/**
|
|
@@ -8,6 +8,10 @@ export type NormalizedDatabaseSchemaV0 = NormalizedApplicationManifestV0['databa
|
|
|
8
8
|
*/
|
|
9
9
|
export declare const FIELD_ATTRIBUTES: readonly ["collate", "default", "enum", "generated", "onDelete", "onUpdate", "table", "unique"];
|
|
10
10
|
export type FieldAttribute = (typeof FIELD_ATTRIBUTES)[number];
|
|
11
|
+
/** Table keys with a step of their own, beside `fields`, `indexes`, `unique` and `checks`. */
|
|
12
|
+
export type IdSource = NonNullable<TableDefinition['idSource']>;
|
|
13
|
+
/** The default a table that says nothing about its ids gets, in the one place the plan reads it. */
|
|
14
|
+
export declare function tableIdSource(table: TableDefinition): IdSource;
|
|
11
15
|
export type SchemaPlanStepV0 = {
|
|
12
16
|
kind: 'table.add';
|
|
13
17
|
table: string;
|
|
@@ -20,6 +24,11 @@ export type SchemaPlanStepV0 = {
|
|
|
20
24
|
constraint: 'unique' | 'checks';
|
|
21
25
|
from: unknown;
|
|
22
26
|
to: unknown;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'table.idSource';
|
|
29
|
+
table: string;
|
|
30
|
+
from: IdSource;
|
|
31
|
+
to: IdSource;
|
|
23
32
|
} | {
|
|
24
33
|
kind: 'field.add';
|
|
25
34
|
table: string;
|
|
@@ -8,6 +8,10 @@ export const SCHEMA_PROTOCOL = 'xeer.schema.v0';
|
|
|
8
8
|
export const FIELD_ATTRIBUTES = Object.freeze([
|
|
9
9
|
'collate', 'default', 'enum', 'generated', 'onDelete', 'onUpdate', 'table', 'unique',
|
|
10
10
|
]);
|
|
11
|
+
/** The default a table that says nothing about its ids gets, in the one place the plan reads it. */
|
|
12
|
+
export function tableIdSource(table) {
|
|
13
|
+
return table.idSource ?? 'runtime';
|
|
14
|
+
}
|
|
11
15
|
export class SchemaPlanningError extends Error {
|
|
12
16
|
code;
|
|
13
17
|
constructor(code, message) {
|
|
@@ -61,6 +65,13 @@ function tableSteps(tableName, from, to) {
|
|
|
61
65
|
steps.push({ kind: 'table.constraint', table: tableName, constraint,
|
|
62
66
|
from: from[constraint] ?? null, to: to[constraint] ?? null });
|
|
63
67
|
}
|
|
68
|
+
// Read through the default, so declaring `"runtime"` on a table that had said nothing is the same
|
|
69
|
+
// table rather than a change — the same reading the hash takes when it omits the defaulted value.
|
|
70
|
+
const beforeIdSource = tableIdSource(from);
|
|
71
|
+
const afterIdSource = tableIdSource(to);
|
|
72
|
+
if (beforeIdSource !== afterIdSource) {
|
|
73
|
+
steps.push({ kind: 'table.idSource', table: tableName, from: beforeIdSource, to: afterIdSource });
|
|
74
|
+
}
|
|
64
75
|
// Indexes are compared through their normalized form, so `["slug"]` and `{ columns: ["slug"] }`
|
|
65
76
|
// are one index written two ways rather than a change from one to the other.
|
|
66
77
|
const terms = (index) => normalizedIndex(index).columns.map(indexTermLabel);
|
|
@@ -92,12 +103,20 @@ function tableSteps(tableName, from, to) {
|
|
|
92
103
|
}
|
|
93
104
|
/**
|
|
94
105
|
* Whether a step leaves the rows already stored valid. Nothing in the extended vocabulary does,
|
|
95
|
-
* with
|
|
96
|
-
*
|
|
106
|
+
* with two exceptions, and both are exceptions for the same reason: they govern what a *later* write
|
|
107
|
+
* is allowed to say and make no claim about what is already stored.
|
|
108
|
+
*
|
|
109
|
+
* A DEFAULT decides what an insert that omits the column receives, and says nothing about the rows
|
|
110
|
+
* already there. `idSource` decides who supplies the next row's id: turning it on leaves every stored
|
|
111
|
+
* uuid a valid primary key, and turning it off leaves every stored application id one too. Neither
|
|
112
|
+
* direction touches a column, an index, or a byte on disk — `id` is `TEXT PRIMARY KEY` under both —
|
|
113
|
+
* so a reset here would rewrite a table to produce the table it started with.
|
|
97
114
|
*/
|
|
98
115
|
function isCompatible(step) {
|
|
99
116
|
if (step.kind === 'table.add' || step.kind === 'index.add')
|
|
100
117
|
return true;
|
|
118
|
+
if (step.kind === 'table.idSource')
|
|
119
|
+
return true;
|
|
101
120
|
if (step.kind === 'field.add')
|
|
102
121
|
return step.optional;
|
|
103
122
|
if (step.kind === 'field.optional')
|
|
@@ -149,7 +168,7 @@ function unknownSchemaSteps(fromSchema, toSchema) {
|
|
|
149
168
|
for (const tableName of tableNames) {
|
|
150
169
|
const beforeTable = fromSchema.tables[tableName];
|
|
151
170
|
const afterTable = toSchema.tables[tableName];
|
|
152
|
-
steps.push(...unknownKeySteps(`database.tables.${tableName}`, beforeTable, afterTable, ['fields', 'indexes', 'unique', 'checks']));
|
|
171
|
+
steps.push(...unknownKeySteps(`database.tables.${tableName}`, beforeTable, afterTable, ['fields', 'indexes', 'unique', 'checks', 'idSource']));
|
|
153
172
|
const beforeFields = beforeTable?.fields ?? {};
|
|
154
173
|
const afterFields = afterTable?.fields ?? {};
|
|
155
174
|
const fieldNames = [...new Set([...Object.keys(beforeFields), ...Object.keys(afterFields)])].sort();
|
package/vendor/spec/schema.d.ts
CHANGED
|
@@ -62,6 +62,10 @@ export declare const normalizedDatabaseSchema: z.ZodObject<{
|
|
|
62
62
|
}, z.core.$strict>]>>>;
|
|
63
63
|
unique: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodString>>>;
|
|
64
64
|
checks: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
65
|
+
idSource: z.ZodOptional<z.ZodEnum<{
|
|
66
|
+
runtime: "runtime";
|
|
67
|
+
application: "application";
|
|
68
|
+
}>>;
|
|
65
69
|
}, z.core.$strict>>;
|
|
66
70
|
}, z.core.$strict>;
|
|
67
71
|
export declare const applicationManifestSchema: z.ZodObject<{
|
|
@@ -140,6 +144,10 @@ export declare const applicationManifestSchema: z.ZodObject<{
|
|
|
140
144
|
}, z.core.$strict>]>>>;
|
|
141
145
|
unique: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodString>>>;
|
|
142
146
|
checks: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
147
|
+
idSource: z.ZodOptional<z.ZodEnum<{
|
|
148
|
+
runtime: "runtime";
|
|
149
|
+
application: "application";
|
|
150
|
+
}>>;
|
|
143
151
|
}, z.core.$strict>>;
|
|
144
152
|
}, z.core.$strict>>;
|
|
145
153
|
storage: z.ZodOptional<z.ZodObject<{
|
|
@@ -151,12 +159,20 @@ export declare const applicationManifestSchema: z.ZodObject<{
|
|
|
151
159
|
database: "database";
|
|
152
160
|
storage: "storage";
|
|
153
161
|
}>>>;
|
|
162
|
+
client: z.ZodOptional<z.ZodObject<{
|
|
163
|
+
runtime: z.ZodOptional<z.ZodObject<{
|
|
164
|
+
provider: z.ZodLiteral<"preact">;
|
|
165
|
+
source: z.ZodOptional<z.ZodLiteral<"platform">>;
|
|
166
|
+
}, z.core.$strict>>;
|
|
167
|
+
}, z.core.$strict>>;
|
|
154
168
|
budgets: z.ZodOptional<z.ZodObject<{
|
|
155
169
|
queryRows: z.ZodOptional<z.ZodNumber>;
|
|
156
170
|
mutationWrites: z.ZodOptional<z.ZodNumber>;
|
|
157
171
|
requestBytes: z.ZodOptional<z.ZodNumber>;
|
|
158
172
|
responseBytes: z.ZodOptional<z.ZodNumber>;
|
|
173
|
+
clientBundleBytes: z.ZodOptional<z.ZodNumber>;
|
|
159
174
|
liveConnections: z.ZodOptional<z.ZodNumber>;
|
|
175
|
+
streamedBytes: z.ZodOptional<z.ZodNumber>;
|
|
160
176
|
}, z.core.$strict>>;
|
|
161
177
|
}, z.core.$strict>;
|
|
162
178
|
/** Stable editor-facing schema location emitted into every scaffolded `xeer.app.json`. */
|
package/vendor/spec/schema.js
CHANGED
|
@@ -138,6 +138,9 @@ const tableSchema = z.strictObject({
|
|
|
138
138
|
indexes: z.record(identifier, indexSchema).optional(),
|
|
139
139
|
unique: z.array(z.array(identifier).min(1)).optional(),
|
|
140
140
|
checks: z.record(identifier, expression).optional(),
|
|
141
|
+
// Who supplies a row's id. Absent means `"runtime"`, and absent is how it stays written: the
|
|
142
|
+
// normalized schema omits the default, so declaring it explicitly is accepted but changes nothing.
|
|
143
|
+
idSource: z.enum(['runtime', 'application']).optional(),
|
|
141
144
|
}).superRefine((table, ctx) => {
|
|
142
145
|
for (const [indexName, definition] of Object.entries(table.indexes ?? {})) {
|
|
143
146
|
const seen = new Set();
|
|
@@ -271,15 +274,34 @@ export const applicationManifestSchema = z.strictObject({
|
|
|
271
274
|
writeBytes: z.number().int().positive().max(STORAGE_MAX_WRITE_BYTES).optional(),
|
|
272
275
|
}).optional(),
|
|
273
276
|
capabilities: z.array(z.enum(CAPABILITIES)).optional(),
|
|
277
|
+
/**
|
|
278
|
+
* The client runtime selection (#212). Closed: only implemented provider/source combinations are
|
|
279
|
+
* accepted, and omitting any level of it normalizes to the platform-managed Preact runtime with
|
|
280
|
+
* identical semantics and build output.
|
|
281
|
+
*/
|
|
282
|
+
client: z.strictObject({
|
|
283
|
+
runtime: z.strictObject({
|
|
284
|
+
provider: z.literal('preact'),
|
|
285
|
+
source: z.literal('platform').optional(),
|
|
286
|
+
}).optional(),
|
|
287
|
+
}).optional(),
|
|
274
288
|
budgets: z.strictObject({
|
|
275
289
|
queryRows: z.number().int().positive().max(10_000).optional(),
|
|
276
290
|
mutationWrites: z.number().int().positive().max(1_000).optional(),
|
|
277
291
|
requestBytes: z.number().int().positive().max(4 * 1024 * 1024).optional(),
|
|
278
292
|
responseBytes: z.number().int().positive().max(4 * 1024 * 1024).optional(),
|
|
293
|
+
// The error tier for the minified pre-gzip client bundle, defaulting to the platform's 1 MiB.
|
|
294
|
+
// Bounded by the same 10 MiB ceiling every compiled module already has, so raising it can never
|
|
295
|
+
// promise bytes the artifact format refuses.
|
|
296
|
+
clientBundleBytes: z.number().int().positive().max(10 * 1024 * 1024).optional(),
|
|
279
297
|
// Zero is a declaration, not an omission: it says the application wants no server push at all,
|
|
280
298
|
// and the runtime answers `live_disabled` for it rather than a retryable budget refusal. The
|
|
281
299
|
// bound is `min(0)` and not `positive()` so the editor schema publishes `minimum: 0`.
|
|
282
300
|
liveConnections: z.number().int().min(0).max(1_000).optional(),
|
|
301
|
+
// Deliberately above `responseBytes`' 4 MiB ceiling: a streamed body that had to fit inside the
|
|
302
|
+
// buffered one would give an author no reason to stream. It is still declared and still bounded —
|
|
303
|
+
// an unbudgeted server→client channel is not something this platform ships (decision 0007).
|
|
304
|
+
streamedBytes: z.number().int().positive().max(64 * 1024 * 1024).optional(),
|
|
283
305
|
}).optional(),
|
|
284
306
|
}).superRefine((manifest, ctx) => {
|
|
285
307
|
// Caught here, at `xeer check` time, rather than as a failure when the schema is applied.
|
|
@@ -336,6 +358,7 @@ export const applicationManifestJsonSchema = Object.freeze({
|
|
|
336
358
|
app: describeProperty('app', 'Browser metadata and SPA behavior.'),
|
|
337
359
|
database: describeProperty('database', 'Typed application database schema. Requires the database capability.'),
|
|
338
360
|
storage: describeProperty('storage', 'Private object-storage limits. Requires the storage capability.'),
|
|
361
|
+
client: describeProperty('client', 'Client runtime selection. Optional; omission means the platform-managed Preact runtime.'),
|
|
339
362
|
capabilities: {
|
|
340
363
|
...describeProperty('capabilities', 'Powers granted to the application. Duplicate names are invalid.'),
|
|
341
364
|
uniqueItems: true,
|
|
@@ -395,6 +418,9 @@ export function normalizeApplicationManifest(manifest) {
|
|
|
395
418
|
}
|
|
396
419
|
: null,
|
|
397
420
|
capabilities: [...(manifest.capabilities ?? [])].sort(),
|
|
421
|
+
// Always normalized: omission and the explicit default are the same statement, and everything
|
|
422
|
+
// downstream (compiler facts, artifact provenance) reads one canonical value.
|
|
423
|
+
client: { runtime: { provider: 'preact', source: 'platform' } },
|
|
398
424
|
budgets: {
|
|
399
425
|
queryRows: manifest.budgets?.queryRows ?? 1_000,
|
|
400
426
|
mutationWrites: manifest.budgets?.mutationWrites ?? 100,
|
|
@@ -404,6 +430,24 @@ export function normalizeApplicationManifest(manifest) {
|
|
|
404
430
|
// application's Durable Object continuously, so a positive default charged every application
|
|
405
431
|
// for a feature most never used. Declare a positive value to turn it on.
|
|
406
432
|
liveConnections: manifest.budgets?.liveConnections ?? 0,
|
|
433
|
+
// Present only when declared, mirroring `clientBundleBytes` below. The normalized budgets go
|
|
434
|
+
// into the artifact payload verbatim, so materializing the platform default here would move
|
|
435
|
+
// the `artifactId` of every application that never asked for a streamed response — the exact
|
|
436
|
+
// identity invariant `storage` and `clientBundleBytes` exist to hold. The runtime applies
|
|
437
|
+
// {@link STREAMED_BYTES_DEFAULT} where it reads the budget instead.
|
|
438
|
+
//
|
|
439
|
+
// A positive default is safe here in a way `liveConnections` is not: this ceiling only ever
|
|
440
|
+
// applies to a stream `liveConnections` already admitted, so the default cannot open a channel
|
|
441
|
+
// on its own. It matches `responseBytes` so that turning an endpoint from buffered to streamed
|
|
442
|
+
// does not silently change how many bytes it may send.
|
|
443
|
+
...(manifest.budgets?.streamedBytes !== undefined
|
|
444
|
+
? { streamedBytes: manifest.budgets.streamedBytes }
|
|
445
|
+
: {}),
|
|
446
|
+
// Present only when declared, mirroring `storage`: the platform default is applied at build
|
|
447
|
+
// time, so an application that never declares the budget hashes exactly as before (#212).
|
|
448
|
+
...(manifest.budgets?.clientBundleBytes !== undefined
|
|
449
|
+
? { clientBundleBytes: manifest.budgets.clientBundleBytes }
|
|
450
|
+
: {}),
|
|
407
451
|
},
|
|
408
452
|
};
|
|
409
453
|
}
|
|
@@ -100,6 +100,28 @@ export declare class StateTransferError extends Error {
|
|
|
100
100
|
/** JSON shape shared by the runtime response, the control-plane envelope, and CLI diagnostics. */
|
|
101
101
|
toDiagnostic(): StateTransferDiagnostic;
|
|
102
102
|
}
|
|
103
|
+
/** The longest a row id may be, in UTF-16 code units. */
|
|
104
|
+
export declare const RECORD_ID_MAX_LENGTH = 256;
|
|
105
|
+
export type RecordIdRefusal = 'record_id_empty' | 'record_id_too_long' | 'record_id_invalid';
|
|
106
|
+
/**
|
|
107
|
+
* Why a row id is refused, or `null` when it is acceptable.
|
|
108
|
+
*
|
|
109
|
+
* Until `idSource: "application"` this rule had one caller and lived inside it, because the only ids
|
|
110
|
+
* an application could produce came from `crypto.randomUUID()` and the only ones a human could were
|
|
111
|
+
* the admin editor's. Both of those are now joined by ids an application chooses on `insert`, so the
|
|
112
|
+
* rule is stated once here and read by all three — the alternative being three spellings of "a row
|
|
113
|
+
* id" that agree until one of them is edited.
|
|
114
|
+
*
|
|
115
|
+
* Bounded and quotable, and no more. The bound is what stops an id becoming an unbounded key in
|
|
116
|
+
* every index and error message that carries one; control characters are refused because an id is
|
|
117
|
+
* quoted back in diagnostics, admin listings and URLs, and a newline or a NUL inside one turns a
|
|
118
|
+
* message into two. Nothing narrower would be honest: these ids come from identity providers and
|
|
119
|
+
* other outside systems, and refusing a charset that a provider legitimately issues would make the
|
|
120
|
+
* capability unusable for exactly the case it exists for.
|
|
121
|
+
*/
|
|
122
|
+
export declare function recordIdRefusal(id: unknown): RecordIdRefusal | null;
|
|
123
|
+
/** The human wording for each refusal, shared so the runtime and the editor say the same thing. */
|
|
124
|
+
export declare function recordIdMessage(refusal: RecordIdRefusal, id: unknown): string;
|
|
103
125
|
/**
|
|
104
126
|
* Whether a decoded value satisfies a declared field. Shared with the runtime so an imported record
|
|
105
127
|
* is held to exactly the same rule a handler-written record is.
|
|
@@ -110,6 +110,42 @@ function validEncodedValue(value, path) {
|
|
|
110
110
|
}
|
|
111
111
|
throw malformed(`Unknown encoded value tag at ${path}.`, { tag: typeof tag === 'string' ? tag : null });
|
|
112
112
|
}
|
|
113
|
+
/** The longest a row id may be, in UTF-16 code units. */
|
|
114
|
+
export const RECORD_ID_MAX_LENGTH = 256;
|
|
115
|
+
/**
|
|
116
|
+
* Why a row id is refused, or `null` when it is acceptable.
|
|
117
|
+
*
|
|
118
|
+
* Until `idSource: "application"` this rule had one caller and lived inside it, because the only ids
|
|
119
|
+
* an application could produce came from `crypto.randomUUID()` and the only ones a human could were
|
|
120
|
+
* the admin editor's. Both of those are now joined by ids an application chooses on `insert`, so the
|
|
121
|
+
* rule is stated once here and read by all three — the alternative being three spellings of "a row
|
|
122
|
+
* id" that agree until one of them is edited.
|
|
123
|
+
*
|
|
124
|
+
* Bounded and quotable, and no more. The bound is what stops an id becoming an unbounded key in
|
|
125
|
+
* every index and error message that carries one; control characters are refused because an id is
|
|
126
|
+
* quoted back in diagnostics, admin listings and URLs, and a newline or a NUL inside one turns a
|
|
127
|
+
* message into two. Nothing narrower would be honest: these ids come from identity providers and
|
|
128
|
+
* other outside systems, and refusing a charset that a provider legitimately issues would make the
|
|
129
|
+
* capability unusable for exactly the case it exists for.
|
|
130
|
+
*/
|
|
131
|
+
export function recordIdRefusal(id) {
|
|
132
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
133
|
+
return 'record_id_empty';
|
|
134
|
+
if (id.length > RECORD_ID_MAX_LENGTH)
|
|
135
|
+
return 'record_id_too_long';
|
|
136
|
+
return /[\u0000-\u001f\u007f-\u009f]/u.test(id) ? 'record_id_invalid' : null;
|
|
137
|
+
}
|
|
138
|
+
/** The human wording for each refusal, shared so the runtime and the editor say the same thing. */
|
|
139
|
+
export function recordIdMessage(refusal, id) {
|
|
140
|
+
switch (refusal) {
|
|
141
|
+
case 'record_id_empty':
|
|
142
|
+
return 'A row id must be a non-empty string.';
|
|
143
|
+
case 'record_id_too_long':
|
|
144
|
+
return `A row id may not exceed ${RECORD_ID_MAX_LENGTH} characters; received ${id.length}.`;
|
|
145
|
+
default:
|
|
146
|
+
return `A row id may not contain control characters: ${JSON.stringify(id.slice(0, 80))}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
113
149
|
/**
|
|
114
150
|
* Whether a decoded value satisfies a declared field. Shared with the runtime so an imported record
|
|
115
151
|
* is held to exactly the same rule a handler-written record is.
|
|
@@ -156,8 +192,12 @@ function parseRecord(table, index, value) {
|
|
|
156
192
|
const row = plainObject(value);
|
|
157
193
|
if (!row)
|
|
158
194
|
throw malformed(`Row ${index} of table ${table} is not an object.`);
|
|
159
|
-
|
|
160
|
-
|
|
195
|
+
// The same rule an application `insert` and the admin editor are held to. An imported row is a row
|
|
196
|
+
// like any other, and a document carrying an id no live write could have produced would restore an
|
|
197
|
+
// application into a state it had no way to reach on its own.
|
|
198
|
+
const idRefusal = recordIdRefusal(row.id);
|
|
199
|
+
if (idRefusal) {
|
|
200
|
+
throw malformed(`Row ${index} of table ${table} has no usable id. ${recordIdMessage(idRefusal, row.id)}`);
|
|
161
201
|
}
|
|
162
202
|
if (!isoTimestamp(row.createdAt) || !isoTimestamp(row.updatedAt)) {
|
|
163
203
|
throw malformed(`Row ${row.id} of table ${table} has non-ISO timestamps.`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform TypeScript profile, in the spelling a `tsconfig.json` uses.
|
|
3
|
+
*
|
|
4
|
+
* `xeer check` type-checks an application with its own compiler options rather than the project's
|
|
5
|
+
* `tsconfig.json`, because the answer must not depend on a file the author can edit. The editor,
|
|
6
|
+
* however, reads only the `tsconfig.json` — so anything the platform enforces and the scaffold omits
|
|
7
|
+
* shows up as a green editor and a red CLI, which is what `exactOptionalPropertyTypes` did (#248).
|
|
8
|
+
*
|
|
9
|
+
* The repair is not to copy the flags into the scaffold: a copy is a thing that drifts. This module
|
|
10
|
+
* is the one declaration. The compiler converts it into `ts.CompilerOptions` for the check, and the
|
|
11
|
+
* scaffold writes it into `tsconfig.json` verbatim, so the two cannot disagree without this file
|
|
12
|
+
* changing. It lives in the spec package rather than the compiler so that writing a scaffold does
|
|
13
|
+
* not require loading TypeScript.
|
|
14
|
+
*
|
|
15
|
+
* Values are the JSON spellings TypeScript accepts in a `tsconfig.json` (`"esnext"`, `"bundler"`,
|
|
16
|
+
* `"react-jsx"`, `lib` without the `lib.`/`.d.ts` affixes), which is what
|
|
17
|
+
* `ts.convertCompilerOptionsFromJson` reads.
|
|
18
|
+
*/
|
|
19
|
+
export declare const PLATFORM_TYPE_CHECK_COMPILER_OPTIONS: Readonly<{
|
|
20
|
+
readonly allowJs: true;
|
|
21
|
+
readonly allowArbitraryExtensions: true;
|
|
22
|
+
readonly checkJs: true;
|
|
23
|
+
readonly exactOptionalPropertyTypes: true;
|
|
24
|
+
readonly jsx: "react-jsx";
|
|
25
|
+
readonly jsxImportSource: "@impetik/xeer";
|
|
26
|
+
readonly lib: readonly string[];
|
|
27
|
+
readonly module: "esnext";
|
|
28
|
+
readonly moduleResolution: "bundler";
|
|
29
|
+
readonly noEmit: true;
|
|
30
|
+
readonly noUncheckedIndexedAccess: true;
|
|
31
|
+
readonly skipLibCheck: true;
|
|
32
|
+
readonly strict: true;
|
|
33
|
+
readonly target: "es2022";
|
|
34
|
+
readonly types: readonly never[];
|
|
35
|
+
readonly verbatimModuleSyntax: true;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* The React-family editor mappings every scaffolded and template `tsconfig.json` carries. Each
|
|
39
|
+
* target is a shim inside the installed platform package that re-exports the platform's
|
|
40
|
+
* `preact/compat` surface, so the mapping resolves under npm hoisting and pnpm strictness alike.
|
|
41
|
+
*
|
|
42
|
+
* Not part of {@link PLATFORM_TYPE_CHECK_COMPILER_OPTIONS}: the platform check answers the same
|
|
43
|
+
* question through its module-resolution host instead, so this is the editor's half of one rule
|
|
44
|
+
* rather than a second rule.
|
|
45
|
+
*/
|
|
46
|
+
export declare const REACT_COMPAT_TSCONFIG_PATHS: Readonly<Record<string, readonly string[]>>;
|
|
47
|
+
/**
|
|
48
|
+
* The files an application's `tsconfig.json` type-checks: sources, TypeScript tests, and the
|
|
49
|
+
* generated contract.
|
|
50
|
+
*
|
|
51
|
+
* `tests/**\/*.ts` rather than `tests`, because the profile enables `checkJs` and the two sides build
|
|
52
|
+
* their file sets differently. The platform checks the entrypoint graph plus `tests/**\/*.test.ts`,
|
|
53
|
+
* so a `.js` file it reaches is a file it judges; a tsconfig's set is a directory glob, and `tests`
|
|
54
|
+
* would additionally drag in a `.mjs` harness written for Node — a file neither side is judging the
|
|
55
|
+
* same way. Restricting the glob to TypeScript keeps the two sets comparable without weakening any
|
|
56
|
+
* rule that decides how a checked file is judged.
|
|
57
|
+
*/
|
|
58
|
+
export declare const APPLICATION_TSCONFIG_INCLUDE: readonly string[];
|
|
59
|
+
/**
|
|
60
|
+
* The `tsconfig.json` document an application carries, as a serialized file body.
|
|
61
|
+
*
|
|
62
|
+
* One function so the scaffold, the checked-in templates, and any conformance test all name the same
|
|
63
|
+
* bytes. Serialized here rather than returned as an object because "the file the editor reads" is
|
|
64
|
+
* what has to match, and two callers stringifying the same object with different options would
|
|
65
|
+
* produce two different files.
|
|
66
|
+
*/
|
|
67
|
+
export declare function applicationTsconfigDocument(): string;
|