Skip to content
5 changes: 5 additions & 0 deletions .changeset/powersync-numeric-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/powersync-db-collection': patch
---

Support numeric collection keys. Set `numericIds: true` and have `deserializationSchema` convert `id` to a number, for a backend with integer primary keys; the collection key is then the deserialized number. On-demand loads, change tracking, and eviction compare `id` as a number, and local updates and deletes use the key. TypeScript requires `numericIds` exactly when the schema outputs a number `id`. Releasing an on-demand subset no longer revalidates its rows, so a row that fails validation no longer blocks the release.
39 changes: 39 additions & 0 deletions docs/collections/powersync-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,42 @@ const task = tasksCollection.get("task-1")
task.due_date.getTime() // OK - TypeScript knows this is a Date
```

### Integer Primary Keys

SQLite stores every PowerSync `id` as text. If your backend uses integer primary keys, convert `id` to a number in `deserializationSchema` and set `numericIds: true`. The collection key is then a number, so integer foreign keys match it, for example in `eq(part.id, line.partId)`. Predicates on `id`, such as `gt(product.id, 2)`, compare numbers in on-demand loads, change tracking, and eviction.

```typescript
const APP_SCHEMA = new Schema({
products: new Table({
name: column.text,
}),
})

const productSchema = z.object({
id: z.number(),
name: z.string().nullable(),
})

const products = createCollection(
powerSyncCollectionOptions({
database: db,
table: APP_SCHEMA.props.products,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
schema: productSchema,
deserializationSchema: productSchema.extend({
id: z.coerce.number<string>(), // SQLite TEXT '5' to the key 5
}),
numericIds: true,
onDeserializationError: (error) => {
// Present fatal error
},
})
)

products.get(5)
```

TypeScript requires `numericIds: true` when the schema outputs a number `id`, and rejects it otherwise. An `id` that may be either a string or a number is not supported. Equality and `in` predicates on a numeric `id` use SQLite's `id` index; range predicates such as `gt` scan the table. Numeric IDs must be safe JavaScript integers, and converting the number back to text must reproduce the stored ID exactly. Keep IDs as strings if they can exceed the safe integer range or contain leading zeros. Updates and deletes use the text form of the key to find the SQLite row.

### Optimistic Updates

Updates to the collection are applied optimistically to the local state first, then synchronized with PowerSync and the backend. If an error occurs during sync, the changes are automatically rolled back.
Expand Down Expand Up @@ -526,6 +562,9 @@ interface PowerSyncCollectionConfig<TTable extends Table, TSchema> {
[Key in keyof TOutput]?: (value: TOutput[Key]) => SQLiteCompatibleType
}

// Integer primary keys: required when the schema outputs a number id
numericIds?: boolean

// Performance tuning
syncBatchSize?: number // Control batch size for initial sync, defaults to 1000
}
Expand Down
8 changes: 4 additions & 4 deletions packages/powersync-db-collection/src/PowerSyncTransactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LogLevels, sanitizeSQL } from '@powersync/common'
import { LoadSubsetOperationAbortedError, isFailedStatus } from '@tanstack/db'
import DebugModule from 'debug'
import { PendingOperationStore } from './PendingOperationStore'
import { asPowerSyncRecord, mapOperationToPowerSync } from './helpers'
import { mapOperationToPowerSync } from './helpers'
import type { CommonPowerSyncDatabase, LockContext } from '@powersync/common'
import type { PendingMutation, Transaction } from '@tanstack/db'
import type { PendingOperation } from './PendingOperationStore'
Expand Down Expand Up @@ -247,7 +247,7 @@ export class PowerSyncTransactor {
SET ${keys.map((key) => `${key} = ?`).join(`, `)}
WHERE id = ?
`,
[...queryParameters, asPowerSyncRecord(mutation.original).id],
[...queryParameters, String(mutation.key)],
)

