Skip to content

Commit 8d489e9

Browse files
Schemas Step 1: Infer types from PowerSync schema table.
1 parent 237ed35 commit 8d489e9

File tree

8 files changed

+158
-181
lines changed

8 files changed

+158
-181
lines changed

docs/collections/powersync-collection.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,26 @@ db.connect(new Connector())
8585

8686
There are two ways to create a collection: using type inference or using schema validation.
8787

88-
#### Option 1: Using Type Inference
88+
#### Option 1: Using Table Type Inference
89+
90+
The collection types are automatically inferred from the PowerSync Schema Table definition. The table is used to construct a default StandardSchema validator which is used internally to validate collection data and operations.
8991

9092
```ts
9193
import { createCollection } from "@tanstack/react-db"
9294
import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection"
9395

9496
const documentsCollection = createCollection(
95-
powerSyncCollectionOptions<Document>({
97+
powerSyncCollectionOptions({
9698
database: db,
97-
tableName: "documents",
99+
table: APP_SCHEMA.props.documents,
98100
})
99101
)
100102
```
101103

102104
#### Option 2: Using Schema Validation
103105

106+
TODO
107+
104108
```ts
105109
import { createCollection } from "@tanstack/react-db"
106110
import {

packages/powersync-db-collection/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
"p-defer": "^4.0.1"
1111
},
1212
"peerDependencies": {
13-
"@powersync/common": "^1.39.0"
13+
"@powersync/common": "0.0.0-dev-20251021113138"
1414
},
1515
"devDependencies": {
16-
"@powersync/common": "0.0.0-dev-20251003085035",
17-
"@powersync/node": "0.0.0-dev-20251003085035",
16+
"@powersync/common": "0.0.0-dev-20251021113138",
17+
"@powersync/node": "0.0.0-dev-20251021113138",
1818
"@types/debug": "^4.1.12",
1919
"@vitest/coverage-istanbul": "^3.2.4"
2020
},

packages/powersync-db-collection/src/definitions.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { AbstractPowerSyncDatabase } from "@powersync/common"
1+
import type { ExtractedTable } from "./helpers"
2+
import type {
3+
AbstractPowerSyncDatabase,
4+
ColumnsType,
5+
Table,
6+
} from "@powersync/common"
27
import type { StandardSchemaV1 } from "@standard-schema/spec"
38
import type { BaseCollectionConfig, CollectionConfig } from "@tanstack/db"
49

@@ -36,14 +41,14 @@ import type { BaseCollectionConfig, CollectionConfig } from "@tanstack/db"
3641
* ```
3742
*/
3843
export type PowerSyncCollectionConfig<
39-
T extends object = Record<string, unknown>,
44+
TableType extends Table<ColumnsType> = Table<ColumnsType>,
4045
TSchema extends StandardSchemaV1 = never,
4146
> = Omit<
42-
BaseCollectionConfig<T, string, TSchema>,
47+
BaseCollectionConfig<ExtractedTable<TableType[`columnMap`]>, string, TSchema>,
4348
`onInsert` | `onUpdate` | `onDelete` | `getKey`
4449
> & {
45-
/** The name of the table in PowerSync database */
46-
tableName: string
50+
/** The PowerSync Schema Table definition */
51+
table: TableType
4752
/** The PowerSync database instance */
4853
database: AbstractPowerSyncDatabase
4954
/**
@@ -73,9 +78,13 @@ export type PowerSyncCollectionMeta = {
7378
}
7479

7580
export type EnhancedPowerSyncCollectionConfig<
76-
T extends object = Record<string, unknown>,
81+
TableType extends Table<any> = Table<any>,
7782
TSchema extends StandardSchemaV1 = never,
78-
> = CollectionConfig<T, string, TSchema> & {
83+
> = CollectionConfig<
84+
ExtractedTable<TableType[`columnMap`]>,
85+
string,
86+
TSchema
87+
> & {
7988
id?: string
8089
utils: PowerSyncCollectionUtils
8190
schema?: TSchema

packages/powersync-db-collection/src/helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DiffTriggerOperation } from "@powersync/common"
2+
import type { ColumnsType, ExtractColumnValueType } from "@powersync/common"
23

34
/**
45
* All PowerSync table records have a uuid `id` column.
@@ -8,6 +9,27 @@ export type PowerSyncRecord = {
89
[key: string]: unknown
910
}
1011

12+
/**
13+
* Utility type that extracts the typed structure of a table based on its column definitions.
14+
* Maps each column to its corresponding TypeScript type using ExtractColumnValueType.
15+
*
16+
* @template Columns - The ColumnsType definition containing column configurations
17+
* @example
18+
* ```typescript
19+
* const table = new Table({
20+
* name: column.text,
21+
* age: column.integer
22+
* })
23+
* type TableType = ExtractedTable<typeof table.columnMap>
24+
* // Results in: { name: string | null, age: number | null }
25+
* ```
26+
*/
27+
export type ExtractedTable<Columns extends ColumnsType> = {
28+
[K in keyof Columns]: ExtractColumnValueType<Columns[K]>
29+
} & {
30+
id: string
31+
}
32+
1133
export function asPowerSyncRecord(record: any): PowerSyncRecord {
1234
if (typeof record.id !== `string`) {
1335
throw new Error(`Record must have a string id field`)

packages/powersync-db-collection/src/powersync.ts

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,17 @@ import { DEFAULT_BATCH_SIZE } from "./definitions"
33
import { asPowerSyncRecord, mapOperation } from "./helpers"
44
import { PendingOperationStore } from "./PendingOperationStore"
55
import { PowerSyncTransactor } from "./PowerSyncTransactor"
6-
import type { TriggerDiffRecord } from "@powersync/common"
7-
import type { StandardSchemaV1 } from "@standard-schema/spec"
8-
import type {
9-
CollectionConfig,
10-
InferSchemaOutput,
11-
SyncConfig,
12-
} from "@tanstack/db"
6+
import { convertTableToSchema } from "./schema"
7+
import type { ExtractedTable } from "./helpers"
8+
import type { PendingOperation } from "./PendingOperationStore"
139
import type {
1410
EnhancedPowerSyncCollectionConfig,
1511
PowerSyncCollectionConfig,
1612
PowerSyncCollectionUtils,
1713
} from "./definitions"
18-
import type { PendingOperation } from "./PendingOperationStore"
14+
import type { CollectionConfig, SyncConfig } from "@tanstack/db"
15+
import type { StandardSchemaV1 } from "@standard-schema/spec"
16+
import type { ColumnsType, Table, TriggerDiffRecord } from "@powersync/common"
1917

2018
/**
2119
* Creates PowerSync collection options for use with a standard Collection
@@ -42,18 +40,19 @@ import type { PendingOperation } from "./PendingOperationStore"
4240
* const collection = createCollection(
4341
* powerSyncCollectionOptions({
4442
* database: db,
45-
* tableName: "documents",
46-
* schema: APP_SCHEMA,
43+
* table: APP_SCHEMA.props.documents,
44+
* schema: TODO
4745
* })
4846
* )
4947
* ```
5048
*/
51-
export function powerSyncCollectionOptions<T extends StandardSchemaV1>(
52-
config: PowerSyncCollectionConfig<InferSchemaOutput<T>, T>
53-
): CollectionConfig<InferSchemaOutput<T>, string, T> & {
54-
schema: T
55-
utils: PowerSyncCollectionUtils
56-
}
49+
// TODO!!!
50+
// export function powerSyncCollectionOptions<T extends StandardSchemaV1>(
51+
// config: PowerSyncCollectionConfig<InferSchemaOutput<T>, T>
52+
// ): CollectionConfig<InferSchemaOutput<T>, string, T> & {
53+
// schema: T
54+
// utils: PowerSyncCollectionUtils
55+
// }
5756

5857
/**
5958
* Creates a PowerSync collection configuration without schema validation.
@@ -76,18 +75,20 @@ export function powerSyncCollectionOptions<T extends StandardSchemaV1>(
7675
* })
7776
*
7877
* const collection = createCollection(
79-
* powerSyncCollectionOptions<Document>({
78+
* powerSyncCollectionOptions({
8079
* database: db,
81-
* tableName: "documents",
80+
* table: APP_SCHEMA.props.documents
8281
* })
8382
* )
8483
* ```
8584
*/
86-
export function powerSyncCollectionOptions<T extends object>(
87-
config: PowerSyncCollectionConfig<T> & {
85+
export function powerSyncCollectionOptions<
86+
TableType extends Table<ColumnsType> = Table<ColumnsType>,
87+
>(
88+
config: PowerSyncCollectionConfig<TableType> & {
8889
schema?: never
8990
}
90-
): CollectionConfig<T, string> & {
91+
): CollectionConfig<ExtractedTable<TableType[`columnMap`]>, string> & {
9192
schema?: never
9293
utils: PowerSyncCollectionUtils
9394
}
@@ -96,18 +97,24 @@ export function powerSyncCollectionOptions<T extends object>(
9697
* Implementation of powerSyncCollectionOptions that handles both schema and non-schema configurations.
9798
*/
9899
export function powerSyncCollectionOptions<
99-
T extends object = Record<string, unknown>,
100+
TableType extends Table<ColumnsType> = Table<ColumnsType>,
100101
TSchema extends StandardSchemaV1 = never,
101102
>(
102-
config: PowerSyncCollectionConfig<T, TSchema>
103-
): EnhancedPowerSyncCollectionConfig<T, TSchema> {
103+
config: PowerSyncCollectionConfig<TableType, TSchema>
104+
): EnhancedPowerSyncCollectionConfig<TableType, TSchema> {
104105
const {
105106
database,
106-
tableName,
107+
table,
108+
schema: inputSchema,
107109
syncBatchSize = DEFAULT_BATCH_SIZE,
108110
...restConfig
109111
} = config
110112

113+
type RecordType = ExtractedTable<TableType[`columnMap`]>
114+
const { viewName } = table
115+
116+
// We can do basic runtime validations for columns if not explicit schema has been provided
117+
const schema = inputSchema ?? (convertTableToSchema(table) as TSchema)
111118
/**
112119
* The onInsert, onUpdate, onDelete handlers should only return
113120
* after we have written the changes to Tanstack DB.
@@ -120,13 +127,13 @@ export function powerSyncCollectionOptions<
120127
*/
121128
const pendingOperationStore = PendingOperationStore.GLOBAL
122129
// Keep the tracked table unique in case of multiple tabs.
123-
const trackedTableName = `__${tableName}_tracking_${Math.floor(
130+
const trackedTableName = `__${viewName}_tracking_${Math.floor(
124131
Math.random() * 0xffffffff
125132
)
126133
.toString(16)
127134
.padStart(8, `0`)}`
128135

129-
const transactor = new PowerSyncTransactor<T>({
136+
const transactor = new PowerSyncTransactor<RecordType>({
130137
database,
131138
})
132139

@@ -135,15 +142,15 @@ export function powerSyncCollectionOptions<
135142
* Notice that this describes the Sync between the local SQLite table
136143
* and the in-memory tanstack-db collection.
137144
*/
138-
const sync: SyncConfig<T, string> = {
145+
const sync: SyncConfig<RecordType, string> = {
139146
sync: (params) => {
140147
const { begin, write, commit, markReady } = params
141148
const abortController = new AbortController()
142149

143150
// The sync function needs to be synchronous
144151
async function start() {
145152
database.logger.info(
146-
`Sync is starting for ${tableName} into ${trackedTableName}`
153+
`Sync is starting for ${viewName} into ${trackedTableName}`
147154
)
148155
database.onChangeWithCallback(
149156
{
@@ -175,7 +182,7 @@ export function powerSyncCollectionOptions<
175182
id,
176183
operation,
177184
timestamp,
178-
tableName,
185+
tableName: viewName,
179186
})
180187
}
181188

@@ -201,7 +208,7 @@ export function powerSyncCollectionOptions<
201208
)
202209

203210
const disposeTracking = await database.triggers.createDiffTrigger({
204-
source: tableName,
211+
source: viewName,
205212
destination: trackedTableName,
206213
when: {
207214
[DiffTriggerOperation.INSERT]: `TRUE`,
@@ -214,8 +221,8 @@ export function powerSyncCollectionOptions<
214221
let cursor = 0
215222
while (currentBatchCount == syncBatchSize) {
216223
begin()
217-
const batchItems = await context.getAll<T>(
218-
sanitizeSQL`SELECT * FROM ${tableName} LIMIT ? OFFSET ?`,
224+
const batchItems = await context.getAll<RecordType>(
225+
sanitizeSQL`SELECT * FROM ${viewName} LIMIT ? OFFSET ?`,
219226
[syncBatchSize, cursor]
220227
)
221228
currentBatchCount = batchItems.length
@@ -230,7 +237,7 @@ export function powerSyncCollectionOptions<
230237
}
231238
markReady()
232239
database.logger.info(
233-
`Sync is ready for ${tableName} into ${trackedTableName}`
240+
`Sync is ready for ${viewName} into ${trackedTableName}`
234241
)
235242
},
236243
},
@@ -252,14 +259,14 @@ export function powerSyncCollectionOptions<
252259

253260
start().catch((error) =>
254261
database.logger.error(
255-
`Could not start syncing process for ${tableName} into ${trackedTableName}`,
262+
`Could not start syncing process for ${viewName} into ${trackedTableName}`,
256263
error
257264
)
258265
)
259266

260267
return () => {
261268
database.logger.info(
262-
`Sync has been stopped for ${tableName} into ${trackedTableName}`
269+
`Sync has been stopped for ${viewName} into ${trackedTableName}`
263270
)
264271
abortController.abort()
265272
}
@@ -268,16 +275,18 @@ export function powerSyncCollectionOptions<
268275
getSyncMetadata: undefined,
269276
}
270277

271-
const getKey = (record: T) => asPowerSyncRecord(record).id
278+
const getKey = (record: RecordType) => asPowerSyncRecord(record).id
272279

273-
const outputConfig: EnhancedPowerSyncCollectionConfig<T, TSchema> = {
280+
const outputConfig: EnhancedPowerSyncCollectionConfig<TableType, TSchema> = {
274281
...restConfig,
282+
schema,
275283
getKey,
276284
// Syncing should start immediately since we need to monitor the changes for mutations
277285
startSync: true,
278286
sync,
279287
onInsert: async (params) => {
280288
// The transaction here should only ever contain a single insert mutation
289+
params.transaction
281290
return await transactor.applyTransaction(params.transaction)
282291
},
283292
onUpdate: async (params) => {
@@ -290,7 +299,7 @@ export function powerSyncCollectionOptions<
290299
},
291300
utils: {
292301
getMeta: () => ({
293-
tableName,
302+
tableName: viewName,
294303
trackedTableName,
295304
}),
296305
},

packages/powersync-db-collection/src/schema.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,7 @@
11
import { ColumnType } from "@powersync/common"
2-
import type {
3-
ColumnsType,
4-
ExtractColumnValueType,
5-
Schema,
6-
Table,
7-
} from "@powersync/common"
2+
import type { ColumnsType, Schema, Table } from "@powersync/common"
83
import type { StandardSchemaV1 } from "@standard-schema/spec"
9-
10-
/**
11-
* Utility type that extracts the typed structure of a table based on its column definitions.
12-
* Maps each column to its corresponding TypeScript type using ExtractColumnValueType.
13-
*
14-
* @template Columns - The ColumnsType definition containing column configurations
15-
* @example
16-
* ```typescript
17-
* const table = new Table({
18-
* name: column.text,
19-
* age: column.integer
20-
* })
21-
* type TableType = ExtractedTable<typeof table.columnMap>
22-
* // Results in: { name: string | null, age: number | null }
23-
* ```
24-
*/
25-
type ExtractedTable<Columns extends ColumnsType> = {
26-
[K in keyof Columns]: ExtractColumnValueType<Columns[K]>
27-
} & {
28-
id: string
29-
}
4+
import type { ExtractedTable } from "./helpers"
305

316
/**
327
* Converts a PowerSync Table instance to a StandardSchemaV1 schema.

0 commit comments

Comments
 (0)