@contember/bindx 0.1.49 → 0.1.51

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 (50) hide show
  1. package/dist/core/actions.d.ts +3 -3
  2. package/dist/core/actions.d.ts.map +1 -1
  3. package/dist/errors/index.d.ts +1 -1
  4. package/dist/errors/index.d.ts.map +1 -1
  5. package/dist/errors/types.d.ts +11 -0
  6. package/dist/errors/types.d.ts.map +1 -1
  7. package/dist/handles/FieldHandle.d.ts +3 -3
  8. package/dist/handles/FieldHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +1 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/types.d.ts +9 -8
  12. package/dist/handles/types.d.ts.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/persistence/BatchPersister.d.ts +6 -0
  16. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  17. package/dist/store/ErrorStore.d.ts +3 -3
  18. package/dist/store/ErrorStore.d.ts.map +1 -1
  19. package/dist/store/HasManyStore.d.ts +10 -0
  20. package/dist/store/HasManyStore.d.ts.map +1 -1
  21. package/dist/store/HasOneStore.d.ts +10 -0
  22. package/dist/store/HasOneStore.d.ts.map +1 -1
  23. package/dist/store/PlannedDeleteIndex.d.ts +19 -0
  24. package/dist/store/PlannedDeleteIndex.d.ts.map +1 -0
  25. package/dist/store/RekeyOrchestrator.d.ts +2 -0
  26. package/dist/store/RekeyOrchestrator.d.ts.map +1 -1
  27. package/dist/store/RelationOwnerIndex.d.ts +8 -0
  28. package/dist/store/RelationOwnerIndex.d.ts.map +1 -0
  29. package/dist/store/RelationStore.d.ts +7 -0
  30. package/dist/store/RelationStore.d.ts.map +1 -1
  31. package/dist/store/SnapshotStore.d.ts +10 -2
  32. package/dist/store/SnapshotStore.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/core/ActionDispatcher.ts +1 -1
  35. package/src/core/actions.ts +4 -4
  36. package/src/errors/index.ts +2 -0
  37. package/src/errors/types.ts +17 -0
  38. package/src/handles/FieldHandle.ts +4 -4
  39. package/src/handles/HasManyListHandle.ts +5 -0
  40. package/src/handles/types.ts +18 -15
  41. package/src/index.ts +2 -0
  42. package/src/persistence/BatchPersister.ts +48 -0
  43. package/src/store/ErrorStore.ts +15 -12
  44. package/src/store/HasManyStore.ts +39 -3
  45. package/src/store/HasOneStore.ts +41 -3
  46. package/src/store/PlannedDeleteIndex.ts +33 -0
  47. package/src/store/RekeyOrchestrator.ts +7 -0
  48. package/src/store/RelationOwnerIndex.ts +31 -0
  49. package/src/store/RelationStore.ts +10 -0
  50. package/src/store/SnapshotStore.ts +17 -3
@@ -1,7 +1,9 @@
1
1
  import type { HasOneRelationState } from '../handles/types.js'
2
2
  import type { EntitySnapshot } from './snapshots.js'
3
3
  import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
4
+ import { PlannedDeleteIndex } from './PlannedDeleteIndex.js'
4
5
  import { RelationEdgeIndex } from './RelationEdgeIndex.js'
6
+ import { RelationOwnerIndex } from './RelationOwnerIndex.js'
5
7
 
6
8
  /**
7
9
  * Relation state stored in SnapshotStore
@@ -40,6 +42,7 @@ function cloneRelationState(state: StoredRelationState): StoredRelationState {
40
42
  export class HasOneStore {
41
43
  /** Relation states keyed by "parentType:parentId:fieldName" */
42
44
  private readonly relationStates = new Map<string, StoredRelationState>()
45
+ private readonly owners = new RelationOwnerIndex()
43
46
 
44
47
  /**
45
48
  * Bidirectional live-edge index, maintained by {@link writeRelation} /
@@ -48,6 +51,12 @@ export class HasOneStore {
48
51
  */
49
52
  private readonly edges = new RelationEdgeIndex()
50
53
 
54
+ /**
55
+ * Targets this store plans to delete through their parent's mutation, maintained
56
+ * by {@link writeRelation} / {@link deleteRelation}, like {@link edges}.
57
+ */
58
+ private readonly plannedDeletes = new PlannedDeleteIndex()
59
+
51
60
  private mutationVersion = 0
52
61
 
53
62
  /**
@@ -73,14 +82,17 @@ export class HasOneStore {
73
82
  * tracking the reverse direction itself.
74
83
  */
75
84
  private writeRelation(key: string, state: StoredRelationState): void {
76
- const oldChild = liveHasOneChildId(this.relationStates.get(key))
85
+ const previous = this.relationStates.get(key)
86
+ const oldChild = liveHasOneChildId(previous)
77
87
  const newChild = liveHasOneChildId(state)
78
88
  this.relationStates.set(key, state)
89
+ if (!previous) this.owners.add(key)
79
90
  if (oldChild !== newChild) {
80
91
  const parentKey = parentKeyFromRelationKey(key)
81
92
  if (oldChild !== null) this.edges.removeEdge(parentKey, oldChild)
82
93
  if (newChild !== null) this.edges.addEdge(parentKey, newChild)
83
94
  }
95
+ this.reconcilePlannedDelete(plannedDeleteChildId(previous), plannedDeleteChildId(state))
84
96
  this.mutationVersion++
85
97
  }
