@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.
@@ -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", { draftId, newOpsCount: tx.ops.length, totalOps: draftState.ops.size });
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", { draftId, opsCount: ops.length });
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", { draftId });
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();
@@ -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<PartialFields<TFields>, TRequired, THasDefault> {
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
- // Create a new field that is not required (optional)
200
- partialFields[key] = this._makeFieldOptional(field);
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
- // Create a new primitive with required: false
212
- // We access the _schema property if available, otherwise return as-is
213
- const fieldAny = field as any;
214
- if (fieldAny._schema && typeof fieldAny._schema === "object") {
215
- const Constructor = field.constructor as new (schema: any) => AnyPrimitive;
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
- ...fieldAny._schema,
250
+ ...fieldWithSchema._schema,
218
251
  required: false,
219
252
  });
220
253
  }
221
- return field;
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
- const currentFieldState = currentState[fieldName] as InferState<typeof fieldPrimitive> | undefined;
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
- // Apply the operation to the field
382
- const newFieldState = fieldPrimitive._internal.applyOperation(currentFieldState, fieldOperation);
503
+ // Apply the operation to the field
504
+ const newFieldState = fieldPrimitive._internal.applyOperation(currentFieldState, fieldOperation);
383
505
 
384
- // Build updated state
385
- newState = {
386
- ...currentState,
387
- [fieldName]: newFieldState,
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
-
@@ -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 (typeof result[key] === "object" && result[key] !== null) {
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, result[key] as Partial<InferState<typeof 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
  });