@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.
Files changed (57) hide show
  1. package/README.md +17 -0
  2. package/package.json +33 -0
  3. package/src/Document.ts +256 -0
  4. package/src/FractionalIndex.ts +1249 -0
  5. package/src/Operation.ts +59 -0
  6. package/src/OperationDefinition.ts +23 -0
  7. package/src/OperationPath.ts +197 -0
  8. package/src/Presence.ts +142 -0
  9. package/src/Primitive.ts +32 -0
  10. package/src/Proxy.ts +8 -0
  11. package/src/ProxyEnvironment.ts +52 -0
  12. package/src/Transaction.ts +72 -0
  13. package/src/Transform.ts +13 -0
  14. package/src/client/ClientDocument.ts +1163 -0
  15. package/src/client/Rebase.ts +309 -0
  16. package/src/client/StateMonitor.ts +307 -0
  17. package/src/client/Transport.ts +318 -0
  18. package/src/client/WebSocketTransport.ts +572 -0
  19. package/src/client/errors.ts +145 -0
  20. package/src/client/index.ts +61 -0
  21. package/src/index.ts +12 -0
  22. package/src/primitives/Array.ts +457 -0
  23. package/src/primitives/Boolean.ts +128 -0
  24. package/src/primitives/Lazy.ts +89 -0
  25. package/src/primitives/Literal.ts +128 -0
  26. package/src/primitives/Number.ts +169 -0
  27. package/src/primitives/String.ts +189 -0
  28. package/src/primitives/Struct.ts +348 -0
  29. package/src/primitives/Tree.ts +1120 -0
  30. package/src/primitives/TreeNode.ts +113 -0
  31. package/src/primitives/Union.ts +329 -0
  32. package/src/primitives/shared.ts +122 -0
  33. package/src/server/ServerDocument.ts +267 -0
  34. package/src/server/errors.ts +90 -0
  35. package/src/server/index.ts +40 -0
  36. package/tests/Document.test.ts +556 -0
  37. package/tests/FractionalIndex.test.ts +377 -0
  38. package/tests/OperationPath.test.ts +151 -0
  39. package/tests/Presence.test.ts +321 -0
  40. package/tests/Primitive.test.ts +381 -0
  41. package/tests/client/ClientDocument.test.ts +1398 -0
  42. package/tests/client/WebSocketTransport.test.ts +992 -0
  43. package/tests/primitives/Array.test.ts +418 -0
  44. package/tests/primitives/Boolean.test.ts +126 -0
  45. package/tests/primitives/Lazy.test.ts +143 -0
  46. package/tests/primitives/Literal.test.ts +122 -0
  47. package/tests/primitives/Number.test.ts +133 -0
  48. package/tests/primitives/String.test.ts +128 -0
  49. package/tests/primitives/Struct.test.ts +311 -0
  50. package/tests/primitives/Tree.test.ts +467 -0
  51. package/tests/primitives/TreeNode.test.ts +50 -0
  52. package/tests/primitives/Union.test.ts +210 -0
  53. package/tests/server/ServerDocument.test.ts +528 -0
  54. package/tsconfig.build.json +24 -0
  55. package/tsconfig.json +8 -0
  56. package/tsdown.config.ts +18 -0
  57. package/vitest.mts +11 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @voidhash/mimic-client
