@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,613 @@
1
+ import { newLogger, type Logger, type LogLevel } from './logger';
2
+ import { sleep } from './helpers';
3
+ import {
4
+ type ApiFunctions,
5
+ type BatchSync,
6
+ type SyncOptions,
7
+ type SyncState,
8
+ type SyncedRecord,
9
+ type MissingRemoteRecordStrategy,
10
+ type ConflictResolutionStrategy,
11
+ type SyncStatus,
12
+ type SyncApi,
13
+ type TableMap,
14
+ type MutationEvent,
15
+ LOCAL_PK,
16
+ SERVER_PK,
17
+ UPDATED_AT,
18
+ } from './types';
19
+ import { addVisibilityChangeListener } from './addVisibilityChangeListener';
20
+ import { type StorageAdapter, type StorageTable, type TransactionMode } from './storage/types';
21
+ import type { StorageSchemaDefinitionOptions, SQLiteVersionConfigurator } from './storage/sqlite/types';
22
+ import type { TableSchemaDefinition, SQLiteTableDefinition } from './storage/sqlite/schema';
23
+ import { enhanceSyncTable, setupEnhancedTables as setupEnhancedTablesHelper, wrapWithMutationEmitter } from './core/tableEnhancers';
24
+ import { pullAll as runPullAll, pullAllBatch as runPullAllBatch } from './core/pullOperations';
25
+ import { pushAll as runPushAll, pushAllBatch as runPushAllBatch } from './core/pushOperations';
26
+ import { startFirstLoad as runFirstLoad, startFirstLoadBatch as runFirstLoadBatch } from './core/firstLoad';
27
+ import type { FirstLoadProgressCallback, VisibilitySubscription } from './types';
28
+ import { StateManager, DYNC_STATE_TABLE, type StateHelpers } from './core/StateManager';
29
+ import type { MemoryQueryContext } from './storage/memory/MemoryQueryContext';
30
+ import type { SqliteQueryContext } from './storage/sqlite/SqliteQueryContext';
31
+ import type { DexieQueryContext } from './storage/dexie/DexieQueryContext';
32
+
33
+ const DEFAULT_SYNC_INTERVAL_MILLIS = 2000;
34
+ const DEFAULT_LOGGER: Logger = console;
35
+ const DEFAULT_MIN_LOG_LEVEL: LogLevel = 'debug';
36
+ const DEFAULT_MISSING_REMOTE_RECORD_STRATEGY: MissingRemoteRecordStrategy = 'insert-remote-record';
37
+ const DEFAULT_CONFLICT_RESOLUTION_STRATEGY: ConflictResolutionStrategy = 'try-shallow-merge';
38
+
39
+ class DyncBase<_TStoreMap = Record<string, any>> {
40
+ private readonly adapter: StorageAdapter;
41
+ private readonly tableCache = new Map<string, StorageTable<any>>();
42
+ private readonly mutationWrappedTables = new Set<string>();
43
+ private readonly syncEnhancedTables = new Set<string>();
44
+ private readonly mutationListeners = new Set<(event: MutationEvent) => void>();
45
+ private visibilitySubscription?: VisibilitySubscription;
46
+ private openPromise?: Promise<void>;
47
+ private disableSyncPromise?: Promise<void>;
48
+ private disableSyncPromiseResolver?: () => void;
49
+ private sleepAbortController?: AbortController;
50
+ private closing = false;
51
+ // Per-table sync mode
52
+ private syncApis: Record<string, ApiFunctions> = {};
53
+ // Batch sync mode
54
+ private batchSync?: BatchSync;
55
+ private syncedTables: Set<string> = new Set();
56
+ private syncOptions: SyncOptions;
57
+ private logger: Logger;
58
+ private syncTimerStarted = false;
59
+ private mutationsDuringSync = false;
60
+ private state!: StateHelpers;
61
+ readonly name: string;
62
+
63
+ /**
64
+ * Create a new Dync instance.
65
+ *
66
+ * Mode 1 - Per-table endpoints:
67
+ * @param databaseName - Name of the database
68
+ * @param syncApis - Map of table names to API functions
69
+ * @param storageAdapter - Storage adapter implementation (required)
70
+ * @param options - Sync options
71
+ *
72
+ * Mode 2 - Batch endpoints:
73
+ * @param databaseName - Name of the database
74
+ * @param batchSync - Batch sync config (syncTables, push, pull, firstLoad)
75
+ * @param storageAdapter - Storage adapter implementation (required)
76
+ * @param options - Sync options
77
+ */
78
+ constructor(databaseName: string, syncApis: Record<string, ApiFunctions>, storageAdapter: StorageAdapter, options?: SyncOptions);
79
+ constructor(databaseName: string, batchSync: BatchSync, storageAdapter: StorageAdapter, options?: SyncOptions);
80
+ constructor(databaseName: string, syncApisOrBatchSync: Record<string, ApiFunctions> | BatchSync, storageAdapter: StorageAdapter, options?: SyncOptions) {
81
+ // Detect mode based on whether the second arg has sync API shape (has 'list' function)
82
+ const isBatchMode = typeof (syncApisOrBatchSync as BatchSync).push === 'function';
83
+
84
+ if (isBatchMode) {
85
+ // Batch mode: (databaseName, batchSync, options?)
86
+ this.batchSync = syncApisOrBatchSync as BatchSync;
87
+ this.syncedTables = new Set(this.batchSync.syncTables);
88
+ } else {
89
+ // Per-table mode: (databaseName, syncApis, options?)
90
+ this.syncApis = syncApisOrBatchSync as Record<string, ApiFunctions>;
91
+ this.syncedTables = new Set(Object.keys(this.syncApis));
92
+ }
93
+
94
+ this.adapter = storageAdapter;
95
+ this.name = databaseName;
96
+ this.syncOptions = {
97
+ syncInterval: DEFAULT_SYNC_INTERVAL_MILLIS,
98
+ logger: DEFAULT_LOGGER,
99
+ minLogLevel: DEFAULT_MIN_LOG_LEVEL,
100
+ missingRemoteRecordDuringUpdateStrategy: DEFAULT_MISSING_REMOTE_RECORD_STRATEGY,
101
+ conflictResolutionStrategy: DEFAULT_CONFLICT_RESOLUTION_STRATEGY,
102
+ ...(options ?? {}),
103
+ };
104
+
105
+ this.logger = newLogger(this.syncOptions.logger!, this.syncOptions.minLogLevel!);
106
+ this.state = new StateManager({
107
+ storageAdapter: this.adapter,
108
+ });
109
+
110
+ const driverInfo = 'driverType' in this.adapter ? ` (Driver: ${this.adapter.driverType})` : '';
111
+ this.logger.debug(`[dync] Initialized with ${this.adapter.type}${driverInfo}`);
112
+ }
113
+
114
+ version(versionNumber: number) {
115
+ /* eslint-disable @typescript-eslint/no-this-alias */
116
+ const self = this;
117
+ const schemaOptions: StorageSchemaDefinitionOptions = {};
118
+ let storesDefined = false;
119
+
120
+ const builder = {
121
+ stores(schema: Record<string, TableSchemaDefinition>) {
122
+ // Detect if any user table uses structured schema (for SQLite)
123
+ const usesStructuredSchema = Object.values(schema).some((def) => typeof def !== 'string');
124
+
125
+ // Inject internal state table for sync state persistence
126
+ // Use structured schema if user is using structured schema, otherwise use string schema
127
+ // Note: SQLite adapter requires _localId column as primary key
128
+ const stateTableSchema: TableSchemaDefinition = usesStructuredSchema
129
+ ? {
130
+ columns: {
131
+ [LOCAL_PK]: { type: 'TEXT' },
132
+ value: { type: 'TEXT' },
133
+ },
134
+ }
135
+ : LOCAL_PK;
136
+
137
+ const fullSchema: Record<string, TableSchemaDefinition> = {
138
+ ...schema,
139
+ [DYNC_STATE_TABLE]: stateTableSchema,
140
+ };
141
+
142
+ for (const [tableName, tableSchema] of Object.entries(schema)) {
143
+ const isSyncTable = self.syncedTables.has(tableName);
144
+
145
+ if (typeof tableSchema === 'string') {
146
+ if (isSyncTable) {
147
+ // Auto-inject sync fields for sync tables
148
+ // Note: updated_at is indexed to support user queries like orderBy('updated_at')
149
+ fullSchema[tableName] = `${LOCAL_PK}, &${SERVER_PK}, ${tableSchema}, ${UPDATED_AT}`;
150
+ }
151
+
152
+ self.logger.debug(
153
+ `[dync] Defining ${isSyncTable ? '' : 'non-'}sync table '${tableName}' with primary key & indexes '${fullSchema[tableName]}'`,
154
+ );
155
+ } else {
156
+ if (isSyncTable) {
157
+ // Auto-inject sync columns for structured schemas
158
+ fullSchema[tableName] = self.injectSyncColumns(tableSchema);
159
+ }
160
+
161
+ const schemaColumns = Object.keys((fullSchema[tableName] as SQLiteTableDefinition).columns ?? {}).join(', ');
162
+ const schemaIndexes = ((fullSchema[tableName] as SQLiteTableDefinition).indexes ?? []).map((idx) => idx.columns.join('+')).join(', ');
163
+
164
+ self.logger.debug(
165
+ `[dync] Defining ${isSyncTable ? '' : 'non-'}sync table '${tableName}' with columns ${schemaColumns} and indexes ${schemaIndexes}`,
166
+ );
167
+ }
168
+ }
169
+
170
+ storesDefined = true;
171
+ self.adapter.defineSchema(versionNumber, fullSchema, schemaOptions);
172
+ self.setupEnhancedTables(Object.keys(schema));
173
+
174
+ return builder;
175
+ },
176
+ sqlite(configure: (builder: SQLiteVersionConfigurator) => void) {
177
+ if (!storesDefined) {
178
+ throw new Error('Call stores() before registering sqlite migrations');
179
+ }
180
+ const sqliteOptions = (schemaOptions.sqlite ??= {});
181
+ const migrations = (sqliteOptions.migrations ??= {});
182
+ const configurator: SQLiteVersionConfigurator = {
183
+ upgrade(handler) {
184
+ migrations.upgrade = handler;
185
+ },
186
+ downgrade(handler) {
187
+ migrations.downgrade = handler;
188
+ },
189
+ };
190
+ configure(configurator);
191
+ return builder;
192
+ },
193
+ };
194
+
195
+ return builder;
196
+ }
197
+
198
+ async open(): Promise<void> {
199
+ if (this.closing) {
200
+ return;
201
+ }
202
+ if (this.openPromise) {
203
+ return this.openPromise;
204
+ }
205
+
206
+ this.openPromise = (async () => {
207
+ if (this.closing) return;
208
+ await this.adapter.open();
209
+ if (this.closing) return;
210
+ await this.state.hydrate();
211
+ })();
212
+
213
+ return this.openPromise;
214
+ }
215
+
216
+ async close(): Promise<void> {
217
+ // Mark as closing to abort any pending opens
218
+ this.closing = true;
219
+ // Wait for any pending open to complete before closing
220
+ if (this.openPromise) {
221
+ await this.openPromise.catch(() => {});
222
+ this.openPromise = undefined;
223
+ }
224
+ await this.enableSync(false);
225
+ await this.adapter.close();
226
+ this.tableCache.clear();
227
+ this.mutationWrappedTables.clear();
228
+ this.syncEnhancedTables.clear();
229
+ }
230
+
231
+ async delete(): Promise<void> {
232
+ await this.adapter.delete();
233
+ // Clear any cached table wrappers that may reference a deleted/closed database.
234
+ this.tableCache.clear();
235
+ this.mutationWrappedTables.clear();
236
+ this.syncEnhancedTables.clear();
237
+ }
238
+
239
+ async query<R>(callback: (ctx: DexieQueryContext | SqliteQueryContext | MemoryQueryContext) => Promise<R>): Promise<R> {
240
+ return this.adapter.query(callback as any);
241
+ }
242
+
243
+ table<K extends keyof _TStoreMap>(name: K): StorageTable<_TStoreMap[K]>;
244
+ table<T = any>(name: string): StorageTable<T>;
245
+ table(name: string) {
246
+ if (this.tableCache.has(name)) {
247
+ return this.tableCache.get(name)!;
248
+ }
249
+
250
+ const table = this.adapter.table(name);
251
+ const isSyncTable = this.syncedTables.has(name);
252
+
253
+ // For sync tables, enhanceSyncTable handles both pending changes AND mutation emission.
254
+ // For non-sync tables, we just need mutation emission for useLiveQuery reactivity.
255
+ if (isSyncTable && !this.syncEnhancedTables.has(name)) {
256
+ this.enhanceSyncTable(table as StorageTable<SyncedRecord>, name);
257
+ } else if (!isSyncTable && !this.mutationWrappedTables.has(name) && name !== DYNC_STATE_TABLE) {
258
+ wrapWithMutationEmitter(table, name, this.emitMutation.bind(this));
259
+ this.mutationWrappedTables.add(name);
260
+ }
261
+
262
+ this.tableCache.set(name, table as StorageTable<any>);
263
+ return table;
264
+ }
265
+
266
+ private async withTransaction<T>(mode: TransactionMode, tableNames: string[], fn: (tables: Record<string, StorageTable<any>>) => Promise<T>): Promise<T> {
267
+ await this.open();
268
+ return this.adapter.transaction(mode, tableNames, async () => {
269
+ const tables: Record<string, StorageTable<any>> = {};
270
+ for (const tableName of tableNames) {
271
+ tables[tableName] = this.table(tableName);
272
+ }
273
+ return fn(tables);
274
+ });
275
+ }
276
+
277
+ private setupEnhancedTables(tableNames: string[]): void {
278
+ setupEnhancedTablesHelper(
279
+ {
280
+ owner: this,
281
+ tableCache: this.tableCache,
282
+ enhancedTables: this.syncEnhancedTables,
283
+ getTable: (name) => this.table(name),
284
+ },
285
+ tableNames,
286
+ );
287
+ // Also clear mutation wrapping so tables get re-wrapped on next access
288
+ for (const tableName of tableNames) {
289
+ this.mutationWrappedTables.delete(tableName);
290
+ }
291
+ }
292
+
293
+ private injectSyncColumns(schema: SQLiteTableDefinition): SQLiteTableDefinition {
294
+ const columns = schema.columns ?? {};
295
+
296
+ // Validate user hasn't defined reserved sync columns
297
+ if (columns[LOCAL_PK]) {
298
+ throw new Error(`Column '${LOCAL_PK}' is auto-injected for sync tables and cannot be defined manually.`);
299
+ }
300
+ if (columns[SERVER_PK]) {
301
+ throw new Error(`Column '${SERVER_PK}' is auto-injected for sync tables and cannot be defined manually.`);
302
+ }
303
+ if (columns[UPDATED_AT]) {
304
+ throw new Error(`Column '${UPDATED_AT}' is auto-injected for sync tables and cannot be defined manually.`);
305
+ }
306
+
307
+ // Inject _localId, id, and updated_at columns
308
+ const injectedColumns: Record<string, any> = {
309
+ ...columns,
310
+ [LOCAL_PK]: { type: 'TEXT' },
311
+ [SERVER_PK]: { type: 'INTEGER', unique: true },
312
+ [UPDATED_AT]: { type: 'TEXT' },
313
+ };
314
+
315
+ // Auto-inject updated_at index if not already defined by user
316
+ // This supports user queries like orderBy('updated_at')
317
+ const userIndexes = schema.indexes ?? [];
318
+ const hasUpdatedAtIndex = userIndexes.some((idx) => idx.columns.length === 1 && idx.columns[0] === UPDATED_AT);
319
+
320
+ const injectedIndexes = hasUpdatedAtIndex ? userIndexes : [...userIndexes, { columns: [UPDATED_AT] }];
321
+
322
+ return {
323
+ ...schema,
324
+ columns: injectedColumns,
325
+ indexes: injectedIndexes,
326
+ };
327
+ }
328
+
329
+ private enhanceSyncTable<T>(table: StorageTable<T & SyncedRecord>, tableName: string): void {
330
+ enhanceSyncTable({
331
+ table,
332
+ tableName,
333
+ withTransaction: this.withTransaction.bind(this),
334
+ state: this.state,
335
+ enhancedTables: this.syncEnhancedTables,
336
+ emitMutation: this.emitMutation.bind(this),
337
+ });
338
+ }
339
+
340
+ private async syncOnce(): Promise<void> {
341
+ if (this.closing) {
342
+ return;
343
+ }
344
+ if (this.syncStatus === 'syncing') {
345
+ this.mutationsDuringSync = true;
346
+ return;
347
+ }
348
+
349
+ this.syncStatus = 'syncing';
350
+ this.mutationsDuringSync = false;
351
+
352
+ const pullResult = await this.pullAll();
353
+ const firstPushSyncError = await this.pushAll();
354
+
355
+ // Emit pull mutation only for tables that had changes
356
+ for (const tableName of pullResult.changedTables) {
357
+ this.emitMutation({ type: 'pull', tableName });
358
+ }
359
+
360
+ this.syncStatus = 'idle';
361
+ await this.state.setState((syncState) => ({
362
+ ...syncState,
363
+ error: pullResult.error ?? firstPushSyncError,
364
+ }));
365
+
366
+ if (this.mutationsDuringSync) {
367
+ this.mutationsDuringSync = false;
368
+ this.syncOnce().catch(() => {
369
+ // Suppress unhandled promise rejections that occur when the database closes
370
+ });
371
+ }
372
+ }
373
+
374
+ private async pullAll(): Promise<{ error?: Error; changedTables: string[] }> {
375
+ const baseContext = {
376
+ logger: this.logger,
377
+ state: this.state,
378
+ table: this.table.bind(this),
379
+ withTransaction: this.withTransaction.bind(this),
380
+ conflictResolutionStrategy: this.syncOptions.conflictResolutionStrategy!,
381
+ };
382
+
383
+ if (this.batchSync) {
384
+ return runPullAllBatch({
385
+ ...baseContext,
386
+ batchSync: this.batchSync,
387
+ });
388
+ }
389
+
390
+ return runPullAll({
391
+ ...baseContext,
392
+ syncApis: this.syncApis,
393
+ });
394
+ }
395
+
396
+ private async pushAll(): Promise<Error | undefined> {
397
+ const baseContext = {
398
+ logger: this.logger,
399
+ state: this.state,
400
+ table: this.table.bind(this),
401
+ withTransaction: this.withTransaction.bind(this),
402
+ syncOptions: this.syncOptions,
403
+ };
404
+
405
+ if (this.batchSync) {
406
+ return runPushAllBatch({
407
+ ...baseContext,
408
+ batchSync: this.batchSync,
409
+ });
410
+ }
411
+
412
+ return runPushAll({
413
+ ...baseContext,
414
+ syncApis: this.syncApis,
415
+ });
416
+ }
417
+
418
+ private startSyncTimer(start: boolean) {
419
+ if (start) {
420
+ void this.tryStart();
421
+ } else {
422
+ this.syncTimerStarted = false;
423
+ }
424
+ }
425
+
426
+ private async tryStart() {
427
+ if (this.syncTimerStarted) return;
428
+ this.syncTimerStarted = true;
429
+
430
+ while (this.syncTimerStarted) {
431
+ this.sleepAbortController = new AbortController();
432
+ await this.syncOnce();
433
+ await sleep(this.syncOptions.syncInterval!, this.sleepAbortController.signal);
434
+ }
435
+
436
+ this.syncStatus = 'disabled';
437
+ this.disableSyncPromiseResolver?.();
438
+ }
439
+
440
+ private setupVisibilityListener(add: boolean) {
441
+ this.visibilitySubscription = addVisibilityChangeListener(add, this.visibilitySubscription, (isVisible) => this.handleVisibilityChange(isVisible));
442
+ }
443
+
444
+ private handleVisibilityChange(isVisible: boolean) {
445
+ if (isVisible) {
446
+ this.logger.debug('[dync] sync:start-in-foreground');
447
+ this.startSyncTimer(true);
448
+ } else {
449
+ this.logger.debug('[dync] sync:pause-in-background');
450
+ this.startSyncTimer(false);
451
+ }
452
+ }
453
+
454
+ private async startFirstLoad(onProgress?: FirstLoadProgressCallback): Promise<void> {
455
+ // Ensure database is open and state is hydrated before first load
456
+ await this.open();
457
+
458
+ const baseContext = {
459
+ logger: this.logger,
460
+ state: this.state,
461
+ table: this.table.bind(this),
462
+ withTransaction: this.withTransaction.bind(this),
463
+ onProgress,
464
+ };
465
+
466
+ if (this.batchSync) {
467
+ await runFirstLoadBatch({
468
+ ...baseContext,
469
+ batchSync: this.batchSync,
470
+ });
471
+ } else {
472
+ await runFirstLoad({
473
+ ...baseContext,
474
+ syncApis: this.syncApis,
475
+ });
476
+ }
477
+
478
+ // Emit pull mutation for all synced tables to trigger live query updates
479
+ for (const tableName of this.syncedTables) {
480
+ this.emitMutation({ type: 'pull', tableName });
481
+ }
482
+ }
483
+
484
+ private getSyncState(): SyncState {
485
+ return this.state.getSyncState();
486
+ }
487
+
488
+ private async resolveConflict(localId: string, keepLocal: boolean): Promise<void> {
489
+ const conflict = this.state.getState().conflicts?.[localId];
490
+ if (!conflict) {
491
+ this.logger.warn(`[dync] No conflict found for localId: ${localId}`);
492
+ return;
493
+ }
494
+
495
+ await this.withTransaction('rw', [conflict.stateKey, DYNC_STATE_TABLE], async (tables) => {
496
+ const txTable = tables[conflict.stateKey]!;
497
+ if (!keepLocal) {
498
+ const item = await txTable.get(localId);
499
+ if (item) {
500
+ // Use remote value(s)
501
+ for (const field of conflict.fields) {
502
+ item[field.key] = field.remoteValue;
503
+ }
504
+
505
+ await txTable.raw.update(localId, item);
506
+ } else {
507
+ this.logger.warn(`[dync] No local item found for localId: ${localId} to apply remote values`);
508
+ }
509
+
510
+ await this.state.setState((syncState) => ({
511
+ ...syncState,
512
+ pendingChanges: syncState.pendingChanges.filter((p) => !(p.localId === localId && p.stateKey === conflict.stateKey)),
513
+ }));
514
+ }
515
+
516
+ await this.state.setState((syncState) => {
517
+ const ss = { ...syncState };
518
+ delete ss.conflicts?.[localId];
519
+ return ss;
520
+ });
521
+ });
522
+ }
523
+
524
+ private async enableSync(enabled: boolean) {
525
+ if (!enabled) {
526
+ // Only wait for sync to stop if it was actually running
527
+ if (this.syncTimerStarted) {
528
+ this.disableSyncPromise = new Promise((resolve) => {
529
+ this.disableSyncPromiseResolver = resolve;
530
+ });
531
+ this.sleepAbortController?.abort();
532
+ this.syncStatus = 'disabling';
533
+ this.startSyncTimer(false);
534
+ this.setupVisibilityListener(false);
535
+ return this.disableSyncPromise;
536
+ }
537
+ this.syncStatus = 'disabled';
538
+ this.setupVisibilityListener(false);
539
+ return Promise.resolve();
540
+ }
541
+ this.syncStatus = 'idle';
542
+ this.startSyncTimer(true);
543
+ this.setupVisibilityListener(true);
544
+ return Promise.resolve();
545
+ }
546
+
547
+ get syncStatus(): SyncStatus {
548
+ return this.state.getSyncStatus();
549
+ }
550
+
551
+ set syncStatus(status: SyncStatus) {
552
+ this.state.setSyncStatus(status);
553
+ }
554
+
555
+ private onSyncStateChange(fn: (state: SyncState) => void): () => void {
556
+ return this.state.subscribe(fn);
557
+ }
558
+
559
+ private onMutation(fn: (event: MutationEvent) => void): () => void {
560
+ this.mutationListeners.add(fn);
561
+ return () => this.mutationListeners.delete(fn);
562
+ }
563
+
564
+ private emitMutation(event: MutationEvent): void {
565
+ // Trigger sync on data changes if sync was enabled
566
+ if (event.type === 'add' || event.type === 'update' || event.type === 'delete') {
567
+ if (this.syncTimerStarted) {
568
+ this.syncOnce().catch(() => {
569
+ // Suppress unhandled promise rejections that occur when the database closes
570
+ });
571
+ }
572
+ }
573
+
574
+ for (const listener of this.mutationListeners) {
575
+ listener(event);
576
+ }
577
+ }
578
+
579
+ // Public API
580
+ sync: SyncApi = {
581
+ enable: this.enableSync.bind(this),
582
+ startFirstLoad: this.startFirstLoad.bind(this),
583
+ getState: this.getSyncState.bind(this),
584
+ resolveConflict: this.resolveConflict.bind(this),
585
+ onStateChange: this.onSyncStateChange.bind(this),
586
+ onMutation: this.onMutation.bind(this),
587
+ };
588
+ }
589
+
590
+ type DyncInstance<TStoreMap extends Record<string, unknown> = Record<string, unknown>> = DyncBase<TStoreMap> &
591
+ TableMap<TStoreMap> & {
592
+ table<K extends keyof TStoreMap & string>(name: K): StorageTable<TStoreMap[K]>;
593
+ table(name: string): StorageTable<any>;
594
+ };
595
+
596
+ const DyncConstructor = DyncBase as unknown as {
597
+ prototype: DyncInstance<Record<string, unknown>>;
598
+ new <TStoreMap extends Record<string, unknown> = Record<string, unknown>>(
599
+ databaseName: string,
600
+ syncApis: Record<string, ApiFunctions>,
601
+ storageAdapter: StorageAdapter,
602
+ options?: SyncOptions,
603
+ ): DyncInstance<TStoreMap>;
604
+ new <TStoreMap extends Record<string, unknown> = Record<string, unknown>>(
605
+ databaseName: string,
606
+ batchSync: BatchSync,
607
+ storageAdapter: StorageAdapter,
608
+ options?: SyncOptions,
609
+ ): DyncInstance<TStoreMap>;
610
+ } & typeof DyncBase;
611
+
612
+ export const Dync = DyncConstructor;
613
+ export type Dync<TStoreMap extends Record<string, unknown> = Record<string, unknown>> = DyncInstance<TStoreMap>;
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export * from './index.shared';
2
+ export { MemoryAdapter } from './storage/memory';
3
+ export { SQLiteAdapter } from './storage/sqlite';
4
+ export { MemoryQueryContext } from './storage/memory';
5
+ export { SqliteQueryContext } from './storage/sqlite';
6
+ export type { SQLiteDatabaseDriver, SQLiteQueryResult, SQLiteRunResult } from './storage/sqlite/types';
7
+ export type { StorageAdapter } from './storage/types';
8
+ export { SyncAction } from './types';
9
+ export { createLocalId } from './helpers';
10
+
11
+ export type {
12
+ AfterRemoteAddCallback,
13
+ ApiFunctions,
14
+ BatchSync,
15
+ BatchPushPayload,
16
+ BatchPushResult,
17
+ BatchFirstLoadResult,
18
+ ConflictResolutionStrategy,
19
+ FirstLoadProgress,
20
+ FirstLoadProgressCallback,
21
+ MissingRemoteRecordStrategy,
22
+ MissingRemoteRecordDuringUpdateCallback,
23
+ MutationEvent,
24
+ SyncOptions,
25
+ SyncState,
26
+ SyncedRecord,
27
+ TableMap,
28
+ } from './types';
package/src/logger.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
2
+
3
+ export interface Logger {
4
+ debug: (...args: any[]) => void;
5
+ info: (...args: any[]) => void;
6
+ warn: (...args: any[]) => void;
7
+ error: (...args: any[]) => void;
8
+ }
9
+
10
+ export function newLogger(base: Logger, min: LogLevel): Logger {
11
+ const order: Record<LogLevel, number> = {
12
+ debug: 10,
13
+ info: 20,
14
+ warn: 30,
15
+ error: 40,
16
+ none: 100,
17
+ };
18
+ const threshold = order[min];
19
+ const enabled = (lvl: LogLevel) => order[lvl] >= threshold;
20
+ return {
21
+ debug: (...a: any[]) => enabled('debug') && base.debug?.(...a),
22
+ info: (...a: any[]) => enabled('info') && base.info?.(...a),
23
+ warn: (...a: any[]) => enabled('warn') && base.warn?.(...a),
24
+ error: (...a: any[]) => enabled('error') && base.error?.(...a),
25
+ };
26
+ }
package/src/node.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Node.js SQLite Driver using better-sqlite3
2
+ // Works in Node.js apps and Electron main process
3
+ export { BetterSqlite3Driver, type BetterSqlite3DriverOptions } from './storage/sqlite/drivers/BetterSqlite3Driver';
4
+ export type { SQLiteDatabaseDriver, SQLiteQueryResult, SQLiteRunResult } from './storage/sqlite/types';
@@ -0,0 +1,2 @@
1
+ export { makeDync } from './useDync';
2
+ export type { BoundUseLiveQuery, MakeDyncConfig, MakeDyncConfigBatch, MakeDyncConfigPerTable, MakeDyncResult, UseDyncValue } from './useDync';