mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
turso migration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
})
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user