livestorejs/livestore

Make query builder pluggable to support third-party SQLite drivers like Drizzle

Open

#468 opened on Jul 13, 2025

View on GitHub
 (3 comments) (0 reactions) (0 assignees)TypeScript (128 forks)github user discovery
help wantedsqlite

Repository metrics

Stars
 (3,599 stars)
PR merge metrics
 (PR metrics pending)

Description

Currently LiveStore has its own SQLite driver (query builder), making it difficult to integrate with existing third-party SQLite drivers like Drizzle, Kysely, or Knex. This creates friction for developers who want to use LiveStore's reactive capabilities with their preferred database tooling.

Tasks

1. Investigate requirements for SQLite driver compatibility

  • Analyze constraints: synchronous SQL generation, table tracking for reactivity
  • Document the interface requirements for external SQLite drivers
  • Identify which SQLite drivers are compatible

2. Design integration API for external SQLite drivers

  • Create converter functions that translate external queries to LiveStore-compatible formats
  • Design schema bridge utilities to map between LiveStore and external schemas
  • Ensure proper table dependency tracking for reactive queries

3. Support client-document table creation

  • Driver DSL should provide convenient methods for creating client-document tables
  • Enable local-only state management patterns similar to existing LiveStore patterns
  • Maintain compatibility with LiveStore's client-side data patterns
  • Support typed document schemas with default values

4. Provide schema integration with Effect Schema preference

  • SQLite drivers should preferably use Effect Schema as the primary type system for consistency
  • Leverage Effect Schema's runtime validation, transformation, and type inference capabilities
  • If not using Effect Schema, drivers must provide bi-directional schemas for encoding and decoding query results
  • Ensure type-safe serialization/deserialization of complex data types (JSON, dates, etc.)
  • Handle nullable fields, default values, and type coercion properly
  • Enable seamless interop between driver schemas and LiveStore's internal type system

Proposed API Design

Note: This API design is currently a draft and will likely need further refinement during implementation.

Driver Interface Types

// Core SQLite driver interface
interface SQLiteDriver<TExternalTable = unknown, TExternalQuery = unknown> {
  // Convert external table definitions to LiveStore-compatible format
  fromExternalTable(externalTable: TExternalTable): TableDef.Any
  
  // Create materializers that can handle external queries
  materializers<TEvents extends Record<string, EventDef.AnyWithoutFn>>(
    events: TEvents,
    handlers: Record<string, (payload: any) => TExternalQuery>
  ): Record<string, Materializer<any>>
  
  // Create client documents
  clientDocument<TSchema extends Schema.Schema<any>>(config: {
    name: string
    schema: TSchema
    default: Schema.Schema.Type<TSchema>
  }): ClientDocument<TSchema>
  
  // Create state from external tables and materializers
  makeState(config: {
    tables: Record<string, TExternalTable>
    materializers: Record<string, Materializer<any>>
  }): InternalState
}

// External query requirement - must be able to generate SQL synchronously
interface ExternalQuery {
  toSQL(): {
    sql: string
    params: unknown[]
  }
}

// Driver factory function signature
declare function createSQLiteDriver<TTable, TQuery extends ExternalQuery>(): SQLiteDriver<TTable, TQuery>

Usage Example

import { drizzle } from 'drizzle-orm/sqlite-core'
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'
import { eq } from 'drizzle-orm'
import { createDrizzleSQLiteDriver } from '@livestore/drizzle-sqlite-driver'
import { Events, makeSchema } from '@livestore/livestore'

// Define Drizzle schema
const todos = sqliteTable('todos', {
  id: text('id').primaryKey(),
  text: text('text').notNull().default(''),
  completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
  deletedAt: integer('deletedAt', { mode: 'timestamp' }),
})

const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull(),
})

// Create SQLite driver
const driver = createDrizzleSQLiteDriver()

// Define events
const events = {
  todoCreated: Events.synced({
    name: 'v1.TodoCreated',
    schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
  }),
  todoCompleted: Events.synced({
    name: 'v1.TodoCompleted', 
    schema: Schema.Struct({ id: Schema.String }),
  }),
}

// Materializers using driver helper and Drizzle syntax
const materializers = driver.materializers(events, {
  'v1.TodoCreated': ({ id, text }) => 
    drizzle.insert(todos).values({ id, text, completed: false }),
  
  'v1.TodoCompleted': ({ id }) => 
    drizzle.update(todos).set({ completed: true }).where(eq(todos.id, id)),
    
  'v1.TodoDeleted': ({ id }) =>
    drizzle.update(todos).set({ deletedAt: new Date() }).where(eq(todos.id, id)),
})

// Client-document support through driver DSL
const uiState = driver.clientDocument({
  name: 'uiState',
  schema: Schema.Struct({ 
    filter: Schema.Literal('all', 'active', 'completed'),
    selectedId: Schema.NullOr(Schema.String)
  }),
  default: { filter: 'all', selectedId: null }
})

// Create state using driver with raw Drizzle tables
const state = driver.makeState({ tables: { users, todos }, materializers })

// Create schema using makeSchema
export const schema = makeSchema({ events, state })

Goal

Enable developers to use their preferred SQLite driver while maintaining LiveStore's reactive capabilities, type safety, and performance characteristics.

Contributor guide