@contember/bindx 0.1.42 → 0.1.44

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 (59) hide show
  1. package/dist/adapter/ContemberAdapter.d.ts.map +1 -1
  2. package/dist/core/ActionDispatcher.d.ts +7 -0
  3. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  4. package/dist/dataview/types.d.ts +6 -1
  5. package/dist/dataview/types.d.ts.map +1 -1
  6. package/dist/handles/BaseHandle.d.ts +9 -18
  7. package/dist/handles/BaseHandle.d.ts.map +1 -1
  8. package/dist/handles/EntityHandle.d.ts +12 -4
  9. package/dist/handles/EntityHandle.d.ts.map +1 -1
  10. package/dist/handles/FieldHandle.d.ts.map +1 -1
  11. package/dist/handles/HasManyListHandle.d.ts +0 -4
  12. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  13. package/dist/handles/HasOneHandle.d.ts +0 -4
  14. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  15. package/dist/handles/PlaceholderHandle.d.ts +18 -0
  16. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  17. package/dist/handles/types.d.ts +10 -0
  18. package/dist/handles/types.d.ts.map +1 -1
  19. package/dist/persistence/BatchPersister.d.ts +8 -3
  20. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  21. package/dist/store/DirtyTracker.d.ts +3 -1
  22. package/dist/store/DirtyTracker.d.ts.map +1 -1
  23. package/dist/store/EntityMetaStore.d.ts +4 -0
  24. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  25. package/dist/store/EntitySnapshotStore.d.ts +14 -0
  26. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  27. package/dist/store/ReachabilityAnalyzer.d.ts +33 -0
  28. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -0
  29. package/dist/store/RelationStore.d.ts +21 -10
  30. package/dist/store/RelationStore.d.ts.map +1 -1
  31. package/dist/store/RootRegistry.d.ts +28 -0
  32. package/dist/store/RootRegistry.d.ts.map +1 -0
  33. package/dist/store/SnapshotStore.d.ts +42 -2
  34. package/dist/store/SnapshotStore.d.ts.map +1 -1
  35. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  36. package/package.json +2 -3
  37. package/src/adapter/ContemberAdapter.ts +2 -6
  38. package/src/core/ActionDispatcher.ts +38 -21
  39. package/src/dataview/filterHandlers.ts +4 -4
  40. package/src/dataview/types.ts +6 -1
  41. package/src/handles/BaseHandle.ts +9 -30
  42. package/src/handles/EntityHandle.ts +35 -30
  43. package/src/handles/FieldHandle.ts +0 -4
  44. package/src/handles/HasManyListHandle.ts +0 -22
  45. package/src/handles/HasOneHandle.ts +0 -19
  46. package/src/handles/PlaceholderHandle.ts +93 -0
  47. package/src/handles/types.ts +10 -0
  48. package/src/persistence/BatchPersister.ts +60 -22
  49. package/src/store/DirtyTracker.ts +9 -1
  50. package/src/store/EntityMetaStore.ts +10 -0
  51. package/src/store/EntitySnapshotStore.ts +32 -0
  52. package/src/store/ReachabilityAnalyzer.ts +92 -0
  53. package/src/store/RelationStore.ts +61 -37
  54. package/src/store/RootRegistry.ts +44 -0
  55. package/src/store/SnapshotStore.ts +91 -30
  56. package/src/store/SubscriptionManager.ts +8 -2
  57. package/dist/core/Disposable.d.ts +0 -57
  58. package/dist/core/Disposable.d.ts.map +0 -1
  59. package/src/core/Disposable.ts +0 -126
@@ -340,7 +340,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
340
340
  * Connects an existing entity to this has-many relation.
341
341
  */
342
342
  connect(itemId: string): void {
343
- this.assertNotDisposed()
344
343
  this.dispatcher.dispatch(
345
344
  connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.itemType, this.alias),
346
345
  )
@@ -352,7 +351,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
352
351
  * For created entities (via add()), cancels the add operation.
353
352
  */
354
353
  disconnect(itemId: string): void {
355
- this.assertNotDisposed()
356
354
  this.dispatcher.dispatch(
357
355
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'disconnect', this.alias),
358
356
  )
@@ -364,7 +362,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
364
362
  * For created entities (via add()), cancels the add operation.
365
363
  */
