@anfenn/dync 1.0.0

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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/dist/capacitor.cjs +228 -0
  4. package/dist/capacitor.cjs.map +1 -0
  5. package/dist/capacitor.d.cts +62 -0
  6. package/dist/capacitor.d.ts +62 -0
  7. package/dist/capacitor.js +9 -0
  8. package/dist/capacitor.js.map +1 -0
  9. package/dist/chunk-LGHOZECP.js +3884 -0
  10. package/dist/chunk-LGHOZECP.js.map +1 -0
  11. package/dist/chunk-SQB6E7V2.js +191 -0
  12. package/dist/chunk-SQB6E7V2.js.map +1 -0
  13. package/dist/dexie-Bv-fV10P.d.cts +444 -0
  14. package/dist/dexie-DJFApKsM.d.ts +444 -0
  15. package/dist/dexie.cjs +381 -0
  16. package/dist/dexie.cjs.map +1 -0
  17. package/dist/dexie.d.cts +3 -0
  18. package/dist/dexie.d.ts +3 -0
  19. package/dist/dexie.js +343 -0
  20. package/dist/dexie.js.map +1 -0
  21. package/dist/expoSqlite.cjs +98 -0
  22. package/dist/expoSqlite.cjs.map +1 -0
  23. package/dist/expoSqlite.d.cts +17 -0
  24. package/dist/expoSqlite.d.ts +17 -0
  25. package/dist/expoSqlite.js +61 -0
  26. package/dist/expoSqlite.js.map +1 -0
  27. package/dist/index.cjs +3916 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +8 -0
  30. package/dist/index.d.ts +8 -0
  31. package/dist/index.js +20 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/index.shared-CPIge2ZM.d.ts +234 -0
  34. package/dist/index.shared-YSn6c01d.d.cts +234 -0
  35. package/dist/node.cjs +126 -0
  36. package/dist/node.cjs.map +1 -0
  37. package/dist/node.d.cts +80 -0
  38. package/dist/node.d.ts +80 -0
  39. package/dist/node.js +89 -0
  40. package/dist/node.js.map +1 -0
  41. package/dist/react/index.cjs +1754 -0
  42. package/dist/react/index.cjs.map +1 -0
  43. package/dist/react/index.d.cts +40 -0
  44. package/dist/react/index.d.ts +40 -0
  45. package/dist/react/index.js +78 -0
  46. package/dist/react/index.js.map +1 -0
  47. package/dist/types-CSbIAfu2.d.cts +46 -0
  48. package/dist/types-CSbIAfu2.d.ts +46 -0
  49. package/dist/wa-sqlite.cjs +318 -0
  50. package/dist/wa-sqlite.cjs.map +1 -0
  51. package/dist/wa-sqlite.d.cts +175 -0
  52. package/dist/wa-sqlite.d.ts +175 -0
  53. package/dist/wa-sqlite.js +281 -0
  54. package/dist/wa-sqlite.js.map +1 -0
  55. package/package.json +171 -0
  56. package/src/addVisibilityChangeListener.native.ts +33 -0
  57. package/src/addVisibilityChangeListener.ts +24 -0
  58. package/src/capacitor.ts +4 -0
  59. package/src/core/StateManager.ts +272 -0
  60. package/src/core/firstLoad.ts +332 -0
  61. package/src/core/pullOperations.ts +212 -0
  62. package/src/core/pushOperations.ts +290 -0
  63. package/src/core/tableEnhancers.ts +457 -0
  64. package/src/core/types.ts +3 -0
  65. package/src/createLocalId.native.ts +8 -0
  66. package/src/createLocalId.ts +6 -0
  67. package/src/dexie.ts +2 -0
  68. package/src/expoSqlite.ts +2 -0
  69. package/src/helpers.ts +87 -0
  70. package/src/index.native.ts +28 -0
  71. package/src/index.shared.ts +613 -0
  72. package/src/index.ts +28 -0
  73. package/src/logger.ts +26 -0
  74. package/src/node.ts +4 -0
  75. package/src/react/index.ts +2 -0
  76. package/src/react/useDync.ts +156 -0
  77. package/src/storage/dexie/DexieAdapter.ts +72 -0
  78. package/src/storage/dexie/DexieQueryContext.ts +14 -0
  79. package/src/storage/dexie/DexieStorageCollection.ts +124 -0
  80. package/src/storage/dexie/DexieStorageTable.ts +123 -0
  81. package/src/storage/dexie/DexieStorageWhereClause.ts +103 -0
  82. package/src/storage/dexie/helpers.ts +1 -0
  83. package/src/storage/dexie/index.ts +7 -0
  84. package/src/storage/memory/MemoryAdapter.ts +55 -0
  85. package/src/storage/memory/MemoryCollection.ts +215 -0
  86. package/src/storage/memory/MemoryQueryContext.ts +14 -0
  87. package/src/storage/memory/MemoryTable.ts +336 -0
  88. package/src/storage/memory/MemoryWhereClause.ts +134 -0
  89. package/src/storage/memory/index.ts +7 -0
  90. package/src/storage/memory/types.ts +24 -0
  91. package/src/storage/sqlite/SQLiteAdapter.ts +564 -0
  92. package/src/storage/sqlite/SQLiteCollection.ts +294 -0
  93. package/src/storage/sqlite/SQLiteTable.ts +604 -0
  94. package/src/storage/sqlite/SQLiteWhereClause.ts +341 -0
  95. package/src/storage/sqlite/SqliteQueryContext.ts +30 -0
  96. package/src/storage/sqlite/drivers/BetterSqlite3Driver.ts +156 -0
  97. package/src/storage/sqlite/drivers/CapacitorFastSqlDriver.ts +114 -0
  98. package/src/storage/sqlite/drivers/CapacitorSQLiteDriver.ts +137 -0
  99. package/src/storage/sqlite/drivers/ExpoSQLiteDriver.native.ts +67 -0
  100. package/src/storage/sqlite/drivers/WaSqliteDriver.ts +537 -0
  101. package/src/storage/sqlite/drivers/wa-sqlite-vfs.d.ts +46 -0
  102. package/src/storage/sqlite/helpers.ts +144 -0
  103. package/src/storage/sqlite/index.ts +11 -0
  104. package/src/storage/sqlite/schema.ts +44 -0
  105. package/src/storage/sqlite/types.ts +164 -0
  106. package/src/storage/types.ts +112 -0
  107. package/src/types.ts +186 -0
  108. package/src/wa-sqlite.ts +4 -0
