@tanstack/powersync-db-collection 0.1.37 → 0.1.38

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.
package/src/powersync.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import { DiffTriggerOperation, sanitizeSQL } from '@powersync/common'
2
+ import { or } from '@tanstack/db'
3
+ import { compileSQLite } from './sqlite-compiler'
2
4
  import { PendingOperationStore } from './PendingOperationStore'
3
5
  import { PowerSyncTransactor } from './PowerSyncTransactor'
4
6
  import { DEFAULT_BATCH_SIZE } from './definitions'
5
7
  import { asPowerSyncRecord, mapOperation } from './helpers'
6
8
  import { convertTableToSchema } from './schema'
7
9
  import { serializeForSQLite } from './serialization'
10
+ import type {
11
+ CleanupFn,
12
+ LoadSubsetOptions,
13
+ OperationType,
14
+ SyncConfig,
15
+ } from '@tanstack/db'
8
16
  import type {
9
17
  AnyTableColumnType,
10
18
  ExtractedTable,
@@ -24,9 +32,8 @@ import type {
24
32
  PowerSyncCollectionUtils,
25
33
  } from './definitions'
26
34
  import type { PendingOperation } from './PendingOperationStore'
27
- import type { SyncConfig } from '@tanstack/db'
28
35
  import type { StandardSchemaV1 } from '@standard-schema/spec'
29
- import type { Table, TriggerDiffRecord } from '@powersync/common'
36
+ import type { LockContext, Table, TriggerDiffRecord } from '@powersync/common'
30
37
 
31
38
  /**
32
39
  * Creates PowerSync collection options for use with a standard Collection.
@@ -225,6 +232,7 @@ export function powerSyncCollectionOptions<
225
232
  table,
226
233
  schema: inputSchema,
227
234
  syncBatchSize = DEFAULT_BATCH_SIZE,
235
+ syncMode = 'eager',
228
236
  ...restConfig
229
237
  } = config
230
238
 
@@ -296,134 +304,361 @@ export function powerSyncCollectionOptions<
296
304
  */
297
305
  const sync: SyncConfig<OutputType, string> = {
298
306
  sync: (params) => {
299
- const { begin, write, commit, markReady } = params
307
+ const { begin, write, collection, commit, markReady } = params
300
308
  const abortController = new AbortController()
301
309
 
302
- // The sync function needs to be synchronous
303
- async function start() {
304
- database.logger.info(
305
- `Sync is starting for ${viewName} into ${trackedTableName}`,
306
- )
307
- database.onChangeWithCallback(
308
- {
309
- onChange: async () => {
310
- await database
311
- .writeTransaction(async (context) => {
312
- begin()
313
- const operations = await context.getAll<TriggerDiffRecord>(
314
- `SELECT * FROM ${trackedTableName} ORDER BY timestamp ASC`,
315
- )
316
- const pendingOperations: Array<PendingOperation> = []
317
-
318
- for (const op of operations) {
319
- const { id, operation, timestamp, value } = op
320
- const parsedValue = deserializeSyncRow({
321
- id,
322
- ...JSON.parse(value),
323
- })
324
- const parsedPreviousValue =
325
- op.operation == DiffTriggerOperation.UPDATE
326
- ? deserializeSyncRow({
327
- id,
328
- ...JSON.parse(op.previous_value),
329
- })
330
- : undefined
331
- write({
332
- type: mapOperation(operation),
333
- value: parsedValue,
334
- previousValue: parsedPreviousValue,
335
- })
336
- pendingOperations.push({
337
- id,
338
- operation,
339
- timestamp,
340
- tableName: viewName,
341
- })
342
- }
343
-
344
- // clear the current operations
345
- await context.execute(`DELETE FROM ${trackedTableName}`)
346
-
347
- commit()
348
- pendingOperationStore.resolvePendingFor(pendingOperations)
349
- })
350
- .catch((error) => {
351
- database.logger.error(
352
- `An error has been detected in the sync handler`,
353
- error,
354
- )
355
- })
356
- },
357
- },
358
- {
359
- signal: abortController.signal,
360
- triggerImmediate: false,
361
- tables: [trackedTableName],
362
- },
363
- )
310
+ let disposeTracking:
311
+ | ((options?: { context?: LockContext }) => Promise<void>)
312
+ | null = null
364
313
 
365
- const disposeTracking = await database.triggers.createDiffTrigger({
314
+ if (syncMode === `eager`) {
315
+ return runEagerSync()
316
+ } else {
317
+ return runOnDemandSync()
318
+ }
319
+
320
+ async function createDiffTrigger(options: {
321
+ setupContext?: LockContext
322
+ when: Record<DiffTriggerOperation, string>
323
+ writeType: (rowId: string) => OperationType
324
+ batchQuery: (
325
+ lockContext: LockContext,
326
+ batchSize: number,
327
+ cursor: number,
328
+ ) => Promise<Array<TableType>>
329
+ onReady: () => void
330
+ }) {
331
+ const { setupContext, when, writeType, batchQuery, onReady } = options
332
+
333
+ return await database.triggers.createDiffTrigger({
366
334
  source: viewName,
367
335
  destination: trackedTableName,
368
- when: {
369
- [DiffTriggerOperation.INSERT]: `TRUE`,
370
- [DiffTriggerOperation.UPDATE]: `TRUE`,
371
- [DiffTriggerOperation.DELETE]: `TRUE`,
372
- },
336
+ setupContext,
337
+ when,
373
338
  hooks: {
374
339
  beforeCreate: async (context) => {
375
340
  let currentBatchCount = syncBatchSize
376
341
  let cursor = 0
377
342
  while (currentBatchCount == syncBatchSize) {
378
343
  begin()
379
- const batchItems = await context.getAll<TableType>(
380
- sanitizeSQL`SELECT * FROM ${viewName} LIMIT ? OFFSET ?`,
381
- [syncBatchSize, cursor],
344
+
345
+ const batchItems = await batchQuery(
346
+ context,
347
+ syncBatchSize,
348
+ cursor,
382
349
  )
383
350
  currentBatchCount = batchItems.length
384
351
  cursor += currentBatchCount
385
352
  for (const row of batchItems) {
386
353
  write({
387
- type: `insert`,
354
+ type: writeType(row.id),
388
355
  value: deserializeSyncRow(row),
389
356
  })
390
357
  }
391
358
  commit()
392
359
  }
393
- markReady()
360
+ onReady()
394
361
  database.logger.info(
395
362
  `Sync is ready for ${viewName} into ${trackedTableName}`,
396
363
  )
397
364
  },
398
365
  },
399
366
  })
367
+ }
368
+
369
+ async function flushDiffRecords(): Promise<void> {
370
+ await database
371
+ .writeTransaction(async (context) => {
372
+ await flushDiffRecordsWithContext(context)
373
+ })
374
+ .catch((error) => {
375
+ database.logger.error(
376
+ `An error has been detected in the sync handler`,
377
+ error,
378
+ )
379
+ })
380
+ }
381
+
382
+ // We can use this directly if we want to pair a flush with dispose+recreate diff trigger.
383
+ async function flushDiffRecordsWithContext(
384
+ context: LockContext,
385
+ ): Promise<void> {
386
+ try {
387
+ begin()
388
+ const operations = await context.getAll<TriggerDiffRecord>(
389
+ `SELECT * FROM ${trackedTableName} ORDER BY operation_id ASC`,
390
+ )
391
+ const pendingOperations: Array<PendingOperation> = []
392
+
393
+ for (const op of operations) {
394
+ const { id, operation, timestamp, value } = op
395
+ const parsedValue = deserializeSyncRow({
396
+ id,
397
+ ...JSON.parse(value),
398
+ })
399
+ const parsedPreviousValue =
400
+ op.operation == DiffTriggerOperation.UPDATE
401
+ ? deserializeSyncRow({
402
+ id,
403
+ ...JSON.parse(op.previous_value),
404
+ })
405
+ : undefined
406
+ write({
407
+ type: mapOperation(operation),
408
+ value: parsedValue,
409
+ previousValue: parsedPreviousValue,
410
+ })
411
+ pendingOperations.push({
412
+ id,
413
+ operation,
414
+ timestamp,
415
+ tableName: viewName,
416
+ })
417
+ }
418
+
419
+ // clear the current operations
420
+ await context.execute(`DELETE FROM ${trackedTableName}`)
421
+
422
+ commit()
423
+ pendingOperationStore.resolvePendingFor(pendingOperations)
424
+ } catch (error) {
425
+ database.logger.error(
426
+ `An error has been detected in the sync handler`,
427
+ error,
428
+ )
429
+ }
430
+ }
431
+
432
+ // The sync function needs to be synchronous.
433
+ async function start(afterOnChangeRegistered?: () => Promise<void>) {
434
+ database.logger.info(
435
+ `Sync is starting for ${viewName} into ${trackedTableName}`,
436
+ )
437
+ database.onChangeWithCallback(
438
+ {
439
+ onChange: async () => {
440
+ await flushDiffRecords()
441
+ },
442
+ },
443
+ {
444
+ signal: abortController.signal,
445
+ triggerImmediate: false,
446
+ tables: [trackedTableName],
447
+ },
448
+ )
449
+
450
+ await afterOnChangeRegistered?.()
400
451
 
401
452
  // If the abort controller was aborted while processing the request above
402
453
  if (abortController.signal.aborted) {
403
- await disposeTracking()
454
+ await disposeTracking?.()
404
455
  } else {
405
456
  abortController.signal.addEventListener(
406
457
  `abort`,
407
- () => {
408
- disposeTracking()
458
+ async () => {
459
+ await disposeTracking?.()
409
460
  },
410
461
  { once: true },
411
462
  )
412
463
  }
413
464
  }
414
465
 
415
- start().catch((error) =>
416
- database.logger.error(
417
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
418
- error,
419
- ),
420
- )
466
+ // Eager mode.
467
+ // Registers a diff trigger for the entire table.
468
+ function runEagerSync() {
469
+ let onUnload: CleanupFn | void | null = null
421
470
 
422
- return () => {
423
- database.logger.info(
424
- `Sync has been stopped for ${viewName} into ${trackedTableName}`,
471
+ start(async () => {
472
+ onUnload = await restConfig.onLoad?.()
473
+
474
+ disposeTracking = await createDiffTrigger({
475
+ when: {
476
+ [DiffTriggerOperation.INSERT]: `TRUE`,
477
+ [DiffTriggerOperation.UPDATE]: `TRUE`,
478
+ [DiffTriggerOperation.DELETE]: `TRUE`,
479
+ },
480
+ writeType: (_rowId: string) => `insert`,
481
+ batchQuery: (
482
+ lockContext: LockContext,
483
+ batchSize: number,
484
+ cursor: number,
485
+ ) =>
486
+ lockContext.getAll<TableType>(
487
+ sanitizeSQL`SELECT * FROM ${viewName} LIMIT ? OFFSET ?`,
488
+ [batchSize, cursor],
489
+ ),
490
+ onReady: () => markReady(),
491
+ })
492
+ }).catch((error) =>
493
+ database.logger.error(
494
+ `Could not start syncing process for ${viewName} into ${trackedTableName}`,
495
+ error,
496
+ ),
425
497
  )
426
- abortController.abort()
498
+
499
+ return () => {
500
+ database.logger.info(
501
+ `Sync has been stopped for ${viewName} into ${trackedTableName}`,
502
+ )
503
+ abortController.abort()
504
+ onUnload?.()
505
+ }
506
+ }
507
+
508
+ // On-demand mode.
509
+ // Registers a diff trigger for the active WHERE expressions.
510
+ function runOnDemandSync() {
511
+ let onUnloadSubset: CleanupFn | void | null = null
512
+
513
+ start().catch((error) =>
514
+ database.logger.error(
515
+ `Could not start syncing process for ${viewName} into ${trackedTableName}`,
516
+ error,
517
+ ),
518
+ )
519
+
520
+ // Tracks all active WHERE expressions for on-demand sync filtering.
521
+ // Each loadSubset call pushes its predicate; unloadSubset removes it.
522
+ const activeWhereExpressions: Array<LoadSubsetOptions['where']> = []
523
+
524
+ const loadSubset = async (
525
+ options?: LoadSubsetOptions,
526
+ ): Promise<void> => {
527
+ if (options) {
528
+ activeWhereExpressions.push(options.where)
529
+ onUnloadSubset = await restConfig.onLoadSubset?.(options)
530
+ }
531
+
532
+ if (activeWhereExpressions.length === 0) {
533
+ await database.writeLock(async (ctx) => {
534
+ await flushDiffRecordsWithContext(ctx)
535
+ await disposeTracking?.({ context: ctx })
536
+ })
537
+ return
538
+ }
539
+
540
+ const combinedWhere =
541
+ activeWhereExpressions.length === 1
542
+ ? activeWhereExpressions[0]
543
+ : or(
544
+ activeWhereExpressions[0],
545
+ activeWhereExpressions[1],
546
+ ...activeWhereExpressions.slice(2),
547
+ )
548
+
549
+ const compiledNewData = compileSQLite(
550
+ { where: combinedWhere },
551
+ { jsonColumn: 'NEW.data' },
552
+ )
553
+
554
+ const compiledOldData = compileSQLite(
555
+ { where: combinedWhere },
556
+ { jsonColumn: 'OLD.data' },
557
+ )
558
+
559
+ const compiledView = compileSQLite({ where: combinedWhere })
560
+
561
+ const newDataWhenClause = toInlinedWhereClause(compiledNewData)
562
+ const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
563
+ const viewWhereClause = toInlinedWhereClause(compiledView)
564
+
565
+ await database.writeLock(async (ctx) => {
566
+ await flushDiffRecordsWithContext(ctx)
567
+ await disposeTracking?.({ context: ctx })
568
+
569
+ disposeTracking = await createDiffTrigger({
570
+ setupContext: ctx,
571
+ when: {
572
+ [DiffTriggerOperation.INSERT]: newDataWhenClause,
573
+ [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
574
+ [DiffTriggerOperation.DELETE]: oldDataWhenClause,
575
+ },
576
+ writeType: (rowId: string) =>
577
+ collection.has(rowId) ? `update` : `insert`,
578
+ batchQuery: (
579
+ lockContext: LockContext,
580
+ batchSize: number,
581
+ cursor: number,
582
+ ) =>
583
+ lockContext.getAll<TableType>(
584
+ `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
585
+ [batchSize, cursor],
586
+ ),
587
+ onReady: () => {},
588
+ })
589
+ })
590
+ }
591
+
592
+ const toInlinedWhereClause = (compiled: {
593
+ where?: string
594
+ params: Array<unknown>
595
+ }): string => {
596
+ if (!compiled.where) return 'TRUE'
597
+ const sqlParts = compiled.where.split('?')
598
+ return sanitizeSQL(
599
+ sqlParts as unknown as TemplateStringsArray,
600
+ ...compiled.params,
601
+ )
602
+ }
603
+
604
+ const unloadSubset = async (options: LoadSubsetOptions) => {
605
+ onUnloadSubset?.()
606
+
607
+ const idx = activeWhereExpressions.indexOf(options.where)
608
+ if (idx !== -1) {
609
+ activeWhereExpressions.splice(idx, 1)
610
+ }
611
+
612
+ // Evict rows that were exclusively loaded by the departing predicate.
613
+ // These are rows matching the departing WHERE that are no longer covered
614
+ // by any remaining active predicate.
615
+ const compiledDeparting = compileSQLite({ where: options.where })
616
+ const departingWhereSQL = toInlinedWhereClause(compiledDeparting)
617
+
618
+ let evictionSQL: string
619
+ if (activeWhereExpressions.length === 0) {
620
+ evictionSQL = `SELECT id FROM ${viewName} WHERE ${departingWhereSQL}`
621
+ } else {
622
+ const combinedRemaining =
623
+ activeWhereExpressions.length === 1
624
+ ? activeWhereExpressions[0]!
625
+ : or(
626
+ activeWhereExpressions[0],
627
+ activeWhereExpressions[1],
628
+ ...activeWhereExpressions.slice(2),
629
+ )
630
+ const compiledRemaining = compileSQLite({
631
+ where: combinedRemaining,
632
+ })
633
+ const remainingWhereSQL = toInlinedWhereClause(compiledRemaining)
634
+ evictionSQL = `SELECT id FROM ${viewName} WHERE (${departingWhereSQL}) AND NOT (${remainingWhereSQL})`
635
+ }
636
+
637
+ const rowsToEvict = await database.getAll<{ id: string }>(evictionSQL)
638
+ if (rowsToEvict.length > 0) {
639
+ begin()
640
+ for (const { id } of rowsToEvict) {
641
+ write({ type: `delete`, key: id })
642
+ }
643
+ commit()
644
+ }
645
+
646
+ // Recreate the diff trigger for the remaining active WHERE expressions.
647
+ await loadSubset()
648
+ }
649
+
650
+ markReady()
651
+
652
+ return {
653
+ cleanup: () => {
654
+ database.logger.info(
655
+ `Sync has been stopped for ${viewName} into ${trackedTableName}`,
656
+ )
657
+ abortController.abort()
658
+ },
659
+ loadSubset: (options: LoadSubsetOptions) => loadSubset(options),
660
+ unloadSubset: (options: LoadSubsetOptions) => unloadSubset(options),
661
+ }
427
662
  }
428
663
  },
429
664
  // Expose the getSyncMetadata function
@@ -442,6 +677,7 @@ export function powerSyncCollectionOptions<
442
677
  getKey,
443
678
  // Syncing should start immediately since we need to monitor the changes for mutations
444
679
  startSync: true,
680
+ syncMode,
445
681
  sync,
446
682
  onInsert: async (params) => {
447
683
  // The transaction here should only ever contain a single insert mutation