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