@verdant-web/common 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/indexes.d.ts +1 -0
- package/dist/cjs/indexes.js +5 -1
- package/dist/cjs/indexes.js.map +1 -1
- package/dist/cjs/migration.d.ts +7 -0
- package/dist/cjs/migration.js +53 -9
- package/dist/cjs/migration.js.map +1 -1
- package/dist/cjs/oids.d.ts +2 -0
- package/dist/cjs/oids.js +30 -4
- package/dist/cjs/oids.js.map +1 -1
- package/dist/cjs/oids.test.js +3 -0
- package/dist/cjs/oids.test.js.map +1 -1
- package/dist/cjs/operation.js +12 -3
- package/dist/cjs/operation.js.map +1 -1
- package/dist/cjs/operation.test.js +151 -1
- package/dist/cjs/operation.test.js.map +1 -1
- package/dist/cjs/schema/defaults.test.js +3 -3
- package/dist/cjs/schema/defaults.test.js.map +1 -1
- package/dist/cjs/schema/{defaults.d.ts → fields.d.ts} +3 -0
- package/dist/cjs/schema/{defaults.js → fields.js} +48 -3
- package/dist/cjs/schema/fields.js.map +1 -0
- package/dist/cjs/schema/index.d.ts +1 -1
- package/dist/cjs/schema/index.js +1 -1
- package/dist/cjs/schema/index.js.map +1 -1
- package/dist/cjs/schema/types/fields.d.ts +6 -0
- package/dist/cjs/schema/validation.d.ts +3 -0
- package/dist/cjs/schema/validation.js +87 -0
- package/dist/cjs/schema/validation.js.map +1 -0
- package/dist/cjs/timestamp.test.js +11 -0
- package/dist/cjs/timestamp.test.js.map +1 -1
- package/dist/cjs/utils.d.ts +5 -1
- package/dist/cjs/utils.js +9 -5
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/indexes.d.ts +1 -0
- package/dist/esm/indexes.js +4 -0
- package/dist/esm/indexes.js.map +1 -1
- package/dist/esm/migration.d.ts +7 -0
- package/dist/esm/migration.js +54 -10
- package/dist/esm/migration.js.map +1 -1
- package/dist/esm/oids.d.ts +2 -0
- package/dist/esm/oids.js +27 -3
- package/dist/esm/oids.js.map +1 -1
- package/dist/esm/oids.test.js +4 -1
- package/dist/esm/oids.test.js.map +1 -1
- package/dist/esm/operation.js +13 -4
- package/dist/esm/operation.js.map +1 -1
- package/dist/esm/operation.test.js +151 -1
- package/dist/esm/operation.test.js.map +1 -1
- package/dist/esm/schema/defaults.test.js +1 -1
- package/dist/esm/schema/defaults.test.js.map +1 -1
- package/dist/esm/schema/{defaults.d.ts → fields.d.ts} +3 -0
- package/dist/esm/schema/{defaults.js → fields.js} +44 -2
- package/dist/esm/schema/fields.js.map +1 -0
- package/dist/esm/schema/index.d.ts +1 -1
- package/dist/esm/schema/index.js +1 -1
- package/dist/esm/schema/index.js.map +1 -1
- package/dist/esm/schema/types/fields.d.ts +6 -0
- package/dist/esm/schema/validation.d.ts +3 -0
- package/dist/esm/schema/validation.js +82 -0
- package/dist/esm/schema/validation.js.map +1 -0
- package/dist/esm/timestamp.test.js +12 -1
- package/dist/esm/timestamp.test.js.map +1 -1
- package/dist/esm/utils.d.ts +5 -1
- package/dist/esm/utils.js +9 -5
- package/dist/esm/utils.js.map +1 -1
- package/dist/tsconfig-cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/indexes.ts +5 -0
- package/src/migration.ts +86 -20
- package/src/oids.test.ts +7 -0
- package/src/oids.ts +31 -3
- package/src/operation.test.ts +157 -1
- package/src/operation.ts +19 -3
- package/src/schema/defaults.test.ts +1 -1
- package/src/schema/{defaults.ts → fields.ts} +55 -1
- package/src/schema/index.ts +1 -1
- package/src/schema/types/fields.ts +6 -1
- package/src/schema/validation.ts +99 -0
- package/src/timestamp.test.ts +13 -0
- package/src/utils.ts +9 -5
- package/dist/cjs/schema/defaults.js.map +0 -1
- package/dist/esm/schema/defaults.js.map +0 -1
package/src/migration.ts
CHANGED
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
StorageDocument,
|
|
7
7
|
CollectionFilter,
|
|
8
8
|
StorageDocumentInit,
|
|
9
|
+
removeExtraProperties,
|
|
10
|
+
assert,
|
|
11
|
+
hasOid,
|
|
12
|
+
assignOid,
|
|
13
|
+
getOid,
|
|
9
14
|
} from './index.js';
|
|
10
15
|
|
|
11
16
|
export interface DroppedCollectionMigrationStrategy<
|
|
@@ -147,6 +152,12 @@ export interface MigrationTools<
|
|
|
147
152
|
> {
|
|
148
153
|
migrate: MigrationRunner<Old, New>;
|
|
149
154
|
identity: <T>(val: T) => T;
|
|
155
|
+
/**
|
|
156
|
+
* @deprecated - default field values are automatically
|
|
157
|
+
* applied during migration, you don't need to use this.
|
|
158
|
+
* Please remove it from your migrations - even old ones
|
|
159
|
+
* (old migrations can be updated!)
|
|
160
|
+
*/
|
|
150
161
|
withDefaults: (collectionName: string, value: any) => any;
|
|
151
162
|
queries: MigrationQueries<Old>;
|
|
152
163
|
mutations: MigrationMutations<New>;
|
|
@@ -171,6 +182,7 @@ export interface MigrationEngine<
|
|
|
171
182
|
newOids: string[];
|
|
172
183
|
/** Promises that should be resolved before completing the migration */
|
|
173
184
|
awaitables: Promise<any>[];
|
|
185
|
+
log: (...messages: any[]) => void;
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
type MigrationProcedure<
|
|
@@ -226,6 +238,24 @@ export function migrate(
|
|
|
226
238
|
const addedCollections = Object.keys(newSchema.collections).filter(
|
|
227
239
|
(key) => !oldSchema.collections[key],
|
|
228
240
|
);
|
|
241
|
+
// collections which added one or more field defaults
|
|
242
|
+
const autoMigratedCollections = new Set<string>();
|
|
243
|
+
for (const collection of changedCollections) {
|
|
244
|
+
const oldFields = oldSchema.collections[collection].fields;
|
|
245
|
+
const newFields = newSchema.collections[collection].fields;
|
|
246
|
+
// a new default was added - we can auto-migrate it
|
|
247
|
+
if (
|
|
248
|
+
Object.keys(newFields).some(
|
|
249
|
+
(key) => !oldFields[key]?.default && newFields[key]?.default,
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
autoMigratedCollections.add(collection);
|
|
253
|
+
}
|
|
254
|
+
// a field was removed - we can auto-migrate it
|
|
255
|
+
if (Object.keys(oldFields).some((key) => !newFields[key])) {
|
|
256
|
+
autoMigratedCollections.add(collection);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
229
259
|
|
|
230
260
|
const addedIndexes: Record<string, MigrationIndexDescription[]> = {};
|
|
231
261
|
const removedIndexes: Record<string, MigrationIndexDescription[]> = {};
|
|
@@ -240,26 +270,47 @@ export function migrate(
|
|
|
240
270
|
);
|
|
241
271
|
if (added.length > 0) {
|
|
242
272
|
addedIndexes[changed] = added;
|
|
273
|
+
autoMigratedCollections.add(changed);
|
|
243
274
|
}
|
|
244
275
|
if (removed.length > 0) {
|
|
245
276
|
removedIndexes[changed] = removed;
|
|
277
|
+
autoMigratedCollections.add(changed);
|
|
246
278
|
}
|
|
247
279
|
}
|
|
248
280
|
|
|
281
|
+
const withDefaults = (collectionName: string, val: any) => {
|
|
282
|
+
return addFieldDefaults(newSchema.collections[collectionName], val);
|
|
283
|
+
};
|
|
284
|
+
const autoMigration = (collectionName: string) => (val: any) => {
|
|
285
|
+
const collection = newSchema.collections[collectionName];
|
|
286
|
+
return addFieldDefaults(collection, removeExtraProperties(collection, val));
|
|
287
|
+
};
|
|
288
|
+
|
|
249
289
|
return {
|
|
250
290
|
version: newSchema.version,
|
|
251
291
|
migrate: async (engine: MigrationEngine<any, any>) => {
|
|
252
292
|
const migratedCollections: string[] = [];
|
|
253
293
|
await procedure({
|
|
254
294
|
migrate: async (collection: any, strategy: any) => {
|
|
295
|
+
const auto = autoMigration(collection);
|
|
296
|
+
const wrapped = async (val: any) => {
|
|
297
|
+
const baseValue = await strategy(val);
|
|
298
|
+
// assign OID from original value in case user's strategy
|
|
299
|
+
// involves cloning
|
|
300
|
+
assignOid(baseValue, getOid(val));
|
|
301
|
+
const result = auto(baseValue);
|
|
302
|
+
return result;
|
|
303
|
+
};
|
|
255
304
|
// @ts-ignore
|
|
256
|
-
await engine.migrate(collection,
|
|
305
|
+
await engine.migrate(collection, wrapped);
|
|
257
306
|
migratedCollections.push(collection);
|
|
307
|
+
// since the user migrated this one and we wrap their
|
|
308
|
+
// strategy with auto-migration, we can remove it from
|
|
309
|
+
// the auto-migration list
|
|
310
|
+
autoMigratedCollections.delete(collection);
|
|
258
311
|
},
|
|
259
312
|
identity: (val: any) => val,
|
|
260
|
-
withDefaults
|
|
261
|
-
return addFieldDefaults(newSchema.collections[collectionName], val);
|
|
262
|
-
},
|
|
313
|
+
withDefaults,
|
|
263
314
|
info: {
|
|
264
315
|
changedCollections,
|
|
265
316
|
addedCollections,
|
|
@@ -269,11 +320,30 @@ export function migrate(
|
|
|
269
320
|
mutations: engine.mutations,
|
|
270
321
|
});
|
|
271
322
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
323
|
+
// mandatory migration of fields which had defaults added or
|
|
324
|
+
// fields removed but weren't migrated by the user
|
|
325
|
+
|
|
326
|
+
if (newSchema.version > 1) {
|
|
327
|
+
engine.log(
|
|
328
|
+
'debug',
|
|
329
|
+
'auto-migrating collections with new defaults',
|
|
330
|
+
autoMigratedCollections,
|
|
331
|
+
);
|
|
332
|
+
for (const name of autoMigratedCollections) {
|
|
333
|
+
await engine.migrate(name, autoMigration(name));
|
|
334
|
+
migratedCollections.push(name);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const unmigrated = changedCollections.filter(
|
|
338
|
+
(collection) => !migratedCollections.includes(collection),
|
|
339
|
+
);
|
|
340
|
+
if (unmigrated.length > 0) {
|
|
341
|
+
// TODO: does this deserve a full-on error?
|
|
342
|
+
console.error(
|
|
343
|
+
`Unmigrated changed collections from version ${oldSchema.version} to version ${newSchema.version}:`,
|
|
344
|
+
unmigrated,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
277
347
|
}
|
|
278
348
|
},
|
|
279
349
|
removedCollections,
|
|
@@ -376,16 +446,12 @@ export function createDefaultMigration(
|
|
|
376
446
|
version: 0,
|
|
377
447
|
collections: {},
|
|
378
448
|
};
|
|
379
|
-
return migrate(
|
|
380
|
-
|
|
381
|
-
newSchema || schema,
|
|
382
|
-
async ({ migrate, withDefaults, info }) => {
|
|
383
|
-
if ((newSchema || schema).version === 1) return;
|
|
449
|
+
return migrate(oldSchema, newSchema || schema, async ({ migrate, info }) => {
|
|
450
|
+
if ((newSchema || schema).version === 1) return;
|
|
384
451
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
);
|
|
452
|
+
for (const collection of info.changedCollections as any) {
|
|
453
|
+
// @ts-ignore indefinite type resolution
|
|
454
|
+
await migrate(collection, (old) => old);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
391
457
|
}
|
package/src/oids.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
decomposeOid,
|
|
10
10
|
getOid,
|
|
11
11
|
getOidRange,
|
|
12
|
+
getOidRoot,
|
|
12
13
|
hasOid,
|
|
13
14
|
maybeGetOidProperty,
|
|
14
15
|
normalize,
|
|
@@ -404,3 +405,9 @@ describe('assigning OIDs to sub-objects', () => {
|
|
|
404
405
|
expect(hasOid(obj.bar[0])).toBe(false);
|
|
405
406
|
});
|
|
406
407
|
});
|
|
408
|
+
|
|
409
|
+
it('should get the root OID for a legacy OID', () => [
|
|
410
|
+
expect(getOidRoot('items/clabgyjfh00003968qycsq3ld.inputs.#')).toEqual(
|
|
411
|
+
'items/clabgyjfh00003968qycsq3ld',
|
|
412
|
+
),
|
|
413
|
+
]);
|
package/src/oids.ts
CHANGED
|
@@ -68,6 +68,9 @@ export function assignOid(obj: any, oid: ObjectIdentifier) {
|
|
|
68
68
|
isObject(obj),
|
|
69
69
|
`Only objects can be assigned OIDs, received ${JSON.stringify(obj)}`,
|
|
70
70
|
);
|
|
71
|
+
if (hasOid(obj)) {
|
|
72
|
+
removeOid(obj);
|
|
73
|
+
}
|
|
71
74
|
oidMap.set(obj, oid);
|
|
72
75
|
return obj;
|
|
73
76
|
}
|
|
@@ -103,6 +106,27 @@ export function ensureOid(
|
|
|
103
106
|
}
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
export function ensureCompatibleOid(
|
|
110
|
+
obj: any,
|
|
111
|
+
rootOid: ObjectIdentifier,
|
|
112
|
+
createSubId?: () => string,
|
|
113
|
+
) {
|
|
114
|
+
if (!hasOid(obj)) {
|
|
115
|
+
const oid = createSubOid(rootOid, createSubId);
|
|
116
|
+
assignOid(obj, oid);
|
|
117
|
+
return oid;
|
|
118
|
+
} else {
|
|
119
|
+
const existingOid = getOid(obj);
|
|
120
|
+
if (!areOidsRelated(existingOid, rootOid)) {
|
|
121
|
+
const oid = createSubOid(rootOid, createSubId);
|
|
122
|
+
assignOid(obj, oid);
|
|
123
|
+
return oid;
|
|
124
|
+
} else {
|
|
125
|
+
return getOid(obj);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
const SANTIIZE_PLACEHOLDERS = {
|
|
107
131
|
'.': '˙',
|
|
108
132
|
'/': '&slash;',
|
|
@@ -188,14 +212,14 @@ export function assignOidsToAllSubObjects(
|
|
|
188
212
|
for (let i = 0; i < obj.length; i++) {
|
|
189
213
|
item = obj[i];
|
|
190
214
|
if (isObject(item) && !isRef(item)) {
|
|
191
|
-
|
|
215
|
+
ensureCompatibleOid(item, rootOid, createSubId);
|
|
192
216
|
assignOidsToAllSubObjects(item, createSubId);
|
|
193
217
|
}
|
|
194
218
|
}
|
|
195
219
|
} else if (isObject(obj) && !isRef(obj)) {
|
|
196
220
|
for (const key of Object.keys(obj)) {
|
|
197
221
|
if (isObject(obj[key]) && !isRef(obj[key])) {
|
|
198
|
-
|
|
222
|
+
ensureCompatibleOid(obj[key], rootOid, createSubId);
|
|
199
223
|
assignOidsToAllSubObjects(obj[key], createSubId);
|
|
200
224
|
}
|
|
201
225
|
}
|
|
@@ -402,7 +426,7 @@ export function normalizeFirstLevel(obj: any) {
|
|
|
402
426
|
}
|
|
403
427
|
|
|
404
428
|
export function getOidRoot(oid: ObjectIdentifier) {
|
|
405
|
-
return oid.split(RANDOM_SEPARATOR)[0];
|
|
429
|
+
return oid.split('.')[0].split(RANDOM_SEPARATOR)[0];
|
|
406
430
|
}
|
|
407
431
|
|
|
408
432
|
/**
|
|
@@ -421,3 +445,7 @@ export function getRoots(oids: ObjectIdentifier[]) {
|
|
|
421
445
|
}
|
|
422
446
|
return Array.from(set);
|
|
423
447
|
}
|
|
448
|
+
|
|
449
|
+
export function areOidsRelated(oidA: ObjectIdentifier, oidB: ObjectIdentifier) {
|
|
450
|
+
return getOidRoot(oidA) === getOidRoot(oidB);
|
|
451
|
+
}
|
package/src/operation.test.ts
CHANGED
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
|
|
19
19
|
function createClock(init = 0) {
|
|
20
20
|
let i = init;
|
|
21
|
-
|
|
21
|
+
const clock = () => (i++).toString();
|
|
22
|
+
Object.defineProperty(clock, 'current', {
|
|
23
|
+
get() {
|
|
24
|
+
return i;
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return clock;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
describe('creating diff patch operations', () => {
|
|
@@ -509,6 +515,156 @@ describe('creating diff patch operations', () => {
|
|
|
509
515
|
]
|
|
510
516
|
`);
|
|
511
517
|
});
|
|
518
|
+
|
|
519
|
+
it('should assign a new OID to objects which had an incompatible existing OID', () => {
|
|
520
|
+
const from = {
|
|
521
|
+
foo: {
|
|
522
|
+
bar: [1, 2, 3],
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
assignOid(from, 'test/a');
|
|
526
|
+
assignOidsToAllSubObjects(from, createClock());
|
|
527
|
+
const to = {
|
|
528
|
+
foo: {
|
|
529
|
+
bar: [1, 2],
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
assignOid(to, 'test/a');
|
|
533
|
+
assignOid(to.foo, 'uff/b');
|
|
534
|
+
assignOidsToAllSubObjects(to.foo, createClock());
|
|
535
|
+
expect(
|
|
536
|
+
diffToPatches(from, to, createClock(), createClock(5), [], {
|
|
537
|
+
mergeUnknownObjects: true,
|
|
538
|
+
}),
|
|
539
|
+
).toMatchInlineSnapshot(`
|
|
540
|
+
[
|
|
541
|
+
{
|
|
542
|
+
"data": {
|
|
543
|
+
"op": "initialize",
|
|
544
|
+
"value": [
|
|
545
|
+
1,
|
|
546
|
+
2,
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
"oid": "test/a:6",
|
|
550
|
+
"timestamp": "0",
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
"data": {
|
|
554
|
+
"op": "initialize",
|
|
555
|
+
"value": {
|
|
556
|
+
"bar": {
|
|
557
|
+
"@@type": "ref",
|
|
558
|
+
"id": "test/a:6",
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
"oid": "test/a:5",
|
|
563
|
+
"timestamp": "1",
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
"data": {
|
|
567
|
+
"name": "foo",
|
|
568
|
+
"op": "set",
|
|
569
|
+
"value": {
|
|
570
|
+
"@@type": "ref",
|
|
571
|
+
"id": "test/a:5",
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
"oid": "test/a",
|
|
575
|
+
"timestamp": "2",
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
"data": {
|
|
579
|
+
"op": "delete",
|
|
580
|
+
},
|
|
581
|
+
"oid": "test/a:0",
|
|
582
|
+
"timestamp": "3",
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
`);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// an edge case - if a subobject which contains nested objects changes identity,
|
|
589
|
+
// this gets written as a new init patch tree - we want to ensure all init subobjects
|
|
590
|
+
// in that tree have compatible OIDs.
|
|
591
|
+
it('should assign a new OID to objects which have an incompatible OID when parent identity changes', () => {
|
|
592
|
+
const from = {
|
|
593
|
+
bar: [],
|
|
594
|
+
};
|
|
595
|
+
assignOid(from, 'test/a');
|
|
596
|
+
assignOid(from.bar, 'test/random');
|
|
597
|
+
const to = {
|
|
598
|
+
bar: [{ baz: 1 }, { baz: 2 }],
|
|
599
|
+
};
|
|
600
|
+
assignOid(to, 'test/a');
|
|
601
|
+
assignOidsToAllSubObjects(to, createClock());
|
|
602
|
+
assignOid(to.bar[0], 'uff/b');
|
|
603
|
+
expect(
|
|
604
|
+
diffToPatches(from, to, createClock(), createClock(5), [], {
|
|
605
|
+
mergeUnknownObjects: true,
|
|
606
|
+
}),
|
|
607
|
+
).toMatchInlineSnapshot(`
|
|
608
|
+
[
|
|
609
|
+
{
|
|
610
|
+
"data": {
|
|
611
|
+
"op": "initialize",
|
|
612
|
+
"value": {
|
|
613
|
+
"baz": 1,
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
"oid": "test/a:5",
|
|
617
|
+
"timestamp": "0",
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
"data": {
|
|
621
|
+
"op": "initialize",
|
|
622
|
+
"value": {
|
|
623
|
+
"baz": 2,
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
"oid": "test/a:2",
|
|
627
|
+
"timestamp": "1",
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
"data": {
|
|
631
|
+
"op": "initialize",
|
|
632
|
+
"value": [
|
|
633
|
+
{
|
|
634
|
+
"@@type": "ref",
|
|
635
|
+
"id": "test/a:5",
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
"@@type": "ref",
|
|
639
|
+
"id": "test/a:2",
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
"oid": "test/a:0",
|
|
644
|
+
"timestamp": "2",
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
"data": {
|
|
648
|
+
"name": "bar",
|
|
649
|
+
"op": "set",
|
|
650
|
+
"value": {
|
|
651
|
+
"@@type": "ref",
|
|
652
|
+
"id": "test/a:0",
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
"oid": "test/a",
|
|
656
|
+
"timestamp": "3",
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"data": {
|
|
660
|
+
"op": "delete",
|
|
661
|
+
},
|
|
662
|
+
"oid": "test/random",
|
|
663
|
+
"timestamp": "4",
|
|
664
|
+
},
|
|
665
|
+
]
|
|
666
|
+
`);
|
|
667
|
+
});
|
|
512
668
|
});
|
|
513
669
|
|
|
514
670
|
describe('substituting refs with objects', () => {
|
package/src/operation.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { DocumentBaseline } from './baseline.js';
|
|
2
2
|
import { FileRef } from './files.js';
|
|
3
3
|
import {
|
|
4
|
+
areOidsRelated,
|
|
4
5
|
assignOid,
|
|
5
6
|
assignOidsToAllSubObjects,
|
|
6
7
|
createRef,
|
|
8
|
+
createSubOid,
|
|
7
9
|
ensureOid,
|
|
8
10
|
getOid,
|
|
9
11
|
getOidRoot,
|
|
@@ -192,13 +194,19 @@ export function diffToPatches<T extends { [key: string]: any } | any[]>(
|
|
|
192
194
|
}
|
|
193
195
|
} else {
|
|
194
196
|
const oldValueOid = maybeGetOid(oldValue);
|
|
195
|
-
let valueOid;
|
|
196
|
-
|
|
197
|
+
let valueOid = maybeGetOid(value);
|
|
198
|
+
// the user supplied an object which was already assigned an OID,
|
|
199
|
+
// but that OID is from a separate entity: this object must
|
|
200
|
+
// be cloned to a new identity.
|
|
201
|
+
if (valueOid && !areOidsRelated(oid, valueOid)) {
|
|
202
|
+
value = cloneDeep(value, false);
|
|
203
|
+
valueOid = createSubOid(oid, createSubId);
|
|
204
|
+
assignOid(value, valueOid);
|
|
205
|
+
} else if (!options.mergeUnknownObjects) {
|
|
197
206
|
valueOid = ensureOid(value, oid, createSubId);
|
|
198
207
|
} else {
|
|
199
208
|
// if merge unknown objects is requested, we copy the previous value's oid
|
|
200
209
|
// to any mirrored new value if it doesn't have one assigned already.
|
|
201
|
-
valueOid = maybeGetOid(value);
|
|
202
210
|
if (!valueOid && oldValueOid) {
|
|
203
211
|
assignOid(value, oldValueOid);
|
|
204
212
|
valueOid = oldValueOid;
|
|
@@ -206,6 +214,14 @@ export function diffToPatches<T extends { [key: string]: any } | any[]>(
|
|
|
206
214
|
valueOid = ensureOid(value, oid, createSubId);
|
|
207
215
|
}
|
|
208
216
|
}
|
|
217
|
+
assert(
|
|
218
|
+
!!valueOid,
|
|
219
|
+
'Error: no value OID was resolved during diff. This is a bug in Verdant.',
|
|
220
|
+
);
|
|
221
|
+
assert(
|
|
222
|
+
areOidsRelated(oid, valueOid),
|
|
223
|
+
`Error: value OID ${valueOid} is not related to parent OID ${oid}. This is a bug in Verdant.`,
|
|
224
|
+
);
|
|
209
225
|
|
|
210
226
|
if (oldValue === undefined || valueOid !== oldValueOid) {
|
|
211
227
|
// first case: previous value exists but the OIDs are different,
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { LEGACY_OID_KEY, OID_KEY } from '../oids.js';
|
|
2
|
+
import { isObject } from '../utils.js';
|
|
2
3
|
import type { StorageFieldSchema, StorageCollectionSchema } from './types.js';
|
|
3
4
|
|
|
5
|
+
export function isNullable(field: StorageFieldSchema) {
|
|
6
|
+
if (field.type === 'any') return true;
|
|
7
|
+
if (field.type === 'map') return false;
|
|
8
|
+
return field.nullable;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export function addFieldDefaults(
|
|
5
12
|
collection: StorageCollectionSchema,
|
|
6
13
|
value: any,
|
|
7
14
|
) {
|
|
8
15
|
for (const [key, field] of Object.entries(collection.fields)) {
|
|
9
16
|
const defaultValue = getFieldDefault(field);
|
|
10
|
-
if (
|
|
17
|
+
if (
|
|
18
|
+
(defaultValue !== undefined && value[key] === undefined) ||
|
|
19
|
+
// covers the case where a previously nullable field
|
|
20
|
+
// now has a default during a new migration
|
|
21
|
+
(!isNullable(field) && value[key] === null)
|
|
22
|
+
) {
|
|
11
23
|
value[key] = defaultValue;
|
|
12
24
|
}
|
|
13
25
|
if (value[key]) {
|
|
@@ -70,3 +82,45 @@ export function getFieldDefault(field: StorageFieldSchema) {
|
|
|
70
82
|
}
|
|
71
83
|
return undefined;
|
|
72
84
|
}
|
|
85
|
+
|
|
86
|
+
export function removeExtraProperties(
|
|
87
|
+
collection: StorageCollectionSchema,
|
|
88
|
+
value: any,
|
|
89
|
+
) {
|
|
90
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
91
|
+
// MUST NOT DELETE THESE!
|
|
92
|
+
if (key === OID_KEY || key === LEGACY_OID_KEY) continue;
|
|
93
|
+
|
|
94
|
+
if (!collection.fields[key]) {
|
|
95
|
+
delete value[key];
|
|
96
|
+
} else {
|
|
97
|
+
traverseCollectionFieldsAndRemoveExtraProperties(
|
|
98
|
+
fieldValue,
|
|
99
|
+
collection.fields[key],
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function traverseCollectionFieldsAndRemoveExtraProperties(
|
|
107
|
+
value: any,
|
|
108
|
+
field: StorageFieldSchema,
|
|
109
|
+
) {
|
|
110
|
+
if (isObject(value) && field.type === 'object') {
|
|
111
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
112
|
+
if (!field.properties[key]) {
|
|
113
|
+
delete value[key];
|
|
114
|
+
} else {
|
|
115
|
+
traverseCollectionFieldsAndRemoveExtraProperties(
|
|
116
|
+
fieldValue,
|
|
117
|
+
field.properties[key],
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else if (Array.isArray(value) && field.type === 'array') {
|
|
122
|
+
for (const item of value) {
|
|
123
|
+
traverseCollectionFieldsAndRemoveExtraProperties(item, field.items);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -37,7 +37,12 @@ export type StorageMapFieldSchema<V extends NestedStorageFieldSchema> = {
|
|
|
37
37
|
export type StorageFileFieldSchema = {
|
|
38
38
|
type: 'file';
|
|
39
39
|
nullable?: boolean;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Instructs the client to download synced files to local storage on first request for offline use.
|
|
42
|
+
* Leave this false to save storage space on the client, at the cost of requiring a network
|
|
43
|
+
* connection to use files created by other devices.
|
|
44
|
+
*/
|
|
45
|
+
downloadRemote?: boolean;
|
|
41
46
|
};
|
|
42
47
|
|
|
43
48
|
export type StorageFieldSchema =
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { LEGACY_OID_KEY, OID_KEY } from '../oids.js';
|
|
2
|
+
import { isObject } from '../utils.js';
|
|
3
|
+
import { StorageCollectionSchema, StorageFieldSchema } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function validateEntity(schema: StorageCollectionSchema, entity: any) {
|
|
6
|
+
for (const [key, value] of Object.entries(entity)) {
|
|
7
|
+
if (!schema.fields[key]) {
|
|
8
|
+
throw new Error(`Invalid field "${key}"`);
|
|
9
|
+
}
|
|
10
|
+
if (value) {
|
|
11
|
+
validateEntityField(schema.fields[key], value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateEntityField(
|
|
17
|
+
field: StorageFieldSchema,
|
|
18
|
+
value: any,
|
|
19
|
+
fieldPath: string[] = [],
|
|
20
|
+
) {
|
|
21
|
+
if (field.type === 'object') {
|
|
22
|
+
if (!isObject(value)) {
|
|
23
|
+
if (value === null && field.nullable) return;
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Expected object${
|
|
26
|
+
field.nullable ? ' or null' : ''
|
|
27
|
+
} for field ${formatField(fieldPath)}}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
for (const [key, subField] of Object.entries(field.properties)) {
|
|
31
|
+
if (value[key]) {
|
|
32
|
+
validateEntityField(subField, value[key]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const key of Object.keys(value)) {
|
|
36
|
+
if (!field.properties[key]) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid field "${key}" on value ${formatField(fieldPath)}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else if (field.type === 'array') {
|
|
43
|
+
if (!Array.isArray(value)) {
|
|
44
|
+
if (value === null && field.nullable) return;
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Expected array${
|
|
47
|
+
field.nullable ? ' or null' : ''
|
|
48
|
+
} for field ${formatField(fieldPath)}}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
for (const item of value) {
|
|
52
|
+
validateEntityField(field.items, item);
|
|
53
|
+
}
|
|
54
|
+
} else if (field.type === 'map') {
|
|
55
|
+
if (!isObject(value)) {
|
|
56
|
+
throw new Error(`Expected map for field ${formatField(fieldPath)}}`);
|
|
57
|
+
}
|
|
58
|
+
for (const [key, item] of Object.entries(value)) {
|
|
59
|
+
// santiy check to weed out any id field
|
|
60
|
+
if (key === OID_KEY || key === LEGACY_OID_KEY) continue;
|
|
61
|
+
validateEntityField(field.values, item);
|
|
62
|
+
}
|
|
63
|
+
} else if (field.type === 'string') {
|
|
64
|
+
if (typeof value !== 'string') {
|
|
65
|
+
if (value === null && field.nullable) return;
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Expected string ${
|
|
68
|
+
field.nullable ? ' or null' : ''
|
|
69
|
+
} for field ${formatField(fieldPath)}}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else if (field.type === 'boolean') {
|
|
73
|
+
if (typeof value !== 'boolean') {
|
|
74
|
+
if (value === null && field.nullable) return;
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Expected boolean ${
|
|
77
|
+
field.nullable ? ' or null' : ''
|
|
78
|
+
} for field ${formatField(fieldPath)}}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} else if (field.type === 'number') {
|
|
82
|
+
if (typeof value !== 'number') {
|
|
83
|
+
if (value === null && field.nullable) return;
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Expected number ${
|
|
86
|
+
field.nullable ? ' or null' : ''
|
|
87
|
+
} for field ${formatField(fieldPath)}}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
} else if (field.type === 'file') {
|
|
91
|
+
if (value === null && !field.nullable) {
|
|
92
|
+
throw new Error(`Expected file for field ${formatField(fieldPath)}}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatField(fieldPath: string[]) {
|
|
98
|
+
return fieldPath.join('.');
|
|
99
|
+
}
|