@verdant-web/common 2.7.3 → 2.9.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 (52) hide show
  1. package/dist/esm/authz.js.map +1 -1
  2. package/dist/esm/baseline.d.ts +2 -1
  3. package/dist/esm/diffing.d.ts +40 -0
  4. package/dist/esm/diffing.js +317 -0
  5. package/dist/esm/diffing.js.map +1 -0
  6. package/dist/esm/diffing.test.d.ts +1 -0
  7. package/dist/esm/diffing.test.js +1680 -0
  8. package/dist/esm/diffing.test.js.map +1 -0
  9. package/dist/esm/index.d.ts +20 -19
  10. package/dist/esm/index.js +17 -16
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/oids.d.ts +5 -0
  13. package/dist/esm/oids.js +23 -1
  14. package/dist/esm/oids.js.map +1 -1
  15. package/dist/esm/operation.d.ts +11 -24
  16. package/dist/esm/operation.js +32 -270
  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 +19 -15
  21. package/dist/esm/patch.js +79 -18
  22. package/dist/esm/patch.js.map +1 -1
  23. package/dist/esm/schema/validation.d.ts +8 -1
  24. package/dist/esm/schema/validation.js +133 -63
  25. package/dist/esm/schema/validation.js.map +1 -1
  26. package/dist/esm/schema/validation.test.d.ts +1 -0
  27. package/dist/esm/schema/validation.test.js +60 -0
  28. package/dist/esm/schema/validation.test.js.map +1 -0
  29. package/dist/esm/timestamp.d.ts +1 -0
  30. package/dist/esm/timestamp.js +3 -0
  31. package/dist/esm/timestamp.js.map +1 -1
  32. package/dist/esm/undo.d.ts +2 -1
  33. package/dist/esm/undo.js +53 -146
  34. package/dist/esm/undo.js.map +1 -1
  35. package/dist/esm/undo.test.d.ts +1 -0
  36. package/dist/esm/undo.test.js +81 -0
  37. package/dist/esm/undo.test.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/authz.ts +2 -0
  40. package/src/baseline.ts +3 -1
  41. package/src/diffing.test.ts +1803 -0
  42. package/src/diffing.ts +402 -0
  43. package/src/index.ts +21 -20
  44. package/src/oids.ts +23 -1
  45. package/src/operation.test.ts +148 -623
  46. package/src/operation.ts +45 -378
  47. package/src/patch.ts +146 -14
  48. package/src/schema/validation.test.ts +66 -0
  49. package/src/schema/validation.ts +156 -73
  50. package/src/timestamp.ts +4 -1
  51. package/src/undo.test.ts +100 -0
  52. package/src/undo.ts +83 -144
@@ -1,7 +1,8 @@
1
- import { isFile, isFileData } from '../files.js';
1
+ import { isFile, isFileData, isFileRef } from '../files.js';
2
2
  import { OID_KEY } from '../oidsLegacy.js';
3
+ import { isObjectRef } from '../operation.js';
3
4
  import { isObject } from '../utils.js';
4
- import { getFieldDefault, hasDefault, isNullable } from './fields.js';
5
+ import { hasDefault, isNullable } from './fields.js';
5
6
  import { StorageFieldSchema, StorageFieldsSchema } from './types.js';
6
7
 
