mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
feat(pwa): move app.logicsrc.com from Turso/libSQL to Postgres via @profullstack/libsql-pg
Production reads DATABASE_URL (postgres://) through @profullstack/libsql-pg, which keeps the @libsql/client surface; no query changed. A missing, non-Postgres or leftover libsql:// URL fails at boot in production. Dev and tests keep libSQL (file:/:memory:) as a devDependency. Postgres migrations in src/migrations-pg/ (converted with libsql-pg convert-schema, same file names so the copied _migrations ledger matches); migrate.mjs picks the dialect. Tests run against Postgres with PWA_TEST_DATABASE_URL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c535aaa32c
commit
9e16923a89
12 changed files with 507 additions and 29 deletions
|
|
@ -36,9 +36,12 @@ export const config = {
|
|||
rpName: "LogicSRC",
|
||||
sessionSecret: process.env.SESSION_SECRET || "dev-insecure-secret-change-me",
|
||||
db: {
|
||||
// Turso (libSQL) in prod; a local file for dev. TURSO_* takes precedence.
|
||||
url: process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./data/local.db",
|
||||
authToken: process.env.TURSO_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN || undefined,
|
||||
// Postgres (postgres://) in production, through @profullstack/libsql-pg; a
|
||||
// local libSQL file or `:memory:` for dev and tests. DATABASE_URL wins. A
|
||||
// leftover TURSO_DATABASE_URL is still read so that db.mjs can refuse it
|
||||
// with a message that names it, rather than silently opening a file db.
|
||||
url: process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL || "file:./data/local.db",
|
||||
authToken: process.env.DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || undefined,
|
||||
},
|
||||
resend: {
|
||||
apiKey: process.env.RESEND_API_KEY || "",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,58 @@
|
|||
// libSQL (SQLite / Turso) client + a tiny query helper.
|
||||
import { createClient } from "@libsql/client";
|
||||
// Database client + a tiny query helper.
|
||||
//
|
||||
// Production runs on Postgres through @profullstack/libsql-pg, which keeps the
|
||||
// @libsql/client surface (execute / batch / rows / rowsAffected) over a `pg`
|
||||
// pool and rewrites the SQLite idioms in our statements per query, so no
|
||||
// caller changed when the app left Turso. Development and the test suite keep
|
||||
// using libSQL itself (a local file or `:memory:`), which is a devDependency.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createClient as createPgClient } from "@profullstack/libsql-pg";
|
||||
import { config } from "./config.mjs";
|
||||
|
||||
// For a local file: url, make sure the directory exists.
|
||||
if (config.db.url.startsWith("file:")) {
|
||||
const p = config.db.url.slice("file:".length);
|
||||
const dir = path.dirname(path.resolve(config.root, p));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const POSTGRES = /^postgres(ql)?:\/\//i;
|
||||
|
||||
/** True when `url` is a Postgres DSN (what production must use). */
|
||||
export const isPostgresUrl = (url) => POSTGRES.test(url);
|
||||
|
||||
/**
|
||||
* Fail fast on a database URL the app cannot run on. A missing or non-Postgres
|
||||
* URL in production is a deploy error, not a condition to limp along under:
|
||||
* there is no fallback to a file database there.
|
||||
*/
|
||||
export function assertDatabaseUrl(url = config.db.url, env = config.env) {
|
||||
if (isPostgresUrl(url)) return url;
|
||||
if (/^libsql:/i.test(url) || /\.turso\.io/i.test(url)) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be a postgres:// URL. A libsql:// (Turso) URL was found" +
|
||||
(process.env.TURSO_DATABASE_URL ? " in TURSO_DATABASE_URL" : "") +
|
||||
"; the app moved to Postgres. Copy the data with `npx libsql-pg copy` and unset TURSO_*.",
|
||||
);
|
||||
}
|
||||
if (env === "production") {
|
||||
throw new Error(`DATABASE_URL must be a postgres:// URL in production, got "${url.split(":")[0]}:"`);
|
||||
}
|
||||
if (url === ":memory:" || url.startsWith("file:")) return url;
|
||||
throw new Error(`Unsupported DATABASE_URL "${url.split(":")[0]}:": use postgres://, file: or :memory:`);
|
||||
}
|
||||
|
||||
export const db = createClient({ url: config.db.url, authToken: config.db.authToken });
|
||||
async function open() {
|
||||
const url = assertDatabaseUrl();
|
||||
if (isPostgresUrl(url)) return createPgClient({ url, dialect: "sqlite" });
|
||||
|
||||
// Local libSQL for dev and tests only (devDependency, never loaded in production).
|
||||
if (url.startsWith("file:")) {
|
||||
const dir = path.dirname(path.resolve(config.root, url.slice("file:".length)));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const { createClient } = await import("@libsql/client");
|
||||
return createClient({ url, authToken: config.db.authToken });
|
||||
}
|
||||
|
||||
export const db = await open();
|
||||
|
||||
/** True when the live client talks to Postgres. */
|
||||
export const isPostgres = isPostgresUrl(config.db.url);
|
||||
|
||||
/** Run a statement; returns the raw result. */
|
||||
export const run = (sql, args = []) => db.execute({ sql, args });
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
// Apply SQL migrations in order. Idempotent — tracks applied files in _migrations.
|
||||
//
|
||||
// Two copies of every migration exist, one per dialect, with the SAME file
|
||||
// names: `migrations/` (SQLite, for the local dev/test database) and
|
||||
// `migrations-pg/` (Postgres, generated with `npx libsql-pg convert-schema`
|
||||
// and reviewed). The ledger is keyed by file name, so a database copied from
|
||||
// Turso with `libsql-pg copy` carries its `_migrations` rows across and the
|
||||
// Postgres side recognises them as applied.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { db, run, all } from "./db.mjs";
|
||||
import { db, run, all, isPostgres } from "./db.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIR = path.join(HERE, "migrations");
|
||||
export const MIGRATIONS_DIR = path.join(HERE, isPostgres ? "migrations-pg" : "migrations");
|
||||
const DIR = MIGRATIONS_DIR;
|
||||
|
||||
export async function migrate() {
|
||||
// Bootstrap the tracking table (the first migration also declares it IF NOT EXISTS).
|
||||
|
|
@ -16,7 +24,7 @@ export async function migrate() {
|
|||
for (const file of files) {
|
||||
if (done.has(file)) { console.log(`· ${file} (already applied)`); continue; }
|
||||
const sql = fs.readFileSync(path.join(DIR, file), "utf8");
|
||||
// libSQL executes one statement per call — split on semicolons at line ends.
|
||||
// One statement per call (libSQL requires it; the Postgres shim binds per statement) — split on semicolons at line ends.
|
||||
const statements = sql.split(/;\s*(?:\n|$)/).map((s) => s.trim()).filter(Boolean);
|
||||
for (const stmt of statements) await run(stmt);
|
||||
await run(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`, [file, Date.now()]);
|
||||
|
|
|
|||
53
apps/pwa/src/migrations-pg/001_auth.sql
Normal file
53
apps/pwa/src/migrations-pg/001_auth.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
|
||||
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
|
||||
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
|
||||
|
||||
create table if not exists users (
|
||||
id text PRIMARY KEY,
|
||||
email text UNIQUE,
|
||||
password_hash text,
|
||||
coinpay_sub text UNIQUE,
|
||||
display_name text,
|
||||
created_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists webauthn_credentials (
|
||||
id text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key text NOT NULL,
|
||||
counter bigint NOT NULL DEFAULT 0,
|
||||
transports text,
|
||||
created_at bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
|
||||
|
||||
create table if not exists sessions (
|
||||
token text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at bigint NOT NULL,
|
||||
expires_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists api_keys (
|
||||
id text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text,
|
||||
token_hash text NOT NULL,
|
||||
prefix text NOT NULL,
|
||||
created_at bigint NOT NULL,
|
||||
last_used_at bigint
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apikeys_user ON api_keys(user_id);
|
||||
|
||||
create table if not exists cli_auth_codes (
|
||||
code text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_challenge text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
name text,
|
||||
used bigint NOT NULL DEFAULT 0,
|
||||
created_at bigint NOT NULL,
|
||||
expires_at bigint NOT NULL
|
||||
);
|
||||
89
apps/pwa/src/migrations-pg/002_credshare.sql
Normal file
89
apps/pwa/src/migrations-pg/002_credshare.sql
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
|
||||
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
|
||||
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
|
||||
|
||||
create table if not exists credshare_keys (
|
||||
user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key text NOT NULL,
|
||||
updated_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists credshare_teams (
|
||||
id text PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
created_by text NOT NULL REFERENCES users(id),
|
||||
created_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists credshare_members (
|
||||
id text PRIMARY KEY,
|
||||
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
user_id text REFERENCES users(id) ON DELETE SET NULL,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member',
|
||||
status text NOT NULL DEFAULT 'invited',
|
||||
invited_by text REFERENCES users(id),
|
||||
joined_at bigint,
|
||||
created_at bigint NOT NULL,
|
||||
UNIQUE(team_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_members_team ON credshare_members(team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_members_user ON credshare_members(user_id);
|
||||
|
||||
create table if not exists credshare_invites (
|
||||
id text PRIMARY KEY,
|
||||
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member',
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
created_by text NOT NULL REFERENCES users(id),
|
||||
expires_at bigint NOT NULL,
|
||||
accepted_at bigint,
|
||||
created_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists credshare_vaults (
|
||||
id text PRIMARY KEY,
|
||||
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_by text NOT NULL REFERENCES users(id),
|
||||
created_at bigint NOT NULL,
|
||||
UNIQUE(team_id, name)
|
||||
);
|
||||
|
||||
create table if not exists credshare_vault_grants (
|
||||
vault_id text NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
wrapped_dek text NOT NULL,
|
||||
granted_by text NOT NULL REFERENCES users(id),
|
||||
created_at bigint NOT NULL,
|
||||
PRIMARY KEY (vault_id, user_id)
|
||||
);
|
||||
|
||||
create table if not exists credshare_secrets (
|
||||
vault_id text NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
nonce text NOT NULL,
|
||||
ciphertext text NOT NULL,
|
||||
fingerprint text NOT NULL,
|
||||
version bigint NOT NULL,
|
||||
updated_by text NOT NULL REFERENCES users(id),
|
||||
updated_at bigint NOT NULL,
|
||||
PRIMARY KEY (vault_id, name)
|
||||
);
|
||||
|
||||
create table if not exists credshare_audit (
|
||||
id text PRIMARY KEY,
|
||||
team_id text,
|
||||
vault_id text,
|
||||
actor_user_id text NOT NULL REFERENCES users(id),
|
||||
action text NOT NULL,
|
||||
key_name text,
|
||||
fingerprint text,
|
||||
created_at bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_audit_vault ON credshare_audit(vault_id, created_at DESC);
|
||||
16
apps/pwa/src/migrations-pg/003_cli_device_codes.sql
Normal file
16
apps/pwa/src/migrations-pg/003_cli_device_codes.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
|
||||
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
|
||||
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
|
||||
|
||||
create table if not exists cli_device_codes (
|
||||
device_code_hash text PRIMARY KEY,
|
||||
user_code text NOT NULL UNIQUE,
|
||||
user_id text REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at bigint NOT NULL,
|
||||
expires_at bigint NOT NULL,
|
||||
last_polled_at bigint
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_device_user_code ON cli_device_codes(user_code);
|
||||
Loading…
Add table
Add a link
Reference in a new issue