@contember/bindx 0.1.46 → 0.1.48
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/adapter/MockAdapter.d.ts +4 -1
- package/dist/adapter/MockAdapter.d.ts.map +1 -1
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/adapter/MockAdapter.ts +15 -5
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +120 -16
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- package/src/core/actionClassification.ts +0 -199
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { DirtyEntity } from './ChangeRegistry.js'
|
|
2
|
+
import type {
|
|
3
|
+
CollectedHasManyChange,
|
|
4
|
+
CollectedHasOneChange,
|
|
5
|
+
CollectedNestedCreate,
|
|
6
|
+
CollectedNestedEntity,
|
|
7
|
+
CollectedNestedUpdate,
|
|
8
|
+
CollectedRelationField,
|
|
9
|
+
} from './MutationCollector.js'
|
|
10
|
+
import type { PersistScope, TransactionMutation } from './types.js'
|
|
11
|
+
import type { SnapshotStore } from '../store/SnapshotStore.js'
|
|
12
|
+
|
|
13
|
+
export interface ExecutionEntity {
|
|
14
|
+
readonly entityType: string
|
|
15
|
+
readonly entityId: string
|
|
16
|
+
readonly operation: 'create' | 'update' | 'delete'
|
|
17
|
+
readonly scalarData: Readonly<Record<string, unknown>>
|
|
18
|
+
readonly nested: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PersistExecution {
|
|
22
|
+
readonly mutations: readonly TransactionMutation[]
|
|
23
|
+
readonly entities: readonly ExecutionEntity[]
|
|
24
|
+
readonly hasOneChanges: readonly CollectedHasOneChange[]
|
|
25
|
+
readonly hasManyChanges: readonly CollectedHasManyChange[]
|
|
26
|
+
readonly vetoed: readonly DirtyEntity[]
|
|
27
|
+
readonly nestedEntities: readonly CollectedNestedEntity[]
|
|
28
|
+
readonly relationFields: readonly CollectedRelationField[]
|
|
29
|
+
readonly nestedCreates: readonly CollectedNestedCreate[]
|
|
30
|
+
readonly nestedUpdates: readonly CollectedNestedUpdate[]
|
|
31
|
+
readonly suppressedRelationItems: ReadonlyMap<string, ReadonlySet<string>>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function entityIdentityKey(entityType: string, entityId: string): string {
|
|
35
|
+
return `${entityType}:${entityId}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPersistExecution(
|
|
39
|
+
store: SnapshotStore,
|
|
40
|
+
mutations: readonly TransactionMutation[],
|
|
41
|
+
entities: readonly DirtyEntity[],
|
|
42
|
+
nestedEntities: readonly CollectedNestedEntity[],
|
|
43
|
+
relationFieldMetadata: readonly CollectedRelationField[],
|
|
44
|
+
nestedCreateMetadata: readonly CollectedNestedCreate[],
|
|
45
|
+
nestedUpdateMetadata: readonly CollectedNestedUpdate[],
|
|
46
|
+
hasOneChanges: readonly CollectedHasOneChange[],
|
|
47
|
+
hasManyChanges: readonly CollectedHasManyChange[],
|
|
48
|
+
vetoed: readonly DirtyEntity[],
|
|
49
|
+
suppressedRelationItems: ReadonlyMap<string, ReadonlySet<string>>,
|
|
50
|
+
scope: PersistScope,
|
|
51
|
+
): PersistExecution {
|
|
52
|
+
const uniqueHasOne = new Map(
|
|
53
|
+
hasOneChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]),
|
|
54
|
+
)
|
|
55
|
+
const uniqueHasMany = new Map(
|
|
56
|
+
hasManyChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]),
|
|
57
|
+
)
|
|
58
|
+
const sentHasOneChanges = [...uniqueHasOne.values()]
|
|
59
|
+
const sentHasManyChanges = [...uniqueHasMany.values()]
|
|
60
|
+
const uniqueNestedCreates = new Map(
|
|
61
|
+
nestedCreateMetadata.map(create => [nestedIdentityKey(
|
|
62
|
+
create.parentEntityType,
|
|
63
|
+
create.parentEntityId,
|
|
64
|
+
create.fieldName,
|
|
65
|
+
create.entityType,
|
|
66
|
+
create.entityId,
|
|
67
|
+
), create]),
|
|
68
|
+
)
|
|
69
|
+
const uniqueNestedUpdates = new Map(
|
|
70
|
+
nestedUpdateMetadata.map(update => [nestedIdentityKey(
|
|
71
|
+
update.parentEntityType,
|
|
72
|
+
update.parentEntityId,
|
|
73
|
+
update.fieldName,
|
|
74
|
+
update.entityType,
|
|
75
|
+
update.entityId,
|
|
76
|
+
), update]),
|
|
77
|
+
)
|
|
78
|
+
const nestedCreates = [...uniqueNestedCreates.values()]
|
|
79
|
+
const nestedUpdates = [...uniqueNestedUpdates.values()]
|
|
80
|
+
const topLevelOperations = new Map(
|
|
81
|
+
mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation.operation]),
|
|
82
|
+
)
|
|
83
|
+
const topLevelMutations = new Map(
|
|
84
|
+
mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation]),
|
|
85
|
+
)
|
|
86
|
+
const uniqueRelationFields = new Map(
|
|
87
|
+
relationFieldMetadata.map(field => [relationIdentityKey(field.entityType, field.entityId, field.fieldName), field]),
|
|
88
|
+
)
|
|
89
|
+
const relationFields = new Map<string, Set<string>>()
|
|
90
|
+
for (const change of uniqueRelationFields.values()) {
|
|
91
|
+
const key = entityIdentityKey(change.entityType, change.entityId)
|
|
92
|
+
const fields = relationFields.get(key)
|
|
93
|
+
if (fields) fields.add(change.fieldName)
|
|
94
|
+
else relationFields.set(key, new Set([change.fieldName]))
|
|
95
|
+
}
|
|
96
|
+
const nestedKeys = new Set(nestedEntities.map(entity => entityIdentityKey(entity.entityType, entity.entityId)))
|
|
97
|
+
const nestedMutationData = new Map<string, Readonly<Record<string, unknown>>>()
|
|
98
|
+
for (const create of nestedCreates) {
|
|
99
|
+
nestedMutationData.set(entityIdentityKey(create.entityType, create.entityId), create.createData)
|
|
100
|
+
}
|
|
101
|
+
for (const update of nestedUpdates) {
|
|
102
|
+
nestedMutationData.set(entityIdentityKey(update.entityType, update.entityId), update.data)
|
|
103
|
+
nestedKeys.add(entityIdentityKey(update.entityType, update.entityId))
|
|
104
|
+
}
|
|
105
|
+
const all = new Map<string, ExecutionEntity>()
|
|
106
|
+
|
|
107
|
+
const candidates: DirtyEntity[] = [...entities]
|
|
108
|
+
for (const nested of nestedEntities) {
|
|
109
|
+
candidates.push({
|
|
110
|
+
entityType: nested.entityType,
|
|
111
|
+
entityId: nested.entityId,
|
|
112
|
+
changeType: 'create',
|
|
113
|
+
dirtyFields: [],
|
|
114
|
+
dirtyRelations: [],
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
for (const nested of nestedUpdates) {
|
|
118
|
+
candidates.push({
|
|
119
|
+
entityType: nested.entityType,
|
|
120
|
+
entityId: nested.entityId,
|
|
121
|
+
changeType: 'update',
|
|
122
|
+
dirtyFields: Object.keys(nested.data),
|
|
123
|
+
dirtyRelations: [],
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const entity of candidates) {
|
|
128
|
+
const key = entityIdentityKey(entity.entityType, entity.entityId)
|
|
129
|
+
if (all.has(key) || vetoed.some(item => entityIdentityKey(item.entityType, item.entityId) === key)) continue
|
|
130
|
+
const operation = topLevelOperations.get(key) ?? entity.changeType
|
|
131
|
+
all.set(key, {
|
|
132
|
+
entityType: entity.entityType,
|
|
133
|
+
entityId: entity.entityId,
|
|
134
|
+
operation,
|
|
135
|
+
scalarData: collectSentScalars(
|
|
136
|
+
store,
|
|
137
|
+
entity,
|
|
138
|
+
scope,
|
|
139
|
+
topLevelMutations.get(key)?.data ?? nestedMutationData.get(key),
|
|
140
|
+
relationFields.get(key) ?? new Set(),
|
|
141
|
+
),
|
|
142
|
+
nested: nestedKeys.has(key),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
mutations: mutations.map(mutation => ({
|
|
148
|
+
...mutation,
|
|
149
|
+
data: mutation.data ? { ...mutation.data } : undefined,
|
|
150
|
+
})),
|
|
151
|
+
entities: [...all.values()],
|
|
152
|
+
hasOneChanges: sentHasOneChanges.map(change => ({ ...change, transition: { ...change.transition } })),
|
|
153
|
+
hasManyChanges: sentHasManyChanges.map(change => ({
|
|
154
|
+
...change,
|
|
155
|
+
additions: change.additions.map(addition => ({ ...addition })),
|
|
156
|
+
removals: change.removals.map(removal => ({ ...removal })),
|
|
157
|
+
})),
|
|
158
|
+
vetoed: [...vetoed],
|
|
159
|
+
nestedEntities: [...nestedEntities],
|
|
160
|
+
relationFields: [...uniqueRelationFields.values()],
|
|
161
|
+
nestedCreates: nestedCreates.map(create => ({
|
|
162
|
+
...create,
|
|
163
|
+
createData: { ...create.createData },
|
|
164
|
+
knownServerIds: [...create.knownServerIds],
|
|
165
|
+
})),
|
|
166
|
+
nestedUpdates: nestedUpdates.map(update => ({ ...update, data: { ...update.data } })),
|
|
167
|
+
suppressedRelationItems: new Map(
|
|
168
|
+
[...suppressedRelationItems].map(([key, ids]) => [key, new Set(ids)]),
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function relationIdentityKey(entityType: string, entityId: string, fieldName: string): string {
|
|
174
|
+
return `${entityIdentityKey(entityType, entityId)}:${fieldName}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nestedIdentityKey(
|
|
178
|
+
parentEntityType: string,
|
|
179
|
+
parentEntityId: string,
|
|
180
|
+
fieldName: string,
|
|
181
|
+
entityType: string,
|
|
182
|
+
entityId: string,
|
|
183
|
+
): string {
|
|
184
|
+
return `${relationIdentityKey(parentEntityType, parentEntityId, fieldName)}>${entityIdentityKey(entityType, entityId)}`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function collectSentScalars(
|
|
188
|
+
store: SnapshotStore,
|
|
189
|
+
entity: Pick<DirtyEntity, 'entityType' | 'entityId' | 'changeType' | 'dirtyFields'>,
|
|
190
|
+
scope: PersistScope,
|
|
191
|
+
mutationData: Readonly<Record<string, unknown>> | undefined,
|
|
192
|
+
relationFields: ReadonlySet<string>,
|
|
193
|
+
): Readonly<Record<string, unknown>> {
|
|
194
|
+
if (entity.changeType === 'delete') return {}
|
|
195
|
+
if (mutationData) {
|
|
196
|
+
const result: Record<string, unknown> = {}
|
|
197
|
+
for (const [field, value] of Object.entries(mutationData)) {
|
|
198
|
+
if (!relationFields.has(field)) result[field] = value
|
|
199
|
+
}
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
const snapshot = store.getEntitySnapshot<Record<string, unknown>>(entity.entityType, entity.entityId)
|
|
203
|
+
if (!snapshot) return {}
|
|
204
|
+
const fields = scope.type === 'fields'
|
|
205
|
+
&& scope.entityType === entity.entityType
|
|
206
|
+
&& scope.entityId === entity.entityId
|
|
207
|
+
? scope.fields
|
|
208
|
+
: entity.changeType === 'create'
|
|
209
|
+
? Object.keys(snapshot.data)
|
|
210
|
+
: store.getDirtyFields(entity.entityType, entity.entityId)
|
|
211
|
+
const result: Record<string, unknown> = {}
|
|
212
|
+
for (const field of fields) {
|
|
213
|
+
if (field === 'id' && entity.entityId.startsWith('__temp_')) continue
|
|
214
|
+
result[field] = snapshot.data[field]
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
package/src/persistence/types.ts
CHANGED
|
@@ -97,6 +97,12 @@ export interface EntityPersistResult {
|
|
|
97
97
|
readonly entityId: string
|
|
98
98
|
readonly operation: 'create' | 'update' | 'delete'
|
|
99
99
|
readonly success: boolean
|
|
100
|
+
/**
|
|
101
|
+
* The entity was deliberately not sent (vetoed by an `entity:persisting`
|
|
102
|
+
* interceptor). `success` is false, but it counts towards `skippedCount`,
|
|
103
|
+
* not `failedCount`.
|
|
104
|
+
*/
|
|
105
|
+
readonly skipped?: true
|
|
100
106
|
readonly error?: PersistError
|
|
101
107
|
readonly fieldResults?: readonly FieldPersistResult[]
|
|
102
108
|
/** Server-assigned ID for creates (when tempId was used) */
|
|
@@ -121,9 +127,9 @@ export interface PersistenceResult {
|
|
|
121
127
|
readonly results: readonly EntityPersistResult[]
|
|
122
128
|
/** Number of successfully persisted entities */
|
|
123
129
|
readonly successCount: number
|
|
124
|
-
/** Number of
|
|
130
|
+
/** Number of entities the server rejected — results with `success: false` and no `skipped` flag */
|
|
125
131
|
readonly failedCount: number
|
|
126
|
-
/** Number of entities
|
|
132
|
+
/** Number of entities not sent: already in-flight, or vetoed by an interceptor (listed in `results` with `skipped`) */
|
|
127
133
|
readonly skippedCount: number
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LoadStatus } from './snapshots.js'
|
|
2
|
-
import { isPersistedId, isPlaceholderId } from './entityId.js'
|
|
3
2
|
import type { FieldError } from '../errors/types.js'
|
|
3
|
+
import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Entity load state tracking.
|
|
@@ -26,7 +26,7 @@ export interface EntityMeta {
|
|
|
26
26
|
* Keys are pre-computed composite strings (e.g., "entityType:id").
|
|
27
27
|
* Follows the same pattern as ErrorStore and RelationStore.
|
|
28
28
|
*/
|
|
29
|
-
export class EntityMetaStore {
|
|
29
|
+
export class EntityMetaStore implements Rekeyable {
|
|
30
30
|
/** Load states keyed by "entityType:id" */
|
|
31
31
|
private readonly loadStates = new Map<string, EntityLoadState>()
|
|
32
32
|
|
|
@@ -36,8 +36,37 @@ export class EntityMetaStore {
|
|
|
36
36
|
/** Persisting status keyed by "entityType:id" */
|
|
37
37
|
private readonly persistingEntities = new Set<string>()
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Entities whose in-flight persist is pessimistic, keyed by "entityType:id".
|
|
41
|
+
* A subset of {@link persistingEntities}. While present, the entity is
|
|
42
|
+
* presented at its server baseline (see SnapshotStore.getPresentationSnapshot)
|
|
43
|
+
* even though its canonical data stays dirty.
|
|
44
|
+
*/
|
|
45
|
+
private readonly pessimisticInFlight = new Set<string>()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Monotonic counter bumped when reachability-relevant metadata changes —
|
|
49
|
+
* `existsOnServer` and `isPersisting` (which seed the reachability roots) plus
|
|
50
|
+
* entity removal/rekey. Load state and deletion scheduling do NOT bump it.
|
|
51
|
+
* Used by {@link ReachabilityAnalyzer} to memoize its walk.
|
|
52
|
+
*/
|
|
53
|
+
private mutationVersion = 0
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Monotonic counter bumped on EDITABLE-layer meta writes — deletion scheduling,
|
|
57
|
+
* the one undoable gesture this store owns. Server-baseline (`setExistsOnServer`),
|
|
58
|
+
* load/persist state, removal, rekey, import and clear deliberately do NOT bump
|
|
59
|
+
* it. Read by the undo write-guard (see UndoJournal).
|
|
60
|
+
*/
|
|
61
|
+
private editableWriteVersion = 0
|
|
62
|
+
|
|
63
|
+
getMutationVersion(): number {
|
|
64
|
+
return this.mutationVersion
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getEditableWriteVersion(): number {
|
|
68
|
+
return this.editableWriteVersion
|
|
69
|
+
}
|
|
41
70
|
|
|
42
71
|
// ==================== Load State ====================
|
|
43
72
|
|
|
@@ -60,8 +89,15 @@ export class EntityMetaStore {
|
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
setExistsOnServer(key: string, existsOnServer: boolean): void {
|
|
63
|
-
const existing = this.entityMetas.get(key)
|
|
64
|
-
|
|
92
|
+
const existing = this.entityMetas.get(key)
|
|
93
|
+
if (existing && existing.existsOnServer === existsOnServer) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
this.entityMetas.set(key, {
|
|
97
|
+
existsOnServer,
|
|
98
|
+
isScheduledForDeletion: existing?.isScheduledForDeletion ?? false,
|
|
99
|
+
})
|
|
100
|
+
this.mutationVersion++
|
|
65
101
|
}
|
|
66
102
|
|
|
67
103
|
existsOnServer(key: string): boolean {
|
|
@@ -71,11 +107,13 @@ export class EntityMetaStore {
|
|
|
71
107
|
scheduleForDeletion(key: string): void {
|
|
72
108
|
const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false }
|
|
73
109
|
this.entityMetas.set(key, { ...existing, isScheduledForDeletion: true })
|
|
110
|
+
this.editableWriteVersion++
|
|
74
111
|
}
|
|
75
112
|
|
|
76
113
|
unscheduleForDeletion(key: string): void {
|
|
77
114
|
const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false }
|
|
78
115
|
this.entityMetas.set(key, { ...existing, isScheduledForDeletion: false })
|
|
116
|
+
this.editableWriteVersion++
|
|
79
117
|
}
|
|
80
118
|
|
|
81
119
|
isScheduledForDeletion(key: string): boolean {
|
|
@@ -88,70 +126,72 @@ export class EntityMetaStore {
|
|
|
88
126
|
return this.persistingEntities.has(key)
|
|
89
127
|
}
|
|
90
128
|
|
|
91
|
-
setPersisting(key: string, isPersisting: boolean): void {
|
|
129
|
+
setPersisting(key: string, isPersisting: boolean, pessimistic: boolean = false): void {
|
|
92
130
|
if (isPersisting) {
|
|
93
|
-
this.persistingEntities.
|
|
131
|
+
if (!this.persistingEntities.has(key)) {
|
|
132
|
+
this.persistingEntities.add(key)
|
|
133
|
+
this.mutationVersion++
|
|
134
|
+
}
|
|
135
|
+
// The pessimistic flag drives presentation only, not reachability, so it
|
|
136
|
+
// deliberately does not bump mutationVersion.
|
|
137
|
+
if (pessimistic) {
|
|
138
|
+
this.pessimisticInFlight.add(key)
|
|
139
|
+
} else {
|
|
140
|
+
this.pessimisticInFlight.delete(key)
|
|
141
|
+
}
|
|
94
142
|
} else {
|
|
95
|
-
this.persistingEntities.delete(key)
|
|
143
|
+
if (this.persistingEntities.delete(key)) {
|
|
144
|
+
this.mutationVersion++
|
|
145
|
+
}
|
|
146
|
+
this.pessimisticInFlight.delete(key)
|
|
96
147
|
}
|
|
97
148
|
}
|
|
98
149
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
mapTempIdToPersistedId(key: string, persistedId: string): void {
|
|
102
|
-
this.tempToPersistedId.set(key, persistedId)
|
|
103
|
-
this.setExistsOnServer(key, true)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
getPersistedId(key: string, id: string): string | null {
|
|
107
|
-
if (isPlaceholderId(id)) return null
|
|
108
|
-
if (isPersistedId(id)) return id
|
|
109
|
-
return this.tempToPersistedId.get(key) ?? null
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
isNewEntity(key: string, id: string): boolean {
|
|
113
|
-
if (isPlaceholderId(id)) return true
|
|
114
|
-
if (isPersistedId(id)) return false
|
|
115
|
-
return !this.tempToPersistedId.has(key)
|
|
150
|
+
isPessimisticInFlight(key: string): boolean {
|
|
151
|
+
return this.pessimisticInFlight.has(key)
|
|
116
152
|
}
|
|
117
153
|
|
|
118
154
|
/**
|
|
119
|
-
* Removes all metadata for an entity (load state, meta, persisting
|
|
155
|
+
* Removes all metadata for an entity (load state, meta, persisting).
|
|
120
156
|
*/
|
|
121
157
|
remove(key: string): void {
|
|
122
158
|
this.loadStates.delete(key)
|
|
123
159
|
this.entityMetas.delete(key)
|
|
124
160
|
this.persistingEntities.delete(key)
|
|
125
|
-
this.
|
|
161
|
+
this.pessimisticInFlight.delete(key)
|
|
162
|
+
this.mutationVersion++
|
|
126
163
|
}
|
|
127
164
|
|
|
128
165
|
/**
|
|
129
|
-
* Moves all metadata from
|
|
166
|
+
* Moves all metadata from the temp key to the persisted key. The entity has
|
|
167
|
+
* just been confirmed by the server, so it is also marked as existing there
|
|
168
|
+
* (this replaces the former separate mapTempIdToPersistedId step).
|
|
130
169
|
*/
|
|
131
|
-
rekey(
|
|
132
|
-
const meta = this.entityMetas.get(oldKey)
|
|
170
|
+
rekey(ctx: RekeyContext): void {
|
|
171
|
+
const meta = this.entityMetas.get(ctx.oldKey)
|
|
133
172
|
if (meta) {
|
|
134
|
-
this.entityMetas.delete(oldKey)
|
|
135
|
-
this.entityMetas.set(newKey, meta)
|
|
173
|
+
this.entityMetas.delete(ctx.oldKey)
|
|
174
|
+
this.entityMetas.set(ctx.newKey, meta)
|
|
136
175
|
}
|
|
137
176
|
|
|
138
|
-
const loadState = this.loadStates.get(oldKey)
|
|
177
|
+
const loadState = this.loadStates.get(ctx.oldKey)
|
|
139
178
|
if (loadState) {
|
|
140
|
-
this.loadStates.delete(oldKey)
|
|
141
|
-
this.loadStates.set(newKey, loadState)
|
|
179
|
+
this.loadStates.delete(ctx.oldKey)
|
|
180
|
+
this.loadStates.set(ctx.newKey, loadState)
|
|
142
181
|
}
|
|
143
182
|
|
|
144
|
-
if (this.persistingEntities.has(oldKey)) {
|
|
145
|
-
this.persistingEntities.delete(oldKey)
|
|
146
|
-
this.persistingEntities.add(newKey)
|
|
183
|
+
if (this.persistingEntities.has(ctx.oldKey)) {
|
|
184
|
+
this.persistingEntities.delete(ctx.oldKey)
|
|
185
|
+
this.persistingEntities.add(ctx.newKey)
|
|
147
186
|
}
|
|
148
187
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.tempToPersistedId.delete(oldKey)
|
|
153
|
-
this.tempToPersistedId.set(newKey, persistedId)
|
|
188
|
+
if (this.pessimisticInFlight.has(ctx.oldKey)) {
|
|
189
|
+
this.pessimisticInFlight.delete(ctx.oldKey)
|
|
190
|
+
this.pessimisticInFlight.add(ctx.newKey)
|
|
154
191
|
}
|
|
192
|
+
|
|
193
|
+
this.mutationVersion++
|
|
194
|
+
this.setExistsOnServer(ctx.newKey, true)
|
|
155
195
|
}
|
|
156
196
|
|
|
157
197
|
// ==================== Bulk Operations ====================
|
|
@@ -168,15 +208,19 @@ export class EntityMetaStore {
|
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
importMetas(metas: Map<string, EntityMeta>): void {
|
|
211
|
+
let imported = false
|
|
171
212
|
for (const [key, meta] of metas) {
|
|
172
213
|
this.entityMetas.set(key, { ...meta })
|
|
214
|
+
imported = true
|
|
173
215
|
}
|
|
216
|
+
if (imported) this.mutationVersion++
|
|
174
217
|
}
|
|
175
218
|
|
|
176
219
|
clear(): void {
|
|
177
220
|
this.loadStates.clear()
|
|
178
221
|
this.entityMetas.clear()
|
|
179
222
|
this.persistingEntities.clear()
|
|
180
|
-
this.
|
|
223
|
+
this.pessimisticInFlight.clear()
|
|
224
|
+
this.mutationVersion++
|
|
181
225
|
}
|
|
182
226
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createEntitySnapshot,
|
|
3
3
|
type EntitySnapshot,
|
|
4
4
|
} from './snapshots.js'
|
|
5
|
+
import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Manages entity snapshots — core CRUD for immutable entity data.
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
* Follows the same sub-store pattern as ErrorStore, RelationStore, etc.
|
|
13
14
|
* SnapshotStore delegates entity snapshot operations here.
|
|
14
15
|
*/
|
|
15
|
-
export class EntitySnapshotStore {
|
|
16
|
+
export class EntitySnapshotStore implements Rekeyable {
|
|
16
17
|
private readonly snapshots = new Map<string, EntitySnapshot>()
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -23,6 +24,67 @@ export class EntitySnapshotStore {
|
|
|
23
24
|
*/
|
|
24
25
|
private readonly idIndex = new Map<string, string>()
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Monotonic counter bumped only when the set of keys or the id→key index
|
|
29
|
+
* changes (new key, removal, rekey, bulk import, clear). Pure value edits
|
|
30
|
+
* (setFieldValue/updateFields/commit/reset/...) do NOT bump it — they cannot
|
|
31
|
+
* change reachability — so the per-keystroke edit path keeps the
|
|
32
|
+
* {@link ReachabilityAnalyzer} cache warm.
|
|
33
|
+
*/
|
|
34
|
+
private mutationVersion = 0
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Monotonic counter bumped on every EDITABLE-layer snapshot write (local
|
|
38
|
+
* setData / updateFields / setFieldValue / reset) — the writes an undo gesture
|
|
39
|
+
* must first record. Server-baseline writes (server setData, refreshServerData,
|
|
40
|
+
* commit, commitFields) and non-write bookkeeping (remove, bumpVersion, import,
|
|
41
|
+
* rekey, clear) deliberately do NOT bump it. Read by the undo write-guard to
|
|
42
|
+
* detect a mutating path that wrote without recording. See UndoJournal.
|
|
43
|
+
*/
|
|
44
|
+
private editableWriteVersion = 0
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Monotonic counter bumped on every write that can change a snapshot's `data`
|
|
48
|
+
* or `serverData` — i.e. every write that can change whether the entity is
|
|
49
|
+
* dirty. Unlike the two counters above it spans BOTH layers: `mutationVersion`
|
|
50
|
+
* ignores value edits and `editableWriteVersion` ignores server-baseline writes
|
|
51
|
+
* (refreshServerData/commit/commitFields), so neither alone is a sound dirty
|
|
52
|
+
* key. {@link bumpVersion} is excluded — it rewrites neither side. Read by
|
|
53
|
+
* SnapshotStore.getDirtyVersion() to memoize the full-store dirty scan.
|
|
54
|
+
*/
|
|
55
|
+
private dataWriteVersion = 0
|
|
56
|
+
|
|
57
|
+
getMutationVersion(): number {
|
|
58
|
+
return this.mutationVersion
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getEditableWriteVersion(): number {
|
|
62
|
+
return this.editableWriteVersion
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getDataWriteVersion(): number {
|
|
66
|
+
return this.dataWriteVersion
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The single write chokepoint. Every path that installs a snapshot goes through
|
|
71
|
+
* here, so {@link dataWriteVersion} cannot be forgotten by a new mutating method
|
|
72
|
+
* or a new early-return branch — a stale dirty memo is silent (dead Save button),
|
|
73
|
+
* so the bump must not be hand-maintained per call site. Mirrors
|
|
74
|
+
* HasOneStore.writeRelation / HasManyStore.writeHasMany.
|
|
75
|
+
*/
|
|
76
|
+
private writeSnapshot(key: string, snapshot: EntitySnapshot): void {
|
|
77
|
+
this.snapshots.set(key, snapshot)
|
|
78
|
+
this.dataWriteVersion++
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The single delete chokepoint — counterpart of {@link writeSnapshot}. */
|
|
82
|
+
private deleteSnapshot(key: string): void {
|
|
83
|
+
if (this.snapshots.delete(key)) {
|
|
84
|
+
this.dataWriteVersion++
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
26
88
|
get(key: string): EntitySnapshot | undefined {
|
|
27
89
|
return this.snapshots.get(key)
|
|
28
90
|
}
|
|
@@ -60,8 +122,13 @@ export class EntitySnapshotStore {
|
|
|
60
122
|
(existing?.version ?? 0) + 1,
|
|
61
123
|
)
|
|
62
124
|
|
|
63
|
-
this.
|
|
125
|
+
this.writeSnapshot(key, newSnapshot)
|
|
126
|
+
// The id index resolves relation edges for reachability, which memoizes on
|
|
127
|
+
// mutationVersion — so any change to the index must move it, not just a new key.
|
|
128
|
+
const indexChanged = this.idIndex.get(id) !== key
|
|
64
129
|
this.idIndex.set(id, key)
|
|
130
|
+
if (!existing || indexChanged) this.mutationVersion++
|
|
131
|
+
if (!isServerData) this.editableWriteVersion++
|
|
65
132
|
return newSnapshot
|
|
66
133
|
}
|
|
67
134
|
|
|
@@ -111,7 +178,7 @@ export class EntitySnapshotStore {
|
|
|
111
178
|
existing.version + 1,
|
|
112
179
|
)
|
|
113
180
|
|
|
114
|
-
this.
|
|
181
|
+
this.writeSnapshot(key, newSnapshot)
|
|
115
182
|
return newSnapshot
|
|
116
183
|
}
|
|
117
184
|
|
|
@@ -135,7 +202,8 @@ export class EntitySnapshotStore {
|
|
|
135
202
|
existing.version + 1,
|
|
136
203
|
)
|
|
137
204
|
|
|
138
|
-
this.
|
|
205
|
+
this.writeSnapshot(key, newSnapshot)
|
|
206
|
+
this.editableWriteVersion++
|
|
139
207
|
return newSnapshot
|
|
140
208
|
}
|
|
141
209
|
|
|
@@ -156,7 +224,8 @@ export class EntitySnapshotStore {
|
|
|
156
224
|
existing.version + 1,
|
|
157
225
|
)
|
|
158
226
|
|
|
159
|
-
this.
|
|
227
|
+
this.writeSnapshot(key, newSnapshot)
|
|
228
|
+
this.editableWriteVersion++
|
|
160
229
|
return true
|
|
161
230
|
}
|
|
162
231
|
|
|
@@ -175,7 +244,7 @@ export class EntitySnapshotStore {
|
|
|
175
244
|
existing.version + 1,
|
|
176
245
|
)
|
|
177
246
|
|
|
178
|
-
this.
|
|
247
|
+
this.writeSnapshot(key, newSnapshot)
|
|
179
248
|
}
|
|
180
249
|
|
|
181
250
|
/**
|
|
@@ -193,7 +262,8 @@ export class EntitySnapshotStore {
|
|
|
193
262
|
existing.version + 1,
|
|
194
263
|
)
|
|
195
264
|
|
|
196
|
-
this.
|
|
265
|
+
this.writeSnapshot(key, newSnapshot)
|
|
266
|
+
this.editableWriteVersion++
|
|
197
267
|
}
|
|
198
268
|
|
|
199
269
|
/**
|
|
@@ -208,13 +278,19 @@ export class EntitySnapshotStore {
|
|
|
208
278
|
// that survivor as id-unresolvable.
|
|
209
279
|
if (existing && this.idIndex.get(existing.id) === key) {
|
|
210
280
|
this.idIndex.delete(existing.id)
|
|
281
|
+
this.mutationVersion++
|
|
211
282
|
}
|
|
212
|
-
this.
|
|
283
|
+
this.deleteSnapshot(key)
|
|
213
284
|
}
|
|
214
285
|
|
|
215
286
|
/**
|
|
216
287
|
* Bumps the version of an entity snapshot without changing data.
|
|
217
288
|
* Used by SubscriptionManager when child entities change.
|
|
289
|
+
*
|
|
290
|
+
* The ONE deliberate bypass of {@link writeSnapshot}: it reinstalls the very same
|
|
291
|
+
* `data` and `serverData` references under a new version, so it cannot change
|
|
292
|
+
* dirtiness and must not invalidate the dirty memo — it runs once per ancestor on
|
|
293
|
+
* every notification, which would keep the cache permanently cold.
|
|
218
294
|
*/
|
|
219
295
|
bumpVersion(key: string): void {
|
|
220
296
|
const existing = this.snapshots.get(key)
|
|
@@ -256,7 +332,7 @@ export class EntitySnapshotStore {
|
|
|
256
332
|
existing.version + 1,
|
|
257
333
|
)
|
|
258
334
|
|
|
259
|
-
this.
|
|
335
|
+
this.writeSnapshot(key, newSnapshot)
|
|
260
336
|
}
|
|
261
337
|
|
|
262
338
|
/**
|
|
@@ -280,36 +356,39 @@ export class EntitySnapshotStore {
|
|
|
280
356
|
importSnapshots(snapshots: Map<string, EntitySnapshot>): Set<string> {
|
|
281
357
|
const keys = new Set<string>()
|
|
282
358
|
for (const [key, snapshot] of snapshots) {
|
|
283
|
-
this.
|
|
359
|
+
this.writeSnapshot(key, snapshot)
|
|
284
360
|
this.idIndex.set(snapshot.id, key)
|
|
285
361
|
keys.add(key)
|
|
286
362
|
}
|
|
363
|
+
if (keys.size > 0) this.mutationVersion++
|
|
287
364
|
return keys
|
|
288
365
|
}
|
|
289
366
|
|
|
290
367
|
/**
|
|
291
|
-
* Moves a snapshot from
|
|
368
|
+
* Moves a snapshot from the temp key to the persisted key, updating the id in
|
|
369
|
+
* the snapshot data.
|
|
292
370
|
*/
|
|
293
|
-
rekey(
|
|
294
|
-
const snapshot = this.snapshots.get(oldKey)
|
|
371
|
+
rekey(ctx: RekeyContext): void {
|
|
372
|
+
const snapshot = this.snapshots.get(ctx.oldKey)
|
|
295
373
|
if (!snapshot) return
|
|
296
374
|
|
|
297
375
|
// Update id field in data and serverData
|
|
298
|
-
const data = { ...snapshot.data as Record<string, unknown>, id: newId }
|
|
299
|
-
const serverData = { ...snapshot.serverData as Record<string, unknown>, id: newId }
|
|
376
|
+
const data = { ...snapshot.data as Record<string, unknown>, id: ctx.newId }
|
|
377
|
+
const serverData = { ...snapshot.serverData as Record<string, unknown>, id: ctx.newId }
|
|
300
378
|
|
|
301
379
|
const newSnapshot = createEntitySnapshot(
|
|
302
|
-
newId,
|
|
380
|
+
ctx.newId,
|
|
303
381
|
snapshot.entityType,
|
|
304
382
|
data,
|
|
305
383
|
serverData,
|
|
306
384
|
snapshot.version + 1,
|
|
307
385
|
)
|
|
308
386
|
|
|
309
|
-
this.
|
|
310
|
-
this.
|
|
387
|
+
this.deleteSnapshot(ctx.oldKey)
|
|
388
|
+
this.writeSnapshot(ctx.newKey, newSnapshot)
|
|
311
389
|
this.idIndex.delete(snapshot.id)
|
|
312
|
-
this.idIndex.set(newId, newKey)
|
|
390
|
+
this.idIndex.set(ctx.newId, ctx.newKey)
|
|
391
|
+
this.mutationVersion++
|
|
313
392
|
}
|
|
314
393
|
|
|
315
394
|
keys(): IterableIterator<string> {
|
|
@@ -327,8 +406,12 @@ export class EntitySnapshotStore {
|
|
|
327
406
|
}
|
|
328
407
|
|
|
329
408
|
clear(): void {
|
|
409
|
+
// Bulk drop: bumps both counters inline rather than per key, the same shape as
|
|
410
|
+
// HasOneStore.clear / HasManyStore.clear.
|
|
330
411
|
this.snapshots.clear()
|
|
331
412
|
this.idIndex.clear()
|
|
413
|
+
this.mutationVersion++
|
|
414
|
+
this.dataWriteVersion++
|
|
332
415
|
}
|
|
333
416
|
}
|
|
334
417
|
|