@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.
package/src/powersync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { DiffTriggerOperation, sanitizeSQL } from '@powersync/common'
1
+ import { DiffTriggerOperation, LogLevels, sanitizeSQL } from '@powersync/common'
2
2
  import { or, withCollectionConfigFactory } from '@tanstack/db'
3
3
  import { compileSQLite } from './sqlite-compiler'
4
4
  import { PendingOperationStore } from './PendingOperationStore'
@@ -17,8 +17,6 @@ import type {
17
17
  import type {
18
18
  AnyTableColumnType,
19
19
  ExtractedTable,
20
- ExtractedTableColumns,
21
- MapBaseColumnType,
22
20
  OptionalExtractedTable,
23
21
  } from './helpers'
24
22
  import type {
@@ -26,7 +24,6 @@ import type {
26
24
  ConfigWithArbitraryCollectionTypes,
27
25
  ConfigWithSQLiteInputType,
28
26
  ConfigWithSQLiteTypes,
29
- CustomSQLiteSerializer,
30
27
  EnhancedPowerSyncCollectionConfig,
31
28
  InferPowerSyncOutputType,
32
29
  PowerSyncCollectionConfig,
@@ -36,6 +33,14 @@ import type { PendingOperation } from './PendingOperationStore'
36
33
  import type { StandardSchemaV1 } from '@standard-schema/spec'
37
34
  import type { LockContext, Table, TriggerDiffRecord } from '@powersync/common'
38
35
 
36
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
37
+ return (
38
+ !!value &&
39
+ (typeof value === `object` || typeof value === `function`) &&
40
+ typeof (value as { then?: unknown }).then === `function`
41
+ )
42
+ }
43
+
39
44
  /**
40
45
  * Creates PowerSync collection options for use with a standard Collection.
41
46
  *
@@ -275,12 +280,15 @@ function createPowerSyncCollectionConfig<
275
280
  return validation.value
276
281
  } else if (`issues` in validation) {
277
282
  const issueMessage = `Failed to validate incoming data for ${viewName}. Issues: ${validation.issues.map((issue) => `${issue.path} - ${issue.message}`)}`
278
- database.logger.error(issueMessage)
283
+ database.logger.log({ level: LogLevels.error, message: issueMessage })
279
284
  onDeserializationError!(validation)
280
285
  throw new Error(issueMessage)
281
286
  } else {
282
287
  const unknownErrorMessage = `Unknown deserialization error for ${viewName}`
283
- database.logger.error(unknownErrorMessage)
288
+ database.logger.log({
289
+ level: LogLevels.error,
290
+ message: unknownErrorMessage,
291
+ })
284
292
  onDeserializationError!({ issues: [{ message: unknownErrorMessage }] })
285
293
  throw new Error(unknownErrorMessage)
286
294
  }
@@ -324,6 +332,8 @@ function createPowerSyncCollectionConfig<
324
332
  | ((options?: { context?: LockContext }) => Promise<void>)
325
333
  | null = null
326
334
  let trackingSetup: Promise<void> | null = null
335
+ let activeTrackingDisposal: Promise<void> | null = null
336
+ let abortTrackingDisposal: Promise<void> | null = null
327
337
 
328
338
  if (syncMode === `eager`) {
329
339
  return runEagerSync()
@@ -347,11 +357,35 @@ function createPowerSyncCollectionConfig<
347
357
 
348
358
  const dispose = disposeTracking
349
359
  if (!dispose) {
360
+ // Reconciliation may have claimed the disposer after this caller
361
+ // observed setup. Join that invocation instead of settling early.
362
+ await activeTrackingDisposal
350
363
  return
351
364
  }
352
365
 
353
366
  disposeTracking = null
354
- await dispose(context ? { context } : undefined)
367
+ let disposal: Promise<void>
368
+ try {
369
+ disposal = Promise.resolve(dispose(context ? { context } : undefined))
370
+ } catch (error) {
371
+ disposal = Promise.reject(error)
372
+ }
373
+ activeTrackingDisposal = disposal
374
+ try {
375
+ await disposal
376
+ } finally {
377
+ if (activeTrackingDisposal === disposal) {
378
+ activeTrackingDisposal = null
379
+ }
380
+ }
381
+ }
382
+
383
+ function disposeTrackingAfterAbort(): Promise<void> {
384
+ const disposal = (abortTrackingDisposal ??= safelyDisposeTracking())
385
+ // Abort event dispatch does not observe a listener's returned promise.
386
+ // The eager cleanup callback awaits this same promise below.
387
+ void disposal.catch(() => undefined)
388
+ return disposal
355
389
  }
356
390
 
357
391
  async function establishTracking(
@@ -392,6 +426,7 @@ function createPowerSyncCollectionConfig<
392
426
  return await database.triggers.createDiffTrigger({
393
427
  source: viewName,
394
428
  destination: trackedTableName,
429
+ columns: table.columns.map((column) => column.name),
395
430
  setupContext,
396
431
  when,
397
432
  hooks: {
@@ -416,9 +451,10 @@ function createPowerSyncCollectionConfig<
416
451
  }
417
452
  appliedReceipts.push(commit())
418
453
  }
419
- database.logger.info(
420
- `Sync is ready for ${viewName} into ${trackedTableName}`,
421
- )
454
+ database.logger.log({
455
+ level: LogLevels.info,
456
+ message: `Sync is ready for ${viewName} into ${trackedTableName}`,
457
+ })
422
458
  },
423
459
  },
424
460
  })
@@ -442,10 +478,11 @@ function createPowerSyncCollectionConfig<
442
478
  await flushDiffRecordsWithContext(context, ignoredReceipts)
443
479
  })
444
480
  .catch((error) => {
445
- database.logger.error(
446
- `An error has been detected in the sync handler`,
481
+ database.logger.log({
482
+ level: LogLevels.error,
483
+ message: `An error has been detected in the sync handler`,
447
484
  error,
448
- )
485
+ })
449
486
  })
450
487
  }
451
488
 
@@ -503,18 +540,20 @@ function createPowerSyncCollectionConfig<
503
540
  // transaction that currently parks it.
504
541
  pendingOperationStore.resolvePendingFor(pendingOperations)
505
542
  } catch (error) {
506
- database.logger.error(
507
- `An error has been detected in the sync handler`,
543
+ database.logger.log({
544
+ level: LogLevels.error,
545
+ message: `An error has been detected in the sync handler`,
508
546
  error,
509
- )
547
+ })
510
548
  }
511
549
  }
512
550
 
513
551
  // The sync function needs to be synchronous.
514
552
  async function start(afterOnChangeRegistered?: () => Promise<void>) {
515
- database.logger.info(
516
- `Sync is starting for ${viewName} into ${trackedTableName}`,
517
- )
553
+ database.logger.log({
554
+ level: LogLevels.info,
555
+ message: `Sync is starting for ${viewName} into ${trackedTableName}`,
556
+ })
518
557
  database.onChangeWithCallback(
519
558
  {
520
559
  onChange: async () => {
@@ -532,12 +571,12 @@ function createPowerSyncCollectionConfig<
532
571
 
533
572
  // If the abort controller was aborted while processing the request above
534
573
  if (abortController.signal.aborted) {
535
- await safelyDisposeTracking()
574
+ await disposeTrackingAfterAbort()
536
575
  } else {
537
576
  abortController.signal.addEventListener(
538
577
  `abort`,
539
- async () => {
540
- await safelyDisposeTracking()
578
+ () => {
579
+ void disposeTrackingAfterAbort()
541
580
  },
542
581
  { once: true },
543
582
  )
@@ -548,14 +587,14 @@ function createPowerSyncCollectionConfig<
548
587
  // Registers a diff trigger for the entire table.
549
588
  function runEagerSync() {
550
589
  let onUnload: CleanupFn | void | null = null
590
+ let onUnloadStarted = false
551
591
 
552
- start(async () => {
592
+ const startup = start(async () => {
553
593
  const cleanup = await restConfig.onLoad?.()
594
+ onUnload = cleanup
554
595
  if (abortController.signal.aborted) {
555
- cleanup?.()
556
596
  return
557
597
  }
558
- onUnload = cleanup
559
598
 
560
599
  const appliedReceipts: Array<SyncAppliedReceipt> = []
561
600
  await establishTracking(
@@ -584,21 +623,61 @@ function createPowerSyncCollectionConfig<
584
623
  await Promise.all(appliedReceipts)
585
624
  markReady()
586
625
  }).catch((error) => {
587
- database.logger.error(
588
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
626
+ database.logger.log({
627
+ level: LogLevels.error,
628
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
589
629
  error,
590
- )
630
+ })
591
631
  if (collection.status === `loading`) {
592
632
  markError(error)
593
633
  }
594
634
  })
595
635
 
596
- return () => {
597
- database.logger.info(
598
- `Sync has been stopped for ${viewName} into ${trackedTableName}`,
599
- )
636
+ const invokeOnUnload = (): void | Promise<void> => {
637
+ if (onUnloadStarted || !onUnload) return
638
+ onUnloadStarted = true
639
+ const cleanup = onUnload
640
+ onUnload = null
641
+ return cleanup()
642
+ }
643
+
644
+ return async () => {
645
+ database.logger.log({
646
+ level: LogLevels.info,
647
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`,
648
+ })
600
649
  abortController.abort()
601
- onUnload?.()
650
+ const trackingDisposal = disposeTrackingAfterAbort()
651
+
652
+ let onUnloadFailure: { error: unknown } | undefined
653
+ const settleOnUnload = async () => {
654
+ try {
655
+ await invokeOnUnload()
656
+ } catch (error) {
657
+ onUnloadFailure ??= { error }
658
+ }
659
+ }
660
+
661
+ // Invoke an already-installed callback in this stack, but also wait
662
+ // for startup to publish a callback acquired concurrently with abort.
663
+ const settlements = await Promise.allSettled([
664
+ startup,
665
+ trackingDisposal,
666
+ settleOnUnload(),
667
+ ])
668
+ const disposalSettlement = settlements[1]
669
+ await settleOnUnload()
670
+ if (disposalSettlement.status === `rejected` && onUnloadFailure) {
671
+ throw new AggregateError(
672
+ [disposalSettlement.reason, onUnloadFailure.error],
673
+ `PowerSync tracking disposal and load-hook cleanup both failed`,
674
+ { cause: disposalSettlement.reason },
675
+ )
676
+ }
677
+ if (disposalSettlement.status === `rejected`) {
678
+ throw disposalSettlement.reason
679
+ }
680
+ if (onUnloadFailure) throw onUnloadFailure.error
602
681
  }
603
682
  }
604
683
 
@@ -614,22 +693,40 @@ function createPowerSyncCollectionConfig<
614
693
  options: LoadSubsetOptions
615
694
  failures: number
616
695
  }
696
+ type DemandCleanupTask = {
697
+ order: number
698
+ promise: Promise<unknown>
699
+ }
700
+ type DemandCleanupFailure = {
701
+ order: number
702
+ error: unknown
703
+ }
704
+ type DemandCleanupCollector = {
705
+ tasks: Array<DemandCleanupTask>
706
+ ownedTasks: Set<Promise<unknown>>
707
+ failures: Array<DemandCleanupFailure>
708
+ nextOrder: number
709
+ }
617
710
 
618
711
  const demands = new Map<LoadSubsetOptions, DemandRecord>()
619
712
  const releasedSubsets = new WeakSet<LoadSubsetOptions>()
620
713
  const pendingReleases: Array<PendingRelease> = []
714
+ const pendingDemandLoads = new Set<Promise<void>>()
715
+ const pendingDemandCleanups = new Set<Promise<unknown>>()
621
716
  let stopped = false
622
717
  let trackingRevision = 0
623
718
  let reconciledTrackingRevision = 0
624
719
  let rebuildPromise: Promise<void> | null = null
625
720
  let drainingReleases = false
626
721
  let releaseRetryTimer: ReturnType<typeof setTimeout> | undefined
722
+ let demandCleanupCollector: DemandCleanupCollector | null = null
627
723
  const startup = start()
628
724
  void startup.catch((error) =>
629
- database.logger.error(
630
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
725
+ database.logger.log({
726
+ level: LogLevels.error,
727
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
631
728
  error,
632
- ),
729
+ }),
633
730
  )
634
731
 
635
732
  const activeWhereExpressions = () =>
@@ -653,51 +750,70 @@ function createPowerSyncCollectionConfig<
653
750
  if (!isCurrent()) return
654
751
 
655
752
  const active = activeWhereExpressions()
656
- if (active.length === 0) return
657
- const combinedWhere =
658
- active.length === 1
659
- ? active[0]
660
- : or(active[0], active[1], ...active.slice(2))
661
- const compiledNewData = compileSQLite(
662
- { where: combinedWhere },
663
- { jsonColumn: 'NEW.data' },
664
- )
665
- const compiledOldData = compileSQLite(
666
- { where: combinedWhere },
667
- { jsonColumn: 'OLD.data' },
668
- )
669
- const compiledView = compileSQLite({ where: combinedWhere })
670
- const newDataWhenClause = toInlinedWhereClause(compiledNewData)
671
- const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
672
- const viewWhereClause = toInlinedWhereClause(compiledView)
673
-
674
- await establishTracking(
675
- {
676
- setupContext: ctx,
677
- when: {
678
- [DiffTriggerOperation.INSERT]: newDataWhenClause,
679
- [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
680
- [DiffTriggerOperation.DELETE]: oldDataWhenClause,
753
+ // Tracking was absent during an error. Positive baseline rows
754
+ // alone cannot reveal deletes or predicate exits from that gap.
755
+ const missing =
756
+ collection.status === `error`
757
+ ? new Set(collection.keys())
758
+ : undefined
759
+ if (active.length > 0) {
760
+ const combinedWhere =
761
+ active.length === 1
762
+ ? active[0]
763
+ : or(active[0], active[1], ...active.slice(2))
764
+ const compiledNewData = compileSQLite(
765
+ { where: combinedWhere },
766
+ { jsonColumn: 'NEW.data' },
767
+ )
768
+ const compiledOldData = compileSQLite(
769
+ { where: combinedWhere },
770
+ { jsonColumn: 'OLD.data' },
771
+ )
772
+ const compiledView = compileSQLite({ where: combinedWhere })
773
+ const newDataWhenClause = toInlinedWhereClause(compiledNewData)
774
+ const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
775
+ const viewWhereClause = toInlinedWhereClause(compiledView)
776
+ await establishTracking(
777
+ {
778
+ setupContext: ctx,
779
+ when: {
780
+ [DiffTriggerOperation.INSERT]: newDataWhenClause,
781
+ [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
782
+ [DiffTriggerOperation.DELETE]: oldDataWhenClause,
783
+ },
784
+ writeType: (rowId: string) =>
785
+ collection.has(rowId) ? `update` : `insert`,
786
+ batchQuery: (
787
+ lockContext: LockContext,
788
+ batchSize: number,
789
+ cursor: number,
790
+ ) =>
791
+ lockContext
792
+ .getAll<TableType>(
793
+ `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
794
+ [batchSize, cursor],
795
+ )
796
+ .then((rows) => {
797
+ for (const row of rows) missing?.delete(row.id)
798
+ return rows
799
+ }),
681
800
  },
682
- writeType: (rowId: string) =>
683
- collection.has(rowId) ? `update` : `insert`,
684
- batchQuery: (
685
- lockContext: LockContext,
686
- batchSize: number,
687
- cursor: number,
688
- ) =>
689
- lockContext.getAll<TableType>(
690
- `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
691
- [batchSize, cursor],
692
- ),
693
- },
694
- appliedReceipts,
695
- )
801
+ appliedReceipts,
802
+ )
803
+ }
696
804
  if (!isCurrent()) await safelyDisposeTracking(ctx)
805
+ else if (missing?.size) {
806
+ begin()
807
+ for (const key of missing) write({ type: `delete`, key })
808
+ appliedReceipts.push(commit())
809
+ }
697
810
  })
698
811
  await Promise.all(appliedReceipts)
699
812
  if (isCurrent()) {
700
813
  reconciledTrackingRevision = revision
814
+ // Replacing the trigger alone is not recovery: its baseline
815
+ // writes must also be applied before the source is ready again.
816
+ if (collection.status === `error`) markReady()
701
817
  }
702
818
  }
703
819
  }
@@ -742,6 +858,13 @@ function createPowerSyncCollectionConfig<
742
858
  if (cleanup) demand.cleanup = cleanup
743
859
  } catch (error) {
744
860
  demands.delete(options)
861
+ const collector = demandCleanupCollector
862
+ if (collector) {
863
+ collector.failures.push({
864
+ order: collector.nextOrder++,
865
+ error,
866
+ })
867
+ }
745
868
  throw error
746
869
  }
747
870
 
@@ -753,8 +876,7 @@ function createPowerSyncCollectionConfig<
753
876
  options.signal?.aborted ||
754
877
  demands.get(options) !== demand
755
878
  ) {
756
- demands.delete(options)
757
- demand.cleanup?.()
879
+ cleanupDemand(demand)
758
880
  return
759
881
  }
760
882
 
@@ -763,6 +885,14 @@ function createPowerSyncCollectionConfig<
763
885
  await rebuildTracking()
764
886
  }
765
887
 
888
+ const trackLoadSubset = (options: LoadSubsetOptions): Promise<void> => {
889
+ const task = loadSubset(options)
890
+ pendingDemandLoads.add(task)
891
+ const finish = () => pendingDemandLoads.delete(task)
892
+ void task.then(finish, finish)
893
+ return task
894
+ }
895
+
766
896
  const toInlinedWhereClause = (compiled: {
767
897
  where?: string
768
898
  params: Array<unknown>
@@ -775,15 +905,64 @@ function createPowerSyncCollectionConfig<
775
905
  )
776
906
  }
777
907
 
908
+ const reportDemandCleanupFailure = (error: unknown): void => {
909
+ database.logger.log({
910
+ level: LogLevels.error,
911
+ message: `Could not clean up subset hook for ${viewName}`,
912
+ error,
913
+ })
914
+ }
915
+
778
916
  const cleanupDemand = (demand: DemandRecord): void => {
779
917
  demands.delete(demand.options)
918
+ const collector = demandCleanupCollector
919
+ const order = collector ? collector.nextOrder++ : undefined
780
920
  try {
781
- demand.cleanup?.()
921
+ const cleanup = demand.cleanup
922
+ demand.cleanup = undefined
923
+ const result: unknown = cleanup?.()
924
+ if (!isPromiseLike(result)) return
925
+
926
+ const task = Promise.resolve(result)
927
+ pendingDemandCleanups.add(task)
928
+ const finish = () => pendingDemandCleanups.delete(task)
929
+ void task.then(finish, finish)
930
+ if (collector) {
931
+ collector.tasks.push({ order: order!, promise: task })
932
+ collector.ownedTasks.add(task)
933
+ void task.catch(() => undefined)
934
+ } else {
935
+ void task.catch((error) => {
936
+ if (!demandCleanupCollector?.ownedTasks.has(task)) {
937
+ reportDemandCleanupFailure(error)
938
+ }
939
+ })
940
+ }
782
941
  } catch (error) {
783
- database.logger.error(
784
- `Could not clean up subset hook for ${viewName}`,
785
- error,
786
- )
942
+ if (collector) {
943
+ collector.failures.push({ order: order!, error })
944
+ } else {
945
+ reportDemandCleanupFailure(error)
946
+ }
947
+ }
948
+ }
949
+
950
+ const throwDemandCleanupFailures = (
951
+ collector: DemandCleanupCollector,
952
+ primaryFailures: ReadonlyArray<unknown> = [],
953
+ ): void => {
954
+ const errors = [
955
+ ...primaryFailures,
956
+ ...collector.failures
957
+ .slice()
958
+ .sort((left, right) => left.order - right.order)
959
+ .map(({ error }) => error),
960
+ ]
961
+ if (errors.length === 1) throw errors[0]
962
+ if (errors.length > 1) {
963
+ throw new AggregateError(errors, `PowerSync cleanup failed`, {
964
+ cause: errors[0],
965
+ })
787
966
  }
788
967
  }
789
968
 
@@ -859,10 +1038,11 @@ function createPowerSyncCollectionConfig<
859
1038
  )
860
1039
  retryDelay =
861
1040
  retryDelay === 0 ? delay : Math.min(retryDelay, delay)
862
- database.logger.error(
863
- `Could not release subset tracking for ${viewName}; retrying`,
1041
+ database.logger.log({
1042
+ level: LogLevels.error,
1043
+ message: `Could not release subset tracking for ${viewName}; retrying`,
864
1044
  error,
865
- )
1045
+ })
866
1046
  }
867
1047
  }
868
1048
  } finally {
@@ -896,16 +1076,62 @@ function createPowerSyncCollectionConfig<
896
1076
  stopped = true
897
1077
  clearTimeout(releaseRetryTimer)
898
1078
  releaseRetryTimer = undefined
899
- database.logger.info(
900
- `Sync has been stopped for ${viewName} into ${trackedTableName}`,
901
- )
1079
+ database.logger.log({
1080
+ level: LogLevels.info,
1081
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`,
1082
+ })
902
1083
  abortController.abort()
1084
+ const trackingDisposal = disposeTrackingAfterAbort()
1085
+ const pendingLoads = [...pendingDemandLoads]
1086
+ const pendingTasks = [...pendingDemandCleanups]
1087
+ const collector: DemandCleanupCollector = {
1088
+ tasks: pendingTasks.map((promise, order) => ({ order, promise })),
1089
+ ownedTasks: new Set(pendingTasks),
1090
+ failures: [],
1091
+ nextOrder: pendingTasks.length,
1092
+ }
1093
+ demandCleanupCollector = collector
903
1094
  for (const demand of demands.values()) {
904
1095
  cleanupDemand(demand)
905
1096
  }
906
1097
  pendingReleases.length = 0
1098
+
1099
+ return (async () => {
1100
+ const ownershipSettlements = await Promise.allSettled([
1101
+ trackingDisposal,
1102
+ ...pendingLoads,
1103
+ ])
1104
+ const disposalSettlement = ownershipSettlements[0]
1105
+
1106
+ let settledTasks = 0
1107
+ while (settledTasks < collector.tasks.length) {
1108
+ const tasks = collector.tasks.slice(settledTasks)
1109
+ settledTasks = collector.tasks.length
1110
+ const outcomes = await Promise.allSettled(
1111
+ tasks.map(({ promise }) => promise),
1112
+ )
1113
+ for (const [index, outcome] of outcomes.entries()) {
1114
+ if (outcome.status === `rejected`) {
1115
+ collector.failures.push({
1116
+ order: tasks[index]!.order,
1117
+ error: outcome.reason,
1118
+ })
1119
+ }
1120
+ }
1121
+ }
1122
+ throwDemandCleanupFailures(
1123
+ collector,
1124
+ disposalSettlement.status === `rejected`
1125
+ ? [disposalSettlement.reason]
1126
+ : [],
1127
+ )
1128
+ })().finally(() => {
1129
+ if (demandCleanupCollector === collector) {
1130
+ demandCleanupCollector = null
1131
+ }
1132
+ })
907
1133
  },
908
- loadSubset: (options: LoadSubsetOptions) => loadSubset(options),
1134
+ loadSubset: trackLoadSubset,
909
1135
  unloadSubset,
910
1136
  }
911
1137
  }
@@ -946,18 +1172,7 @@ function createPowerSyncCollectionConfig<
946
1172
  trackedTableName,
947
1173
  metadataIsTracked,
948
1174
  serializeValue: (value) =>
949
- serializeForSQLite(
950
- value,
951
- // This is required by the input generic
952
- table as Table<
953
- MapBaseColumnType<InferPowerSyncOutputType<TTable, TSchema>>
954
- >,
955
- // Coerce serializer to the shape that corresponds to the Table constructed from OutputType
956
- serializer as CustomSQLiteSerializer<
957
- OutputType,
958
- ExtractedTableColumns<Table<MapBaseColumnType<OutputType>>>
959
- >,
960
- ),
1175
+ serializeForSQLite<TTable>(value, table, serializer),
961
1176
  }),
962
1177
  },
963
1178
  }
@@ -2,9 +2,9 @@ import { ColumnType } from '@powersync/common'
2
2
  import type { Table } from '@powersync/common'
3
3
  import type { CustomSQLiteSerializer } from './definitions'
4
4
  import type {
5
+ AnyTableColumnType,
5
6
  ExtractedTable,
6
7
  ExtractedTableColumns,
7
- MapBaseColumnType,
8
8
  } from './helpers'
9
9
 
10
10
  /**
@@ -13,10 +13,6 @@ import type {
13
13
  * This function takes an object representing a row, a table schema, and an optional custom serializer map.
14
14
  * It returns a new object with values transformed to be compatible with SQLite column types.
15
15
  *
16
- * ## Generics
17
- * - `TOutput`: The shape of the input object, typically matching the row data.
18
- * - `TTable`: The table schema, which must match the keys of `TOutput`.
19
- *
20
16
  * ## Parameters
21
17
  * - `value`: The object to serialize (row data).
22
18
  * - `tableSchema`: The schema describing the SQLite table columns and types.
@@ -37,17 +33,12 @@ import type {
37
33
  * - Throws if a key in `value` does not exist in the schema.
38
34
  * - Throws if a value cannot be converted to the required SQLite type.
39
35
  */
40
- export function serializeForSQLite<
41
- TOutput extends Record<string, unknown>,
42
- // The keys should match
43
- TTable extends Table<MapBaseColumnType<TOutput>> = Table<
44
- MapBaseColumnType<TOutput>
45
- >,
46
- >(
47
- value: TOutput,
36
+ export function serializeForSQLite<TTable extends Table>(
37
+ value: AnyTableColumnType<TTable>,
48
38
  tableSchema: TTable,
49
- customSerializer: Partial<
50
- CustomSQLiteSerializer<TOutput, ExtractedTableColumns<TTable>>
39
+ customSerializer: CustomSQLiteSerializer<
40
+ AnyTableColumnType<TTable>,
41
+ ExtractedTableColumns<TTable>
51
42
  > = {},
52
43
  ): ExtractedTable<TTable> {
53
44
  return Object.fromEntries(
@@ -68,7 +59,7 @@ export function serializeForSQLite<
68
59
 
69
60
  const customTransform = customSerializer[key]
70
61
  if (customTransform) {
71
- return [key, customTransform(value as TOutput[string])]
62
+ return [key, customTransform(value)]
72
63
  }
73
64
 
74
65
  // Map to the output
@@ -98,5 +89,5 @@ export function serializeForSQLite<
98
89
  }
99
90
  }
100
91
  }),
101
- )
92
+ ) as ExtractedTable<TTable>
102
93
  }