@verdant-web/common 2.7.2 → 2.8.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.
Files changed (45) hide show
  1. package/dist/esm/diffing.d.ts +39 -0
  2. package/dist/esm/diffing.js +257 -0
  3. package/dist/esm/diffing.js.map +1 -0
  4. package/dist/esm/diffing.test.d.ts +1 -0
  5. package/dist/esm/diffing.test.js +896 -0
  6. package/dist/esm/diffing.test.js.map +1 -0
  7. package/dist/esm/index.d.ts +20 -19
  8. package/dist/esm/index.js +17 -16
  9. package/dist/esm/index.js.map +1 -1
  10. package/dist/esm/migration.js +1 -1
  11. package/dist/esm/migration.js.map +1 -1
  12. package/dist/esm/migration.test.d.ts +1 -0
  13. package/dist/esm/migration.test.js +98 -0
  14. package/dist/esm/migration.test.js.map +1 -0
  15. package/dist/esm/operation.d.ts +7 -21
  16. package/dist/esm/operation.js +28 -266
  17. package/dist/esm/operation.js.map +1 -1
  18. package/dist/esm/operation.test.js +122 -592
  19. package/dist/esm/operation.test.js.map +1 -1
  20. package/dist/esm/patch.d.ts +13 -13
  21. package/dist/esm/patch.js +41 -16
  22. package/dist/esm/patch.js.map +1 -1
  23. package/dist/esm/timestamp.d.ts +1 -0
  24. package/dist/esm/timestamp.js +3 -0
  25. package/dist/esm/timestamp.js.map +1 -1
  26. package/dist/esm/undo.js +15 -2
  27. package/dist/esm/undo.js.map +1 -1
  28. package/dist/esm/undo.test.d.ts +1 -0
  29. package/dist/esm/undo.test.js +20 -0
  30. package/dist/esm/undo.test.js.map +1 -0
  31. package/dist/esm/utils.js +13 -3
  32. package/dist/esm/utils.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/diffing.test.ts +939 -0
  35. package/src/diffing.ts +325 -0
  36. package/src/index.ts +21 -20
  37. package/src/migration.test.ts +112 -0
  38. package/src/migration.ts +2 -1
  39. package/src/operation.test.ts +148 -623
  40. package/src/operation.ts +37 -371
  41. package/src/patch.ts +68 -11
  42. package/src/timestamp.ts +4 -1
  43. package/src/undo.test.ts +21 -0
  44. package/src/undo.ts +15 -2
  45. package/src/utils.ts +13 -3
