@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.
Files changed (82) hide show
  1. package/dist/cjs/indexes.d.ts +1 -0
  2. package/dist/cjs/indexes.js +5 -1
  3. package/dist/cjs/indexes.js.map +1 -1
  4. package/dist/cjs/migration.d.ts +7 -0
  5. package/dist/cjs/migration.js +53 -9
  6. package/dist/cjs/migration.js.map +1 -1
  7. package/dist/cjs/oids.d.ts +2 -0
  8. package/dist/cjs/oids.js +30 -4
  9. package/dist/cjs/oids.js.map +1 -1
  10. package/dist/cjs/oids.test.js +3 -0
  11. package/dist/cjs/oids.test.js.map +1 -1
  12. package/dist/cjs/operation.js +12 -3
  13. package/dist/cjs/operation.js.map +1 -1
  14. package/dist/cjs/operation.test.js +151 -1
  15. package/dist/cjs/operation.test.js.map +1 -1
  16. package/dist/cjs/schema/defaults.test.js +3 -3
  17. package/dist/cjs/schema/defaults.test.js.map +1 -1
  18. package/dist/cjs/schema/{defaults.d.ts → fields.d.ts} +3 -0
  19. package/dist/cjs/schema/{defaults.js → fields.js} +48 -3
  20. package/dist/cjs/schema/fields.js.map +1 -0
  21. package/dist/cjs/schema/index.d.ts +1 -1
  22. package/dist/cjs/schema/index.js +1 -1
  23. package/dist/cjs/schema/index.js.map +1 -1
  24. package/dist/cjs/schema/types/fields.d.ts +6 -0
  25. package/dist/cjs/schema/validation.d.ts +3 -0
  26. package/dist/cjs/schema/validation.js +87 -0
  27. package/dist/cjs/schema/validation.js.map +1 -0
  28. package/dist/cjs/timestamp.test.js +11 -0
  29. package/dist/cjs/timestamp.test.js.map +1 -1
  30. package/dist/cjs/utils.d.ts +5 -1
  31. package/dist/cjs/utils.js +9 -5
  32. package/dist/cjs/utils.js.map +1 -1
  33. package/dist/esm/indexes.d.ts +1 -0
  34. package/dist/esm/indexes.js +4 -0
  35. package/dist/esm/indexes.js.map +1 -1
  36. package/dist/esm/migration.d.ts +7 -0
  37. package/dist/esm/migration.js +54 -10
  38. package/dist/esm/migration.js.map +1 -1
  39. package/dist/esm/oids.d.ts +2 -0
  40. package/dist/esm/oids.js +27 -3
  41. package/dist/esm/oids.js.map +1 -1
  42. package/dist/esm/oids.test.js +4 -1
  43. package/dist/esm/oids.test.js.map +1 -1
  44. package/dist/esm/operation.js +13 -4
  45. package/dist/esm/operation.js.map +1 -1
  46. package/dist/esm/operation.test.js +151 -1
  47. package/dist/esm/operation.test.js.map +1 -1
  48. package/dist/esm/schema/defaults.test.js +1 -1
  49. package/dist/esm/schema/defaults.test.js.map +1 -1
  50. package/dist/esm/schema/{defaults.d.ts → fields.d.ts} +3 -0
  51. package/dist/esm/schema/{defaults.js → fields.js} +44 -2
  52. package/dist/esm/schema/fields.js.map +1 -0
  53. package/dist/esm/schema/index.d.ts +1 -1
  54. package/dist/esm/schema/index.js +1 -1
  55. package/dist/esm/schema/index.js.map +1 -1
  56. package/dist/esm/schema/types/fields.d.ts +6 -0
  57. package/dist/esm/schema/validation.d.ts +3 -0
  58. package/dist/esm/schema/validation.js +82 -0
  59. package/dist/esm/schema/validation.js.map +1 -0
  60. package/dist/esm/timestamp.test.js +12 -1
  61. package/dist/esm/timestamp.test.js.map +1 -1
  62. package/dist/esm/utils.d.ts +5 -1
  63. package/dist/esm/utils.js +9 -5
  64. package/dist/esm/utils.js.map +1 -1
  65. package/dist/tsconfig-cjs.tsbuildinfo +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +1 -1
  68. package/src/indexes.ts +5 -0
  69. package/src/migration.ts +86 -20
  70. package/src/oids.test.ts +7 -0
  71. package/src/oids.ts +31 -3
  72. package/src/operation.test.ts +157 -1
  73. package/src/operation.ts +19 -3
  74. package/src/schema/defaults.test.ts +1 -1
  75. package/src/schema/{defaults.ts → fields.ts} +55 -1
  76. package/src/schema/index.ts +1 -1
  77. package/src/schema/types/fields.ts +6 -1
  78. package/src/schema/validation.ts +99 -0
  79. package/src/timestamp.test.ts +13 -0
  80. package/src/utils.ts +9 -5
  81. package/dist/cjs/schema/defaults.js.map +0 -1
  82. 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, strategy);
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: (collectionName: string, val: any) => {
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
- const unmigrated = changedCollections.filter(
273
- (collection) => !migratedCollections.includes(collection),
274
- );
275
- if (unmigrated.length > 0) {
276
- console.error('Unmigrated changed collections:', unmigrated);
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
- oldSchema,
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
- for (const collection of info.changedCollections as any) {
386
- // @ts-ignore indefinite type resolution
387
- await migrate(collection, (old) => withDefaults(collection, old));
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
  '.': '&dot;',
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
- ensureOid(item, rootOid, createSubId);
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
- ensureOid(obj[key], rootOid, createSubId);
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
+ }
@@ -18,7 +18,13 @@ import {
18
18
 
19
19
  function createClock(init = 0) {
20
20
  let i = init;
21
- return () => (i++).toString();
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
- if (!options.mergeUnknownObjects) {
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,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { getFieldDefault } from './defaults.js';
2
+ import { getFieldDefault } from './fields.js';
3
3
  import { assignOid, createOid, getOid, maybeGetOid } from '../oids.js';
4
4
 
5
5
  describe('getting field defaults', () => {
@@ -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 (defaultValue !== undefined && value[key] === undefined) {
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
+ }
@@ -25,4 +25,4 @@ export function schema<
25
25
  export * from './types.js';
26
26
 
27
27
  export * from './indexFilters.js';
28
- export * from './defaults.js';
28
+ export * from './fields.js';
@@ -37,7 +37,12 @@ export type StorageMapFieldSchema<V extends NestedStorageFieldSchema> = {
37
37
  export type StorageFileFieldSchema = {
38
38
  type: 'file';
39
39
  nullable?: boolean;
40
- // downloadRemote?: boolean; TODO: is this a good feature? maybe later.
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
+ }