Skip to content

Commit 4692c8b

Browse files
Support input schema validations with Zod
1 parent 8d489e9 commit 4692c8b

File tree

5 files changed

+121
-65
lines changed

5 files changed

+121
-65
lines changed

docs/collections/powersync-collection.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,25 +101,29 @@ const documentsCollection = createCollection(
101101
)
102102
```
103103

104-
#### Option 2: Using Schema Validation
104+
#### Option 2: Using Advanced Schema Validation
105105

106-
TODO
106+
Additional validations can be performed by supplying a Standard Schema.
107107

108108
```ts
109109
import { createCollection } from "@tanstack/react-db"
110110
import {
111111
powerSyncCollectionOptions,
112112
convertPowerSyncSchemaToSpecs,
113113
} from "@tanstack/powersync-db-collection"
114+
import { z } from "zod"
114115

115-
// Convert PowerSync schema to TanStack DB schema
116-
const schemas = convertPowerSyncSchemaToSpecs(APP_SCHEMA)
116+
// The output of this schema must correspond to the SQLite schema
117+
const schema = z.object({
118+
id: z.string(),
119+
name: z.string().min(3, { message: errorMessage }).nullable(),
120+
})
117121

118122
const documentsCollection = createCollection(
119123
powerSyncCollectionOptions({
120124
database: db,
121-
tableName: "documents",
122-
schema: schemas.documents, // Use schema for runtime type validation
125+
table: APP_SCHEMA.props.documents,
126+
schema,
123127
})
124128
)
125129
```

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

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

106
/**
117
* Configuration interface for PowerSync collection options
@@ -41,14 +37,14 @@ import type { BaseCollectionConfig, CollectionConfig } from "@tanstack/db"
4137
* ```
4238
*/
4339
export type PowerSyncCollectionConfig<
44-
TableType extends Table<ColumnsType> = Table<ColumnsType>,
40+
TTable extends Table = Table,
4541
TSchema extends StandardSchemaV1 = never,
4642
> = Omit<
47-
BaseCollectionConfig<ExtractedTable<TableType[`columnMap`]>, string, TSchema>,
43+
BaseCollectionConfig<ExtractedTable<TTable>, string, TSchema>,
4844
`onInsert` | `onUpdate` | `onDelete` | `getKey`
4945
> & {
5046
/** The PowerSync Schema Table definition */
51-
table: TableType
47+
table: TTable
5248
/** The PowerSync database instance */
5349
database: AbstractPowerSyncDatabase
5450
/**
@@ -78,13 +74,9 @@ export type PowerSyncCollectionMeta = {
7874
}
7975

8076
export type EnhancedPowerSyncCollectionConfig<
81-
TableType extends Table<any> = Table<any>,
77+
TTable extends Table = Table,
8278
TSchema extends StandardSchemaV1 = never,
83-
> = CollectionConfig<
84-
ExtractedTable<TableType[`columnMap`]>,
85-
string,
86-
TSchema
87-
> & {
79+
> = CollectionConfig<ExtractedTable<TTable>, string, TSchema> & {
8880
id?: string
8981
utils: PowerSyncCollectionUtils
9082
schema?: TSchema

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

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

44
/**
55
* All PowerSync table records have a uuid `id` column.
@@ -20,12 +20,14 @@ export type PowerSyncRecord = {
2020
* name: column.text,
2121
* age: column.integer
2222
* })
23-
* type TableType = ExtractedTable<typeof table.columnMap>
23+
* type TableType = ExtractedTable<typeof table>
2424
* // Results in: { name: string | null, age: number | null }
2525
* ```
2626
*/
27-
export type ExtractedTable<Columns extends ColumnsType> = {
28-
[K in keyof Columns]: ExtractColumnValueType<Columns[K]>
27+
export type ExtractedTable<TTable extends Table> = {
28+
[K in keyof TTable[`columnMap`]]: ExtractColumnValueType<
29+
TTable[`columnMap`][K]
30+
>
2931
} & {
3032
id: string
3133
}

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

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,104 +4,110 @@ import { asPowerSyncRecord, mapOperation } from "./helpers"
44
import { PendingOperationStore } from "./PendingOperationStore"
55
import { PowerSyncTransactor } from "./PowerSyncTransactor"
66
import { convertTableToSchema } from "./schema"
7-
import type { ExtractedTable } from "./helpers"
8-
import type { PendingOperation } from "./PendingOperationStore"
7+
import type { Table, TriggerDiffRecord } from "@powersync/common"
8+
import type { StandardSchemaV1 } from "@standard-schema/spec"
9+
import type { CollectionConfig, SyncConfig } from "@tanstack/db"
910
import type {
1011
EnhancedPowerSyncCollectionConfig,
1112
PowerSyncCollectionConfig,
1213
PowerSyncCollectionUtils,
1314
} from "./definitions"
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"
15+
import type { ExtractedTable } from "./helpers"
16+
import type { PendingOperation } from "./PendingOperationStore"
1717

1818
/**
1919
* Creates PowerSync collection options for use with a standard Collection
2020
*
21-
* @template TExplicit - The explicit type of items in the collection (highest priority)
22-
* @template TSchema - The schema type for validation and type inference (second priority)
21+
* @template TTable - The SQLite based typing
22+
* @template TSchema - The schema type for validation - optionally supports a custom input type
2323
* @param config - Configuration options for the PowerSync collection
2424
* @returns Collection options with utilities
2525
*/
2626

27-
// Overload for when schema is provided
2827
/**
29-
* Creates a PowerSync collection configuration with schema validation.
28+
* Creates a PowerSync collection configuration with basic default validation.
3029
*
3130
* @example
3231
* ```typescript
33-
* // With schema validation
3432
* const APP_SCHEMA = new Schema({
3533
* documents: new Table({
3634
* name: column.text,
3735
* }),
3836
* })
3937
*
38+
* type Document = (typeof APP_SCHEMA)["types"]["documents"]
39+
*
40+
* const db = new PowerSyncDatabase({
41+
* database: {
42+
* dbFilename: "test.sqlite",
43+
* },
44+
* schema: APP_SCHEMA,
45+
* })
46+
*
4047
* const collection = createCollection(
4148
* powerSyncCollectionOptions({
4249
* database: db,
43-
* table: APP_SCHEMA.props.documents,
44-
* schema: TODO
50+
* table: APP_SCHEMA.props.documents
4551
* })
4652
* )
4753
* ```
4854
*/
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-
// }
55+
export function powerSyncCollectionOptions<TTable extends Table = Table>(
56+
config: PowerSyncCollectionConfig<TTable, never>
57+
): CollectionConfig<ExtractedTable<TTable>, string, never> & {
58+
utils: PowerSyncCollectionUtils
59+
}
5660

61+
// Overload for when schema is provided
5762
/**
58-
* Creates a PowerSync collection configuration without schema validation.
63+
* Creates a PowerSync collection configuration with schema validation.
5964
*
6065
* @example
6166
* ```typescript
67+
* import { z } from "zod"
68+
*
69+
* // The PowerSync SQLite Schema
6270
* const APP_SCHEMA = new Schema({
6371
* documents: new Table({
6472
* name: column.text,
6573
* }),
6674
* })
6775
*
68-
* type Document = (typeof APP_SCHEMA)["types"]["documents"]
69-
*
70-
* const db = new PowerSyncDatabase({
71-
* database: {
72-
* dbFilename: "test.sqlite",
73-
* },
74-
* schema: APP_SCHEMA,
76+
* // Advanced Zod validations. The output type of this schema
77+
* // is constrained to the SQLite schema of APP_SCHEMA
78+
* const schema = z.object({
79+
* id: z.string(),
80+
* name: z.string().min(3, { message: "Should be at least 3 characters" }).nullable(),
7581
* })
7682
*
7783
* const collection = createCollection(
7884
* powerSyncCollectionOptions({
7985
* database: db,
80-
* table: APP_SCHEMA.props.documents
86+
* table: APP_SCHEMA.props.documents,
87+
* schema
8188
* })
8289
* )
8390
* ```
8491
*/
8592
export function powerSyncCollectionOptions<
86-
TableType extends Table<ColumnsType> = Table<ColumnsType>,
93+
TTable extends Table,
94+
TSchema extends StandardSchemaV1<ExtractedTable<TTable>, any>,
8795
>(
88-
config: PowerSyncCollectionConfig<TableType> & {
89-
schema?: never
90-
}
91-
): CollectionConfig<ExtractedTable<TableType[`columnMap`]>, string> & {
92-
schema?: never
96+
config: PowerSyncCollectionConfig<TTable, TSchema>
97+
): CollectionConfig<ExtractedTable<TTable>, string, TSchema> & {
9398
utils: PowerSyncCollectionUtils
99+
schema: TSchema
94100
}
95101

96102
/**
97103
* Implementation of powerSyncCollectionOptions that handles both schema and non-schema configurations.
98104
*/
99105
export function powerSyncCollectionOptions<
100-
TableType extends Table<ColumnsType> = Table<ColumnsType>,
106+
TTable extends Table = Table,
101107
TSchema extends StandardSchemaV1 = never,
102108
>(
103-
config: PowerSyncCollectionConfig<TableType, TSchema>
104-
): EnhancedPowerSyncCollectionConfig<TableType, TSchema> {
109+
config: PowerSyncCollectionConfig<TTable, TSchema>
110+
): EnhancedPowerSyncCollectionConfig<TTable, TSchema> {
105111
const {
106112
database,
107113
table,
@@ -110,7 +116,7 @@ export function powerSyncCollectionOptions<
110116
...restConfig
111117
} = config
112118

113-
type RecordType = ExtractedTable<TableType[`columnMap`]>
119+
type RecordType = ExtractedTable<TTable>
114120
const { viewName } = table
115121

116122
// We can do basic runtime validations for columns if not explicit schema has been provided
@@ -277,7 +283,7 @@ export function powerSyncCollectionOptions<
277283

278284
const getKey = (record: RecordType) => asPowerSyncRecord(record).id
279285

280-
const outputConfig: EnhancedPowerSyncCollectionConfig<TableType, TSchema> = {
286+
const outputConfig: EnhancedPowerSyncCollectionConfig<TTable, TSchema> = {
281287
...restConfig,
282288
schema,
283289
getKey,
@@ -286,7 +292,6 @@ export function powerSyncCollectionOptions<
286292
sync,
287293
onInsert: async (params) => {
288294
// The transaction here should only ever contain a single insert mutation
289-
params.transaction
290295
return await transactor.applyTransaction(params.transaction)
291296
},
292297
onUpdate: async (params) => {

packages/powersync-db-collection/tests/powersync.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
liveQueryCollectionOptions,
1616
} from "@tanstack/db"
1717
import { describe, expect, it, onTestFinished, vi } from "vitest"
18+
import { z } from "zod"
1819
import { powerSyncCollectionOptions } from "../src"
1920
import { PowerSyncTransactor } from "../src/PowerSyncTransactor"
2021
import type { AbstractPowerSyncDatabase } from "@powersync/node"
@@ -102,6 +103,58 @@ describe(`PowerSync Integration`, () => {
102103
}
103104
}
104105
})
106+
107+
it(`should allow for advanced validations`, async () => {
108+
const db = await createDatabase()
109+
110+
const errorMessage = `Name must be at least 3 characters`
111+
const schema = z.object({
112+
id: z.string(),
113+
name: z.string().min(3, { message: errorMessage }).nullable(),
114+
})
115+
116+
const collection = createCollection(
117+
powerSyncCollectionOptions({
118+
database: db,
119+
table: APP_SCHEMA.props.documents,
120+
schema,
121+
})
122+
)
123+
onTestFinished(() => collection.cleanup())
124+
125+
try {
126+
collection.insert({
127+
id: randomUUID(),
128+
name: `2`,
129+
})
130+
expect.fail(`Should throw a validation error`)
131+
} catch (ex) {
132+
expect(ex instanceof SchemaValidationError).true
133+
if (ex instanceof SchemaValidationError) {
134+
console.log(ex)
135+
expect(ex.message).contains(errorMessage)
136+
}
137+
}
138+
139+
collection.insert({
140+
id: randomUUID(),
141+
name: null,
142+
})
143+
144+
expect(collection.size).eq(1)
145+
146+
// should validate inputs
147+
try {
148+
collection.insert({} as any)
149+
console.log(`failed`)
150+
} catch (ex) {
151+
expect(ex instanceof SchemaValidationError).true
152+
if (ex instanceof SchemaValidationError) {
153+
console.log(ex)
154+
expect(ex.message).contains(`Required - path: id`)
155+
}
156+
}
157+
})
105158
})
106159

107160
describe(`sync`, () => {

0 commit comments

Comments
 (0)