Skip to content

Commit fbfa75a

Browse files
Support better schema type conversions
1 parent b7fc0ff commit fbfa75a

File tree

8 files changed

+1123
-224
lines changed

8 files changed

+1123
-224
lines changed

docs/collections/powersync-collection.md

Lines changed: 243 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ import { Schema, Table, column } from "@powersync/web"
3838
const APP_SCHEMA = new Schema({
3939
documents: new Table({
4040
name: column.text,
41-
content: column.text,
41+
author: column.text,
4242
created_at: column.text,
43-
updated_at: column.text,
43+
archived: column.integer,
4444
}),
4545
})
4646

@@ -81,12 +81,14 @@ db.connect(new Connector())
8181

8282
### 4. Create a TanStack DB Collection
8383

84-
There are two ways to create a collection: using type inference or using schema validation.
84+
There are two main ways to create a collection: using type inference or using schema validation. Type inference will infer collection types from the underlying PowerSync SQLite tables. Schema validation can be used for additional input/output validations and type transforms.
8585

8686
#### Option 1: Using Table Type Inference
8787

8888
The collection types are automatically inferred from the PowerSync schema table definition. The table is used to construct a default standard schema validator which is used internally to validate collection operations.
8989

90+
Collection mutations accept SQLite types and queries report data with SQLite types.
91+
9092
```ts
9193
import { createCollection } from "@tanstack/react-db"
9294
import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection"
@@ -99,26 +101,148 @@ const documentsCollection = createCollection(
99101
)
100102
```
101103

102-
#### Option 2: Using Advanced Schema Validation
104+
#### Option 2: SQLite Types with Schema Validation
105+
106+
The standard PowerSync SQLite types map to these TypeScript types:
107+
108+
| PowerSync Column Type | TypeScript Type | Description |
109+
| --------------------- | ---------------- | -------------------------------------------------------------------- |
110+
| `column.text` | `string \| null` | Text values, commonly used for strings, JSON, dates (as ISO strings) |
111+
| `column.integer` | `number \| null` | Integer values, also used for booleans (0/1) |
112+
| `column.real` | `number \| null` | Floating point numbers |
103113

104-
Additional validations can be performed by supplying a compatible validation schema (such as a Zod schema). The output typing of the validator is constrained to match the typing of the SQLite table. The input typing can be arbitrary.
114+
Note: All PowerSync column types are nullable by default, as SQLite allows null values in any column. Your schema should always handle null values appropriately by using `.nullable()` in your Zod schemas and handling null cases in your transformations.
115+
116+
Additional validations for collection mutations can be performed with a custom schema. The Schema below asserts that
117+
the `name`, `author` and `created_at` fields are required as input. `name` also has an additional string length check.
118+
119+
Note: The input and output types specified in this example still satisfy the underlying SQLite types. An additional `deserializationSchema` is required if the typing differs. See the examples below for more details.
105120

