From 7561bf90f7a2d7e2127dd2d3ccbc1ced671a8eab Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 21 Jul 2026 14:02:17 +0200 Subject: [PATCH 1/2] Upgrade PowerSync to version 2 --- .changeset/olive-guests-bathe.md | 5 + docs/collections/powersync-collection.md | 6 +- packages/powersync-db-collection/package.json | 6 +- .../src/PowerSyncTransactor.ts | 16 +-- .../powersync-db-collection/src/helpers.ts | 45 ++++----- .../powersync-db-collection/src/powersync.ts | 99 ++++++++++--------- .../src/serialization.ts | 21 ++-- .../tests/on-demand-sync.test.ts | 90 ++++++++--------- .../tests/powersync.test.ts | 48 +++------ pnpm-lock.yaml | 47 ++++----- 10 files changed, 174 insertions(+), 209 deletions(-) create mode 100644 .changeset/olive-guests-bathe.md diff --git a/.changeset/olive-guests-bathe.md b/.changeset/olive-guests-bathe.md new file mode 100644 index 0000000000..3b5c5554be --- /dev/null +++ b/.changeset/olive-guests-bathe.md @@ -0,0 +1,5 @@ +--- +'@tanstack/powersync-db-collection': minor +--- + +Upgrade PowerSync to version 2. diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index 3860e5495a..64950360ef 100644 --- a/docs/collections/powersync-collection.md +++ b/docs/collections/powersync-collection.md @@ -56,7 +56,7 @@ const db = new PowerSyncDatabase({ ```ts import { - AbstractPowerSyncDatabase, + CommonPowerSyncDatabase, PowerSyncBackendConnector, PowerSyncCredentials, } from "@powersync/web" @@ -67,11 +67,11 @@ class Connector implements PowerSyncBackendConnector { /** Upload local changes to the app backend. * - * Use {@link AbstractPowerSyncDatabase.getCrudBatch} to get a batch of changes to upload. + * Use {@link CommonPowerSyncDatabase.getCrudBatch} to get a batch of changes to upload. * * Any thrown errors will result in a retry after the configured wait period (default: 5 seconds). */ - uploadData: (database: AbstractPowerSyncDatabase) => Promise + uploadData: (database: CommonPowerSyncDatabase) => Promise } // Configure the client to connect to a PowerSync service and your backend diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 2e96684feb..27f3e193df 100644 --- a/packages/powersync-db-collection/package.json +++ b/packages/powersync-db-collection/package.json @@ -59,11 +59,11 @@ "p-defer": "^4.0.1" }, "peerDependencies": { - "@powersync/common": "^1.41.0" + "@powersync/common": "^2.0.0" }, "devDependencies": { - "@powersync/common": "1.49.0", - "@powersync/node": "0.18.1", + "@powersync/common": "^2.0.0", + "@powersync/node": "^0.20.0", "@types/debug": "^4.1.12", "@vitest/coverage-istanbul": "^3.2.4", "better-sqlite3": "^12.6.2" diff --git a/packages/powersync-db-collection/src/PowerSyncTransactor.ts b/packages/powersync-db-collection/src/PowerSyncTransactor.ts index b5d14a5287..383d12161d 100644 --- a/packages/powersync-db-collection/src/PowerSyncTransactor.ts +++ b/packages/powersync-db-collection/src/PowerSyncTransactor.ts @@ -1,9 +1,9 @@ -import { sanitizeSQL } from '@powersync/common' +import { sanitizeSQL, LogLevels } from '@powersync/common' import { LoadSubsetOperationAbortedError } from '@tanstack/db' import DebugModule from 'debug' import { PendingOperationStore } from './PendingOperationStore' import { asPowerSyncRecord, mapOperationToPowerSync } from './helpers' -import type { AbstractPowerSyncDatabase, LockContext } from '@powersync/common' +import type { CommonPowerSyncDatabase, LockContext } from '@powersync/common' import type { PendingMutation, Transaction } from '@tanstack/db' import type { PendingOperation } from './PendingOperationStore' import type { @@ -14,7 +14,7 @@ import type { const debug = DebugModule.debug(`ts/db:powersync`) export type TransactorOptions = { - database: AbstractPowerSyncDatabase + database: CommonPowerSyncDatabase } /** @@ -53,7 +53,7 @@ export type TransactorOptions = { * @returns A promise that resolves when the mutations have been persisted to PowerSync */ export class PowerSyncTransactor { - database: AbstractPowerSyncDatabase + database: CommonPowerSyncDatabase pendingOperationStore: PendingOperationStore constructor(options: TransactorOptions) { @@ -344,10 +344,10 @@ export class PowerSyncTransactor { // If it's not supported, we don't store metadata. if (typeof mutation.metadata != `undefined`) { // Log a warning if metadata is provided but not tracked. - this.database.logger.warn( - `Metadata provided for collection ${mutation.collection.id} but the PowerSync table does not track metadata. The PowerSync table should be configured with trackMetadata: true.`, - mutation.metadata, - ) + this.database.logger.log({ + level: LogLevels.warn, + message: `Metadata provided for collection ${mutation.collection.id} but the PowerSync table does not track metadata. The PowerSync table should be configured with trackMetadata: true.`, + }) } return null } else if (typeof mutation.metadata == `undefined`) { diff --git a/packages/powersync-db-collection/src/helpers.ts b/packages/powersync-db-collection/src/helpers.ts index af33d259a1..62534eddbd 100644 --- a/packages/powersync-db-collection/src/helpers.ts +++ b/packages/powersync-db-collection/src/helpers.ts @@ -1,9 +1,5 @@ import { DiffTriggerOperation } from '@powersync/common' -import type { - BaseColumnType, - ExtractColumnValueType, - Table, -} from '@powersync/common' +import type { ExtractColumnValueType, RowType, Table } from '@powersync/common' /** * All PowerSync table records include a UUID `id` column. @@ -28,11 +24,11 @@ type OptionalIfUndefined = { /** * Provides the base column types for a table. This excludes the `id` column. */ -export type ExtractedTableColumns = { - [K in keyof TTable[`columnMap`]]: ExtractColumnValueType< - TTable[`columnMap`][K] - > -} +export type ExtractedTableColumns> = Omit< + RowType, + 'id' +> + /** * Utility type that extracts the typed structure of a table based on its column definitions. * Maps each column to its corresponding TypeScript type using ExtractColumnValueType. @@ -48,25 +44,25 @@ export type ExtractedTableColumns = { * // Results in: { id: string, name: string | null, age: number | null } * ``` */ -export type ExtractedTable = - ExtractedTableColumns & { - id: string - } +export type ExtractedTable = RowType -export type OptionalExtractedTable = OptionalIfUndefined<{ - [K in keyof TTable[`columnMap`]]: WithUndefinedIfNull< - ExtractColumnValueType - > -}> & { - id: string -} +export type OptionalExtractedTable = + TTable extends Table + ? OptionalIfUndefined<{ + [K in keyof Columns]: WithUndefinedIfNull< + ExtractColumnValueType + > + }> & { + id: string + } + : never /** * Maps the schema of TTable to a type which * requires the keys be equal, but the values can have any value type. */ export type AnyTableColumnType = { - [K in keyof TTable[`columnMap`]]: any + [K in keyof ExtractedTableColumns]: any } & { id: string } export function asPowerSyncRecord(record: any): PowerSyncRecord { @@ -76,11 +72,6 @@ export function asPowerSyncRecord(record: any): PowerSyncRecord { return record as PowerSyncRecord } -// Helper type to ensure the keys of TOutput match the Table columns -export type MapBaseColumnType = { - [Key in keyof TOutput]: BaseColumnType -} - /** * Maps {@link DiffTriggerOperation} to TanstackDB operations */ diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 786bb817c8..07958bd067 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -1,4 +1,4 @@ -import { DiffTriggerOperation, sanitizeSQL } from '@powersync/common' +import { DiffTriggerOperation, LogLevels, sanitizeSQL } from '@powersync/common' import { or, withCollectionConfigFactory } from '@tanstack/db' import { compileSQLite } from './sqlite-compiler' import { PendingOperationStore } from './PendingOperationStore' @@ -17,8 +17,6 @@ import type { import type { AnyTableColumnType, ExtractedTable, - ExtractedTableColumns, - MapBaseColumnType, OptionalExtractedTable, } from './helpers' import type { @@ -26,7 +24,6 @@ import type { ConfigWithArbitraryCollectionTypes, ConfigWithSQLiteInputType, ConfigWithSQLiteTypes, - CustomSQLiteSerializer, EnhancedPowerSyncCollectionConfig, InferPowerSyncOutputType, PowerSyncCollectionConfig, @@ -275,12 +272,15 @@ function createPowerSyncCollectionConfig< return validation.value } else if (`issues` in validation) { const issueMessage = `Failed to validate incoming data for ${viewName}. Issues: ${validation.issues.map((issue) => `${issue.path} - ${issue.message}`)}` - database.logger.error(issueMessage) + database.logger.log({ level: LogLevels.error, message: issueMessage }) onDeserializationError!(validation) throw new Error(issueMessage) } else { const unknownErrorMessage = `Unknown deserialization error for ${viewName}` - database.logger.error(unknownErrorMessage) + database.logger.log({ + level: LogLevels.error, + message: unknownErrorMessage, + }) onDeserializationError!({ issues: [{ message: unknownErrorMessage }] }) throw new Error(unknownErrorMessage) } @@ -416,9 +416,10 @@ function createPowerSyncCollectionConfig< } appliedReceipts.push(commit()) } - database.logger.info( - `Sync is ready for ${viewName} into ${trackedTableName}`, - ) + database.logger.log({ + level: LogLevels.info, + message: `Sync is ready for ${viewName} into ${trackedTableName}`, + }) }, }, }) @@ -442,10 +443,11 @@ function createPowerSyncCollectionConfig< await flushDiffRecordsWithContext(context, ignoredReceipts) }) .catch((error) => { - database.logger.error( - `An error has been detected in the sync handler`, + database.logger.log({ + level: LogLevels.error, + message: `An error has been detected in the sync handler`, error, - ) + }) }) } @@ -503,18 +505,20 @@ function createPowerSyncCollectionConfig< // transaction that currently parks it. pendingOperationStore.resolvePendingFor(pendingOperations) } catch (error) { - database.logger.error( - `An error has been detected in the sync handler`, + database.logger.log({ + level: LogLevels.error, + message: `An error has been detected in the sync handler`, error, - ) + }) } } // The sync function needs to be synchronous. async function start(afterOnChangeRegistered?: () => Promise) { - database.logger.info( - `Sync is starting for ${viewName} into ${trackedTableName}`, - ) + database.logger.log({ + level: LogLevels.info, + message: `Sync is starting for ${viewName} into ${trackedTableName}`, + }) database.onChangeWithCallback( { onChange: async () => { @@ -584,19 +588,21 @@ function createPowerSyncCollectionConfig< await Promise.all(appliedReceipts) markReady() }).catch((error) => { - database.logger.error( - `Could not start syncing process for ${viewName} into ${trackedTableName}`, + database.logger.log({ + level: LogLevels.error, + message: `Could not start syncing process for ${viewName} into ${trackedTableName}`, error, - ) + }) if (collection.status === `loading`) { markError(error) } }) return () => { - database.logger.info( - `Sync has been stopped for ${viewName} into ${trackedTableName}`, - ) + database.logger.log({ + level: LogLevels.info, + message: `Sync has been stopped for ${viewName} into ${trackedTableName}`, + }) abortController.abort() onUnload?.() } @@ -626,10 +632,11 @@ function createPowerSyncCollectionConfig< let releaseRetryTimer: ReturnType | undefined const startup = start() void startup.catch((error) => - database.logger.error( - `Could not start syncing process for ${viewName} into ${trackedTableName}`, + database.logger.log({ + level: LogLevels.error, + message: `Could not start syncing process for ${viewName} into ${trackedTableName}`, error, - ), + }), ) const activeWhereExpressions = () => @@ -799,10 +806,11 @@ function createPowerSyncCollectionConfig< try { demand.cleanup?.() } catch (error) { - database.logger.error( - `Could not clean up subset hook for ${viewName}`, + database.logger.log({ + level: LogLevels.error, + message: `Could not clean up subset hook for ${viewName}`, error, - ) + }) } } @@ -878,10 +886,11 @@ function createPowerSyncCollectionConfig< ) retryDelay = retryDelay === 0 ? delay : Math.min(retryDelay, delay) - database.logger.error( - `Could not release subset tracking for ${viewName}; retrying`, + database.logger.log({ + level: LogLevels.error, + message: `Could not release subset tracking for ${viewName}; retrying`, error, - ) + }) } } } finally { @@ -915,9 +924,10 @@ function createPowerSyncCollectionConfig< stopped = true clearTimeout(releaseRetryTimer) releaseRetryTimer = undefined - database.logger.info( - `Sync has been stopped for ${viewName} into ${trackedTableName}`, - ) + database.logger.log({ + level: LogLevels.info, + message: `Sync has been stopped for ${viewName} into ${trackedTableName}`, + }) abortController.abort() for (const demand of demands.values()) { cleanupDemand(demand) @@ -940,7 +950,9 @@ function createPowerSyncCollectionConfig< OutputType, TSchema > = { - ...restConfig, + ...(restConfig as Partial< + EnhancedPowerSyncCollectionConfig + >), schema, getKey, // Syncing should start immediately since we need to monitor the changes for mutations @@ -965,18 +977,7 @@ function createPowerSyncCollectionConfig< trackedTableName, metadataIsTracked, serializeValue: (value) => - serializeForSQLite( - value, - // This is required by the input generic - table as Table< - MapBaseColumnType> - >, - // Coerce serializer to the shape that corresponds to the Table constructed from OutputType - serializer as CustomSQLiteSerializer< - OutputType, - ExtractedTableColumns>> - >, - ), + serializeForSQLite(value, table, serializer), }), }, } diff --git a/packages/powersync-db-collection/src/serialization.ts b/packages/powersync-db-collection/src/serialization.ts index f25815f80e..bee6431b26 100644 --- a/packages/powersync-db-collection/src/serialization.ts +++ b/packages/powersync-db-collection/src/serialization.ts @@ -2,9 +2,9 @@ import { ColumnType } from '@powersync/common' import type { Table } from '@powersync/common' import type { CustomSQLiteSerializer } from './definitions' import type { + AnyTableColumnType, ExtractedTable, ExtractedTableColumns, - MapBaseColumnType, } from './helpers' /** @@ -37,17 +37,14 @@ import type { * - Throws if a key in `value` does not exist in the schema. * - Throws if a value cannot be converted to the required SQLite type. */ -export function serializeForSQLite< - TOutput extends Record, - // The keys should match - TTable extends Table> = Table< - MapBaseColumnType - >, ->( - value: TOutput, +export function serializeForSQLite( + value: AnyTableColumnType, tableSchema: TTable, customSerializer: Partial< - CustomSQLiteSerializer> + CustomSQLiteSerializer< + AnyTableColumnType, + ExtractedTableColumns + > > = {}, ): ExtractedTable { return Object.fromEntries( @@ -68,7 +65,7 @@ export function serializeForSQLite< const customTransform = customSerializer[key] if (customTransform) { - return [key, customTransform(value as TOutput[string])] + return [key, customTransform(value)] } // Map to the output @@ -98,5 +95,5 @@ export function serializeForSQLite< } } }), - ) + ) as ExtractedTable } diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 855a9449fd..4812fedc93 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -1,6 +1,12 @@ import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' -import { PowerSyncDatabase, Schema, Table, column } from '@powersync/node' +import { + LogLevels, + PowerSyncDatabase, + Schema, + Table, + column, +} from '@powersync/node' import { IR, and, @@ -18,6 +24,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { powerSyncCollectionOptions } from '../src' import { withTestCleanup } from './with-test-cleanup' import type { LoadSubsetOptions } from '@tanstack/db' +import type { PowerSyncLogger } from '@powersync/node' const APP_SCHEMA = new Schema({ products: new Table({ @@ -28,7 +35,7 @@ const APP_SCHEMA = new Schema({ }) describe(`On-Demand Sync Mode`, () => { - async function createDatabase() { + async function createDatabase(logger?: PowerSyncLogger) { const db = new PowerSyncDatabase({ database: { dbFilename: `test-on-demand-${randomUUID()}.sqlite`, @@ -36,6 +43,7 @@ describe(`On-Demand Sync Mode`, () => { implementation: { type: `node:sqlite` }, }, schema: APP_SCHEMA, + logger, }) onTestFinished(async () => { // Wait a moment for any pending cleanup operations to complete @@ -48,6 +56,22 @@ describe(`On-Demand Sync Mode`, () => { return db } + // The sync handler catches its own errors and surfaces them only through the + // logger, so captured errors are how these tests assert it stayed healthy. + function errorCapturingLogger(): [Array, PowerSyncLogger] { + const errors: Array = [] + return [ + errors, + { + log({ level, message }) { + if (level >= LogLevels.error) { + errors.push(message) + } + }, + }, + ] + } + async function createTestProducts(db: PowerSyncDatabase) { await db.execute(` INSERT INTO products (id, name, price, category) @@ -114,18 +138,13 @@ describe(`On-Demand Sync Mode`, () => { expect(actual).toEqual(wanted) } - const db = await createDatabase() - const reports: Array> = [] + const [loggedErrors, logger] = errorCapturingLogger() + const db = await createDatabase(logger) const rejections: Array = [] const recordRejection = (error: unknown) => { rejections.push(error) } process.on(`unhandledRejection`, recordRejection) - const errors = vi - .spyOn(db.logger, `error`) - .mockImplementation((...args) => { - reports.push(args) - }) const triggerRecords: Array<{ calls: number; settled: boolean }> = [] const realCreateTrigger = db.triggers.createDiffTrigger.bind(db.triggers) const triggers = vi @@ -208,7 +227,7 @@ describe(`On-Demand Sync Mode`, () => { await vi.waitFor(() => check(capture(), wanted)) const actual = capture() observations.push({ cut: name, actual, wanted }) - expect(reports).toEqual([]) + expect(loggedErrors).toEqual([]) expect(rejections).toEqual([]) return { actual, wanted } } @@ -349,10 +368,9 @@ describe(`On-Demand Sync Mode`, () => { () => collection.cleanup(), expectTrackingGone, () => new Promise((resolve) => setTimeout(resolve, 0)), - () => expect(reports).toEqual([]), + () => expect(loggedErrors).toEqual([]), () => expect(rejections).toEqual([]), () => triggers.mockRestore(), - () => errors.mockRestore(), () => process.off(`unhandledRejection`, recordRejection), ]) }) @@ -445,7 +463,8 @@ describe(`On-Demand Sync Mode`, () => { it.each([`apply`, `cleanup`] as const)( `settles staged subset baseline rows after %s`, async (outcome) => { - const db = await createDatabase() + const [errors, logger] = errorCapturingLogger() + const db = await createDatabase(logger) await createTestProducts(db) let resolvePersistence!: () => void @@ -515,7 +534,6 @@ describe(`On-Demand Sync Mode`, () => { }, ) const reports = vi.spyOn(console, `error`).mockImplementation(() => {}) - const sdkErrors = vi.spyOn(db.logger, `error`) const unexpected: Array = [] const recordUnhandled = (error: unknown) => unexpected.push(error) process.on(`unhandledRejection`, recordUnhandled) @@ -613,11 +631,10 @@ describe(`On-Demand Sync Mode`, () => { () => expectTrackingCount(0), () => expect(cleanupHook).toHaveBeenCalledOnce(), () => expect(triggerDisposals[0]).toHaveBeenCalledOnce(), - () => expect(sdkErrors).not.toHaveBeenCalled(), + () => expect(errors).toHaveLength(0), () => expect(unexpected).toEqual([]), () => process.off(`unhandledRejection`, recordUnhandled), () => reports.mockRestore(), - () => sdkErrors.mockRestore(), () => triggerSpy.mockRestore(), ]) }, @@ -2622,16 +2639,6 @@ describe(`On-Demand Sync Mode`, () => { new IR.Value(category), ]) - // The sync handler catches its own errors and surfaces them only through the - // logger, so captured errors are how these tests assert it stayed healthy. - function captureSyncErrors(db: PowerSyncDatabase) { - const errors: Array = [] - vi.spyOn(db.logger, `error`).mockImplementation((...args: Array) => { - errors.push(args.map(String).join(` `)) - }) - return () => errors - } - function makeCollection(db: PowerSyncDatabase) { return createCollection( powerSyncCollectionOptions({ @@ -2704,7 +2711,7 @@ describe(`On-Demand Sync Mode`, () => { } it(`does not publish a provisional or rejected subset`, async () => { - const db = await createDatabase() + const db = await createDatabase(errorCapturingLogger()[1]) const firstHook = pDefer() const hookFailure = new Error(`subset hook failed`) const onLoadSubset = vi @@ -3024,7 +3031,6 @@ describe(`On-Demand Sync Mode`, () => { it(`does not repeat release work started by a reentrant cleanup`, async () => { const db = await createDatabase() - vi.spyOn(db.triggers, `createDiffTrigger`).mockResolvedValue(vi.fn()) const getAll = vi.spyOn(db, `getAll`).mockResolvedValue([]) const first = { where: categoryEquals(`electronics`) } const second = { where: categoryEquals(`clothing`) } @@ -3056,9 +3062,8 @@ describe(`On-Demand Sync Mode`, () => { }) it(`does not create tracking when change observation cannot start`, async () => { - const db = await createDatabase() + const db = await createDatabase(errorCapturingLogger()[1]) const startupError = new Error(`change observation failed`) - vi.spyOn(db.logger, `error`).mockImplementation(() => {}) vi.spyOn(db, `onChangeWithCallback`).mockImplementation(() => { throw startupError }) @@ -3178,11 +3183,10 @@ describe(`On-Demand Sync Mode`, () => { `reconciles rows after a release rebuild outage with %s`, async (change) => { vi.useFakeTimers() - const db = await createDatabase() + const db = await createDatabase(errorCapturingLogger()[1]) await db.execute( `INSERT INTO products (id, name, price, category) VALUES ('retained', 'Before', 10, 'clothing')`, ) - vi.spyOn(db.logger, `error`).mockImplementation(() => {}) const collection = createCollection( powerSyncCollectionOptions({ database: db, @@ -3243,8 +3247,7 @@ describe(`On-Demand Sync Mode`, () => { it(`retries a failed physical release`, async () => { vi.useFakeTimers() - const db = await createDatabase() - vi.spyOn(db.logger, `error`).mockImplementation(() => {}) + const db = await createDatabase(errorCapturingLogger()[1]) vi.spyOn(db.triggers, `createDiffTrigger`).mockResolvedValue(vi.fn()) const getAll = vi .spyOn(db, `getAll`) @@ -3268,8 +3271,7 @@ describe(`On-Demand Sync Mode`, () => { it(`does not let one failed release block another`, async () => { vi.useFakeTimers() - const db = await createDatabase() - vi.spyOn(db.logger, `error`).mockImplementation(() => {}) + const db = await createDatabase(errorCapturingLogger()[1]) vi.spyOn(db.triggers, `createDiffTrigger`).mockResolvedValue(vi.fn()) const getAll = vi .spyOn(db, `getAll`) @@ -3304,8 +3306,7 @@ describe(`On-Demand Sync Mode`, () => { it(`evicts a newly released demand without waiting for another demand's retry timer`, async () => { vi.useFakeTimers() - const db = await createDatabase() - vi.spyOn(db.logger, `error`).mockImplementation(() => {}) + const db = await createDatabase(errorCapturingLogger()[1]) vi.spyOn(db.triggers, `createDiffTrigger`).mockResolvedValue(vi.fn()) const getAll = vi .spyOn(db, `getAll`) @@ -3339,7 +3340,6 @@ describe(`On-Demand Sync Mode`, () => { it(`rechecks active demand before evicting released rows`, async () => { const db = await createDatabase() - vi.spyOn(db.triggers, `createDiffTrigger`).mockResolvedValue(vi.fn()) const firstEviction = pDefer>() const getAll = vi .spyOn(db, `getAll`) @@ -3373,9 +3373,9 @@ describe(`On-Demand Sync Mode`, () => { }) it(`should start tracking again when a subset is loaded after every subset was unloaded`, async () => { - const db = await createDatabase() + const [syncErrors, logger] = errorCapturingLogger() + const db = await createDatabase(logger) await createTestProducts(db) - const syncErrors = captureSyncErrors(db) const collection = makeCollection(db) onTestFinished(() => collection.cleanup()) @@ -3412,13 +3412,13 @@ describe(`On-Demand Sync Mode`, () => { { timeout: 2000 }, ) - expect(syncErrors()).toEqual([]) + expect(syncErrors).toEqual([]) }) it(`should stop tracking cleanly when every subset is unloaded and the collection is cleaned up`, async () => { - const db = await createDatabase() + const [syncErrors, logger] = errorCapturingLogger() + const db = await createDatabase(logger) await createTestProducts(db) - const syncErrors = captureSyncErrors(db) const collection = makeCollection(db) await collection.stateWhenReady() @@ -3451,7 +3451,7 @@ describe(`On-Demand Sync Mode`, () => { collection.cleanup() await new Promise((resolve) => setTimeout(resolve, 200)) - expect(syncErrors()).toEqual([]) + expect(syncErrors).toEqual([]) }) it(`should dispose each diff trigger exactly once`, async () => { diff --git a/packages/powersync-db-collection/tests/powersync.test.ts b/packages/powersync-db-collection/tests/powersync.test.ts index ba433f2488..571da3dc24 100644 --- a/packages/powersync-db-collection/tests/powersync.test.ts +++ b/packages/powersync-db-collection/tests/powersync.test.ts @@ -1,12 +1,6 @@ import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' -import { - CrudEntry, - PowerSyncDatabase, - Schema, - Table, - column, -} from '@powersync/node' +import { PowerSyncDatabase, Schema, Table, column } from '@powersync/node' import { createCollection, createTransaction, @@ -17,7 +11,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { powerSyncCollectionOptions } from '../src' import { PowerSyncTransactor } from '../src/PowerSyncTransactor' import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' -import type { AbstractPowerSyncDatabase, LockContext } from '@powersync/node' +import type { LockContext } from '@powersync/node' import type { PendingMutation } from '@tanstack/db' const APP_SCHEMA = new Schema({ @@ -100,7 +94,7 @@ describePowerSync(`PowerSync Integration`, () => { return collection } - async function createTestData(db: AbstractPowerSyncDatabase) { + async function createTestData(db: PowerSyncDatabase) { await db.execute(` INSERT into documents (id, name) VALUES @@ -234,12 +228,8 @@ describePowerSync(`PowerSync Integration`, () => { await collection.delete(id).isPersisted.promise // There should be a crud entries for this - const _crudEntries = await db.getAll(` - SELECT * FROM ps_crud ORDER BY id`) - - const crudEntries = _crudEntries.map((r) => - CrudEntry.fromRow(r as Parameters[0]), - ) + const crudEntries = await db.getAll<{ op: string }>(` + SELECT data ->> 'op' AS op FROM ps_crud ORDER BY id`) expect(crudEntries.length).toBe(6) // We can only group transactions for similar operations @@ -298,10 +288,10 @@ describePowerSync(`PowerSync Integration`, () => { // fetch the ps_crud items // There should be a crud entries for this - const _crudEntries = await db.getAll(` + const crudEntries = await db.getAll(` SELECT * FROM ps_crud ORDER BY id`) - expect(_crudEntries.slice(0, initialCrud.length)).toEqual(initialCrud) - const newCrud = _crudEntries.slice(initialCrud.length) + expect(crudEntries.slice(0, initialCrud.length)).toEqual(initialCrud) + const newCrud = crudEntries.slice(initialCrud.length) expectTransactionCrud(newCrud, expected) expect(() => expectTransactionCrud(newCrud.slice(1), expected), @@ -343,12 +333,8 @@ describePowerSync(`PowerSync Integration`, () => { })), ), ).toEqual(expectedRows) - const crudEntries = _crudEntries.map((r) => - CrudEntry.fromRow(r as Parameters[0]), - ) - const lastTransactionId = - crudEntries[crudEntries.length - 1]?.transactionId + const lastTransactionId = crudEntries[crudEntries.length - 1]?.tx_id /** * The last items, created in the same transaction, should be in the same * PowerSync transaction. @@ -357,7 +343,7 @@ describePowerSync(`PowerSync Integration`, () => { crudEntries .reverse() .slice(0, 5) - .every((crudEntry) => crudEntry.transactionId == lastTransactionId), + .every((crudEntry) => crudEntry.tx_id == lastTransactionId), ).true } finally { await collection.cleanup() @@ -494,10 +480,10 @@ describePowerSync(`PowerSync Integration`, () => { // fetch the ps_crud items // There should be a crud entries for this - const _crudEntries = await db.getAll(` + const crudEntries = await db.getAll(` SELECT * FROM ps_crud ORDER BY id`) - expect(_crudEntries.slice(0, initialCrud.length)).toEqual(initialCrud) - const newCrud = _crudEntries.slice(initialCrud.length) + expect(crudEntries.slice(0, initialCrud.length)).toEqual(initialCrud) + const newCrud = crudEntries.slice(initialCrud.length) expectTransactionCrud(newCrud, expected) const corrupted = structuredClone(newCrud) corrupted[0]!.data = JSON.stringify({ @@ -563,12 +549,8 @@ describePowerSync(`PowerSync Integration`, () => { })), ), ).toEqual(expectedUsers) - const crudEntries = _crudEntries.map((r) => - CrudEntry.fromRow(r as Parameters[0]), - ) - const lastTransactionId = - crudEntries[crudEntries.length - 1]?.transactionId + const lastTransactionId = crudEntries[crudEntries.length - 1]?.tx_id /** * The last items, created in the same transaction, should be in the same * PowerSync transaction. @@ -577,7 +559,7 @@ describePowerSync(`PowerSync Integration`, () => { crudEntries .reverse() .slice(0, 10) - .every((crudEntry) => crudEntry.transactionId == lastTransactionId), + .every((crudEntry) => crudEntry.tx_id == lastTransactionId), ).true } finally { await documentsCollection.cleanup() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d3bbcd10d..020eb8600f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1418,11 +1418,11 @@ importers: version: 4.0.1 devDependencies: '@powersync/common': - specifier: 1.49.0 - version: 1.49.0 + specifier: ^2.0.0 + version: 2.0.0 '@powersync/node': - specifier: 0.18.1 - version: 0.18.1(@powersync/common@1.49.0)(better-sqlite3@12.8.0) + specifier: ^0.20.0 + version: 0.20.0(@powersync/common@2.0.0)(better-sqlite3@12.8.0) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -5156,18 +5156,21 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@powersync/common@1.49.0': - resolution: {integrity: sha512-g6uonubvtmtyx8hS/G5trg9LsBvzHY3tAKHiV7SIQV3Xyz9ONM6NNnjDMP2vcLZVmsOSi8x/QJZmy/ig1YtBMg==} + '@powersync/common@2.0.0': + resolution: {integrity: sha512-GuWmk0w0Y6I1HDwX4uGFzzw1e4E4bkvDSSCpVikdTEIk1xcW5NNnqTX8xfDheZdoQZunm/XushrR/LsIp1Yc/g==} - '@powersync/node@0.18.1': - resolution: {integrity: sha512-fcTICgs61CAEb39xiC7pedYsPgbjUInJ/47dr7RIdnEHpAgjWH8bW95/b70qK1fQUANy9lKBBF3PcmfswVgfCw==} + '@powersync/node@0.20.0': + resolution: {integrity: sha512-jAfCMPnXiS9EeoebkDkAoE8iM8t8pvza7RTVgKgSQ1ks32rzIE2lTIcflh9tzuiA8F6uDvAHg94gJEet1zbORg==} peerDependencies: - '@powersync/common': ^1.49.0 + '@powersync/common': ^2.0.0 better-sqlite3: 12.x peerDependenciesMeta: better-sqlite3: optional: true + '@powersync/shared-internals@1.0.1': + resolution: {integrity: sha512-0IIuZbDwLEIaSAzvp8z8+CrjKnjja6BPv9WMtF1eExQVTBVOx1TXK4E3iLrEdLceByRcSFlQ0TmPNkPRe5BDow==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -7127,9 +7130,6 @@ packages: async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -8501,9 +8501,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - event-iterator@2.0.0: - resolution: {integrity: sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ==} - event-reduce-js@5.2.7: resolution: {integrity: sha512-Vi6aIiAmakzx81JAwhw8L988aSX5a3ZqqVjHyZa9xFU6P4oT1IotoDreWtjNlS+fvEnASvyIQT565nmkOtns/Q==} engines: {node: '>=16'} @@ -17019,21 +17016,19 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@powersync/common@1.49.0': - dependencies: - async-mutex: 0.5.0 - event-iterator: 2.0.0 + '@powersync/common@2.0.0': {} - '@powersync/node@0.18.1(@powersync/common@1.49.0)(better-sqlite3@12.8.0)': + '@powersync/node@0.20.0(@powersync/common@2.0.0)(better-sqlite3@12.8.0)': dependencies: - '@powersync/common': 1.49.0 - async-mutex: 0.5.0 - bson: 6.10.4 + '@powersync/common': 2.0.0 + '@powersync/shared-internals': 1.0.1 comlink: 4.4.2 undici: 7.24.4 optionalDependencies: better-sqlite3: 12.8.0 + '@powersync/shared-internals@1.0.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -19500,10 +19495,6 @@ snapshots: async-limiter@1.0.1: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -21121,8 +21112,6 @@ snapshots: etag@1.8.1: {} - event-iterator@2.0.0: {} - event-reduce-js@5.2.7: dependencies: array-push-at-sort-position: 4.0.1 From f5f84405c7d37eb5799497114a844bfbc9f5e8f5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 15 Sep 2026 10:41:33 +0200 Subject: [PATCH 2/2] Fix schema output type --- .../src/definitions.ts | 8 ++- .../powersync-db-collection/src/powersync.ts | 11 ++-- .../tests/collection-schema.test.ts | 59 +++++++++++++++++++ .../tests/powersync.test-d.ts | 48 ++++++++++++++- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/powersync-db-collection/src/definitions.ts b/packages/powersync-db-collection/src/definitions.ts index 30f7fd23b6..09791d91a9 100644 --- a/packages/powersync-db-collection/src/definitions.ts +++ b/packages/powersync-db-collection/src/definitions.ts @@ -202,9 +202,13 @@ export type OnDemandSyncHooks = { export type BasePowerSyncCollectionConfig< TTable extends Table = Table, - TSchema extends StandardSchemaV1 = never, + TSchema extends StandardSchemaV1 = never, > = Omit< - BaseCollectionConfig, string, TSchema>, + BaseCollectionConfig< + InferPowerSyncOutputType, + string, + TSchema + >, `onInsert` | `onUpdate` | `onDelete` | `getKey` | `syncMode` > & { /** The PowerSync schema Table definition */ diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 07958bd067..8a7475e510 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -225,11 +225,12 @@ export function powerSyncCollectionOptions< TTable extends Table, TSchema extends StandardSchemaV1 = never, >( - config: PowerSyncCollectionConfig, + config: unknown, ): ReturnType> { - const outputConfig = createPowerSyncCollectionConfig(config) + const typedConfig = config as PowerSyncCollectionConfig + const outputConfig = createPowerSyncCollectionConfig(typedConfig) return withCollectionConfigFactory(outputConfig, () => - createPowerSyncCollectionConfig(config), + createPowerSyncCollectionConfig(typedConfig), ) } @@ -950,9 +951,7 @@ function createPowerSyncCollectionConfig< OutputType, TSchema > = { - ...(restConfig as Partial< - EnhancedPowerSyncCollectionConfig - >), + ...restConfig, schema, getKey, // Syncing should start immediately since we need to monitor the changes for mutations diff --git a/packages/powersync-db-collection/tests/collection-schema.test.ts b/packages/powersync-db-collection/tests/collection-schema.test.ts index 10a502cf84..518830962c 100644 --- a/packages/powersync-db-collection/tests/collection-schema.test.ts +++ b/packages/powersync-db-collection/tests/collection-schema.test.ts @@ -243,6 +243,65 @@ describePowerSyncSchema(`PowerSync Schema Integration`, () => { } }) + it(`invokes compare with the schema's transformed output type`, async () => { + const db = await createDatabase() + + const schema = z.object({ + id: z.string(), + name: z.string().nullable(), + archived: z.number().nullable(), + author: z.string().nullable(), + created_at: z + .string() + .nullable() + .transform((val) => (val ? new Date(val) : null)), + }) + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: () => {}, + // `created_at` is a `Date` in the output type, not the SQLite string. + // Calling `.getTime()` here would throw `TypeError` if `compare` were + // (incorrectly) invoked with the raw SQLite row. + compare: (left, right) => + (left.created_at?.getTime() ?? 0) - + (right.created_at?.getTime() ?? 0), + }), + ) + try { + const older = randomUUID() + const newer = randomUUID() + const newerResult = collection.insert({ + id: newer, + name: `newer`, + author: `author`, + created_at: `2024-06-01T00:00:00.000Z`, + archived: 0, + }) + const olderResult = collection.insert({ + id: older, + name: `older`, + author: `author`, + created_at: `2024-01-01T00:00:00.000Z`, + archived: 0, + }) + + // Wait for both mutations to be synced back through the diff trigger, + // since `compare` only orders the synced (non-optimistic) state. + await Promise.all([ + newerResult.isPersisted.promise, + olderResult.isPersisted.promise, + ]) + + expect([...collection.keys()]).toEqual([older, newer]) + } finally { + await collection.cleanup() + } + }) + /** * In this example the TInput and TOutput types are different. * In this example we use custom types for TInput. This requires an additional schema for validating diff --git a/packages/powersync-db-collection/tests/powersync.test-d.ts b/packages/powersync-db-collection/tests/powersync.test-d.ts index 9694ec46f8..a12e668c19 100644 --- a/packages/powersync-db-collection/tests/powersync.test-d.ts +++ b/packages/powersync-db-collection/tests/powersync.test-d.ts @@ -1,14 +1,16 @@ import { describe, expectTypeOf, it } from 'vitest' import { Schema, Table, column } from '@powersync/node' import { createCollection } from '@tanstack/db' +import { z } from 'zod' import { powerSyncCollectionOptions } from '../src' import type { PowerSyncCollectionUtils } from '../src' -import type { AbstractPowerSyncDatabase } from '@powersync/node' +import type { PowerSyncDatabase } from '@powersync/node' const APP_SCHEMA = new Schema({ documents: new Table({ name: column.text, author: column.text, + created_at: column.text, }), }) @@ -16,7 +18,7 @@ describe(`PowerSync collection type tests`, () => { it(`should type collection.utils as PowerSyncCollectionUtils after createCollection`, () => { const collection = createCollection( powerSyncCollectionOptions({ - database: {} as AbstractPowerSyncDatabase, + database: {} as PowerSyncDatabase, table: APP_SCHEMA.props.documents, }), ) @@ -28,4 +30,46 @@ describe(`PowerSync collection type tests`, () => { expectTypeOf(utils.getMeta).toBeFunction() expectTypeOf(collection.utils.getMeta).toBeFunction() }) + + it(`types compare against the schema's transformed output, not the raw SQLite row`, () => { + // `created_at` is a SQLite TEXT column, transformed to a `Date` by the schema below. + const schema = z.object({ + id: z.string(), + name: z.string().nullable(), + author: z.string().nullable(), + created_at: z + .string() + .nullable() + .transform((val) => (val ? new Date(val) : null)), + }) + + powerSyncCollectionOptions({ + database: {} as PowerSyncDatabase, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: () => {}, + compare: (left, right) => { + // `compare` must receive the schema's output type, not `ExtractedTable`. + expectTypeOf(left).toEqualTypeOf>() + // This only type-checks because `created_at` is a `Date` here. + return ( + (left.created_at?.getTime() ?? 0) - (right.created_at?.getTime() ?? 0) + ) + }, + }) + + powerSyncCollectionOptions({ + database: {} as PowerSyncDatabase, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: () => {}, + compare: (left, right) => { + // `created_at` is a `Date` in the output type here, so a + // `string`-only method like `localeCompare` must not type-check - + // even though it would have against the raw (pre-transform) SQLite row. + // @ts-expect-error + return left.created_at!.localeCompare(right.created_at!) + }, + }) + }) })