package/src/diffing.ts ADDED
@@ -0,0 +1,325 @@
1
+ import { VerdantError } from './error.js';
2
+ import {
3
+ areOidsRelated,
4
+ assignOid,
5
+ getOid,
6
+ maybeGetOid,
7
+ ObjectIdentifier,
8
+ } from './oids.js';
9
+ import { isOidKey } from './oidsLegacy.js';
10
+ import { Operation } from './operation.js';
11
+ import { PatchCreator } from './patch.js';
12
+ import { compareRefs, isRef } from './refs.js';
13
+ import { cloneDeep, isObject } from './utils.js';
14
+
15
+ export type DiffContext = {
16
+ patches: Operation[];
17
+ /**
18
+ * If an object is merged with another and the new one does not
19
+ * have an OID assigned, assume it is the same identity as previous
20
+ */
21
+ mergeUnknownObjects?: boolean;
22
+ /**
23
+ * If an incoming value is not assigned on the new object, use the previous value.
24
+ * If false, undefined properties will erase the previous value.
25
+ */
26
+ merge?: boolean;
27
+ /**
28
+ * Authorization to apply to all created operations
29
+ */
30
+ authz?: string;
31
+ patchCreator: PatchCreator;
32
+ };
33
+
34
+ /**
35
+ * Compares two anythings and determines if they
36
+ * represent the same thing. Works for primitives,
37
+ * refs, and objects with OIDs.
38
+ */
39
+ function areTheSameIdentity(a: any, b: any) {
40
+ if (a === b) return true;
41
+ if (isRef(a) && isRef(b)) return compareRefs(a, b);
42
+ const aOid = maybeGetOid(a);
43
+ const bOid = maybeGetOid(b);
44
+ if (aOid && bOid && aOid === bOid) return true;
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Enforces OID rules on subobjects being added to an entity.
50
+ * - Every sub-object must have an OID
51
+ * - The sub-object's OID must relate to the parent OID
52
+ */
53
+ function enforceAssignedOid(
54
+ parentOid: ObjectIdentifier,
55
+ newObject: any,
56
+ existingObjectOid: ObjectIdentifier | undefined,
57
+ ctx: DiffContext,
58
+ ) {
59
+ if (!isDiffableObject(newObject)) {
60
+ // nothing to do, the new value cannot have an oid as it is not
61
+ // a sub-object.
62
+ return newObject;
63
+ }
64
+
65
+ const oid = maybeGetOid(newObject);
66
+ if (!oid) {
67
+ // if merge unknown objects is enabled, we can assume the new object is the same
68
+ // as the existing object (if present) and use its oid. otherwise we assign a new one.
69
+ if (ctx.mergeUnknownObjects && existingObjectOid) {
70
+ assignOid(newObject, existingObjectOid);
71
+ }
72
+ // NOTE: new OID assignments are done in patchCreator.
73
+ // else {
74
+ // assignOid(
75
+ // newObject,
76
+ // createSubOid(parentOid, ctx.patchCreator.createSubId),
77
+ // );
78
+ // }
79
+ } else if (!areOidsRelated(parentOid, oid)) {
80
+ // when there's any doubt, clone the whole object. false -> do not copy OIDs
81
+ const clone = cloneDeep(newObject, false);
82
+ // NOTE: new OID assignments are done in patchCreator.
83
+ // const cloneOid = createSubOid(parentOid, ctx.patchCreator.createSubId);
84
+ // assignOid(clone, cloneOid);
85
+ return clone;
86
+ }
87
+ return newObject;
88
+ }
89
+
90
+ function isDiffableObject(val: any) {
91
+ return isObject(val) && !isRef(val);
92
+ }
93
+
94
+ export function diffToPatches(
95
+ from: any,
96
+ to: any,
97
+ getNow: () => string,
98
+ createSubId?: () => string,
99
+ _?: any, // legacy, TODO: remove
100
+ options?: {
101
+ mergeUnknownObjects?: boolean;
102
+ merge?: boolean;
103
+ /** @deprecated - use 'merge' */
104
+ defaultUndefined?: boolean;
105
+ authz?: string;
106
+ },
107
+ ) {
108
+ const ctx: DiffContext = {
109
+ patches: [],
110
+ mergeUnknownObjects: options?.mergeUnknownObjects,
111
+ merge: options?.merge ?? options?.defaultUndefined,
112
+ authz: options?.authz,
113
+ patchCreator: new PatchCreator(getNow, createSubId),
114
+ };
115
+ diff(from, to, ctx);
116
+ return ctx.patches;
117
+ }
118
+
119
+ export function diff(from: any, to: any, ctx: DiffContext) {
120
+ if (Array.isArray(from) && Array.isArray(to)) {
121
+ diffLists(from, to, ctx);
122
+ } else if (Array.isArray(from) || Array.isArray(to)) {
123
+ throw new VerdantError(
124
+ VerdantError.Code.Unexpected,
125
+ undefined,
126
+ 'Cannot diff between array and non-array',
127
+ );
128
+ } else if (isDiffableObject(from) && isDiffableObject(to)) {
129
+ diffObjects(from, to, ctx);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Deep diff lists of any kind of item. Adds patches for the changes
135
+ * to the context patch list.
136
+ * @param from - the original list snapshot. must be the full snapshot, no references.
137
+ * @param to - the new list snapshot. must be the full snapshot, no references.
138
+ * @param ctx - the diff context to add patches to.
139
+ */
140
+ export function diffLists(from: any[], to: any[], ctx: DiffContext) {
141
+ // from object must be registered with an OID.
142
+ const oid = getOid(from);
143
+ // this copy will be mutated to align with inserts, making things easier to compare.
144
+ const fromCopy = [...from];
145
+
146
+ // first, normalize incoming data according to OID rules. this is done early
147
+ // so future equality checks are according to configuration like mergeUnknownObjects.
148
+ // for example, if a bare (no oid) object is supplied for an item and mergeUnknownObjects
149
+ // is set, this will assign it the same OID as the existing item at that index, so it will
150
+ // pass equality checks for insertion range, etc.
151
+ for (let i = 0; i < to.length; i++) {
152
+ const value = to[i];
153
+ const oldValue = from[i];
154
+ to[i] = enforceAssignedOid(oid, value, maybeGetOid(oldValue), ctx);
155
+ }
156
+
157
+ // track whether the new list has gaps in it. a gap
158
+ // means we can no longer use list-push for new items.
159
+ let noGaps = true;
160
+ for (let i = 0; i < to.length; i++) {
161
+ const value = to[i];
162
+ const oldValue = fromCopy[i];
163
+ if (value === undefined) {
164
+ noGaps = false;
165
+ }
166
+
167
+ // we decide if this item is being added to the end of the list if:
168
+ // - there were no empty spaces before it in the new list
169
+ // - the index is beyond the scope of the original list.
170
+ // the second condition is carefully selected since it accounts for
171
+ // the prior list having gaps, too. length should represent the final
172
+ // defined item even if gaps were there previously. we don't want to
173
+ // accidentally 'push' into a gap, which would put the item in the wrong place.
174
+ const isEndOfList = noGaps && i >= fromCopy.length;
175
+
176
+ if (isEndOfList) {
177
+ // this will initialize all sub-objects in the item, too
178
+ ctx.patches.push(
179
+ ...ctx.patchCreator.createListPush(oid, value, ctx.authz),
180
+ );
181
+ } else if (areTheSameIdentity(value, oldValue)) {
182
+ // the identity of this item hasn't changed, but we
183
+ // still have to diff the contents.
184
+ diff(oldValue, value, ctx);
185
+ } else {
186
+ // the identity of this item has changed. we can now evaluate
187
+ // whether we should replace the original item or insert a new
188
+ // item.
189
+
190
+ // we can insert an item if the items which used to be at this
191
+ // index and the one before it are still in the list next to the
192
+ // new item.
193
+ // i.e. [0,1,2,3] -> [0,1,4,2,3] can insert 4 at index 2.
194
+ // theoretically we could support inserting a group of items,
195
+ // like [0,1,2,3] -> [0,1,4,5,6,2,3], but in practice this is much
196
+ // harder to detect.
197
+ const isPreviousItemStillThere =
198
+ i === 0 || areTheSameIdentity(to[i - 1], fromCopy[i - 1]);
199
+ const isNextItemStillThere =
200
+ i === to.length - 1 ||
201
+ areTheSameIdentity(
202
+ to[i + 1],
203
+ // only "i" here because in the prior list, this was the item
204
+ // at the insertion point.
205
+ fromCopy[i],
206
+ );
207
+ if (isPreviousItemStillThere && isNextItemStillThere) {
208
+ ctx.patches.push(
209
+ ...ctx.patchCreator.createListInsert(oid, i, value, ctx.authz),
210
+ );
211
+ // mutate from copy to mirror this insert so further comparisons
212
+ // are correct. we just insert an undefined.
213
+ fromCopy.splice(i, 0, undefined);
214
+ } else {
215
+ // if we can't insert, we have to replace the item.
216
+ ctx.patches.push(
217
+ ...ctx.patchCreator.createListSet(oid, i, value, ctx.authz),
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ // remove any remaining items at the end of the array
224
+ const deletedItemsAtEnd = fromCopy.length - to.length;
225
+ if (deletedItemsAtEnd > 0) {
226
+ // if sub-items were objects, we need to delete them all
227
+ // this should recursively delete children of these items
228
+ // also!
229
+ for (let i = to.length; i < fromCopy.length; i++) {
230
+ const value = fromCopy[i];
231
+ deleteWithSubObjects(value, ctx);
232
+ }
233
+ // push the list-delete for the deleted items
234
+ ctx.patches.push(
235
+ ...ctx.patchCreator.createListDelete(
236
+ oid,
237
+ to.length,
238
+ deletedItemsAtEnd,
239
+ ctx.authz,
240
+ ),
241
+ );
242
+ }
243
+ }
244
+
245
+ export function diffObjects(from: any, to: any, ctx: DiffContext) {
246
+ const oldKeys = new Set(Object.keys(from));
247
+ const oid = getOid(from);
248
+ for (const key in to) {
249
+ const value = to[key];
250
+ if (value === undefined && ctx.merge) continue;
251
+ oldKeys.delete(key);
252
+ if (isOidKey(key)) continue; // legacy
253
+ const oldValue = from[key];
254
+ if (!isDiffableObject(value)) {
255
+ if (!areTheSameIdentity(value, oldValue)) {
256
+ // the value has changed for this key
257
+ // if the value is undefined (merge is off), delete instead of
258
+ // set.
259
+ if (value === undefined) {
260
+ ctx.patches.push(
261
+ ...ctx.patchCreator.createRemove(oid, key, ctx.authz),
262
+ );
263
+ } else {
264
+ ctx.patches.push(
265
+ ...ctx.patchCreator.createSet(oid, key, value, ctx.authz),
266
+ );
267
+ }
268
+ // if there was an old value at this key, delete it.
269
+ deleteWithSubObjects(oldValue, ctx);
270
+ } else {
271
+ // two primitive, non-diffable values of the
272
+ // same identity are considered equal.
273
+ // we have nothing to do here.
274
+ }
275
+ } else {
276
+ // make sure incoming object has a valid OID assigned,
277
+ // and/or copy the existing value's OID if mergeUnknownObjects is
278
+ // true.
279
+ enforceAssignedOid(oid, value, maybeGetOid(oldValue), ctx);
280
+ if (!oldValue) {
281
+ // set the new value on this key
282
+ ctx.patches.push(
283
+ ...ctx.patchCreator.createSet(oid, key, value, ctx.authz),
284
+ );
285
+ } else if (!areTheSameIdentity(value, oldValue)) {
286
+ // overwrite the key with the changed value
287
+ ctx.patches.push(
288
+ ...ctx.patchCreator.createSet(oid, key, value, ctx.authz),
289
+ );
290
+ // and we must also fully delete the
291
+ // old object and its children
292
+ deleteWithSubObjects(oldValue, ctx);
293
+ } else {
294
+ // finally, this is the case when the identity of
295
+ // the new and old values are the same -- we still
296
+ // have to diff the contents.
297
+ diff(oldValue, value, ctx);
298
+ }
299
+ }
300
+ }
301
+ // this set now only contains keys which were not in the new object
302
+ if (!ctx.merge) {
303
+ for (const key of oldKeys) {
304
+ if (isOidKey(key)) continue;
305
+ // remove the key entirely
306
+ ctx.patches.push(...ctx.patchCreator.createRemove(oid, key, ctx.authz));
307
+ // push the deletes for the contents of the item
308
+ deleteWithSubObjects(from[key], ctx);
309
+ }
310
+ }
311
+ }
312
+
313
+ export function deleteWithSubObjects(root: any, ctx: DiffContext) {
314
+ if (!isDiffableObject(root)) {
315
+ return;
316
+ }
317
+ const oid = maybeGetOid(root);
318
+ if (oid) {
319
+ ctx.patches.push(...ctx.patchCreator.createDelete(oid, ctx.authz));
320
+ for (const key in root) {
321
+ const value = root[key];
322
+ deleteWithSubObjects(value, ctx);
323
+ }
324
+ }
325
+ }
package/src/index.ts CHANGED
@@ -1,33 +1,34 @@
1
- export * from './protocol.js';
2
- export * from './timestamp.js';
3
- export * from './operation.js';
1
+ export * from './authz.js';
4
2
  export * from './baseline.js';
5
- export * from './replica.js';
6
- export * from './schema/index.js';
7
- export * from './utils.js';
3
+ export * from './batching.js';
4
+ export * from './diffing.js';
5
+ export * from './error.js';
6
+ export * from './EventSubscriber.js';
7
+ export * from './files.js';
8
8
  export * from './indexes.js';
9
+ export * from './memo.js';
9
10
  export {
10
- migrate,
11
- migrationRange,
12
11
  createDefaultMigration,
13
12
  createMigration,
13
+ migrate,
14
+ migrationRange,
14
15
  } from './migration.js';
15
16
  export type {
16
17
  Migration,
17
- MigrationIndexDescription,
18
18
  MigrationEngine,
19
+ MigrationIndexDescription,
19
20
  } from './migration.js';
20
- export type * from './presence.js';
21
- export { initialInternalPresence } from './presence.js';
22
- export * from './patch.js';
23
21
  export * from './oids.js';
24
22
  export * from './oidsLegacy.js';
25
- export * from './EventSubscriber.js';
26
- export * from './undo.js';
27
- export * from './batching.js';
28
- export * from './files.js';
23
+ export * from './operation.js';
24
+ export * from './patch.js';
25
+ export type * from './presence.js';
26
+ export { initialInternalPresence } from './presence.js';
27
+ export * from './protocol.js';
28
+ export { compareRefs, isRef, makeFileRef, makeObjectRef } from './refs.js';
29
29
  export type { Ref } from './refs.js';
30
- export { makeObjectRef, makeFileRef, isRef, compareRefs } from './refs.js';
31
- export * from './memo.js';
32
- export * from './error.js';
33
- export * from './authz.js';
30
+ export * from './replica.js';
31
+ export * from './schema/index.js';
32
+ export * from './timestamp.js';
33
+ export * from './undo.js';
34
+ export * from './utils.js';
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createMigration } from './migration.js';
3
+ import { schema } from './schema/index.js';
4
+
5
+ describe('migration', () => {
6
+ it('can migrate schemas with recursive fields', async () => {
7
+ const recursiveFieldBase = schema.fields.object({
8
+ fields: {},
9
+ });
10
+ const recursiveField = schema.fields.replaceObjectFields(
11
+ recursiveFieldBase,
12
+ {
13
+ nested: recursiveFieldBase,
14
+ },
15
+ );
16
+ const fromSchema = schema({
17
+ version: 1,
18
+ collections: {
19
+ things: schema.collection({
20
+ name: 'thing',
21
+ primaryKey: 'id',
22
+ fields: {
23
+ id: schema.fields.id(),
24
+ recursive: recursiveField,
25
+ },
26
+ }),
27
+ },
28
+ });
29
+ const toSchema = schema({
30
+ version: 2,
31
+ collections: {
32
+ things: schema.collection({
33
+ name: 'thing',
34
+ primaryKey: 'id',
35
+ fields: {
36
+ id: schema.fields.id(),
37
+ recursive: recursiveField,
38
+ newField: schema.fields.number(),
39
+ },
40
+ }),
41
+ },
42
+ });
43
+
44
+ const procedure = vi.fn(() => Promise.resolve());
45
+ const migration = createMigration(fromSchema, toSchema, procedure);
46
+
47
+ await migration.migrate({
48
+ log: () => {},
49
+ queries: {},
50
+ mutations: {},
51
+ migrate: () => Promise.resolve(),
52
+ deleteCollection: () => Promise.resolve(),
53
+ });
54
+
55
+ expect(procedure).toHaveBeenCalledOnce();
56
+ });
57
+
58
+ it('auto-migrates new default fields without complaining', async () => {
59
+ const fromSchema = schema({
60
+ version: 1,
61
+ collections: {
62
+ things: schema.collection({
63
+ name: 'thing',
64
+ primaryKey: 'id',
65
+ fields: {
66
+ id: schema.fields.id(),
67
+ },
68
+ }),
69
+ },
70
+ });
71
+ const toSchema = schema({
72
+ version: 2,
73
+ collections: {
74
+ things: schema.collection({
75
+ name: 'thing',
76
+ primaryKey: 'id',
77
+ fields: {
78
+ id: schema.fields.id(),
79
+ newField: schema.fields.number({
80
+ default: 1,
81
+ }),
82
+ newNullable: schema.fields.object({
83
+ fields: {
84
+ foo: schema.fields.string(),
85
+ },
86
+ nullable: true,
87
+ }),
88
+ },
89
+ }),
90
+ },
91
+ });
92
+
93
+ const procedure = vi.fn(() => Promise.resolve());
94
+ const migration = createMigration(fromSchema, toSchema, procedure);
95
+
96
+ const log = vi.fn();
97
+ await migration.migrate({
98
+ log,
99
+ queries: {},
100
+ mutations: {},
101
+ migrate: () => Promise.resolve(),
102
+ deleteCollection: () => Promise.resolve(),
103
+ });
104
+
105
+ expect(procedure).toHaveBeenCalledOnce();
106
+ expect(log).not.toHaveBeenCalledWith(
107
+ 'error',
108
+ 'Unmigrated changed collections from version 1 to version 2:',
109
+ ['things'],
110
+ );
111
+ });
112
+ });
package/src/migration.ts CHANGED
@@ -654,7 +654,8 @@ export function createMigration(
654
654
  );
655
655
  if (unmigrated.length > 0) {
656
656
  // TODO: does this deserve a full-on error?
657
- console.error(
657
+ engine.log(
658
+ 'error',
658
659
  `Unmigrated changed collections from version ${oldSchema.version} to version ${newSchema.version}:`,
659
660
  unmigrated,
660
661
  );