diff --git a/.changeset/tender-mugs-deliver.md b/.changeset/tender-mugs-deliver.md new file mode 100644 index 000000000..7327e5fdd --- /dev/null +++ b/.changeset/tender-mugs-deliver.md @@ -0,0 +1,5 @@ +--- +'@powersync/drizzle-driver': minor +--- + +Added helper `toPowersyncTable` function and `DrizzleAppSchema` constructor to convert a Drizzle schema into a PowerSync app schema. diff --git a/packages/common/src/db/schema/Schema.ts b/packages/common/src/db/schema/Schema.ts index 82141e0e5..0b72499a1 100644 --- a/packages/common/src/db/schema/Schema.ts +++ b/packages/common/src/db/schema/Schema.ts @@ -2,7 +2,7 @@ import { RowType, Table } from './Table.js'; type SchemaType = Record>; -type SchemaTableType = { +export type SchemaTableType = { [K in keyof S]: RowType; }; diff --git a/packages/drizzle-driver/README.md b/packages/drizzle-driver/README.md index 3523df9c9..e27cddcfb 100644 --- a/packages/drizzle-driver/README.md +++ b/packages/drizzle-driver/README.md @@ -15,7 +15,7 @@ import { wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver'; import { PowerSyncDatabase } from '@powersync/web'; import { relations } from 'drizzle-orm'; import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { appSchema } from './schema'; +import { AppSchema } from './schema'; export const lists = sqliteTable('lists', { id: text('id'), @@ -47,24 +47,99 @@ export const drizzleSchema = { todosRelations }; +// As an alternative to manually defining a PowerSync schema, generate the local PowerSync schema from the Drizzle schema with the `DrizzleAppSchema` constructor: +// import { DrizzleAppSchema } from '@powersync/drizzle-driver'; +// export const AppSchema = new DrizzleAppSchema(drizzleSchema); +// +// This is optional, but recommended, since you will only need to maintain one schema on the client-side +// Read on to learn more. + export const powerSyncDb = new PowerSyncDatabase({ database: { dbFilename: 'test.sqlite' }, - schema: appSchema + schema: AppSchema }); +// This is the DB you will use in queries export const db = wrapPowerSyncWithDrizzle(powerSyncDb, { schema: drizzleSchema }); ``` -## Known limitations +## Schema Conversion -- The integration does not currently support nested transactions (also known as `savepoints`). -- The Drizzle schema needs to be created manually, and it should match the table definitions of your PowerSync schema. +The `DrizzleAppSchema` constructor simplifies the process of integrating Drizzle with PowerSync. It infers the local [PowerSync schema](https://docs.powersync.com/installation/client-side-setup/define-your-schema) from your Drizzle schema definition, providing a unified development experience. + +As the PowerSync schema only supports SQLite types (`text`, `integer`, and `real`), the same limitation extends to the Drizzle table definitions. + +To use it, define your Drizzle tables and supply the schema to the `DrizzleAppSchema` function: + +```js +import { DrizzleAppSchema } from '@powersync/drizzle-driver'; +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +// Define a Drizzle table +const lists = sqliteTable('lists', { + id: text('id').primaryKey().notNull(), + created_at: text('created_at'), + name: text('name').notNull(), + owner_id: text('owner_id') +}); + +export const drizzleSchema = { + lists +}; + +// Infer the PowerSync schema from your Drizzle schema +export const AppSchema = new DrizzleAppSchema(drizzleSchema); +``` + +### Defining PowerSync Options -### Compilable queries +The PowerSync table definition allows additional options supported by PowerSync's app schema beyond that which are supported by Drizzle. +They can be specified as follows. Note that these options exclude indexes as they can be specified in a Drizzle table. + +```js +import { DrizzleAppSchema } from '@powersync/drizzle-driver'; +// import { DrizzleAppSchema, type DrizzleTableWithPowerSyncOptions} from '@powersync/drizzle-driver'; for TypeScript + +const listsWithOptions = { tableDefinition: logs, options: { localOnly: true } }; +// const listsWithOptions: DrizzleTableWithPowerSyncOptions = { tableDefinition: logs, options: { localOnly: true } }; for TypeScript + +export const drizzleSchemaWithOptions = { + lists: listsWithOptions +}; + +export const AppSchema = new DrizzleAppSchema(drizzleSchemaWithOptions); +``` + +### Converting a Single Table From Drizzle to PowerSync + +Drizzle tables can also be converted on a table-by-table basis with `toPowerSyncTable`. + +```js +import { toPowerSyncTable } from '@powersync/drizzle-driver'; +import { Schema } from '@powersync/web'; +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +// Define a Drizzle table +const lists = sqliteTable('lists', { + id: text('id').primaryKey().notNull(), + created_at: text('created_at'), + name: text('name').notNull(), + owner_id: text('owner_id') +}); + +const psLists = toPowerSyncTable(lists); // converts the Drizzle table to a PowerSync table +// toPowerSyncTable(lists, { localOnly: true }); - allows for PowerSync table configuration + +export const AppSchema = new Schema({ + lists: psLists // names the table `lists` in the PowerSync schema +}); +``` + +## Compilable queries To use Drizzle queries in your hooks and composables, queries need to be converted using `toCompilableQuery`. @@ -76,3 +151,7 @@ const { data: listRecords, isLoading } = useQuery(toCompilableQuery(query)); ``` For more information on how to use Drizzle queries in PowerSync, see [here](https://docs.powersync.com/client-sdk-references/javascript-web/javascript-orm/drizzle#usage-examples). + +## Known limitations + +- The integration does not currently support nested transactions (also known as `savepoints`). diff --git a/packages/drizzle-driver/src/index.ts b/packages/drizzle-driver/src/index.ts index 6dd870c88..07c6e550b 100644 --- a/packages/drizzle-driver/src/index.ts +++ b/packages/drizzle-driver/src/index.ts @@ -1,4 +1,27 @@ import { wrapPowerSyncWithDrizzle, type DrizzleQuery, type PowerSyncSQLiteDatabase } from './sqlite/db'; import { toCompilableQuery } from './utils/compilableQuery'; +import { + DrizzleAppSchema, + toPowerSyncTable, + type DrizzleTablePowerSyncOptions, + type DrizzleTableWithPowerSyncOptions, + type Expand, + type ExtractPowerSyncColumns, + type TableName, + type TablesFromSchemaEntries +} from './utils/schema'; -export { wrapPowerSyncWithDrizzle, toCompilableQuery, DrizzleQuery, PowerSyncSQLiteDatabase }; +export { + DrizzleAppSchema, + DrizzleTablePowerSyncOptions, + DrizzleTableWithPowerSyncOptions, + DrizzleQuery, + Expand, + ExtractPowerSyncColumns, + PowerSyncSQLiteDatabase, + TableName, + TablesFromSchemaEntries, + toCompilableQuery, + toPowerSyncTable, + wrapPowerSyncWithDrizzle +}; diff --git a/packages/drizzle-driver/src/utils/schema.ts b/packages/drizzle-driver/src/utils/schema.ts new file mode 100644 index 000000000..eadc744b8 --- /dev/null +++ b/packages/drizzle-driver/src/utils/schema.ts @@ -0,0 +1,133 @@ +import { + column, + IndexShorthand, + Schema, + SchemaTableType, + Table, + type BaseColumnType, + type TableV2Options +} from '@powersync/common'; +import { InferSelectModel, isTable, Relations } from 'drizzle-orm'; +import { + getTableConfig, + SQLiteInteger, + SQLiteReal, + SQLiteText, + type SQLiteTableWithColumns, + type TableConfig +} from 'drizzle-orm/sqlite-core'; + +export type ExtractPowerSyncColumns> = { + [K in keyof InferSelectModel as K extends 'id' ? never : K]: BaseColumnType[K]>; +}; + +export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; + +export function toPowerSyncTable>( + table: T, + options?: Omit +): Table>> { + const { columns: drizzleColumns, indexes: drizzleIndexes } = getTableConfig(table); + + const columns: { [key: string]: BaseColumnType } = {}; + for (const drizzleColumn of drizzleColumns) { + // Skip the id column + if (drizzleColumn.name === 'id') { + continue; + } + + let mappedType: BaseColumnType; + switch (drizzleColumn.columnType) { + case SQLiteText.name: + mappedType = column.text; + break; + case SQLiteInteger.name: + mappedType = column.integer; + break; + case SQLiteReal.name: + mappedType = column.real; + break; + default: + throw new Error(`Unsupported column type: ${drizzleColumn.columnType}`); + } + columns[drizzleColumn.name] = mappedType; + } + const indexes: IndexShorthand = {}; + + for (const index of drizzleIndexes) { + index.config; + if (!index.config.columns.length) { + continue; + } + const columns: string[] = []; + for (const indexColumn of index.config.columns) { + columns.push((indexColumn as { name: string }).name); + } + + indexes[index.config.name] = columns; + } + return new Table(columns, { ...options, indexes }) as Table>>; +} + +export type DrizzleTablePowerSyncOptions = Omit; + +export type DrizzleTableWithPowerSyncOptions = { + tableDefinition: SQLiteTableWithColumns; + options?: DrizzleTablePowerSyncOptions | undefined; +}; + +export type TableName = + T extends SQLiteTableWithColumns + ? T['_']['name'] + : T extends DrizzleTableWithPowerSyncOptions + ? T['tableDefinition']['_']['name'] + : never; + +export type TablesFromSchemaEntries = { + [K in keyof T as T[K] extends Relations + ? never + : T[K] extends SQLiteTableWithColumns | DrizzleTableWithPowerSyncOptions + ? TableName + : never]: T[K] extends SQLiteTableWithColumns + ? Table>> + : T[K] extends DrizzleTableWithPowerSyncOptions + ? Table>> + : never; +}; + +function toPowerSyncTables< + T extends Record | Relations | DrizzleTableWithPowerSyncOptions> +>(schemaEntries: T) { + const tables: Record = {}; + for (const schemaEntry of Object.values(schemaEntries)) { + let maybeTable: SQLiteTableWithColumns | Relations | undefined = undefined; + let maybeOptions: DrizzleTablePowerSyncOptions | undefined = undefined; + + if (typeof schemaEntry === 'object' && 'tableDefinition' in schemaEntry) { + const tableWithOptions = schemaEntry as DrizzleTableWithPowerSyncOptions; + maybeTable = tableWithOptions.tableDefinition; + maybeOptions = tableWithOptions.options; + } else { + maybeTable = schemaEntry; + } + + if (isTable(maybeTable)) { + const { name } = getTableConfig(maybeTable); + tables[name] = toPowerSyncTable(maybeTable as SQLiteTableWithColumns, maybeOptions); + } + } + + return tables; +} + +export class DrizzleAppSchema< + T extends Record | Relations | DrizzleTableWithPowerSyncOptions> +> extends Schema { + constructor(drizzleSchema: T) { + super(toPowerSyncTables(drizzleSchema)); + // This is just used for typing + this.types = {} as SchemaTableType>>; + } + + readonly types: SchemaTableType>>; +} diff --git a/packages/drizzle-driver/tests/sqlite/schema.test.ts b/packages/drizzle-driver/tests/sqlite/schema.test.ts new file mode 100644 index 000000000..069d97884 --- /dev/null +++ b/packages/drizzle-driver/tests/sqlite/schema.test.ts @@ -0,0 +1,200 @@ +import { column, Schema, Table } from '@powersync/common'; +import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { describe, expect, it } from 'vitest'; +import { DrizzleAppSchema, DrizzleTableWithPowerSyncOptions, toPowerSyncTable } from '../../src/utils/schema'; + +describe('toPowerSyncTable', () => { + it('basic conversion', () => { + const lists = sqliteTable('lists', { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_id: text('owner_id'), + counter: integer('counter'), + completion: real('completion') + }); + const convertedList = toPowerSyncTable(lists); + + const expectedLists = new Table({ + name: column.text, + owner_id: column.text, + counter: column.integer, + completion: column.real + }); + + expect(convertedList).toEqual(expectedLists); + }); + + it('conversion with index', () => { + const lists = sqliteTable( + 'lists', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_id: text('owner_id') + }, + (lists) => ({ + owner: index('owner').on(lists.owner_id) + }) + ); + const convertedList = toPowerSyncTable(lists); + + const expectedLists = new Table( + { + name: column.text, + owner_id: column.text + }, + { indexes: { owner: ['owner_id'] } } + ); + + expect(convertedList).toEqual(expectedLists); + }); + + it('conversion with options', () => { + const lists = sqliteTable('lists', { + id: text('id').primaryKey(), + name: text('name').notNull() + }); + + const convertedList = toPowerSyncTable(lists, { localOnly: true, insertOnly: true, viewName: 'listsView' }); + + const expectedLists = new Table( + { + name: column.text + }, + { localOnly: true, insertOnly: true, viewName: 'listsView' } + ); + + expect(convertedList).toEqual(expectedLists); + }); +}); + +describe('DrizzleAppSchema constructor', () => { + it('basic conversion', () => { + const lists = sqliteTable('lists', { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_id: text('owner_id'), + counter: integer('counter'), + completion: real('completion') + }); + + const todos = sqliteTable('todos', { + id: text('id').primaryKey(), + list_id: text('list_id').references(() => lists.id), + description: text('description') + }); + + const drizzleSchema = { + lists, + todos + }; + + const convertedSchema = new DrizzleAppSchema(drizzleSchema); + + const expectedSchema = new Schema({ + lists: new Table({ + name: column.text, + owner_id: column.text, + counter: column.integer, + completion: column.real + }), + todos: new Table({ + list_id: column.text, + description: column.text + }) + }); + + expect(convertedSchema.tables).toEqual(expectedSchema.tables); + }); + + it('conversion with options', () => { + const lists = sqliteTable('lists', { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_id: text('owner_id'), + counter: integer('counter'), + completion: real('completion') + }); + + const todos = sqliteTable('todos', { + id: text('id').primaryKey(), + list_id: text('list_id').references(() => lists.id), + description: text('description') + }); + + const drizzleSchemaWithOptions = { + lists: { + tableDefinition: lists, + options: { localOnly: true, insertOnly: true, viewName: 'listsView' } + } as DrizzleTableWithPowerSyncOptions, + todos + }; + + const convertedSchema = new DrizzleAppSchema(drizzleSchemaWithOptions); + + const expectedSchema = new Schema({ + lists: new Table( + { + name: column.text, + owner_id: column.text, + counter: column.integer, + completion: column.real + }, + { localOnly: true, insertOnly: true, viewName: 'listsView' } + ), + todos: new Table({ + list_id: column.text, + description: column.text + }) + }); + + expect(convertedSchema.tables).toEqual(expectedSchema.tables); + }); + + it('conversion with index', () => { + const lists = sqliteTable('lists', { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_id: text('owner_id'), + counter: integer('counter'), + completion: real('completion') + }); + + const todos = sqliteTable( + 'todos', + { + id: text('id').primaryKey(), + list_id: text('list_id').references(() => lists.id), + description: text('description') + }, + (todos) => ({ + list: index('list').on(todos.list_id) + }) + ); + + const drizzleSchemaWithOptions = { + lists, + todos + }; + + const convertedSchema = new DrizzleAppSchema(drizzleSchemaWithOptions); + + const expectedSchema = new Schema({ + lists: new Table({ + name: column.text, + owner_id: column.text, + counter: column.integer, + completion: column.real + }), + todos: new Table( + { + list_id: column.text, + description: column.text + }, + { indexes: { list: ['list_id'] } } + ) + }); + + expect(convertedSchema.tables).toEqual(expectedSchema.tables); + }); +});