@brydio/manifest 0.1.0-alpha.0
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/LICENSE +21 -0
- package/package.json +27 -0
- package/src/base.d.ts +73 -0
- package/src/base.js +61 -0
- package/src/bundle.d.ts +52 -0
- package/src/bundle.js +95 -0
- package/src/define.d.ts +49 -0
- package/src/define.js +21 -0
- package/src/document-limits.d.ts +21 -0
- package/src/document-limits.js +21 -0
- package/src/field-types.d.ts +157 -0
- package/src/field-types.js +298 -0
- package/src/grants.d.ts +20 -0
- package/src/grants.js +30 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +17 -0
- package/src/migrations.d.ts +142 -0
- package/src/migrations.js +322 -0
- package/src/schema.d.ts +381 -0
- package/src/schema.js +375 -0
- package/src/sdk.d.ts +30 -0
- package/src/sdk.js +77 -0
- package/src/secrets.d.ts +32 -0
- package/src/secrets.js +81 -0
- package/src/tools.d.ts +21 -0
- package/src/tools.js +31 -0
- package/src/validate.d.ts +27 -0
- package/src/validate.js +29 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SEMVER_FORMAT } from "./base.js";
|
|
3
|
+
import { FIELD_NAME, parseFieldType, valueProblem } from "./field-types.js";
|
|
4
|
+
// A copy of the pure half of Brydio's `apps/api/src/apps/manifest/migrations.ts`
|
|
5
|
+
// (the steps, the schema comparison and the check the publish route runs),
|
|
6
|
+
// kept word for word so `brydio validate` refuses a missing migration with
|
|
7
|
+
// the server's sentence. Planning and running a migration are the server's
|
|
8
|
+
// alone and are left out. `test/migrations.test.ts` compares the two.
|
|
9
|
+
/**
|
|
10
|
+
* How a version says what happens to the records already kept when its
|
|
11
|
+
* schema changes (A3-F07, ADR-A11).
|
|
12
|
+
*
|
|
13
|
+
* Five steps, each with a rule a machine can check and a person can read:
|
|
14
|
+
* add a field (with a default when it is required), rename one, drop one,
|
|
15
|
+
* drop a whole collection, and replace an allowed value that a choice no
|
|
16
|
+
* longer has. Nothing cleverer. A computed value or a merge is an app's own
|
|
17
|
+
* job after the migration has run (A3-F08).
|
|
18
|
+
*
|
|
19
|
+
* A version's `migrations` lists every step since the schema began, each
|
|
20
|
+
* entry naming the version it brings the schema to, so a workspace two
|
|
21
|
+
* versions behind runs both entries in order. Everything here is pure: the
|
|
22
|
+
* store's half, which rewrites records, is `SchemaMigrations` in `data/`.
|
|
23
|
+
*/
|
|
24
|
+
const collection = z.string().min(1).max(40);
|
|
25
|
+
const field = z.string().min(1).max(40);
|
|
26
|
+
export const migrationStepSchema = z.discriminatedUnion('op', [
|
|
27
|
+
z.object({ op: z.literal('add'), collection, field, default: z.unknown().optional() }),
|
|
28
|
+
z.object({ op: z.literal('rename'), collection, from: field, to: field }),
|
|
29
|
+
z.object({ op: z.literal('drop'), collection, field }),
|
|
30
|
+
z.object({ op: z.literal('dropCollection'), collection }),
|
|
31
|
+
/** A choice lost `from`: records holding it hold `to` instead. */
|
|
32
|
+
z.object({ op: z.literal('replace'), collection, field, from: z.string().max(64), to: z.string().max(64) }),
|
|
33
|
+
]);
|
|
34
|
+
export const migrationSchema = z.object({
|
|
35
|
+
/** The version these steps bring the schema to. */
|
|
36
|
+
version: z.string().max(64).regex(SEMVER_FORMAT),
|
|
37
|
+
steps: z.array(migrationStepSchema).min(1).max(100),
|
|
38
|
+
});
|
|
39
|
+
export const migrationsSchema = z.array(migrationSchema).max(200);
|
|
40
|
+
/** A version's collections and their field types, read tolerantly: a stored manifest was checked when published. */
|
|
41
|
+
export function schemaOf(manifest) {
|
|
42
|
+
const schema = new Map();
|
|
43
|
+
const data = record(record(manifest).data);
|
|
44
|
+
for (const [name, declared] of Object.entries(data)) {
|
|
45
|
+
const fields = new Map();
|
|
46
|
+
for (const [fieldName, raw] of Object.entries(record(record(declared).schema))) {
|
|
47
|
+
try {
|
|
48
|
+
fields.set(fieldName, parseFieldType(raw));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Unreadable here means unreadable to the store too; leaving it out
|
|
52
|
+
// makes it a difference that has to be explained, which is right.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
schema.set(name, fields);
|
|
56
|
+
}
|
|
57
|
+
return schema;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Every way the records one schema describes differ from another's, collection
|
|
61
|
+
* by collection and field by field, in a fixed order.
|
|
62
|
+
*
|
|
63
|
+
* About storage only: labels, search lists and everything outside `data` are
|
|
64
|
+
* Hodler's `diffManifests` to show, and none of them moves a record.
|
|
65
|
+
*/
|
|
66
|
+
export function diffSchemas(from, to) {
|
|
67
|
+
return compare(schemaOf(from), schemaOf(to));
|
|
68
|
+
}
|
|
69
|
+
function compare(a, b) {
|
|
70
|
+
const changes = [];
|
|
71
|
+
for (const name of sorted(new Set([...a.keys(), ...b.keys()]))) {
|
|
72
|
+
const before = a.get(name);
|
|
73
|
+
const after = b.get(name);
|
|
74
|
+
if (!before) {
|
|
75
|
+
changes.push({ kind: 'collection_added', collection: name });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!after) {
|
|
79
|
+
changes.push({ kind: 'collection_removed', collection: name });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const fieldName of sorted(new Set([...before.keys(), ...after.keys()]))) {
|
|
83
|
+
const was = before.get(fieldName);
|
|
84
|
+
const is = after.get(fieldName);
|
|
85
|
+
if (!was) {
|
|
86
|
+
changes.push({ kind: 'field_added', collection: name, field: fieldName, type: is });
|
|
87
|
+
}
|
|
88
|
+
else if (!is) {
|
|
89
|
+
changes.push({ kind: 'field_removed', collection: name, field: fieldName, type: was });
|
|
90
|
+
}
|
|
91
|
+
else if (was.kind !== is.kind) {
|
|
92
|
+
changes.push({ kind: 'field_type_changed', collection: name, field: fieldName, from: was, to: is });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
if (was.optional !== is.optional) {
|
|
96
|
+
changes.push({
|
|
97
|
+
kind: is.optional ? 'field_now_optional' : 'field_now_required',
|
|
98
|
+
collection: name,
|
|
99
|
+
field: fieldName,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (was.kind === 'enum') {
|
|
103
|
+
const removed = (was.values ?? []).filter(value => !(is.values ?? []).includes(value));
|
|
104
|
+
const added = (is.values ?? []).filter(value => !(was.values ?? []).includes(value));
|
|
105
|
+
if (removed.length)
|
|
106
|
+
changes.push({ kind: 'values_removed', collection: name, field: fieldName, values: removed });
|
|
107
|
+
if (added.length)
|
|
108
|
+
changes.push({ kind: 'values_added', collection: name, field: fieldName, values: added });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return changes;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether `steps` take the records `from` describes to the records `to`
|
|
117
|
+
* describes: every difference explained by a step, every step making one.
|
|
118
|
+
*
|
|
119
|
+
* Worked out by running the steps over a copy of the old schema and
|
|
120
|
+
* comparing what comes out with the new one, so a rename followed by an add
|
|
121
|
+
* under the old name is judged as the two moves it is, not as "nothing
|
|
122
|
+
* changed". An empty list means the steps are right.
|
|
123
|
+
*/
|
|
124
|
+
export function migrationProblems(from, to, steps) {
|
|
125
|
+
const target = schemaOf(to);
|
|
126
|
+
const { schema, problems } = run(schemaOf(from), target, steps);
|
|
127
|
+
for (const change of compare(schema, target)) {
|
|
128
|
+
const where = 'field' in change ? `${change.collection}.${change.field}` : change.collection;
|
|
129
|
+
switch (change.kind) {
|
|
130
|
+
case 'collection_added':
|
|
131
|
+
case 'field_now_optional':
|
|
132
|
+
case 'values_added':
|
|
133
|
+
// Nothing already kept is unreadable after any of these.
|
|
134
|
+
break;
|
|
135
|
+
case 'collection_removed':
|
|
136
|
+
problems.push({
|
|
137
|
+
code: 'migration_unexplained',
|
|
138
|
+
collection: change.collection,
|
|
139
|
+
message: `${change.collection} is no longer declared; say dropCollection to remove its records.`,
|
|
140
|
+
});
|
|
141
|
+
break;
|
|
142
|
+
case 'field_added':
|
|
143
|
+
problems.push({
|
|
144
|
+
code: 'migration_unexplained',
|
|
145
|
+
collection: change.collection,
|
|
146
|
+
field: change.field,
|
|
147
|
+
message: `${where} is new; add it with a step${change.type.optional || change.type.kind === 'string[]' ? '' : ' and a default'}.`,
|
|
148
|
+
});
|
|
149
|
+
break;
|
|
150
|
+
case 'field_removed':
|
|
151
|
+
problems.push({
|
|
152
|
+
code: 'migration_unexplained',
|
|
153
|
+
collection: change.collection,
|
|
154
|
+
field: change.field,
|
|
155
|
+
message: `${where} is gone; drop it or rename it with a step.`,
|
|
156
|
+
});
|
|
157
|
+
break;
|
|
158
|
+
case 'field_type_changed':
|
|
159
|
+
problems.push({
|
|
160
|
+
code: 'migration_type_changed',
|
|
161
|
+
collection: change.collection,
|
|
162
|
+
field: change.field,
|
|
163
|
+
message: `${where} changed type; drop \`${change.field}\` and add it again under a new name.`,
|
|
164
|
+
});
|
|
165
|
+
break;
|
|
166
|
+
case 'field_now_required':
|
|
167
|
+
problems.push({
|
|
168
|
+
code: 'migration_type_changed',
|
|
169
|
+
collection: change.collection,
|
|
170
|
+
field: change.field,
|
|
171
|
+
message: `${where} became required, and records without it would not be readable; add a new field with a default instead.`,
|
|
172
|
+
});
|
|
173
|
+
break;
|
|
174
|
+
case 'values_removed':
|
|
175
|
+
problems.push({
|
|
176
|
+
code: 'migration_value_removed',
|
|
177
|
+
collection: change.collection,
|
|
178
|
+
field: change.field,
|
|
179
|
+
message: `${where} no longer allows ${change.values.map(value => `"${value}"`).join(', ')}; say which value replaces each.`,
|
|
180
|
+
});
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return problems;
|
|
185
|
+
}
|
|
186
|
+
/** The old schema with the steps applied, and every step that could not apply or changed nothing. */
|
|
187
|
+
function run(start, target, steps) {
|
|
188
|
+
const schema = new Map([...start].map(([name, fields]) => [name, new Map(fields)]));
|
|
189
|
+
const problems = [];
|
|
190
|
+
const invalid = (step, message, fieldName) => problems.push({ code: 'migration_step_invalid', collection: step.collection, ...(fieldName ? { field: fieldName } : {}), message });
|
|
191
|
+
const pointless = (step, message, fieldName) => problems.push({ code: 'migration_step_pointless', collection: step.collection, ...(fieldName ? { field: fieldName } : {}), message });
|
|
192
|
+
for (const step of steps) {
|
|
193
|
+
const fields = schema.get(step.collection);
|
|
194
|
+
if (step.op === 'dropCollection') {
|
|
195
|
+
if (!fields)
|
|
196
|
+
invalid(step, `dropCollection ${step.collection}: there is no such collection to drop.`);
|
|
197
|
+
else if (target.has(step.collection))
|
|
198
|
+
pointless(step, `dropCollection ${step.collection}: the new version still keeps it.`);
|
|
199
|
+
else
|
|
200
|
+
schema.delete(step.collection);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!fields) {
|
|
204
|
+
invalid(step, `${step.op} in ${step.collection}: there is no such collection.`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const wanted = target.get(step.collection);
|
|
208
|
+
switch (step.op) {
|
|
209
|
+
case 'add': {
|
|
210
|
+
const type = wanted?.get(step.field);
|
|
211
|
+
if (fields.has(step.field)) {
|
|
212
|
+
invalid(step, `add ${step.collection}.${step.field}: it is already a field.`, step.field);
|
|
213
|
+
}
|
|
214
|
+
else if (!type) {
|
|
215
|
+
pointless(step, `add ${step.collection}.${step.field}: the new version does not declare it.`, step.field);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const required = !type.optional && type.kind !== 'string[]';
|
|
219
|
+
if (step.default === undefined && required) {
|
|
220
|
+
problems.push({
|
|
221
|
+
code: 'migration_default_missing',
|
|
222
|
+
collection: step.collection,
|
|
223
|
+
field: step.field,
|
|
224
|
+
message: `add ${step.collection}.${step.field}: a required field needs a default for the records already kept.`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
else if (step.default !== undefined) {
|
|
228
|
+
const problem = valueProblem(step.field, type, step.default);
|
|
229
|
+
if (problem) {
|
|
230
|
+
problems.push({
|
|
231
|
+
code: 'migration_default_invalid',
|
|
232
|
+
collection: step.collection,
|
|
233
|
+
field: step.field,
|
|
234
|
+
message: `add ${step.collection}.${step.field}: the default is not valid; ${problem}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
fields.set(step.field, type);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'rename': {
|
|
243
|
+
const type = fields.get(step.from);
|
|
244
|
+
if (!type) {
|
|
245
|
+
invalid(step, `rename ${step.collection}.${step.from}: there is no such field.`, step.from);
|
|
246
|
+
}
|
|
247
|
+
else if (step.from === step.to || !FIELD_NAME.test(step.to)) {
|
|
248
|
+
invalid(step, `rename ${step.collection}.${step.from} to "${step.to}": not a new field name.`, step.from);
|
|
249
|
+
}
|
|
250
|
+
else if (fields.has(step.to)) {
|
|
251
|
+
invalid(step, `rename ${step.collection}.${step.from} to ${step.to}: ${step.to} is already a field.`, step.to);
|
|
252
|
+
}
|
|
253
|
+
else if (!wanted?.has(step.to)) {
|
|
254
|
+
pointless(step, `rename ${step.collection}.${step.from} to ${step.to}: the new version does not use the new name in its place.`, step.from);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
fields.delete(step.from);
|
|
258
|
+
fields.set(step.to, type);
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'drop': {
|
|
263
|
+
if (!fields.has(step.field)) {
|
|
264
|
+
invalid(step, `drop ${step.collection}.${step.field}: there is no such field.`, step.field);
|
|
265
|
+
}
|
|
266
|
+
else if (wanted?.has(step.field)) {
|
|
267
|
+
pointless(step, `drop ${step.collection}.${step.field}: the new version still declares it. To change its type, drop \`${step.field}\` and add it again under a new name.`, step.field);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
fields.delete(step.field);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 'replace': {
|
|
275
|
+
const type = fields.get(step.field);
|
|
276
|
+
const next = wanted?.get(step.field);
|
|
277
|
+
if (!type || type.kind !== 'enum' || !(type.values ?? []).includes(step.from)) {
|
|
278
|
+
invalid(step, `replace in ${step.collection}.${step.field}: "${step.from}" is not one of its values.`, step.field);
|
|
279
|
+
}
|
|
280
|
+
else if (!next || next.kind !== 'enum' || !(next.values ?? []).includes(step.to)) {
|
|
281
|
+
invalid(step, `replace in ${step.collection}.${step.field}: "${step.to}" is not one of the new version's values.`, step.field);
|
|
282
|
+
}
|
|
283
|
+
else if ((next.values ?? []).includes(step.from)) {
|
|
284
|
+
pointless(step, `replace in ${step.collection}.${step.field}: the new version still allows "${step.from}".`, step.field);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
fields.set(step.field, { ...type, values: (type.values ?? []).filter(value => value !== step.from) });
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { schema, problems };
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Publishing and pinning
|
|
297
|
+
/**
|
|
298
|
+
* What is wrong with a version's migration against the version published
|
|
299
|
+
* before it (A3-F07-S01): the check the publish route runs, so a version
|
|
300
|
+
* that could never be pinned never reaches a workspace. `previous` is null
|
|
301
|
+
* for an app's first version, which has nothing to migrate.
|
|
302
|
+
*/
|
|
303
|
+
export function publishedMigrationProblems(previous, next) {
|
|
304
|
+
if (previous === null || previous === undefined)
|
|
305
|
+
return [];
|
|
306
|
+
const version = versionOf(next);
|
|
307
|
+
const steps = migrationsOf(next)
|
|
308
|
+
.filter(entry => entry.version === version)
|
|
309
|
+
.flatMap(entry => entry.steps);
|
|
310
|
+
return migrationProblems(previous, next, steps);
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
const versionOf = (manifest) => {
|
|
314
|
+
const version = record(manifest).version;
|
|
315
|
+
return typeof version === 'string' ? version : null;
|
|
316
|
+
};
|
|
317
|
+
function migrationsOf(manifest) {
|
|
318
|
+
const parsed = migrationsSchema.safeParse(record(manifest).migrations ?? []);
|
|
319
|
+
return parsed.success ? parsed.data : [];
|
|
320
|
+
}
|
|
321
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
322
|
+
const sorted = (values) => [...values].sort();
|