86
98
 
@@ -94,9 +106,23 @@ export class HasOneStore {
94
106
  const child = liveHasOneChildId(existing)
95
107
  if (child !== null) this.edges.removeEdge(parentKeyFromRelationKey(key), child)
96
108
  this.relationStates.delete(key)
109
+ this.owners.delete(key)
110
+ this.reconcilePlannedDelete(plannedDeleteChildId(existing), null)
97
111
  this.mutationVersion++
98
112
  }
99
113
 
114
+ /** Applies one relation's planned-delete diff to the refcounted index. */
115
+ private reconcilePlannedDelete(previous: string | null, next: string | null): void {
116
+ if (previous === next) return
117
+ if (previous !== null) this.plannedDeletes.release(previous)
118
+ if (next !== null) this.plannedDeletes.retain(next)
119
+ }
120
+
121
+ /** Whether this store plans to delete {@link childId} through its parent. */
122
+ isPlannedForDelete(childId: string): boolean {
123
+ return this.plannedDeletes.has(childId)
124
+ }
125
+
100
126
  /**
101
127
  * Gets or creates relation state.
102
128
  */
@@ -289,8 +315,8 @@ export class HasOneStore {
289
315
  * Collects the field names of dirty has-one relations for an entity.
290
316
  */
291
317
  collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void {
292
- for (const [key, state] of this.relationStates) {
293
- if (!key.startsWith(keyPrefix)) continue
318
+ for (const key of this.owners.get(parentKeyFromOwnerPrefix(keyPrefix))) {
319
+ const state = this.relationStates.get(key)!
294
320
  const fieldName = key.slice(keyPrefix.length)
295
321
 
296
322
  if (
@@ -375,7 +401,9 @@ export class HasOneStore {
375
401
  */
376
402
  clear(): void {
377
403
  this.relationStates.clear()
404
+ this.owners.clear()
378
405
  this.edges.clear()
406
+ this.plannedDeletes.clear()
379
407
  this.mutationVersion++
380
408
  }
381
409
  }
@@ -390,6 +418,16 @@ function liveHasOneChildId(state: StoredRelationState | undefined): string | nul
390
418
  return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null
391
419
  }
392
420
 
421
+ /**
422
+ * The id the parent's `{ delete: true }` removes — `serverId`, not `currentId`,
423
+ * because that is the row MutationCollector deletes for a `deleted` relation, and a
424
+ * null serverId emits no delete at all.
425
+ */
426
+ function plannedDeleteChildId(state: StoredRelationState | undefined): string | null {
427
+ if (!state || state.state !== 'deleted') return null
428
+ return state.serverId
429
+ }
430
+
393
431
  interface HasOneReconciliation {
394
432
  state: StoredRelationState
395
433
  result: ReconciliationResult
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Refcounted multiset of entity ids whose row a parent relation plans to delete
3
+ * through its own mutation ({@link HasManyStore} `plannedRemovals` of type `delete`,
4
+ * {@link HasOneStore} state `deleted`).
5
+ *
6
+ * Refcounted because more than one relation may plan the same child's deletion, so
7
+ * the id stays planned until the last of them drops it. Like {@link RelationEdgeIndex},
8
+ * the index knows nothing about the rules: turning relation state into a planned-delete
9
+ * id is the owning sub-store's job, done once per write in its chokepoint by diffing
10
+ * the previous state against the next.
11
+ */
12
+ export class PlannedDeleteIndex {
13
+ private readonly counts = new Map<string, number>()
14
+
15
+ retain(childId: string): void {
16
+ this.counts.set(childId, (this.counts.get(childId) ?? 0) + 1)
17
+ }
18
+
19
+ release(childId: string): void {
20
+ const count = this.counts.get(childId)
21
+ if (count === undefined) return
22
+ if (count > 1) this.counts.set(childId, count - 1)
23
+ else this.counts.delete(childId)
24
+ }
25
+
26
+ has(childId: string): boolean {
27
+ return this.counts.has(childId)
28
+ }
29
+
30
+ clear(): void {
31
+ this.counts.clear()
32
+ }
33
+ }
@@ -42,6 +42,7 @@ export interface Rekeyable {
42
42
  export class RekeyOrchestrator {
43
43
  /** "entityType:tempId" → persistedId. The single identity-redirect map. */
44
44
  private readonly tempToPersisted = new Map<string, string>()
45
+ private mutationVersion = 0
45
46
  private readonly participants: Rekeyable[] = []
46
47
  private readonly participantSet = new Set<Rekeyable>()
47
48
 
@@ -66,6 +67,10 @@ export class RekeyOrchestrator {
66
67
  return persisted !== undefined ? `${entityType}:${persisted}` : `${entityType}:${id}`
67
68
  }
68
69
 
70
+ getMutationVersion(): number {
71
+ return this.mutationVersion
72
+ }
73
+
69
74
  /** Resolves an entity id to its persisted id if it has been rekeyed. */
70
75
  resolveId(entityType: string, id: string): string {
71
76
  return this.tempToPersisted.get(`${entityType}:${id}`) ?? id
@@ -111,6 +116,7 @@ export class RekeyOrchestrator {
111
116
  }
112
117
 
113
118
  this.tempToPersisted.set(ctx.oldKey, persistedId)
119
+ this.mutationVersion++
114
120
 
115
121
  for (const participant of this.participants) {
116
122
  participant.rekey(ctx)
@@ -119,5 +125,6 @@ export class RekeyOrchestrator {
119
125
 
120
126
  clear(): void {
121
127
  this.tempToPersisted.clear()
128
+ this.mutationVersion++
122
129
  }
123
130
  }
@@ -0,0 +1,31 @@
1
+ import { parentKeyFromRelationKey } from './relationKey.js'
2
+
3
+ export class RelationOwnerIndex {
4
+ private readonly keysByOwner = new Map<string, Set<string>>()
5
+
6
+ add(key: string): void {
7
+ const owner = parentKeyFromRelationKey(key)
8
+ let keys = this.keysByOwner.get(owner)
9
+ if (!keys) {
10
+ keys = new Set()
11
+ this.keysByOwner.set(owner, keys)
12
+ }
13
+ keys.add(key)
14
+ }
15
+
16
+ delete(key: string): void {
17
+ const owner = parentKeyFromRelationKey(key)
18
+ const keys = this.keysByOwner.get(owner)
19
+ if (!keys) return
20
+ keys.delete(key)
21
+ if (keys.size === 0) this.keysByOwner.delete(owner)
22
+ }
23
+
24
+ get(owner: string): Iterable<string> {
25
+ return this.keysByOwner.get(owner) ?? []
26
+ }
27
+
28
+ clear(): void {
29
+ this.keysByOwner.clear()
30
+ }
31
+ }
@@ -158,6 +158,16 @@ export class RelationStore implements Rekeyable {
158
158
  return this.hasMany.getHasManyOrderedIds(key)
159
159
  }
160
160
 
161
+ /**
162
+ * Whether some relation plans to delete {@link childId} through its parent's
163
+ * mutation — a has-many `delete` removal or a has-one target marked `deleted`.
164
+ * Such a row goes away with the parent's update, so the child must not be
165
+ * written on its own (see issue #91).
166
+ */
167
+ isPlannedForDeleteByParent(childId: string): boolean {
168
+ return this.hasOne.isPlannedForDelete(childId) || this.hasMany.isPlannedForDelete(childId)
169
+ }
170
+
161
171
  // ==================== Reachability / Reverse Lookup ====================
162
172
 
163
173
  /**
@@ -1,6 +1,6 @@
1
1
  import type { EntitySnapshot, LoadStatus } from './snapshots.js'
2
2
  import { createEntitySnapshot } from './snapshots.js'
3
- import type { FieldError } from '../errors/types.js'
3
+ import type { FieldError, FieldErrorFilter } from '../errors/types.js'
4
4
  import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js'
5
5
  import { ErrorStore } from './ErrorStore.js'
6
6
  import {
@@ -194,6 +194,11 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
194
194
  return this.rekeyOrchestrator.resolveId(entityType, id)
195
195
  }
196
196
 
197
+ /** Identity caches only need reconciliation when redirects change, including on clear. */
198
+ getRekeyVersion(): number {
199
+ return this.rekeyOrchestrator.getMutationVersion()
200
+ }
201
+
197
202
  /** Attaches identity-keyed state to the store's single rekey fan-out. */
198
203
  attachRekeyParticipant(participant: Rekeyable): void {
199
204
  this.rekeyOrchestrator.registerParticipant(participant)
@@ -577,6 +582,15 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
577
582
  return this.relations.getHasMany(key)?.plannedRemovals
578
583
  }
579
584
 
585
+ /**
586
+ * Whether some relation plans to delete {@link entityId} through its parent, i.e.
587
+ * the row goes away with the parent's update rather than with a mutation of its
588
+ * own (see issue #91).
589
+ */
590
+ isPlannedForDeleteByParent(entityId: string): boolean {
591
+ return this.relations.isPlannedForDeleteByParent(entityId)
592
+ }
593
+
580
594
  planHasManyConnection(
581
595
  parentType: string,
582
596
  parentId: string,
@@ -814,10 +828,10 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
814
828
  entityType: string,
815
829
  id: string,
816
830
  fieldName: string,
817
- source?: 'client' | 'server',
831
+ filter?: FieldErrorFilter,
818
832
  ): void {
819
833
  const key = this.getRelationKey(entityType, id, fieldName)
820
- this.errors.clearFieldErrors(key, source)
834
+ this.errors.clearFieldErrors(key, filter)
821
835
  this.notifyEntitySubscribers(this.getEntityKey(entityType, id))
822
836
  }
823
837