7
8
  export function validateEntity(
@@ -47,17 +48,29 @@ export function validateEntityField({
47
48
  fieldPath = [],
48
49
  depth,
49
50
  requireDefaults,
51
+ expectRefs,
52
+ hasPassedFirstLevel,
50
53
  }: {
51
54
  field: StorageFieldSchema;
52
55
  value: any;
53
56
  fieldPath: (string | number)[];
54
57
  depth?: number;
55
58
  requireDefaults?: boolean;
59
+ /**
60
+ * Run validation expecting refs, not nested objects. For validating
61
+ * views (as opposed to inits/snapshots). Recommended to use depth 2
62
+ * or no depth specified.
63
+ */
64
+ expectRefs?: boolean;
65
+ hasPassedFirstLevel?: boolean;
56
66
  }): EntityValidationProblem | undefined {
67
+ // we only _actually_ start expecting refs after the first validation level.
68
+ // TODO: something more... elegant?
69
+ const actuallyExpectRefs = expectRefs && hasPassedFirstLevel;
57
70
  if (depth !== undefined && depth <= 0) return;
58
71
 
59
- if (isNullable(field) && value === null) return;
60
- if (value === null) {
72
+ if (isNullable(field) && (value === null || value === undefined)) return;
73
+ if (value === null || value === undefined) {
61
74
  if (requireDefaults || !hasDefault(field)) {
62
75
  return {
63
76
  type: 'no-default',
@@ -68,77 +81,126 @@ export function validateEntityField({
68
81
  }
69
82
 
70
83
  if (field.type === 'object') {
71
- if (!isObject(value)) {
72
- return {
73
- type: 'invalid-type',
74
- fieldPath,
75
- message: `Expected object ${
76
- field.nullable ? 'or null ' : ''
77
- }for field ${formatField(fieldPath)}, got ${value}`,
78
- };
79
- }
80
- for (const [key, subField] of Object.entries(
81
- field.properties as StorageFieldsSchema,
82
- )) {
83
- // legacy -- old objects sometimes accidentally include this key
84
- if (key === OID_KEY) continue;
85
- if (value[key]) {
86
- validateEntityField({
84
+ if (actuallyExpectRefs) {
85
+ if (!isObjectRef(value)) {
86
+ return {
87
+ type: 'invalid-type',
88
+ fieldPath,
89
+ message: `Expected ref for field ${formatField(
90
+ fieldPath,
91
+ )}, got ${safeStringify(value)}`,
92
+ };
93
+ }
94
+ } else {
95
+ if (!isObject(value)) {
96
+ return {
97
+ type: 'invalid-type',
98
+ fieldPath,
99
+ message: `Expected object ${
100
+ field.nullable ? 'or null ' : ''
101
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
102
+ };
103
+ }
104
+ for (const [key, subField] of Object.entries(
105
+ field.properties as StorageFieldsSchema,
106
+ )) {
107
+ // legacy -- old objects sometimes accidentally include this key
108
+ if (key === OID_KEY) continue;
109
+ const error = validateEntityField({
87
110
  field: subField,
88
111
  value: value[key],
89
112
  fieldPath: [...fieldPath, key],
90
113
  depth: depth !== undefined ? depth - 1 : undefined,
114
+ expectRefs,
115
+ hasPassedFirstLevel: true,
91
116
  });
117
+ if (error) {
118
+ return error;
119
+ }
120
+ }
121
+ // check for unexpected keys
122
+ for (const key of Object.keys(value)) {
123
+ if (!field.properties[key]) {
124
+ return {
125
+ type: 'invalid-key',
126
+ fieldPath: [...fieldPath, key],
127
+ message: `Invalid unexpected field "${key}" on value ${formatField(
128
+ fieldPath,
129
+ )}`,
130
+ };
131
+ }
92
132
  }
93
133
  }
94
- // check for unexpected keys
95
- for (const key of Object.keys(value)) {
96
- if (!field.properties[key]) {
134
+ } else if (field.type === 'array') {
135
+ if (actuallyExpectRefs) {
136
+ if (!isObjectRef(value)) {
97
137
  return {
98
- type: 'invalid-key',
99
- fieldPath: [...fieldPath, key],
100
- message: `Invalid unexpected field "${key}" on value ${formatField(
138
+ type: 'invalid-type',
139
+ fieldPath,
140
+ message: `Expected ref for field ${formatField(
101
141
  fieldPath,
102
- )}`,
142
+ )}, got ${safeStringify(value)}`,
103
143
  };
104
144
  }
105
- }
106
- } else if (field.type === 'array') {
107
- if (!Array.isArray(value)) {
108
- if (value === null && field.nullable) return;
109
- return {
110
- type: 'invalid-value',
111
- fieldPath,
112
- message: `Expected array ${
113
- field.nullable ? 'or null ' : ''
114
- }for field ${formatField(fieldPath)}, got ${value}`,
115
- };
116
- }
117
- for (const item of value) {
118
- validateEntityField({
119
- field: field.items,
120
- value: item,
121
- fieldPath: [...fieldPath, '[]'],
122
- depth: depth !== undefined ? depth - 1 : undefined,
123
- });
145
+ } else {
146
+ if (!Array.isArray(value)) {
147
+ if (value === null && field.nullable) return;
148
+ return {
149
+ type: 'invalid-value',
150
+ fieldPath,
151
+ message: `Expected array ${
152
+ field.nullable ? 'or null ' : ''
153
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
154
+ };
155
+ }
156
+ for (const item of value) {
157
+ const error = validateEntityField({
158
+ field: field.items,
159
+ value: item,
160
+ fieldPath: [...fieldPath, '[]'],
161
+ depth: depth !== undefined ? depth - 1 : undefined,
162
+ expectRefs,
163
+ hasPassedFirstLevel: true,
164
+ });
165
+ if (error) {
166
+ return error;
167
+ }
168
+ }
124
169
  }
125
170
  } else if (field.type === 'map') {
126
- if (!isObject(value)) {
127
- return {
128
- type: 'invalid-type',
129
- fieldPath,
130
- message: `Expected map for field ${formatField(
171
+ if (actuallyExpectRefs) {
172
+ if (!isObjectRef(value)) {
173
+ return {
174
+ type: 'invalid-type',
131
175
  fieldPath,
132
- )}, got ${value}`,
133
- };
134
- }
135
- for (const [key, item] of Object.entries(value)) {
136
- validateEntityField({
137
- field: field.values,
138
- value: item,
139
- fieldPath: [...fieldPath, key],
140
- depth: depth !== undefined ? depth - 1 : undefined,
141
- });
176
+ message: `Expected ref for field ${formatField(
177
+ fieldPath,
178
+ )}, got ${safeStringify(value)}`,
179
+ };
180
+ }
181
+ } else {
182
+ if (!isObject(value)) {
183
+ return {
184
+ type: 'invalid-type',
185
+ fieldPath,
186
+ message: `Expected map for field ${formatField(
187
+ fieldPath,
188
+ )}, got ${safeStringify(value)}`,
189
+ };
190
+ }
191
+ for (const [key, item] of Object.entries(value)) {
192
+ const error = validateEntityField({
193
+ field: field.values,
194
+ value: item,
195
+ fieldPath: [...fieldPath, key],
196
+ depth: depth !== undefined ? depth - 1 : undefined,
197
+ expectRefs,
198
+ hasPassedFirstLevel: true,
199
+ });
200
+ if (error) {
201
+ return error;
202
+ }
203
+ }
142
204
  }
143
205
  } else if (field.type === 'string') {
144
206
  if (typeof value !== 'string') {
@@ -147,7 +209,7 @@ export function validateEntityField({
147
209
  fieldPath,
148
210
  message: `Expected string ${
149
211
  field.nullable ? 'or null ' : ''
150
- }for field ${formatField(fieldPath)}, got ${value}`,
212
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
151
213
  };
152
214
  }
153
215
  if (field.options && !field.options.includes(value)) {
@@ -156,7 +218,7 @@ export function validateEntityField({
156
218
  fieldPath,
157
219
  message: `Expected one of ${field.options.join(
158
220
  ', ',
159
- )} for field ${formatField(fieldPath)}, got ${value}`,
221
+ )} for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
160
222
  };
161
223
  }
162
224
  } else if (field.type === 'boolean') {
@@ -166,7 +228,7 @@ export function validateEntityField({
166
228
  fieldPath,
167
229
  message: `Expected boolean ${
168
230
  field.nullable ? 'or null ' : ''
169
- }for field ${formatField(fieldPath)}, got ${value}`,
231
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
170
232
  };
171
233
  }
172
234
  } else if (field.type === 'number') {
@@ -176,22 +238,43 @@ export function validateEntityField({
176
238
  fieldPath,
177
239
  message: `Expected number ${
178
240
  field.nullable ? 'or null ' : ''
179
- }for field ${formatField(fieldPath)}, got ${value}`,
241
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
180
242
  };
181
243
  }
182
244
  } else if (field.type === 'file') {
183
- if (!isFile(value) && !isFileData(value)) {
184
- return {
185
- type: 'invalid-type',
186
- fieldPath,
187
- message: `Expected file ${
188
- field.nullable ? 'or null ' : ''
189
- }for field ${formatField(fieldPath)}, got ${value}`,
190
- };
245
+ if (actuallyExpectRefs) {
246
+ if (!isFileRef(value)) {
247
+ return {
248
+ type: 'invalid-type',
249
+ fieldPath,
250
+ message: `Expected file ref for field ${formatField(
251
+ fieldPath,
252
+ )}, got ${safeStringify(value)}`,
253
+ };
254
+ }
255
+ } else {
256
+ if (!isFile(value) && !isFileData(value)) {
257
+ return {
258
+ type: 'invalid-type',
259
+ fieldPath,
260
+ message: `Expected file ${
261
+ field.nullable ? 'or null ' : ''
262
+ }for field ${formatField(fieldPath)}, got ${safeStringify(value)}`,
263
+ };
264
+ }
191
265
  }
192
266
  }
193
267
  }
194
268
 
269
+ function safeStringify(value: any) {
270
+ try {
271
+ return JSON.stringify(value);
272
+ } catch (err) {
273
+ // for example, recursive objects can't be stringified
274
+ return String(value);
275
+ }
276
+ }
277
+
195
278
  function formatField(fieldPath: (string | number)[]) {
196
279
  if (fieldPath.length === 0) return 'root';
197
280
  return fieldPath.join('.');
package/src/timestamp.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import cuid from 'cuid';
2
- import { v4 } from 'uuid';
3
2
 
4
3
  export interface TimestampProvider {
5
4
  now(version: number): string;
@@ -37,6 +36,10 @@ export class NaiveTimestampProvider implements TimestampProvider {
37
36
  getWallClockTime = (timestamp: string) => {
38
37
  return parseInt(timestamp.slice(VERSION_BLOCK_LENGTH).split('-')[0], 10);
39
38
  };
39
+
40
+ reset() {
41
+ this.counter = 0;
42
+ }
40
43
  }
41
44
 
42
45
  export class HybridLogicalClockTimestampProvider implements TimestampProvider {
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assignOid, createOid, createRef } from './oids.js';
3
+ import { PatchCreator } from './patch.js';
4
+ import { getUndoOperations } from './undo.js';
5
+
6
+ function createClock(start: number = 0) {
7
+ let time = start;
8
+ function clock() {
9
+ return `${time++}`;
10
+ }
11
+ clock.reset = () => {
12
+ time = start;
13
+ };
14
+ return clock;
15
+ }
16
+
17
+ describe('undo', () => {
18
+ it('correctly removes added list items', () => {
19
+ const clock = createClock();
20
+ const patchCreator = new PatchCreator(clock, createClock());
21
+ const initial = [1, 2];
22
+ const oid = createOid('test', 'foo');
23
+ const operations = patchCreator.createListSet(oid, 2, 3);
24
+ clock.reset();
25
+ const undos = getUndoOperations(oid, initial, operations, clock);
26
+ clock.reset();
27
+ expect(undos).toEqual(patchCreator.createListDelete(oid, 2, 1));
28
+ });
29
+ it('correctly restores removed items by ref', () => {
30
+ const clock = createClock();
31
+ const patchCreator = new PatchCreator(clock, createClock());
32
+ const initial = assignOid(
33
+ [createRef('test/a:1'), createRef('test/a:2'), createRef('test/a:3')],
34
+ 'test/a',
35
+ );
36
+ const operations = patchCreator.createListRemove(
37
+ 'test/a',
38
+ initial[1],
39
+ 'last',
40
+ );
41
+ clock.reset();
42
+ const undos = getUndoOperations('test/a', initial, operations, clock);
43
+ clock.reset();
44
+ expect(undos).toEqual(
45
+ patchCreator.createListInsert('test/a', 1, initial[1]),
46
+ );
47
+ });
48
+ it('undoes list insert with 1 item', () => {
49
+ const clock = createClock();
50
+ const patchCreator = new PatchCreator(clock, createClock());
51
+ const initial = [1, 2];
52
+ const oid = createOid('test', 'foo');
53
+ const operations = patchCreator.createListInsert(oid, 1, 3);
54
+ clock.reset();
55
+ const undos = getUndoOperations(oid, initial, operations, clock);
56
+ clock.reset();
57
+ expect(undos).toEqual(patchCreator.createListDelete(oid, 1, 1));
58
+ });
59
+ it('undoes list insert with multiple items', () => {
60
+ const clock = createClock();
61
+ const patchCreator = new PatchCreator(clock, createClock());
62
+ const initial = [1, 2];
63
+ const oid = createOid('test', 'foo');
64
+ const operations = patchCreator.createListInsertMany(oid, 1, [3, 4]);
65
+ clock.reset();
66
+ const undos = getUndoOperations(oid, initial, operations, clock);
67
+ clock.reset();
68
+ expect(undos).toEqual(patchCreator.createListDelete(oid, 1, 2));
69
+ });
70
+ it('undoes initialize with delete', () => {
71
+ const clock = createClock();
72
+ const patchCreator = new PatchCreator(clock, createClock());
73
+ const initial = { foo: 'bar' };
74
+ const oid = createOid('test', 'foo');
75
+ const operations = patchCreator.createInitialize(initial, oid);
76
+ clock.reset();
77
+ const undos = getUndoOperations(oid, initial, operations, clock);
78
+ clock.reset();
79
+ expect(undos).toEqual(patchCreator.createDelete(oid));
80
+ });
81
+ it('undoes an object move by ref', () => {
82
+ const clock = createClock();
83
+ const patchCreator = new PatchCreator(clock, createClock());
84
+ const initial = assignOid(
85
+ [createRef('test/a:1'), createRef('test/a:2'), createRef('test/a:3')],
86
+ 'test/a',
87
+ );
88
+ const operations = patchCreator.createListMoveByRef(
89
+ 'test/a',
90
+ initial[1],
91
+ 0,
92
+ );
93
+ clock.reset();
94
+ const undos = getUndoOperations('test/a', initial, operations, clock);
95
+ clock.reset();
96
+ expect(undos).toEqual(
97
+ patchCreator.createListMoveByRef('test/a', initial[1], 1),
98
+ );
99
+ });
100
+ });