@verdant-web/common 2.8.0 → 2.9.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/esm/authz.js.map +1 -1
- package/dist/esm/baseline.d.ts +2 -1
- package/dist/esm/diffing.d.ts +3 -2
- package/dist/esm/diffing.js +79 -19
- package/dist/esm/diffing.js.map +1 -1
- package/dist/esm/diffing.test.js +967 -183
- package/dist/esm/diffing.test.js.map +1 -1
- package/dist/esm/oids.d.ts +5 -0
- package/dist/esm/oids.js +23 -1
- package/dist/esm/oids.js.map +1 -1
- package/dist/esm/operation.d.ts +5 -4
- package/dist/esm/operation.js +4 -4
- package/dist/esm/operation.js.map +1 -1
- package/dist/esm/patch.d.ts +17 -13
- package/dist/esm/patch.js +42 -6
- package/dist/esm/patch.js.map +1 -1
- package/dist/esm/schema/fields.d.ts +1 -0
- package/dist/esm/schema/fields.js +5 -0
- package/dist/esm/schema/fields.js.map +1 -1
- package/dist/esm/schema/validation.d.ts +8 -1
- package/dist/esm/schema/validation.js +133 -63
- package/dist/esm/schema/validation.js.map +1 -1
- package/dist/esm/schema/validation.test.d.ts +1 -0
- package/dist/esm/schema/validation.test.js +60 -0
- package/dist/esm/schema/validation.test.js.map +1 -0
- package/dist/esm/timestamp.d.ts +8 -0
- package/dist/esm/timestamp.js +15 -0
- package/dist/esm/timestamp.js.map +1 -1
- package/dist/esm/undo.d.ts +2 -1
- package/dist/esm/undo.js +52 -158
- package/dist/esm/undo.js.map +1 -1
- package/dist/esm/undo.test.js +68 -7
- package/dist/esm/undo.test.js.map +1 -1
- package/package.json +1 -1
- package/src/authz.ts +2 -0
- package/src/baseline.ts +3 -1
- package/src/diffing.test.ts +1089 -225
- package/src/diffing.ts +99 -22
- package/src/oids.ts +23 -1
- package/src/operation.ts +9 -8
- package/src/patch.ts +92 -17
- package/src/schema/fields.ts +8 -0
- package/src/schema/validation.test.ts +66 -0
- package/src/schema/validation.ts +156 -73
- package/src/timestamp.ts +14 -0
- package/src/undo.test.ts +87 -8
- package/src/undo.ts +83 -157
package/src/schema/validation.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
if (!
|
|
134
|
+
} else if (field.type === 'array') {
|
|
135
|
+
if (actuallyExpectRefs) {
|
|
136
|
+
if (!isObjectRef(value)) {
|
|
97
137
|
return {
|
|
98
|
-
type: 'invalid-
|
|
99
|
-
fieldPath
|
|
100
|
-
message: `
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
field
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
message: `Expected map for field ${formatField(
|
|
171
|
+
if (actuallyExpectRefs) {
|
|
172
|
+
if (!isObjectRef(value)) {
|
|
173
|
+
return {
|
|
174
|
+
type: 'invalid-type',
|
|
131
175
|
fieldPath,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
@@ -42,6 +42,20 @@ export class NaiveTimestampProvider implements TimestampProvider {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// good for tests that require determinism.
|
|
46
|
+
export class FixedTimestampProvider implements TimestampProvider {
|
|
47
|
+
time = new Date(2025, 2, 19).getTime().toString();
|
|
48
|
+
counter = 0;
|
|
49
|
+
now = (version: number | string) => {
|
|
50
|
+
return encodeVersion(version) + this.time + '-' + this.counter++;
|
|
51
|
+
};
|
|
52
|
+
update = () => {};
|
|
53
|
+
zero = (version: number | string) => {
|
|
54
|
+
return encodeVersion(version) + '0' + '-' + this.counter++;
|
|
55
|
+
};
|
|
56
|
+
getWallClockTime = () => 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
export class HybridLogicalClockTimestampProvider implements TimestampProvider {
|
|
46
60
|
private latest: HLCTimestamp = {
|
|
47
61
|
time: Date.now(),
|
package/src/undo.test.ts
CHANGED
|
@@ -1,21 +1,100 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { createOid } from './oids.js';
|
|
2
|
+
import { assignOid, createOid, createRef } from './oids.js';
|
|
3
3
|
import { PatchCreator } from './patch.js';
|
|
4
4
|
import { getUndoOperations } from './undo.js';
|
|
5
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
|
+
|
|
6
17
|
describe('undo', () => {
|
|
7
18
|
it('correctly removes added list items', () => {
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
let subId = 0;
|
|
11
|
-
const patchCreator = new PatchCreator(clock, () => `${subId++}`);
|
|
19
|
+
const clock = createClock();
|
|
20
|
+
const patchCreator = new PatchCreator(clock, createClock());
|
|
12
21
|
const initial = [1, 2];
|
|
13
22
|
const oid = createOid('test', 'foo');
|
|
14
23
|
const operations = patchCreator.createListSet(oid, 2, 3);
|
|
15
|
-
|
|
24
|
+
clock.reset();
|
|
16
25
|
const undos = getUndoOperations(oid, initial, operations, clock);
|
|
17
|
-
|
|
18
|
-
time = 0;
|
|
26
|
+
clock.reset();
|
|
19
27
|
expect(undos).toEqual(patchCreator.createListDelete(oid, 2, 1));
|
|
20
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
|
+
});
|
|
21
100
|
});
|