@contember/bindx 0.1.51 → 0.1.53

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 (54) hide show
  1. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  2. package/dist/core/EntityLoader.d.ts +7 -0
  3. package/dist/core/EntityLoader.d.ts.map +1 -1
  4. package/dist/core/actions.d.ts +1 -2
  5. package/dist/core/actions.d.ts.map +1 -1
  6. package/dist/dataview/types.d.ts +6 -6
  7. package/dist/dataview/types.d.ts.map +1 -1
  8. package/dist/handles/EntityHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +8 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  15. package/dist/store/HasManyStore.d.ts +94 -78
  16. package/dist/store/HasManyStore.d.ts.map +1 -1
  17. package/dist/store/RelationStore.d.ts +21 -12
  18. package/dist/store/RelationStore.d.ts.map +1 -1
  19. package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
  20. package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
  21. package/dist/store/SnapshotStore.d.ts +53 -15
  22. package/dist/store/SnapshotStore.d.ts.map +1 -1
  23. package/dist/store/SubscriptionManager.d.ts +19 -4
  24. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  25. package/dist/store/hasManyState.d.ts +164 -0
  26. package/dist/store/hasManyState.d.ts.map +1 -0
  27. package/dist/store/relationKey.d.ts +11 -0
  28. package/dist/store/relationKey.d.ts.map +1 -1
  29. package/dist/undo/UndoJournal.d.ts.map +1 -1
  30. package/dist/undo/rekeyJournalEntry.d.ts +4 -4
  31. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
  32. package/dist/utils/aliasGenerator.d.ts +1 -1
  33. package/dist/utils/aliasGenerator.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/core/ActionDispatcher.ts +0 -1
  36. package/src/core/EntityLoader.ts +13 -6
  37. package/src/core/actions.ts +1 -3
  38. package/src/dataview/types.ts +6 -6
  39. package/src/handles/EntityHandle.ts +11 -0
  40. package/src/handles/HasManyListHandle.ts +15 -17
  41. package/src/handles/HasOneHandle.ts +4 -2
  42. package/src/index.ts +6 -0
  43. package/src/persistence/BatchPersister.ts +15 -11
  44. package/src/store/HasManyStore.ts +312 -488
  45. package/src/store/RelationStore.ts +60 -24
  46. package/src/store/ResponseOccurrenceIndex.ts +224 -0
  47. package/src/store/SnapshotStore.ts +183 -109
  48. package/src/store/SubscriptionManager.ts +116 -58
  49. package/src/store/hasManyState.ts +408 -0
  50. package/src/store/relationKey.ts +17 -0
  51. package/src/undo/UndoJournal.ts +3 -1
  52. package/src/undo/UndoManager.ts +1 -1
  53. package/src/undo/rekeyJournalEntry.ts +31 -43
  54. package/src/utils/aliasGenerator.ts +1 -1
@@ -5,27 +5,33 @@ import {
5
5
  type SentHasOneTransition,
6
6
  type StoredRelationState,
7
7
  } from './HasOneStore.js'
8
+ import { HasManyStore } from './HasManyStore.js'
8
9
  import {
9
- HasManyStore,
10
- computeDefaultOrderedIds,
11
10
  type HasManyAdditionKind,
11
+ type HasManyRelationProjection,
12
12
  type HasManyRemovalType,
13
+ type HasManyViewMembership,
14
+ type HasManyViewProjection,
13
15
  type SentHasManyDelta,
14
16
  type StoredHasManyState,
15
- } from './HasManyStore.js'
17
+ } from './hasManyState.js'
16
18
 
17
19
  // Re-exported so existing imports from './RelationStore.js' keep resolving.
18
20
  export type { StoredRelationState } from './HasOneStore.js'
19
21
  export type { SentHasOneTransition } from './HasOneStore.js'