106121
```ts
107122
import { createCollection } from "@tanstack/react-db"
108123
import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection"
109124
import { z } from "zod"
110125

111-
// The output of this schema must match the SQLite schema
126+
// Schema validates SQLite types but adds constraints
112127
const schema = z.object({
113128
id: z.string(),
114129
name: z.string().min(3, { message: "Should be at least 3 characters" }),
130+
author: z.string(),
131+
created_at: z.string(), // SQLite TEXT for dates
132+
archived: z.number(),
133+
})
134+
135+
const documentsCollection = createCollection(
136+
powerSyncCollectionOptions({
137+
database: db,
138+
table: APP_SCHEMA.props.documents,
139+
schema,
140+
})
141+
)
142+
143+
/** Note: The types for input and output are defined as this */
144+
// Used for mutations like `insert` or `update`
145+
type DocumentCollectionInput = {
146+
id: string
147+
name: string
148+
author: string
149+
created_at: string // SQLite TEXT
150+
archived: number // SQLite integer
151+
}
152+
// The type of query/data results
153+
type DocumentCollectionOutput = DocumentCollectionInput
154+
```
155+
156+
#### Option 3: Transform SQLite Input Types to Rich Output Types
157+
158+
You can transform SQLite types to richer types (like Date objects) while keeping SQLite-compatible input types:
159+
160+
Note: The Transformed types are provided by TanStackDB to the PowerSync SQLite persister. These types need to be serialized in
161+
order to be persisted to SQLite. Most types are converted by default. For custom types, override the serialization by providing a
162+
`serializer` param.
163+
164+
```ts
165+
const schema = z.object({
166+
id: z.string(),
167+
name: z.string().nullable(),
168+
created_at: z
169+
.string()
170+
.nullable()
171+
.transform((val) => (val ? new Date(val) : null)), // Transform SQLite TEXT to Date
172+
archived: z
173+
.number()
174+
.nullable()
175+
.transform((val) => (val != null ? val > 0 : null)), // Transform SQLite INTEGER to boolean
176+
})
177+
178+
const documentsCollection = createCollection(
179+
powerSyncCollectionOptions({
180+
database: db,
181+
table: APP_SCHEMA.props.documents,
182+
schema,
183+
// Optional: custom column serialization
184+
serializer: {
185+
// Dates are serialized by default, this is just an example
186+
created_at: (value) => (value ? value.toISOString() : null),
187+
},
188+
})
189+
)
190+
191+
/** Note: The types for input and output are defined as this */
192+
// Used for mutations like `insert` or `update`
193+
type DocumentCollectionInput = {
194+
id: string
195+
name: string | null
196+
author: string | null
197+
created_at: string | null // SQLite TEXT
198+
archived: number | null
199+
}
200+
// The type of query/data results
201+
type DocumentCollectionOutput = {
202+
id: string
203+
name: string | null
204+
author: string | null
205+
created_at: Date | null // JS Date instance
206+
archived: boolean | null // JS boolean
207+
}
208+
```
209+
210+
#### Option 4: Custom Input/Output Types with Deserialization
211+
212+
The input and output types can be completely decoupled from the internal SQLite types. This can be used to accept rich values for input mutations.
213+
We require an additional `deserializationSchema` in order to validate and transform incoming synced (SQLite) updates. This schema should convert the incoming SQLite update to the output type.
214+
215+
The application logic (including the backend) should enforce that all incoming synced data passes validation with the `deserializationSchema`. Failing to validate data will result in inconsistency of the collection data. This is a fatal error! An `onDeserializationError` handler must be provided to react to this case.
216+
217+
```ts
218+
// Our input/output types use Date and boolean
219+
const schema = z.object({
220+
id: z.string(),
221+
name: z.string(),
222+
author: z.string(),
223+
created_at: z.date(), // Accept Date objects as input
224+
})
225+
226+
// Schema to transform from SQLite types to our output types
227+
const deserializationSchema = z.object({
228+
id: z.string(),
229+
name: z.string(),
230+
author: z.string(),
231+
created_at: z
232+
.string()
233+
.nullable()
234+
.transform((val) => (val ? new Date(val) : null)), // SQLite TEXT to Date
115235
})
116236

117237
const documentsCollection = createCollection(
118238
powerSyncCollectionOptions({
119239
database: db,
120240
table: APP_SCHEMA.props.documents,
121241
schema,
242+
deserializationSchema,
243+
onDeserializationError: (error) => {
244+
// Present fatal error
245+
},
122246
})
123247
)
124248
```
@@ -138,6 +262,102 @@ When connected to a PowerSync backend, changes are automatically synchronized in
138262
- Queue management for offline changes
139263
- Automatic retries on connection loss
140264

