@@ -8,7 +8,13 @@ import {
8
8
SyncTransactionAlreadyCommittedWriteError ,
9
9
} from "../errors"
10
10
import type { StandardSchemaV1 } from "@standard-schema/spec"
11
- import type { ChangeMessage , CollectionConfig } from "../types"
11
+ import type {
12
+ ChangeMessage ,
13
+ CollectionConfig ,
14
+ CleanupFn ,
15
+ OnLoadMoreOptions ,
16
+ SyncConfigRes ,
17
+ } from "../types"
12
18
import type { CollectionImpl } from "./index.js"
13
19
import type { CollectionStateManager } from "./state"
14
20
import type { CollectionLifecycleManager } from "./lifecycle"
@@ -27,6 +33,9 @@ export class CollectionSyncManager<
27
33
28
34
public preloadPromise : Promise < void > | null = null
29
35
public syncCleanupFn : ( ( ) => void ) | null = null
36
+ public syncOnLoadMoreFn :
37
+ | ( ( options : OnLoadMoreOptions ) => void | Promise < void > )
38
+ | null = null
30
39
31
40
/**
32
41
* Creates a new CollectionSyncManager instance
@@ -62,106 +71,111 @@ export class CollectionSyncManager<
62
71
this . lifecycle . setStatus ( `loading` )
63
72
64
73
try {
65
- const cleanupFn = this . config . sync . sync ( {
66
- collection : this . collection ,
67
- begin : ( ) => {
68
- state . pendingSyncedTransactions . push ( {
69
- committed : false ,
70
- operations : [ ] ,
71
- deletedKeys : new Set ( ) ,
72
- } )
73
- } ,
74
- write : ( messageWithoutKey : Omit < ChangeMessage < TOutput > , `key`> ) => {
75
- const pendingTransaction =
76
- state . pendingSyncedTransactions [
77
- state . pendingSyncedTransactions . length - 1
78
- ]
79
- if ( ! pendingTransaction ) {
80
- throw new NoPendingSyncTransactionWriteError ( )
81
- }
82
- if ( pendingTransaction . committed ) {
83
- throw new SyncTransactionAlreadyCommittedWriteError ( )
84
- }
85
- const key = this . config . getKey ( messageWithoutKey . value )
86
-
87
- // Check if an item with this key already exists when inserting
88
- if ( messageWithoutKey . type === `insert` ) {
89
- const insertingIntoExistingSynced = state . syncedData . has ( key )
90
- const hasPendingDeleteForKey =
91
- pendingTransaction . deletedKeys . has ( key )
92
- const isTruncateTransaction = pendingTransaction . truncate === true
93
- // Allow insert after truncate in the same transaction even if it existed in syncedData
94
- if (
95
- insertingIntoExistingSynced &&
96
- ! hasPendingDeleteForKey &&
97
- ! isTruncateTransaction
98
- ) {
99
- throw new DuplicateKeySyncError ( key , this . id )
74
+ const syncRes = normalizeSyncFnResult (
75
+ this . config . sync . sync ( {
76
+ collection : this ,
77
+ begin : ( ) => {
78
+ this . pendingSyncedTransactions . push ( {
79
+ committed : false ,
80
+ operations : [ ] ,
81
+ deletedKeys : new Set ( ) ,
82
+ } )
83
+ } ,
84
+ write : ( messageWithoutKey : Omit < ChangeMessage < TOutput > , `key`> ) => {
85
+ const pendingTransaction =
86
+ this . pendingSyncedTransactions [
87
+ this . pendingSyncedTransactions . length - 1
88
+ ]
89
+ if ( ! pendingTransaction ) {
90
+ throw new NoPendingSyncTransactionWriteError ( )
100
91
}
101
- }
102
-
103
- const message : ChangeMessage < TOutput > = {
104
- ...messageWithoutKey ,
105
- key,
106
- }
107
- pendingTransaction . operations . push ( message )
108
-
109
- if ( messageWithoutKey . type === `delete` ) {
110
- pendingTransaction . deletedKeys . add ( key )
111
- }
112
- } ,
113
- commit : ( ) => {
114
- const pendingTransaction =
115
- state . pendingSyncedTransactions [
116
- state . pendingSyncedTransactions . length - 1
117
- ]
118
- if ( ! pendingTransaction ) {
119
- throw new NoPendingSyncTransactionCommitError ( )
120
- }
121
- if ( pendingTransaction . committed ) {
122
- throw new SyncTransactionAlreadyCommittedError ( )
123
- }
124
-
125
- pendingTransaction . committed = true
126
-
127
- // Update status to initialCommit when transitioning from loading
128
- // This indicates we're in the process of committing the first transaction
129
- if ( this . lifecycle . status === `loading` ) {
130
- this . lifecycle . setStatus ( `initialCommit` )
131
- }
132
-
133
- state . commitPendingTransactions ( )
134
- } ,
135
- markReady : ( ) => {
136
- this . lifecycle . markReady ( )
137
- } ,
138
- truncate : ( ) => {
139
- const pendingTransaction =
140
- state . pendingSyncedTransactions [
141
- state . pendingSyncedTransactions . length - 1
142
- ]
143
- if ( ! pendingTransaction ) {
144
- throw new NoPendingSyncTransactionWriteError ( )
145
- }
146
- if ( pendingTransaction . committed ) {
147
- throw new SyncTransactionAlreadyCommittedWriteError ( )
148
- }
149
-
150
- // Clear all operations from the current transaction
151
- pendingTransaction . operations = [ ]
152
- pendingTransaction . deletedKeys . clear ( )
153
-
154
- // Mark the transaction as a truncate operation. During commit, this triggers:
155
- // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
156
- // - Clearing of syncedData/syncedMetadata
157
- // - Subsequent synced ops applied on the fresh base
158
- // - Finally, optimistic mutations re-applied on top (single batch)
159
- pendingTransaction . truncate = true
160
- } ,
161
- } )
92
+ if ( pendingTransaction . committed ) {
93
+ throw new SyncTransactionAlreadyCommittedWriteError ( )
94
+ }
95
+ const key = this . getKeyFromItem ( messageWithoutKey . value )
96
+
97
+ // Check if an item with this key already exists when inserting
98
+ if ( messageWithoutKey . type === `insert` ) {
99
+ const insertingIntoExistingSynced = this . syncedData . has ( key )
100
+ const hasPendingDeleteForKey =
101
+ pendingTransaction . deletedKeys . has ( key )
102
+ const isTruncateTransaction = pendingTransaction . truncate === true
103
+ // Allow insert after truncate in the same transaction even if it existed in syncedData
104
+ if (
105
+ insertingIntoExistingSynced &&
106
+ ! hasPendingDeleteForKey &&
107
+ ! isTruncateTransaction
108
+ ) {
109
+ throw new DuplicateKeySyncError ( key , this . id )
110
+ }
111
+ }
112
+
113
+ const message : ChangeMessage < TOutput > = {
114
+ ...messageWithoutKey ,
115
+ key,
116
+ }
117
+ pendingTransaction . operations . push ( message )
118
+
119
+ if ( messageWithoutKey . type === `delete` ) {
120
+ pendingTransaction . deletedKeys . add ( key )
121
+ }
122
+ } ,
123
+ commit : ( ) => {
124
+ const pendingTransaction =
125
+ this . pendingSyncedTransactions [
126
+ this . pendingSyncedTransactions . length - 1
127
+ ]
128
+ if ( ! pendingTransaction ) {
129
+ throw new NoPendingSyncTransactionCommitError ( )
130
+ }
131
+ if ( pendingTransaction . committed ) {
132
+ throw new SyncTransactionAlreadyCommittedError ( )
133
+ }
134
+
135
+ pendingTransaction . committed = true
136
+
137
+ // Update status to initialCommit when transitioning from loading
138
+ // This indicates we're in the process of committing the first transaction
139
+ if ( this . _status === `loading` ) {
140
+ this . setStatus ( `initialCommit` )
141
+ }
142
+
143
+ this . commitPendingTransactions ( )
144
+ } ,
145
+ markReady : ( ) => {
146
+ this . markReady ( )
147
+ } ,
148
+ truncate : ( ) => {
149
+ const pendingTransaction =
150
+ this . pendingSyncedTransactions [
151
+ this . pendingSyncedTransactions . length - 1
152
+ ]
153
+ if ( ! pendingTransaction ) {
154
+ throw new NoPendingSyncTransactionWriteError ( )
155
+ }
156
+ if ( pendingTransaction . committed ) {
157
+ throw new SyncTransactionAlreadyCommittedWriteError ( )
158
+ }
159
+
160
+ // Clear all operations from the current transaction
161
+ pendingTransaction . operations = [ ]
162
+ pendingTransaction . deletedKeys . clear ( )
163
+
164
+ // Mark the transaction as a truncate operation. During commit, this triggers:
165
+ // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
166
+ // - Clearing of syncedData/syncedMetadata
167
+ // - Subsequent synced ops applied on the fresh base
168
+ // - Finally, optimistic mutations re-applied on top (single batch)
169
+ pendingTransaction . truncate = true
170
+ } ,
171
+ } )
172
+ )
162
173
163
174
// Store cleanup function if provided
164
- this . syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
175
+ this . syncCleanupFn = syncRes ?. cleanup ?? null
176
+
177
+ // Store onLoadMore function if provided
178
+ this . syncOnLoadMoreFn = syncRes ?. onLoadMore ?? null
165
179
} catch ( error ) {
166
180
this . lifecycle . setStatus ( `error` )
167
181
throw error
@@ -210,6 +224,18 @@ export class CollectionSyncManager<
210
224
return this . preloadPromise
211
225
}
212
226
227
+ /**
228
+ * Requests the sync layer to load more data.
229
+ * @param options Options to control what data is being loaded
230
+ * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded.
231
+ * If data loading is synchronous, the data is loaded when the method returns.
232
+ */
233
+ public syncMore ( options : OnLoadMoreOptions ) : void | Promise < void > {
234
+ if ( this . syncOnLoadMoreFn ) {
235
+ return this . syncOnLoadMoreFn ( options )
236
+ }
237
+ }
238
+
213
239
public cleanup ( ) : void {
214
240
try {
215
241
if ( this . syncCleanupFn ) {
@@ -233,3 +259,15 @@ export class CollectionSyncManager<
233
259
this . preloadPromise = null
234
260
}
235
261
}
262
+
263
+ function normalizeSyncFnResult ( result : void | CleanupFn | SyncConfigRes ) {
264
+ if ( typeof result === `function` ) {
265
+ return { cleanup : result }
266
+ }
267
+
268
+ if ( typeof result === `object` ) {
269
+ return result
270
+ }
271
+
272
+ return undefined
273
+ }
0 commit comments