@tanstack/db 0.0.11 → 0.0.13
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/cjs/SortedMap.cjs +38 -11
- package/dist/cjs/SortedMap.cjs.map +1 -1
- package/dist/cjs/SortedMap.d.cts +10 -0
- package/dist/cjs/collection.cjs +476 -144
- package/dist/cjs/collection.cjs.map +1 -1
- package/dist/cjs/collection.d.cts +107 -32
- package/dist/cjs/index.cjs +2 -1
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/optimistic-action.cjs +21 -0
- package/dist/cjs/optimistic-action.cjs.map +1 -0
- package/dist/cjs/optimistic-action.d.cts +39 -0
- package/dist/cjs/query/compiled-query.cjs +38 -16
- package/dist/cjs/query/compiled-query.cjs.map +1 -1
- package/dist/cjs/query/query-builder.cjs +2 -2
- package/dist/cjs/query/query-builder.cjs.map +1 -1
- package/dist/cjs/transactions.cjs +3 -1
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/types.d.cts +83 -10
- package/dist/esm/SortedMap.d.ts +10 -0
- package/dist/esm/SortedMap.js +38 -11
- package/dist/esm/SortedMap.js.map +1 -1
- package/dist/esm/collection.d.ts +107 -32
- package/dist/esm/collection.js +477 -145
- package/dist/esm/collection.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +3 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/optimistic-action.d.ts +39 -0
- package/dist/esm/optimistic-action.js +21 -0
- package/dist/esm/optimistic-action.js.map +1 -0
- package/dist/esm/query/compiled-query.js +38 -16
- package/dist/esm/query/compiled-query.js.map +1 -1
- package/dist/esm/query/query-builder.js +2 -2
- package/dist/esm/query/query-builder.js.map +1 -1
- package/dist/esm/transactions.js +3 -1
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/types.d.ts +83 -10
- package/package.json +1 -1
- package/src/SortedMap.ts +46 -13
- package/src/collection.ts +689 -239
- package/src/index.ts +1 -0
- package/src/optimistic-action.ts +65 -0
- package/src/query/compiled-query.ts +79 -21
- package/src/query/query-builder.ts +2 -2
- package/src/transactions.ts +6 -1
- package/src/types.ts +124 -8
package/src/collection.ts
CHANGED
|
@@ -6,28 +6,22 @@ import type {
|
|
|
6
6
|
ChangeListener,
|
|
7
7
|
ChangeMessage,
|
|
8
8
|
CollectionConfig,
|
|
9
|
+
CollectionStatus,
|
|
9
10
|
Fn,
|
|
10
11
|
InsertConfig,
|
|
11
12
|
OperationConfig,
|
|
12
13
|
OptimisticChangeMessage,
|
|
13
14
|
PendingMutation,
|
|
15
|
+
ResolveType,
|
|
14
16
|
StandardSchema,
|
|
15
17
|
Transaction as TransactionType,
|
|
16
18
|
UtilsRecord,
|
|
17
19
|
} from "./types"
|
|
20
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
18
21
|
|
|
19
22
|
// Store collections in memory
|
|
20
23
|
export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
|
|
21
24
|
|
|
22
|
-
// Map to track loading collections
|
|
23
|
-
const loadingCollectionResolvers = new Map<
|
|
24
|
-
string,
|
|
25
|
-
{
|
|
26
|
-
promise: Promise<CollectionImpl<any, any>>
|
|
27
|
-
resolve: (value: CollectionImpl<any, any>) => void
|
|
28
|
-
}
|
|
29
|
-
>()
|
|
30
|
-
|
|
31
25
|
interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
32
26
|
committed: boolean
|
|
33
27
|
operations: Array<OptimisticChangeMessage<T>>
|
|
@@ -36,6 +30,7 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
|
36
30
|
/**
|
|
37
31
|
* Enhanced Collection interface that includes both data type T and utilities TUtils
|
|
38
32
|
* @template T - The type of items in the collection
|
|
33
|
+
* @template TKey - The type of the key for the collection
|
|
39
34
|
* @template TUtils - The utilities record type
|
|
40
35
|
*/
|
|
41
36
|
export interface Collection<
|
|
@@ -49,20 +44,53 @@ export interface Collection<
|
|
|
49
44
|
/**
|
|
50
45
|
* Creates a new Collection instance with the given configuration
|
|
51
46
|
*
|
|
52
|
-
* @template
|
|
47
|
+
* @template TExplicit - The explicit type of items in the collection (highest priority)
|
|
53
48
|
* @template TKey - The type of the key for the collection
|
|
54
49
|
* @template TUtils - The utilities record type
|
|
50
|
+
* @template TSchema - The schema type for validation and type inference (second priority)
|
|
51
|
+
* @template TFallback - The fallback type if no explicit or schema type is provided
|
|
55
52
|
* @param options - Collection options with optional utilities
|
|
56
53
|
* @returns A new Collection with utilities exposed both at top level and under .utils
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* // Using explicit type
|
|
57
|
+
* const todos = createCollection<Todo>({
|
|
58
|
+
* getKey: (todo) => todo.id,
|
|
59
|
+
* sync: { sync: () => {} }
|
|
60
|
+
* })
|
|
61
|
+
*
|
|
62
|
+
* // Using schema for type inference (preferred as it also gives you client side validation)
|
|
63
|
+
* const todoSchema = z.object({
|
|
64
|
+
* id: z.string(),
|
|
65
|
+
* title: z.string(),
|
|
66
|
+
* completed: z.boolean()
|
|
67
|
+
* })
|
|
68
|
+
*
|
|
69
|
+
* const todos = createCollection({
|
|
70
|
+
* schema: todoSchema,
|
|
71
|
+
* getKey: (todo) => todo.id,
|
|
72
|
+
* sync: { sync: () => {} }
|
|
73
|
+
* })
|
|
74
|
+
*
|
|
75
|
+
* // Note: You must provide either an explicit type or a schema, but not both
|
|
57
76
|
*/
|
|
58
77
|
export function createCollection<
|
|
59
|
-
|
|
78
|
+
TExplicit = unknown,
|
|
60
79
|
TKey extends string | number = string | number,
|
|
61
80
|
TUtils extends UtilsRecord = {},
|
|
81
|
+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
|
|
82
|
+
TFallback extends object = Record<string, unknown>,
|
|
62
83
|
>(
|
|
63
|
-
options: CollectionConfig<
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
options: CollectionConfig<
|
|
85
|
+
ResolveType<TExplicit, TSchema, TFallback>,
|
|
86
|
+
TKey,
|
|
87
|
+
TSchema
|
|
88
|
+
> & { utils?: TUtils }
|
|
89
|
+
): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils> {
|
|
90
|
+
const collection = new CollectionImpl<
|
|
91
|
+
ResolveType<TExplicit, TSchema, TFallback>,
|
|
92
|
+
TKey
|
|
93
|
+
>(options)
|
|
66
94
|
|
|
67
95
|
// Copy utils to both top level and .utils namespace
|
|
68
96
|
if (options.utils) {
|
|
@@ -71,98 +99,11 @@ export function createCollection<
|
|
|
71
99
|
collection.utils = {} as TUtils
|
|
72
100
|
}
|
|
73
101
|
|
|
74
|
-
return collection as Collection<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished)
|
|
80
|
-
* If the collection has already loaded, it resolves immediately
|
|
81
|
-
*
|
|
82
|
-
* This function is useful in route loaders or similar pre-rendering scenarios where you want
|
|
83
|
-
* to ensure data is available before a route transition completes. It uses the same shared collection
|
|
84
|
-
* instance that will be used by useCollection, ensuring data consistency.
|
|
85
|
-
*
|
|
86
|
-
* @example
|
|
87
|
-
* ```typescript
|
|
88
|
-
* // In a route loader
|
|
89
|
-
* async function loader({ params }) {
|
|
90
|
-
* await preloadCollection({
|
|
91
|
-
* id: `users-${params.userId}`,
|
|
92
|
-
* sync: { ... },
|
|
93
|
-
* });
|
|
94
|
-
*
|
|
95
|
-
* return null;
|
|
96
|
-
* }
|
|
97
|
-
* ```
|
|
98
|
-
*
|
|
99
|
-
* @template T - The type of items in the collection
|
|
100
|
-
* @param config - Configuration for the collection, including id and sync
|
|
101
|
-
* @returns Promise that resolves when the initial sync is finished
|
|
102
|
-
*/
|
|
103
|
-
export function preloadCollection<
|
|
104
|
-
T extends object = Record<string, unknown>,
|
|
105
|
-
TKey extends string | number = string | number,
|
|
106
|
-
>(config: CollectionConfig<T, TKey>): Promise<CollectionImpl<T, TKey>> {
|
|
107
|
-
if (!config.id) {
|
|
108
|
-
throw new Error(`The id property is required for preloadCollection`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// If the collection is already fully loaded, return a resolved promise
|
|
112
|
-
if (
|
|
113
|
-
collectionsStore.has(config.id) &&
|
|
114
|
-
!loadingCollectionResolvers.has(config.id)
|
|
115
|
-
) {
|
|
116
|
-
return Promise.resolve(
|
|
117
|
-
collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// If the collection is in the process of loading, return its promise
|
|
122
|
-
if (loadingCollectionResolvers.has(config.id)) {
|
|
123
|
-
return loadingCollectionResolvers.get(config.id)!.promise
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Create a new collection instance if it doesn't exist
|
|
127
|
-
if (!collectionsStore.has(config.id)) {
|
|
128
|
-
collectionsStore.set(
|
|
129
|
-
config.id,
|
|
130
|
-
createCollection<T, TKey>({
|
|
131
|
-
id: config.id,
|
|
132
|
-
getKey: config.getKey,
|
|
133
|
-
sync: config.sync,
|
|
134
|
-
schema: config.schema,
|
|
135
|
-
})
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
140
|
-
|
|
141
|
-
// Create a promise that will resolve after the first commit
|
|
142
|
-
let resolveFirstCommit: (value: CollectionImpl<T, TKey>) => void
|
|
143
|
-
const firstCommitPromise = new Promise<CollectionImpl<T, TKey>>((resolve) => {
|
|
144
|
-
resolveFirstCommit = resolve
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// Store the loading promise first
|
|
148
|
-
loadingCollectionResolvers.set(config.id, {
|
|
149
|
-
promise: firstCommitPromise,
|
|
150
|
-
resolve: resolveFirstCommit!,
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
// Register a one-time listener for the first commit
|
|
154
|
-
collection.onFirstCommit(() => {
|
|
155
|
-
if (!config.id) {
|
|
156
|
-
throw new Error(`The id property is required for preloadCollection`)
|
|
157
|
-
}
|
|
158
|
-
if (loadingCollectionResolvers.has(config.id)) {
|
|
159
|
-
const resolver = loadingCollectionResolvers.get(config.id)!
|
|
160
|
-
loadingCollectionResolvers.delete(config.id)
|
|
161
|
-
resolver.resolve(collection)
|
|
162
|
-
}
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
return firstCommitPromise
|
|
102
|
+
return collection as Collection<
|
|
103
|
+
ResolveType<TExplicit, TSchema, TFallback>,
|
|
104
|
+
TKey,
|
|
105
|
+
TUtils
|
|
106
|
+
>
|
|
166
107
|
}
|
|
167
108
|
|
|
168
109
|
/**
|
|
@@ -184,8 +125,8 @@ export class SchemaValidationError extends Error {
|
|
|
184
125
|
message?: string
|
|
185
126
|
) {
|
|
186
127
|
const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues
|
|
187
|
-
.map((issue) => issue.message)
|
|
188
|
-
.join(
|
|
128
|
+
.map((issue) => `\n- ${issue.message} - path: ${issue.path}`)
|
|
129
|
+
.join(``)}`
|
|
189
130
|
|
|
190
131
|
super(message || defaultMessage)
|
|
191
132
|
this.name = `SchemaValidationError`
|
|
@@ -198,10 +139,12 @@ export class CollectionImpl<
|
|
|
198
139
|
T extends object = Record<string, unknown>,
|
|
199
140
|
TKey extends string | number = string | number,
|
|
200
141
|
> {
|
|
201
|
-
public
|
|
142
|
+
public config: CollectionConfig<T, TKey, any>
|
|
202
143
|
|
|
203
144
|
// Core state - make public for testing
|
|
204
|
-
public
|
|
145
|
+
public transactions: SortedMap<string, Transaction<any>>
|
|
146
|
+
public pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
|
|
147
|
+
public syncedData: Map<TKey, T> | SortedMap<TKey, T>
|
|
205
148
|
public syncedMetadata = new Map<TKey, unknown>()
|
|
206
149
|
|
|
207
150
|
// Optimistic state tracking - make public for testing
|
|
@@ -219,14 +162,23 @@ export class CollectionImpl<
|
|
|
219
162
|
// This is populated by createCollection
|
|
220
163
|
public utils: Record<string, Fn> = {}
|
|
221
164
|
|
|
222
|
-
|
|
165
|
+
// State used for computing the change events
|
|
223
166
|
private syncedKeys = new Set<TKey>()
|
|
224
|
-
|
|
167
|
+
private preSyncVisibleState = new Map<TKey, T>()
|
|
168
|
+
private recentlySyncedKeys = new Set<TKey>()
|
|
225
169
|
private hasReceivedFirstCommit = false
|
|
170
|
+
private isCommittingSyncTransactions = false
|
|
226
171
|
|
|
227
172
|
// Array to store one-time commit listeners
|
|
228
173
|
private onFirstCommitCallbacks: Array<() => void> = []
|
|
229
174
|
|
|
175
|
+
// Lifecycle management
|
|
176
|
+
private _status: CollectionStatus = `idle`
|
|
177
|
+
private activeSubscribersCount = 0
|
|
178
|
+
private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
179
|
+
private preloadPromise: Promise<void> | null = null
|
|
180
|
+
private syncCleanupFn: (() => void) | null = null
|
|
181
|
+
|
|
230
182
|
/**
|
|
231
183
|
* Register a callback to be executed on the next commit
|
|
232
184
|
* Useful for preloading collections
|
|
@@ -238,13 +190,78 @@ export class CollectionImpl<
|
|
|
238
190
|
|
|
239
191
|
public id = ``
|
|
240
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Gets the current status of the collection
|
|
195
|
+
*/
|
|
196
|
+
public get status(): CollectionStatus {
|
|
197
|
+
return this._status
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validates that the collection is in a usable state for data operations
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
private validateCollectionUsable(operation: string): void {
|
|
205
|
+
switch (this._status) {
|
|
206
|
+
case `error`:
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Cannot perform ${operation} on collection "${this.id}" - collection is in error state. ` +
|
|
209
|
+
`Try calling cleanup() and restarting the collection.`
|
|
210
|
+
)
|
|
211
|
+
case `cleaned-up`:
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. ` +
|
|
214
|
+
`The collection will automatically restart on next access.`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validates state transitions to prevent invalid status changes
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
private validateStatusTransition(
|
|
224
|
+
from: CollectionStatus,
|
|
225
|
+
to: CollectionStatus
|
|
226
|
+
): void {
|
|
227
|
+
if (from === to) {
|
|
228
|
+
// Allow same state transitions
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const validTransitions: Record<
|
|
232
|
+
CollectionStatus,
|
|
233
|
+
Array<CollectionStatus>
|
|
234
|
+
> = {
|
|
235
|
+
idle: [`loading`, `error`, `cleaned-up`],
|
|
236
|
+
loading: [`ready`, `error`, `cleaned-up`],
|
|
237
|
+
ready: [`cleaned-up`, `error`],
|
|
238
|
+
error: [`cleaned-up`, `idle`],
|
|
239
|
+
"cleaned-up": [`loading`, `error`],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!validTransitions[from].includes(to)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Safely update the collection status with validation
|
|
251
|
+
* @private
|
|
252
|
+
*/
|
|
253
|
+
private setStatus(newStatus: CollectionStatus): void {
|
|
254
|
+
this.validateStatusTransition(this._status, newStatus)
|
|
255
|
+
this._status = newStatus
|
|
256
|
+
}
|
|
257
|
+
|
|
241
258
|
/**
|
|
242
259
|
* Creates a new Collection instance
|
|
243
260
|
*
|
|
244
261
|
* @param config - Configuration object for the collection
|
|
245
262
|
* @throws Error if sync config is missing
|
|
246
263
|
*/
|
|
247
|
-
constructor(config: CollectionConfig<T, TKey>) {
|
|
264
|
+
constructor(config: CollectionConfig<T, TKey, any>) {
|
|
248
265
|
// eslint-disable-next-line
|
|
249
266
|
if (!config) {
|
|
250
267
|
throw new Error(`Collection requires a config`)
|
|
@@ -266,74 +283,276 @@ export class CollectionImpl<
|
|
|
266
283
|
|
|
267
284
|
this.config = config
|
|
268
285
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
collection: this,
|
|
272
|
-
begin: () => {
|
|
273
|
-
this.pendingSyncedTransactions.push({
|
|
274
|
-
committed: false,
|
|
275
|
-
operations: [],
|
|
276
|
-
})
|
|
277
|
-
},
|
|
278
|
-
write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
|
|
279
|
-
const pendingTransaction =
|
|
280
|
-
this.pendingSyncedTransactions[
|
|
281
|
-
this.pendingSyncedTransactions.length - 1
|
|
282
|
-
]
|
|
283
|
-
if (!pendingTransaction) {
|
|
284
|
-
throw new Error(`No pending sync transaction to write to`)
|
|
285
|
-
}
|
|
286
|
-
if (pendingTransaction.committed) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`The pending sync transaction is already committed, you can't still write to it.`
|
|
289
|
-
)
|
|
290
|
-
}
|
|
291
|
-
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
286
|
+
// Store in global collections store
|
|
287
|
+
collectionsStore.set(this.id, this)
|
|
292
288
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
289
|
+
// Set up data storage with optional comparison function
|
|
290
|
+
if (this.config.compare) {
|
|
291
|
+
this.syncedData = new SortedMap<TKey, T>(this.config.compare)
|
|
292
|
+
} else {
|
|
293
|
+
this.syncedData = new Map<TKey, T>()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Only start sync immediately if explicitly enabled
|
|
297
|
+
if (config.startSync === true) {
|
|
298
|
+
this.startSync()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Start sync immediately - internal method for compiled queries
|
|
304
|
+
* This bypasses lazy loading for special cases like live query results
|
|
305
|
+
*/
|
|
306
|
+
public startSyncImmediate(): void {
|
|
307
|
+
this.startSync()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start the sync process for this collection
|
|
312
|
+
* This is called when the collection is first accessed or preloaded
|
|
313
|
+
*/
|
|
314
|
+
private startSync(): void {
|
|
315
|
+
if (this._status !== `idle` && this._status !== `cleaned-up`) {
|
|
316
|
+
return // Already started or in progress
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.setStatus(`loading`)
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const cleanupFn = this.config.sync.sync({
|
|
323
|
+
collection: this,
|
|
324
|
+
begin: () => {
|
|
325
|
+
this.pendingSyncedTransactions.push({
|
|
326
|
+
committed: false,
|
|
327
|
+
operations: [],
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
|
|
331
|
+
const pendingTransaction =
|
|
332
|
+
this.pendingSyncedTransactions[
|
|
333
|
+
this.pendingSyncedTransactions.length - 1
|
|
334
|
+
]
|
|
335
|
+
if (!pendingTransaction) {
|
|
336
|
+
throw new Error(`No pending sync transaction to write to`)
|
|
337
|
+
}
|
|
338
|
+
if (pendingTransaction.committed) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`The pending sync transaction is already committed, you can't still write to it.`
|
|
299
341
|
)
|
|
300
|
-
|
|
342
|
+
}
|
|
343
|
+
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
344
|
+
|
|
345
|
+
// Check if an item with this key already exists when inserting
|
|
346
|
+
if (messageWithoutKey.type === `insert`) {
|
|
347
|
+
if (
|
|
348
|
+
this.syncedData.has(key) &&
|
|
349
|
+
!pendingTransaction.operations.some(
|
|
350
|
+
(op) => op.key === key && op.type === `delete`
|
|
351
|
+
)
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const message: ChangeMessage<T> = {
|
|
360
|
+
...messageWithoutKey,
|
|
361
|
+
key,
|
|
362
|
+
}
|
|
363
|
+
pendingTransaction.operations.push(message)
|
|
364
|
+
},
|
|
365
|
+
commit: () => {
|
|
366
|
+
const pendingTransaction =
|
|
367
|
+
this.pendingSyncedTransactions[
|
|
368
|
+
this.pendingSyncedTransactions.length - 1
|
|
369
|
+
]
|
|
370
|
+
if (!pendingTransaction) {
|
|
371
|
+
throw new Error(`No pending sync transaction to commit`)
|
|
372
|
+
}
|
|
373
|
+
if (pendingTransaction.committed) {
|
|
301
374
|
throw new Error(
|
|
302
|
-
`
|
|
375
|
+
`The pending sync transaction is already committed, you can't commit it again.`
|
|
303
376
|
)
|
|
304
377
|
}
|
|
305
|
-
}
|
|
306
378
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
379
|
+
pendingTransaction.committed = true
|
|
380
|
+
this.commitPendingTransactions()
|
|
381
|
+
|
|
382
|
+
// Update status to ready after first commit
|
|
383
|
+
if (this._status === `loading`) {
|
|
384
|
+
this.setStatus(`ready`)
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Store cleanup function if provided
|
|
390
|
+
this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.setStatus(`error`)
|
|
393
|
+
throw error
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Preload the collection data by starting sync if not already started
|
|
399
|
+
* Multiple concurrent calls will share the same promise
|
|
400
|
+
*/
|
|
401
|
+
public preload(): Promise<void> {
|
|
402
|
+
if (this.preloadPromise) {
|
|
403
|
+
return this.preloadPromise
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
this.preloadPromise = new Promise<void>((resolve, reject) => {
|
|
407
|
+
if (this._status === `ready`) {
|
|
408
|
+
resolve()
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (this._status === `error`) {
|
|
413
|
+
reject(new Error(`Collection is in error state`))
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Register callback BEFORE starting sync to avoid race condition
|
|
418
|
+
this.onFirstCommit(() => {
|
|
419
|
+
resolve()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// Start sync if collection hasn't started yet or was cleaned up
|
|
423
|
+
if (this._status === `idle` || this._status === `cleaned-up`) {
|
|
424
|
+
try {
|
|
425
|
+
this.startSync()
|
|
426
|
+
} catch (error) {
|
|
427
|
+
reject(error)
|
|
428
|
+
return
|
|
320
429
|
}
|
|
321
|
-
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
return this.preloadPromise
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Clean up the collection by stopping sync and clearing data
|
|
438
|
+
* This can be called manually or automatically by garbage collection
|
|
439
|
+
*/
|
|
440
|
+
public async cleanup(): Promise<void> {
|
|
441
|
+
// Clear GC timeout
|
|
442
|
+
if (this.gcTimeoutId) {
|
|
443
|
+
clearTimeout(this.gcTimeoutId)
|
|
444
|
+
this.gcTimeoutId = null
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Stop sync - wrap in try/catch since it's user-provided code
|
|
448
|
+
try {
|
|
449
|
+
if (this.syncCleanupFn) {
|
|
450
|
+
this.syncCleanupFn()
|
|
451
|
+
this.syncCleanupFn = null
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// Re-throw in a microtask to surface the error after cleanup completes
|
|
455
|
+
queueMicrotask(() => {
|
|
456
|
+
if (error instanceof Error) {
|
|
457
|
+
// Preserve the original error and stack trace
|
|
458
|
+
const wrappedError = new Error(
|
|
459
|
+
`Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
|
|
460
|
+
)
|
|
461
|
+
wrappedError.cause = error
|
|
462
|
+
wrappedError.stack = error.stack
|
|
463
|
+
throw wrappedError
|
|
464
|
+
} else {
|
|
322
465
|
throw new Error(
|
|
323
|
-
`
|
|
466
|
+
`Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
|
|
324
467
|
)
|
|
325
468
|
}
|
|
469
|
+
})
|
|
470
|
+
}
|
|
326
471
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
472
|
+
// Clear data
|
|
473
|
+
this.syncedData.clear()
|
|
474
|
+
this.syncedMetadata.clear()
|
|
475
|
+
this.derivedUpserts.clear()
|
|
476
|
+
this.derivedDeletes.clear()
|
|
477
|
+
this._size = 0
|
|
478
|
+
this.pendingSyncedTransactions = []
|
|
479
|
+
this.syncedKeys.clear()
|
|
480
|
+
this.hasReceivedFirstCommit = false
|
|
481
|
+
this.onFirstCommitCallbacks = []
|
|
482
|
+
this.preloadPromise = null
|
|
483
|
+
|
|
484
|
+
// Update status
|
|
485
|
+
this.setStatus(`cleaned-up`)
|
|
486
|
+
|
|
487
|
+
return Promise.resolve()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Start the garbage collection timer
|
|
492
|
+
* Called when the collection becomes inactive (no subscribers)
|
|
493
|
+
*/
|
|
494
|
+
private startGCTimer(): void {
|
|
495
|
+
if (this.gcTimeoutId) {
|
|
496
|
+
clearTimeout(this.gcTimeoutId)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
|
|
500
|
+
this.gcTimeoutId = setTimeout(() => {
|
|
501
|
+
if (this.activeSubscribersCount === 0) {
|
|
502
|
+
this.cleanup()
|
|
503
|
+
}
|
|
504
|
+
}, gcTime)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Cancel the garbage collection timer
|
|
509
|
+
* Called when the collection becomes active again
|
|
510
|
+
*/
|
|
511
|
+
private cancelGCTimer(): void {
|
|
512
|
+
if (this.gcTimeoutId) {
|
|
513
|
+
clearTimeout(this.gcTimeoutId)
|
|
514
|
+
this.gcTimeoutId = null
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Increment the active subscribers count and start sync if needed
|
|
520
|
+
*/
|
|
521
|
+
private addSubscriber(): void {
|
|
522
|
+
this.activeSubscribersCount++
|
|
523
|
+
this.cancelGCTimer()
|
|
524
|
+
|
|
525
|
+
// Start sync if collection was cleaned up
|
|
526
|
+
if (this._status === `cleaned-up` || this._status === `idle`) {
|
|
527
|
+
this.startSync()
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Decrement the active subscribers count and start GC timer if needed
|
|
533
|
+
*/
|
|
534
|
+
private removeSubscriber(): void {
|
|
535
|
+
this.activeSubscribersCount--
|
|
536
|
+
|
|
537
|
+
if (this.activeSubscribersCount === 0) {
|
|
538
|
+
this.activeSubscribersCount = 0
|
|
539
|
+
this.startGCTimer()
|
|
540
|
+
} else if (this.activeSubscribersCount < 0) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Active subscribers count is negative - this should never happen`
|
|
543
|
+
)
|
|
544
|
+
}
|
|
331
545
|
}
|
|
332
546
|
|
|
333
547
|
/**
|
|
334
548
|
* Recompute optimistic state from active transactions
|
|
335
549
|
*/
|
|
336
550
|
private recomputeOptimisticState(): void {
|
|
551
|
+
// Skip redundant recalculations when we're in the middle of committing sync transactions
|
|
552
|
+
if (this.isCommittingSyncTransactions) {
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
337
556
|
const previousState = new Map(this.derivedUpserts)
|
|
338
557
|
const previousDeletes = new Set(this.derivedDeletes)
|
|
339
558
|
|
|
@@ -341,23 +560,31 @@ export class CollectionImpl<
|
|
|
341
560
|
this.derivedUpserts.clear()
|
|
342
561
|
this.derivedDeletes.clear()
|
|
343
562
|
|
|
344
|
-
|
|
345
|
-
const
|
|
563
|
+
const activeTransactions: Array<Transaction<any>> = []
|
|
564
|
+
const completedTransactions: Array<Transaction<any>> = []
|
|
565
|
+
|
|
566
|
+
for (const transaction of this.transactions.values()) {
|
|
567
|
+
if (transaction.state === `completed`) {
|
|
568
|
+
completedTransactions.push(transaction)
|
|
569
|
+
} else if (![`completed`, `failed`].includes(transaction.state)) {
|
|
570
|
+
activeTransactions.push(transaction)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Apply active transactions only (completed transactions are handled by sync operations)
|
|
346
575
|
for (const transaction of activeTransactions) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
break
|
|
360
|
-
}
|
|
576
|
+
for (const mutation of transaction.mutations) {
|
|
577
|
+
if (mutation.collection === this) {
|
|
578
|
+
switch (mutation.type) {
|
|
579
|
+
case `insert`:
|
|
580
|
+
case `update`:
|
|
581
|
+
this.derivedUpserts.set(mutation.key, mutation.modified as T)
|
|
582
|
+
this.derivedDeletes.delete(mutation.key)
|
|
583
|
+
break
|
|
584
|
+
case `delete`:
|
|
585
|
+
this.derivedUpserts.delete(mutation.key)
|
|
586
|
+
this.derivedDeletes.add(mutation.key)
|
|
587
|
+
break
|
|
361
588
|
}
|
|
362
589
|
}
|
|
363
590
|
}
|
|
@@ -370,8 +597,58 @@ export class CollectionImpl<
|
|
|
370
597
|
const events: Array<ChangeMessage<T, TKey>> = []
|
|
371
598
|
this.collectOptimisticChanges(previousState, previousDeletes, events)
|
|
372
599
|
|
|
373
|
-
//
|
|
374
|
-
|
|
600
|
+
// Filter out events for recently synced keys to prevent duplicates
|
|
601
|
+
const filteredEventsBySyncStatus = events.filter(
|
|
602
|
+
(event) => !this.recentlySyncedKeys.has(event.key)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
// Filter out redundant delete events if there are pending sync transactions
|
|
606
|
+
// that will immediately restore the same data, but only for completed transactions
|
|
607
|
+
if (this.pendingSyncedTransactions.length > 0) {
|
|
608
|
+
const pendingSyncKeys = new Set<TKey>()
|
|
609
|
+
const completedTransactionMutations = new Set<string>()
|
|
610
|
+
|
|
611
|
+
// Collect keys from pending sync operations
|
|
612
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
613
|
+
for (const operation of transaction.operations) {
|
|
614
|
+
pendingSyncKeys.add(operation.key as TKey)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Collect mutation IDs from completed transactions
|
|
619
|
+
for (const tx of completedTransactions) {
|
|
620
|
+
for (const mutation of tx.mutations) {
|
|
621
|
+
if (mutation.collection === this) {
|
|
622
|
+
completedTransactionMutations.add(mutation.mutationId)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Only filter out delete events for keys that:
|
|
628
|
+
// 1. Have pending sync operations AND
|
|
629
|
+
// 2. Are from completed transactions (being cleaned up)
|
|
630
|
+
const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
|
|
631
|
+
if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
|
|
632
|
+
// Check if this delete is from clearing optimistic state of completed transactions
|
|
633
|
+
// We can infer this by checking if we have no remaining optimistic mutations for this key
|
|
634
|
+
const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
|
|
635
|
+
tx.mutations.some(
|
|
636
|
+
(m) => m.collection === this && m.key === event.key
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if (!hasActiveOptimisticMutation) {
|
|
641
|
+
return false // Skip this delete event as sync will restore the data
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return true
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
this.emitEvents(filteredEvents)
|
|
648
|
+
} else {
|
|
649
|
+
// Emit all events if no pending sync transactions
|
|
650
|
+
this.emitEvents(filteredEventsBySyncStatus)
|
|
651
|
+
}
|
|
375
652
|
}
|
|
376
653
|
|
|
377
654
|
/**
|
|
@@ -552,7 +829,10 @@ export class CollectionImpl<
|
|
|
552
829
|
for (const key of this.keys()) {
|
|
553
830
|
const value = this.get(key)
|
|
554
831
|
if (value !== undefined) {
|
|
555
|
-
|
|
832
|
+
const { _orderByIndex, ...copy } = value as T & {
|
|
833
|
+
_orderByIndex?: number | string
|
|
834
|
+
}
|
|
835
|
+
yield copy as T
|
|
556
836
|
}
|
|
557
837
|
}
|
|
558
838
|
}
|
|
@@ -564,7 +844,10 @@ export class CollectionImpl<
|
|
|
564
844
|
for (const key of this.keys()) {
|
|
565
845
|
const value = this.get(key)
|
|
566
846
|
if (value !== undefined) {
|
|
567
|
-
|
|
847
|
+
const { _orderByIndex, ...copy } = value as T & {
|
|
848
|
+
_orderByIndex?: number | string
|
|
849
|
+
}
|
|
850
|
+
yield [key, copy as T]
|
|
568
851
|
}
|
|
569
852
|
}
|
|
570
853
|
}
|
|
@@ -574,18 +857,46 @@ export class CollectionImpl<
|
|
|
574
857
|
* This method processes operations from pending transactions and applies them to the synced data
|
|
575
858
|
*/
|
|
576
859
|
commitPendingTransactions = () => {
|
|
577
|
-
if
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
)
|
|
581
|
-
|
|
860
|
+
// Check if there are any persisting transaction
|
|
861
|
+
let hasPersistingTransaction = false
|
|
862
|
+
for (const transaction of this.transactions.values()) {
|
|
863
|
+
if (transaction.state === `persisting`) {
|
|
864
|
+
hasPersistingTransaction = true
|
|
865
|
+
break
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!hasPersistingTransaction) {
|
|
870
|
+
// Set flag to prevent redundant optimistic state recalculations
|
|
871
|
+
this.isCommittingSyncTransactions = true
|
|
872
|
+
|
|
873
|
+
// First collect all keys that will be affected by sync operations
|
|
582
874
|
const changedKeys = new Set<TKey>()
|
|
875
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
876
|
+
for (const operation of transaction.operations) {
|
|
877
|
+
changedKeys.add(operation.key as TKey)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Use pre-captured state if available (from optimistic scenarios),
|
|
882
|
+
// otherwise capture current state (for pure sync scenarios)
|
|
883
|
+
let currentVisibleState = this.preSyncVisibleState
|
|
884
|
+
if (currentVisibleState.size === 0) {
|
|
885
|
+
// No pre-captured state, capture it now for pure sync operations
|
|
886
|
+
currentVisibleState = new Map<TKey, T>()
|
|
887
|
+
for (const key of changedKeys) {
|
|
888
|
+
const currentValue = this.get(key)
|
|
889
|
+
if (currentValue !== undefined) {
|
|
890
|
+
currentVisibleState.set(key, currentValue)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
583
895
|
const events: Array<ChangeMessage<T, TKey>> = []
|
|
584
896
|
|
|
585
897
|
for (const transaction of this.pendingSyncedTransactions) {
|
|
586
898
|
for (const operation of transaction.operations) {
|
|
587
899
|
const key = operation.key as TKey
|
|
588
|
-
changedKeys.add(key)
|
|
589
900
|
this.syncedKeys.add(key)
|
|
590
901
|
|
|
591
902
|
// Update metadata
|
|
@@ -608,22 +919,10 @@ export class CollectionImpl<
|
|
|
608
919
|
break
|
|
609
920
|
}
|
|
610
921
|
|
|
611
|
-
// Update synced data
|
|
612
|
-
const previousValue = this.syncedData.get(key)
|
|
613
|
-
|
|
922
|
+
// Update synced data
|
|
614
923
|
switch (operation.type) {
|
|
615
924
|
case `insert`:
|
|
616
925
|
this.syncedData.set(key, operation.value)
|
|
617
|
-
if (
|
|
618
|
-
!this.derivedDeletes.has(key) &&
|
|
619
|
-
!this.derivedUpserts.has(key)
|
|
620
|
-
) {
|
|
621
|
-
events.push({
|
|
622
|
-
type: `insert`,
|
|
623
|
-
key,
|
|
624
|
-
value: operation.value,
|
|
625
|
-
})
|
|
626
|
-
}
|
|
627
926
|
break
|
|
628
927
|
case `update`: {
|
|
629
928
|
const updatedValue = Object.assign(
|
|
@@ -632,38 +931,103 @@ export class CollectionImpl<
|
|
|
632
931
|
operation.value
|
|
633
932
|
)
|
|
634
933
|
this.syncedData.set(key, updatedValue)
|
|
635
|
-
if (
|
|
636
|
-
!this.derivedDeletes.has(key) &&
|
|
637
|
-
!this.derivedUpserts.has(key)
|
|
638
|
-
) {
|
|
639
|
-
events.push({
|
|
640
|
-
type: `update`,
|
|
641
|
-
key,
|
|
642
|
-
value: updatedValue,
|
|
643
|
-
previousValue,
|
|
644
|
-
})
|
|
645
|
-
}
|
|
646
934
|
break
|
|
647
935
|
}
|
|
648
936
|
case `delete`:
|
|
649
937
|
this.syncedData.delete(key)
|
|
650
|
-
if (
|
|
651
|
-
!this.derivedDeletes.has(key) &&
|
|
652
|
-
!this.derivedUpserts.has(key)
|
|
653
|
-
) {
|
|
654
|
-
if (previousValue) {
|
|
655
|
-
events.push({
|
|
656
|
-
type: `delete`,
|
|
657
|
-
key,
|
|
658
|
-
value: previousValue,
|
|
659
|
-
})
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
938
|
break
|
|
663
939
|
}
|
|
664
940
|
}
|
|
665
941
|
}
|
|
666
942
|
|
|
943
|
+
// Clear optimistic state since sync operations will now provide the authoritative data
|
|
944
|
+
this.derivedUpserts.clear()
|
|
945
|
+
this.derivedDeletes.clear()
|
|
946
|
+
|
|
947
|
+
// Reset flag and recompute optimistic state for any remaining active transactions
|
|
948
|
+
this.isCommittingSyncTransactions = false
|
|
949
|
+
for (const transaction of this.transactions.values()) {
|
|
950
|
+
if (![`completed`, `failed`].includes(transaction.state)) {
|
|
951
|
+
for (const mutation of transaction.mutations) {
|
|
952
|
+
if (mutation.collection === this) {
|
|
953
|
+
switch (mutation.type) {
|
|
954
|
+
case `insert`:
|
|
955
|
+
case `update`:
|
|
956
|
+
this.derivedUpserts.set(mutation.key, mutation.modified as T)
|
|
957
|
+
this.derivedDeletes.delete(mutation.key)
|
|
958
|
+
break
|
|
959
|
+
case `delete`:
|
|
960
|
+
this.derivedUpserts.delete(mutation.key)
|
|
961
|
+
this.derivedDeletes.add(mutation.key)
|
|
962
|
+
break
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Check for redundant sync operations that match completed optimistic operations
|
|
970
|
+
const completedOptimisticOps = new Map<TKey, any>()
|
|
971
|
+
|
|
972
|
+
for (const transaction of this.transactions.values()) {
|
|
973
|
+
if (transaction.state === `completed`) {
|
|
974
|
+
for (const mutation of transaction.mutations) {
|
|
975
|
+
if (mutation.collection === this && changedKeys.has(mutation.key)) {
|
|
976
|
+
completedOptimisticOps.set(mutation.key, {
|
|
977
|
+
type: mutation.type,
|
|
978
|
+
value: mutation.modified,
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Now check what actually changed in the final visible state
|
|
986
|
+
for (const key of changedKeys) {
|
|
987
|
+
const previousVisibleValue = currentVisibleState.get(key)
|
|
988
|
+
const newVisibleValue = this.get(key) // This returns the new derived state
|
|
989
|
+
|
|
990
|
+
// Check if this sync operation is redundant with a completed optimistic operation
|
|
991
|
+
const completedOp = completedOptimisticOps.get(key)
|
|
992
|
+
const isRedundantSync =
|
|
993
|
+
completedOp &&
|
|
994
|
+
newVisibleValue !== undefined &&
|
|
995
|
+
this.deepEqual(completedOp.value, newVisibleValue)
|
|
996
|
+
|
|
997
|
+
if (!isRedundantSync) {
|
|
998
|
+
if (
|
|
999
|
+
previousVisibleValue === undefined &&
|
|
1000
|
+
newVisibleValue !== undefined
|
|
1001
|
+
) {
|
|
1002
|
+
events.push({
|
|
1003
|
+
type: `insert`,
|
|
1004
|
+
key,
|
|
1005
|
+
value: newVisibleValue,
|
|
1006
|
+
})
|
|
1007
|
+
} else if (
|
|
1008
|
+
previousVisibleValue !== undefined &&
|
|
1009
|
+
newVisibleValue === undefined
|
|
1010
|
+
) {
|
|
1011
|
+
events.push({
|
|
1012
|
+
type: `delete`,
|
|
1013
|
+
key,
|
|
1014
|
+
value: previousVisibleValue,
|
|
1015
|
+
})
|
|
1016
|
+
} else if (
|
|
1017
|
+
previousVisibleValue !== undefined &&
|
|
1018
|
+
newVisibleValue !== undefined &&
|
|
1019
|
+
!this.deepEqual(previousVisibleValue, newVisibleValue)
|
|
1020
|
+
) {
|
|
1021
|
+
events.push({
|
|
1022
|
+
type: `update`,
|
|
1023
|
+
key,
|
|
1024
|
+
value: newVisibleValue,
|
|
1025
|
+
previousValue: previousVisibleValue,
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
667
1031
|
// Update cached size after synced data changes
|
|
668
1032
|
this._size = this.calculateSize()
|
|
669
1033
|
|
|
@@ -672,6 +1036,14 @@ export class CollectionImpl<
|
|
|
672
1036
|
|
|
673
1037
|
this.pendingSyncedTransactions = []
|
|
674
1038
|
|
|
1039
|
+
// Clear the pre-sync state since sync operations are complete
|
|
1040
|
+
this.preSyncVisibleState.clear()
|
|
1041
|
+
|
|
1042
|
+
// Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
|
|
1043
|
+
Promise.resolve().then(() => {
|
|
1044
|
+
this.recentlySyncedKeys.clear()
|
|
1045
|
+
})
|
|
1046
|
+
|
|
675
1047
|
// Call any registered one-time commit listeners
|
|
676
1048
|
if (!this.hasReceivedFirstCommit) {
|
|
677
1049
|
this.hasReceivedFirstCommit = true
|
|
@@ -707,6 +1079,29 @@ export class CollectionImpl<
|
|
|
707
1079
|
return `KEY::${this.id}/${key}`
|
|
708
1080
|
}
|
|
709
1081
|
|
|
1082
|
+
private deepEqual(a: any, b: any): boolean {
|
|
1083
|
+
if (a === b) return true
|
|
1084
|
+
if (a == null || b == null) return false
|
|
1085
|
+
if (typeof a !== typeof b) return false
|
|
1086
|
+
|
|
1087
|
+
if (typeof a === `object`) {
|
|
1088
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
1089
|
+
|
|
1090
|
+
const keysA = Object.keys(a)
|
|
1091
|
+
const keysB = Object.keys(b)
|
|
1092
|
+
if (keysA.length !== keysB.length) return false
|
|
1093
|
+
|
|
1094
|
+
const keysBSet = new Set(keysB)
|
|
1095
|
+
for (const key of keysA) {
|
|
1096
|
+
if (!keysBSet.has(key)) return false
|
|
1097
|
+
if (!this.deepEqual(a[key], b[key])) return false
|
|
1098
|
+
}
|
|
1099
|
+
return true
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return false
|
|
1103
|
+
}
|
|
1104
|
+
|
|
710
1105
|
private validateData(
|
|
711
1106
|
data: unknown,
|
|
712
1107
|
type: `insert` | `update`,
|
|
@@ -793,6 +1188,8 @@ export class CollectionImpl<
|
|
|
793
1188
|
* insert({ text: "Buy groceries" }, { key: "grocery-task" })
|
|
794
1189
|
*/
|
|
795
1190
|
insert = (data: T | Array<T>, config?: InsertConfig) => {
|
|
1191
|
+
this.validateCollectionUsable(`insert`)
|
|
1192
|
+
|
|
796
1193
|
const ambientTransaction = getActiveTransaction()
|
|
797
1194
|
|
|
798
1195
|
// If no ambient transaction exists, check for an onInsert handler early
|
|
@@ -803,7 +1200,7 @@ export class CollectionImpl<
|
|
|
803
1200
|
}
|
|
804
1201
|
|
|
805
1202
|
const items = Array.isArray(data) ? data : [data]
|
|
806
|
-
const mutations: Array<PendingMutation<T
|
|
1203
|
+
const mutations: Array<PendingMutation<T, `insert`>> = []
|
|
807
1204
|
|
|
808
1205
|
// Create mutations for each item
|
|
809
1206
|
items.forEach((item) => {
|
|
@@ -817,7 +1214,7 @@ export class CollectionImpl<
|
|
|
817
1214
|
}
|
|
818
1215
|
const globalKey = this.generateGlobalKey(key, item)
|
|
819
1216
|
|
|
820
|
-
const mutation: PendingMutation<T
|
|
1217
|
+
const mutation: PendingMutation<T, `insert`> = {
|
|
821
1218
|
mutationId: crypto.randomUUID(),
|
|
822
1219
|
original: {},
|
|
823
1220
|
modified: validatedData,
|
|
@@ -938,6 +1335,8 @@ export class CollectionImpl<
|
|
|
938
1335
|
throw new Error(`The first argument to update is missing`)
|
|
939
1336
|
}
|
|
940
1337
|
|
|
1338
|
+
this.validateCollectionUsable(`update`)
|
|
1339
|
+
|
|
941
1340
|
const ambientTransaction = getActiveTransaction()
|
|
942
1341
|
|
|
943
1342
|
// If no ambient transaction exists, check for an onUpdate handler early
|
|
@@ -987,7 +1386,7 @@ export class CollectionImpl<
|
|
|
987
1386
|
}
|
|
988
1387
|
|
|
989
1388
|
// Create mutations for each object that has changes
|
|
990
|
-
const mutations: Array<PendingMutation<T
|
|
1389
|
+
const mutations: Array<PendingMutation<T, `update`>> = keysArray
|
|
991
1390
|
.map((key, index) => {
|
|
992
1391
|
const itemChanges = changesArray[index] // User-provided changes for this specific item
|
|
993
1392
|
|
|
@@ -1025,9 +1424,9 @@ export class CollectionImpl<
|
|
|
1025
1424
|
|
|
1026
1425
|
return {
|
|
1027
1426
|
mutationId: crypto.randomUUID(),
|
|
1028
|
-
original: originalItem
|
|
1029
|
-
modified: modifiedItem
|
|
1030
|
-
changes: validatedUpdatePayload as
|
|
1427
|
+
original: originalItem,
|
|
1428
|
+
modified: modifiedItem,
|
|
1429
|
+
changes: validatedUpdatePayload as Partial<T>,
|
|
1031
1430
|
globalKey,
|
|
1032
1431
|
key,
|
|
1033
1432
|
metadata: config.metadata as unknown,
|
|
@@ -1041,7 +1440,7 @@ export class CollectionImpl<
|
|
|
1041
1440
|
collection: this,
|
|
1042
1441
|
}
|
|
1043
1442
|
})
|
|
1044
|
-
.filter(Boolean) as Array<PendingMutation<T
|
|
1443
|
+
.filter(Boolean) as Array<PendingMutation<T, `update`>>
|
|
1045
1444
|
|
|
1046
1445
|
// If no changes were made, return an empty transaction early
|
|
1047
1446
|
if (mutations.length === 0) {
|
|
@@ -1103,6 +1502,8 @@ export class CollectionImpl<
|
|
|
1103
1502
|
keys: Array<TKey> | TKey,
|
|
1104
1503
|
config?: OperationConfig
|
|
1105
1504
|
): TransactionType<any> => {
|
|
1505
|
+
this.validateCollectionUsable(`delete`)
|
|
1506
|
+
|
|
1106
1507
|
const ambientTransaction = getActiveTransaction()
|
|
1107
1508
|
|
|
1108
1509
|
// If no ambient transaction exists, check for an onDelete handler early
|
|
@@ -1117,15 +1518,20 @@ export class CollectionImpl<
|
|
|
1117
1518
|
}
|
|
1118
1519
|
|
|
1119
1520
|
const keysArray = Array.isArray(keys) ? keys : [keys]
|
|
1120
|
-
const mutations: Array<PendingMutation<T
|
|
1521
|
+
const mutations: Array<PendingMutation<T, `delete`>> = []
|
|
1121
1522
|
|
|
1122
1523
|
for (const key of keysArray) {
|
|
1524
|
+
if (!this.has(key)) {
|
|
1525
|
+
throw new Error(
|
|
1526
|
+
`Collection.delete was called with key '${key}' but there is no item in the collection with this key`
|
|
1527
|
+
)
|
|
1528
|
+
}
|
|
1123
1529
|
const globalKey = this.generateGlobalKey(key, this.get(key)!)
|
|
1124
|
-
const mutation: PendingMutation<T
|
|
1530
|
+
const mutation: PendingMutation<T, `delete`> = {
|
|
1125
1531
|
mutationId: crypto.randomUUID(),
|
|
1126
|
-
original: this.get(key)
|
|
1532
|
+
original: this.get(key)!,
|
|
1127
1533
|
modified: this.get(key)!,
|
|
1128
|
-
changes: this.get(key)
|
|
1534
|
+
changes: this.get(key)!,
|
|
1129
1535
|
globalKey,
|
|
1130
1536
|
key,
|
|
1131
1537
|
metadata: config?.metadata as unknown,
|
|
@@ -1266,6 +1672,9 @@ export class CollectionImpl<
|
|
|
1266
1672
|
callback: (changes: Array<ChangeMessage<T>>) => void,
|
|
1267
1673
|
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1268
1674
|
): () => void {
|
|
1675
|
+
// Start sync and track subscriber
|
|
1676
|
+
this.addSubscriber()
|
|
1677
|
+
|
|
1269
1678
|
if (includeInitialState) {
|
|
1270
1679
|
// First send the current state as changes
|
|
1271
1680
|
callback(this.currentStateAsChanges())
|
|
@@ -1276,6 +1685,7 @@ export class CollectionImpl<
|
|
|
1276
1685
|
|
|
1277
1686
|
return () => {
|
|
1278
1687
|
this.changeListeners.delete(callback)
|
|
1688
|
+
this.removeSubscriber()
|
|
1279
1689
|
}
|
|
1280
1690
|
}
|
|
1281
1691
|
|
|
@@ -1287,6 +1697,9 @@ export class CollectionImpl<
|
|
|
1287
1697
|
listener: ChangeListener<T, TKey>,
|
|
1288
1698
|
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1289
1699
|
): () => void {
|
|
1700
|
+
// Start sync and track subscriber
|
|
1701
|
+
this.addSubscriber()
|
|
1702
|
+
|
|
1290
1703
|
if (!this.changeKeyListeners.has(key)) {
|
|
1291
1704
|
this.changeKeyListeners.set(key, new Set())
|
|
1292
1705
|
}
|
|
@@ -1312,6 +1725,40 @@ export class CollectionImpl<
|
|
|
1312
1725
|
this.changeKeyListeners.delete(key)
|
|
1313
1726
|
}
|
|
1314
1727
|
}
|
|
1728
|
+
this.removeSubscriber()
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Capture visible state for keys that will be affected by pending sync operations
|
|
1734
|
+
* This must be called BEFORE onTransactionStateChange clears optimistic state
|
|
1735
|
+
*/
|
|
1736
|
+
private capturePreSyncVisibleState(): void {
|
|
1737
|
+
if (this.pendingSyncedTransactions.length === 0) return
|
|
1738
|
+
|
|
1739
|
+
// Clear any previous capture
|
|
1740
|
+
this.preSyncVisibleState.clear()
|
|
1741
|
+
|
|
1742
|
+
// Get all keys that will be affected by sync operations
|
|
1743
|
+
const syncedKeys = new Set<TKey>()
|
|
1744
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
1745
|
+
for (const operation of transaction.operations) {
|
|
1746
|
+
syncedKeys.add(operation.key as TKey)
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
|
|
1751
|
+
for (const key of syncedKeys) {
|
|
1752
|
+
this.recentlySyncedKeys.add(key)
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Only capture current visible state for keys that will be affected by sync operations
|
|
1756
|
+
// This is much more efficient than capturing the entire collection state
|
|
1757
|
+
for (const key of syncedKeys) {
|
|
1758
|
+
const currentValue = this.get(key)
|
|
1759
|
+
if (currentValue !== undefined) {
|
|
1760
|
+
this.preSyncVisibleState.set(key, currentValue)
|
|
1761
|
+
}
|
|
1315
1762
|
}
|
|
1316
1763
|
}
|
|
1317
1764
|
|
|
@@ -1320,6 +1767,9 @@ export class CollectionImpl<
|
|
|
1320
1767
|
* This method should be called by the Transaction class when state changes
|
|
1321
1768
|
*/
|
|
1322
1769
|
public onTransactionStateChange(): void {
|
|
1770
|
+
// CRITICAL: Capture visible state BEFORE clearing optimistic state
|
|
1771
|
+
this.capturePreSyncVisibleState()
|
|
1772
|
+
|
|
1323
1773
|
this.recomputeOptimisticState()
|
|
1324
1774
|
}
|
|
1325
1775
|
|
|
@@ -1334,7 +1784,7 @@ export class CollectionImpl<
|
|
|
1334
1784
|
public asStoreMap(): Store<Map<TKey, T>> {
|
|
1335
1785
|
if (!this._storeMap) {
|
|
1336
1786
|
this._storeMap = new Store(new Map(this.entries()))
|
|
1337
|
-
this.
|
|
1787
|
+
this.changeListeners.add(() => {
|
|
1338
1788
|
this._storeMap!.setState(() => new Map(this.entries()))
|
|
1339
1789
|
})
|
|
1340
1790
|
}
|
|
@@ -1352,7 +1802,7 @@ export class CollectionImpl<
|
|
|
1352
1802
|
public asStoreArray(): Store<Array<T>> {
|
|
1353
1803
|
if (!this._storeArray) {
|
|
1354
1804
|
this._storeArray = new Store(this.toArray)
|
|
1355
|
-
this.
|
|
1805
|
+
this.changeListeners.add(() => {
|
|
1356
1806
|
this._storeArray!.setState(() => this.toArray)
|
|
1357
1807
|
})
|
|
1358
1808
|
}
|