265+
### Working with Rich JavaScript Types
266+
267+
PowerSync collections support rich JavaScript types like `Date`, `Boolean`, and custom objects while maintaining SQLite compatibility. The collection handles serialization and deserialization automatically:
268+
269+
```typescript
270+
import { z } from "zod"
271+
import { Schema, Table, column } from "@powersync/web"
272+
import { createCollection } from "@tanstack/react-db"
273+
import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection"
274+
275+
// Define PowerSync SQLite schema
276+
const APP_SCHEMA = new Schema({
277+
tasks: new Table({
278+
title: column.text,
279+
due_date: column.text, // Stored as ISO string in SQLite
280+
completed: column.integer, // Stored as 0/1 in SQLite
281+
metadata: column.text, // Stored as JSON string in SQLite
282+
}),
283+
})
284+
285+
// Define rich types schema
286+
const taskSchema = z.object({
287+
id: z.string(),
288+
title: z.string().nullable(),
289+
due_date: z
290+
.string()
291+
.nullable()
292+
.transform((val) => (val ? new Date(val) : null)), // Convert to Date
293+
completed: z
294+
.number()
295+
.nullable()
296+
.transform((val) => (val != null ? val > 0 : null)), // Convert to boolean
297+
metadata: z
298+
.string()
299+
.nullable()
300+
.transform((val) => (val ? JSON.parse(val) : null)), // Parse JSON
301+
})
302+
303+
// Create collection with rich types
304+
const tasksCollection = createCollection(
305+
powerSyncCollectionOptions({
306+
database: db,
307+
table: APP_SCHEMA.props.tasks,
308+
schema: taskSchema,
309+
})
310+
)
311+
312+
// Work with rich types in your code
313+
await tasksCollection.insert({
314+
id: crypto.randomUUID(),
315+
title: "Review PR",
316+
due_date: "2025-10-30T10:00:00Z", // String input is automatically converted to Date
317+
completed: 0, // Number input is automatically converted to boolean
318+
metadata: JSON.stringify({ priority: "high" }),
319+
})
320+
321+
// Query returns rich types
322+
const task = tasksCollection.get("task-1")
323+
console.log(task.due_date instanceof Date) // true
324+
console.log(typeof task.completed) // "boolean"
325+
console.log(task.metadata.priority) // "high"
326+
```
327+
328+
### Type Safety with Rich Types
329+
330+
The collection maintains type safety throughout:
331+
332+
```typescript
333+
type TaskInput = {
334+
id: string
335+
title: string | null
336+
due_date: string | null // Accept ISO string for mutations
337+
completed: number | null // Accept 0/1 for mutations
338+
metadata: string | null // Accept JSON string for mutations
339+
}
340+
341+
type TaskOutput = {
342+
id: string
343+
title: string | null
344+
due_date: Date | null // Get Date object in queries
345+
completed: boolean | null // Get boolean in queries
346+
metadata: {
347+
priority: string
348+
[key: string]: any
349+
} | null
350+
}
351+
352+
// TypeScript enforces correct types:
353+
tasksCollection.insert({
354+
due_date: new Date(), // Error: Type 'Date' is not assignable to type 'string'
355+
})
356+
357+
const task = tasksCollection.get("task-1")
358+
task.due_date.getTime() // OK - TypeScript knows this is a Date
359+
```
360+
141361
### Optimistic Updates
142362

143363
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.
@@ -147,10 +367,23 @@ Updates to the collection are applied optimistically to the local state first, t
147367
The `powerSyncCollectionOptions` function accepts the following options:
148368

149369
```ts
150-
interface PowerSyncCollectionConfig<T> {
151-
database: PowerSyncDatabase // PowerSync database instance
152-
table: Table // PowerSync schema table definition
153-
schema?: StandardSchemaV1 // Optional schema for additional validation (e.g., Zod schema)
370+
interface PowerSyncCollectionConfig<TTable extends Table, TSchema> {
371+
// Required options
372+
database: PowerSyncDatabase
373+
table: Table
374+
375+
// Schema validation and type transformation
376+
schema?: StandardSchemaV1
377+
deserializationSchema?: StandardSchemaV1 // Required for custom input types
378+
onDeserializationError?: (error: StandardSchemaV1.FailureResult) => void // Required for custom input types
379+
380+
// Optional Custom serialization
381+
serializer?: {
382+
[Key in keyof TOutput]?: (value: TOutput[Key]) => SQLiteCompatibleType
383+
}
384+
385+
// Performance tuning
386+
syncBatchSize?: number // Control batch size for initial sync, defaults to 1000
154387
}
155388
```
156389

0 commit comments

Comments
 (0)