@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.
@@ -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();