20
22
  export type {
21
23
  HasManyAdditionKind,
24
+ HasManyRelationProjection,
22
25
  HasManyRemovalType,
26
+ HasManyView,
27
+ HasManyViewMembership,
28
+ HasManyViewProjection,
29
+ PlannedHasManyAddition,
23
30
  SentHasManyAddition,
24
31
  SentHasManyDelta,
25
32
  SentHasManyRemoval,
26
33
  StoredHasManyState,
27
- } from './HasManyStore.js'
28
- export { computeDefaultOrderedIds } from './HasManyStore.js'
34
+ } from './hasManyState.js'
29
35
 
30
36
  export type RelationReconciliationResult = 'applied' | 'conflict'
31
37
 
@@ -103,28 +109,58 @@ export class RelationStore implements Rekeyable {
103
109
 
104
110
  // ==================== Has-Many Relations ====================
105
111
 
106
- getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
107
- return this.hasMany.getOrCreateHasMany(key, serverIds)
112
+ declareHasManyViewMembership(
113
+ entityType: string,
114
+ fieldName: string,
115
+ alias: string,
116
+ membership: HasManyViewMembership,
117
+ ): void {
118
+ this.hasMany.declareViewMembership(entityType, fieldName, alias, membership)
108
119
  }
109
120
 
110
- getHasMany(key: string): StoredHasManyState | undefined {
121
+ getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void {
122
+ this.hasMany.getOrCreateHasMany(key, alias, serverIds)
123
+ }
124
+
125
+ /**
126
+ * The raw stored state, views and all. Named apart from the projections above it:
127
+ * {@link SnapshotStore.getHasMany} hands out a relation projection with no views,
128
+ * and the undo / export paths that need the real thing must not get that by mistake.
129
+ */
130
+ getHasManyState(key: string): StoredHasManyState | undefined {
111
131
  return this.hasMany.getHasMany(key)
112
132
  }
113
133
 
114
- setHasManyServerIds(key: string, serverIds: string[]): void {
115
- this.hasMany.setHasManyServerIds(key, serverIds)
134
+ getHasManyRelation(key: string): HasManyRelationProjection | undefined {
135
+ return this.hasMany.getRelationProjection(key)
116
136
  }
117
137
 
118
- planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
119
- this.hasMany.planHasManyRemoval(key, itemId, type)
138
+ getHasManyView(key: string, alias: string): HasManyViewProjection {
139
+ return this.hasMany.getViewProjection(key, alias)
140
+ }
141
+
142
+ hasPendingHasManyWrites(key: string): boolean {
143
+ return this.hasMany.hasPendingWrites(key)
144
+ }
145
+
146
+ hasExplicitHasManyOrder(key: string, alias: string): boolean {
147
+ return this.hasMany.hasExplicitOrder(key, alias)
148
+ }
149
+
150
+ collectHasManyViewServerIds(key: string): ReadonlyMap<string, ReadonlySet<string>> {
151
+ return this.hasMany.collectViewServerIds(key)
120
152
  }
121
153
 
122
- planHasManyConnection(key: string, itemId: string): void {
123
- this.hasMany.planHasManyConnection(key, itemId)
154
+ setHasManyServerIds(key: string, alias: string, serverIds: string[]): void {
155
+ this.hasMany.setHasManyServerIds(key, alias, serverIds)
156
+ }
157
+
158
+ planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
159
+ this.hasMany.planHasManyRemoval(key, itemId, type)
124
160
  }
125
161
 
126
- commitHasMany(key: string, newServerIds: string[]): void {
127
- this.hasMany.commitHasMany(key, newServerIds)
162
+ planHasManyConnection(key: string, alias: string, itemId: string): void {
163
+ this.hasMany.planHasManyConnection(key, alias, itemId)
128
164
  }
129
165
 
130
166
  reconcileSentHasMany(
@@ -138,24 +174,24 @@ export class RelationStore implements Rekeyable {
138
174
  this.hasMany.resetHasMany(key)
139
175
  }
140
176
 
141
- addToHasMany(key: string, itemId: string): void {
142
- this.hasMany.addToHasMany(key, itemId)
177
+ addToHasMany(key: string, alias: string, itemId: string): void {
178
+ this.hasMany.addToHasMany(key, alias, itemId)
143
179
  }
144
180
 
145
- connectExistingToHasMany(key: string, itemId: string): void {
146
- this.hasMany.connectExistingToHasMany(key, itemId)
181
+ connectExistingToHasMany(key: string, alias: string, itemId: string): void {
182
+ this.hasMany.connectExistingToHasMany(key, alias, itemId)
147
183
  }
148
184
 
149
185
  removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean {
150
186
  return this.hasMany.removeFromHasMany(key, itemId, removalType)
151
187
  }
152
188
 
153
- moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
154
- this.hasMany.moveInHasMany(key, fromIndex, toIndex)
189
+ moveInHasMany(key: string, alias: string, fromIndex: number, toIndex: number): void {
190
+ this.hasMany.moveInHasMany(key, alias, fromIndex, toIndex)
155
191
  }
156
192
 
157
- getHasManyOrderedIds(key: string): string[] {
158
- return this.hasMany.getHasManyOrderedIds(key)
193
+ getHasManyOrderedIds(key: string, alias: string): string[] {
194
+ return this.hasMany.getHasManyOrderedIds(key, alias)
159
195
  }
160
196
 
161
197
  /**
@@ -0,0 +1,224 @@
1
+ import type { SelectionMeta } from '../selection/types.js'
2
+
3
+ /** The schema knowledge the index needs: where a relation points and whether it is a list. */
4
+ export interface RelationTargetResolver {
5
+ getRelationTarget(entityType: string, fieldName: string): string | undefined
6
+ isHasMany(entityType: string, fieldName: string): boolean
7
+ }
8
+
9
+ type EntityRecord = Record<string, unknown>
10
+
11
+ interface PendingVisit {
12
+ readonly value: unknown
13
+ readonly entityType: string
14
+ readonly selection: SelectionMeta
15
+ readonly isList: boolean
16
+ }
17
+
18
+ /**
19
+ * Unites the occurrences of one entity within a single server response.
20
+ *
21
+ * One query can reach the same entity through several paths, each with its own
22
+ * sub-selection (`article.attachments` next to `article.sections.article.attachments`).
23
+ * Every occurrence is later written into the entity's snapshot, so without this
24
+ * the narrower one could replace fields the wider one carried. Occurrences of
25
+ * one response are equally fresh, so they are merged without any recency rule:
26
+ * each maps to the union of all of them, keyed by entity type and id. A scalar two
27
+ * occurrences disagree on (which one server read should not produce) takes the
28
+ * value of the occurrence visited last. The response is walked breadth first,
29
+ * relations in selection order and list items in list order, so the outcome
30
+ * follows from the response and the selection alone.
31
+ *
32
+ * The union is shallow. A relation value in it is one occurrence's raw value, and
33
+ * the related entities inside it resolve to their own unions when they are
34
+ * written, so relations inside relations unite level by level without rewriting
35
+ * the response.
36
+ *
37
+ * Reads never merge across responses: a later read replaces what an earlier one
38
+ * stored, as before.
39
+ */
40
+ export class ResponseOccurrenceIndex {
41
+ private readonly unions = new WeakMap<object, Readonly<EntityRecord>>()
42
+
43
+ /**
44
+ * Indexes a response read for `entityType` with `selection`. The type of every
45
+ * nested occurrence comes from the schema through the selection's field names;
46
+ * an occurrence whose type cannot be resolved is left out rather than matched
47
+ * by id alone.
48
+ *
49
+ * Occurrences of one type at one selection node carry the same fields, so
50
+ * their union changes nothing. Only types the selection reaches at two or more
51
+ * nodes are collected, only the relations leading to them are walked, and a
52
+ * selection with none skips the walk entirely (see {@link planOccurrenceWalk}).
53
+ */
54
+ index(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void {
55
+ const plan = planOccurrenceWalk(entityType, selection, schema)
56
+ if (plan.repeatedTypes.size === 0) {
57
+ return
58
+ }
59
+ const root: PendingVisit = { value: data, entityType, selection, isList: Array.isArray(data) }
60
+ const occurrencesByType = collectOccurrences(root, plan, schema)
61
+ for (const occurrencesById of occurrencesByType.values()) {
62
+ for (const occurrences of occurrencesById.values()) {
63
+ if (Array.isArray(occurrences)) {
64
+ this.unite(occurrences)
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ private unite(occurrences: readonly EntityRecord[]): void {
71
+ const union: EntityRecord = {}
72
+ for (const occurrence of occurrences) {
73
+ Object.assign(union, occurrence)
74
+ }
75
+ Object.freeze(union)
76
+ for (const occurrence of occurrences) {
77
+ this.unions.set(occurrence, union)
78
+ }
79
+ }
80
+
81
+ /** The union of every occurrence of this entity in its response, or the occurrence itself when it had no sibling. */
82
+ resolve(occurrence: EntityRecord): EntityRecord {
83
+ return this.unions.get(occurrence) ?? occurrence
84
+ }
85
+ }
86
+
87
+ /** Occurrences by entity type and id. A lone occurrence is kept as is; an array only appears once an id repeats. */
88
+ type OccurrencesByType = Map<string, Map<unknown, EntityRecord | EntityRecord[]>>
89
+
90
+ function collectOccurrences(root: PendingVisit, plan: OccurrenceWalkPlan, schema: RelationTargetResolver): OccurrencesByType {
91
+ const occurrencesByType: OccurrencesByType = new Map()
92
+ const pending: PendingVisit[] = [root]
93
+ for (let next = 0; next < pending.length; next++) {
94
+ const visit = pending[next]!
95
+ for (const entity of entitiesOf(visit.value, visit.isList)) {
96
+ if (plan.repeatedTypes.has(visit.entityType)) {
97
+ addOccurrence(occurrencesByType, visit.entityType, entity)
98
+ }
99
+ for (const fieldMeta of visit.selection.fields.values()) {
100
+ if (!fieldMeta.nested || !plan.nodesToWalk.has(fieldMeta.nested) || entity[fieldMeta.alias] == null) continue
101
+ const targetType = schema.getRelationTarget(visit.entityType, fieldMeta.fieldName)
102
+ if (targetType === undefined) continue
103
+ pending.push({
104
+ value: entity[fieldMeta.alias],
105
+ entityType: targetType,
106
+ selection: fieldMeta.nested,
107
+ isList: schema.isHasMany(visit.entityType, fieldMeta.fieldName),
108
+ })
109
+ }
110
+ }
111
+ }
112
+ return occurrencesByType
113
+ }
114
+
115
+ /** What walking a response needs from its selection. */
116
+ export interface OccurrenceWalkPlan {
117
+ /** Entity types the selection reaches at two or more nodes, such as `article` and `article.sections.article`. Only these are united. */
118
+ readonly repeatedTypes: ReadonlySet<string>
119
+ /** Selection nodes on a path to a repeated type. The walk descends into no other. */
120
+ readonly nodesToWalk: ReadonlySet<SelectionMeta>
121
+ }
122
+
123
+ /**
124
+ * Plans the walk of one response. It depends only on the schema, the selection
125
+ * and its root type, and costs one pass over the selection tree, not over the
126
+ * response. It is computed per response rather than cached, because a selection
127
+ * can still grow after its first fetch (`mergeSelections` extends nested
128
+ * selections in place, and a fragment's selection is shared by every place
129
+ * that uses it).
130
+ */
131
+ export function planOccurrenceWalk(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): OccurrenceWalkPlan {
132
+ const nodes = listSelectionNodes(entityType, selection, schema)
133
+ const repeatedTypes = findRepeatedTypes(nodes)
134
+ return { repeatedTypes, nodesToWalk: findNodesToWalk(nodes, repeatedTypes) }
135
+ }
136
+
137
+ interface SelectionNode {
138
+ readonly entityType: string
139
+ readonly selection: SelectionMeta
140
+ readonly parent: number | null
141
+ }
142
+
143
+ /** Every position in the selection tree, breadth first, each with the index of its parent. */
144
+ function listSelectionNodes(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): readonly SelectionNode[] {
145
+ const nodes: SelectionNode[] = [{ entityType, selection, parent: null }]
146
+ for (let index = 0; index < nodes.length; index++) {
147
+ const node = nodes[index]!
148
+ for (const fieldMeta of node.selection.fields.values()) {
149
+ if (!fieldMeta.isRelation || !fieldMeta.nested) continue
150
+ const targetType = schema.getRelationTarget(node.entityType, fieldMeta.fieldName)
151
+ if (targetType !== undefined) {
152
+ nodes.push({ entityType: targetType, selection: fieldMeta.nested, parent: index })
153
+ }
154
+ }
155
+ }
156
+ return nodes
157
+ }
158
+
159
+ function findRepeatedTypes(nodes: readonly SelectionNode[]): ReadonlySet<string> {
160
+ const seenTypes = new Set<string>()
161
+ const repeatedTypes = new Set<string>()
162
+ for (const node of nodes) {
163
+ if (seenTypes.has(node.entityType)) {
164
+ repeatedTypes.add(node.entityType)
165
+ }
166
+ seenTypes.add(node.entityType)
167
+ }
168
+ return repeatedTypes
169
+ }
170
+
171
+ /**
172
+ * The selections on a path from the root to a repeated type. The climb tracks
173
+ * tree positions, not selection objects: a reused fragment is one selection
174
+ * object at several positions, and each position has ancestors of its own.
175
+ */
176
+ function findNodesToWalk(nodes: readonly SelectionNode[], repeatedTypes: ReadonlySet<string>): ReadonlySet<SelectionMeta> {
177
+ const nodesToWalk = new Set<SelectionMeta>()
178
+ const climbed = new Set<number>()
179
+ nodes.forEach((node, index) => {
180
+ if (!repeatedTypes.has(node.entityType)) return
181
+ for (let position: number | null = index; position !== null && !climbed.has(position); position = nodes[position]!.parent) {
182
+ climbed.add(position)
183
+ nodesToWalk.add(nodes[position]!.selection)
184
+ }
185
+ })
186
+ return nodesToWalk
187
+ }
188
+
189
+ function addOccurrence(occurrencesByType: OccurrencesByType, entityType: string, entity: EntityRecord): void {
190
+ const id = entity['id']
191
+ if (typeof id !== 'string' && typeof id !== 'number') return
192
+ let occurrencesById = occurrencesByType.get(entityType)
193
+ if (!occurrencesById) {
194
+ occurrencesById = new Map()
195
+ occurrencesByType.set(entityType, occurrencesById)
196
+ }
197
+ const known = occurrencesById.get(id)
198
+ if (known === undefined) {
199
+ occurrencesById.set(id, entity)
200
+ } else if (Array.isArray(known)) {
201
+ known.push(entity)
202
+ } else if (known !== entity) {
203
+ occurrencesById.set(id, [known, entity])
204
+ }
205
+ }
206
+
207
+ /** The entity records a relation value holds: a has-one object, or a has-many array or connection (`{ edges: [{ node }] }`). */
208
+ function entitiesOf(value: unknown, isList: boolean): readonly EntityRecord[] {
209
+ if (Array.isArray(value)) {
210
+ return value.filter(isRecord)
211
+ }
212
+ if (!isRecord(value)) {
213
+ return []
214
+ }
215
+ if (!isList) {
216
+ return [value]
217
+ }
218
+ const edges = value['edges']
219
+ return Array.isArray(edges) ? edges.map(edge => (isRecord(edge) ? edge['node'] : undefined)).filter(isRecord) : []
220
+ }
221
+
222
+ function isRecord(value: unknown): value is EntityRecord {
223
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
224
+ }