return
Expand Down Expand Up @@ -278,14 +278,14 @@ export class PowerSyncTransactor {
`
UPDATE ${tableName} SET _deleted = TRUE, _metadata = ? WHERE id = ?
`,
[metadataValue, asPowerSyncRecord(mutation.original).id],
[metadataValue, String(mutation.key)],
)
} else {
await context.execute(
`
DELETE FROM ${tableName} WHERE id = ?
`,
[asPowerSyncRecord(mutation.original).id],
[String(mutation.key)],
)
}
},
Expand Down
31 changes: 28 additions & 3 deletions packages/powersync-db-collection/src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ export type InferPowerSyncOutputType<
? ExtractedTable<TTable>
: InferSchemaOutput<TSchema>

/**
* The collection key: the type of the output row's `id`. It is a string
* unless a schema converts `id`, for example to a number.
*/
export type InferPowerSyncKey<TOutput> = TOutput extends { id: infer TId }
? TId extends number
? number
: string
: string

/**
* SQLite stores every PowerSync `id` as text. Set `numericIds: true` when the
* schema converts `id` to a number, as for a backend with integer primary
* keys. Predicates on `id` then compare numbers, and the collection key is
* the number. The option is required exactly when the output `id` is a number.
* An `id` that may be a string or a number fits neither mode and is rejected.
*/
export type NumericIdsConfig<TOutput> =
InferPowerSyncKey<TOutput> extends number
? { numericIds: true }
: InferPowerSyncKey<TOutput> extends string
? { numericIds?: false }
: { numericIds: never }

/**
* A mapping type for custom serialization of object properties to SQLite-compatible values.
*
Expand Down Expand Up @@ -208,7 +232,7 @@ export type BasePowerSyncCollectionConfig<
> = Omit<
BaseCollectionConfig<
InferPowerSyncOutputType<TTable, TSchema>,
string,
InferPowerSyncKey<InferPowerSyncOutputType<TTable, TSchema>>,
TSchema
>,
`onInsert` | `onUpdate` | `onDelete` | `getKey` | `syncMode`
Expand All @@ -230,7 +254,8 @@ export type BasePowerSyncCollectionConfig<
* streaming of initial results, at the cost of more query calls.
*/
syncBatchSize?: number
} & (EagerSyncHooks | OnDemandSyncHooks)
} & NumericIdsConfig<InferPowerSyncOutputType<TTable, TSchema>> &
(EagerSyncHooks | OnDemandSyncHooks)

/**
* Configuration interface for PowerSync collection options.
Expand Down Expand Up @@ -306,7 +331,7 @@ export type EnhancedPowerSyncCollectionConfig<
TSchema extends StandardSchemaV1 = never,
> = CollectionConfig<
OutputType,
string,
InferPowerSyncKey<OutputType>,
TSchema,
PowerSyncCollectionUtils<TTable>
> & {
Expand Down
15 changes: 5 additions & 10 deletions packages/powersync-db-collection/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { DiffTriggerOperation } from '@powersync/common'
import type { ExtractColumnValueType, Table } from '@powersync/common'

/**
* All PowerSync table records include a UUID `id` column.
* All PowerSync table records include an `id` column. SQLite stores it as
* text; a deserialization schema may convert it to a number, as for a
* backend with integer primary keys.
*/
export type PowerSyncRecord = {
id: string
id: string | number
[key: string]: unknown
}

Expand Down Expand Up @@ -64,14 +66,7 @@ export type OptionalExtractedTable<TTable extends Table> = OptionalIfUndefined<{
*/
export type AnyTableColumnType<TTable extends Table> = {
[K in keyof ExtractedTableColumns<TTable>]: unknown
} & { id: string }

export function asPowerSyncRecord(record: any): PowerSyncRecord {
if (typeof record.id !== `string`) {
throw new Error(`Record must have a string id field`)
}
return record as PowerSyncRecord
}
} & { id: string | number }

/**
* Maps {@link DiffTriggerOperation} to TanstackDB operations
Expand Down
Loading
Loading