@contember/bindx 0.1.43 → 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.
- package/dist/core/ActionDispatcher.d.ts +7 -0
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/dataview/types.d.ts +6 -1
- package/dist/dataview/types.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +9 -18
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +12 -4
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +0 -4
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +0 -4
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +18 -0
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/types.d.ts +10 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +8 -3
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/store/DirtyTracker.d.ts +3 -1
- package/dist/store/DirtyTracker.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +4 -0
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +14 -0
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ReachabilityAnalyzer.d.ts +33 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +21 -10
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +28 -0
- package/dist/store/RootRegistry.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +42 -2
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +38 -21
- package/src/dataview/filterHandlers.ts +4 -4
- package/src/dataview/types.ts +6 -1
- package/src/handles/BaseHandle.ts +9 -30
- package/src/handles/EntityHandle.ts +35 -30
- package/src/handles/FieldHandle.ts +0 -4
- package/src/handles/HasManyListHandle.ts +0 -22
- package/src/handles/HasOneHandle.ts +0 -19
- package/src/handles/PlaceholderHandle.ts +93 -0
- package/src/handles/types.ts +10 -0
- package/src/persistence/BatchPersister.ts +60 -22
- package/src/store/DirtyTracker.ts +9 -1
- package/src/store/EntityMetaStore.ts +10 -0
- package/src/store/EntitySnapshotStore.ts +32 -0
- package/src/store/ReachabilityAnalyzer.ts +92 -0
- package/src/store/RelationStore.ts +61 -37
- package/src/store/RootRegistry.ts +44 -0
- package/src/store/SnapshotStore.ts +91 -30
- package/src/store/SubscriptionManager.ts +8 -2
- package/dist/core/Disposable.d.ts +0 -57
- package/dist/core/Disposable.d.ts.map +0 -1
- package/src/core/Disposable.ts +0 -126
|
@@ -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
|
*/
|
package/src/handles/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
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
|
|
809
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
+
}
|