@@ -0,0 +1,33 @@
1
+ import type { VisibilitySubscription } from './types';
2
+
3
+ export function addVisibilityChangeListener(
4
+ add: boolean,
5
+ currentSubscription: VisibilitySubscription | undefined,
6
+ onVisibilityChange: (isVisible: boolean) => void,
7
+ ): VisibilitySubscription | undefined {
8
+ if (add && !currentSubscription) {
9
+ let nativeSubscription: { remove: () => void } | undefined;
10
+
11
+ void (async () => {
12
+ try {
13
+ const ReactNative = await import('react-native');
14
+ const handler = (state: Parameters<typeof ReactNative.AppState.addEventListener>[1] extends (state: infer S) => any ? S : never) => {
15
+ onVisibilityChange(state === 'active');
16
+ };
17
+ nativeSubscription = ReactNative.AppState.addEventListener('change', handler);
18
+ } catch {
19
+ // AppState unavailable
20
+ }
21
+ })();
22
+
23
+ return {
24
+ remove: () => {
25
+ nativeSubscription?.remove();
26
+ },
27
+ };
28
+ } else if (!add && currentSubscription) {
29
+ currentSubscription.remove();
30
+ return undefined;
31
+ }
32
+ return currentSubscription;
33
+ }
@@ -0,0 +1,24 @@
1
+ import type { VisibilitySubscription } from './types';
2
+
3
+ export function addVisibilityChangeListener(
4
+ add: boolean,
5
+ currentSubscription: VisibilitySubscription | undefined,
6
+ onVisibilityChange: (isVisible: boolean) => void,
7
+ ): VisibilitySubscription | undefined {
8
+ if (add && !currentSubscription) {
9
+ const handler = () => {
10
+ onVisibilityChange(document.visibilityState === 'visible');
11
+ };
12
+ document.addEventListener('visibilitychange', handler);
13
+
14
+ return {
15
+ remove: () => {
16
+ document.removeEventListener('visibilitychange', handler);
17
+ },
18
+ };
19
+ } else if (!add && currentSubscription) {
20
+ currentSubscription.remove();
21
+ return undefined;
22
+ }
23
+ return currentSubscription;
24
+ }
@@ -0,0 +1,4 @@
1
+ // Capacitor SQLite Drivers
2
+ // Import this entry point only in Capacitor/native builds
3
+ export { CapacitorSQLiteDriver } from './storage/sqlite/drivers/CapacitorSQLiteDriver';
4
+ export { CapacitorFastSqlDriver, type FastSqlDriverOptions } from './storage/sqlite/drivers/CapacitorFastSqlDriver';
@@ -0,0 +1,272 @@
1
+ import { deleteKeyIfEmptyObject, omitFields } from '../helpers';
2
+ import { LOCAL_PK, UPDATED_AT, SyncAction, type PendingChange, type PersistedSyncState, type SyncState, type SyncStatus } from '../types';
3
+ import type { StorageAdapter } from '../storage/types';
4
+
5
+ const LOCAL_ONLY_SYNC_FIELDS = [LOCAL_PK, UPDATED_AT];
6
+
7
+ // Internal table name used for storing sync state
8
+ export const DYNC_STATE_TABLE = '_dync_state';
9
+ const SYNC_STATE_KEY = 'sync_state';
10
+
11
+ interface StateRow {
12
+ [LOCAL_PK]: string;
13
+ value: string;
14
+ }
15
+
16
+ const DEFAULT_STATE: PersistedSyncState = {
17
+ firstLoadDone: false,
18
+ pendingChanges: [],
19
+ lastPulled: {},
20
+ };
21
+
22
+ interface StateContext {
23
+ initialStatus?: SyncStatus;
24
+ storageAdapter?: StorageAdapter;
25
+ }
26
+
27
+ export interface StateHelpers {
28
+ hydrate(): Promise<void>;
29
+ getState(): PersistedSyncState;
30
+ setState(setterOrState: PersistedSyncState | ((state: PersistedSyncState) => Partial<PersistedSyncState>)): Promise<void>;
31
+ setErrorInMemory(error: Error): void;
32
+ addPendingChange(change: Omit<PendingChange, 'version'>): Promise<void>;
33
+ samePendingVersion(stateKey: string, localId: string, version: number): boolean;
34
+ removePendingChange(localId: string, stateKey: string): Promise<void>;
35
+ updatePendingChange(stateKey: string, localId: string, action: SyncAction, id?: any): Promise<void>;
36
+ setPendingChangeBefore(stateKey: string, localId: string, before: any): Promise<void>;
37
+ hasConflicts(localId: string): boolean;
38
+ getSyncStatus(): SyncStatus;
39
+ setSyncStatus(status: SyncStatus): void;
40
+ getSyncState(): SyncState;
41
+ subscribe(listener: (state: SyncState) => void): () => void;
42
+ }
43
+
44
+ export class StateManager implements StateHelpers {
45
+ private persistedState: PersistedSyncState;
46
+ private syncStatus: SyncStatus;
47
+ private readonly listeners = new Set<(state: SyncState) => void>();
48
+ private readonly storageAdapter?: StorageAdapter;
49
+ private hydrated = false;
50
+
51
+ constructor(ctx: StateContext) {
52
+ this.storageAdapter = ctx.storageAdapter;
53
+ // Start with default state, hydrate() will load from database
54
+ this.persistedState = DEFAULT_STATE;
55
+ this.syncStatus = ctx.initialStatus ?? 'disabled';
56
+ }
57
+
58
+ /**
59
+ * Load state from the database. Called after stores() defines the schema.
60
+ */
61
+ async hydrate(): Promise<void> {
62
+ if (this.hydrated) return;
63
+
64
+ if (!this.storageAdapter) {
65
+ throw new Error('Cannot hydrate state without a storage adapter');
66
+ }
67
+
68
+ const table = this.storageAdapter.table<StateRow>(DYNC_STATE_TABLE);
69
+ const row = await table.get(SYNC_STATE_KEY);
70
+ if (row?.value) {
71
+ this.persistedState = parseStoredState(row.value);
72
+ }
73
+
74
+ this.hydrated = true;
75
+
76
+ // Notify subscribers if state was loaded from database
77
+ this.emit();
78
+ }
79
+
80
+ private emit(): void {
81
+ this.listeners.forEach((fn) => fn(this.getSyncState()));
82
+ }
83
+
84
+ private async persist(): Promise<void> {
85
+ // Only persist after hydration (i.e., after database is opened)
86
+ if (!this.hydrated || !this.storageAdapter) return;
87
+
88
+ this.emit();
89
+
90
+ const table = this.storageAdapter.table<StateRow>(DYNC_STATE_TABLE);
91
+ await table.put({ [LOCAL_PK]: SYNC_STATE_KEY, value: JSON.stringify(this.persistedState) } as StateRow);
92
+ }
93
+
94
+ getState(): PersistedSyncState {
95
+ return clonePersistedState(this.persistedState);
96
+ }
97
+
98
+ setState(setterOrState: PersistedSyncState | ((state: PersistedSyncState) => Partial<PersistedSyncState>)): Promise<void> {
99
+ this.persistedState = resolveNextState(this.persistedState, setterOrState);
100
+ return this.persist();
101
+ }
102
+
103
+ /**
104
+ * Set error in memory only without persisting to database.
105
+ * Used when the database itself failed to open.
106
+ */
107
+ setErrorInMemory(error: Error): void {
108
+ this.persistedState = { ...this.persistedState, error };
109
+ this.emit();
110
+ }
111
+
112
+ addPendingChange(change: Omit<PendingChange, 'version'>): Promise<void> {
113
+ const next = clonePersistedState(this.persistedState);
114
+ const queueItem = next.pendingChanges.find((p) => p.localId === change.localId && p.stateKey === change.stateKey);
115
+
116
+ const omittedChanges = omitFields(change.changes, LOCAL_ONLY_SYNC_FIELDS);
117
+ const omittedBefore = omitFields(change.before, LOCAL_ONLY_SYNC_FIELDS);
118
+ const omittedAfter = omitFields(change.after, LOCAL_ONLY_SYNC_FIELDS);
119
+ const hasChanges = Object.keys(omittedChanges || {}).length > 0;
120
+ const action = change.action;
121
+
122
+ if (queueItem) {
123
+ if (queueItem.action === SyncAction.Remove) {
124
+ return Promise.resolve();
125
+ }
126
+
127
+ queueItem.version += 1;
128
+
129
+ if (action === SyncAction.Remove) {
130
+ queueItem.action = SyncAction.Remove;
131
+ } else if (hasChanges) {
132
+ queueItem.changes = { ...queueItem.changes, ...omittedChanges };
133
+ queueItem.after = { ...queueItem.after, ...omittedAfter };
134
+ }
135
+ } else if (action === SyncAction.Remove || hasChanges) {
136
+ next.pendingChanges = [...next.pendingChanges];
137
+ next.pendingChanges.push({
138
+ action,
139
+ stateKey: change.stateKey,
140
+ localId: change.localId,
141
+ id: change.id,
142
+ version: 1,
143
+ changes: omittedChanges,
144
+ before: omittedBefore,
145
+ after: omittedAfter,
146
+ });
147
+ }
148
+
149
+ this.persistedState = next;
150
+ return this.persist();
151
+ }
152
+
153
+ samePendingVersion(stateKey: string, localId: string, version: number): boolean {
154
+ return this.persistedState.pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey)?.version === version;
155
+ }
156
+
157
+ removePendingChange(localId: string, stateKey: string): Promise<void> {
158
+ const next = clonePersistedState(this.persistedState);
159
+ next.pendingChanges = next.pendingChanges.filter((p) => !(p.localId === localId && p.stateKey === stateKey));
160
+ this.persistedState = next;
161
+ return this.persist();
162
+ }
163
+
164
+ updatePendingChange(stateKey: string, localId: string, action: SyncAction, id?: any): Promise<void> {
165
+ const next = clonePersistedState(this.persistedState);
166
+ const changeItem = next.pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
167
+ if (changeItem) {
168
+ changeItem.action = action;
169
+ if (id) changeItem.id = id;
170
+ this.persistedState = next;
171
+ return this.persist();
172
+ }
173
+ return Promise.resolve();
174
+ }
175
+
176
+ setPendingChangeBefore(stateKey: string, localId: string, before: any): Promise<void> {
177
+ const next = clonePersistedState(this.persistedState);
178
+ const changeItem = next.pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
179
+ if (changeItem) {
180
+ changeItem.before = { ...(changeItem.before ?? {}), ...before };
181
+ this.persistedState = next;
182
+ return this.persist();
183
+ }
184
+ return Promise.resolve();
185
+ }
186
+
187
+ hasConflicts(localId: string): boolean {
188
+ return Boolean(this.persistedState.conflicts?.[localId]);
189
+ }
190
+
191
+ getSyncStatus(): SyncStatus {
192
+ return this.syncStatus;
193
+ }
194
+
195
+ setSyncStatus(status: SyncStatus): void {
196
+ if (this.syncStatus === status) return;
197
+ this.syncStatus = status;
198
+ this.emit();
199
+ }
200
+
201
+ getSyncState(): SyncState {
202
+ return buildSyncState(this.persistedState, this.syncStatus, this.hydrated);
203
+ }
204
+
205
+ subscribe(listener: (state: SyncState) => void): () => void {
206
+ this.listeners.add(listener);
207
+ return () => this.listeners.delete(listener);
208
+ }
209
+ }
210
+
211
+ function parseStoredState(stored: string): PersistedSyncState {
212
+ const parsed = JSON.parse(stored);
213
+ if (parsed.pendingChanges) {
214
+ parsed.pendingChanges = parsed.pendingChanges.map((change: any) => ({
215
+ ...change,
216
+ timestamp: change.timestamp ? new Date(change.timestamp) : change.timestamp,
217
+ }));
218
+ }
219
+ return parsed;
220
+ }
221
+
222
+ function resolveNextState(
223
+ current: PersistedSyncState,
224
+ setterOrState: PersistedSyncState | ((state: PersistedSyncState) => Partial<PersistedSyncState>),
225
+ ): PersistedSyncState {
226
+ if (typeof setterOrState === 'function') {
227
+ return { ...current, ...setterOrState(clonePersistedState(current)) };
228
+ }
229
+ return { ...current, ...setterOrState };
230
+ }
231
+
232
+ function buildSyncState(state: PersistedSyncState, status: SyncStatus, hydrated: boolean): SyncState {
233
+ const persisted = clonePersistedState(state);
234
+ const syncState: SyncState = {
235
+ ...persisted,
236
+ status,
237
+ hydrated,
238
+ };
239
+ deleteKeyIfEmptyObject(syncState, 'conflicts');
240
+ return syncState;
241
+ }
242
+
243
+ function clonePersistedState(state: PersistedSyncState): PersistedSyncState {
244
+ return {
245
+ ...state,
246
+ pendingChanges: state.pendingChanges.map((change) => ({
247
+ ...change,
248
+ changes: cloneRecord(change.changes),
249
+ before: cloneRecord(change.before),
250
+ after: cloneRecord(change.after),
251
+ })),
252
+ lastPulled: { ...state.lastPulled },
253
+ conflicts: cloneConflicts(state.conflicts),
254
+ };
255
+ }
256
+
257
+ function cloneConflicts(conflicts: SyncState['conflicts'] | undefined): SyncState['conflicts'] | undefined {
258
+ if (!conflicts) return undefined;
259
+ const next: NonNullable<SyncState['conflicts']> = {};
260
+ for (const [key, value] of Object.entries(conflicts)) {
261
+ next[key] = {
262
+ stateKey: value.stateKey,
263
+ fields: value.fields.map((field) => ({ ...field })),
264
+ };
265
+ }
266
+ return next;
267
+ }
268
+
269
+ function cloneRecord<T extends Record<string, any> | null | undefined>(record: T): T {
270
+ if (!record) return record;
271
+ return { ...record } as T;
272
+ }
@@ -0,0 +1,332 @@
1
+ import { createLocalId, sleep } from '../helpers';
2
+ import type { Logger } from '../logger';
3
+ import type { ApiFunctions, BatchFirstLoadResult, BatchSync, FirstLoadProgressCallback } from '../types';
4
+ import type { StorageTable } from '../storage/types';
5
+ import { DYNC_STATE_TABLE, type StateHelpers } from './StateManager';
6
+ import type { WithTransaction } from './types';
7
+
8
+ export interface FirstLoadBaseContext {
9
+ logger: Logger;
10
+ state: StateHelpers;
11
+ table: <T>(name: string) => StorageTable<T>;
12
+ withTransaction: WithTransaction;
13
+ onProgress?: FirstLoadProgressCallback;
14
+ }
15
+
16
+ export interface FirstLoadContext extends FirstLoadBaseContext {
17
+ syncApis: Record<string, ApiFunctions>;
18
+ }
19
+
20
+ export interface FirstLoadBatchContext extends FirstLoadBaseContext {
21
+ batchSync: BatchSync;
22
+ }
23
+
24
+ interface RemoteRecord {
25
+ id?: unknown;
26
+ updated_at?: string;
27
+ deleted?: boolean;
28
+ _localId?: string;
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ // Yield to event loop to prevent UI freezing on constrained devices
33
+ const yieldToEventLoop = (): Promise<void> => sleep(0);
34
+
35
+ const WRITE_BATCH_SIZE = 200;
36
+
37
+ export async function startFirstLoad(ctx: FirstLoadContext): Promise<void> {
38
+ ctx.logger.debug('[dync] Starting first load...');
39
+
40
+ if (ctx.state.getState().firstLoadDone) {
41
+ ctx.logger.debug('[dync] First load already completed');
42
+ return;
43
+ }
44
+
45
+ let error: Error | undefined;
46
+
47
+ for (const [stateKey, api] of Object.entries(ctx.syncApis)) {
48
+ if (!api.firstLoad) {
49
+ ctx.logger.error(`[dync] firstLoad:no-api-function stateKey=${stateKey}`);
50
+ continue;
51
+ }
52
+
53
+ try {
54
+ ctx.logger.info(`[dync] firstLoad:start stateKey=${stateKey}`);
55
+
56
+ let lastId: unknown;
57
+ let isEmptyTable = true;
58
+ let batchCount = 0;
59
+ let totalInserted = 0;
60
+ let totalUpdated = 0;
61
+
62
+ while (true) {
63
+ const batch = await api.firstLoad(lastId);
64
+ if (!batch?.length) break;
65
+
66
+ batchCount++;
67
+
68
+ // Process batch in smaller chunks to manage memory and allow UI updates
69
+ const { inserted, updated } = await processBatchInChunks(ctx, stateKey, batch, isEmptyTable, lastId === undefined);
70
+ totalInserted += inserted;
71
+ totalUpdated += updated;
72
+
73
+ // Report progress if callback provided
74
+ if (ctx.onProgress) {
75
+ ctx.onProgress({
76
+ table: stateKey,
77
+ inserted: totalInserted,
78
+ updated: totalUpdated,
79
+ total: totalInserted + totalUpdated,
80
+ });
81
+ }
82
+
83
+ // After first batch, we know if table was empty
84
+ if (lastId === undefined) {
85
+ isEmptyTable = (await ctx.table(stateKey).count()) === batch.length;
86
+ }
87
+
88
+ if (lastId !== undefined && lastId === batch[batch.length - 1].id) {
89
+ throw new Error(`Duplicate records downloaded, stopping to prevent infinite loop`);
90
+ }
91
+
92
+ lastId = batch[batch.length - 1].id;
93
+
94
+ // Yield between API batches to allow UI updates
95
+ if (batchCount % 5 === 0) {
96
+ await yieldToEventLoop();
97
+ }
98
+ }
99
+
100
+ ctx.logger.info(`[dync] firstLoad:done stateKey=${stateKey} inserted=${totalInserted} updated=${totalUpdated}`);
101
+ } catch (err) {
102
+ error = error ?? (err as Error);
103
+ ctx.logger.error(`[dync] firstLoad:error stateKey=${stateKey}`, err);
104
+ }
105
+ }
106
+
107
+ await ctx.state.setState((syncState) => ({
108
+ ...syncState,
109
+ firstLoadDone: true,
110
+ error,
111
+ }));
112
+
113
+ ctx.logger.debug('[dync] First load completed');
114
+ }
115
+
116
+ interface BatchResult {
117
+ inserted: number;
118
+ updated: number;
119
+ }
120
+
121
+ async function processBatchInChunks(
122
+ ctx: FirstLoadBaseContext,
123
+ stateKey: string,
124
+ batch: RemoteRecord[],
125
+ isEmptyTable: boolean,
126
+ isFirstBatch: boolean,
127
+ ): Promise<BatchResult> {
128
+ let newest = new Date(ctx.state.getState().lastPulled[stateKey] || 0);
129
+
130
+ return ctx.withTransaction('rw', [stateKey, DYNC_STATE_TABLE], async (tables) => {
131
+ const txTable = tables[stateKey]!;
132
+
133
+ // Check if table is empty on first batch
134
+ let tableIsEmpty = isEmptyTable;
135
+ if (isFirstBatch) {
136
+ const count = await txTable.count();
137
+ tableIsEmpty = count === 0;
138
+ }
139
+
140
+ // Pre-filter and prepare records, mutating in place to reduce allocations
141
+ const activeRecords: RemoteRecord[] = [];
142
+ for (const remote of batch) {
143
+ const remoteUpdated = new Date(remote.updated_at || 0);
144
+ if (remoteUpdated > newest) newest = remoteUpdated;
145
+
146
+ if (remote.deleted) continue;
147
+
148
+ // Mutate in place instead of spreading
149
+ delete remote.deleted;
150
+ remote._localId = createLocalId();
151
+ activeRecords.push(remote);
152
+ }
153
+
154
+ let inserted = 0;
155
+ let updated = 0;
156
+
157
+ if (tableIsEmpty) {
158
+ // Fast path: no existing records, bulk add in chunks
159
+ for (let i = 0; i < activeRecords.length; i += WRITE_BATCH_SIZE) {
160
+ const chunk = activeRecords.slice(i, i + WRITE_BATCH_SIZE);
161
+ await txTable.raw.bulkAdd(chunk as any);
162
+ inserted += chunk.length;
163
+ }
164
+ } else {
165
+ // Slower path: need to check for existing records
166
+ // Process in chunks to limit memory usage of lookup map
167
+ for (let i = 0; i < activeRecords.length; i += WRITE_BATCH_SIZE) {
168
+ const chunk = activeRecords.slice(i, i + WRITE_BATCH_SIZE);
169
+ const chunkResult = await processChunkWithLookup(txTable, chunk);
170
+ inserted += chunkResult.inserted;
171
+ updated += chunkResult.updated;
172
+ }
173
+ }
174
+
175
+ await ctx.state.setState((syncState) => ({
176
+ ...syncState,
177
+ lastPulled: {
178
+ ...syncState.lastPulled,
179
+ [stateKey]: newest.toISOString(),
180
+ },
181
+ }));
182
+
183
+ return { inserted, updated };
184
+ });
185
+ }
186
+
187
+ async function processChunkWithLookup(txTable: StorageTable<any>, chunk: RemoteRecord[]): Promise<BatchResult> {
188
+ // Collect server IDs for this chunk
189
+ const serverIds = chunk.filter((r) => r.id != null).map((r) => r.id);
190
+
191
+ // Bulk lookup existing records
192
+ const existingByServerId = new Map<unknown, { _localId: string }>();
193
+ if (serverIds.length > 0) {
194
+ const existingRecords = await txTable.where('id').anyOf(serverIds).toArray();
195
+ for (const existing of existingRecords) {
196
+ existingByServerId.set((existing as any).id, existing as { _localId: string });
197
+ }
198
+ }
199
+
200
+ // Separate into adds and updates
201
+ const toAdd: RemoteRecord[] = [];
202
+ let updated = 0;
203
+
204
+ for (const remote of chunk) {
205
+ const existing = remote.id != null ? existingByServerId.get(remote.id) : undefined;
206
+ if (existing) {
207
+ // Update: merge and update in place
208
+ const merged = Object.assign({}, existing, remote, { _localId: existing._localId });
209
+ await txTable.raw.update(existing._localId, merged as any);
210
+ updated++;
211
+ } else {
212
+ toAdd.push(remote);
213
+ }
214
+ }
215
+
216
+ // Bulk add new records
217
+ if (toAdd.length > 0) {
218
+ await txTable.raw.bulkAdd(toAdd as any);
219
+ }
220
+
221
+ // Clear the map to help GC
222
+ existingByServerId.clear();
223
+
224
+ return { inserted: toAdd.length, updated };
225
+ }
226
+
227
+ // ============================================================================
228
+ // Batch Mode First Load Operations
229
+ // ============================================================================
230
+
231
+ export async function startFirstLoadBatch(ctx: FirstLoadBatchContext): Promise<void> {
232
+ ctx.logger.debug('[dync] Starting batch first load...');
233
+
234
+ if (ctx.state.getState().firstLoadDone) {
235
+ ctx.logger.debug('[dync] First load already completed');
236
+ return;
237
+ }
238
+
239
+ if (!ctx.batchSync.firstLoad) {
240
+ ctx.logger.warn('[dync] firstLoad:batch:no-firstLoad-function');
241
+ await ctx.state.setState((syncState) => ({
242
+ ...syncState,
243
+ firstLoadDone: true,
244
+ }));
245
+ return;
246
+ }
247
+
248
+ let error: Error | undefined;
249
+
250
+ try {
251
+ ctx.logger.info(`[dync] firstLoad:batch:start tables=${[...ctx.batchSync.syncTables].join(',')}`);
252
+
253
+ // Track progress per table
254
+ const progress: Record<string, { inserted: number; updated: number }> = {};
255
+ for (const tableName of ctx.batchSync.syncTables) {
256
+ progress[tableName] = { inserted: 0, updated: 0 };
257
+ }
258
+
259
+ // Initialize cursors for all tables
260
+ let cursors: Record<string, any> = {};
261
+ for (const tableName of ctx.batchSync.syncTables) {
262
+ cursors[tableName] = undefined;
263
+ }
264
+
265
+ let batchCount = 0;
266
+
267
+ while (true) {
268
+ const result: BatchFirstLoadResult = await ctx.batchSync.firstLoad(cursors);
269
+
270
+ if (!result.hasMore && Object.values(result.data).every((d) => !d?.length)) {
271
+ break;
272
+ }
273
+
274
+ batchCount++;
275
+
276
+ // Process each table's data
277
+ for (const [stateKey, batch] of Object.entries(result.data)) {
278
+ if (!ctx.batchSync.syncTables.includes(stateKey)) {
279
+ ctx.logger.warn(`[dync] firstLoad:batch:unknown-table stateKey=${stateKey}`);
280
+ continue;
281
+ }
282
+
283
+ if (!batch?.length) continue;
284
+
285
+ const isFirstBatch = progress[stateKey]!.inserted === 0 && progress[stateKey]!.updated === 0;
286
+ const isEmptyTable = isFirstBatch && (await ctx.table(stateKey).count()) === 0;
287
+
288
+ const { inserted, updated } = await processBatchInChunks(ctx, stateKey, batch, isEmptyTable, isFirstBatch);
289
+ progress[stateKey]!.inserted += inserted;
290
+ progress[stateKey]!.updated += updated;
291
+
292
+ // Report progress if callback provided
293
+ if (ctx.onProgress) {
294
+ ctx.onProgress({
295
+ table: stateKey,
296
+ inserted: progress[stateKey]!.inserted,
297
+ updated: progress[stateKey]!.updated,
298
+ total: progress[stateKey]!.inserted + progress[stateKey]!.updated,
299
+ });
300
+ }
301
+ }
302
+
303
+ // Update cursors for next batch
304
+ cursors = result.cursors;
305
+
306
+ // Yield between API batches to allow UI updates
307
+ if (batchCount % 5 === 0) {
308
+ await yieldToEventLoop();
309
+ }
310
+
311
+ if (!result.hasMore) {
312
+ break;
313
+ }
314
+ }
315
+
316
+ // Log completion for each table
317
+ for (const [stateKey, p] of Object.entries(progress)) {
318
+ ctx.logger.info(`[dync] firstLoad:batch:done stateKey=${stateKey} inserted=${p.inserted} updated=${p.updated}`);
319
+ }
320
+ } catch (err) {
321
+ error = err as Error;
322
+ ctx.logger.error('[dync] firstLoad:batch:error', err);
323
+ }
324
+
325
+ await ctx.state.setState((syncState) => ({
326
+ ...syncState,
327
+ firstLoadDone: true,
328
+ error,
329
+ }));
330
+
331
+ ctx.logger.debug('[dync] Batch first load completed');
332
+ }