@voidhash/mimic 1.0.0-beta.14 → 1.0.0-beta.16
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/.turbo/turbo-build.log +30 -30
- package/dist/Document.cjs +0 -3
- package/dist/Document.d.mts.map +1 -1
- package/dist/Document.mjs +0 -3
- package/dist/Document.mjs.map +1 -1
- package/dist/Primitive.cjs +1 -0
- package/dist/Primitive.d.cts +3 -3
- package/dist/Primitive.d.mts +3 -3
- package/dist/Primitive.mjs +2 -1
- package/dist/client/ClientDocument.cjs +36 -7
- package/dist/client/ClientDocument.d.mts.map +1 -1
- package/dist/client/ClientDocument.mjs +36 -7
- package/dist/client/ClientDocument.mjs.map +1 -1
- package/dist/primitives/Struct.cjs +86 -22
- package/dist/primitives/Struct.d.cts +23 -3
- package/dist/primitives/Struct.d.cts.map +1 -1
- package/dist/primitives/Struct.d.mts +23 -3
- package/dist/primitives/Struct.d.mts.map +1 -1
- package/dist/primitives/Struct.mjs +87 -23
- package/dist/primitives/Struct.mjs.map +1 -1
- package/dist/primitives/shared.cjs +25 -4
- package/dist/primitives/shared.d.cts +2 -1
- package/dist/primitives/shared.d.cts.map +1 -1
- package/dist/primitives/shared.d.mts +2 -1
- package/dist/primitives/shared.d.mts.map +1 -1
- package/dist/primitives/shared.mjs +25 -5
- package/dist/primitives/shared.mjs.map +1 -1
- package/package.json +2 -2
- package/src/Document.ts +13 -4
- package/src/client/ClientDocument.ts +40 -3
- package/src/primitives/Struct.ts +152 -30
- package/src/primitives/shared.ts +59 -5
- package/tests/client/ClientDocument.test.ts +99 -0
- package/tests/primitives/Struct.test.ts +251 -4
- package/tests/primitives/Tree.test.ts +143 -0
|
@@ -558,6 +558,9 @@ export const make = <
|
|
|
558
558
|
ops: tx.ops,
|
|
559
559
|
pendingCount: _pending.length + 1,
|
|
560
560
|
isConnected: transport.isConnected(),
|
|
561
|
+
activeDraftCount: _drafts.size,
|
|
562
|
+
activeDraftIds: Array.from(_drafts.keys()),
|
|
563
|
+
callStack: new Error().stack?.split("\n").slice(1, 6).join("\n"),
|
|
561
564
|
});
|
|
562
565
|
|
|
563
566
|
const pending: PendingTransaction = {
|
|
@@ -1002,6 +1005,9 @@ export const make = <
|
|
|
1002
1005
|
isConnected: transport.isConnected(),
|
|
1003
1006
|
isReady: _initState.type === "ready",
|
|
1004
1007
|
pendingCount: _pending.length,
|
|
1008
|
+
activeDraftCount: _drafts.size,
|
|
1009
|
+
activeDraftIds: Array.from(_drafts.keys()),
|
|
1010
|
+
callStack: new Error().stack?.split("\n").slice(1, 6).join("\n"),
|
|
1005
1011
|
});
|
|
1006
1012
|
|
|
1007
1013
|
// Allow transactions even when disconnected - they will be queued
|
|
@@ -1176,6 +1182,13 @@ export const make = <
|
|
|
1176
1182
|
id: draftId,
|
|
1177
1183
|
|
|
1178
1184
|
update: (fn: (root: Primitive.InferProxy<TSchema>) => void): void => {
|
|
1185
|
+
debugLog("draft.update: starting", {
|
|
1186
|
+
draftId,
|
|
1187
|
+
consumed,
|
|
1188
|
+
currentOpsCount: draftState.ops.size,
|
|
1189
|
+
pendingCount: _pending.length,
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1179
1192
|
if (consumed) {
|
|
1180
1193
|
throw new InvalidStateError("Draft has already been committed or discarded.");
|
|
1181
1194
|
}
|
|
@@ -1213,7 +1226,12 @@ export const make = <
|
|
|
1213
1226
|
}
|
|
1214
1227
|
}
|
|
1215
1228
|
|
|
1216
|
-
debugLog("draft.update", {
|
|
1229
|
+
debugLog("draft.update: complete", {
|
|
1230
|
+
draftId,
|
|
1231
|
+
newOpsCount: tx.ops.length,
|
|
1232
|
+
totalOps: draftState.ops.size,
|
|
1233
|
+
note: "Ops stored in draft - NOT sent to server",
|
|
1234
|
+
});
|
|
1217
1235
|
|
|
1218
1236
|
// Recompute optimistic state to reflect draft changes
|
|
1219
1237
|
recomputeOptimisticState();
|
|
@@ -1221,6 +1239,12 @@ export const make = <
|
|
|
1221
1239
|
},
|
|
1222
1240
|
|
|
1223
1241
|
commit: (): void => {
|
|
1242
|
+
debugLog("draft.commit: starting", {
|
|
1243
|
+
draftId,
|
|
1244
|
+
consumed,
|
|
1245
|
+
opsCount: draftState.ops.size,
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1224
1248
|
if (consumed) {
|
|
1225
1249
|
throw new InvalidStateError("Draft has already been committed or discarded.");
|
|
1226
1250
|
}
|
|
@@ -1229,7 +1253,11 @@ export const make = <
|
|
|
1229
1253
|
const ops = Array.from(draftState.ops.values());
|
|
1230
1254
|
_drafts.delete(draftId);
|
|
1231
1255
|
|
|
1232
|
-
debugLog("draft.commit", {
|
|
1256
|
+
debugLog("draft.commit: submitting", {
|
|
1257
|
+
draftId,
|
|
1258
|
+
opsCount: ops.length,
|
|
1259
|
+
note: ops.length > 0 ? "Will call submitTransaction" : "Empty draft - no transaction",
|
|
1260
|
+
});
|
|
1233
1261
|
|
|
1234
1262
|
if (ops.length > 0) {
|
|
1235
1263
|
const tx = Transaction.make(ops);
|
|
@@ -1243,6 +1271,12 @@ export const make = <
|
|
|
1243
1271
|
},
|
|
1244
1272
|
|
|
1245
1273
|
discard: (): void => {
|
|
1274
|
+
debugLog("draft.discard: starting", {
|
|
1275
|
+
draftId,
|
|
1276
|
+
consumed,
|
|
1277
|
+
opsCount: draftState.ops.size,
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1246
1280
|
if (consumed) {
|
|
1247
1281
|
throw new InvalidStateError("Draft has already been committed or discarded.");
|
|
1248
1282
|
}
|
|
@@ -1250,7 +1284,10 @@ export const make = <
|
|
|
1250
1284
|
|
|
1251
1285
|
_drafts.delete(draftId);
|
|
1252
1286
|
|
|
1253
|
-
debugLog("draft.discard", {
|
|
1287
|
+
debugLog("draft.discard: complete", {
|
|
1288
|
+
draftId,
|
|
1289
|
+
note: "Draft discarded - no transaction sent to server",
|
|
1290
|
+
});
|
|
1254
1291
|
|
|
1255
1292
|
recomputeOptimisticState();
|
|
1256
1293
|
notifyDraftChange();
|
package/src/primitives/Struct.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as ProxyEnvironment from "../ProxyEnvironment";
|
|
|
6
6
|
import * as Transform from "../Transform";
|
|
7
7
|
import type { Primitive, PrimitiveInternal, MaybeUndefined, AnyPrimitive, Validator, InferState, InferProxy, InferSnapshot, NeedsValue, InferUpdateInput, InferSetInput } from "../Primitive";
|
|
8
8
|
import { ValidationError } from "../Primitive";
|
|
9
|
-
import { runValidators, applyDefaults } from "./shared";
|
|
9
|
+
import { runValidators, applyDefaults, primitiveAllowsNullValue } from "./shared";
|
|
10
10
|
|
|
11
11
|
// =============================================================================
|
|
12
12
|
// Struct Set Input Types
|
|
@@ -89,6 +89,13 @@ export type MakeOptional<T extends AnyPrimitive> = T extends Primitive<infer S,
|
|
|
89
89
|
? Primitive<S, P, false, H, SI, UI>
|
|
90
90
|
: T;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Transforms a primitive type to make it optional and strip its default (TRequired = false, THasDefault = false).
|
|
94
|
+
*/
|
|
95
|
+
export type MakeOptionalNoDefault<T extends AnyPrimitive> = T extends Primitive<infer S, infer P, any, any, infer SI, infer UI>
|
|
96
|
+
? Primitive<S, P, false, false, SI, UI>
|
|
97
|
+
: T;
|
|
98
|
+
|
|
92
99
|
/**
|
|
93
100
|
* Maps each field in a struct to its optional version.
|
|
94
101
|
* All fields become optional (TRequired = false).
|
|
@@ -97,6 +104,14 @@ export type PartialFields<TFields extends Record<string, AnyPrimitive>> = {
|
|
|
97
104
|
[K in keyof TFields]: MakeOptional<TFields[K]>;
|
|
98
105
|
};
|
|
99
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Maps each field in a struct to its optional version with defaults stripped.
|
|
109
|
+
* All fields become optional (TRequired = false) and lose their defaults (THasDefault = false).
|
|
110
|
+
*/
|
|
111
|
+
export type PartialFieldsNoDefault<TFields extends Record<string, AnyPrimitive>> = {
|
|
112
|
+
[K in keyof TFields]: MakeOptionalNoDefault<TFields[K]>;
|
|
113
|
+
};
|
|
114
|
+
|
|
100
115
|
/**
|
|
101
116
|
* Maps a schema definition to its proxy type.
|
|
102
117
|
* Provides nested field access + get()/set()/toSnapshot() methods for the whole struct.
|
|
@@ -142,6 +157,13 @@ export class StructPrimitive<TFields extends Record<string, AnyPrimitive>, TRequ
|
|
|
142
157
|
apply: (payload) => payload,
|
|
143
158
|
deduplicable: true,
|
|
144
159
|
}),
|
|
160
|
+
unset: OperationDefinition.make({
|
|
161
|
+
kind: "struct.unset" as const,
|
|
162
|
+
payload: Schema.Unknown,
|
|
163
|
+
target: Schema.Unknown,
|
|
164
|
+
apply: (payload) => payload,
|
|
165
|
+
deduplicable: true,
|
|
166
|
+
}),
|
|
145
167
|
};
|
|
146
168
|
|
|
147
169
|
constructor(schema: StructPrimitiveSchema<TFields>) {
|
|
@@ -191,13 +213,23 @@ export class StructPrimitive<TFields extends Record<string, AnyPrimitive>, TRequ
|
|
|
191
213
|
});
|
|
192
214
|
}
|
|
193
215
|
|
|
194
|
-
/** Make all properties of this struct optional (TRequired = false for all fields) */
|
|
195
|
-
partial(): StructPrimitive<
|
|
216
|
+
/** Make all properties of this struct optional (TRequired = false for all fields), optionally stripping defaults */
|
|
217
|
+
partial(options: { stripDefaults: true }): StructPrimitive<PartialFieldsNoDefault<TFields>, TRequired, THasDefault>;
|
|
218
|
+
partial(options?: { stripDefaults?: boolean }): StructPrimitive<PartialFields<TFields>, TRequired, THasDefault>;
|
|
219
|
+
partial(options?: { stripDefaults?: boolean }): StructPrimitive<PartialFields<TFields>, TRequired, THasDefault> | StructPrimitive<PartialFieldsNoDefault<TFields>, TRequired, THasDefault> {
|
|
220
|
+
const stripDefaults = options?.stripDefaults;
|
|
196
221
|
const partialFields: Record<string, AnyPrimitive> = {};
|
|
197
222
|
for (const key in this._schema.fields) {
|
|
198
223
|
const field = this._schema.fields[key]!;
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
partialFields[key] = this._makeFieldOptional(field, stripDefaults);
|
|
225
|
+
}
|
|
226
|
+
if (stripDefaults) {
|
|
227
|
+
return new StructPrimitive<PartialFieldsNoDefault<TFields>, TRequired, THasDefault>({
|
|
228
|
+
required: this._schema.required,
|
|
229
|
+
defaultValue: undefined,
|
|
230
|
+
fields: partialFields as PartialFieldsNoDefault<TFields>,
|
|
231
|
+
validators: [],
|
|
232
|
+
});
|
|
201
233
|
}
|
|
202
234
|
return new StructPrimitive<PartialFields<TFields>, TRequired, THasDefault>({
|
|
203
235
|
required: this._schema.required,
|
|
@@ -207,18 +239,89 @@ export class StructPrimitive<TFields extends Record<string, AnyPrimitive>, TRequ
|
|
|
207
239
|
});
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
private _makeFieldOptional(field: AnyPrimitive): AnyPrimitive {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
242
|
+
private _makeFieldOptional(field: AnyPrimitive, stripDefaults?: boolean): AnyPrimitive {
|
|
243
|
+
const maybeStripped = stripDefaults ? this._stripDefaultsRecursively(field) : field;
|
|
244
|
+
|
|
245
|
+
// Create a new field with required: false, preserving its shape.
|
|
246
|
+
const fieldWithSchema = maybeStripped as { _schema?: Record<string, unknown> };
|
|
247
|
+
if (fieldWithSchema._schema && typeof fieldWithSchema._schema === "object") {
|
|
248
|
+
const Constructor = maybeStripped.constructor as new (schema: unknown) => AnyPrimitive;
|
|
216
249
|
return new Constructor({
|
|
217
|
-
...
|
|
250
|
+
...fieldWithSchema._schema,
|
|
218
251
|
required: false,
|
|
219
252
|
});
|
|
220
253
|
}
|
|
221
|
-
return
|
|
254
|
+
return maybeStripped;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Recursively strips defaults from a primitive and nested child primitives.
|
|
259
|
+
* This is used by partial({ stripDefaults: true }) so omitted fields resolve to undefined.
|
|
260
|
+
*/
|
|
261
|
+
private _stripDefaultsRecursively(field: AnyPrimitive): AnyPrimitive {
|
|
262
|
+
const fieldWithSchema = field as { _schema?: Record<string, unknown> };
|
|
263
|
+
if (!fieldWithSchema._schema || typeof fieldWithSchema._schema !== "object") {
|
|
264
|
+
return field;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const nextSchema: Record<string, unknown> = { ...fieldWithSchema._schema };
|
|
268
|
+
|
|
269
|
+
// Struct fields
|
|
270
|
+
if (
|
|
271
|
+
nextSchema["fields"] &&
|
|
272
|
+
typeof nextSchema["fields"] === "object" &&
|
|
273
|
+
!Array.isArray(nextSchema["fields"])
|
|
274
|
+
) {
|
|
275
|
+
const nextFields: Record<string, AnyPrimitive> = {};
|
|
276
|
+
for (const key in nextSchema["fields"] as Record<string, AnyPrimitive>) {
|
|
277
|
+
const nestedField = (nextSchema["fields"] as Record<string, AnyPrimitive>)[key]!;
|
|
278
|
+
nextFields[key] = this._stripDefaultsRecursively(nestedField);
|
|
279
|
+
}
|
|
280
|
+
nextSchema["fields"] = nextFields;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Array element primitive
|
|
284
|
+
if (nextSchema["element"] && typeof nextSchema["element"] === "object") {
|
|
285
|
+
nextSchema["element"] = this._stripDefaultsRecursively(nextSchema["element"] as AnyPrimitive);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Either variants (array) / Union variants (record)
|
|
289
|
+
if (Array.isArray(nextSchema["variants"])) {
|
|
290
|
+
nextSchema["variants"] = (nextSchema["variants"] as readonly AnyPrimitive[]).map((variant) =>
|
|
291
|
+
this._stripDefaultsRecursively(variant)
|
|
292
|
+
);
|
|
293
|
+
} else if (nextSchema["variants"] && typeof nextSchema["variants"] === "object") {
|
|
294
|
+
const nextVariants: Record<string, AnyPrimitive> = {};
|
|
295
|
+
for (const key in nextSchema["variants"] as Record<string, AnyPrimitive>) {
|
|
296
|
+
const nestedVariant = (nextSchema["variants"] as Record<string, AnyPrimitive>)[key]!;
|
|
297
|
+
nextVariants[key] = this._stripDefaultsRecursively(nestedVariant);
|
|
298
|
+
}
|
|
299
|
+
nextSchema["variants"] = nextVariants;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Tree root node primitive
|
|
303
|
+
if (nextSchema["root"] && typeof nextSchema["root"] === "object") {
|
|
304
|
+
nextSchema["root"] = this._stripDefaultsRecursively(nextSchema["root"] as AnyPrimitive);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Clear known default carriers across primitives.
|
|
308
|
+
if ("defaultValue" in nextSchema) {
|
|
309
|
+
nextSchema["defaultValue"] = undefined;
|
|
310
|
+
}
|
|
311
|
+
if ("defaultInput" in nextSchema) {
|
|
312
|
+
nextSchema["defaultInput"] = undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const Constructor = field.constructor as new (schema: unknown) => AnyPrimitive;
|
|
316
|
+
return new Constructor(nextSchema);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private _isRequiredWithoutDefault(field: AnyPrimitive): boolean {
|
|
320
|
+
const fieldDefault = field._internal.getInitialState();
|
|
321
|
+
return (
|
|
322
|
+
(field as { _schema?: { required?: boolean } })._schema?.required === true &&
|
|
323
|
+
fieldDefault === undefined
|
|
324
|
+
);
|
|
222
325
|
}
|
|
223
326
|
|
|
224
327
|
readonly _internal: PrimitiveInternal<InferStructState<TFields>, StructProxy<TFields, TRequired, THasDefault>> = {
|
|
@@ -270,12 +373,24 @@ export class StructPrimitive<TFields extends Record<string, AnyPrimitive>, TRequ
|
|
|
270
373
|
for (const key in value) {
|
|
271
374
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
272
375
|
const fieldValue = value[key as keyof TFields];
|
|
273
|
-
if (fieldValue === undefined) continue; // Skip undefined values
|
|
274
|
-
|
|
275
376
|
const fieldPrimitive = fields[key as keyof TFields];
|
|
276
377
|
if (!fieldPrimitive) continue; // Skip unknown fields
|
|
277
378
|
|
|
278
379
|
const fieldPath = operationPath.append(key);
|
|
380
|
+
|
|
381
|
+
const shouldUnset =
|
|
382
|
+
fieldValue === undefined ||
|
|
383
|
+
(fieldValue === null && !primitiveAllowsNullValue(fieldPrimitive));
|
|
384
|
+
if (shouldUnset) {
|
|
385
|
+
if (this._isRequiredWithoutDefault(fieldPrimitive)) {
|
|
386
|
+
throw new ValidationError(`Field "${key}" is required and cannot be null or undefined`);
|
|
387
|
+
}
|
|
388
|
+
env.addOperation(
|
|
389
|
+
Operation.fromDefinition(fieldPath, this._opDefinitions.unset, null)
|
|
390
|
+
);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
279
394
|
const fieldProxy = fieldPrimitive._internal.createProxy(env, fieldPath);
|
|
280
395
|
|
|
281
396
|
// Check if this is a nested struct and value is a plain object (partial update)
|
|
@@ -368,24 +483,32 @@ export class StructPrimitive<TFields extends Record<string, AnyPrimitive>, TRequ
|
|
|
368
483
|
}
|
|
369
484
|
|
|
370
485
|
const fieldPrimitive = this._schema.fields[fieldName]!;
|
|
371
|
-
const remainingPath = path.shift();
|
|
372
|
-
const fieldOperation = {
|
|
373
|
-
...operation,
|
|
374
|
-
path: remainingPath,
|
|
375
|
-
};
|
|
376
|
-
|
|
377
486
|
// Get the current field state
|
|
378
487
|
const currentState = state ?? ({} as InferStructState<TFields>);
|
|
379
|
-
|
|
488
|
+
if (operation.kind === "struct.unset" && tokens.length === 1) {
|
|
489
|
+
if (this._isRequiredWithoutDefault(fieldPrimitive)) {
|
|
490
|
+
throw new ValidationError(`Field "${globalThis.String(fieldName)}" is required and cannot be removed`);
|
|
491
|
+
}
|
|
492
|
+
const mutableState = { ...currentState } as Record<string, unknown>;
|
|
493
|
+
delete mutableState[globalThis.String(fieldName)];
|
|
494
|
+
newState = mutableState as InferStructState<TFields>;
|
|
495
|
+
} else {
|
|
496
|
+
const remainingPath = path.shift();
|
|
497
|
+
const fieldOperation = {
|
|
498
|
+
...operation,
|
|
499
|
+
path: remainingPath,
|
|
500
|
+
};
|
|
501
|
+
const currentFieldState = currentState[fieldName] as InferState<typeof fieldPrimitive> | undefined;
|
|
380
502
|
|
|
381
|
-
|
|
382
|
-
|
|
503
|
+
// Apply the operation to the field
|
|
504
|
+
const newFieldState = fieldPrimitive._internal.applyOperation(currentFieldState, fieldOperation);
|
|
383
505
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
506
|
+
// Build updated state
|
|
507
|
+
newState = {
|
|
508
|
+
...currentState,
|
|
509
|
+
[fieldName]: newFieldState,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
389
512
|
}
|
|
390
513
|
|
|
391
514
|
// Run validators on the new state
|
|
@@ -501,4 +624,3 @@ export const Struct = <TFields extends Record<string, AnyPrimitive>>(
|
|
|
501
624
|
fields: TFields
|
|
502
625
|
): StructPrimitive<TFields, false, false> =>
|
|
503
626
|
new StructPrimitive({ required: false, defaultValue: undefined, fields, validators: [] });
|
|
504
|
-
|
package/src/primitives/shared.ts
CHANGED
|
@@ -171,6 +171,31 @@ export function runValidators<T>(value: T, validators: readonly { validate: (val
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Returns true if a primitive can represent null as a meaningful value.
|
|
176
|
+
* This is used to avoid pruning explicit null values for null-capable scalar unions.
|
|
177
|
+
*/
|
|
178
|
+
type PrimitiveWithLiteral = AnyPrimitive & {
|
|
179
|
+
readonly _tag: "LiteralPrimitive";
|
|
180
|
+
readonly literal: unknown;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const isLiteralPrimitive = (primitive: AnyPrimitive): primitive is PrimitiveWithLiteral =>
|
|
184
|
+
primitive._tag === "LiteralPrimitive" && "literal" in primitive;
|
|
185
|
+
|
|
186
|
+
export function primitiveAllowsNullValue(primitive: AnyPrimitive): boolean {
|
|
187
|
+
if (isLiteralPrimitive(primitive)) {
|
|
188
|
+
return primitive.literal === null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (primitive._tag === "EitherPrimitive") {
|
|
192
|
+
const variants = (primitive as { _schema?: { variants?: readonly AnyPrimitive[] } })._schema?.variants;
|
|
193
|
+
return Array.isArray(variants) && variants.some((variant) => primitiveAllowsNullValue(variant));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
174
199
|
/**
|
|
175
200
|
* Checks if an operation is compatible with the given operation definitions.
|
|
176
201
|
* @param operation - The operation to check.
|
|
@@ -214,20 +239,50 @@ export function applyDefaults<T extends AnyPrimitive>(
|
|
|
214
239
|
|
|
215
240
|
// Layer the provided values on top of initial state
|
|
216
241
|
const result: Record<string, unknown> = { ...structInitialState, ...value };
|
|
242
|
+
const inputObject =
|
|
243
|
+
typeof value === "object" && value !== null
|
|
244
|
+
? (value as Record<string, unknown>)
|
|
245
|
+
: undefined;
|
|
217
246
|
|
|
218
247
|
for (const key in structPrimitive.fields) {
|
|
219
248
|
const fieldPrimitive = structPrimitive.fields[key]!;
|
|
249
|
+
const hasExplicitKey = inputObject !== undefined && Object.prototype.hasOwnProperty.call(inputObject, key);
|
|
250
|
+
const explicitValue = hasExplicitKey ? inputObject[key] : undefined;
|
|
251
|
+
const fieldDefault = fieldPrimitive._internal.getInitialState();
|
|
252
|
+
const isRequiredWithoutDefault =
|
|
253
|
+
(fieldPrimitive as { _schema?: { required?: boolean } })._schema?.required === true &&
|
|
254
|
+
fieldDefault === undefined;
|
|
255
|
+
|
|
256
|
+
// Explicit undefined values always prune optional keys.
|
|
257
|
+
// Explicit null values prune optional keys unless null is a valid semantic value for this field.
|
|
258
|
+
// Required fields without defaults reject nullish values.
|
|
259
|
+
const shouldPruneExplicitNullish =
|
|
260
|
+
hasExplicitKey &&
|
|
261
|
+
(
|
|
262
|
+
explicitValue === undefined ||
|
|
263
|
+
(explicitValue === null && !primitiveAllowsNullValue(fieldPrimitive))
|
|
264
|
+
);
|
|
265
|
+
if (shouldPruneExplicitNullish) {
|
|
266
|
+
if (isRequiredWithoutDefault) {
|
|
267
|
+
throw new ValidationError(`Field "${key}" is required and cannot be null or undefined`);
|
|
268
|
+
}
|
|
269
|
+
delete result[key];
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
220
272
|
|
|
221
|
-
if (result[key] === undefined) {
|
|
273
|
+
if (!hasExplicitKey && result[key] === undefined) {
|
|
222
274
|
// Field still not provided after merging - try individual field default
|
|
223
|
-
const fieldDefault = fieldPrimitive._internal.getInitialState();
|
|
224
275
|
if (fieldDefault !== undefined) {
|
|
225
276
|
result[key] = fieldDefault;
|
|
226
277
|
}
|
|
227
|
-
} else if (
|
|
278
|
+
} else if (
|
|
279
|
+
hasExplicitKey &&
|
|
280
|
+
typeof explicitValue === "object" &&
|
|
281
|
+
explicitValue !== null
|
|
282
|
+
) {
|
|
228
283
|
// Recursively apply defaults to nested structs and unions
|
|
229
284
|
if (fieldPrimitive._tag === "StructPrimitive" || fieldPrimitive._tag === "UnionPrimitive") {
|
|
230
|
-
result[key] = applyDefaults(fieldPrimitive,
|
|
285
|
+
result[key] = applyDefaults(fieldPrimitive, explicitValue as Partial<InferState<typeof fieldPrimitive>>);
|
|
231
286
|
}
|
|
232
287
|
}
|
|
233
288
|
}
|
|
@@ -285,4 +340,3 @@ export function applyDefaults<T extends AnyPrimitive>(
|
|
|
285
340
|
// For other primitives, return the value as-is
|
|
286
341
|
return value as InferState<T>;
|
|
287
342
|
}
|
|
288
|
-
|
|
@@ -1878,5 +1878,104 @@ describe("ClientDocument Presence", () => {
|
|
|
1878
1878
|
|
|
1879
1879
|
expect(transport.sentTransactions.length).toBe(0);
|
|
1880
1880
|
});
|
|
1881
|
+
|
|
1882
|
+
it("should NEVER send transactions to server during draft.update() - explicit verification", async () => {
|
|
1883
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1884
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1885
|
+
await client.connect();
|
|
1886
|
+
|
|
1887
|
+
// Track when transport.send is called
|
|
1888
|
+
const sendCalls: Transaction.Transaction[] = [];
|
|
1889
|
+
const originalSend = transport.send;
|
|
1890
|
+
transport.send = (tx) => {
|
|
1891
|
+
sendCalls.push(tx);
|
|
1892
|
+
originalSend.call(transport, tx);
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1895
|
+
const draft = client.createDraft();
|
|
1896
|
+
|
|
1897
|
+
// Perform multiple updates
|
|
1898
|
+
draft.update((root) => root.title.set("Update 1"));
|
|
1899
|
+
expect(sendCalls.length).toBe(0);
|
|
1900
|
+
|
|
1901
|
+
draft.update((root) => root.count.set(10));
|
|
1902
|
+
expect(sendCalls.length).toBe(0);
|
|
1903
|
+
|
|
1904
|
+
draft.update((root) => root.title.set("Update 2"));
|
|
1905
|
+
expect(sendCalls.length).toBe(0);
|
|
1906
|
+
|
|
1907
|
+
draft.update((root) => {
|
|
1908
|
+
root.title.set("Update 3");
|
|
1909
|
+
root.count.set(20);
|
|
1910
|
+
});
|
|
1911
|
+
expect(sendCalls.length).toBe(0);
|
|
1912
|
+
|
|
1913
|
+
// Verify optimistic state is updated
|
|
1914
|
+
expect(client.get()?.title).toBe("Update 3");
|
|
1915
|
+
expect(client.get()?.count).toBe(20);
|
|
1916
|
+
|
|
1917
|
+
// Still no transactions sent
|
|
1918
|
+
expect(sendCalls.length).toBe(0);
|
|
1919
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1920
|
+
|
|
1921
|
+
// Only after commit should transaction be sent
|
|
1922
|
+
draft.commit();
|
|
1923
|
+
expect(sendCalls.length).toBe(1);
|
|
1924
|
+
expect(transport.sentTransactions.length).toBe(1);
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
it("should never call transport.send during draft lifecycle until commit", async () => {
|
|
1928
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1929
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1930
|
+
await client.connect();
|
|
1931
|
+
|
|
1932
|
+
// Create a spy to track exact moments of transport.send calls
|
|
1933
|
+
const sendTimestamps: { time: number; action: string }[] = [];
|
|
1934
|
+
const originalSend = transport.send;
|
|
1935
|
+
transport.send = (tx) => {
|
|
1936
|
+
sendTimestamps.push({ time: Date.now(), action: "send" });
|
|
1937
|
+
originalSend.call(transport, tx);
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
// Draft operations should NOT trigger send
|
|
1941
|
+
const draft = client.createDraft();
|
|
1942
|
+
expect(sendTimestamps.length).toBe(0);
|
|
1943
|
+
|
|
1944
|
+
draft.update((root) => root.title.set("Draft"));
|
|
1945
|
+
expect(sendTimestamps.length).toBe(0);
|
|
1946
|
+
|
|
1947
|
+
// Regular transaction SHOULD trigger send
|
|
1948
|
+
client.transaction((root) => root.count.set(5));
|
|
1949
|
+
expect(sendTimestamps.length).toBe(1);
|
|
1950
|
+
|
|
1951
|
+
// More draft updates should NOT trigger send
|
|
1952
|
+
draft.update((root) => root.title.set("Draft 2"));
|
|
1953
|
+
expect(sendTimestamps.length).toBe(1);
|
|
1954
|
+
|
|
1955
|
+
// Commit SHOULD trigger send
|
|
1956
|
+
draft.commit();
|
|
1957
|
+
expect(sendTimestamps.length).toBe(2);
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
it("should not send transactions when draft is discarded", async () => {
|
|
1961
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1962
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1963
|
+
await client.connect();
|
|
1964
|
+
|
|
1965
|
+
const draft = client.createDraft();
|
|
1966
|
+
draft.update((root) => root.title.set("Will be discarded"));
|
|
1967
|
+
draft.update((root) => root.count.set(999));
|
|
1968
|
+
|
|
1969
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1970
|
+
|
|
1971
|
+
draft.discard();
|
|
1972
|
+
|
|
1973
|
+
// Still no transactions should be sent
|
|
1974
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1975
|
+
|
|
1976
|
+
// State should revert
|
|
1977
|
+
expect(client.get()?.title).toBe("Hello");
|
|
1978
|
+
expect(client.get()?.count).toBe(0);
|
|
1979
|
+
});
|
|
1881
1980
|
});
|
|
1882
1981
|
});
|