3
+ *
4
+ * Optimistic client library for the Mimic sync engine.
5
+ * Provides optimistic updates with server synchronization.
6
+ *
7
+ * @since 0.0.1
8
+ */
9
+
10
+ // =============================================================================
11
+ // Presence (re-exported from core for convenience)
12
+ // =============================================================================
13
+
14
+ export * as Presence from "../Presence.js";
15
+
16
+ // =============================================================================
17
+ // Main Client Document
18
+ // =============================================================================
19
+
20
+ export * as ClientDocument from "./ClientDocument.js";
21
+
22
+ // =============================================================================
23
+ // Transport Interface
24
+ // =============================================================================
25
+
26
+ export * as Transport from "./Transport.js";
27
+
28
+ // =============================================================================
29
+ // WebSocket Transport
30
+ // =============================================================================
31
+
32
+ export * as WebSocketTransport from "./WebSocketTransport.js";
33
+
34
+ // =============================================================================
35
+ // Rebase Logic
36
+ // =============================================================================
37
+
38
+ export * as Rebase from "./Rebase.js";
39
+
40
+ // =============================================================================
41
+ // State Monitoring
42
+ // =============================================================================
43
+
44
+ export * as StateMonitor from "./StateMonitor.js";
45
+
46
+ // =============================================================================
47
+ // Errors
48
+ // =============================================================================
49
+
50
+ export {
51
+ MimicClientError,
52
+ TransactionRejectedError,
53
+ NotConnectedError,
54
+ ConnectionError,
55
+ StateDriftError,
56
+ TransactionTimeoutError,
57
+ RebaseError,
58
+ InvalidStateError,
59
+ WebSocketError,
60
+ AuthenticationError,
61
+ } from "./errors.js";
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @since 0.0.1
3
+ */
4
+
5
+ export * as Primitive from "./Primitive.js";
6
+ export * as Document from "./Document.js";
7
+ export * as Transaction from "./Transaction.js";
8
+ export * as Operation from "./Operation.js";
9
+ export * as OperationPath from "./OperationPath.js";
10
+ export * as ProxyEnvironment from "./ProxyEnvironment.js";
11
+ export * as Transform from "./Transform.js";
12
+ export * as Presence from "./Presence.js";
@@ -0,0 +1,457 @@
1
+ import { Effect, Schema } from "effect";
2
+ import * as OperationDefinition from "../OperationDefinition";
3
+ import * as Operation from "../Operation";
4
+ import * as OperationPath from "../OperationPath";
5
+ import * as ProxyEnvironment from "../ProxyEnvironment";
6
+ import * as Transform from "../Transform";
7
+ import * as FractionalIndex from "../FractionalIndex";
8
+ import type { Primitive, PrimitiveInternal, MaybeUndefined, AnyPrimitive, Validator, InferState, InferProxy, InferSnapshot } from "../Primitive";
9
+ import { ValidationError } from "../Primitive";
10
+ import { runValidators } from "./shared";
11
+
12
+
13
+ /**
14
+ * Entry in an ordered array with ID and fractional position
15
+ */
16
+ export interface ArrayEntry<T> {
17
+ readonly id: string; // Unique element identifier (UUID)
18
+ readonly pos: string; // Fractional index for ordering
19
+ readonly value: T; // The element value
20
+ }
21
+
22
+ /**
23
+ * Sort array entries by their fractional position
24
+ */
25
+ const sortByPos = <T,>(entries: readonly ArrayEntry<T>[]): ArrayEntry<T>[] =>
26
+ [...entries].sort((a, b) => a.pos < b.pos ? -1 : a.pos > b.pos ? 1 : 0);
27
+
28
+ /**
29
+ * Generate a fractional position between two positions
30
+ */
31
+ const generatePosBetween = (left: string | null, right: string | null): string => {
32
+ const charSet = FractionalIndex.base62CharSet();
33
+ return Effect.runSync(FractionalIndex.generateKeyBetween(left, right, charSet));
34
+ };
35
+
36
+ /**
37
+ * Entry in an array snapshot with ID and value snapshot
38
+ */
39
+ export interface ArrayEntrySnapshot<TElement extends AnyPrimitive> {
40
+ readonly id: string;
41
+ readonly value: InferSnapshot<TElement>;
42
+ }
43
+
44
+ /**
45
+ * Snapshot type for arrays - always an array (never undefined)
46
+ */
47
+ export type ArraySnapshot<TElement extends AnyPrimitive> = readonly ArrayEntrySnapshot<TElement>[];
48
+
49
+ export interface ArrayProxy<TElement extends AnyPrimitive> {
50
+ /** Gets the current array entries (sorted by position) */
51
+ get(): ArrayState<TElement>;
52
+ /** Replaces the entire array with new values (generates new IDs and positions) */
53
+ set(values: readonly InferState<TElement>[]): void;
54
+ /** Appends a value to the end of the array */
55
+ push(value: InferState<TElement>): void;
56
+ /** Inserts a value at the specified visual index */
57
+ insertAt(index: number, value: InferState<TElement>): void;
58
+ /** Removes the element with the specified ID */
59
+ remove(id: string): void;
60
+ /** Moves an element to a new visual index */
61
+ move(id: string, toIndex: number): void;
62
+ /** Returns a proxy for the element with the specified ID */
63
+ at(id: string): InferProxy<TElement>;
64
+ /** Finds an element by predicate and returns its proxy */
65
+ find(predicate: (value: InferState<TElement>, id: string) => boolean): InferProxy<TElement> | undefined;
66
+ /** Returns a readonly snapshot of the array for rendering (always returns an array, never undefined) */
67
+ toSnapshot(): ArraySnapshot<TElement>;
68
+ }
69
+
70
+ /** The state type for arrays - an array of entries */
71
+ export type ArrayState<TElement extends AnyPrimitive> = readonly ArrayEntry<InferState<TElement>>[];
72
+
73
+ interface ArrayPrimitiveSchema<TElement extends AnyPrimitive> {
74
+ readonly required: boolean;
75
+ readonly defaultValue: ArrayState<TElement> | undefined;
76
+ readonly element: TElement;
77
+ readonly validators: readonly Validator<ArrayState<TElement>>[];
78
+ }
79
+
80
+ export class ArrayPrimitive<TElement extends AnyPrimitive>
81
+ implements Primitive<ArrayState<TElement>, ArrayProxy<TElement>>
82
+ {
83
+ readonly _tag = "ArrayPrimitive" as const;
84
+ readonly _State!: ArrayState<TElement>;
85
+ readonly _Proxy!: ArrayProxy<TElement>;
86
+
87
+ private readonly _schema: ArrayPrimitiveSchema<TElement>;
88
+
89
+ private readonly _opDefinitions = {
90
+ set: OperationDefinition.make({
91
+ kind: "array.set" as const,
92
+ payload: Schema.Unknown,
93
+ target: Schema.Unknown,
94
+ apply: (payload) => payload,
95
+ }),
96
+ insert: OperationDefinition.make({
97
+ kind: "array.insert" as const,
98
+ payload: Schema.Unknown,
99
+ target: Schema.Unknown,
100
+ apply: (payload) => payload,
101
+ }),
102
+ remove: OperationDefinition.make({
103
+ kind: "array.remove" as const,
104
+ payload: Schema.Unknown,
105
+ target: Schema.Unknown,
106
+ apply: (payload) => payload,
107
+ }),
108
+ move: OperationDefinition.make({
109
+ kind: "array.move" as const,
110
+ payload: Schema.Unknown,
111
+ target: Schema.Unknown,
112
+ apply: (payload) => payload,
113
+ }),
114
+ };
115
+
116
+ constructor(schema: ArrayPrimitiveSchema<TElement>) {
117
+ this._schema = schema;
118
+ }
119
+
120
+ /** Mark this array as required */
121
+ required(): ArrayPrimitive<TElement> {
122
+ return new ArrayPrimitive({
123
+ ...this._schema,
124
+ required: true,
125
+ });
126
+ }
127
+
128
+ /** Set a default value for this array */
129
+ default(defaultValue: ArrayState<TElement>): ArrayPrimitive<TElement> {
130
+ return new ArrayPrimitive({
131
+ ...this._schema,
132
+ defaultValue,
133
+ });
134
+ }
135
+
136
+ /** Get the element primitive */
137
+ get element(): TElement {
138
+ return this._schema.element;
139
+ }
140
+
141
+ /** Add a custom validation rule */
142
+ refine(fn: (value: ArrayState<TElement>) => boolean, message: string): ArrayPrimitive<TElement> {
143
+ return new ArrayPrimitive({
144
+ ...this._schema,
145
+ validators: [...this._schema.validators, { validate: fn, message }],
146
+ });
147
+ }
148
+
149
+ /** Minimum array length */
150
+ minLength(length: number): ArrayPrimitive<TElement> {
151
+ return this.refine(
152
+ (v) => v.length >= length,
153
+ `Array must have at least ${length} elements`
154
+ );
155
+ }
156
+
157
+ /** Maximum array length */
158
+ maxLength(length: number): ArrayPrimitive<TElement> {
159
+ return this.refine(
160
+ (v) => v.length <= length,
161
+ `Array must have at most ${length} elements`
162
+ );
163
+ }
164
+
165
+ readonly _internal: PrimitiveInternal<ArrayState<TElement>, ArrayProxy<TElement>> = {
166
+ createProxy: (env: ProxyEnvironment.ProxyEnvironment, operationPath: OperationPath.OperationPath): ArrayProxy<TElement> => {
167
+ const elementPrimitive = this._schema.element;
168
+
169
+ // Helper to get current state (sorted)
170
+ const getCurrentState = (): ArrayEntry<InferState<TElement>>[] => {
171
+ const state = env.getState(operationPath) as ArrayState<TElement> | undefined;
172
+ if (!state || !globalThis.Array.isArray(state)) return [];
173
+ return sortByPos(state);
174
+ };
175
+
176
+ return {
177
+ get: (): ArrayState<TElement> => {
178
+ return getCurrentState();
179
+ },
180
+
181
+ set: (values: readonly InferState<TElement>[]) => {
182
+ // Generate entries with new IDs and sequential positions
183
+ const entries: ArrayEntry<InferState<TElement>>[] = [];
184
+ let prevPos: string | null = null;
185
+
186
+ for (const value of values) {
187
+ const id = env.generateId();
188
+ const pos = generatePosBetween(prevPos, null);
189
+ entries.push({ id, pos, value });
190
+ prevPos = pos;
191
+ }
192
+
193
+ env.addOperation(
194
+ Operation.fromDefinition(operationPath, this._opDefinitions.set, entries)
195
+ );
196
+ },
197
+
198
+ push: (value: InferState<TElement>) => {
199
+ const sorted = getCurrentState();
200
+ const lastPos = sorted.length > 0 ? sorted[sorted.length - 1]!.pos : null;
201
+ const id = env.generateId();
202
+ const pos = generatePosBetween(lastPos, null);
203
+
204
+ env.addOperation(
205
+ Operation.fromDefinition(operationPath, this._opDefinitions.insert, { id, pos, value })
206
+ );
207
+ },
208
+
209
+ insertAt: (index: number, value: InferState<TElement>) => {
210
+ const sorted = getCurrentState();
211
+ const leftPos = index > 0 && sorted[index - 1] ? sorted[index - 1]!.pos : null;
212
+ const rightPos = index < sorted.length && sorted[index] ? sorted[index]!.pos : null;
213
+
214
+ const id = env.generateId();
215
+ const pos = generatePosBetween(leftPos, rightPos);
216
+
217
+ env.addOperation(
218
+ Operation.fromDefinition(operationPath, this._opDefinitions.insert, { id, pos, value })
219
+ );
220
+ },
221
+
222
+ remove: (id: string) => {
223
+ env.addOperation(
224
+ Operation.fromDefinition(operationPath, this._opDefinitions.remove, { id })
225
+ );
226
+ },
227
+
228
+ move: (id: string, toIndex: number) => {
229
+ const sorted = getCurrentState();
230
+ // Filter out the element being moved
231
+ const without = sorted.filter(e => e.id !== id);
232
+
233
+ const clampedIndex = Math.max(0, Math.min(toIndex, without.length));
234
+ const leftPos = clampedIndex > 0 && without[clampedIndex - 1] ? without[clampedIndex - 1]!.pos : null;
235
+ const rightPos = clampedIndex < without.length && without[clampedIndex] ? without[clampedIndex]!.pos : null;
236
+
237
+ const pos = generatePosBetween(leftPos, rightPos);
238
+
239
+ env.addOperation(
240
+ Operation.fromDefinition(operationPath, this._opDefinitions.move, { id, pos })
241
+ );
242
+ },
243
+
244
+ at: (id: string): InferProxy<TElement> => {
245
+ // Use ID in path for element access
246
+ const elementPath = operationPath.append(id);
247
+ return elementPrimitive._internal.createProxy(env, elementPath) as InferProxy<TElement>;
248
+ },
249
+
250
+ find: (predicate: (value: InferState<TElement>, id: string) => boolean): InferProxy<TElement> | undefined => {
251
+ const sorted = getCurrentState();
252
+ const found = sorted.find(entry => predicate(entry.value, entry.id));
253
+ if (!found) return undefined;
254
+
255
+ const elementPath = operationPath.append(found.id);
256
+ return elementPrimitive._internal.createProxy(env, elementPath) as InferProxy<TElement>;
257
+ },
258
+
259
+ toSnapshot: (): ArraySnapshot<TElement> => {
260
+ const sorted = getCurrentState();
261
+ return sorted.map(entry => {
262
+ const elementPath = operationPath.append(entry.id);
263
+ const elementProxy = elementPrimitive._internal.createProxy(env, elementPath);
264
+ return {
265
+ id: entry.id,
266
+ value: (elementProxy as { toSnapshot(): InferSnapshot<TElement> }).toSnapshot(),
267
+ };
268
+ });
269
+ },
270
+ };
271
+ },
272
+
273
+ applyOperation: (
274
+ state: ArrayState<TElement> | undefined,
275
+ operation: Operation.Operation<any, any, any>
276
+ ): ArrayState<TElement> => {
277
+ const path = operation.path;
278
+ const tokens = path.toTokens().filter((t: string) => t !== "");
279
+ const currentState = state ?? [];
280
+
281
+ let newState: ArrayState<TElement>;
282
+
283
+ // If path is empty, this is an array-level operation
284
+ if (tokens.length === 0) {
285
+ switch (operation.kind) {
286
+ case "array.set": {
287
+ const payload = operation.payload;
288
+ if (!globalThis.Array.isArray(payload)) {
289
+ throw new ValidationError(`ArrayPrimitive.set requires an array payload`);
290
+ }
291
+ newState = payload as ArrayState<TElement>;
292
+ break;
293
+ }
294
+ case "array.insert": {
295
+ const { id, pos, value } = operation.payload as { id: string; pos: string; value: InferState<TElement> };
296
+ newState = [...currentState, { id, pos, value }];
297
+ break;
298
+ }
299
+ case "array.remove": {
300
+ const { id } = operation.payload as { id: string };
301
+ newState = currentState.filter(entry => entry.id !== id);
302
+ break;
303
+ }
304
+ case "array.move": {
305
+ const { id, pos } = operation.payload as { id: string; pos: string };
306
+ newState = currentState.map(entry =>
307
+ entry.id === id ? { ...entry, pos } : entry
308
+ );
309
+ break;
310
+ }
311
+ default:
312
+ throw new ValidationError(`ArrayPrimitive cannot apply operation of kind: ${operation.kind}`);
313
+ }
314
+ } else {
315
+ // Otherwise, delegate to the element with the specified ID
316
+ const elementId = tokens[0]!;
317
+ const entryIndex = currentState.findIndex(entry => entry.id === elementId);
318
+
319
+ if (entryIndex === -1) {
320
+ throw new ValidationError(`Array element not found with ID: ${elementId}`);
321
+ }
322
+
323
+ const elementPrimitive = this._schema.element;
324
+ const remainingPath = path.shift();
325
+ const elementOperation = {
326
+ ...operation,
327
+ path: remainingPath,
328
+ };
329
+
330
+ const currentEntry = currentState[entryIndex]!;
331
+ const newValue = elementPrimitive._internal.applyOperation(currentEntry.value, elementOperation);
332
+
333
+ const mutableState = [...currentState];
334
+ mutableState[entryIndex] = { ...currentEntry, value: newValue };
335
+ newState = mutableState;
336
+ }
337
+
338
+ // Run validators on the new state
339
+ runValidators(newState, this._schema.validators);
340
+
341
+ return newState;
342
+ },
343
+
344
+ getInitialState: (): ArrayState<TElement> | undefined => {
345
+ return this._schema.defaultValue;
346
+ },
347
+
348
+ transformOperation: (
349
+ clientOp: Operation.Operation<any, any, any>,
350
+ serverOp: Operation.Operation<any, any, any>
351
+ ): Transform.TransformResult => {
352
+ const clientPath = clientOp.path;
353
+ const serverPath = serverOp.path;
354
+
355
+ // If paths don't overlap at all, no transformation needed
356
+ if (!OperationPath.pathsOverlap(clientPath, serverPath)) {
357
+ return { type: "transformed", operation: clientOp };
358
+ }
359
+
360
+ // Handle array.remove from server - check if client is operating on removed element
361
+ if (serverOp.kind === "array.remove") {
362
+ const removedId = (serverOp.payload as { id: string }).id;
363
+ const clientTokens = clientPath.toTokens().filter((t: string) => t !== "");
364
+ const serverTokens = serverPath.toTokens().filter((t: string) => t !== "");
365
+
366
+ // Check if client is operating on the removed element or its children
367
+ if (clientTokens.length > serverTokens.length) {
368
+ const elementId = clientTokens[serverTokens.length];
369
+ if (elementId === removedId) {
370
+ // Client operation targets a removed element - becomes noop
371
+ return { type: "noop" };
372
+ }
373
+ }
374
+ }
375
+
376
+ // Both inserting into same array - no conflict (fractional indexing handles ordering)
377
+ if (serverOp.kind === "array.insert" && clientOp.kind === "array.insert") {
378
+ return { type: "transformed", operation: clientOp };
379
+ }
380
+
381
+ // Both moving elements in same array
382
+ if (serverOp.kind === "array.move" && clientOp.kind === "array.move") {
383
+ const serverMoveId = (serverOp.payload as { id: string }).id;
384
+ const clientMoveId = (clientOp.payload as { id: string }).id;
385
+
386
+ if (serverMoveId === clientMoveId) {
387
+ // Client's move supersedes server's move (last-write-wins for position)
388
+ return { type: "transformed", operation: clientOp };
389
+ }
390
+ // Different elements - no conflict
391
+ return { type: "transformed", operation: clientOp };
392
+ }
393
+
394
+ // For operations on same exact path: client wins (last-write-wins)
395
+ if (OperationPath.pathsEqual(clientPath, serverPath)) {
396
+ return { type: "transformed", operation: clientOp };
397
+ }
398
+
399
+ // If server set entire array and client is operating on an element
400
+ if (serverOp.kind === "array.set" && OperationPath.isPrefix(serverPath, clientPath)) {
401
+ // Client's element operation may be invalid after array replacement
402
+ // However, for optimistic updates, we let the client op proceed
403
+ // and the server will validate/reject if needed
404
+ return { type: "transformed", operation: clientOp };
405
+ }
406
+
407
+ // Delegate to element primitive for nested operations
408
+ const clientTokens = clientPath.toTokens().filter((t: string) => t !== "");
409
+ const serverTokens = serverPath.toTokens().filter((t: string) => t !== "");
410
+
411
+ // Both operations target children of this array
412
+ if (clientTokens.length > 0 && serverTokens.length > 0) {
413
+ const clientElementId = clientTokens[0];
414
+ const serverElementId = serverTokens[0];
415
+
416
+ // If operating on different elements, no conflict
417
+ if (clientElementId !== serverElementId) {
418
+ return { type: "transformed", operation: clientOp };
419
+ }
420
+
421
+ // Same element - delegate to element primitive
422
+ const elementPrimitive = this._schema.element;
423
+ const clientOpForElement = {
424
+ ...clientOp,
425
+ path: clientOp.path.shift(),
426
+ };
427
+ const serverOpForElement = {
428
+ ...serverOp,
429
+ path: serverOp.path.shift(),
430
+ };
431
+
432
+ const result = elementPrimitive._internal.transformOperation(clientOpForElement, serverOpForElement);
433
+
434
+ if (result.type === "transformed") {
435
+ // Restore the original path prefix
436
+ return {
437
+ type: "transformed",
438
+ operation: {
439
+ ...result.operation,
440
+ path: clientOp.path,
441
+ },
442
+ };
443
+ }
444
+
445
+ return result;
446
+ }
447
+
448
+ // Default: no transformation needed
449
+ return { type: "transformed", operation: clientOp };
450
+ },
451
+ };
452
+ }
453
+
454
+ /** Creates a new ArrayPrimitive with the given element type */
455
+ export const Array = <TElement extends AnyPrimitive>(element: TElement): ArrayPrimitive<TElement> =>
456
+ new ArrayPrimitive({ required: false, defaultValue: undefined, element, validators: [] });
457
+
@@ -0,0 +1,128 @@
1
+ import { Schema } from "effect";
2
+ import * as OperationDefinition from "../OperationDefinition";
3
+ import * as Operation from "../Operation";
4
+ import * as OperationPath from "../OperationPath";
5
+ import * as ProxyEnvironment from "../ProxyEnvironment";
6
+ import * as Transform from "../Transform";
7
+ import type { Primitive, PrimitiveInternal, MaybeUndefined, Validator } from "./shared";
8
+ import { runValidators, isCompatibleOperation, ValidationError } from "./shared";
9
+
10
+
11
+ export interface BooleanProxy<TDefined extends boolean = false> {
12
+ /** Gets the current boolean value */
13
+ get(): MaybeUndefined<boolean, TDefined>;
14
+ /** Sets the boolean value, generating a boolean.set operation */
15
+ set(value: boolean): void;
16
+ /** Returns a readonly snapshot of the boolean value for rendering */
17
+ toSnapshot(): MaybeUndefined<boolean, TDefined>;
18
+ }
19
+
20
+ interface BooleanPrimitiveSchema {
21
+ readonly required: boolean;
22
+ readonly defaultValue: boolean | undefined;
23
+ readonly validators: readonly Validator<boolean>[];
24
+ }
25
+
26
+ export class BooleanPrimitive<TDefined extends boolean = false> implements Primitive<boolean, BooleanProxy<TDefined>> {
27
+ readonly _tag = "BooleanPrimitive" as const;
28
+ readonly _State!: boolean;
29
+ readonly _Proxy!: BooleanProxy<TDefined>;
30
+
31
+ private readonly _schema: BooleanPrimitiveSchema;
32
+
33
+ private readonly _opDefinitions = {
34
+ set: OperationDefinition.make({
35
+ kind: "boolean.set" as const,
36
+ payload: Schema.Boolean,
37
+ target: Schema.Boolean,
38
+ apply: (payload) => payload,
39
+ }),
40
+ };
41
+
42
+ constructor(schema: BooleanPrimitiveSchema) {
43
+ this._schema = schema;
44
+ }
45
+
46
+ /** Mark this boolean as required */
47
+ required(): BooleanPrimitive<true> {
48
+ return new BooleanPrimitive({
49
+ ...this._schema,
50
+ required: true,
51
+ });
52
+ }
53
+
54
+ /** Set a default value for this boolean */
55
+ default(defaultValue: boolean): BooleanPrimitive<true> {
56
+ return new BooleanPrimitive({
57
+ ...this._schema,
58
+ defaultValue,
59
+ });
60
+ }
61
+
62
+ /** Add a custom validation rule */
63
+ refine(fn: (value: boolean) => boolean, message: string): BooleanPrimitive<TDefined> {
64
+ return new BooleanPrimitive({
65
+ ...this._schema,
66
+ validators: [...this._schema.validators, { validate: fn, message }],
67
+ });
68
+ }
69
+
70
+ readonly _internal: PrimitiveInternal<boolean, BooleanProxy<TDefined>> = {
71
+ createProxy: (env: ProxyEnvironment.ProxyEnvironment, operationPath: OperationPath.OperationPath): BooleanProxy<TDefined> => {
72
+ const defaultValue = this._schema.defaultValue;
73
+ return {
74
+ get: (): MaybeUndefined<boolean, TDefined> => {
75
+ const state = env.getState(operationPath) as boolean | undefined;
76
+ return (state ?? defaultValue) as MaybeUndefined<boolean, TDefined>;
77
+ },
78
+ set: (value: boolean) => {
79
+ env.addOperation(
80
+ Operation.fromDefinition(operationPath, this._opDefinitions.set, value)
81
+ );
82
+ },
83
+ toSnapshot: (): MaybeUndefined<boolean, TDefined> => {
84
+ const state = env.getState(operationPath) as boolean | undefined;
85
+ return (state ?? defaultValue) as MaybeUndefined<boolean, TDefined>;
86
+ },
87
+ };
88
+ },
89
+
90
+ applyOperation: (state: boolean | undefined, operation: Operation.Operation<any, any, any>): boolean => {
91
+ if (operation.kind !== "boolean.set") {
92
+ throw new ValidationError(`BooleanPrimitive cannot apply operation of kind: ${operation.kind}`);
93
+ }
94
+
95
+ const payload = operation.payload;
96
+ if (typeof payload !== "boolean") {
97
+ throw new ValidationError(`BooleanPrimitive.set requires a boolean payload, got: ${typeof payload}`);
98
+ }
99
+
100
+ // Run validators
101
+ runValidators(payload, this._schema.validators);
102
+
103
+ return payload;
104
+ },
105
+
106
+ getInitialState: (): boolean | undefined => {
107
+ return this._schema.defaultValue;
108
+ },
109
+
110
+ transformOperation: (
111
+ clientOp: Operation.Operation<any, any, any>,
112
+ serverOp: Operation.Operation<any, any, any>
113
+ ): Transform.TransformResult => {
114
+ // If paths don't overlap, no transformation needed
115
+ if (!OperationPath.pathsOverlap(clientOp.path, serverOp.path)) {
116
+ return { type: "transformed", operation: clientOp };
117
+ }
118
+
119
+ // For same path, client wins (last-write-wins)
120
+ return { type: "transformed", operation: clientOp };
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Creates a new BooleanPrimitive */
126
+ export const Boolean = (): BooleanPrimitive<false> =>
127
+ new BooleanPrimitive({ required: false, defaultValue: undefined, validators: [] });
128
+