366
364
  delete(itemId: string): void {
367
- this.assertNotDisposed()
368
365
  this.dispatcher.dispatch(
369
366
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'delete', this.alias),
370
367
  )
@@ -376,7 +373,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
376
373
  * For connecting existing entities, use connect() instead.
377
374
  */
378
375
  add(data?: Partial<TEntity>): string {
379
- this.assertNotDisposed()
380
376
 
381
377
  // Pre-create entity so we can return tempId
382
378
  const tempId = this.store.createEntity(this.itemType, data as Record<string, unknown>)
@@ -399,7 +395,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
399
395
  * Falls back to disconnect when schema metadata is not available.
400
396
  */
401
397
  remove(itemId: string): void {
402
- this.assertNotDisposed()
403
398
  this.dispatcher.dispatch(
404
399
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, this.resolveRemovalType(), this.alias),
405
400
  )
@@ -430,7 +425,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
430
425
  * use an 'order' field on the entity.
431
426
  */
432
427
  move(fromIndex: number, toIndex: number): void {
433
- this.assertNotDisposed()
434
428
  this.dispatcher.dispatch(
435
429
  moveInList(this.entityType, this.entityId, this.fieldName, fromIndex, toIndex, this.alias),
436
430
  )
@@ -441,7 +435,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
441
435
  * Clears all planned connections and removals.
442
436
  */
443
437
  reset(): void {
444
- this.assertNotDisposed()
445
438
  this.store.resetHasMany(
446
439
  this.entityType,
447
440
  this.entityId,
@@ -450,19 +443,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
450
443
  )
451
444
  }
452
445
 
453
- /**
454
- * Disposes the handle.
455
- */
456
- override dispose(): void {
457
- super.dispose()
458
-
459
- for (const handle of this.itemHandleCacheRaw.values()) {
460
- handle.dispose()
461
- }
462
- this.itemHandleCacheRaw.clear()
463
- this.itemHandleCacheProxy.clear()
464
- }
465
-
466
446
  /**
467
447
  * Type brand - ensures HasManyRef<Author> is not assignable to HasManyRef<Tag>.
468
448
  * This is a phantom property that only exists in the type system.
@@ -489,7 +469,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
489
469
  * Adds a client-side error to this relation.
490
470
  */
491
471
  addError(error: ErrorInput): void {
492
- this.assertNotDisposed()
493
472
  this.dispatcher.dispatch(
494
473
  addRelationError(this.entityType, this.entityId, this.fieldName, createClientError(error)),
495
474
  )
@@ -499,7 +478,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
499
478
  * Clears all errors from this relation.
500
479
  */
501
480
  clearErrors(): void {
502
- this.assertNotDisposed()
503
481
  this.dispatcher.dispatch(
504
482
  clearRelationErrors(this.entityType, this.entityId, this.fieldName),
505
483
  )
@@ -374,7 +374,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
374
374
  * Accessible via proxy as `$create()`.
375
375
  */
376
376
  create(data?: Partial<TEntity>): string {
377
- this.assertNotDisposed()
378
377
  const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
379
378
  this.dispatcher.dispatch(
380
379
  connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
@@ -386,7 +385,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
386
385
  * Connects the relation to an entity.
387
386
  */
388
387
  connect(targetId: string): void {
389
- this.assertNotDisposed()
390
388
  this.dispatcher.dispatch(
391
389
  connectRelation(this.entityType, this.entityId, this.fieldName, targetId, this.targetType),
392
390
  )
@@ -397,7 +395,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
397
395
  * Sets the FK to null — only works when the FK column is nullable.
398
396
  */
399
397
  disconnect(): void {
400
- this.assertNotDisposed()
401
398
  this.dispatcher.dispatch(
402
399
  disconnectRelation(this.entityType, this.entityId, this.fieldName),
403
400
  )
@@ -407,7 +404,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
407
404
  * Marks the related entity for deletion.
408
405
  */
409
406
  delete(): void {
410
- this.assertNotDisposed()
411
407
  this.dispatcher.dispatch(
412
408
  deleteRelation(this.entityType, this.entityId, this.fieldName),
413
409
  )
@@ -432,22 +428,9 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
432
428
  * Resets the relation to server state.
433
429
  */
434
430
  reset(): void {
435
- this.assertNotDisposed()
436
431
  this.store.resetRelation(this.entityType, this.entityId, this.fieldName)
437
432
  }
438
433
 
439
- /**
440
- * Disposes the handle.
441
- */
442
- override dispose(): void {
443
- super.dispose()
444
- this.entityHandleCacheRaw?.dispose()
445
- this.entityHandleCacheRaw = null
446
- this.entityHandleCacheProxy = null
447
- this.placeholderCacheRaw = null
448
- this.placeholderCacheProxy = null
449
- }
450
-
451
434
  /**
452
435
  * Type brand - ensures HasOneRef<Author> is not assignable to HasOneRef<Tag>.
453
436
  * This is a phantom property that only exists in the type system.
@@ -474,7 +457,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
474
457
  * Adds a client-side error to this relation.
475
458
  */
476
459
  addError(error: ErrorInput): void {
477
- this.assertNotDisposed()
478
460
  this.dispatcher.dispatch(
479
461
  addRelationError(this.entityType, this.entityId, this.fieldName, createClientError(error)),
480
462
  )
@@ -484,7 +466,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
484
466
  * Clears all errors from this relation.
485
467
  */
486
468
  clearErrors(): void {
487
- this.assertNotDisposed()
488
469
  this.dispatcher.dispatch(
489
470
  clearRelationErrors(this.entityType, this.entityId, this.fieldName),
490
471
  )
@@ -187,6 +187,9 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
187
187
  * Creates a field handle for placeholder data.
188
188
  * For has-many relations, returns a real (empty) has-many handle bound to this
189
189
  * placeholder entity, so it behaves identically to a connected one.
190
+ * For has-one relations, returns a nested placeholder has-one handle so chains like
191
+ * `<HasOne field={parentPlaceholder.profile}>` materialize an inner placeholder
192
+ * rather than handing `undefined` to the children callback.
190
193
  */
191
194
  private createPlaceholderFieldHandle(fieldName: string): unknown {
192
195
  // For has-many relations, return a real HasManyListHandle bound to this placeholder's
@@ -227,6 +230,14 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
227
230
  })
228
231
  }
229
232
 
233
+ // For has-one relations, return a placeholder-of-placeholder has-one handle
234
+ if (this.schema?.isHasOne(this.targetType, fieldName)) {
235
+ const innerTargetType = this.schema.getRelationTarget(this.targetType, fieldName)
236
+ if (innerTargetType) {
237
+ return this.createPlaceholderHasOneFieldHandle(fieldName, innerTargetType)
238
+ }
239
+ }
240
+
230
241
  const self = this
231
242
 
232
243
  return {
@@ -320,6 +331,88 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
320
331
  }
321
332
  }
322
333
 
334
+ /**
335
+ * Creates a nested placeholder has-one handle.
336
+ *
337
+ * Used when a placeholder entity (the outer placeholder) is asked for one of its
338
+ * has-one relations — there is no real parent row, so the inner relation is also
339
+ * a placeholder. The returned handle exposes the HasOne-shaped surface that
340
+ * `<HasOne>` JSX relies on (FIELD_REF_META, $entity, $state) plus field-access
341
+ * proxying so chains like `parentPlaceholder.profile.bio.value` resolve to `null`
342
+ * instead of throwing.
343
+ *
344
+ * Mutations (`$connect`, `$create`) are not supported on nested placeholders —
345
+ * materialize the outer placeholder first (e.g. by writing to a scalar field or
346
+ * connecting the outer relation).
347
+ */
348
+ private createPlaceholderHasOneFieldHandle(fieldName: string, innerTargetType: string): unknown {
349
+ const self = this
350
+ const nestedPlaceholderRaw = PlaceholderHandle.createRaw(
351
+ this.targetType,
352
+ this.placeholderId,
353
+ fieldName,
354
+ innerTargetType,
355
+ this.store,
356
+ this.dispatcher,
357
+ this.schema,
358
+ this.__brands,
359
+ )
360
+ const nestedPlaceholderProxy = PlaceholderHandle.wrapProxy(nestedPlaceholderRaw)
361
+ const fieldRefMeta: FieldRefMeta = {
362
+ entityType: self.targetType,
363
+ entityId: self.placeholderId,
364
+ path: [fieldName],
365
+ fieldName,
366
+ isArray: false,
367
+ isRelation: true,
368
+ targetType: innerTargetType,
369
+ }
370
+ const noopUnsubscribe: Unsubscribe = () => {}
371
+
372
+ const handleLike = {
373
+ get [FIELD_REF_META](): FieldRefMeta { return fieldRefMeta },
374
+ get id(): string { return nestedPlaceholderRaw.id },
375
+ get state(): 'disconnected' { return 'disconnected' },
376
+ get isConnected(): boolean { return false },
377
+ get isNew(): boolean { return true },
378
+ get isPersisting(): boolean { return false },
379
+ get isDirty(): boolean { return false },
380
+ get persistedId(): null { return null },
381
+ get data(): null { return null },
382
+ get entity(): EntityAccessor<object> { return nestedPlaceholderProxy },
383
+ get fields() { return nestedPlaceholderRaw.fields },
384
+ get errors(): readonly FieldError[] { return [] },
385
+ get hasError(): boolean { return false },
386
+ get __entityName(): string { return innerTargetType },
387
+ get __entityType(): unknown { return undefined },
388
+ get __brands(): Set<symbol> | undefined { return self.__brands },
389
+ create(): string {
390
+ throw new Error(`Cannot $create on nested placeholder has-one "${fieldName}" — materialize the outer placeholder first`)
391
+ },
392
+ connect(): void {
393
+ throw new Error(`Cannot $connect on nested placeholder has-one "${fieldName}" — materialize the outer placeholder first`)
394
+ },
395
+ disconnect(): void {},
396
+ delete(): void {},
397
+ remove(): void {},
398
+ reset(): void {},
399
+ addError(_error: ErrorInput): void {},
400
+ clearErrors(): void {},
401
+ clearAllErrors(): void {},
402
+ onConnect(_listener: EventListener<EventTypeMap['relation:connected']>): Unsubscribe { return noopUnsubscribe },
403
+ onDisconnect(_listener: EventListener<EventTypeMap['relation:disconnected']>): Unsubscribe { return noopUnsubscribe },
404
+ interceptConnect(_interceptor: Interceptor<EventTypeMap['relation:connecting']>): Unsubscribe { return noopUnsubscribe },
405
+ interceptDisconnect(_interceptor: Interceptor<EventTypeMap['relation:disconnecting']>): Unsubscribe { return noopUnsubscribe },
406
+ on<E extends AfterEventTypes>(_eventType: E, _listener: EventListener<EventTypeMap[E]>): Unsubscribe { return noopUnsubscribe },
407
+ intercept<E extends BeforeEventTypes>(_eventType: E, _interceptor: Interceptor<EventTypeMap[E]>): Unsubscribe { return noopUnsubscribe },
408
+ onPersisted(_listener: EventListener<EntityPersistedEvent>): Unsubscribe { return noopUnsubscribe },
409
+ interceptPersisting(_interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe { return noopUnsubscribe },
410
+ subscribe(_callback: () => void): Unsubscribe { return noopUnsubscribe },
411
+ }
412
+
413
+ return createHandleProxy(handleLike, target => target.fields)
414
+ }
415
+
323
416
  /**
324
417
  * Type brand for EntityRef compatibility.
325
418
  */
@@ -98,6 +98,16 @@ export interface FieldRefMeta<TEntityName extends string = string> {
98
98
  readonly enumName?: string
99
99
  readonly columnType?: string
100
100
  readonly targetType?: string
101
+ /**
102
+ * Absolute dot-path of segments from the root entity to this field.
103
+ * For a top-level field this is `[fieldName]`; for a field reached through
104
+ * relations (e.g. `it.author.name`) it is the full chain
105
+ * (`['author', 'name']`). `fieldName` / `path` remain the *last* segment
106
+ * (relative to the field's own scope) so selection/relation metadata is
107
+ * unaffected — consumers that need the full dotted path (DataGrid column
108
+ * filter/sort/value extraction) read `fullPath`.
109
+ */
110
+ readonly fullPath?: readonly string[]
101
111
  }
102
112
 
103
113
  export interface InputProps<T> {
@@ -222,6 +222,7 @@ export class BatchPersister {
222
222
  capturedStates = this.captureEntityStates(sortedEntities)
223
223
  }
224
224
 
225
+ let result: PersistenceResult | undefined
225
226
  try {
226
227
  // Build mutations BEFORE resetting state (for pessimistic mode)
227
228
  // The mutations need to contain the dirty data to send to server
@@ -229,13 +230,14 @@ export class BatchPersister {
229
230
 
230
231
  if (mutations.length === 0) {
231
232
  // Nothing to persist
232
- return {
233
+ result = {
233
234
  success: true,
234
235
  results: [],
235
236
  successCount: 0,
236
237
  failedCount: 0,
237
238
  skippedCount: 0,
238
239
  }
240
+ return result
239
241
  }
240
242
 
241
243
  // For pessimistic mode, reset entities to server state after capturing
@@ -254,7 +256,8 @@ export class BatchPersister {
254
256
  const transactionResult = await this.executeTransaction(mutations, options?.signal)
255
257
 
256
258
  // Process results with captured state for pessimistic mode
257
- return this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates)
259
+ result = this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates)
260
+ return result
258
261
 
259
262
  } finally {
260
263
  // Clear in-flight status
@@ -267,6 +270,14 @@ export class BatchPersister {
267
270
 
268
271
  // Unblock undo
269
272
  this.undoManager?.unblock()
273
+
274
+ // Reclaim memory: drop snapshots of any created entities orphaned during
275
+ // editing. Only after a FULLY successful persist — on failure the captured
276
+ // creates/edits were restored for retry (see processTransactionResult), and
277
+ // sweeping then could reclaim a create the user still intends to save.
278
+ if (result?.success) {
279
+ this.store.sweepUnreachableCreated()
280
+ }
270
281
  }
271
282
  }
272
283
 
@@ -686,7 +697,7 @@ export class BatchPersister {
686
697
  s => s.entityType === entity.entityType && s.entityId === entity.entityId,
687
698
  )
688
699
  if (capturedState) {
689
- this.restoreAndCommitEntityState(capturedState)
700
+ this.restoreEntityState(capturedState, true)
690
701
  }
691
702
  } else {
692
703
  // Commit based on scope
@@ -747,9 +758,10 @@ export class BatchPersister {
747
758
  mutationResult.errorMessage,
748
759
  )
749
760
 
750
- // Rollback optimistic changes if enabled (and not in pessimistic mode)
751
- // In pessimistic mode, we're already at server state, no rollback needed
752
- if (rollbackOnError && !isPessimistic) {
761
+ // Optimistic mode rolls a FAILED entity back to server state only when
762
+ // rollbackOnError is set. Pessimistic restore is handled below for ALL
763
+ // captured entities, not just the failed ones.
764
+ if (!isPessimistic && rollbackOnError) {
753
765
  this.rollbackEntity(entity)
754
766
  }
755
767
  }
@@ -773,6 +785,20 @@ export class BatchPersister {
773
785
  }
774
786
  }
775
787
  }
788
+
789
+ // Pessimistic mode: the transaction failed, so from the user's point of view
790
+ // nothing was committed. Undo the pre-transaction server-view reset for EVERY
791
+ // captured entity (not only the ones whose individual mutation failed) and keep
792
+ // the restored data dirty (commit=false) so the user can retry. Restoring even
793
+ // the entities whose mutation happened to succeed in the non-atomic sequential
794
+ // fallback is what re-establishes their relation edges to inline-created
795
+ // children — keeping those creates reachable so the post-persist sweep does not
796
+ // reclaim a child the user still intends to save (P2).
797
+ if (isPessimistic && capturedStates) {
798
+ for (const capturedState of capturedStates) {
799
+ this.restoreEntityState(capturedState, false)
800
+ }
801
+ }
776
802
  }
777
803
 
778
804
  return {
@@ -805,46 +831,58 @@ export class BatchPersister {
805
831
  }
806
832
 
807
833
  /**
808
- * Restores a captured entity state and commits it.
809
- * Used in pessimistic mode after successful server confirmation.
834
+ * Restores a captured entity state (pessimistic mode), optionally committing it.
835
+ *
836
+ * - commit=true (server confirmed): restore the dirty data and commit it as the
837
+ * new server baseline.
838
+ * - commit=false (server rejected): restore the dirty data but leave it dirty, so
839
+ * the user's in-flight edits and creates survive for a retry (P2) instead of
840
+ * being stuck at the server view the pessimistic reset left behind.
810
841
  */
811
- private restoreAndCommitEntityState(capturedState: CapturedEntityState): void {
842
+ private restoreEntityState(capturedState: CapturedEntityState, commit: boolean): void {
812
843
  if (capturedState.snapshot) {
813
- // Restore the entity data from captured snapshot
814
844
  this.store.setEntityData(
815
845
  capturedState.entityType,
816
846
  capturedState.entityId,
817
847
  capturedState.snapshot.data as Record<string, unknown>,
818
- true, // Mark as server data since it's now confirmed
848
+ commit, // server data only when confirmed; otherwise keep it as a dirty edit
819
849
  )
820
850
  }
821
851
 
822
- // Restore and commit relations
823
852
  for (const [relationKey, relationState] of capturedState.relations) {
824
853
  const fieldName = relationKey.split(':')[2]
825
854
  if (fieldName) {
826
855
  this.store.setRelation(capturedState.entityType, capturedState.entityId, fieldName, {
827
856
  currentId: relationState.currentId,
828
857
  state: relationState.state,
858
+ // Restore placeholderData too: a has-one in the 'creating' state carries
859
+ // its inline create payload here, and getDirtyRelations treats a
860
+ // non-empty placeholderData as a dirty signal. Dropping it on a
861
+ // commit=false retry would lose the inline data and silently mark the
862
+ // relation clean. (For commit=true it is cleared by commitRelation below.)
863
+ placeholderData: relationState.placeholderData,
829
864
  })
830
- this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName)
865
+ if (commit) {
866
+ this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName)
867
+ }
831
868
  }
832
869
  }
833
870
 
834
- // Restore and commit has-many states
835
871
  for (const [hasManyKey, hasManyState] of capturedState.hasManyStates) {
836
872
  const fieldName = hasManyKey.split(':')[2]
837
873
  if (fieldName) {
838
874
  this.store.restoreHasManyState(capturedState.entityType, capturedState.entityId, fieldName, hasManyState)
839
- // Compute new server IDs: serverIds - removals + connections
840
- const newServerIds = new Set(hasManyState.serverIds)
841
- for (const removedId of hasManyState.plannedRemovals.keys()) {
842
- newServerIds.delete(removedId)
843
- }
844
- for (const connectedId of hasManyState.plannedConnections) {
845
- newServerIds.add(connectedId)
875
+ if (commit) {
876
+ // Compute new server IDs: serverIds - removals + connections
877
+ const newServerIds = new Set(hasManyState.serverIds)
878
+ for (const removedId of hasManyState.plannedRemovals.keys()) {
879
+ newServerIds.delete(removedId)
880
+ }
881
+ for (const connectedId of hasManyState.plannedConnections) {
882
+ newServerIds.add(connectedId)
883
+ }
884
+ this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds))
846
885
  }
847
- this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds))
848
886
  }
849
887
  }
850
888
  }
@@ -1,6 +1,7 @@
1
1
  import type { EntitySnapshotStore } from './EntitySnapshotStore.js'
2
2
  import type { EntityMetaStore } from './EntityMetaStore.js'
3
3
  import type { RelationStore } from './RelationStore.js'
4
+ import type { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js'
4
5
  import { deepEqual } from '../utils/deepEqual.js'
5
6
 
6
7
  interface DirtyEntity {
@@ -20,10 +21,15 @@ export class DirtyTracker {
20
21
  private readonly entitySnapshots: EntitySnapshotStore,
21
22
  private readonly meta: EntityMetaStore,
22
23
  private readonly relations: RelationStore,
24
+ private readonly reachability: ReachabilityAnalyzer,
23
25
  ) {}
24
26
 
25
27
  getAllDirtyEntities(): DirtyEntity[] {
26
28
  const dirtyEntities: DirtyEntity[] = []
29
+ // A created entity is a `create` only when reachable from a root through
30
+ // live relations. A created entity detached from every relation is
31
+ // unreachable and must not be reported, even if its snapshot still lingers.
32
+ const reachableCreated = this.reachability.computeReachableCreated()
27
33
 
28
34
  for (const key of this.entitySnapshots.keys()) {
29
35
  const [entityType, ...idParts] = key.split(':')
@@ -41,7 +47,9 @@ export class DirtyTracker {
41
47
  }
42
48
 
43
49
  if (!this.meta.existsOnServer(entityKey)) {
44
- dirtyEntities.push({ entityType, entityId, changeType: 'create' })
50
+ if (reachableCreated.has(entityKey)) {
51
+ dirtyEntities.push({ entityType, entityId, changeType: 'create' })
52
+ }
45
53
  continue
46
54
  }
47
55
 
@@ -115,6 +115,16 @@ export class EntityMetaStore {
115
115
  return !this.tempToPersistedId.has(key)
116
116
  }
117
117
 
118
+ /**
119
+ * Removes all metadata for an entity (load state, meta, persisting, temp mapping).
120
+ */
121
+ remove(key: string): void {
122
+ this.loadStates.delete(key)
123
+ this.entityMetas.delete(key)
124
+ this.persistingEntities.delete(key)
125
+ this.tempToPersistedId.delete(key)
126
+ }
127
+
118
128
  /**
119
129
  * Moves all metadata from oldKey to newKey.
120
130
  */
@@ -15,6 +15,14 @@ import {
15
15
  export class EntitySnapshotStore {
16
16
  private readonly snapshots = new Map<string, EntitySnapshot>()
17
17
 
18
+ /**
19
+ * Index from entity id to its composite key, so reachability walks can resolve
20
+ * a relation's child id (which carries no type) to a snapshot in O(1). Ids are
21
+ * globally unique (temp ids are minted unique; server ids are UUIDs), so a
22
+ * single key per id is unambiguous.
23
+ */
24
+ private readonly idIndex = new Map<string, string>()
25
+
18
26
  get(key: string): EntitySnapshot | undefined {
19
27
  return this.snapshots.get(key)
20
28
  }
@@ -53,6 +61,7 @@ export class EntitySnapshotStore {
53
61
  )
54
62
 
55
63
  this.snapshots.set(key, newSnapshot)
64
+ this.idIndex.set(id, key)
56
65
  return newSnapshot
57
66
  }
58
67
 
@@ -191,6 +200,15 @@ export class EntitySnapshotStore {
191
200
  * Removes an entity snapshot.
192
201
  */
193
202
  remove(key: string): void {
203
+ const existing = this.snapshots.get(key)
204
+ // Only drop the id→key entry when it still points at THIS key. Ids are
205
+ // globally unique by construction (server UUIDs / minted temp ids), but if a
206
+ // caller-supplied id ever collided across types, last-writer-wins means
207
+ // another snapshot now owns the entry — removing this one must not strand
208
+ // that survivor as id-unresolvable.
209
+ if (existing && this.idIndex.get(existing.id) === key) {
210
+ this.idIndex.delete(existing.id)
211
+ }
194
212
  this.snapshots.delete(key)
195
213
  }
196
214
 
@@ -263,6 +281,7 @@ export class EntitySnapshotStore {
263
281
  const keys = new Set<string>()
264
282
  for (const [key, snapshot] of snapshots) {
265
283
  this.snapshots.set(key, snapshot)
284
+ this.idIndex.set(snapshot.id, key)
266
285
  keys.add(key)
267
286
  }
268
287
  return keys
@@ -289,14 +308,27 @@ export class EntitySnapshotStore {
289
308
 
290
309
  this.snapshots.delete(oldKey)
291
310
  this.snapshots.set(newKey, newSnapshot)
311
+ this.idIndex.delete(snapshot.id)
312
+ this.idIndex.set(newId, newKey)
292
313
  }
293
314
 
294
315
  keys(): IterableIterator<string> {
295
316
  return this.snapshots.keys()
296
317
  }
297
318
 
319
+ /**
320
+ * Resolves an entity id (without its type) to its composite key in O(1).
321
+ * Relation state records only child ids, not their types, so this is how a
322
+ * reachability walk (and {@link SnapshotStore.isNeverPersisted}) maps a child
323
+ * id back to its snapshot. Ids are globally unique, so the mapping is unambiguous.
324
+ */
325
+ keyForId(id: string): string | undefined {
326
+ return this.idIndex.get(id)
327
+ }
328
+
298
329
  clear(): void {
299
330
  this.snapshots.clear()
331
+ this.idIndex.clear()
300
332
  }
301
333
  }
302
334
 
@@ -0,0 +1,92 @@
1
+ import type { EntitySnapshotStore } from './EntitySnapshotStore.js'
2
+ import type { EntityMetaStore } from './EntityMetaStore.js'
3
+ import type { RelationStore } from './RelationStore.js'
4
+ import type { RootRegistry } from './RootRegistry.js'
5
+
6
+ /**
7
+ * Computes which never-persisted (created) entities are "live" — i.e. reachable
8
+ * from a root through live relations.
9
+ *
10
+ * This is the single invariant behind create detection: a created entity is a
11
+ * `create` to be sent to the server iff it is reachable from a root. Roots are
12
+ * server entities plus explicitly-registered top-level creates (see
13
+ * {@link RootRegistry}). A created entity that has been detached from every
14
+ * relation is simply unreachable and produces no mutation — no eager purge of
15
+ * its snapshot is required for correctness; memory cleanup is a separate
16
+ * concern handled by a lazy sweep.
17
+ *
18
+ * The graph is read from {@link RelationStore} (the source of truth for live
19
+ * relation membership), never from the subscription parent-child registry,
20
+ * which is append-mostly for notifications and does not reflect disconnects.
21
+ */
22
+ export class ReachabilityAnalyzer {
23
+ constructor(
24
+ private readonly entitySnapshots: EntitySnapshotStore,
25
+ private readonly meta: EntityMetaStore,
26
+ private readonly relations: RelationStore,
27
+ private readonly roots: RootRegistry,
28
+ ) {}
29
+
30
+ /**
31
+ * Returns the set of entity keys ("entityType:id") for created
32
+ * (never-persisted) entities reachable from a root through live relations.
33
+ */
34
+ computeReachableCreated(): Set<string> {
35
+ const reachableCreated = new Set<string>()
36
+ const visited = new Set<string>()
37
+ const stack: string[] = []
38
+
39
+ // Seed roots and, in the same pass, detect whether any never-persisted
40
+ // (created) snapshot exists at all. Every server entity is a root, and so is
41
+ // every in-flight (persisting) entity — the latter keeps a created entity
42
+ // live while it is mid-persist (e.g. pessimistic mode temporarily resets an
43
+ // update parent to its server view, which would otherwise make a freshly
44
+ // created child look unreachable during the async transaction window).
45
+ let hasCreated = false
46
+ for (const key of this.entitySnapshots.keys()) {
47
+ const onServer = this.meta.existsOnServer(key)
48
+ if (!onServer) hasCreated = true
49
+ if (onServer || this.meta.isPersisting(key)) {
50
+ stack.push(key)
51
+ }
52
+ }
53
+
54
+ // Fast path: with no created snapshot anywhere, nothing can be a reachable
55
+ // create. Skip the O(V·(R+H)) graph walk entirely — the common update-only
56
+ // form/grid pays only the single existsOnServer scan above (which the seed
57
+ // loop performs regardless) instead of walking every server entity's edges
58
+ // on every dirty check (this runs on each store notification, ≈ keystroke).
59
+ if (!hasCreated) return reachableCreated
60
+
61
+ // Seed: explicitly-registered top-level creates (no persisted parent).
62
+ for (const key of this.roots.keys()) {
63
+ if (this.entitySnapshots.has(key)) {
64
+ stack.push(key)
65
+ }
66
+ }
67
+
68
+ while (stack.length > 0) {
69
+ const key = stack.pop()!
70
+ if (visited.has(key)) continue
71
+ visited.add(key)
72
+
73
+ // A created entity reached from a root is a live create. Server roots
74
+ // are not creates; they fall through to update/delete detection.
75
+ if (!this.meta.existsOnServer(key)) {
76
+ reachableCreated.add(key)
77
+ }
78
+
79
+ // Walk through this entity's live child edges. The traversal continues
80
+ // through created entities too, so a created chain (created child of a
81
+ // created child of a root) is fully covered.
82
+ for (const childId of this.relations.getLiveChildIds(`${key}:`)) {
83
+ const childKey = this.entitySnapshots.keyForId(childId)
84
+ if (childKey && !visited.has(childKey)) {
85
+ stack.push(childKey)
86
+ }
87
+ }
88
+ }
89
+
90
+ return reachableCreated
91
+ }
92
+ }