@contember/bindx 0.1.52 → 0.1.54
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.map +1 -1
- package/dist/core/EntityLoader.d.ts +7 -0
- package/dist/core/EntityLoader.d.ts.map +1 -1
- package/dist/core/actions.d.ts +1 -2
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/dataview/types.d.ts +6 -6
- package/dist/dataview/types.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +8 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +94 -78
- package/dist/store/HasManyStore.d.ts.map +1 -1
- package/dist/store/RelationStore.d.ts +21 -12
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
- package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +49 -14
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/hasManyState.d.ts +164 -0
- package/dist/store/hasManyState.d.ts.map +1 -0
- package/dist/store/relationKey.d.ts +11 -0
- package/dist/store/relationKey.d.ts.map +1 -1
- package/dist/undo/UndoJournal.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +4 -4
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
- package/dist/utils/aliasGenerator.d.ts +1 -1
- package/dist/utils/aliasGenerator.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +0 -1
- package/src/core/EntityLoader.ts +7 -0
- package/src/core/actions.ts +1 -3
- package/src/dataview/types.ts +6 -6
- package/src/handles/EntityHandle.ts +11 -0
- package/src/handles/HasManyListHandle.ts +15 -17
- package/src/handles/HasOneHandle.ts +4 -2
- package/src/index.ts +6 -0
- package/src/store/HasManyStore.ts +312 -488
- package/src/store/RelationStore.ts +60 -24
- package/src/store/ResponseOccurrenceIndex.ts +224 -0
- package/src/store/SnapshotStore.ts +144 -73
- package/src/store/hasManyState.ts +408 -0
- package/src/store/relationKey.ts +17 -0
- package/src/undo/UndoJournal.ts +3 -1
- package/src/undo/UndoManager.ts +1 -1
- package/src/undo/rekeyJournalEntry.ts +31 -43
- package/src/utils/aliasGenerator.ts +1 -1
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The has-many state algebra: the stored shape plus every pure function over it.
|
|
3
|
+
*
|
|
4
|
+
* Separated from {@link HasManyStore}, which owns the keyed map, the write
|
|
5
|
+
* chokepoint and the three indexes. Nothing here touches the store's state.
|
|
6
|
+
*
|
|
7
|
+
* ## Reads are per view, writes are per relation
|
|
8
|
+
*
|
|
9
|
+
* A has-many selected with params (filter/orderBy/limit/offset) is fetched under an
|
|
10
|
+
* auto-generated alias (`tags_a7x9k2`), and several such VIEWS of one relation can be
|
|
11
|
+
* mounted at once. What the server returned differs per view, so {@link HasManyView}
|
|
12
|
+
* is keyed by alias. The pending mutations do NOT: the backend has one `tags` relation
|
|
13
|
+
* and one mutation input for it, so `plannedAdditions` / `plannedRemovals` live at the
|
|
14
|
+
* relation level and the state is keyed by the SCHEMA FIELD NAME alone.
|
|
15
|
+
*
|
|
16
|
+
* The two directions are deliberately asymmetric, because subset-closure only holds
|
|
17
|
+
* one way:
|
|
18
|
+
*
|
|
19
|
+
* - A **removal** says the item is no longer a member of the relation. Every view is
|
|
20
|
+
* a subset of the relation, so the item is hidden in all of them.
|
|
21
|
+
* - An **addition** says the item is a member of the relation, which does not settle
|
|
22
|
+
* whether it belongs in a *filtered* view — the client cannot evaluate the filter.
|
|
23
|
+
* So an addition renders in the views it was made in ({@link PlannedHasManyAddition.origins})
|
|
24
|
+
* plus every view that cannot exclude anything ({@link HasManyView.membership} `'total'`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type ReconciliationResult = 'applied' | 'conflict'
|
|
28
|
+
|
|
29
|
+
export function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
|
30
|
+
if (a.size !== b.size) return false
|
|
31
|
+
for (const item of a) {
|
|
32
|
+
if (!b.has(item)) return false
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function arraysEqual(a: string[], b: string[]): boolean {
|
|
38
|
+
if (a.length !== b.length) return false
|
|
39
|
+
for (let i = 0; i < a.length; i++) {
|
|
40
|
+
if (a[i] !== b[i]) return false
|
|
41
|
+
}
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removal type for has-many items
|
|
47
|
+
*/
|
|
48
|
+
export type HasManyRemovalType = 'disconnect' | 'delete'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Kind of a planned has-many addition:
|
|
52
|
+
* - 'created': a newly created entity (via add())
|
|
53
|
+
* - 'connected': an existing persisted entity being connected (via connect())
|
|
54
|
+
*/
|
|
55
|
+
export type HasManyAdditionKind = 'created' | 'connected'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a view's query args can exclude a member of the relation.
|
|
59
|
+
* - 'total': no filter/limit/offset, so every member belongs in this view.
|
|
60
|
+
* - 'partial': the args may exclude members and the client cannot evaluate them.
|
|
61
|
+
*/
|
|
62
|
+
export type HasManyViewMembership = 'total' | 'partial'
|
|
63
|
+
|
|
64
|
+
/** What the server returned for ONE args-view of the relation, plus its local order. */
|
|
65
|
+
export interface HasManyView {
|
|
66
|
+
/** IDs of items the server returned for this view's args */
|
|
67
|
+
serverIds: Set<string>
|
|
68
|
+
/** Explicit ordered list of item IDs, null means use the view's default order */
|
|
69
|
+
orderedIds: string[] | null
|
|
70
|
+
membership: HasManyViewMembership
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PlannedHasManyAddition {
|
|
74
|
+
kind: HasManyAdditionKind
|
|
75
|
+
/**
|
|
76
|
+
* Aliases of the views the addition was made in — never empty. Presentation only:
|
|
77
|
+
* it never affects what is sent. A `connect()` of the same id can legitimately
|
|
78
|
+
* arrive from two views, and both are entitled to show it, hence a set.
|
|
79
|
+
*/
|
|
80
|
+
origins: Set<string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SentHasManyAddition {
|
|
84
|
+
itemId: string
|
|
85
|
+
kind: HasManyAdditionKind
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SentHasManyRemoval {
|
|
89
|
+
itemId: string
|
|
90
|
+
type: HasManyRemovalType
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SentHasManyDelta {
|
|
94
|
+
additions: readonly SentHasManyAddition[]
|
|
95
|
+
removals: readonly SentHasManyRemoval[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Has-many relation state stored in SnapshotStore, keyed by
|
|
100
|
+
* "parentType:parentId:fieldName" — the schema field name, never an alias.
|
|
101
|
+
*/
|
|
102
|
+
export interface StoredHasManyState {
|
|
103
|
+
/** Per-args-view server data, keyed by alias; the unparameterized view uses the field name. */
|
|
104
|
+
views: Map<string, HasManyView>
|
|
105
|
+
/** Planned removals (disconnect or delete) keyed by entity ID. Relation-level. */
|
|
106
|
+
plannedRemovals: Map<string, HasManyRemovalType>
|
|
107
|
+
/**
|
|
108
|
+
* Planned additions keyed by entity ID. Relation-level: these are exactly the
|
|
109
|
+
* operations the next persist will send. The entries whose kind is 'created' are
|
|
110
|
+
* exactly the created entities, so the "created ⊆ connections" invariant is structural.
|
|
111
|
+
*/
|
|
112
|
+
plannedAdditions: Map<string, PlannedHasManyAddition>
|
|
113
|
+
version: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What every consumer that names the relation the way the schema and the server do
|
|
118
|
+
* sees: persistence, dirty tracking, error paths. Views are not its business, so the
|
|
119
|
+
* baseline it reads is the union across them.
|
|
120
|
+
*/
|
|
121
|
+
export interface HasManyRelationProjection {
|
|
122
|
+
serverIds: Set<string>
|
|
123
|
+
plannedRemovals: Map<string, HasManyRemovalType>
|
|
124
|
+
plannedAdditions: Map<string, HasManyAdditionKind>
|
|
125
|
+
version: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What is this view's own, as opposed to the relation's: the server rows its args
|
|
130
|
+
* returned and the order its user arranged. The pending writes are not here — they
|
|
131
|
+
* belong to the relation, so consumers read {@link HasManyRelationProjection}.
|
|
132
|
+
*/
|
|
133
|
+
export interface HasManyViewProjection {
|
|
134
|
+
serverIds: Set<string>
|
|
135
|
+
orderedIds: string[] | null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createHasManyView(
|
|
139
|
+
membership: HasManyViewMembership,
|
|
140
|
+
serverIds?: Iterable<string>,
|
|
141
|
+
): HasManyView {
|
|
142
|
+
return { serverIds: new Set(serverIds ?? []), orderedIds: null, membership }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function cloneHasManyView(view: HasManyView): HasManyView {
|
|
146
|
+
return {
|
|
147
|
+
serverIds: new Set(view.serverIds),
|
|
148
|
+
orderedIds: view.orderedIds ? [...view.orderedIds] : null,
|
|
149
|
+
membership: view.membership,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function cloneViews(views: ReadonlyMap<string, HasManyView>): Map<string, HasManyView> {
|
|
154
|
+
const clone = new Map<string, HasManyView>()
|
|
155
|
+
for (const [alias, view] of views) clone.set(alias, cloneHasManyView(view))
|
|
156
|
+
return clone
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function cloneAddition(addition: PlannedHasManyAddition): PlannedHasManyAddition {
|
|
160
|
+
return { kind: addition.kind, origins: new Set(addition.origins) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function cloneAdditions(
|
|
164
|
+
additions: ReadonlyMap<string, PlannedHasManyAddition>,
|
|
165
|
+
): Map<string, PlannedHasManyAddition> {
|
|
166
|
+
const clone = new Map<string, PlannedHasManyAddition>()
|
|
167
|
+
for (const [id, addition] of additions) clone.set(id, cloneAddition(addition))
|
|
168
|
+
return clone
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Deep clone. Every path that hands a state out or stores a new one goes through
|
|
173
|
+
* this: {@link SnapshotStore}'s dirty-version memo assumes values are REPLACED, never
|
|
174
|
+
* mutated in place, and with two nesting levels a shallow copy would share mutable
|
|
175
|
+
* Sets across store generations.
|
|
176
|
+
*/
|
|
177
|
+
export function cloneHasManyState(state: StoredHasManyState): StoredHasManyState {
|
|
178
|
+
return {
|
|
179
|
+
views: cloneViews(state.views),
|
|
180
|
+
plannedRemovals: new Map(state.plannedRemovals),
|
|
181
|
+
plannedAdditions: cloneAdditions(state.plannedAdditions),
|
|
182
|
+
version: state.version,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function emptyHasManyState(): StoredHasManyState {
|
|
187
|
+
return { views: new Map(), plannedRemovals: new Map(), plannedAdditions: new Map(), version: 0 }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether an addition renders in {@link alias} — see the asymmetry note at the top. */
|
|
191
|
+
export function additionRendersIn(
|
|
192
|
+
views: ReadonlyMap<string, HasManyView>,
|
|
193
|
+
alias: string,
|
|
194
|
+
addition: PlannedHasManyAddition,
|
|
195
|
+
): boolean {
|
|
196
|
+
if (addition.origins.has(alias)) return true
|
|
197
|
+
return views.get(alias)?.membership === 'total'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The aliases of views whose args cannot exclude any member of the relation. */
|
|
201
|
+
function totalAliases(views: ReadonlyMap<string, HasManyView>): string[] {
|
|
202
|
+
const aliases: string[] = []
|
|
203
|
+
for (const [alias, view] of views) {
|
|
204
|
+
if (view.membership === 'total') aliases.push(alias)
|
|
205
|
+
}
|
|
206
|
+
return aliases
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The aliases a confirmed member should render in, never empty.
|
|
211
|
+
*
|
|
212
|
+
* {@link preferred} is what the local record says; when nothing is left of it the row
|
|
213
|
+
* falls back to the views that cannot exclude it. If there are none — every mounted
|
|
214
|
+
* view is filtered — it is parked in the unparameterized view, created here if absent:
|
|
215
|
+
* the relation's baseline is the UNION across views, so a row that joins no view is a
|
|
216
|
+
* server row the store has lost. That view is `total` by definition, so parking shows
|
|
217
|
+
* it to nobody it does not belong to.
|
|
218
|
+
*/
|
|
219
|
+
function renderTargets(
|
|
220
|
+
views: Map<string, HasManyView>,
|
|
221
|
+
fieldName: string,
|
|
222
|
+
preferred: string[],
|
|
223
|
+
): string[] {
|
|
224
|
+
if (preferred.length > 0) return preferred
|
|
225
|
+
const totals = totalAliases(views)
|
|
226
|
+
if (totals.length > 0) return totals
|
|
227
|
+
if (!views.has(fieldName)) views.set(fieldName, createHasManyView('total'))
|
|
228
|
+
return [fieldName]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The aliases of existing views a confirmed addition folds into. */
|
|
232
|
+
export function additionFoldTargets(
|
|
233
|
+
state: StoredHasManyState,
|
|
234
|
+
addition: PlannedHasManyAddition,
|
|
235
|
+
): string[] {
|
|
236
|
+
const targets: string[] = []
|
|
237
|
+
for (const alias of state.views.keys()) {
|
|
238
|
+
if (additionRendersIn(state.views, alias, addition)) targets.push(alias)
|
|
239
|
+
}
|
|
240
|
+
return targets
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Default order of one view: its server rows minus the relation's planned removals,
|
|
245
|
+
* then the planned additions that render there.
|
|
246
|
+
*/
|
|
247
|
+
export function computeViewOrderedIds(state: StoredHasManyState, alias: string): string[] {
|
|
248
|
+
const result: string[] = []
|
|
249
|
+
const view = state.views.get(alias)
|
|
250
|
+
|
|
251
|
+
if (view) {
|
|
252
|
+
for (const id of view.serverIds) {
|
|
253
|
+
if (!state.plannedRemovals.has(id)) result.push(id)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const [id, addition] of state.plannedAdditions) {
|
|
258
|
+
if (state.plannedRemovals.has(id)) continue
|
|
259
|
+
if (!additionRendersIn(state.views, alias, addition)) continue
|
|
260
|
+
if (!result.includes(id)) result.push(id)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Explicit order of a view if it has one, otherwise its default order. */
|
|
267
|
+
export function viewOrderedIds(state: StoredHasManyState, alias: string): string[] {
|
|
268
|
+
const explicit = state.views.get(alias)?.orderedIds
|
|
269
|
+
return explicit !== null && explicit !== undefined ? [...explicit] : computeViewOrderedIds(state, alias)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The relation's server baseline: the union across views.
|
|
274
|
+
* Known limitation: views are never dropped, so a view nobody refetches any more can
|
|
275
|
+
* keep a row the server has since removed in it.
|
|
276
|
+
*/
|
|
277
|
+
export function relationServerIds(state: StoredHasManyState): Set<string> {
|
|
278
|
+
const ids = new Set<string>()
|
|
279
|
+
for (const view of state.views.values()) {
|
|
280
|
+
for (const id of view.serverIds) ids.add(id)
|
|
281
|
+
}
|
|
282
|
+
return ids
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function toRelationProjection(state: StoredHasManyState): HasManyRelationProjection {
|
|
286
|
+
const plannedAdditions = new Map<string, HasManyAdditionKind>()
|
|
287
|
+
for (const [id, addition] of state.plannedAdditions) plannedAdditions.set(id, addition.kind)
|
|
288
|
+
return {
|
|
289
|
+
serverIds: relationServerIds(state),
|
|
290
|
+
plannedRemovals: new Map(state.plannedRemovals),
|
|
291
|
+
plannedAdditions,
|
|
292
|
+
version: state.version,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function toViewProjection(
|
|
297
|
+
state: StoredHasManyState | undefined,
|
|
298
|
+
alias: string,
|
|
299
|
+
): HasManyViewProjection {
|
|
300
|
+
const view = state?.views.get(alias)
|
|
301
|
+
return {
|
|
302
|
+
serverIds: new Set(view?.serverIds),
|
|
303
|
+
orderedIds: view?.orderedIds ? [...view.orderedIds] : null,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The single liveness predicate for has-many membership: effective members are
|
|
309
|
+
* (every view's serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and
|
|
310
|
+
* consumed by the write chokepoint so the forward/reverse index can never drift from it.
|
|
311
|
+
*/
|
|
312
|
+
export function liveHasManyChildIds(state: StoredHasManyState | undefined): Set<string> {
|
|
313
|
+
const live = new Set<string>()
|
|
314
|
+
if (!state) return live
|
|
315
|
+
for (const view of state.views.values()) {
|
|
316
|
+
for (const id of view.serverIds) {
|
|
317
|
+
if (!state.plannedRemovals.has(id)) live.add(id)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const id of state.plannedAdditions.keys()) {
|
|
321
|
+
if (!state.plannedRemovals.has(id)) live.add(id)
|
|
322
|
+
}
|
|
323
|
+
return live
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set<string> {
|
|
327
|
+
const ids = new Set<string>()
|
|
328
|
+
if (!state) return ids
|
|
329
|
+
for (const [id, type] of state.plannedRemovals) {
|
|
330
|
+
if (type === 'delete') ids.add(id)
|
|
331
|
+
}
|
|
332
|
+
return ids
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface HasManyReconciliation {
|
|
336
|
+
state: StoredHasManyState
|
|
337
|
+
result: ReconciliationResult
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Advances the server baseline by the confirmed sent delta and rebases local edits
|
|
342
|
+
* made after the request went out.
|
|
343
|
+
*
|
|
344
|
+
* The rebase logic is relation-level and unchanged by views — "did the user locally
|
|
345
|
+
* undo this while the request was in flight" has one answer per relation. Only the
|
|
346
|
+
* baseline bookkeeping is per view: a confirmed addition joins the views it renders
|
|
347
|
+
* in, a confirmed removal leaves every view.
|
|
348
|
+
*/
|
|
349
|
+
export function reconcileHasManyState(
|
|
350
|
+
existing: StoredHasManyState,
|
|
351
|
+
delta: SentHasManyDelta,
|
|
352
|
+
fieldName: string,
|
|
353
|
+
): HasManyReconciliation {
|
|
354
|
+
const currentLive = liveHasManyChildIds(existing)
|
|
355
|
+
const views = cloneViews(existing.views)
|
|
356
|
+
const plannedAdditions = cloneAdditions(existing.plannedAdditions)
|
|
357
|
+
const plannedRemovals = new Map(existing.plannedRemovals)
|
|
358
|
+
let result: ReconciliationResult = 'applied'
|
|
359
|
+
|
|
360
|
+
for (const addition of delta.additions) {
|
|
361
|
+
// Read the origins before dropping the record. A locally cancelled addition
|
|
362
|
+
// leaves none, and the row then joins only the views that cannot exclude it.
|
|
363
|
+
const planned = plannedAdditions.get(addition.itemId)
|
|
364
|
+
const targets = renderTargets(views, fieldName, planned ? additionFoldTargets(existing, planned) : [])
|
|
365
|
+
for (const alias of targets) views.get(alias)?.serverIds.add(addition.itemId)
|
|
366
|
+
|
|
367
|
+
plannedAdditions.delete(addition.itemId)
|
|
368
|
+
plannedRemovals.delete(addition.itemId)
|
|
369
|
+
if (!currentLive.has(addition.itemId)) {
|
|
370
|
+
plannedRemovals.set(
|
|
371
|
+
addition.itemId,
|
|
372
|
+
addition.kind === 'created' ? 'delete' : 'disconnect',
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const removal of delta.removals) {
|
|
378
|
+
// Which views listed the row, read as the baseline drops it: a rebase below must
|
|
379
|
+
// not claim membership in a filtered view that never showed it.
|
|
380
|
+
const shownIn: string[] = []
|
|
381
|
+
for (const [alias, view] of views) {
|
|
382
|
+
if (view.serverIds.delete(removal.itemId)) shownIn.push(alias)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const currentRemoval = plannedRemovals.get(removal.itemId)
|
|
386
|
+
if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId)
|
|
387
|
+
|
|
388
|
+
if (!currentLive.has(removal.itemId)) {
|
|
389
|
+
if (removal.type === 'delete') plannedRemovals.delete(removal.itemId)
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
if (removal.type === 'delete') {
|
|
393
|
+
result = 'conflict'
|
|
394
|
+
} else if (!plannedAdditions.has(removal.itemId)) {
|
|
395
|
+
// Re-plan the connection where the row actually was, so the rebase is visible
|
|
396
|
+
// where the user is looking without inventing membership elsewhere.
|
|
397
|
+
plannedAdditions.set(removal.itemId, {
|
|
398
|
+
kind: 'connected',
|
|
399
|
+
origins: new Set(renderTargets(views, fieldName, shownIn)),
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
state: { views, plannedRemovals, plannedAdditions, version: existing.version + 1 },
|
|
406
|
+
result,
|
|
407
|
+
}
|
|
408
|
+
}
|
package/src/store/relationKey.ts
CHANGED
|
@@ -9,6 +9,23 @@ export function parentKeyFromRelationKey(relationKey: string): string {
|
|
|
9
9
|
return relationKey.slice(0, lastSeparator)
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Derives the parent entity type from a relation key ("parentType:parentId:fieldName").
|
|
14
|
+
* Entity type names never contain ':', so the type is everything before the first separator.
|
|
15
|
+
*/
|
|
16
|
+
export function entityTypeFromRelationKey(relationKey: string): string {
|
|
17
|
+
return relationKey.slice(0, relationKey.indexOf(':'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derives the field name from a relation key ("parentType:parentId:fieldName").
|
|
22
|
+
* The counterpart of {@link parentKeyFromRelationKey}: entity ids and field names
|
|
23
|
+
* never contain ':', so the field is everything after the last separator.
|
|
24
|
+
*/
|
|
25
|
+
export function fieldFromRelationKey(relationKey: string): string {
|
|
26
|
+
return relationKey.slice(relationKey.lastIndexOf(':') + 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/**
|
|
13
30
|
* Derives the parent composite key ("parentType:parentId") from an owner key
|
|
14
31
|
* prefix ("parentType:parentId:") by dropping the trailing separator. Callers
|
package/src/undo/UndoJournal.ts
CHANGED
|
@@ -113,7 +113,9 @@ function collectDetachedSeedIds(active: Map<string, JournalCellImage>): Set<stri
|
|
|
113
113
|
if (image.kind === 'relation') {
|
|
114
114
|
if (image.state?.currentId) ids.add(image.state.currentId)
|
|
115
115
|
} else if (image.kind === 'hasMany' && image.state) {
|
|
116
|
-
|
|
116
|
+
for (const view of image.state.views.values()) {
|
|
117
|
+
if (view.orderedIds) for (const id of view.orderedIds) ids.add(id)
|
|
118
|
+
}
|
|
117
119
|
for (const id of image.state.plannedAdditions.keys()) ids.add(id)
|
|
118
120
|
for (const id of image.state.plannedRemovals.keys()) ids.add(id)
|
|
119
121
|
}
|
package/src/undo/UndoManager.ts
CHANGED
|
@@ -214,7 +214,7 @@ export class UndoManager {
|
|
|
214
214
|
* so stored cells keep valid keys / id references (and sealed creates drop out).
|
|
215
215
|
*/
|
|
216
216
|
private rekeyStacks(ctx: RekeyContext): void {
|
|
217
|
-
const liveServerIds = (key: string):
|
|
217
|
+
const liveServerIds = (key: string): ReadonlyMap<string, ReadonlySet<string>> => this.store.getLiveHasManyServerIds(key)
|
|
218
218
|
const undoCount = this.undoStack.length
|
|
219
219
|
const redoCount = this.redoStack.length
|
|
220
220
|
const hadPending = this.pending !== null && this.pending.size > 0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RekeyContext } from '../store/RekeyOrchestrator.js'
|
|
2
2
|
import { createEntitySnapshot } from '../store/snapshots.js'
|
|
3
3
|
import type { EntitySnapshot } from '../store/snapshots.js'
|
|
4
|
-
import type
|
|
4
|
+
import { cloneHasManyState, type StoredHasManyState } from '../store/hasManyState.js'
|
|
5
5
|
import type { JournalEntry, JournalCellImage } from './UndoJournal.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -15,11 +15,11 @@ import type { JournalEntry, JournalCellImage } from './UndoJournal.js'
|
|
|
15
15
|
* server-backed row — only later edits to it remain undoable.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
* Looks up the live server-member ids of a has-many
|
|
19
|
-
* rekey rebase a pre-image when the just-persisted create has
|
|
20
|
-
* member of that list.
|
|
18
|
+
* Looks up the live server-member ids of a has-many relation by its key, per
|
|
19
|
+
* args-view. Lets the rekey rebase a pre-image when the just-persisted create has
|
|
20
|
+
* become a permanent member of that list.
|
|
21
21
|
*/
|
|
22
|
-
export type LiveServerIdsLookup = (relationKey: string) =>
|
|
22
|
+
export type LiveServerIdsLookup = (relationKey: string) => ReadonlyMap<string, ReadonlySet<string>>
|
|
23
23
|
|
|
24
24
|
export function rekeyJournalEntry(
|
|
25
25
|
entry: JournalEntry,
|
|
@@ -77,19 +77,19 @@ function rekeyCell(
|
|
|
77
77
|
|
|
78
78
|
const key = rekeyKey(cell.key, ctx)
|
|
79
79
|
if (!cell.state) return { ...cell, key }
|
|
80
|
-
|
|
80
|
+
const state = rekeyHasManyState(cell.state, ctx)
|
|
81
81
|
// Membership rebase: when the just-persisted create became a permanent member of
|
|
82
|
-
//
|
|
83
|
-
// dropping it. Default order picks it up automatically; an explicit
|
|
84
|
-
// the id appended.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
// a live view, fold it into the (older) pre-image of that view so undo keeps it
|
|
83
|
+
// instead of dropping it. Default order picks it up automatically; an explicit
|
|
84
|
+
// order needs the id appended. Views born after the gesture are left alone —
|
|
85
|
+
// the pre-image says nothing about them.
|
|
86
|
+
const live = liveServerIds(key)
|
|
87
|
+
for (const [alias, view] of state.views) {
|
|
88
|
+
if (!live.get(alias)?.has(ctx.newId) || view.serverIds.has(ctx.newId)) continue
|
|
89
|
+
view.serverIds.add(ctx.newId)
|
|
90
|
+
if (view.orderedIds && !view.orderedIds.includes(ctx.newId)) {
|
|
91
|
+
view.orderedIds = [...view.orderedIds, ctx.newId]
|
|
91
92
|
}
|
|
92
|
-
state = { ...state, serverIds, orderedIds }
|
|
93
93
|
}
|
|
94
94
|
return { ...cell, key, state }
|
|
95
95
|
}
|
|
@@ -105,41 +105,29 @@ function rekeySnapshotId(snapshot: EntitySnapshot, ctx: RekeyContext): EntitySna
|
|
|
105
105
|
)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
if (!set.has(oldId)) return set
|
|
110
|
-
const next = new Set(set)
|
|
111
|
-
next.delete(oldId)
|
|
112
|
-
next.add(newId)
|
|
113
|
-
return next
|
|
114
|
-
}
|
|
115
|
-
|
|
108
|
+
/** Returns a deep copy with oldId swapped for newId everywhere it appears. */
|
|
116
109
|
function rekeyHasManyState(state: StoredHasManyState, ctx: RekeyContext): StoredHasManyState {
|
|
117
|
-
const
|
|
110
|
+
const next = cloneHasManyState(state)
|
|
118
111
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
orderedIds =
|
|
124
|
-
orderedIds[idx] = ctx.newId
|
|
112
|
+
for (const view of next.views.values()) {
|
|
113
|
+
if (view.serverIds.delete(ctx.oldId)) view.serverIds.add(ctx.newId)
|
|
114
|
+
if (view.orderedIds) {
|
|
115
|
+
const idx = view.orderedIds.indexOf(ctx.oldId)
|
|
116
|
+
if (idx !== -1) view.orderedIds[idx] = ctx.newId
|
|
125
117
|
}
|
|
126
118
|
}
|
|
127
119
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
plannedAdditions
|
|
132
|
-
plannedAdditions.delete(ctx.oldId)
|
|
133
|
-
plannedAdditions.set(ctx.newId, additionKind)
|
|
120
|
+
const addition = next.plannedAdditions.get(ctx.oldId)
|
|
121
|
+
if (addition) {
|
|
122
|
+
next.plannedAdditions.delete(ctx.oldId)
|
|
123
|
+
next.plannedAdditions.set(ctx.newId, addition)
|
|
134
124
|
}
|
|
135
125
|
|
|
136
|
-
|
|
137
|
-
const removalType = plannedRemovals.get(ctx.oldId)
|
|
126
|
+
const removalType = next.plannedRemovals.get(ctx.oldId)
|
|
138
127
|
if (removalType !== undefined) {
|
|
139
|
-
plannedRemovals
|
|
140
|
-
plannedRemovals.
|
|
141
|
-
plannedRemovals.set(ctx.newId, removalType)
|
|
128
|
+
next.plannedRemovals.delete(ctx.oldId)
|
|
129
|
+
next.plannedRemovals.set(ctx.newId, removalType)
|
|
142
130
|
}
|
|
143
131
|
|
|
144
|
-
return
|
|
132
|
+
return next
|
|
145
133
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { generateHasManyAlias } from '@contember/bindx-client'
|
|
1
|
+
export { canHasManyParamsExcludeMembers, generateHasManyAlias } from '@contember/bindx-client'
|