turso migration

This commit is contained in:
Devin Zuczek
2026-09-27 15:38:52 -04:00
parent b32b16c1f0
commit 3f2cf2e700
34 changed files with 946 additions and 49 deletions
+8 -5
View File
@@ -7,14 +7,17 @@
"main": "src/index.ts",
"scripts": {
"check:lint": "run-oxlint",
"check:types": "run-tsc"
"check:types": "run-tsc",
"test": "run-vitest"
},
"dependencies": {
"@libsql/client": "0.18.0",
"isemail": "^3.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20260630.1",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*"
},
"dependencies": {
"isemail": "^3.2.0"
"@repo/typescript-config": "workspace:*",
"vitest": "4.1.9"
}
}
+1
View File
@@ -10,6 +10,7 @@ export * from './accounts-db'
export * from './audit-db'
export * from './clubs-db'
export * from './d1-binds'
export * from './libsql-db'
export * from './images-db'
export * from './password'
export * from './rooms-db'
+175
View File
@@ -0,0 +1,175 @@
import { createClient } from '@libsql/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { bindPlaceholders, chunkForBinds } from './d1-binds'
import { libsqlDatabase, resolveDatabaseEnv, withDatabase } from './libsql-db'
import type { Client } from '@libsql/client'
/**
* The adapter against a real (in-memory) libSQL database, asserting the D1 behaviours the
* workers lean on: `first` is null on no row, `run`/`all` carry `results` and
* `meta.changes`, `batch` is one transaction, numbered placeholders bind by position.
*/
describe('libsqlDatabase', () => {
let client: Client
let db: D1Database
beforeEach(async () => {
client = createClient({ url: ':memory:' })
db = libsqlDatabase(client)
await db.exec(`
CREATE TABLE account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
);
CREATE UNIQUE INDEX idx_account_id ON account (account_id);
`)
})
afterEach(() => {
client.close()
})
const insert = (id: number, username: string) =>
db
.prepare('INSERT INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username }))
.run()
it('runs writes and reports changes like D1', async () => {
const res = await insert(1, 'Alice')
expect(res.success).toBe(true)
expect(res.results).toEqual([])
expect(res.meta.changes).toBe(1)
expect(res.meta.last_row_id).toBe(1)
const del = await db.prepare('DELETE FROM account WHERE account_id = ?1').bind(99).run()
expect(del.meta.changes).toBe(0)
})
it('reads rows as plain objects, and null when there is none', async () => {
await insert(1, 'Alice')
const row = await db
.prepare(
'SELECT account_id AS id, username_lower AS name, data FROM account WHERE account_id = ?1'
)
.bind(1)
.first<{ id: number; name: string; data: string }>()
expect(row).toEqual({ id: 1, name: 'alice', data: '{"accountId":1,"username":"Alice"}' })
// A plain object, not the client's row shape: no hidden length, spreads cleanly.
expect(Object.keys(row!)).toEqual(['id', 'name', 'data'])
expect({ ...row }).toEqual(row)
expect(
await db.prepare('SELECT 1 FROM account WHERE account_id = ?1').bind(2).first()
).toBeNull()
expect(await db.prepare('SELECT account_id FROM account').first<number>('account_id')).toBe(1)
})
it('binds numbered placeholders by position, as d1-binds builds them', async () => {
await insert(1, 'a')
await insert(2, 'b')
await insert(3, 'c')
const ids = [3, 1, 42]
const [chunk] = chunkForBinds(ids, 1)
const { results } = await db
.prepare(
`SELECT account_id AS id FROM account WHERE username_lower != ?1 AND account_id IN (${bindPlaceholders(chunk!, 1)}) ORDER BY id`
)
.bind('zzz', ...chunk!)
.all<{ id: number }>()
expect(results).toEqual([{ id: 1 }, { id: 3 }])
})
it('supports RETURNING through first() the way the auth refresh path uses it', async () => {
await insert(7, 'Seven')
const row = await db
.prepare('DELETE FROM account WHERE account_id = ?1 RETURNING account_id AS accountId')
.bind(7)
.first<{ accountId: number }>()
expect(row).toEqual({ accountId: 7 })
expect(await db.prepare('SELECT COUNT(*) AS n FROM account').first<number>('n')).toBe(0)
})
it('runs a batch as one transaction and returns a result per statement', async () => {
const [a, b, c] = await db.batch<{ n: number }>([
db.prepare('INSERT INTO account (data) VALUES (?1)').bind('{"accountId":1,"username":"a"}'),
db.prepare('INSERT INTO account (data) VALUES (?1)').bind('{"accountId":2,"username":"b"}'),
db.prepare('SELECT COUNT(*) AS n FROM account'),
])
expect(a!.meta.changes).toBe(1)
expect(b!.meta.changes).toBe(1)
expect(c!.results).toEqual([{ n: 2 }])
// The second insert violates the unique index; the first must not survive either.
await expect(
db.batch([
db.prepare('INSERT INTO account (data) VALUES (?1)').bind('{"accountId":3,"username":"c"}'),
db.prepare('INSERT INTO account (data) VALUES (?1)').bind('{"accountId":3,"username":"d"}'),
])
).rejects.toThrow()
expect(await db.prepare('SELECT COUNT(*) AS n FROM account').first<number>('n')).toBe(2)
})
it('round-trips blobs and booleans', async () => {
await db.exec('CREATE TABLE blob (id INTEGER PRIMARY KEY, body BLOB, flag INTEGER)')
const bytes = new Uint8Array([1, 2, 3]).buffer
await db
.prepare('INSERT INTO blob (id, body, flag) VALUES (?1, ?2, ?3)')
.bind(1, bytes, true)
.run()
const row = await db.prepare('SELECT body, flag FROM blob WHERE id = ?1').bind(1).first<{
body: ArrayBuffer
flag: number
}>()
expect(new Uint8Array(row!.body)).toEqual(new Uint8Array([1, 2, 3]))
expect(row!.flag).toBe(1)
})
it('refuses an undefined bind, as D1 does', () => {
expect(() => db.prepare('SELECT ?1').bind(undefined)).toThrow(/undefined/)
})
it('serves raw() rows as arrays', async () => {
await insert(1, 'Alice')
expect(await db.prepare('SELECT account_id, username_lower FROM account').raw()).toEqual([
[1, 'alice'],
])
expect(
await db.prepare('SELECT account_id, username_lower FROM account').raw({ columnNames: true })
).toEqual([
['account_id', 'username_lower'],
[1, 'alice'],
])
})
})
describe('withDatabase', () => {
const d1 = { tag: 'd1' } as unknown as D1Database
it('passes the env through untouched when no libSQL URL is set', async () => {
const env = { DB: d1, NAME: 'x' }
let seen: unknown
const handler = withDatabase({
fetch(_request, e) {
seen = e
return new Response('ok')
},
})
await handler.fetch!(new Request('https://example.com'), env, {} as ExecutionContext)
expect(seen).toBe(env)
})
it('swaps DB for the libSQL adapter when the URL is set, and only once', () => {
const env = { DB: d1, LIBSQL_DB_URL: 'libsql://db-org.turso.io', LIBSQL_DB_AUTH_TOKEN: 't' }
const resolved = resolveDatabaseEnv(env)
expect(resolved).not.toBe(env)
expect(resolved.DB).not.toBe(d1)
expect(typeof resolved.DB.prepare).toBe('function')
expect(resolved.LIBSQL_DB_URL).toBe(env.LIBSQL_DB_URL)
// A nested handler (mono mounting a worker) sees an env this module already built.
expect(resolveDatabaseEnv(resolved)).toBe(resolved)
})
})
+259
View File
@@ -0,0 +1,259 @@
/**
* The shared `recflare` database on a libSQL server (Turso) instead of Cloudflare D1.
*
* D1 is the default and stays fully supported: every worker still binds it as `DB`, local
* dev and vitest run on miniflare's D1, and an operator who never sets `LIBSQL_DB_URL`
* never loads this. Set the URL (and its auth token) and the SAME workers read and write
* a libSQL database instead, through an adapter that presents the D1 API — `prepare`,
* `bind`, `first`, `all`, `run`, `batch` — over `@libsql/client`. Nothing that queries the
* database knows which backend it is on; the 400-odd `db: D1Database` signatures and the
* hand-written SQL behind them are untouched, which is the point of doing it this way.
*
* Both are SQLite, so the schema, the migrations and the SQL carry across as written
* (generated columns, JSON1, `RETURNING`, numbered `?1` placeholders). Two differences
* worth knowing:
* - D1 caps a statement at 100 bound parameters and the code chunks for that
* (`d1-binds.ts`); libSQL's limit is far higher, so the chunking is merely harmless.
* - A D1 `batch` and a libSQL `batch` are both one transaction, all-or-nothing, so the
* call sites that rely on that (`rooms-db.ts`) behave the same.
*
* The import is `@libsql/client/web`, which the Cloudflare tutorial insists on: the
* default entry is the Node build and does not run in workerd. In the web build a
* `libsql://` URL is Hrana over HTTP — stateless, one fetch per statement, no socket held
* between requests — which is the right transport for a Worker, and why the client is
* created per request rather than cached on the isolate.
*
* Migrations go through `runx libsql migrate` (see packages/tools), which `just migrate`
* switches to on its own when the URL is configured; operator tooling (`runx admin`,
* `runx catalog`) follows the same switch in packages/tools/src/d1.ts.
*/
import { createClient } from '@libsql/client/web'
import type { Client, InStatement, InValue, ResultSet } from '@libsql/client/web'
/** The bindings and vars the switch reads. Every D1-backed worker's Env satisfies this. */
export interface DatabaseEnv {
/** The D1 binding — used as-is when no libSQL URL is configured. */
DB: D1Database
/**
* A libSQL server to use INSTEAD of `DB`, e.g. `libsql://<db>-<org>.turso.io`. Set from
* `RECFLARE_LIBSQL_DB_URL` in .env at deploy time (see .env.example). Unset — the usual
* case, and always in local dev and tests — means D1.
*/
LIBSQL_DB_URL?: string
/**
* The server's auth token. Arrives as an encrypted worker secret, which `just deploy`
* sets from `RECFLARE_LIBSQL_DB_AUTH_TOKEN`; never as a plain var.
*/
LIBSQL_DB_AUTH_TOKEN?: string
}
/**
* The database a request should use: the libSQL adapter when `LIBSQL_DB_URL` is set, the
* D1 binding otherwise. Cheap either way — creating the libSQL client does no I/O.
*/
export function openDatabase(env: DatabaseEnv): D1Database {
const url = env.LIBSQL_DB_URL?.trim()
if (!url) return env.DB
const authToken = env.LIBSQL_DB_AUTH_TOKEN?.trim()
return libsqlDatabase(createClient({ url, authToken: authToken || undefined }))
}
/** Envs this module has already produced, so nested handlers (`mono`) don't wrap twice. */
const resolvedEnvs = new WeakSet<object>()
/** `env` with `DB` swapped for whichever database {@link openDatabase} picks. */
export function resolveDatabaseEnv<E extends DatabaseEnv>(env: E): E {
if (resolvedEnvs.has(env) || !env.LIBSQL_DB_URL?.trim()) return env
const resolved: E = { ...env, DB: openDatabase(env) }
resolvedEnvs.add(resolved)
return resolved
}
/** The handler shapes a worker exports: a Hono app, or the `{ fetch, scheduled }` object. */
export interface DatabaseHandler<E extends DatabaseEnv> {
fetch(request: Request, env: E, ctx: ExecutionContext): Response | Promise<Response>
scheduled?(controller: ScheduledController, env: E, ctx: ExecutionContext): void | Promise<void>
queue?(batch: MessageBatch, env: E, ctx: ExecutionContext): void | Promise<void>
}
/**
* What {@link withDatabase} returns: an `ExportedHandler` whose `fetch` is known to exist.
* Declared as a method taking a plain `Request` so a worker that mounts others (`mono`)
* can call it with the request it has, wrapped and unwrapped apps alike.
*/
export interface DatabaseExportedHandler<E> extends ExportedHandler<E> {
fetch(request: Request, env: E, ctx: ExecutionContext): Response | Promise<Response>
}
/**
* Wrap a worker's default export so every handler sees the database
* {@link openDatabase} picks in `env.DB`. This is the ONE place a worker changes to gain
* libSQL support: `export default withDatabase(app)`.
*
* With no `LIBSQL_DB_URL` the env passes through untouched, so a D1 deployment (and every
* test) pays nothing for this.
*/
export function withDatabase<E extends DatabaseEnv>(
handler: DatabaseHandler<E>
): DatabaseExportedHandler<E> {
const fetch = handler.fetch.bind(handler)
const wrapped: DatabaseExportedHandler<E> = {
fetch: (request, env, ctx) => fetch(request, resolveDatabaseEnv(env), ctx),
}
if (handler.scheduled) {
const scheduled = handler.scheduled.bind(handler)
wrapped.scheduled = (controller, env, ctx) =>
scheduled(controller, resolveDatabaseEnv(env), ctx)
}
if (handler.queue) {
const queue = handler.queue.bind(handler)
wrapped.queue = (batch, env, ctx) => queue(batch, resolveDatabaseEnv(env), ctx)
}
return wrapped
}
/** A libSQL client behind the D1 API. Exported so a test can hand it a local client. */
export function libsqlDatabase(client: Client): D1Database {
return new LibsqlDatabase(client)
}
/**
* D1 accepts null, number, string, boolean and ArrayBuffer; libSQL accepts all of those
* (plus a few more). `undefined` is a bug at the call site on either backend — D1 refuses
* it with a type error — so refuse it here with the same clarity instead of letting the
* client turn it into something.
*/
function toInValue(value: unknown, index: number): InValue {
if (value === undefined) {
throw new TypeError(`bound parameter ${index + 1} is undefined (D1 would reject it too)`)
}
return value as InValue
}
/**
* The client's rows carry their columns as named properties already, but they are hrana
* `Row` objects with a hidden `length` and numeric indices. Build plain objects by column
* position instead so a row spreads, serialises and compares like the plain object D1
* hands back — and so a column with no name in the object (a duplicate) is still last-wins
* rather than dropped.
*/
function rowToObject(columns: string[], row: ResultSet['rows'][number]): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) out[columns[i]!] = row[i]
return out
}
function toResult<T>(rs: ResultSet): D1Result<T> {
return {
success: true,
results: rs.rows.map((row) => rowToObject(rs.columns, row) as T),
meta: {
duration: 0,
size_after: 0,
rows_read: 0,
rows_written: 0,
last_row_id: Number(rs.lastInsertRowid ?? 0),
changed_db: rs.rowsAffected > 0,
changes: rs.rowsAffected,
},
}
}
class LibsqlPreparedStatement implements D1PreparedStatement {
constructor(
private readonly client: Client,
private readonly sql: string,
private readonly args: InValue[] = []
) {}
/** What the client runs. `batch` collects these from every statement it is handed. */
toStatement(): InStatement {
return { sql: this.sql, args: this.args }
}
bind(...values: unknown[]): D1PreparedStatement {
return new LibsqlPreparedStatement(this.client, this.sql, values.map(toInValue))
}
private execute(): Promise<ResultSet> {
return this.client.execute(this.toStatement())
}
first<T = unknown>(colName: string): Promise<T | null>
first<T = Record<string, unknown>>(): Promise<T | null>
async first<T>(colName?: string): Promise<T | null> {
const rs = await this.execute()
const row = rs.rows[0]
if (row === undefined) return null
const object = rowToObject(rs.columns, row)
if (colName === undefined) return object as T
if (!rs.columns.includes(colName)) {
throw new Error(`D1_COLUMN_NOTFOUND: column "${colName}" is not in the result`)
}
return object[colName] as T
}
async run<T = Record<string, unknown>>(): Promise<D1Result<T>> {
return toResult<T>(await this.execute())
}
async all<T = Record<string, unknown>>(): Promise<D1Result<T>> {
return toResult<T>(await this.execute())
}
raw<T = unknown[]>(options: { columnNames: true }): Promise<[string[], ...T[]]>
raw<T = unknown[]>(options?: { columnNames?: false }): Promise<T[]>
async raw<T = unknown[]>(options?: { columnNames?: boolean }): Promise<T[] | [string[], ...T[]]> {
const rs = await this.execute()
const rows = rs.rows.map((row) => rs.columns.map((_, i) => row[i]) as T)
return options?.columnNames ? [rs.columns, ...rows] : rows
}
}
class LibsqlDatabase implements D1Database {
constructor(private readonly client: Client) {}
prepare(query: string): D1PreparedStatement {
return new LibsqlPreparedStatement(this.client, query)
}
/** One write transaction, like D1's: any statement failing rolls back the lot. */
async batch<T = unknown>(statements: D1PreparedStatement[]): Promise<Array<D1Result<T>>> {
const results = await this.client.batch(
statements.map((statement) => {
if (!(statement instanceof LibsqlPreparedStatement)) {
throw new TypeError('batch: every statement must come from this database')
}
return statement.toStatement()
}),
'write'
)
return results.map((rs) => toResult<T>(rs))
}
/** A script of `;`-separated statements, as D1's `exec` takes. Nothing here uses it. */
async exec(query: string): Promise<D1ExecResult> {
const started = Date.now()
await this.client.executeMultiple(query)
const count = query.split(';').filter((statement) => statement.trim() !== '').length
return { count, duration: Date.now() - started }
}
/**
* D1 sessions pin reads after a write to a replica that has seen it. A libSQL primary
* is always read-your-writes, so a "session" is the database itself with no bookmark.
*/
withSession(): D1DatabaseSession {
return {
prepare: (query) => this.prepare(query),
batch: (statements) => this.batch(statements),
getBookmark: () => null,
}
}
dump(): Promise<ArrayBuffer> {
return Promise.reject(new Error('dump() is not supported on a libSQL database'))
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
// Node, not the Workers pool: the libSQL adapter is exercised against an in-memory libSQL
// database through the Node client, which workerd can't host. Everything else in this
// package is covered by the workers' own integration tests, on D1.
export default defineConfig({
test: {
environment: 'node',
},
})
+8
View File
@@ -20,6 +20,14 @@ export type SharedHonoEnv = {
* Useful for logs, Sentry, etc.
*/
SENTRY_RELEASE: string
/**
* A libSQL server (Turso) to use INSTEAD of the D1 binding, e.g. `libsql://<db>-<org>.turso.io`.
* Set by `just deploy` from `RECFLARE_LIBSQL_DB_URL`; unset means D1. Read only by
* `withDatabase` in @repo/domain, which swaps `DB` for the matching adapter.
*/
LIBSQL_DB_URL?: string
/** That server's auth token — a worker SECRET `just deploy` sets, never a var. */
LIBSQL_DB_AUTH_TOKEN?: string
}
/** Global Hono variables */
export type SharedHonoVariables = {
+22
View File
@@ -181,6 +181,20 @@ fi
# both a deploy and a local dev server.
EXTRA_VARS=$(recflare_vars)
# Using a libSQL server (Turso) instead of D1: RECFLARE_LIBSQL_DB_URL reaches the worker as the
# LIBSQL_DB_URL var and switches `withDatabase` (@repo/domain) over; the D1 binding stays in
# place, unused, so pointing back at D1 is unsetting the URL and redeploying. The token is NOT
# a var — it is put as an encrypted worker secret after the deploy (secrets survive a deploy,
# vars are replaced wholesale). Both come from .env; see .env.example.
LIBSQL_VARS=""
if [ -n "${RECFLARE_LIBSQL_DB_URL:-}" ]; then
if [ -z "${RECFLARE_LIBSQL_DB_AUTH_TOKEN:-}" ]; then
echo "error: RECFLARE_LIBSQL_DB_URL is set but RECFLARE_LIBSQL_DB_AUTH_TOKEN is not — add the database's auth token to .env (see .env.example)" >&2
exit 1
fi
LIBSQL_VARS="--var LIBSQL_DB_URL:$RECFLARE_LIBSQL_DB_URL"
fi
# Vite-built configs set no_bundle (vite already bundled and minified), which is
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
# Keyed on IS_VITE, not on $CONFIG: the splicing above may have swapped $CONFIG for
@@ -199,6 +213,14 @@ wrangler deploy \
--var DOMAIN:"$DOMAIN" \
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
$EXTRA_VARS \
$LIBSQL_VARS \
--domain "$HOST" \
$MINIFY \
"$@"
# The token, once the worker exists (a first deploy has nothing to attach a secret to). On the
# very first switch to libSQL this leaves a few seconds in which the new code has a URL and no
# token; every later deploy finds the secret already there.
if [ -n "${RECFLARE_LIBSQL_DB_URL:-}" ]; then
printf '%s' "$RECFLARE_LIBSQL_DB_AUTH_TOKEN" | wrangler secret put LIBSQL_DB_AUTH_TOKEN --config "$CONFIG"
fi
+7
View File
@@ -46,6 +46,13 @@ fi
# RECFLARE_D1 comes from the gitignored root .env, or from CI secrets, which win over it.
recflare_load_env
# A libSQL server (Turso) configured in .env takes the remote migrations instead of D1: the
# same files, the same per-worker migrations table, applied by `runx libsql migrate` from
# packages/tools. The D1 database is not touched. `--local` above never comes here.
if [ -n "${RECFLARE_LIBSQL_DB_URL:-}" ]; then
exec runx libsql migrate
fi
DB_ID=${RECFLARE_D1:-}
if [ -z "$DB_ID" ]; then
echo "error: RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)" >&2
+1
View File
@@ -19,6 +19,7 @@
"dependencies": {
"@commander-js/extra-typings": "15.0.0",
"@jahands/cli-tools": "0.11.5",
"@libsql/client": "0.18.0",
"@types/fs-extra": "11.0.4",
"@types/node": "26.0.1",
"@typescript/native-preview": "7.0.0-dev.20260629.1",
+2
View File
@@ -10,6 +10,7 @@ import { checkCmd } from '../cmd/check.cmd'
import { ciCmd } from '../cmd/ci.cmd'
import { devCmd } from '../cmd/dev.cmd'
import { fixCmd } from '../cmd/fix.cmd'
import { libsqlCmd } from '../cmd/libsql.cmd'
import { shfmtCmd } from '../cmd/shfmt.cmd'
import { storefrontCmd } from '../cmd/storefront.cmd'
import { updateCmd } from '../cmd/update.cmd'
@@ -25,6 +26,7 @@ program
.addCommand(catalogCmd)
.addCommand(storefrontCmd)
.addCommand(fixCmd)
.addCommand(libsqlCmd)
.addCommand(buildCmd)
.addCommand(checkCmd)
.addCommand(devCmd)
+74
View File
@@ -0,0 +1,74 @@
import { Command } from '@commander-js/extra-typings'
import { libsqlClient, readLibsqlTarget } from '../d1'
/**
* `runx libsql migrate` — apply a worker's D1 migrations to the libSQL server (Turso)
* configured in .env, which is what `just migrate` runs when RECFLARE_LIBSQL_DB_URL is set
* (see run-wrangler-migrate). `wrangler d1 migrations apply` only knows D1, so this does the
* same job by hand, the same way:
*
* - the files are the worker's `migrations_dir` (from its wrangler.jsonc), `.sql`, applied
* in name order;
* - what has been applied is recorded in the worker's `migrations_table`, created with
* wrangler's exact schema. A D1 database exported and loaded into libSQL therefore
* brings its history along, and this picks up where D1 left off — and a libSQL database
* loaded back into D1 hands wrangler a table it recognises;
* - each file runs in one transaction with its history row, so a failed migration leaves
* nothing half-applied and is simply re-run once fixed.
*
* Run from the worker's directory (turbo does; `--dir` says otherwise).
*/
export const libsqlCmd = new Command('libsql').description(
'Operate on the libSQL (Turso) database configured in .env, in place of D1'
)
libsqlCmd
.command('migrate')
.description("Apply this worker's pending migrations to the configured libSQL server")
.option('--dir <path>', 'the worker directory (defaults to the current one)')
.action(async (opts) => {
const target = await readLibsqlTarget()
if (!target) {
throw new Error('RECFLARE_LIBSQL_DB_URL is not set — nothing to migrate (see .env.example)')
}
const dir = path.resolve(opts.dir ?? process.cwd())
const config = await fs.readFile(path.join(dir, 'wrangler.jsonc'), 'utf8')
const migrationsDir = config.match(/"migrations_dir"\s*:\s*"([^"]+)"/)?.[1] ?? 'migrations'
const table = config.match(/"migrations_table"\s*:\s*"([^"]+)"/)?.[1] ?? 'd1_migrations'
if (!/^\w+$/.test(table)) throw new Error(`unusable migrations_table name: ${table}`)
const files = (await fs.readdir(path.join(dir, migrationsDir)))
.filter((name) => name.endsWith('.sql'))
.sort()
const client = libsqlClient(target)
await client.execute(
`CREATE TABLE IF NOT EXISTS "${table}" (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`
)
const applied = new Set(
(await client.execute(`SELECT name FROM "${table}"`)).rows
.map((row) => row.name)
.filter((name): name is string => typeof name === 'string')
)
const pending = files.filter((name) => !applied.has(name))
const worker = path.basename(dir)
if (pending.length === 0) {
console.log(chalk.dim(`${worker}: no pending migrations (${table})`))
return
}
for (const name of pending) {
const sql = await fs.readFile(path.join(dir, migrationsDir, name), 'utf8')
const tx = await client.transaction('write')
try {
await tx.executeMultiple(sql)
await tx.execute({ sql: `INSERT INTO "${table}" (name) VALUES (?)`, args: [name] })
await tx.commit()
} catch (err) {
await tx.rollback().catch(() => {})
throw new Error(`${worker}: migration ${name} failed: ${String(err)}`)
}
console.log(`${worker}: applied ${chalk.green(name)}`)
}
})
+67
View File
@@ -1,7 +1,11 @@
import 'zx/globals'
import { createClient } from '@libsql/client/web'
import { getRepoRoot } from './path'
import type { Client, ResultSet } from '@libsql/client/web'
/**
* Shared plumbing for talking to the one `recflare` D1 database from a CLI.
*
@@ -13,6 +17,11 @@ import { getRepoRoot } from './path'
* needs the real D1 id, because the committed `wrangler.jsonc` files all carry the literal
* placeholder `"local"` as their `database_id`. Splicing the real id into a gitignored
* generated config is the same thing `run-wrangler-migrate` does at deploy time.
*
* With a libSQL server (Turso) configured — RECFLARE_LIBSQL_DB_URL in .env, see .env.example —
* `--remote` means THAT database, reached directly through `@libsql/client` rather than through
* wrangler, so an operator's `runx admin --remote` acts on the database the deployed workers
* are actually using. `--local` is the local D1 either way.
*/
/** The one shared database every D1-backed worker binds. */
@@ -112,6 +121,10 @@ export async function execSql<Row = Record<string, unknown>>(
remote: boolean,
worker = 'auth'
): Promise<D1ExecResult<Row>> {
if (remote) {
const target = await readLibsqlTarget()
if (target) return libsqlResult<Row>(await libsqlClient(target).execute(sql))
}
const stdout = await runD1(worker, ['--command', sql, '--json'], remote)
// wrangler --json prints a one-element array of results to stdout.
const start = stdout.indexOf('[')
@@ -130,5 +143,59 @@ export async function execSql<Row = Record<string, unknown>>(
* which is megabytes of nothing. The human-readable output is returned for the caller.
*/
export async function execSqlFile(file: string, remote: boolean, worker = 'auth'): Promise<string> {
if (remote) {
const target = await readLibsqlTarget()
if (target) {
// One script in one request. Fine for the loads this is used for; a file too large for
// the server's request limit would have to be split by statement first.
await libsqlClient(target).executeMultiple(await fs.readFile(file, 'utf8'))
return `applied ${path.basename(file)} to ${new URL(target.url).host}`
}
}
return await runD1(worker, ['--file', file], remote)
}
/** Where `--remote` goes when the deployment runs on libSQL instead of D1. */
export interface LibsqlTarget {
url: string
authToken?: string
}
/**
* The libSQL target from RECFLARE_LIBSQL_DB_URL / RECFLARE_LIBSQL_DB_AUTH_TOKEN, or undefined
* when the deployment is on D1 (the URL unset). Same precedence as every other setting:
* process environment first, then the root .env.
*/
export async function readLibsqlTarget(): Promise<LibsqlTarget | undefined> {
const url = await readRootEnv('RECFLARE_LIBSQL_DB_URL')
if (!url) return undefined
return { url, authToken: await readRootEnv('RECFLARE_LIBSQL_DB_AUTH_TOKEN') }
}
let libsql: { url: string; client: Client } | undefined
/**
* A client for the target, made once per process. The web build of the client is used on
* purpose: it speaks Hrana over HTTP with nothing native to install, and a CLI only ever
* talks to a remote server.
*/
export function libsqlClient(target: LibsqlTarget): Client {
if (libsql?.url !== target.url) {
console.error(chalk.magenta(`→ libSQL ${new URL(target.url).host}`))
libsql = {
url: target.url,
client: createClient({ url: target.url, authToken: target.authToken }),
}
}
return libsql.client
}
/** The client's rows as the plain objects wrangler's `--json` output gives. */
function libsqlResult<Row>(rs: ResultSet): D1ExecResult<Row> {
const results = rs.rows.map((row) => {
const out: Record<string, unknown> = {}
rs.columns.forEach((column, i) => (out[column] = row[i]))
return out as Row
})
return { results, success: true, meta: { changes: rs.rowsAffected } }
}
+5 -2
View File
@@ -12,8 +12,11 @@
# and `just dev`, so a value is never configured twice.
# Names the deploy scripts consume themselves — the domain and the ids of the operator's
# Cloudflare resources. Everything else in .env is worker config; see recflare_vars.
RECFLARE_RESERVED="DOMAIN SUBDOMAINS D1 KV SECRETS_STORE ENV_LOADED"
# Cloudflare resources. The two LIBSQL_DB_* entries are reserved too: they choose the database (see .env.example) and the deploy script hands them
# over itself — the URL as a var, the token as an encrypted worker SECRET — so neither may
# ride along as a plain `--var`, nor reach a local dev server, which stays on the local D1.
# Everything else in .env is worker config; see recflare_vars.
RECFLARE_RESERVED="DOMAIN SUBDOMAINS D1 KV SECRETS_STORE LIBSQL_DB_URL LIBSQL_DB_AUTH_TOKEN ENV_LOADED"
# Load the root .env, letting anything already in the environment win. The file is a local
# convenience; CI exports the same names as secrets and must not be clobbered by a stray