From 2f80f5af74c10f05264f1f624ed93138f46c5e7b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 11:11:32 -0600 Subject: [PATCH 1/6] Fix PowerSync adapter correctness and v2 compatibility Co-authored-by: Ali Ansari <322224371+AliNaqiAnsari@users.noreply.github.com> Co-authored-by: lukasz wolski <5732713+lukiwolski@users.noreply.github.com> Co-authored-by: Simon Binder Co-authored-by: Kirill Kleymenov --- docs/collections/powersync-collection.md | 12 +- packages/powersync-db-collection/package.json | 6 +- .../src/PowerSyncTransactor.ts | 53 +- .../src/definitions.ts | 16 +- .../powersync-db-collection/src/helpers.ts | 28 +- packages/powersync-db-collection/src/index.ts | 1 + .../powersync-db-collection/src/powersync.ts | 96 +-- .../src/serialization.ts | 21 +- .../tests/correctness-oracle.test.ts | 576 ++++++++++++++++++ .../tests/on-demand-sync.test.ts | 92 +-- .../tests/portable-declarations.test.ts | 113 ++++ .../tests/powersync.test-d.ts | 56 +- .../tests/powersync.test.ts | 48 +- .../tests/transactor-readiness.test.ts | 4 +- pnpm-lock.yaml | 47 +- 15 files changed, 948 insertions(+), 221 deletions(-) create mode 100644 packages/powersync-db-collection/tests/correctness-oracle.test.ts create mode 100644 packages/powersync-db-collection/tests/portable-declarations.test.ts diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index 3860e5495a..92342550d6 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 @@ -474,12 +474,12 @@ await documents.delete(docId, { The metadata is available in PowerSync `CrudEntry` records when processing uploads in the connector: ```typescript -import { CrudEntry } from "@powersync/web" +import type { CommonPowerSyncDatabase } from "@powersync/web" class Connector implements PowerSyncBackendConnector { // ... - async uploadData(database: AbstractPowerSyncDatabase) { + async uploadData(database: CommonPowerSyncDatabase) { const batch = await database.getCrudBatch() if (!batch) return @@ -1097,4 +1097,4 @@ const liveQuery = createLiveQueryCollection({ completed: todo.completed, })), }) -``` \ No newline at end of file +``` diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 2e96684feb..5399526c78 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..6f247b1665 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 { LogLevels, sanitizeSQL } 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) { @@ -82,11 +82,15 @@ export class PowerSyncTransactor { const allCollections = collectionIds .map((id) => mutations.find((mutation) => mutation.collection.id == id)!) .map((mutation) => mutation.collection) - for (const collectionId of collectionIds) { - lastCollectionMutationIndexes.set( - collectionId, - mutationsCollectionIds.lastIndexOf(collectionId), - ) + for (const [index, mutation] of mutations.entries()) { + const changesDatabase = + mutation.type != `update` || + Object.keys(mutation.changes).some((key) => key != `id`) || + (typeof mutation.metadata != `undefined` && + this.getMutationCollectionMeta(mutation).metadataIsTracked) + if (changesDatabase) { + lastCollectionMutationIndexes.set(mutation.collection.id, index) + } } // Check all the observers are ready before taking a lock @@ -221,7 +225,8 @@ export class PowerSyncTransactor { waitForCompletion, // eslint-disable-next-line no-shadow async (tableName, mutation, serializeValue) => { - const values = serializeValue(mutation.modified) + const values = serializeValue(mutation.changes) + delete values.id const keys = Object.keys(values).map((key) => sanitizeSQL`${key}`) const queryParameters = Object.values(values) @@ -231,14 +236,20 @@ export class PowerSyncTransactor { queryParameters.push(metadataValue) } + if (keys.length == 0) { + return false + } + await context.execute( ` UPDATE ${tableName} SET ${keys.map((key) => `${key} = ?`).join(`, `)} WHERE id = ? `, - [...queryParameters, asPowerSyncRecord(mutation.modified).id], + [...queryParameters, asPowerSyncRecord(mutation.original).id], ) + + return true }, ) } @@ -294,12 +305,20 @@ export class PowerSyncTransactor { tableName: string, mutation: PendingMutation, serializeValue: (value: any) => Record, - ) => Promise, + ) => Promise, ): Promise { const { tableName, trackedTableName, serializeValue } = this.getMutationCollectionMeta(mutation) - await handler(sanitizeSQL`${tableName}`, mutation, serializeValue) + const executed = await handler( + sanitizeSQL`${tableName}`, + mutation, + serializeValue, + ) + + if (executed === false) { + return null + } if (!waitForCompletion) { return null @@ -344,10 +363,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/definitions.ts b/packages/powersync-db-collection/src/definitions.ts index 30f7fd23b6..b57253a485 100644 --- a/packages/powersync-db-collection/src/definitions.ts +++ b/packages/powersync-db-collection/src/definitions.ts @@ -1,4 +1,4 @@ -import type { AbstractPowerSyncDatabase, Table } from '@powersync/common' +import type { CommonPowerSyncDatabase, Table } from '@powersync/common' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BaseCollectionConfig, @@ -22,7 +22,9 @@ import type { export type InferPowerSyncOutputType< TTable extends Table = Table, TSchema extends StandardSchemaV1 = never, -> = TSchema extends never ? ExtractedTable : InferSchemaOutput +> = [TSchema] extends [never] + ? ExtractedTable + : InferSchemaOutput /** * A mapping type for custom serialization of object properties to SQLite-compatible values. @@ -202,15 +204,19 @@ 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 */ table: TTable /** The PowerSync database instance */ - database: AbstractPowerSyncDatabase + database: CommonPowerSyncDatabase /** * The maximum number of documents to read from the SQLite table * in a single batch during the initial sync between PowerSync and the diff --git a/packages/powersync-db-collection/src/helpers.ts b/packages/powersync-db-collection/src/helpers.ts index af33d259a1..7bbb8e8b67 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, Table } from '@powersync/common' /** * All PowerSync table records include a UUID `id` column. @@ -28,11 +24,12 @@ 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 = + TTable extends Table + ? { + [K in keyof Columns]: ExtractColumnValueType + } + : never /** * 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. @@ -54,8 +51,8 @@ export type ExtractedTable = } export type OptionalExtractedTable = OptionalIfUndefined<{ - [K in keyof TTable[`columnMap`]]: WithUndefinedIfNull< - ExtractColumnValueType + [K in keyof ExtractedTableColumns]: WithUndefinedIfNull< + ExtractedTableColumns[K] > }> & { id: string @@ -66,7 +63,7 @@ export type OptionalExtractedTable = OptionalIfUndefined<{ * 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]: unknown } & { id: string } export function asPowerSyncRecord(record: any): PowerSyncRecord { @@ -76,11 +73,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/index.ts b/packages/powersync-db-collection/src/index.ts index f8d0928056..dbf822281a 100644 --- a/packages/powersync-db-collection/src/index.ts +++ b/packages/powersync-db-collection/src/index.ts @@ -2,3 +2,4 @@ export * from './definitions' export * from './powersync' export * from './PowerSyncTransactor' export * from './sqlite-compiler' +export type { OptionalExtractedTable } from './helpers' diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 786bb817c8..2f9040a140 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) } @@ -392,6 +392,7 @@ function createPowerSyncCollectionConfig< return await database.triggers.createDiffTrigger({ source: viewName, destination: trackedTableName, + columns: table.columns.map((column) => column.name), setupContext, when, hooks: { @@ -416,9 +417,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 +444,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 +506,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 +589,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 +633,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 +807,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 +887,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 +925,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) @@ -965,18 +976,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/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts new file mode 100644 index 0000000000..eb3438c359 --- /dev/null +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -0,0 +1,576 @@ +/** + * Laws: collection updates conserve independent SQLite fields; collection rows + * expose the declared PowerSync view; comparison observes schema output. + * + * Reference: disjoint logical changes are composed from their changed fields, + * PowerSync's declared SQLite view defines the readable keys, and the supplied + * Standard Schema defines collection output values. + * + * Production path: a real node database, real collection sync, the registered + * watcher callback, PowerSync CRUD rows, and the collection comparator. + * The held watcher is the only timing control; assertions run after explicitly + * releasing it. Each test proves path reach before comparing the full promised + * observation. The comparison-sensitivity test rejects the historical stale + * full-row patch and undeclared public key without invoking production. + */ +import { randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { + LogLevels, + PowerSyncDatabase, + Schema, + Table, + column, +} from '@powersync/node' +import { createCollection, createTransaction } from '@tanstack/db' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { powerSyncCollectionOptions } from '../src' +import { PowerSyncTransactor } from '../src/PowerSyncTransactor' +import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' +import type { WatchOnChangeHandler } from '@powersync/common' + +const describePowerSync = TEST_DATABASE_IMPLEMENTATION + ? describe + : describe.skip + +type CrudRow = { data: string } + +async function createDatabase( + schema: Schema, + logger?: { + log: (record: { level: number; message: string; error?: unknown }) => void + }, +) { + const db = new PowerSyncDatabase({ + schema, + database: { + dbFilename: `correctness-oracle-${randomUUID()}.sqlite`, + dbLocation: tmpdir(), + implementation: TEST_DATABASE_IMPLEMENTATION, + }, + logger: logger as never, + }) + await db.disconnectAndClear() + return db +} + +function holdAdapterChanges(db: PowerSyncDatabase) { + let handler: WatchOnChangeHandler | undefined + vi.spyOn(db, `onChangeWithCallback`).mockImplementation((candidate) => { + handler = candidate + return () => {} + }) + return { + expectReached: () => expect(handler).toBeDefined(), + flush: async () => { + if (!handler) + throw new Error(`adapter did not register its change handler`) + await handler.onChange({ changedTables: [] }) + }, + } +} + +function observeUpdate( + sqliteRow: { assignee: string | null; done: number }, + patch: { op: string; data: Record }, +) { + return { sqliteRow, patch: { op: patch.op, data: patch.data } } +} + +function observeViewKeys( + viewRow: Record, + collectionRow: Record, +) { + return { + view: Object.keys(viewRow).sort(), + collection: Object.keys(collectionRow) + .filter((key) => !key.startsWith(`$`)) + .sort(), + } +} + +describePowerSync(`PowerSync correctness oracle`, () => { + afterEach(() => vi.restoreAllMocks()) + + it(`rejects stale full-row patches and undeclared public keys`, () => { + expect( + observeUpdate( + { assignee: null, done: 1 }, + { op: `PATCH`, data: { assignee: null, done: 1 } }, + ), + ).not.toEqual( + observeUpdate( + { assignee: `alice`, done: 1 }, + { op: `PATCH`, data: { done: 1 } }, + ), + ) + expect( + observeViewKeys( + { id: `t1`, title: `Write report` }, + { id: `t1`, title: `Write report`, priority: `high` }, + ), + ).not.toEqual({ + view: [`id`, `title`], + collection: [`id`, `title`], + }) + }) + + it(`preserves a newer disjoint SQLite field across a collection update`, async () => { + const schema = new Schema({ + todos: new Table({ + title: column.text, + assignee: column.text, + done: column.integer, + }), + }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO todos (id, title, assignee, done) VALUES ('t1', 'Write report', NULL, 0)`, + ) + const heldChanges = holdAdapterChanges(db) + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: schema.props.todos, + }), + ) + + try { + await collection.preload() + heldChanges.expectReached() + + await db.execute(`UPDATE todos SET assignee = 'alice' WHERE id = 't1'`) + expect(collection.get(`t1`)?.assignee).toBeNull() + + const transaction = collection.update(`t1`, (draft) => { + draft.done = 1 + }) + + let rowBeforeFlush: { assignee: string | null; done: number } | null = + null + await vi.waitFor(async () => { + rowBeforeFlush = await db.get<{ + assignee: string | null + done: number + }>(`SELECT assignee, done FROM todos WHERE id = 't1'`) + expect(rowBeforeFlush.done).toBe(1) + }) + + await heldChanges.flush() + await transaction.isPersisted.promise + + const crud = await db.getAll( + `SELECT data FROM ps_crud ORDER BY id`, + ) + const finalPatch = JSON.parse(crud.at(-1)!.data) as { + op: string + data: Record + } + + expect(observeUpdate(rowBeforeFlush!, finalPatch)).toEqual( + observeUpdate( + { assignee: `alice`, done: 1 }, + { op: `PATCH`, data: { done: 1 } }, + ), + ) + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`waits for the last effective update when a trailing update changes nothing`, async () => { + const schema = new Schema({ + todos: new Table({ title: column.text, done: column.integer }), + }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO todos (id, title, done) VALUES ('t1', 'Write report', 0)`, + ) + const heldChanges = holdAdapterChanges(db) + const collection = createCollection( + powerSyncCollectionOptions({ database: db, table: schema.props.todos }), + ) + + const transaction = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + try { + await collection.preload() + heldChanges.expectReached() + transaction.mutate(() => + collection.update(`t1`, (draft) => { + draft.done = 1 + }), + ) + const effective = transaction.mutations[0]! + const trailingNoop = { + ...effective, + mutationId: `${effective.mutationId}-noop`, + original: effective.modified, + changes: {}, + } + const transactor = new PowerSyncTransactor({ database: db }) + let settled = false + const persistence = transactor + .applyTransaction({ + mutations: [effective, trailingNoop], + } as never) + .then(() => { + settled = true + }) + + await vi.waitFor(async () => { + expect( + await db.get<{ done: number }>( + `SELECT done FROM todos WHERE id = 't1'`, + ), + ).toEqual({ done: 1 }) + }) + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + expect(settled).toBe(false) + + await heldChanges.flush() + await persistence + expect(settled).toBe(true) + } finally { + transaction.rollback() + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`persists a tracked metadata-only update and waits for its diff`, async () => { + const schema = new Schema({ + todos: new Table( + { title: column.text, done: column.integer }, + { trackMetadata: true }, + ), + }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO todos (id, title, done) VALUES ('t1', 'Write report', 0)`, + ) + const heldChanges = holdAdapterChanges(db) + const collection = createCollection( + powerSyncCollectionOptions({ database: db, table: schema.props.todos }), + ) + const transaction = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + + try { + await collection.preload() + heldChanges.expectReached() + transaction.mutate(() => + collection.update(`t1`, (draft) => { + draft.done = 1 + }), + ) + const template = transaction.mutations[0]! + const metadata = { source: `correctness-oracle` } + const metadataOnly = { + ...template, + mutationId: `${template.mutationId}-metadata`, + modified: template.original, + changes: {}, + metadata, + } + const transactor = new PowerSyncTransactor({ database: db }) + let settled = false + const persistence = transactor + .applyTransaction({ mutations: [metadataOnly] } as never) + .then(() => { + settled = true + }) + + await vi.waitFor(async () => { + const batch = await db.getCrudBatch(100) + expect(batch?.crud.at(-1)?.metadata).toBe(JSON.stringify(metadata)) + }) + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + expect(settled).toBe(false) + + await heldChanges.flush() + await persistence + expect(settled).toBe(true) + } finally { + transaction.rollback() + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`persists explicit falsey field changes without treating them as absent`, async () => { + const schema = new Schema({ + flags: new Table({ + label: column.text, + note: column.text, + count: column.integer, + active: column.integer, + }), + }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO flags (id, label, note, count, active) VALUES ('f1', 'before', 'owner', 1, 1)`, + ) + const outputSchema = z.object({ + id: z.string(), + label: z.string().nullable(), + note: z.string().nullable(), + count: z.number().nullable(), + active: z.boolean().nullable(), + }) + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: schema.props.flags, + schema: outputSchema, + deserializationSchema: z.object({ + id: z.string(), + label: z.string().nullable(), + note: z.string().nullable(), + count: z.number().nullable(), + active: z + .number() + .nullable() + .transform((value) => (value == null ? null : value !== 0)), + }), + onDeserializationError: () => {}, + }), + ) + + try { + await collection.preload() + await collection.update(`f1`, (draft) => { + draft.label = `` + draft.note = null + draft.count = 0 + draft.active = false + }).isPersisted.promise + + const crud = await db.getAll( + `SELECT data FROM ps_crud ORDER BY id`, + ) + const patch = JSON.parse(crud.at(-1)!.data) as { + op: string + data: Record + } + expect({ op: patch.op, data: patch.data }).toEqual({ + op: `PATCH`, + data: { label: ``, note: null, count: 0, active: 0 }, + }) + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`initializes and cleans up with a structured logger`, async () => { + const schema = new Schema({ todos: new Table({ title: column.text }) }) + const records: Array<{ + level: number + message: string + error?: unknown + }> = [] + const db = await createDatabase(schema, { + log: (record) => records.push(record), + }) + await db.execute(`INSERT INTO todos (id, title) VALUES ('t1', 'before')`) + const collection = createCollection( + powerSyncCollectionOptions({ database: db, table: schema.props.todos }), + ) + + try { + await collection.preload() + await collection.update( + `t1`, + { metadata: { ignored: true } }, + (draft) => { + draft.title = `after` + }, + ).isPersisted.promise + await collection.cleanup() + + expect( + records.some( + (record) => + record.level === LogLevels.info && + record.message.includes(`Sync is ready`), + ), + ).toBe(true) + expect( + records.some( + (record) => + record.level === LogLevels.warn && + record.message.includes(`does not track metadata`), + ), + ).toBe(true) + expect( + records.some( + (record) => + record.level === LogLevels.info && + record.message.includes(`Sync has been stopped`), + ), + ).toBe(true) + expect( + records.filter((record) => record.level >= LogLevels.error), + ).toEqual([]) + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`keeps collection keys congruent with the declared PowerSync view`, async () => { + const schema = new Schema({ todos: new Table({ title: column.text }) }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO todos (id, title) VALUES ('t1', 'Write report')`, + ) + const heldChanges = holdAdapterChanges(db) + const createDiffTrigger = vi.spyOn(db.triggers, `createDiffTrigger`) + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: schema.props.todos, + }), + ) + + try { + await collection.preload() + heldChanges.expectReached() + expect(createDiffTrigger).toHaveBeenCalled() + expect(createDiffTrigger.mock.calls[0]![0].columns).toEqual([`title`]) + + await db.writeTransaction(async (tx) => { + await tx.execute( + `UPDATE ps_data__todos SET data = json_set(data, '$.priority', 'high', '$.title', NULL) WHERE id = 't1'`, + ) + }) + await heldChanges.flush() + + const viewRow = await db.get>( + `SELECT * FROM todos WHERE id = 't1'`, + ) + const collectionRow = collection.get(`t1`) as unknown as Record< + string, + unknown + > + expect(viewRow.title).toBeNull() + expect(collectionRow.title).toBeNull() + + let persistenceError: unknown + const persistenceState = { settled: false } + const persistence = collection + .update(`t1`, (draft) => { + draft.title = `Write report today` + }) + .isPersisted.promise.catch((error: unknown) => { + persistenceError = error + }) + .finally(() => { + persistenceState.settled = true + }) + + await vi.waitFor(async () => { + const row = await db.get<{ title: string }>( + `SELECT title FROM todos WHERE id = 't1'`, + ) + expect( + persistenceState.settled || row.title === `Write report today`, + ).toBe(true) + }) + if (!persistenceState.settled) await heldChanges.flush() + await persistence + + expect(observeViewKeys(viewRow, collectionRow)).toEqual({ + view: [`id`, `title`], + collection: [`id`, `title`], + }) + expect(collectionRow).not.toHaveProperty(`priority`) + expect(persistenceError).toBeUndefined() + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`passes an empty declared-column list for an id-only table`, async () => { + const schema = new Schema({ ids: new Table({}) }) + const db = await createDatabase(schema) + const createDiffTrigger = vi.spyOn(db.triggers, `createDiffTrigger`) + const collection = createCollection( + powerSyncCollectionOptions({ database: db, table: schema.props.ids }), + ) + + try { + await collection.preload() + expect(createDiffTrigger).toHaveBeenCalled() + expect(createDiffTrigger.mock.calls[0]![0].columns).toEqual([]) + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) + + it(`compares transformed schema output rather than raw SQLite rows`, async () => { + const schema = new Schema({ + todos: new Table({ + title: column.text, + created_at: column.text, + }), + }) + const db = await createDatabase(schema) + await db.execute( + `INSERT INTO todos (id, title, created_at) VALUES ('newer', 'newer', '2024-06-01T00:00:00.000Z')`, + ) + await db.execute( + `INSERT INTO todos (id, title, created_at) VALUES ('older', 'older', '2024-01-01T00:00:00.000Z')`, + ) + await db.execute( + `INSERT INTO todos (id, title, created_at) VALUES ('undated', 'undated', NULL)`, + ) + const compare = vi.fn( + (left: Date | null, right: Date | null) => + (left?.getTime() ?? Number.NEGATIVE_INFINITY) - + (right?.getTime() ?? Number.NEGATIVE_INFINITY), + ) + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: schema.props.todos, + schema: z.object({ + id: z.string(), + title: z.string().nullable(), + created_at: z + .string() + .nullable() + .transform((value) => (value ? new Date(value) : null)), + }), + onDeserializationError: () => {}, + compare: (left, right) => compare(left.created_at, right.created_at), + }), + ) + + try { + await expect(collection.preload()).resolves.toBeUndefined() + expect(compare).toHaveBeenCalled() + expect([...collection.keys()]).toEqual([`undated`, `older`, `newer`]) + } finally { + await collection.cleanup() + await db.disconnectAndClear() + await db.close() + } + }) +}) 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..d64842d612 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 @@ -2932,7 +2939,7 @@ describe(`On-Demand Sync Mode`, () => { async (options) => { let cursor = 0 await options.hooks?.beforeCreate?.({ - getAll: async () => rows.slice(cursor, ++cursor), + getAll: () => Promise.resolve(rows.slice(cursor, ++cursor)), } as never) return vi.fn() }, @@ -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/portable-declarations.test.ts b/packages/powersync-db-collection/tests/portable-declarations.test.ts new file mode 100644 index 0000000000..df3604b990 --- /dev/null +++ b/packages/powersync-db-collection/tests/portable-declarations.test.ts @@ -0,0 +1,113 @@ +/** + * Law: an external inferred factory can emit declarations using package-root + * public types only. A temporary consumer imports the built packages by their + * published names, emits its own declaration, and rejects internal helper or + * workspace paths. Successful compilation is the path-reach witness. + */ +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), `..`) +const workspaceRoot = resolve(packageRoot, `../..`) + +function run(command: string, args: Array, cwd: string) { + return spawnSync(command, args, { + cwd, + encoding: `utf8`, + env: { ...process.env, NO_COLOR: `1` }, + }) +} + +function expectSuccess(result: ReturnType) { + expect({ + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }).toMatchObject({ status: 0 }) +} + +it(`emits portable declarations for an external inferred collection factory`, () => { + const vite = resolve(workspaceRoot, `node_modules/.bin/vite`) + expectSuccess(run(vite, [`build`], resolve(packageRoot, `../db`))) + expectSuccess(run(vite, [`build`], packageRoot)) + + const consumerRoot = mkdtempSync( + join(tmpdir(), `powersync-portable-declarations-`), + ) + try { + const scope = join(consumerRoot, `node_modules`, `@tanstack`) + mkdirSync(scope, { recursive: true }) + symlinkSync(packageRoot, join(scope, `powersync-db-collection`), `dir`) + symlinkSync(resolve(packageRoot, `../db`), join(scope, `db`), `dir`) + const powerSyncScope = join(consumerRoot, `node_modules`, `@powersync`) + mkdirSync(powerSyncScope, { recursive: true }) + symlinkSync( + resolve(packageRoot, `node_modules/@powersync/common`), + join(powerSyncScope, `common`), + `dir`, + ) + + writeFileSync( + join(consumerRoot, `index.ts`), + `import { Schema, Table, column } from '@powersync/common' +import { createCollection } from '@tanstack/db' +import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection' +import type { CommonPowerSyncDatabase } from '@powersync/common' + +const schema = new Schema({ + items: new Table({ name: column.text }), +}) + +export const createTanstackCollection = (db: CommonPowerSyncDatabase) => + createCollection( + powerSyncCollectionOptions({ database: db, table: schema.props.items }), + ) +`, + ) + writeFileSync( + join(consumerRoot, `tsconfig.json`), + JSON.stringify({ + compilerOptions: { + target: `ES2022`, + module: `ESNext`, + moduleResolution: `Bundler`, + strict: true, + skipLibCheck: true, + declaration: true, + emitDeclarationOnly: true, + outDir: `dist`, + }, + include: [`index.ts`], + }), + ) + + const compile = run( + resolve(workspaceRoot, `node_modules/.bin/tsc`), + [`--project`, `tsconfig.json`, `--pretty`, `false`], + consumerRoot, + ) + expectSuccess(compile) + + const declaration = readFileSync( + join(consumerRoot, `dist`, `index.d.ts`), + `utf8`, + ) + expect(declaration).toContain(`@tanstack/powersync-db-collection`) + expect(declaration).not.toMatch( + /(?:dist\/esm|src)\/helpers|\.worktrees|\/Users\//, + ) + } finally { + rmSync(consumerRoot, { recursive: true, force: true }) + } +}, 120_000) diff --git a/packages/powersync-db-collection/tests/powersync.test-d.ts b/packages/powersync-db-collection/tests/powersync.test-d.ts index 9694ec46f8..f098d2c5b9 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 { PowerSyncDatabase } from '@powersync/node' import type { PowerSyncCollectionUtils } from '../src' -import type { AbstractPowerSyncDatabase } 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,54 @@ describe(`PowerSync collection type tests`, () => { expectTypeOf(utils.getMeta).toBeFunction() expectTypeOf(collection.utils.getMeta).toBeFunction() }) + + it(`types a no-schema comparator against the inferred SQLite row`, () => { + powerSyncCollectionOptions({ + database: {} as PowerSyncDatabase, + table: APP_SCHEMA.props.documents, + compare: (left, right) => { + expectTypeOf(left.id).toEqualTypeOf() + expectTypeOf(left.name).toEqualTypeOf() + expectTypeOf(left.author).toEqualTypeOf() + expectTypeOf(left.created_at).toEqualTypeOf() + return (left.name ?? ``).localeCompare(right.name ?? ``) + }, + }) + }) + + it(`types compare against transformed schema output`, () => { + const schema = z.object({ + id: z.string(), + name: z.string().nullable(), + author: z.string().nullable(), + created_at: z + .string() + .nullable() + .transform((value) => (value ? new Date(value) : null)), + }) + + powerSyncCollectionOptions({ + database: {} as PowerSyncDatabase, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: () => {}, + compare: (left, right) => { + expectTypeOf(left).toEqualTypeOf>() + 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) => { + // @ts-expect-error transformed dates do not expose string methods + return left.created_at!.localeCompare(right.created_at!) + }, + }) + }) }) 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/packages/powersync-db-collection/tests/transactor-readiness.test.ts b/packages/powersync-db-collection/tests/transactor-readiness.test.ts index dad011bda3..a1e5739d36 100644 --- a/packages/powersync-db-collection/tests/transactor-readiness.test.ts +++ b/packages/powersync-db-collection/tests/transactor-readiness.test.ts @@ -1,7 +1,7 @@ import { createCollection, createTransaction } from '@tanstack/db' import { expect, it, vi } from 'vitest' import { PowerSyncTransactor } from '../src/PowerSyncTransactor' -import type { AbstractPowerSyncDatabase } from '@powersync/common' +import type { CommonPowerSyncDatabase } from '@powersync/common' it.each([`cleanup`, `error`, `ready`] as const)( `settles a transaction waiting for source readiness on %s`, @@ -11,7 +11,7 @@ it.each([`cleanup`, `error`, `ready`] as const)( .mockResolvedValue({ whenComplete: Promise.resolve() }) // This boundary must settle before taking a database lock; no SQL runs. const transactor = new PowerSyncTransactor({ - database: { writeTransaction } as unknown as AbstractPowerSyncDatabase, + database: { writeTransaction } as unknown as CommonPowerSyncDatabase, }) let markSourceReady!: () => void const collection = createCollection<{ id: string }>({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d3bbcd10d..328d4fe5ad 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 c8d3ddc3c64476f563306bbe37cf335fdc27c7fb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 11:11:52 -0600 Subject: [PATCH 2/6] Add PowerSync correctness changeset --- .changeset/fix-powersync-correctness.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-powersync-correctness.md diff --git a/.changeset/fix-powersync-correctness.md b/.changeset/fix-powersync-correctness.md new file mode 100644 index 0000000000..d01850d74f --- /dev/null +++ b/.changeset/fix-powersync-correctness.md @@ -0,0 +1,5 @@ +--- +'@tanstack/powersync-db-collection': patch +--- + +Fix update conservation, declared-view rows, PowerSync 2 compatibility, transformed-schema comparison, and portable inferred declarations. From f42e0f39e9c1ce2ee480e155dc923d35cb4e92d2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 11:28:51 -0600 Subject: [PATCH 3/6] Use type-only imports in PowerSync docs --- docs/collections/powersync-collection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index 92342550d6..d2147241ce 100644 --- a/docs/collections/powersync-collection.md +++ b/docs/collections/powersync-collection.md @@ -55,7 +55,7 @@ const db = new PowerSyncDatabase({ ### 3. (optional) Configure Sync with a Backend ```ts -import { +import type { CommonPowerSyncDatabase, PowerSyncBackendConnector, PowerSyncCredentials, From bb4cd84eaa703de229c5d48dd98a446e334148bf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 14:18:57 -0600 Subject: [PATCH 4/6] Address PowerSync review findings --- .changeset/fix-powersync-correctness.md | 4 +-- .../src/PowerSyncTransactor.ts | 28 ++++++++++--------- .../tests/correctness-oracle.test.ts | 14 ++++------ .../tests/portable-declarations.test.ts | 13 +++++++-- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/.changeset/fix-powersync-correctness.md b/.changeset/fix-powersync-correctness.md index d01850d74f..42442827a0 100644 --- a/.changeset/fix-powersync-correctness.md +++ b/.changeset/fix-powersync-correctness.md @@ -1,5 +1,5 @@ --- -'@tanstack/powersync-db-collection': patch +'@tanstack/powersync-db-collection': minor --- -Fix update conservation, declared-view rows, PowerSync 2 compatibility, transformed-schema comparison, and portable inferred declarations. +Require PowerSync 2 and fix update conservation, declared-view rows, transformed-schema comparison, and portable inferred declarations. diff --git a/packages/powersync-db-collection/src/PowerSyncTransactor.ts b/packages/powersync-db-collection/src/PowerSyncTransactor.ts index 6f247b1665..643044d7e3 100644 --- a/packages/powersync-db-collection/src/PowerSyncTransactor.ts +++ b/packages/powersync-db-collection/src/PowerSyncTransactor.ts @@ -74,28 +74,29 @@ export class PowerSyncTransactor { * The transaction might contain operations for different collections. * We can do some optimizations for single-collection transactions. */ - const mutationsCollectionIds = mutations.map( - (mutation) => mutation.collection.id, - ) - const collectionIds = Array.from(new Set(mutationsCollectionIds)) + const collectionsById = new Map< + string, + PendingMutation[`collection`] + >() const lastCollectionMutationIndexes = new Map() - const allCollections = collectionIds - .map((id) => mutations.find((mutation) => mutation.collection.id == id)!) - .map((mutation) => mutation.collection) for (const [index, mutation] of mutations.entries()) { + const collectionId = mutation.collection.id + if (!collectionsById.has(collectionId)) { + collectionsById.set(collectionId, mutation.collection) + } const changesDatabase = mutation.type != `update` || Object.keys(mutation.changes).some((key) => key != `id`) || (typeof mutation.metadata != `undefined` && this.getMutationCollectionMeta(mutation).metadataIsTracked) if (changesDatabase) { - lastCollectionMutationIndexes.set(mutation.collection.id, index) + lastCollectionMutationIndexes.set(collectionId, index) } } // Check all the observers are ready before taking a lock await Promise.all( - allCollections.map(async (collection) => { + Array.from(collectionsById.values()).map(async (collection) => { if (collection.isReady()) { return } @@ -225,8 +226,8 @@ export class PowerSyncTransactor { waitForCompletion, // eslint-disable-next-line no-shadow async (tableName, mutation, serializeValue) => { - const values = serializeValue(mutation.changes) - delete values.id + const { id: _id, ...changes } = mutation.changes + const values = serializeValue(changes) const keys = Object.keys(values).map((key) => sanitizeSQL`${key}`) const queryParameters = Object.values(values) @@ -249,7 +250,7 @@ export class PowerSyncTransactor { [...queryParameters, asPowerSyncRecord(mutation.original).id], ) - return true + return }, ) } @@ -305,7 +306,7 @@ export class PowerSyncTransactor { tableName: string, mutation: PendingMutation, serializeValue: (value: any) => Record, - ) => Promise, + ) => Promise, ): Promise { const { tableName, trackedTableName, serializeValue } = this.getMutationCollectionMeta(mutation) @@ -366,6 +367,7 @@ export class PowerSyncTransactor { 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.`, + error: mutation.metadata, }) } return null diff --git a/packages/powersync-db-collection/tests/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts index eb3438c359..36a392ac23 100644 --- a/packages/powersync-db-collection/tests/correctness-oracle.test.ts +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -387,16 +387,13 @@ describePowerSync(`PowerSync correctness oracle`, () => { const collection = createCollection( powerSyncCollectionOptions({ database: db, table: schema.props.todos }), ) + const ignoredMetadata = { ignored: true } try { await collection.preload() - await collection.update( - `t1`, - { metadata: { ignored: true } }, - (draft) => { - draft.title = `after` - }, - ).isPersisted.promise + await collection.update(`t1`, { metadata: ignoredMetadata }, (draft) => { + draft.title = `after` + }).isPersisted.promise await collection.cleanup() expect( @@ -410,7 +407,8 @@ describePowerSync(`PowerSync correctness oracle`, () => { records.some( (record) => record.level === LogLevels.warn && - record.message.includes(`does not track metadata`), + record.message.includes(`does not track metadata`) && + record.error === ignoredMetadata, ), ).toBe(true) expect( diff --git a/packages/powersync-db-collection/tests/portable-declarations.test.ts b/packages/powersync-db-collection/tests/portable-declarations.test.ts index df3604b990..d67342c11c 100644 --- a/packages/powersync-db-collection/tests/portable-declarations.test.ts +++ b/packages/powersync-db-collection/tests/portable-declarations.test.ts @@ -6,6 +6,7 @@ */ import { spawnSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -38,9 +39,15 @@ function expectSuccess(result: ReturnType) { } it(`emits portable declarations for an external inferred collection factory`, () => { - const vite = resolve(workspaceRoot, `node_modules/.bin/vite`) - expectSuccess(run(vite, [`build`], resolve(packageRoot, `../db`))) - expectSuccess(run(vite, [`build`], packageRoot)) + for (const declaration of [ + resolve(packageRoot, `../db/dist/esm/index.d.ts`), + resolve(packageRoot, `dist/esm/index.d.ts`), + ]) { + expect( + existsSync(declaration), + `Missing built declaration: ${declaration}`, + ).toBe(true) + } const consumerRoot = mkdtempSync( join(tmpdir(), `powersync-portable-declarations-`), From 05337c85f12fe288fc519774c461cb1ff25fd803 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 16 Sep 2026 14:43:25 -0600 Subject: [PATCH 5/6] Simplify PowerSync review fixes --- .../powersync-db-collection/src/serialization.ts | 12 +++--------- .../tests/correctness-oracle.test.ts | 7 +++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/powersync-db-collection/src/serialization.ts b/packages/powersync-db-collection/src/serialization.ts index bee6431b26..2931c855f1 100644 --- a/packages/powersync-db-collection/src/serialization.ts +++ b/packages/powersync-db-collection/src/serialization.ts @@ -13,10 +13,6 @@ import type { * This function takes an object representing a row, a table schema, and an optional custom serializer map. * It returns a new object with values transformed to be compatible with SQLite column types. * - * ## Generics - * - `TOutput`: The shape of the input object, typically matching the row data. - * - `TTable`: The table schema, which must match the keys of `TOutput`. - * * ## Parameters * - `value`: The object to serialize (row data). * - `tableSchema`: The schema describing the SQLite table columns and types. @@ -40,11 +36,9 @@ import type { export function serializeForSQLite( value: AnyTableColumnType, tableSchema: TTable, - customSerializer: Partial< - CustomSQLiteSerializer< - AnyTableColumnType, - ExtractedTableColumns - > + customSerializer: CustomSQLiteSerializer< + AnyTableColumnType, + ExtractedTableColumns > = {}, ): ExtractedTable { return Object.fromEntries( diff --git a/packages/powersync-db-collection/tests/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts index 36a392ac23..431b3e4589 100644 --- a/packages/powersync-db-collection/tests/correctness-oracle.test.ts +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -29,6 +29,7 @@ import { powerSyncCollectionOptions } from '../src' import { PowerSyncTransactor } from '../src/PowerSyncTransactor' import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' import type { WatchOnChangeHandler } from '@powersync/common' +import type { PowerSyncLogger } from '@powersync/node' const describePowerSync = TEST_DATABASE_IMPLEMENTATION ? describe @@ -38,9 +39,7 @@ type CrudRow = { data: string } async function createDatabase( schema: Schema, - logger?: { - log: (record: { level: number; message: string; error?: unknown }) => void - }, + logger?: PowerSyncLogger, ) { const db = new PowerSyncDatabase({ schema, @@ -49,7 +48,7 @@ async function createDatabase( dbLocation: tmpdir(), implementation: TEST_DATABASE_IMPLEMENTATION, }, - logger: logger as never, + logger, }) await db.disconnectAndClear() return db From 33ce94d5f811bf137e75c90b8f970923e1e4810f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:45:29 +0000 Subject: [PATCH 6/6] ci: apply automated fixes --- .../powersync-db-collection/tests/correctness-oracle.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/powersync-db-collection/tests/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts index 431b3e4589..b081ec3593 100644 --- a/packages/powersync-db-collection/tests/correctness-oracle.test.ts +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -37,10 +37,7 @@ const describePowerSync = TEST_DATABASE_IMPLEMENTATION type CrudRow = { data: string } -async function createDatabase( - schema: Schema, - logger?: PowerSyncLogger, -) { +async function createDatabase(schema: Schema, logger?: PowerSyncLogger) { const db = new PowerSyncDatabase({ schema, database: {