@voidhash/mimic 0.0.1-alpha.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/README.md +17 -0
- package/package.json +33 -0
- package/src/Document.ts +256 -0
- package/src/FractionalIndex.ts +1249 -0
- package/src/Operation.ts +59 -0
- package/src/OperationDefinition.ts +23 -0
- package/src/OperationPath.ts +197 -0
- package/src/Presence.ts +142 -0
- package/src/Primitive.ts +32 -0
- package/src/Proxy.ts +8 -0
- package/src/ProxyEnvironment.ts +52 -0
- package/src/Transaction.ts +72 -0
- package/src/Transform.ts +13 -0
- package/src/client/ClientDocument.ts +1163 -0
- package/src/client/Rebase.ts +309 -0
- package/src/client/StateMonitor.ts +307 -0
- package/src/client/Transport.ts +318 -0
- package/src/client/WebSocketTransport.ts +572 -0
- package/src/client/errors.ts +145 -0
- package/src/client/index.ts +61 -0
- package/src/index.ts +12 -0
- package/src/primitives/Array.ts +457 -0
- package/src/primitives/Boolean.ts +128 -0
- package/src/primitives/Lazy.ts +89 -0
- package/src/primitives/Literal.ts +128 -0
- package/src/primitives/Number.ts +169 -0
- package/src/primitives/String.ts +189 -0
- package/src/primitives/Struct.ts +348 -0
- package/src/primitives/Tree.ts +1120 -0
- package/src/primitives/TreeNode.ts +113 -0
- package/src/primitives/Union.ts +329 -0
- package/src/primitives/shared.ts +122 -0
- package/src/server/ServerDocument.ts +267 -0
- package/src/server/errors.ts +90 -0
- package/src/server/index.ts +40 -0
- package/tests/Document.test.ts +556 -0
- package/tests/FractionalIndex.test.ts +377 -0
- package/tests/OperationPath.test.ts +151 -0
- package/tests/Presence.test.ts +321 -0
- package/tests/Primitive.test.ts +381 -0
- package/tests/client/ClientDocument.test.ts +1398 -0
- package/tests/client/WebSocketTransport.test.ts +992 -0
- package/tests/primitives/Array.test.ts +418 -0
- package/tests/primitives/Boolean.test.ts +126 -0
- package/tests/primitives/Lazy.test.ts +143 -0
- package/tests/primitives/Literal.test.ts +122 -0
- package/tests/primitives/Number.test.ts +133 -0
- package/tests/primitives/String.test.ts +128 -0
- package/tests/primitives/Struct.test.ts +311 -0
- package/tests/primitives/Tree.test.ts +467 -0
- package/tests/primitives/TreeNode.test.ts +50 -0
- package/tests/primitives/Union.test.ts +210 -0
- package/tests/server/ServerDocument.test.ts +528 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as Presence from "../src/Presence";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Test Schemas
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
const CursorSchema = Schema.Struct({
|
|
10
|
+
x: Schema.Number,
|
|
11
|
+
y: Schema.Number,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const UserPresenceSchema = Schema.Struct({
|
|
15
|
+
name: Schema.String,
|
|
16
|
+
status: Schema.Literal("online", "away", "busy"),
|
|
17
|
+
cursor: Schema.optional(CursorSchema),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Presence Tests
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
describe("Presence", () => {
|
|
25
|
+
describe("make", () => {
|
|
26
|
+
it("should create a Presence instance with complex schema", () => {
|
|
27
|
+
const presence = Presence.make({
|
|
28
|
+
schema: UserPresenceSchema,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(presence._tag).toBe("Presence");
|
|
32
|
+
expect(presence.schema).toBe(UserPresenceSchema);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("validate", () => {
|
|
37
|
+
it("should return validated data for valid input", () => {
|
|
38
|
+
const presence = Presence.make({
|
|
39
|
+
schema: CursorSchema,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result = Presence.validate(presence, { x: 10, y: 20 });
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({ x: 10, y: 20 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should throw ParseError for invalid input", () => {
|
|
48
|
+
const presence = Presence.make({
|
|
49
|
+
schema: CursorSchema,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(() => Presence.validate(presence, { x: "invalid", y: 20 })).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should throw for missing required fields", () => {
|
|
56
|
+
const presence = Presence.make({
|
|
57
|
+
schema: CursorSchema,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(() => Presence.validate(presence, { x: 10 })).toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should throw for null input", () => {
|
|
64
|
+
const presence = Presence.make({
|
|
65
|
+
schema: CursorSchema,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(() => Presence.validate(presence, null)).toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should throw for undefined input", () => {
|
|
72
|
+
const presence = Presence.make({
|
|
73
|
+
schema: CursorSchema,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(() => Presence.validate(presence, undefined)).toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should validate complex schema with optional fields", () => {
|
|
80
|
+
const presence = Presence.make({
|
|
81
|
+
schema: UserPresenceSchema,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Without optional cursor
|
|
85
|
+
const result1 = Presence.validate(presence, {
|
|
86
|
+
name: "Alice",
|
|
87
|
+
status: "online",
|
|
88
|
+
});
|
|
89
|
+
expect(result1).toEqual({ name: "Alice", status: "online" });
|
|
90
|
+
|
|
91
|
+
// With optional cursor
|
|
92
|
+
const result2 = Presence.validate(presence, {
|
|
93
|
+
name: "Bob",
|
|
94
|
+
status: "away",
|
|
95
|
+
cursor: { x: 100, y: 200 },
|
|
96
|
+
});
|
|
97
|
+
expect(result2).toEqual({
|
|
98
|
+
name: "Bob",
|
|
99
|
+
status: "away",
|
|
100
|
+
cursor: { x: 100, y: 200 },
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should throw for invalid literal value", () => {
|
|
105
|
+
const presence = Presence.make({
|
|
106
|
+
schema: UserPresenceSchema,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(() =>
|
|
110
|
+
Presence.validate(presence, {
|
|
111
|
+
name: "Alice",
|
|
112
|
+
status: "invalid-status",
|
|
113
|
+
})
|
|
114
|
+
).toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("validateSafe", () => {
|
|
119
|
+
it("should return validated data for valid input", () => {
|
|
120
|
+
const presence = Presence.make({
|
|
121
|
+
schema: CursorSchema,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const result = Presence.validateSafe(presence, { x: 10, y: 20 });
|
|
125
|
+
|
|
126
|
+
expect(result).toEqual({ x: 10, y: 20 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should return undefined for invalid input", () => {
|
|
130
|
+
const presence = Presence.make({
|
|
131
|
+
schema: CursorSchema,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = Presence.validateSafe(presence, { x: "invalid", y: 20 });
|
|
135
|
+
|
|
136
|
+
expect(result).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should return undefined for missing required fields", () => {
|
|
140
|
+
const presence = Presence.make({
|
|
141
|
+
schema: CursorSchema,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = Presence.validateSafe(presence, { x: 10 });
|
|
145
|
+
|
|
146
|
+
expect(result).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should return undefined for null input", () => {
|
|
150
|
+
const presence = Presence.make({
|
|
151
|
+
schema: CursorSchema,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = Presence.validateSafe(presence, null);
|
|
155
|
+
|
|
156
|
+
expect(result).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should return undefined for undefined input", () => {
|
|
160
|
+
const presence = Presence.make({
|
|
161
|
+
schema: CursorSchema,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = Presence.validateSafe(presence, undefined);
|
|
165
|
+
|
|
166
|
+
expect(result).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should validate complex schema with optional fields", () => {
|
|
170
|
+
const presence = Presence.make({
|
|
171
|
+
schema: UserPresenceSchema,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = Presence.validateSafe(presence, {
|
|
175
|
+
name: "Alice",
|
|
176
|
+
status: "busy",
|
|
177
|
+
cursor: { x: 50, y: 75 },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
name: "Alice",
|
|
182
|
+
status: "busy",
|
|
183
|
+
cursor: { x: 50, y: 75 },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("isValid", () => {
|
|
189
|
+
it("should return true for valid input", () => {
|
|
190
|
+
const presence = Presence.make({
|
|
191
|
+
schema: CursorSchema,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(Presence.isValid(presence, { x: 10, y: 20 })).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should return false for invalid input", () => {
|
|
198
|
+
const presence = Presence.make({
|
|
199
|
+
schema: CursorSchema,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(Presence.isValid(presence, { x: "invalid", y: 20 })).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should return false for missing required fields", () => {
|
|
206
|
+
const presence = Presence.make({
|
|
207
|
+
schema: CursorSchema,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(Presence.isValid(presence, { x: 10 })).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should return false for null input", () => {
|
|
214
|
+
const presence = Presence.make({
|
|
215
|
+
schema: CursorSchema,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(Presence.isValid(presence, null)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should return false for undefined input", () => {
|
|
222
|
+
const presence = Presence.make({
|
|
223
|
+
schema: CursorSchema,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(Presence.isValid(presence, undefined)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should act as type guard", () => {
|
|
230
|
+
const presence = Presence.make({
|
|
231
|
+
schema: CursorSchema,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const data: unknown = { x: 10, y: 20 };
|
|
235
|
+
|
|
236
|
+
if (Presence.isValid(presence, data)) {
|
|
237
|
+
// TypeScript should now know data is { x: number; y: number }
|
|
238
|
+
expect(data.x).toBe(10);
|
|
239
|
+
expect(data.y).toBe(20);
|
|
240
|
+
} else {
|
|
241
|
+
// Should not reach here
|
|
242
|
+
expect.fail("isValid should return true for valid data");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should validate complex schema correctly", () => {
|
|
247
|
+
const presence = Presence.make({
|
|
248
|
+
schema: UserPresenceSchema,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(
|
|
252
|
+
Presence.isValid(presence, {
|
|
253
|
+
name: "Alice",
|
|
254
|
+
status: "online",
|
|
255
|
+
})
|
|
256
|
+
).toBe(true);
|
|
257
|
+
|
|
258
|
+
expect(
|
|
259
|
+
Presence.isValid(presence, {
|
|
260
|
+
name: "Bob",
|
|
261
|
+
status: "invalid",
|
|
262
|
+
})
|
|
263
|
+
).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("PresenceEntry", () => {
|
|
268
|
+
it("should have correct structure with data only", () => {
|
|
269
|
+
const entry: Presence.PresenceEntry<{ x: number; y: number }> = {
|
|
270
|
+
data: { x: 10, y: 20 },
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
expect(entry.data).toEqual({ x: 10, y: 20 });
|
|
274
|
+
expect(entry.userId).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("should have correct structure with data and userId", () => {
|
|
278
|
+
const entry: Presence.PresenceEntry<{ x: number; y: number }> = {
|
|
279
|
+
data: { x: 10, y: 20 },
|
|
280
|
+
userId: "user-123",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
expect(entry.data).toEqual({ x: 10, y: 20 });
|
|
284
|
+
expect(entry.userId).toBe("user-123");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("type inference", () => {
|
|
289
|
+
it("should correctly infer data type from Presence", () => {
|
|
290
|
+
const presence = Presence.make({
|
|
291
|
+
schema: CursorSchema,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
type InferredType = Presence.Infer<typeof presence>;
|
|
295
|
+
|
|
296
|
+
// This is a compile-time check - if it compiles, the type is correct
|
|
297
|
+
const data: InferredType = { x: 10, y: 20 };
|
|
298
|
+
expect(data.x).toBe(10);
|
|
299
|
+
expect(data.y).toBe(20);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("should correctly infer complex data type", () => {
|
|
303
|
+
const presence = Presence.make({
|
|
304
|
+
schema: UserPresenceSchema,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
type InferredType = Presence.Infer<typeof presence>;
|
|
308
|
+
|
|
309
|
+
const data: InferredType = {
|
|
310
|
+
name: "Alice",
|
|
311
|
+
status: "online",
|
|
312
|
+
cursor: { x: 10, y: 20 },
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
expect(data.name).toBe("Alice");
|
|
316
|
+
expect(data.status).toBe("online");
|
|
317
|
+
expect(data.cursor).toEqual({ x: 10, y: 20 });
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Primitive from "../src/Primitive";
|
|
3
|
+
import * as ProxyEnvironment from "../src/ProxyEnvironment";
|
|
4
|
+
import * as OperationPath from "../src/OperationPath";
|
|
5
|
+
import * as Operation from "../src/Operation";
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Integration Tests
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
describe("Integration - Complex Nested Structures", () => {
|
|
12
|
+
it("handles deeply nested structs with arrays", () => {
|
|
13
|
+
const schema = Primitive.Struct({
|
|
14
|
+
users: Primitive.Array(
|
|
15
|
+
Primitive.Struct({
|
|
16
|
+
name: Primitive.String(),
|
|
17
|
+
age: Primitive.Number(),
|
|
18
|
+
tags: Primitive.Array(Primitive.String()),
|
|
19
|
+
})
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
24
|
+
const env = ProxyEnvironment.make((op) => {
|
|
25
|
+
operations.push(op);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
29
|
+
|
|
30
|
+
// Create a user
|
|
31
|
+
proxy.users.push({
|
|
32
|
+
name: "Alice",
|
|
33
|
+
age: 30,
|
|
34
|
+
tags: [],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(operations).toHaveLength(1);
|
|
38
|
+
expect(operations[0]!.kind).toBe("array.insert");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles nested structs with unions", () => {
|
|
42
|
+
const schema = Primitive.Struct({
|
|
43
|
+
content: Primitive.Union({
|
|
44
|
+
variants: {
|
|
45
|
+
text: Primitive.Struct({
|
|
46
|
+
type: Primitive.Literal("text"),
|
|
47
|
+
value: Primitive.String(),
|
|
48
|
+
}),
|
|
49
|
+
image: Primitive.Struct({
|
|
50
|
+
type: Primitive.Literal("image"),
|
|
51
|
+
url: Primitive.String(),
|
|
52
|
+
alt: Primitive.String(),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
59
|
+
const env = ProxyEnvironment.make((op) => {
|
|
60
|
+
operations.push(op);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
64
|
+
|
|
65
|
+
proxy.content.set({
|
|
66
|
+
type: "text",
|
|
67
|
+
value: "Hello",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(operations).toHaveLength(1);
|
|
71
|
+
expect(operations[0]!.kind).toBe("union.set");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("transformOperation", () => {
|
|
76
|
+
const makeOp = (kind: string, path: string, payload: any) => ({
|
|
77
|
+
kind,
|
|
78
|
+
path: OperationPath.make(path),
|
|
79
|
+
payload,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("cross-primitive transformations", () => {
|
|
83
|
+
it("transforms operations on different struct fields independently", () => {
|
|
84
|
+
const schema = Primitive.Struct({
|
|
85
|
+
name: Primitive.String(),
|
|
86
|
+
age: Primitive.Number(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const clientOp = makeOp("string.set", "name", "Alice");
|
|
90
|
+
const serverOp = makeOp("number.set", "age", 30);
|
|
91
|
+
|
|
92
|
+
const result = schema._internal.transformOperation(clientOp, serverOp);
|
|
93
|
+
|
|
94
|
+
expect(result.type).toBe("transformed");
|
|
95
|
+
if (result.type === "transformed") {
|
|
96
|
+
expect(result.operation.path.toTokens()).toEqual(["name"]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("transforms operations on different array elements independently", () => {
|
|
101
|
+
const schema = Primitive.Array(Primitive.String());
|
|
102
|
+
|
|
103
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
104
|
+
const env = ProxyEnvironment.make((op) => {
|
|
105
|
+
operations.push(op);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make("items"));
|
|
109
|
+
|
|
110
|
+
// Insert two items
|
|
111
|
+
proxy.push("first");
|
|
112
|
+
proxy.push("second");
|
|
113
|
+
|
|
114
|
+
const firstId = operations[0]!.payload.id;
|
|
115
|
+
const secondId = operations[1]!.payload.id;
|
|
116
|
+
|
|
117
|
+
const clientOp = makeOp("string.set", `items/${firstId}`, "updated first");
|
|
118
|
+
const serverOp = makeOp("string.set", `items/${secondId}`, "updated second");
|
|
119
|
+
|
|
120
|
+
const result = schema._internal.transformOperation(clientOp, serverOp);
|
|
121
|
+
|
|
122
|
+
expect(result.type).toBe("transformed");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Integration - Tree with Complex Structures
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
describe("Integration - Tree with Complex Structures", () => {
|
|
132
|
+
const FileNode = Primitive.TreeNode("file", {
|
|
133
|
+
data: Primitive.Struct({
|
|
134
|
+
name: Primitive.String(),
|
|
135
|
+
size: Primitive.Number(),
|
|
136
|
+
metadata: Primitive.Struct({
|
|
137
|
+
author: Primitive.String(),
|
|
138
|
+
created: Primitive.Number(),
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
children: [] as const,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const FolderNode = Primitive.TreeNode("folder", {
|
|
145
|
+
data: Primitive.Struct({ name: Primitive.String() }),
|
|
146
|
+
children: (): readonly Primitive.AnyTreeNodePrimitive[] => [FolderNode, FileNode],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const fileSystemTree = Primitive.Tree({
|
|
150
|
+
root: FolderNode,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("handles complex tree structures with nested data", () => {
|
|
154
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
155
|
+
let state: Primitive.TreeState<typeof FolderNode> = [];
|
|
156
|
+
|
|
157
|
+
const env = ProxyEnvironment.make({
|
|
158
|
+
onOperation: (op) => {
|
|
159
|
+
operations.push(op);
|
|
160
|
+
state = fileSystemTree._internal.applyOperation(state, op);
|
|
161
|
+
},
|
|
162
|
+
getState: () => state,
|
|
163
|
+
generateId: () => crypto.randomUUID(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
|
|
167
|
+
|
|
168
|
+
const rootId = proxy.insertFirst(null, FolderNode, { name: "root" });
|
|
169
|
+
const fileId = proxy.insertFirst(rootId, FileNode, {
|
|
170
|
+
name: "test.txt",
|
|
171
|
+
size: 1024,
|
|
172
|
+
metadata: {
|
|
173
|
+
author: "Alice",
|
|
174
|
+
created: Date.now(),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(operations.length).toBeGreaterThan(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("toSnapshot", () => {
|
|
183
|
+
// Helper to extract state at a given path
|
|
184
|
+
const getStateAtPath = (state: unknown, path: OperationPath.OperationPath): unknown => {
|
|
185
|
+
const tokens = path.toTokens().filter((t: string) => t !== "");
|
|
186
|
+
let current = state;
|
|
187
|
+
for (const token of tokens) {
|
|
188
|
+
if (current === undefined || current === null) return undefined;
|
|
189
|
+
if (typeof current === "object") {
|
|
190
|
+
current = (current as Record<string, unknown>)[token];
|
|
191
|
+
} else {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return current;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
describe("nested structures", () => {
|
|
199
|
+
it("creates snapshot for struct with nested arrays", () => {
|
|
200
|
+
const schema = Primitive.Struct({
|
|
201
|
+
items: Primitive.Array(
|
|
202
|
+
Primitive.Struct({
|
|
203
|
+
name: Primitive.String(),
|
|
204
|
+
count: Primitive.Number(),
|
|
205
|
+
})
|
|
206
|
+
),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
type Snapshot = Primitive.InferSnapshot<typeof schema>;
|
|
210
|
+
|
|
211
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
212
|
+
let state: any = undefined;
|
|
213
|
+
|
|
214
|
+
const env = ProxyEnvironment.make({
|
|
215
|
+
onOperation: (op) => {
|
|
216
|
+
operations.push(op);
|
|
217
|
+
state = schema._internal.applyOperation(state, op);
|
|
218
|
+
},
|
|
219
|
+
getState: (path) => getStateAtPath(state, path),
|
|
220
|
+
generateId: () => crypto.randomUUID(),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
224
|
+
|
|
225
|
+
proxy.items.push({ name: "Item 1", count: 10 });
|
|
226
|
+
proxy.items.push({ name: "Item 2", count: 20 });
|
|
227
|
+
|
|
228
|
+
const snapshot = proxy.toSnapshot();
|
|
229
|
+
|
|
230
|
+
expect(snapshot).toBeDefined();
|
|
231
|
+
if (snapshot) {
|
|
232
|
+
expect(snapshot.items).toHaveLength(2);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("creates snapshot for union with nested structs", () => {
|
|
237
|
+
const schema = Primitive.Union({
|
|
238
|
+
variants: {
|
|
239
|
+
text: Primitive.Struct({
|
|
240
|
+
type: Primitive.Literal("text"),
|
|
241
|
+
content: Primitive.String(),
|
|
242
|
+
}),
|
|
243
|
+
list: Primitive.Struct({
|
|
244
|
+
type: Primitive.Literal("list"),
|
|
245
|
+
items: Primitive.Array(Primitive.String()),
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
type Snapshot = Primitive.InferSnapshot<typeof schema>;
|
|
251
|
+
|
|
252
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
253
|
+
let state: any = undefined;
|
|
254
|
+
|
|
255
|
+
const env = ProxyEnvironment.make({
|
|
256
|
+
onOperation: (op) => {
|
|
257
|
+
operations.push(op);
|
|
258
|
+
state = schema._internal.applyOperation(state, op);
|
|
259
|
+
},
|
|
260
|
+
getState: (path) => getStateAtPath(state, path),
|
|
261
|
+
generateId: () => crypto.randomUUID(),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
265
|
+
|
|
266
|
+
// Set union value with items already populated (simpler than pushing one by one)
|
|
267
|
+
const itemsWithEntries = [
|
|
268
|
+
{ id: "id-1", pos: "a0", value: "item1" },
|
|
269
|
+
{ id: "id-2", pos: "a1", value: "item2" },
|
|
270
|
+
];
|
|
271
|
+
proxy.set({
|
|
272
|
+
type: "list",
|
|
273
|
+
items: itemsWithEntries,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Verify state was updated
|
|
277
|
+
expect(state).toBeDefined();
|
|
278
|
+
if (state && "items" in state) {
|
|
279
|
+
expect((state.items as any[]).length).toBe(2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const snapshot = proxy.toSnapshot();
|
|
283
|
+
|
|
284
|
+
expect(snapshot).toBeDefined();
|
|
285
|
+
if (snapshot && "items" in snapshot) {
|
|
286
|
+
// The snapshot should have items array with 2 entries
|
|
287
|
+
const items = snapshot.items as Array<{ id: string; value: string }>;
|
|
288
|
+
expect(items).toHaveLength(2);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("Validation", () => {
|
|
295
|
+
describe("cross-field validation", () => {
|
|
296
|
+
it("validates struct fields together", () => {
|
|
297
|
+
const schema = Primitive.Struct({
|
|
298
|
+
start: Primitive.Number(),
|
|
299
|
+
end: Primitive.Number(),
|
|
300
|
+
}).refine(
|
|
301
|
+
(value) => value.end >= value.start,
|
|
302
|
+
"End must be >= start"
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
306
|
+
let state: any = undefined;
|
|
307
|
+
|
|
308
|
+
const env = ProxyEnvironment.make({
|
|
309
|
+
onOperation: (op) => {
|
|
310
|
+
operations.push(op);
|
|
311
|
+
state = schema._internal.applyOperation(state, op);
|
|
312
|
+
},
|
|
313
|
+
getState: () => state,
|
|
314
|
+
generateId: () => crypto.randomUUID(),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
318
|
+
|
|
319
|
+
expect(() => {
|
|
320
|
+
proxy.set({ start: 10, end: 5 });
|
|
321
|
+
}).toThrow(Primitive.ValidationError);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("array validation", () => {
|
|
326
|
+
it("validates array length constraints", () => {
|
|
327
|
+
const schema = Primitive.Array(Primitive.String())
|
|
328
|
+
.minLength(2)
|
|
329
|
+
.maxLength(5);
|
|
330
|
+
|
|
331
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
332
|
+
let state: any = [];
|
|
333
|
+
|
|
334
|
+
const env = ProxyEnvironment.make({
|
|
335
|
+
onOperation: (op) => {
|
|
336
|
+
operations.push(op);
|
|
337
|
+
state = schema._internal.applyOperation(state, op);
|
|
338
|
+
},
|
|
339
|
+
getState: () => state,
|
|
340
|
+
generateId: () => crypto.randomUUID(),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const proxy = schema._internal.createProxy(env, OperationPath.make(""));
|
|
344
|
+
|
|
345
|
+
// Push first item - this will fail validation because minLength is 2
|
|
346
|
+
expect(() => {
|
|
347
|
+
proxy.push("item1");
|
|
348
|
+
}).toThrow(Primitive.ValidationError);
|
|
349
|
+
|
|
350
|
+
// Reset state and push both items at once using set
|
|
351
|
+
state = [];
|
|
352
|
+
const twoItems = [
|
|
353
|
+
{ id: "id-1", pos: "a0", value: "item1" },
|
|
354
|
+
{ id: "id-2", pos: "a1", value: "item2" },
|
|
355
|
+
];
|
|
356
|
+
state = schema._internal.applyOperation(state, {
|
|
357
|
+
kind: "array.set",
|
|
358
|
+
path: OperationPath.make(""),
|
|
359
|
+
payload: twoItems,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Should pass validation with 2 items
|
|
363
|
+
expect(state.length).toBe(2);
|
|
364
|
+
|
|
365
|
+
// Try to set array with too many items
|
|
366
|
+
const tooManyItems = Array.from({ length: 10 }, (_, i) => ({
|
|
367
|
+
id: `id-${i}`,
|
|
368
|
+
pos: `pos-${i}`,
|
|
369
|
+
value: `item${i}`,
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
expect(() => {
|
|
373
|
+
schema._internal.applyOperation(state, {
|
|
374
|
+
kind: "array.set",
|
|
375
|
+
path: OperationPath.make(""),
|
|
376
|
+
payload: tooManyItems,
|
|
377
|
+
});
|
|
378
|
+
}).toThrow();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|