@tanstack/powersync-db-collection 0.1.67 → 0.2.1

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.
@@ -9,6 +9,9 @@ const definitions = require("./definitions.cjs");
9
9
  const helpers = require("./helpers.cjs");
10
10
  const schema = require("./schema.cjs");
11
11
  const serialization = require("./serialization.cjs");
12
+ function isPromiseLike(value) {
13
+ return !!value && (typeof value === `object` || typeof value === `function`) && typeof value.then === `function`;
14
+ }
12
15
  function powerSyncCollectionOptions(config) {
13
16
  const outputConfig = createPowerSyncCollectionConfig(config);
14
17
  return db.withCollectionConfigFactory(
@@ -36,12 +39,15 @@ function createPowerSyncCollectionConfig(config) {
36
39
  return validation.value;
37
40
  } else if (`issues` in validation) {
38
41
  const issueMessage = `Failed to validate incoming data for ${viewName}. Issues: ${validation.issues.map((issue) => `${issue.path} - ${issue.message}`)}`;
39
- database.logger.error(issueMessage);
42
+ database.logger.log({ level: common.LogLevels.error, message: issueMessage });
40
43
  onDeserializationError(validation);
41
44
  throw new Error(issueMessage);
42
45
  } else {
43
46
  const unknownErrorMessage = `Unknown deserialization error for ${viewName}`;
44
- database.logger.error(unknownErrorMessage);
47
+ database.logger.log({
48
+ level: common.LogLevels.error,
49
+ message: unknownErrorMessage
50
+ });
45
51
  onDeserializationError({ issues: [{ message: unknownErrorMessage }] });
46
52
  throw new Error(unknownErrorMessage);
47
53
  }
@@ -60,6 +66,8 @@ function createPowerSyncCollectionConfig(config) {
60
66
  const abortController = new AbortController();
61
67
  let disposeTracking = null;
62
68
  let trackingSetup = null;
69
+ let activeTrackingDisposal = null;
70
+ let abortTrackingDisposal = null;
63
71
  if (syncMode === `eager`) {
64
72
  return runEagerSync();
65
73
  } else {
@@ -72,10 +80,29 @@ function createPowerSyncCollectionConfig(config) {
72
80
  }
73
81
  const dispose = disposeTracking;
74
82
  if (!dispose) {
83
+ await activeTrackingDisposal;
75
84
  return;
76
85
  }
77
86
  disposeTracking = null;
78
- await dispose(context ? { context } : void 0);
87
+ let disposal;
88
+ try {
89
+ disposal = Promise.resolve(dispose(context ? { context } : void 0));
90
+ } catch (error) {
91
+ disposal = Promise.reject(error);
92
+ }
93
+ activeTrackingDisposal = disposal;
94
+ try {
95
+ await disposal;
96
+ } finally {
97
+ if (activeTrackingDisposal === disposal) {
98
+ activeTrackingDisposal = null;
99
+ }
100
+ }
101
+ }
102
+ function disposeTrackingAfterAbort() {
103
+ const disposal = abortTrackingDisposal ??= safelyDisposeTracking();
104
+ void disposal.catch(() => void 0);
105
+ return disposal;
79
106
  }
80
107
  async function establishTracking(options, appliedReceipts) {
81
108
  const setup = (async () => {
@@ -96,6 +123,7 @@ function createPowerSyncCollectionConfig(config) {
96
123
  return await database.triggers.createDiffTrigger({
97
124
  source: viewName,
98
125
  destination: trackedTableName,
126
+ columns: table.columns.map((column) => column.name),
99
127
  setupContext,
100
128
  when,
101
129
  hooks: {
@@ -119,9 +147,10 @@ function createPowerSyncCollectionConfig(config) {
119
147
  }
120
148
  appliedReceipts.push(commit());
121
149
  }
122
- database.logger.info(
123
- `Sync is ready for ${viewName} into ${trackedTableName}`
124
- );
150
+ database.logger.log({
151
+ level: common.LogLevels.info,
152
+ message: `Sync is ready for ${viewName} into ${trackedTableName}`
153
+ });
125
154
  }
126
155
  }
127
156
  });
@@ -138,10 +167,11 @@ function createPowerSyncCollectionConfig(config) {
138
167
  await database.writeTransaction(async (context) => {
139
168
  await flushDiffRecordsWithContext(context, ignoredReceipts);
140
169
  }).catch((error) => {
141
- database.logger.error(
142
- `An error has been detected in the sync handler`,
170
+ database.logger.log({
171
+ level: common.LogLevels.error,
172
+ message: `An error has been detected in the sync handler`,
143
173
  error
144
- );
174
+ });
145
175
  });
146
176
  }
147
177
  async function flushDiffRecordsWithContext(context, appliedReceipts) {
@@ -181,16 +211,18 @@ function createPowerSyncCollectionConfig(config) {
181
211
  appliedReceipts.push(applied);
182
212
  pendingOperationStore.resolvePendingFor(pendingOperations);
183
213
  } catch (error) {
184
- database.logger.error(
185
- `An error has been detected in the sync handler`,
214
+ database.logger.log({
215
+ level: common.LogLevels.error,
216
+ message: `An error has been detected in the sync handler`,
186
217
  error
187
- );
218
+ });
188
219
  }
189
220
  }
190
221
  async function start(afterOnChangeRegistered) {
191
- database.logger.info(
192
- `Sync is starting for ${viewName} into ${trackedTableName}`
193
- );
222
+ database.logger.log({
223
+ level: common.LogLevels.info,
224
+ message: `Sync is starting for ${viewName} into ${trackedTableName}`
225
+ });
194
226
  database.onChangeWithCallback(
195
227
  {
196
228
  onChange: async () => {
@@ -205,12 +237,12 @@ function createPowerSyncCollectionConfig(config) {
205
237
  );
206
238
  await afterOnChangeRegistered?.();
207
239
  if (abortController.signal.aborted) {
208
- await safelyDisposeTracking();
240
+ await disposeTrackingAfterAbort();
209
241
  } else {
210
242
  abortController.signal.addEventListener(
211
243
  `abort`,
212
- async () => {
213
- await safelyDisposeTracking();
244
+ () => {
245
+ void disposeTrackingAfterAbort();
214
246
  },
215
247
  { once: true }
216
248
  );
@@ -218,13 +250,13 @@ function createPowerSyncCollectionConfig(config) {
218
250
  }
219
251
  function runEagerSync() {
220
252
  let onUnload = null;
221
- start(async () => {
253
+ let onUnloadStarted = false;
254
+ const startup = start(async () => {
222
255
  const cleanup = await restConfig.onLoad?.();
256
+ onUnload = cleanup;
223
257
  if (abortController.signal.aborted) {
224
- cleanup?.();
225
258
  return;
226
259
  }
227
- onUnload = cleanup;
228
260
  const appliedReceipts = [];
229
261
  await establishTracking(
230
262
  {
@@ -247,38 +279,77 @@ function createPowerSyncCollectionConfig(config) {
247
279
  await Promise.all(appliedReceipts);
248
280
  markReady();
249
281
  }).catch((error) => {
250
- database.logger.error(
251
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
282
+ database.logger.log({
283
+ level: common.LogLevels.error,
284
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
252
285
  error
253
- );
286
+ });
254
287
  if (collection.status === `loading`) {
255
288
  markError(error);
256
289
  }
257
290
  });
258
- return () => {
259
- database.logger.info(
260
- `Sync has been stopped for ${viewName} into ${trackedTableName}`
261
- );
291
+ const invokeOnUnload = () => {
292
+ if (onUnloadStarted || !onUnload) return;
293
+ onUnloadStarted = true;
294
+ const cleanup = onUnload;
295
+ onUnload = null;
296
+ return cleanup();
297
+ };
298
+ return async () => {
299
+ database.logger.log({
300
+ level: common.LogLevels.info,
301
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`
302
+ });
262
303
  abortController.abort();
263
- onUnload?.();
304
+ const trackingDisposal = disposeTrackingAfterAbort();
305
+ let onUnloadFailure;
306
+ const settleOnUnload = async () => {
307
+ try {
308
+ await invokeOnUnload();
309
+ } catch (error) {
310
+ onUnloadFailure ??= { error };
311
+ }
312
+ };
313
+ const settlements = await Promise.allSettled([
314
+ startup,
315
+ trackingDisposal,
316
+ settleOnUnload()
317
+ ]);
318
+ const disposalSettlement = settlements[1];
319
+ await settleOnUnload();
320
+ if (disposalSettlement.status === `rejected` && onUnloadFailure) {
321
+ throw new AggregateError(
322
+ [disposalSettlement.reason, onUnloadFailure.error],
323
+ `PowerSync tracking disposal and load-hook cleanup both failed`,
324
+ { cause: disposalSettlement.reason }
325
+ );
326
+ }
327
+ if (disposalSettlement.status === `rejected`) {
328
+ throw disposalSettlement.reason;
329
+ }
330
+ if (onUnloadFailure) throw onUnloadFailure.error;
264
331
  };
265
332
  }
266
333
  function runOnDemandSync() {
267
334
  const demands = /* @__PURE__ */ new Map();
268
335
  const releasedSubsets = /* @__PURE__ */ new WeakSet();
269
336
  const pendingReleases = [];
337
+ const pendingDemandLoads = /* @__PURE__ */ new Set();
338
+ const pendingDemandCleanups = /* @__PURE__ */ new Set();
270
339
  let stopped = false;
271
340
  let trackingRevision = 0;
272
341
  let reconciledTrackingRevision = 0;
273
342
  let rebuildPromise = null;
274
343
  let drainingReleases = false;
275
344
  let releaseRetryTimer;
345
+ let demandCleanupCollector = null;
276
346
  const startup = start();
277
347
  void startup.catch(
278
- (error) => database.logger.error(
279
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
348
+ (error) => database.logger.log({
349
+ level: common.LogLevels.error,
350
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
280
351
  error
281
- )
352
+ })
282
353
  );
283
354
  const activeWhereExpressions = () => Array.from(demands.values()).filter((demand) => demand.active).map((demand) => demand.options.where);
284
355
  const reconcileTracking = async () => {
@@ -293,41 +364,52 @@ function createPowerSyncCollectionConfig(config) {
293
364
  await safelyDisposeTracking(ctx);
294
365
  if (!isCurrent()) return;
295
366
  const active = activeWhereExpressions();
296
- if (active.length === 0) return;
297
- const combinedWhere = active.length === 1 ? active[0] : db.or(active[0], active[1], ...active.slice(2));
298
- const compiledNewData = sqliteCompiler.compileSQLite(
299
- { where: combinedWhere },
300
- { jsonColumn: "NEW.data" }
301
- );
302
- const compiledOldData = sqliteCompiler.compileSQLite(
303
- { where: combinedWhere },
304
- { jsonColumn: "OLD.data" }
305
- );
306
- const compiledView = sqliteCompiler.compileSQLite({ where: combinedWhere });
307
- const newDataWhenClause = toInlinedWhereClause(compiledNewData);
308
- const oldDataWhenClause = toInlinedWhereClause(compiledOldData);
309
- const viewWhereClause = toInlinedWhereClause(compiledView);
310
- await establishTracking(
311
- {
312
- setupContext: ctx,
313
- when: {
314
- [common.DiffTriggerOperation.INSERT]: newDataWhenClause,
315
- [common.DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
316
- [common.DiffTriggerOperation.DELETE]: oldDataWhenClause
367
+ const missing = collection.status === `error` ? new Set(collection.keys()) : void 0;
368
+ if (active.length > 0) {
369
+ const combinedWhere = active.length === 1 ? active[0] : db.or(active[0], active[1], ...active.slice(2));
370
+ const compiledNewData = sqliteCompiler.compileSQLite(
371
+ { where: combinedWhere },
372
+ { jsonColumn: "NEW.data" }
373
+ );
374
+ const compiledOldData = sqliteCompiler.compileSQLite(
375
+ { where: combinedWhere },
376
+ { jsonColumn: "OLD.data" }
377
+ );
378
+ const compiledView = sqliteCompiler.compileSQLite({ where: combinedWhere });
379
+ const newDataWhenClause = toInlinedWhereClause(compiledNewData);
380
+ const oldDataWhenClause = toInlinedWhereClause(compiledOldData);
381
+ const viewWhereClause = toInlinedWhereClause(compiledView);
382
+ await establishTracking(
383
+ {
384
+ setupContext: ctx,
385
+ when: {
386
+ [common.DiffTriggerOperation.INSERT]: newDataWhenClause,
387
+ [common.DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
388
+ [common.DiffTriggerOperation.DELETE]: oldDataWhenClause
389
+ },
390
+ writeType: (rowId) => collection.has(rowId) ? `update` : `insert`,
391
+ batchQuery: (lockContext, batchSize, cursor) => lockContext.getAll(
392
+ `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
393
+ [batchSize, cursor]
394
+ ).then((rows) => {
395
+ for (const row of rows) missing?.delete(row.id);
396
+ return rows;
397
+ })
317
398
  },
318
- writeType: (rowId) => collection.has(rowId) ? `update` : `insert`,
319
- batchQuery: (lockContext, batchSize, cursor) => lockContext.getAll(
320
- `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
321
- [batchSize, cursor]
322
- )
323
- },
324
- appliedReceipts
325
- );
399
+ appliedReceipts
400
+ );
401
+ }
326
402
  if (!isCurrent()) await safelyDisposeTracking(ctx);
403
+ else if (missing?.size) {
404
+ begin();
405
+ for (const key of missing) write({ type: `delete`, key });
406
+ appliedReceipts.push(commit());
407
+ }
327
408
  });
328
409
  await Promise.all(appliedReceipts);
329
410
  if (isCurrent()) {
330
411
  reconciledTrackingRevision = revision;
412
+ if (collection.status === `error`) markReady();
331
413
  }
332
414
  }
333
415
  };
@@ -358,6 +440,13 @@ function createPowerSyncCollectionConfig(config) {
358
440
  if (cleanup) demand.cleanup = cleanup;
359
441
  } catch (error) {
360
442
  demands.delete(options);
443
+ const collector = demandCleanupCollector;
444
+ if (collector) {
445
+ collector.failures.push({
446
+ order: collector.nextOrder++,
447
+ error
448
+ });
449
+ }
361
450
  throw error;
362
451
  }
363
452
  if (
@@ -365,14 +454,20 @@ function createPowerSyncCollectionConfig(config) {
365
454
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
366
455
  stopped || releasedSubsets.has(options) || options.signal?.aborted || demands.get(options) !== demand
367
456
  ) {
368
- demands.delete(options);
369
- demand.cleanup?.();
457
+ cleanupDemand(demand);
370
458
  return;
371
459
  }
372
460
  demand.active = true;
373
461
  trackingRevision++;
374
462
  await rebuildTracking();
375
463
  };
464
+ const trackLoadSubset = (options) => {
465
+ const task = loadSubset(options);
466
+ pendingDemandLoads.add(task);
467
+ const finish = () => pendingDemandLoads.delete(task);
468
+ void task.then(finish, finish);
469
+ return task;
470
+ };
376
471
  const toInlinedWhereClause = (compiled) => {
377
472
  if (!compiled.where) return "TRUE";
378
473
  const sqlParts = compiled.where.split("?");
@@ -381,15 +476,55 @@ function createPowerSyncCollectionConfig(config) {
381
476
  ...compiled.params
382
477
  );
383
478
  };
479
+ const reportDemandCleanupFailure = (error) => {
480
+ database.logger.log({
481
+ level: common.LogLevels.error,
482
+ message: `Could not clean up subset hook for ${viewName}`,
483
+ error
484
+ });
485
+ };
384
486
  const cleanupDemand = (demand) => {
385
487
  demands.delete(demand.options);
488
+ const collector = demandCleanupCollector;
489
+ const order = collector ? collector.nextOrder++ : void 0;
386
490
  try {
387
- demand.cleanup?.();
491
+ const cleanup = demand.cleanup;
492
+ demand.cleanup = void 0;
493
+ const result = cleanup?.();
494
+ if (!isPromiseLike(result)) return;
495
+ const task = Promise.resolve(result);
496
+ pendingDemandCleanups.add(task);
497
+ const finish = () => pendingDemandCleanups.delete(task);
498
+ void task.then(finish, finish);
499
+ if (collector) {
500
+ collector.tasks.push({ order, promise: task });
501
+ collector.ownedTasks.add(task);
502
+ void task.catch(() => void 0);
503
+ } else {
504
+ void task.catch((error) => {
505
+ if (!demandCleanupCollector?.ownedTasks.has(task)) {
506
+ reportDemandCleanupFailure(error);
507
+ }
508
+ });
509
+ }
388
510
  } catch (error) {
389
- database.logger.error(
390
- `Could not clean up subset hook for ${viewName}`,
391
- error
392
- );
511
+ if (collector) {
512
+ collector.failures.push({ order, error });
513
+ } else {
514
+ reportDemandCleanupFailure(error);
515
+ }
516
+ }
517
+ };
518
+ const throwDemandCleanupFailures = (collector, primaryFailures = []) => {
519
+ const errors = [
520
+ ...primaryFailures,
521
+ ...collector.failures.slice().sort((left, right) => left.order - right.order).map(({ error }) => error)
522
+ ];
523
+ if (errors.length === 1) throw errors[0];
524
+ if (errors.length > 1) {
525
+ throw new AggregateError(errors, `PowerSync cleanup failed`, {
526
+ cause: errors[0]
527
+ });
393
528
  }
394
529
  };
395
530
  const performPhysicalRelease = async (options) => {
@@ -453,10 +588,11 @@ function createPowerSyncCollectionConfig(config) {
453
588
  3e4
454
589
  );
455
590
  retryDelay = retryDelay === 0 ? delay : Math.min(retryDelay, delay);
456
- database.logger.error(
457
- `Could not release subset tracking for ${viewName}; retrying`,
591
+ database.logger.log({
592
+ level: common.LogLevels.error,
593
+ message: `Could not release subset tracking for ${viewName}; retrying`,
458
594
  error
459
- );
595
+ });
460
596
  }
461
597
  }
462
598
  } finally {
@@ -484,16 +620,58 @@ function createPowerSyncCollectionConfig(config) {
484
620
  stopped = true;
485
621
  clearTimeout(releaseRetryTimer);
486
622
  releaseRetryTimer = void 0;
487
- database.logger.info(
488
- `Sync has been stopped for ${viewName} into ${trackedTableName}`
489
- );
623
+ database.logger.log({
624
+ level: common.LogLevels.info,
625
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`
626
+ });
490
627
  abortController.abort();
628
+ const trackingDisposal = disposeTrackingAfterAbort();
629
+ const pendingLoads = [...pendingDemandLoads];
630
+ const pendingTasks = [...pendingDemandCleanups];
631
+ const collector = {
632
+ tasks: pendingTasks.map((promise, order) => ({ order, promise })),
633
+ ownedTasks: new Set(pendingTasks),
634
+ failures: [],
635
+ nextOrder: pendingTasks.length
636
+ };
637
+ demandCleanupCollector = collector;
491
638
  for (const demand of demands.values()) {
492
639
  cleanupDemand(demand);
493
640
  }
494
641
  pendingReleases.length = 0;
642
+ return (async () => {
643
+ const ownershipSettlements = await Promise.allSettled([
644
+ trackingDisposal,
645
+ ...pendingLoads
646
+ ]);
647
+ const disposalSettlement = ownershipSettlements[0];
648
+ let settledTasks = 0;
649
+ while (settledTasks < collector.tasks.length) {
650
+ const tasks = collector.tasks.slice(settledTasks);
651
+ settledTasks = collector.tasks.length;
652
+ const outcomes = await Promise.allSettled(
653
+ tasks.map(({ promise }) => promise)
654
+ );
655
+ for (const [index, outcome] of outcomes.entries()) {
656
+ if (outcome.status === `rejected`) {
657
+ collector.failures.push({
658
+ order: tasks[index].order,
659
+ error: outcome.reason
660
+ });
661
+ }
662
+ }
663
+ }
664
+ throwDemandCleanupFailures(
665
+ collector,
666
+ disposalSettlement.status === `rejected` ? [disposalSettlement.reason] : []
667
+ );
668
+ })().finally(() => {
669
+ if (demandCleanupCollector === collector) {
670
+ demandCleanupCollector = null;
671
+ }
672
+ });
495
673
  },
496
- loadSubset: (options) => loadSubset(options),
674
+ loadSubset: trackLoadSubset,
497
675
  unloadSubset
498
676
  };
499
677
  }
@@ -524,13 +702,7 @@ function createPowerSyncCollectionConfig(config) {
524
702
  tableName: viewName,
525
703
  trackedTableName,
526
704
  metadataIsTracked,
527
- serializeValue: (value) => serialization.serializeForSQLite(
528
- value,
529
- // This is required by the input generic
530
- table,
531
- // Coerce serializer to the shape that corresponds to the Table constructed from OutputType
532
- serializer
533
- )
705
+ serializeValue: (value) => serialization.serializeForSQLite(value, table, serializer)
534
706
  })
535
707
  }
536
708
  };