From dce73569b5c915545c7ee50ddf6a5cb1cd04870d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:05:22 +0000 Subject: [PATCH 1/2] feat(runtime,cli): unify default database resolution across dev/start/migrate (#6469) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- packages/cli/src/commands/db/clean.ts | 18 +- .../cli/src/commands/dev-default-db.test.ts | 100 ++++-- packages/cli/src/commands/dev.ts | 85 +++-- packages/cli/src/commands/start.ts | 58 +++- .../unified-db-resolution.pin.test.ts | 159 +++++++++ .../cli/src/utils/sqlite-occupancy.test.ts | 3 +- packages/cli/src/utils/sqlite-occupancy.ts | 15 +- packages/runtime/src/index.ts | 15 + .../src/resolve-project-database.test.ts | 310 ++++++++++++++++++ .../runtime/src/resolve-project-database.ts | 300 +++++++++++++++++ packages/runtime/src/standalone-stack.ts | 105 ++++-- 11 files changed, 1054 insertions(+), 114 deletions(-) create mode 100644 packages/cli/src/commands/unified-db-resolution.pin.test.ts create mode 100644 packages/runtime/src/resolve-project-database.test.ts create mode 100644 packages/runtime/src/resolve-project-database.ts diff --git a/packages/cli/src/commands/db/clean.ts b/packages/cli/src/commands/db/clean.ts index 0a41a3dc62..a5e0500a5e 100644 --- a/packages/cli/src/commands/db/clean.ts +++ b/packages/cli/src/commands/db/clean.ts @@ -4,7 +4,6 @@ import { Command, Flags } from '@oclif/core'; import { statSync, existsSync } from 'node:fs'; import chalk from 'chalk'; import { printError } from '../../utils/format.js'; -import { resolveDefaultDevDbUrl } from '../dev.js'; import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js'; /** @@ -24,13 +23,13 @@ export default class DbClean extends Command { static override examples = [ '$ os db clean', - '$ os db clean --database file:./.objectstack/data/dev.db', + '$ os db clean --database file:./.objectstack/data/objectstack.db', ]; static override flags = { database: Flags.string({ char: 'd', - description: 'SQLite database URL/path (defaults to $OS_DATABASE_URL, then the per-project dev DB)', + description: 'SQLite database URL/path (defaults to $OS_DATABASE_URL, then the project database via the shared #6469 resolution)', env: 'OS_DATABASE_URL', }), }; @@ -38,10 +37,15 @@ export default class DbClean extends Command { async run(): Promise { const { flags } = await this.parse(DbClean); - const raw = - flags.database?.trim() || - resolveDefaultDevDbUrl({ env: process.env, cwd: process.cwd() }) || - ''; + // The ONE shared resolution (#6469): the file this cleans is the file + // `os dev` / `os start` / `os migrate` actually use in this directory — + // including a legacy `dev.db` / `standalone.db` still being compat-read. + const { resolveProjectDatabaseUrl } = await import('@objectstack/runtime'); + const resolved = flags.database?.trim() + ? undefined + : resolveProjectDatabaseUrl({ env: process.env, projectRoot: process.cwd() }); + if (resolved?.notice) console.log(chalk.yellow(`⚠ ${resolved.notice}`)); + const raw = flags.database?.trim() || resolved?.url || ''; const primary = raw.replace(/^file:/i, '').replace(/^sqlite:/i, ''); if (!primary || primary === ':memory:' || primary.startsWith(':')) { diff --git a/packages/cli/src/commands/dev-default-db.test.ts b/packages/cli/src/commands/dev-default-db.test.ts index 0c3628be42..a9c50c6362 100644 --- a/packages/cli/src/commands/dev-default-db.test.ts +++ b/packages/cli/src/commands/dev-default-db.test.ts @@ -1,48 +1,86 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +/** + * `objectstack dev` database resolution — the dev seam of the ONE shared + * resolution (#6469). + * + * This file used to pin `dev`'s OWN default (`.objectstack/data/dev.db`) — + * which is exactly how the three-way default fork lived: each command's test + * pinned its own filename and nothing asserted they agree. The maintainer + * ruling (#6469, 2026-08-08) unified the default on `objectstack.db`, so this + * file now pins the dev seam's mapping onto `resolveProjectDatabaseUrl`; + * cross-command agreement is pinned by `unified-db-resolution.pin.test.ts`. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'path'; -import { resolveDefaultDevDbUrl } from './dev.js'; +import { resolveDevDatabase } from './dev.js'; + +// The dev seam lazy-imports @objectstack/runtime (oclif startup weight, #5726); +// the FIRST import cold-loads that graph and can exceed vitest's 5s default on +// a cold worker — warm it once, like standalone-stack.test.ts's BOOT_TIMEOUT. +beforeAll(async () => { await import('@objectstack/runtime'); }, 60_000); + +let cwd: string; +beforeEach(() => { cwd = mkdtempSync(path.join(tmpdir(), 'os-dev-db-')); }); +afterEach(() => { try { rmSync(cwd, { recursive: true, force: true }); } catch { /* noop */ } }); + +const unified = () => `file:${path.join(cwd, '.objectstack', 'data', 'objectstack.db')}`; -const CWD = '/proj/app'; -const FILE = `file:${path.join(CWD, '.objectstack', 'data', 'dev.db')}`; +describe('resolveDevDatabase — objectstack dev persists by default (#6469 unified)', () => { + it('defaults to the unified project-anchored sqlite file when nothing else is chosen', async () => { + const r = await resolveDevDatabase({ env: {}, cwd }); + expect(r).toEqual({ url: unified(), source: 'unified-default' }); + }); + + it('yields to an explicit --database flag (normalized: sqlite:// → file:)', async () => { + expect(await resolveDevDatabase({ databaseFlag: 'postgres://x', env: {}, cwd })) + .toEqual({ url: 'postgres://x', source: 'explicit' }); + expect(await resolveDevDatabase({ databaseFlag: 'sqlite:///abs/flag.db', env: {}, cwd })) + .toEqual({ url: 'file:/abs/flag.db', source: 'explicit' }); + }); -describe('resolveDefaultDevDbUrl — objectstack dev persists by default', () => { - it('defaults to a project-anchored sqlite file when nothing else is chosen', () => { - expect(resolveDefaultDevDbUrl({ env: {}, cwd: CWD })).toBe(FILE); + it('yields to --fresh (its own ephemeral temp DB, unified filename)', async () => { + const r = await resolveDevDatabase({ freshDbUrl: 'file:/tmp/x/objectstack.db', env: {}, cwd }); + expect(r).toEqual({ url: 'file:/tmp/x/objectstack.db', source: 'explicit' }); }); - it('yields to an explicit --database flag', () => { - expect( - resolveDefaultDevDbUrl({ databaseFlag: 'postgres://x', env: {}, cwd: CWD }), - ).toBeUndefined(); + it('yields to OS_DATABASE_URL / DATABASE_URL / TURSO_DATABASE_URL env', async () => { + expect(await resolveDevDatabase({ env: { OS_DATABASE_URL: 'file:./custom.db' }, cwd })) + .toEqual({ url: 'file:./custom.db', source: 'env' }); + expect(await resolveDevDatabase({ env: { DATABASE_URL: 'libsql://x' }, cwd })) + .toEqual({ url: 'libsql://x', source: 'env' }); + expect(await resolveDevDatabase({ env: { TURSO_DATABASE_URL: 'libsql://t' }, cwd })) + .toEqual({ url: 'libsql://t', source: 'env' }); }); - it('yields to --fresh (its own ephemeral temp DB)', () => { - expect( - resolveDefaultDevDbUrl({ freshDbUrl: 'file:/tmp/x/dev.db', env: {}, cwd: CWD }), - ).toBeUndefined(); + it('respects an explicit in-memory driver opt-out (no file default imposed)', async () => { + expect(await resolveDevDatabase({ databaseDriverFlag: 'memory', env: {}, cwd })) + .toEqual({ url: 'memory://', source: 'memory-driver' }); + expect(await resolveDevDatabase({ env: { OS_DATABASE_DRIVER: 'memory' }, cwd })) + .toEqual({ url: 'memory://', source: 'memory-driver' }); }); - it('yields to OS_DATABASE_URL / DATABASE_URL env', () => { - expect( - resolveDefaultDevDbUrl({ env: { OS_DATABASE_URL: 'file:./custom.db' }, cwd: CWD }), - ).toBeUndefined(); - expect( - resolveDefaultDevDbUrl({ env: { DATABASE_URL: 'libsql://x' }, cwd: CWD }), - ).toBeUndefined(); + it('treats blank env values as unset (still defaults to the unified file)', async () => { + expect((await resolveDevDatabase({ env: { OS_DATABASE_URL: ' ' }, cwd })).url).toBe(unified()); }); - it('respects an explicit in-memory driver opt-out', () => { - expect( - resolveDefaultDevDbUrl({ databaseDriverFlag: 'memory', env: {}, cwd: CWD }), - ).toBeUndefined(); - expect( - resolveDefaultDevDbUrl({ env: { OS_DATABASE_DRIVER: 'memory' }, cwd: CWD }), - ).toBeUndefined(); + it('compat-reads a legacy dev.db with one loud notice — never silent, never a prompt', async () => { + const dataDir = path.join(cwd, '.objectstack', 'data'); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(path.join(dataDir, 'dev.db'), ''); + const r = await resolveDevDatabase({ env: {}, cwd }); + expect(r.url).toBe(`file:${path.join(dataDir, 'dev.db')}`); + expect(r.source).toBe('legacy-file'); + expect(r.notice).toContain('dev.db'); + expect(r.notice).toContain('objectstack.db'); }); - it('treats blank env values as unset (still defaults to file)', () => { - expect(resolveDefaultDevDbUrl({ env: { OS_DATABASE_URL: ' ' }, cwd: CWD })).toBe(FILE); + it('honours OS_HOME for the state dir (start/migrate already did; dev now agrees)', async () => { + const osHome = path.join(cwd, 'oshome'); + const r = await resolveDevDatabase({ env: { OS_HOME: osHome }, cwd }); + expect(r.url).toBe(`file:${path.join(osHome, 'data', 'objectstack.db')}`); }); }); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 5046a2fbd6..9f55749019 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -16,38 +16,43 @@ import { formatMtimeGap, } from '../utils/dev-restart.js'; import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types'; +import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime'; /** - * Resolve the persistent default database URL for `objectstack dev`. + * Resolve the database URL for `objectstack dev` — dev's flag surface mapped + * onto the ONE shared resolution (`resolveProjectDatabaseUrl`, #6469) that + * `os start` and `os migrate` resolve through too. Priority: `--database` / + * `--fresh`'s ephemeral file → `OS_DATABASE_URL` / `DATABASE_URL` / + * `TURSO_DATABASE_URL` → explicit in-memory driver (`--database-driver memory` + * / `OS_DATABASE_DRIVER=memory`) → the config-declared default datasource → + * the unified default `/data/objectstack.db` (legacy `dev.db` / + * `standalone.db` still compat-read, with the loud `notice` line). * - * `dev` should keep your work between restarts — the historical serve default + * `dev` keeps a persistent default on purpose — the historical serve default * of `:memory:` wipes all data (and AI-authored metadata) on every restart, - * which makes local app-building unusable. So when the user has NOT chosen a - * database another way, default to a project-anchored sqlite file at - * `/.objectstack/data/dev.db` (gitignored, per-project). + * which makes local app-building unusable. * - * Returns `undefined` (i.e. "don't impose a default") when the user already - * selected a database, so the existing resolution wins: - * - `--database ` flag - * - `--fresh` (its own ephemeral temp DB) - * - `OS_DATABASE_URL` / `DATABASE_URL` env - * - an explicit in-memory driver (`--database-driver memory` or - * `OS_DATABASE_DRIVER=memory`) + * This wrapper is dev's ONE resolution seam (pinned, together with start's and + * migrate's, by `unified-db-resolution.pin.test.ts`): it maps inputs, it never + * re-implements any fallback. The runtime import is lazy so oclif's + * import-every-command startup (#5726) does not pay for the runtime graph. */ -export function resolveDefaultDevDbUrl(opts: { +export async function resolveDevDatabase(opts: { databaseFlag?: string; freshDbUrl?: string; databaseDriverFlag?: string; env: Record; cwd: string; -}): string | undefined { - if (opts.databaseFlag || opts.freshDbUrl) return undefined; - const envDbUrl = (opts.env.OS_DATABASE_URL ?? opts.env.DATABASE_URL)?.trim(); - if (envDbUrl) return undefined; - const forcedMemory = - opts.databaseDriverFlag === 'memory' || opts.env.OS_DATABASE_DRIVER?.trim() === 'memory'; - if (forcedMemory) return undefined; - return `file:${path.join(opts.cwd, '.objectstack', 'data', 'dev.db')}`; + artifactPath?: string; +}): Promise { + const { resolveProjectDatabaseUrl } = await import('@objectstack/runtime'); + return resolveProjectDatabaseUrl({ + explicitUrl: opts.databaseFlag ?? opts.freshDbUrl, + explicitDriver: opts.databaseDriverFlag, + env: opts.env, + projectRoot: opts.cwd, + artifactPath: opts.artifactPath, + }); } export default class Dev extends Command { @@ -228,7 +233,7 @@ export default class Dev extends Command { // Creates a unique scratch dir that owns the state this command can // actually place, and nothing more. What it covers, exactly: // - the dev SQLite DB the CLI resolves for the run - // (OS_HOME → /data/dev.db, published as OS_DATABASE_URL), + // (OS_HOME → /data/objectstack.db, published as OS_DATABASE_URL), // - the storage-service uploads root, published on the settings // service's own env name OS_STORAGE_LOCAL_ROOT (#4968), // - any other state a plugin keys off OS_HOME. @@ -262,7 +267,9 @@ export default class Dev extends Command { if (flags.fresh) { freshHome = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-dev-')); fs.mkdirSync(path.join(freshHome, 'data'), { recursive: true }); - freshDbUrl = `file:${path.join(freshHome, 'data', 'dev.db')}`; + // The unified default filename (#6469) — the same name every command + // resolves, just anchored on this run's ephemeral OS_HOME. + freshDbUrl = `file:${path.join(freshHome, 'data', 'objectstack.db')}`; freshStorageRoot = path.join(freshHome, 'uploads'); fs.mkdirSync(freshStorageRoot, { recursive: true }); printKV('Fresh OS_HOME', freshHome, '🧪'); @@ -292,23 +299,31 @@ export default class Dev extends Command { // idempotent (empty-DB only) and never overwrites an existing account. const seedAdmin = flags['seed-admin'] ?? true; - // Default `dev` to a PERSISTENT, project-anchored sqlite database so - // AI-authored metadata and records survive restarts. The historical - // serve default is `:memory:`, which silently wipes everything on every - // restart — fine for throwaway demos, but it makes local app-building - // unusable (build an app, restart, it's gone). See {@link resolveDefaultDevDbUrl} - // for the opt-out matrix (--fresh / --database / OS_DATABASE_URL / memory driver). - const defaultDevDb = resolveDefaultDevDbUrl({ + // Resolve the database through the ONE shared resolution (#6469) — + // `os dev`, `os start` and `os migrate` all land on the same URL for the + // same project directory. `dev` keeps a PERSISTENT default on purpose: + // the historical serve default is `:memory:`, which silently wipes + // everything on every restart — fine for throwaway demos, but it makes + // local app-building unusable (build an app, restart, it's gone). See + // {@link resolveDevDatabase} for the priority ladder. + const resolvedDb = await resolveDevDatabase({ databaseFlag: flags.database, freshDbUrl, databaseDriverFlag: flags['database-driver'], env: process.env, cwd: process.cwd(), + artifactPath, }); - if (defaultDevDb) { - fs.mkdirSync(path.dirname(defaultDevDb.replace(/^file:/, '')), { recursive: true }); + if (resolvedDb.notice) { + // Legacy-file compat-read — one loud line naming the file being read + // and how to converge on the unified default. Never an interactive + // prompt (CI-safe), never silent (#6469). + console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`)); } - const effectiveDb = flags.database ?? freshDbUrl ?? defaultDevDb; + if (resolvedDb.source === 'unified-default') { + fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true }); + } + const effectiveDb = resolvedDb.url; const localEnv: NodeJS.ProcessEnv = { ...process.env, OS_ENVIRONMENT_ID: environmentId, @@ -318,14 +333,14 @@ export default class Dev extends Command { ...(seedAdmin && flags['admin-password'] ? { OS_SEED_ADMIN_PASSWORD: flags['admin-password'] } : {}), ...(freshHome ? { OS_HOME: freshHome } : {}), ...(freshStorageRoot ? { OS_STORAGE_LOCAL_ROOT: freshStorageRoot } : {}), - ...(effectiveDb ? { OS_DATABASE_URL: effectiveDb } : {}), + OS_DATABASE_URL: effectiveDb, ...(flags['database-driver'] ? { OS_DATABASE_DRIVER: flags['database-driver'] } : {}), ...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}), ...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}), }; printKV('Environment ID', environmentId, '🎯'); printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦'); - if (effectiveDb) printKV('Database', redactConnectionUrl(effectiveDb), '🗄️'); + printKV('Database', redactConnectionUrl(effectiveDb), '🗄️'); const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }); const binPath = process.argv[1]; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 28ea008fc4..d7ecd9d5f4 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -11,6 +11,7 @@ import path from 'path'; import { printHeader, printKV, printStep, printError } from '../utils/format.js'; import { redactConnectionUrl } from '../utils/connection-display.js'; import { readEnvWithDeprecation } from '@objectstack/types'; +import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime'; /** * `objectstack start` — zero-config quick boot. @@ -187,10 +188,24 @@ export default class Start extends Command { } // ── Database resolution ───────────────────────────────────────── - // Priority: --database > $OS_DATABASE_URL > $DATABASE_URL (legacy) > file:/data/objectstack.db - const databaseUrl = flags.database - ?? readEnvWithDeprecation('OS_DATABASE_URL', 'DATABASE_URL', { silent: true }) - ?? `file:${path.join(homeDir, 'data', 'objectstack.db')}`; + // The ONE shared resolution (#6469) — `os dev` / `os start` / `os migrate` + // land on the same URL for the same project directory. Priority: + // --database > $OS_DATABASE_URL / $DATABASE_URL / $TURSO_DATABASE_URL > + // explicit memory driver > config-declared default datasource > + // file:/data/objectstack.db (legacy dev.db / standalone.db still + // compat-read, with a loud notice). + const resolvedDb = await resolveStartDatabase({ + databaseFlag: flags.database, + databaseDriverFlag: flags['database-driver'], + env: process.env, + homeDir, + projectRoot: cwd, + artifactPath: artifactSource?.path, + }); + if (resolvedDb.notice) { + console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`)); + } + const databaseUrl = resolvedDb.url; const environmentId = flags['environment-id'] ?? process.env.OS_ENVIRONMENT_ID @@ -274,6 +289,41 @@ export default class Start extends Command { } } +/** + * Resolve the database URL for `objectstack start` — start's flag surface + * mapped onto the ONE shared resolution (`resolveProjectDatabaseUrl`, #6469) + * that `os dev` and `os migrate` resolve through too. + * + * `homeDir` is start's already-resolved home (`--home` > `$OS_HOME` > + * `/.objectstack` in project mode > `~/.objectstack`), so it is passed as + * the pre-resolved state dir — the same directory the other commands derive + * from `OS_HOME` / the project root, which is what makes the three answers + * identical (pinned by `unified-db-resolution.pin.test.ts`). + * + * This wrapper is start's ONE resolution seam: it maps inputs, it never + * re-implements any fallback. The runtime import is lazy so oclif's + * import-every-command startup (#5726) does not pay for the runtime graph. + */ +export async function resolveStartDatabase(opts: { + databaseFlag?: string; + databaseDriverFlag?: string; + env: Record; + homeDir: string; + /** The project root (start's cwd) — anchors a config-declared relative sqlite filename. */ + projectRoot?: string; + artifactPath?: string; +}): Promise { + const { resolveProjectDatabaseUrl } = await import('@objectstack/runtime'); + return resolveProjectDatabaseUrl({ + explicitUrl: opts.databaseFlag, + explicitDriver: opts.databaseDriverFlag, + env: opts.env, + homeDir: opts.homeDir, + projectRoot: opts.projectRoot, + artifactPath: opts.artifactPath, + }); +} + function resolveHome( flagValue: string | undefined, opts: { hasProjectConfig: boolean; cwd: string }, diff --git a/packages/cli/src/commands/unified-db-resolution.pin.test.ts b/packages/cli/src/commands/unified-db-resolution.pin.test.ts new file mode 100644 index 0000000000..b81c4d174f --- /dev/null +++ b/packages/cli/src/commands/unified-db-resolution.pin.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE #6469 PIN: `os dev`, `os start` and `os migrate` resolve the SAME + * database URL for the same project root, in EVERY fallback state. + * + * This is the test whose absence let the three-way default fork live: + * `dev-default-db.test.ts` pinned dev's own filename (`dev.db`), nothing + * pinned start's (`objectstack.db`) or migrate's (`standalone.db`), and no + * test compared them — so `os migrate plan` could open a fresh empty + * `standalone.db` of its own making and report every table of a healthy + * `objectstack.db` deployment as "to create" (the inverted failure + * direction, issue #6469's measured harm). + * + * Each case drives the three commands' REAL resolution seams — + * `resolveDevDatabase` (dev.ts), `resolveStartDatabase` (start.ts) and + * `resolveStandaloneDatabase` (@objectstack/runtime, the `os migrate` boot and + * occupancy-probe seam) — with the inputs those commands pass at runtime. If + * any command grows its own fallback again, its seam diverges here and this + * file goes red. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'path'; +import { resolveDevDatabase } from './dev.js'; +import { resolveStartDatabase } from './start.js'; +import { resolveStandaloneDatabase } from '@objectstack/runtime'; + +// The seams lazy-import @objectstack/runtime; the first cold import can exceed +// vitest's 5s default — warm it once (standalone-stack.test.ts's BOOT_TIMEOUT). +beforeAll(async () => { await import('@objectstack/runtime'); }, 60_000); + +// resolveStandaloneDatabase reads process.env (the other two take env as an +// argument) — scrub the vars that steer resolution so each case controls its +// own inputs, and restore them afterwards. +const ENV_KEYS = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', +] as const; +const saved: Record = {}; + +let root: string; +beforeEach(() => { + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + root = mkdtempSync(path.join(tmpdir(), 'os-6469-pin-')); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ } +}); + +const dataDir = () => path.join(root, '.objectstack', 'data'); + +function seedDataDir(...files: string[]): void { + mkdirSync(dataDir(), { recursive: true }); + for (const f of files) writeFileSync(path.join(dataDir(), f), ''); +} + +/** + * Resolve through all three command seams with the inputs each command passes + * at runtime for a project rooted at `root`: + * - dev: projectRoot = cwd, env from the process + * - start: homeDir = its resolved project-mode home (/.objectstack) + * - migrate: resolveStandaloneDatabase({ projectRoot }) — the exact call + * `bootSchemaStack`/`probeMigrationTarget` make (plus the artifact + * the boot would read, when the case provides one) + */ +async function resolveAllThree(opts: { env?: Record; artifactPath?: string } = {}) { + const env = opts.env ?? {}; + const dev = await resolveDevDatabase({ env, cwd: root, artifactPath: opts.artifactPath }); + const start = await resolveStartDatabase({ + env, + homeDir: path.join(root, '.objectstack'), + projectRoot: root, + artifactPath: opts.artifactPath, + }); + for (const [k, v] of Object.entries(env)) if (v !== undefined) process.env[k] = v; + const migrate = resolveStandaloneDatabase({ + projectRoot: root, + ...(opts.artifactPath ? { artifactPath: opts.artifactPath } : { artifactPath: path.join(root, 'dist', 'objectstack.json') }), + }); + return { dev, start, migrate }; +} + +function expectAllSame(r: Awaited>, url: string) { + expect(r.dev.url).toBe(url); + expect(r.start.url).toBe(url); + expect(r.migrate.url).toBe(url); +} + +describe('#6469 pin — dev / start / migrate resolve ONE URL per project root', () => { + it('fresh project (nothing on disk): all three → the unified default file', async () => { + const r = await resolveAllThree(); + expectAllSame(r, `file:${path.join(dataDir(), 'objectstack.db')}`); + expect(r.dev.source).toBe('unified-default'); + expect(r.start.source).toBe('unified-default'); + expect(r.migrate.source).toBe('unified-default'); + }); + + it('unified default present: all three read it, no notice', async () => { + seedDataDir('objectstack.db'); + const r = await resolveAllThree(); + expectAllSame(r, `file:${path.join(dataDir(), 'objectstack.db')}`); + expect(r.dev.notice).toBeUndefined(); + expect(r.migrate.notice).toBeUndefined(); + }); + + it('legacy dev.db state: all three compat-read the SAME legacy file, loudly', async () => { + seedDataDir('dev.db'); + const r = await resolveAllThree(); + expectAllSame(r, `file:${path.join(dataDir(), 'dev.db')}`); + for (const seat of [r.dev, r.start, r.migrate]) { + expect(seat.source).toBe('legacy-file'); + expect(seat.notice).toContain('dev.db'); + } + }); + + it('legacy standalone.db state: all three compat-read it', async () => { + seedDataDir('standalone.db'); + const r = await resolveAllThree(); + expectAllSame(r, `file:${path.join(dataDir(), 'standalone.db')}`); + }); + + it('both legacies: all three prefer dev.db (identical probe order — per-command affinity would re-fork)', async () => { + seedDataDir('dev.db', 'standalone.db'); + const r = await resolveAllThree(); + expectAllSame(r, `file:${path.join(dataDir(), 'dev.db')}`); + }); + + it('config-declared datasource: all three resolve the SAME declared connection', async () => { + mkdirSync(path.join(root, 'dist'), { recursive: true }); + const artifactPath = path.join(root, 'dist', 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify({ + datasources: [ + { name: 'crm_primary', driver: 'sqlite', config: { filename: '.objectstack/data/crm.db' } }, + ], + datasourceMapping: [{ default: true, datasource: 'crm_primary' }], + })); + const r = await resolveAllThree({ artifactPath }); + expectAllSame(r, `file:${path.join(root, '.objectstack', 'data', 'crm.db')}`); + expect(r.dev.source).toBe('config-datasource'); + expect(r.start.source).toBe('config-datasource'); + expect(r.migrate.source).toBe('config-datasource'); + expect(r.migrate.datasourceName).toBe('crm_primary'); + }); + + it('env URL (sqlite:// alias): all three normalize to the SAME file: URL', async () => { + const target = path.join(root, 'named.db'); + const r = await resolveAllThree({ env: { OS_DATABASE_URL: `sqlite://${target}` } }); + expectAllSame(r, `file:${target}`); + expect(r.migrate.driver).toBe('sqlite'); + expect(r.migrate.sqliteFile).toBe(target); + }); +}); diff --git a/packages/cli/src/utils/sqlite-occupancy.test.ts b/packages/cli/src/utils/sqlite-occupancy.test.ts index cf75cd4419..0a8b07c586 100644 --- a/packages/cli/src/utils/sqlite-occupancy.test.ts +++ b/packages/cli/src/utils/sqlite-occupancy.test.ts @@ -4,7 +4,8 @@ * SQLite occupancy detection (#3917). * * The scenario these encode is the one that produced the report: a dev server - * holding `.objectstack/data/standalone.db` open while `os migrate apply` runs + * holding `.objectstack/data/objectstack.db` (the unified default since #6469) + * open while `os migrate apply` runs * in another terminal. The probe has to tell that apart from a database nobody * is attached to — including a database that merely has `-wal`/`-shm` left over * from a crash, which sidecar presence alone cannot do. diff --git a/packages/cli/src/utils/sqlite-occupancy.ts b/packages/cli/src/utils/sqlite-occupancy.ts index 2ba81769bf..8a9be473e6 100644 --- a/packages/cli/src/utils/sqlite-occupancy.ts +++ b/packages/cli/src/utils/sqlite-occupancy.ts @@ -6,12 +6,23 @@ * `os migrate apply` used to gate only on `--allow-destructive` and the `[y/N]` * prompt. Neither says anything about *occupancy*: the overwhelmingly common * shape of a dev machine is a `pnpm dev` server holding the same - * `.objectstack/data/standalone.db` open while the operator runs a migration in - * another terminal. What that costs is not a swapped-out file — the SQLite + * `.objectstack/data/objectstack.db` open while the operator runs a migration + * in another terminal. What that costs is not a swapped-out file — the SQLite * column-op rebuild swaps tables *inside* the file, in one transaction — it is * `SQLITE_BUSY` mid-migration, stale prepared statements in the live server, * and schema-cookie churn under its feet. * + * That scenario was FALSE under the pre-#6469 defaults, and this comment used + * to assert it anyway: `pnpm dev` (`os dev`) held `dev.db` while `os migrate` + * opened `standalone.db`, so under default configuration the two never + * contended for one file and this guard could not fire in its own primary + * scenario (it still worked when an explicit `OS_DATABASE_URL` pointed both at + * one file). #6469 unified all three commands on ONE resolution — + * `/data/objectstack.db`, legacy files compat-read — so the dev + * server and a migration in another terminal now genuinely open the same file + * by default, and the scenario above is real again. The probes themselves are + * filename-parametric and needed no change. + * * ## Two independent signals, because neither covers the ground alone * * **1. Which processes hold the file open** (`/proc` on Linux, `lsof` on diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e34d3727b7..55338a3df7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -11,6 +11,21 @@ export type { RuntimeConfig } from './runtime.js'; export { createStandaloneStack, resolveObjectStackHome, resolveStandaloneDatabase } from './standalone-stack.js'; export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDatabase } from './standalone-stack.js'; +// The ONE default-database resolution shared by `os dev` / `os start` / +// `os migrate` (#6469) — commands map their flags onto it; no command carries +// its own fallback filename. +export { + resolveProjectDatabaseUrl, + normalizeDatabaseUrl, + UNIFIED_DEFAULT_DB_FILENAME, + LEGACY_DEFAULT_DB_FILENAMES, +} from './resolve-project-database.js'; +export type { + ResolveProjectDatabaseUrlOptions, + ResolvedProjectDatabaseUrl, + ProjectDatabaseUrlSource, +} from './resolve-project-database.js'; + // Export Default Host (artifact-first, no objectstack.config.ts required) export { createDefaultHostConfig, resolveDefaultArtifactPath } from './default-host.js'; export type { DefaultHostConfigOptions, DefaultHostConfigResult } from './default-host.js'; diff --git a/packages/runtime/src/resolve-project-database.test.ts b/packages/runtime/src/resolve-project-database.test.ts new file mode 100644 index 0000000000..c4eb6090f4 --- /dev/null +++ b/packages/runtime/src/resolve-project-database.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE default-database resolution (#6469). + * + * Before it existed, `os dev` / `os start` / `os migrate` resolved THREE + * different default files in the same directory (`dev.db` / `objectstack.db` / + * `standalone.db`) and none consulted the project config — `os migrate plan` + * then reported 22 tables of drift against a freshly-created empty database of + * its own making. These cases pin every rung of the maintainer-ruled priority + * ladder; the cross-command agreement itself is pinned in + * `packages/cli/src/commands/unified-db-resolution.pin.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { + resolveProjectDatabaseUrl, + normalizeDatabaseUrl, + UNIFIED_DEFAULT_DB_FILENAME, + LEGACY_DEFAULT_DB_FILENAMES, +} from './resolve-project-database.js'; +import { resolveStandaloneDatabase } from './standalone-stack.js'; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'os-6469-resolve-')); +}); +afterEach(() => { + try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ } +}); + +const dataDir = () => join(root, '.objectstack', 'data'); +const unified = () => join(dataDir(), UNIFIED_DEFAULT_DB_FILENAME); + +function seedDataDir(...files: string[]): void { + mkdirSync(dataDir(), { recursive: true }); + for (const f of files) writeFileSync(join(dataDir(), f), ''); +} + +describe('normalizeDatabaseUrl — sqlite:// is an alias of file: (#6469 ruling item 3)', () => { + it('rewrites sqlite:// and sqlite: to file:', () => { + expect(normalizeDatabaseUrl('sqlite:///abs/app.db')).toBe('file:/abs/app.db'); + expect(normalizeDatabaseUrl('sqlite://relative/app.db')).toBe('file:relative/app.db'); + expect(normalizeDatabaseUrl('sqlite:./app.db')).toBe('file:./app.db'); + expect(normalizeDatabaseUrl('SQLITE://x.db')).toBe('file:x.db'); + expect(normalizeDatabaseUrl('sqlite::memory:')).toBe('file::memory:'); + }); + + it('leaves every other URL untouched (trimmed only)', () => { + expect(normalizeDatabaseUrl(' postgres://u:p@h:5432/db ')).toBe('postgres://u:p@h:5432/db'); + expect(normalizeDatabaseUrl('file:/abs/app.db')).toBe('file:/abs/app.db'); + expect(normalizeDatabaseUrl('memory://x')).toBe('memory://x'); + expect(normalizeDatabaseUrl('libsql://db.turso.io')).toBe('libsql://db.turso.io'); + }); +}); + +describe('resolveProjectDatabaseUrl — priority ladder', () => { + it('explicit URL wins over everything, normalized', () => { + seedDataDir('dev.db'); + const r = resolveProjectDatabaseUrl({ + explicitUrl: `sqlite://${join(root, 'x.db')}`, + env: { OS_DATABASE_URL: 'postgres://elsewhere/db' }, + projectRoot: root, + }); + expect(r).toEqual({ url: `file:${join(root, 'x.db')}`, source: 'explicit' }); + }); + + it('OS_DATABASE_URL beats DATABASE_URL beats TURSO_DATABASE_URL', () => { + expect(resolveProjectDatabaseUrl({ + env: { OS_DATABASE_URL: 'file:/a.db', DATABASE_URL: 'file:/b.db', TURSO_DATABASE_URL: 'libsql://x' }, + projectRoot: root, + })).toEqual({ url: 'file:/a.db', source: 'env' }); + expect(resolveProjectDatabaseUrl({ + env: { DATABASE_URL: 'file:/b.db', TURSO_DATABASE_URL: 'libsql://x' }, + projectRoot: root, + })).toEqual({ url: 'file:/b.db', source: 'env' }); + expect(resolveProjectDatabaseUrl({ + env: { TURSO_DATABASE_URL: 'libsql://x' }, + projectRoot: root, + })).toEqual({ url: 'libsql://x', source: 'env' }); + }); + + it('env URLs are normalized too (sqlite:// in OS_DATABASE_URL works)', () => { + expect(resolveProjectDatabaseUrl({ + env: { OS_DATABASE_URL: 'sqlite:///abs/env.db' }, + projectRoot: root, + })).toEqual({ url: 'file:/abs/env.db', source: 'env' }); + }); + + it('blank env values are treated as unset (still the unified default)', () => { + const r = resolveProjectDatabaseUrl({ env: { OS_DATABASE_URL: ' ' }, projectRoot: root }); + expect(r).toEqual({ url: `file:${unified()}`, source: 'unified-default' }); + }); + + it('an explicit memory driver (flag or env) imposes no file default', () => { + expect(resolveProjectDatabaseUrl({ explicitDriver: 'memory', env: {}, projectRoot: root })) + .toEqual({ url: 'memory://', source: 'memory-driver' }); + expect(resolveProjectDatabaseUrl({ env: { OS_DATABASE_DRIVER: 'memory' }, projectRoot: root })) + .toEqual({ url: 'memory://', source: 'memory-driver' }); + // …but an env URL still wins over the driver shortcut (driver selects the + // engine; it does not erase an explicitly-named database). + expect(resolveProjectDatabaseUrl({ + env: { OS_DATABASE_DRIVER: 'memory', OS_DATABASE_URL: 'memory://named' }, + projectRoot: root, + })).toEqual({ url: 'memory://named', source: 'env' }); + }); + + it('fresh project: the unified default file, under /.objectstack/data', () => { + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root }); + expect(r).toEqual({ url: `file:${unified()}`, source: 'unified-default' }); + }); + + it('OS_HOME beats projectRoot for the state dir; explicit homeDir beats OS_HOME', () => { + const osHome = join(root, 'oshome'); + const explicitHome = join(root, 'flag-home'); + expect(resolveProjectDatabaseUrl({ env: { OS_HOME: osHome }, projectRoot: root }).url) + .toBe(`file:${join(osHome, 'data', UNIFIED_DEFAULT_DB_FILENAME)}`); + expect(resolveProjectDatabaseUrl({ env: { OS_HOME: osHome }, projectRoot: root, homeDir: explicitHome }).url) + .toBe(`file:${join(explicitHome, 'data', UNIFIED_DEFAULT_DB_FILENAME)}`); + }); + + it('no projectRoot and no OS_HOME → the user-home state dir', () => { + const r = resolveProjectDatabaseUrl({ env: {} }); + expect(r.url).toBe(`file:${join(homedir(), '.objectstack', 'data', UNIFIED_DEFAULT_DB_FILENAME)}`); + }); +}); + +describe('resolveProjectDatabaseUrl — legacy compat-read (#6469 ruling item 2)', () => { + it('probe order is objectstack.db → dev.db → standalone.db, identical constants', () => { + // The order itself is contract: dev.db holds real dev data, while + // standalone.db is most likely an empty artifact of the pre-#6469 fork. + expect([...LEGACY_DEFAULT_DB_FILENAMES]).toEqual(['dev.db', 'standalone.db']); + }); + + it('unified file present → unified wins, no notice, even when legacies exist', () => { + seedDataDir(UNIFIED_DEFAULT_DB_FILENAME, 'dev.db', 'standalone.db'); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root }); + expect(r).toEqual({ url: `file:${unified()}`, source: 'unified-default' }); + }); + + it('legacy dev.db is read when the unified file does not exist — with one loud line', () => { + seedDataDir('dev.db'); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root }); + expect(r.url).toBe(`file:${join(dataDir(), 'dev.db')}`); + expect(r.source).toBe('legacy-file'); + // The notice must name BOTH files and the migration command — an existing + // environment must never look like data loss, and the operator must learn + // how to converge. + expect(r.notice).toContain(join(dataDir(), 'dev.db')); + expect(r.notice).toContain(unified()); + expect(r.notice).toContain('mv '); + }); + + it('legacy standalone.db is read when it is the only file', () => { + seedDataDir('standalone.db'); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root }); + expect(r.url).toBe(`file:${join(dataDir(), 'standalone.db')}`); + expect(r.source).toBe('legacy-file'); + }); + + it('dev.db beats standalone.db when both exist (real dev data over the fork artifact)', () => { + seedDataDir('dev.db', 'standalone.db'); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root }); + expect(r.url).toBe(`file:${join(dataDir(), 'dev.db')}`); + }); +}); + +describe('resolveProjectDatabaseUrl — config-declared default datasource', () => { + function writeArtifact(bundle: unknown, envelope = false): string { + const p = join(root, 'objectstack.json'); + writeFileSync(p, JSON.stringify(envelope ? { schemaVersion: 1, metadata: bundle } : bundle)); + return p; + } + + const CRM_PRIMARY = { + datasources: [ + { name: 'crm_primary', driver: 'sqlite', config: { filename: '.objectstack/data/crm.db' } }, + ], + datasourceMapping: [{ default: true, datasource: 'crm_primary' }], + }; + + it('a { default: true } mapping rule resolves the declared sqlite datasource, relative to projectRoot', () => { + const artifactPath = writeArtifact(CRM_PRIMARY); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root, artifactPath }); + expect(r).toEqual({ + url: `file:${join(root, '.objectstack', 'data', 'crm.db')}`, + source: 'config-datasource', + datasourceName: 'crm_primary', + }); + }); + + it('unwraps the { schemaVersion, metadata } artifact envelope', () => { + const artifactPath = writeArtifact(CRM_PRIMARY, true); + const r = resolveProjectDatabaseUrl({ env: {}, projectRoot: root, artifactPath }); + expect(r.source).toBe('config-datasource'); + expect(r.url).toBe(`file:${join(root, '.objectstack', 'data', 'crm.db')}`); + }); + + it('a url-carrying driver resolves through config.url (spec alias table: pg → postgres)', () => { + const artifactPath = writeArtifact({ + datasources: [{ name: 'warehouse', driver: 'pg', config: { url: 'postgres://u@h:5432/wh' } }], + datasourceMapping: [{ default: true, datasource: 'warehouse' }], + }); + expect(resolveProjectDatabaseUrl({ env: {}, projectRoot: root, artifactPath })).toEqual({ + url: 'postgres://u@h:5432/wh', + source: 'config-datasource', + datasourceName: 'warehouse', + }); + }); + + it('explicit URL and env still beat the config declaration', () => { + const artifactPath = writeArtifact(CRM_PRIMARY); + expect(resolveProjectDatabaseUrl({ + explicitUrl: 'file:/explicit.db', env: {}, projectRoot: root, artifactPath, + }).source).toBe('explicit'); + expect(resolveProjectDatabaseUrl({ + env: { OS_DATABASE_URL: 'file:/env.db' }, projectRoot: root, artifactPath, + }).source).toBe('env'); + }); + + it('falls through to the unified default when the rule names the host-reserved "default"', () => { + const artifactPath = writeArtifact({ + datasources: [], + datasourceMapping: [{ default: true, datasource: 'default' }], + }); + expect(resolveProjectDatabaseUrl({ env: {}, projectRoot: root, artifactPath }).source) + .toBe('unified-default'); + }); + + it('falls through when the named datasource is missing or its URL is underivable', () => { + // missing declaration + expect(resolveProjectDatabaseUrl({ + env: {}, projectRoot: root, + artifactPath: writeArtifact({ datasources: [], datasourceMapping: [{ default: true, datasource: 'ghost' }] }), + }).source).toBe('unified-default'); + // postgres declared via discrete fields — no DSN is invented for it + expect(resolveProjectDatabaseUrl({ + env: {}, projectRoot: root, + artifactPath: writeArtifact({ + datasources: [{ name: 'wh', driver: 'postgres', config: { host: 'h', database: 'd' } }], + datasourceMapping: [{ default: true, datasource: 'wh' }], + }), + }).source).toBe('unified-default'); + }); + + it('no mapping rule (the common case — e.g. app-crm) → unified default', () => { + const artifactPath = writeArtifact({ + datasources: [{ name: 'crm_analytics', driver: 'sqlite', config: { filename: ':memory:' } }], + }); + expect(resolveProjectDatabaseUrl({ env: {}, projectRoot: root, artifactPath }).source) + .toBe('unified-default'); + }); + + it('missing or http(s) artifacts are skipped', () => { + expect(resolveProjectDatabaseUrl({ + env: {}, projectRoot: root, artifactPath: join(root, 'nope.json'), + }).source).toBe('unified-default'); + expect(resolveProjectDatabaseUrl({ + env: {}, projectRoot: root, artifactPath: 'https://example.com/objectstack.json', + }).source).toBe('unified-default'); + }); +}); + +// The standalone stack (the `os migrate` resolution seam) consumes the shared +// resolver — these pin the two #6469 behaviours visible through it. Cross- +// command agreement lives in the CLI's unified-db-resolution.pin.test.ts. +describe('resolveStandaloneDatabase — #6469 behaviours through the migrate seam', () => { + const ENV_KEYS = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', + ] as const; + const saved: Record = {}; + beforeEach(() => { + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + }); + afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('accepts sqlite:// as a file: alias (was: Unsupported database URL scheme)', () => { + const r = resolveStandaloneDatabase({ databaseUrl: `sqlite://${join(root, 'a.db')}` }); + expect(r.driver).toBe('sqlite'); + expect(r.url).toBe(`file:${join(root, 'a.db')}`); + expect(r.sqliteFile).toBe(join(root, 'a.db')); + }); + + it('still refuses a genuinely unsupported scheme, message intact', () => { + expect(() => resolveStandaloneDatabase({ databaseUrl: 'redis://localhost:6379' })) + .toThrow(/Unsupported database URL scheme/); + }); + + it('resolves the unified default for a projectRoot, and surfaces the legacy notice', () => { + const fresh = resolveStandaloneDatabase({ projectRoot: root, artifactPath: join(root, 'no-artifact.json') }); + expect(fresh.url).toBe(`file:${unified()}`); + expect(fresh.source).toBe('unified-default'); + expect(fresh.notice).toBeUndefined(); + + seedDataDir('standalone.db'); + const legacy = resolveStandaloneDatabase({ projectRoot: root, artifactPath: join(root, 'no-artifact.json') }); + expect(legacy.url).toBe(`file:${join(dataDir(), 'standalone.db')}`); + expect(legacy.source).toBe('legacy-file'); + expect(legacy.notice).toContain('standalone.db'); + }); +}); diff --git a/packages/runtime/src/resolve-project-database.ts b/packages/runtime/src/resolve-project-database.ts new file mode 100644 index 0000000000..e052b45771 --- /dev/null +++ b/packages/runtime/src/resolve-project-database.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE default-database resolution for a project directory (#6469). + * + * Before this module, three commands resolved three different default + * databases in the same directory — `os dev` → `.objectstack/data/dev.db`, + * `os start` → `.objectstack/data/objectstack.db`, `os migrate *` → + * `.objectstack/data/standalone.db` — and none of them consulted the project + * config. The measured consequence (issue #6469, hotcrm 17.0.0-rc.5): after + * `os start` + seed, `os migrate plan` opened a fresh empty `standalone.db` it + * had just created and reported 22 tables of drift against a healthy database. + * The failure direction is inverted — the truth was "zero drift" and the + * output said "everything is missing" — which sends an operator rolling back a + * database that is fine. + * + * Maintainer ruling (#6469, 2026-08-08): + * + * 1. One shared resolution function, used by `os dev`, `os start` and + * `os migrate` (via `resolveStandaloneDatabase`). This module is it — + * commands map their flags onto {@link ResolveProjectDatabaseUrlOptions} + * and MUST NOT carry their own fallback filename. + * 2. Priority: explicit URL (flag / config) → environment + * (`OS_DATABASE_URL` / legacy `DATABASE_URL` / vendor + * `TURSO_DATABASE_URL`) → explicit `memory` driver selection → the + * datasource the project config declares as its default home + * ({@link readConfigDeclaredDefault}) → the unified default file. + * 3. Unified default filename: `objectstack.db` — what `os start` (the + * serving process) already used; the serving database is the ground + * truth the other two commands must align to. + * 4. Legacy compat-read, NO interactive prompt (CI-safe): when the unified + * default does not exist but a legacy `dev.db` / `standalone.db` does, + * resolve to the legacy file and return one loud {@link notice} line + * naming the file and how to migrate. An existing dev environment must + * never look like data loss. Probe order is `objectstack.db` → `dev.db` + * → `standalone.db`, IDENTICAL for every command — `dev.db` first among + * the legacies because it holds real dev data, while `standalone.db` is + * most likely an empty artifact of the very fork this fixes. + * 5. `sqlite://` is accepted as an alias of `file:` + * ({@link normalizeDatabaseUrl}); genuinely unsupported schemes keep + * their precise refusal in `standalone-stack.ts`. + */ + +import { resolve as resolvePath, isAbsolute } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolveDriverId } from '@objectstack/spec/data'; + +/** The unified default database filename (`/data/objectstack.db`). */ +export const UNIFIED_DEFAULT_DB_FILENAME = 'objectstack.db'; + +/** + * Legacy default filenames still read for compatibility, in probe order. + * `dev.db` deliberately precedes `standalone.db`: the former holds real dev + * data (`os dev` persisted into it), while the latter is most likely an empty + * file `os migrate` itself created through the pre-#6469 fork. + */ +export const LEGACY_DEFAULT_DB_FILENAMES = ['dev.db', 'standalone.db'] as const; + +/** + * Normalize a database URL: `sqlite:` / `sqlite://` are aliases of `file:` + * (#6469 ruling item 3 — `sqlite://` is the most natural guess for "point me + * at a SQLite file", and refusing it taught nothing the alias cannot). + * Everything else passes through trimmed; scheme support is still decided by + * the driver dispatch, so a genuinely unsupported scheme keeps its precise + * refusal. + */ +export function normalizeDatabaseUrl(url: string): string { + const trimmed = url.trim(); + const sqliteAlias = /^sqlite:(\/\/)?/i.exec(trimmed); + if (sqliteAlias) return `file:${trimmed.slice(sqliteAlias[0].length)}`; + return trimmed; +} + +/** Where a resolved database URL came from — one tier per priority rung. */ +export type ProjectDatabaseUrlSource = + /** `--database` / `--database-url` flag, or programmatic `databaseUrl`. */ + | 'explicit' + /** `OS_DATABASE_URL` / `DATABASE_URL` / `TURSO_DATABASE_URL`. */ + | 'env' + /** `--database-driver memory` / `OS_DATABASE_DRIVER=memory` — no file default is imposed. */ + | 'memory-driver' + /** The datasource the project config declares as its default home. */ + | 'config-datasource' + /** A legacy `dev.db` / `standalone.db` read for compatibility (see {@link notice}). */ + | 'legacy-file' + /** The unified default `/data/objectstack.db`. */ + | 'unified-default'; + +export interface ResolvedProjectDatabaseUrl { + /** The resolved database URL, `sqlite://` already normalized to `file:`. */ + url: string; + source: ProjectDatabaseUrlSource; + /** The declared datasource's `name`, when {@link source} is `config-datasource`. */ + datasourceName?: string; + /** + * One loud, actionable line the caller must surface (set only for + * `legacy-file`). Print it once per command run — never swallow it: the + * whole point of the compat-read is that the operator learns which file + * is being read and how to converge on the unified name. + */ + notice?: string; +} + +export interface ResolveProjectDatabaseUrlOptions { + /** Explicit database URL (CLI flag / programmatic config). Wins over everything. */ + explicitUrl?: string; + /** + * Explicit driver selection (`--database-driver` / config). Only `memory` + * changes URL resolution (no file default is imposed for an explicitly + * in-memory boot); other values select engines, not URLs, and their + * vocabulary is #6345's seam — deliberately not judged here. + */ + explicitDriver?: string; + /** Environment to read (`process.env` by default; tests inject their own). */ + env?: Record; + /** + * The project root — the directory holding `objectstack.config.ts` (or + * the cwd of a project-scoped one-shot command). Anchors the default + * `.objectstack/` state dir and relative sqlite filenames from the config. + */ + projectRoot?: string; + /** + * Pre-resolved state directory (e.g. `os start --home`). When set it wins + * over `OS_HOME` / `projectRoot` — the caller already folded those in. + */ + homeDir?: string; + /** + * Compiled artifact (`dist/objectstack.json`) to consult for the + * config-declared datasource tier. Local paths only — an `http(s)://` + * artifact is skipped (resolution is synchronous and remote configs do + * not describe the local filesystem anyway). + */ + artifactPath?: string; +} + +/** + * Resolve the state directory the default database lives under. + * Order: caller-resolved `homeDir` → `OS_HOME` → `/.objectstack` + * → `~/.objectstack` — the same order `os start` applies to its home dir, and + * the order the standalone stack applied before unification. + */ +function resolveDatabaseStateDir(opts: { + homeDir?: string; + projectRoot?: string; + env: Record; +}): string { + if (opts.homeDir && opts.homeDir.trim().length > 0) return resolvePath(opts.homeDir.trim()); + const rawOsHome = opts.env.OS_HOME?.trim(); + if (rawOsHome && rawOsHome.length > 0) { + if (rawOsHome.startsWith('~')) return resolvePath(homedir(), rawOsHome.slice(1).replace(/^[/\\]/, '')); + return resolvePath(rawOsHome); + } + if (opts.projectRoot) return resolvePath(opts.projectRoot, '.objectstack'); + return resolvePath(homedir(), '.objectstack'); +} + +/** + * The datasource the project config declares as the default home of its + * objects, as a database URL — or `undefined` when the config declares none. + * + * What "declares" means today: the compiled artifact's `datasourceMapping` + * carries a `{ default: true, datasource: }` rule (the one existing + * config surface that routes the project's objects somewhere by default — + * since #4462 that routing is real, not a fall-through), and `datasources[]` + * declares `` with a connection this function can express as a URL: + * + * - sqlite family → `config.filename` (relative paths anchored on + * `projectRoot`, exactly where the driver resolves them from a project + * boot), expressed as `file:`/`wasm-sqlite://`; + * - url-carrying drivers (postgres / mysql / mongo / turso) → + * `config.url` when present. Discrete `host`/`port`/`database` fields are + * NOT reassembled into a DSN here — inventing a URL (with credentials) + * that the author never wrote is worse than falling through; + * - memory → `memory://`. + * + * A rule naming the host-reserved `default`, a missing declaration, or an + * underivable connection all yield `undefined` — resolution then falls + * through to the unified default, which is what those projects got before + * this tier existed. (Driver-id spellings resolve through the spec's ONE + * alias table, `resolveDriverId`; `turso`/`libsql` are recognized here + * additionally because they are not builtin factory ids.) + */ +function readConfigDeclaredDefault(opts: { + artifactPath?: string; + projectRoot?: string; +}): { url: string; datasourceName: string } | undefined { + const artifactPath = opts.artifactPath; + if (!artifactPath || /^https?:\/\//i.test(artifactPath) || !existsSync(artifactPath)) return undefined; + + let bundle: any; + try { + const parsed = JSON.parse(readFileSync(artifactPath, 'utf8')); + // Same envelope unwrap as `loadArtifactBundle` (`{ schemaVersion, metadata }`). + bundle = parsed?.schemaVersion != null && parsed?.metadata !== undefined ? parsed.metadata : parsed; + } catch { + // Unreadable/malformed artifact — the boot's own loader reports that + // loudly; the URL resolver just cannot consult it. + return undefined; + } + + const mapping: unknown[] = Array.isArray(bundle?.datasourceMapping) ? bundle.datasourceMapping : []; + const rule = mapping.find( + (r: any) => r && typeof r === 'object' && r.default === true && typeof r.datasource === 'string', + ) as { datasource: string } | undefined; + if (!rule || rule.datasource === 'default') return undefined; + + const dsDefs = bundle?.datasources; + const declared: any[] = Array.isArray(dsDefs) + ? dsDefs + : dsDefs && typeof dsDefs === 'object' + ? Object.entries(dsDefs).map(([name, def]) => ({ name, ...(def as object) })) + : []; + const ds = declared.find((d: any) => d && typeof d === 'object' && d.name === rule.datasource); + if (!ds) return undefined; + + const url = datasourceUrlOf(ds, opts.projectRoot); + return url ? { url, datasourceName: rule.datasource } : undefined; +} + +/** Express a declared datasource's connection as a database URL, or `undefined`. */ +function datasourceUrlOf(ds: { driver?: unknown; config?: unknown }, projectRoot?: string): string | undefined { + const config = (ds.config ?? {}) as { filename?: unknown; url?: unknown }; + const rawDriver = typeof ds.driver === 'string' ? ds.driver.trim().toLowerCase() : ''; + const canonical = resolveDriverId(ds.driver) ?? (rawDriver === 'turso' || rawDriver === 'libsql' ? 'turso' : undefined); + switch (canonical) { + case 'sqlite': + case 'sqlite-wasm': { + const filename = typeof config.filename === 'string' ? config.filename.trim() : ''; + if (!filename) return undefined; + if (filename === ':memory:') return ':memory:'; + const abs = isAbsolute(filename) + ? filename + : resolvePath(projectRoot ?? process.cwd(), filename); + return canonical === 'sqlite-wasm' ? `wasm-sqlite://${abs}` : `file:${abs}`; + } + case 'memory': + return 'memory://'; + case 'postgres': + case 'mysql': + case 'mongo': + case 'turso': + return typeof config.url === 'string' && config.url.trim() ? config.url.trim() : undefined; + default: + // A plugin-contributed driver — no URL contract to read. + return typeof config.url === 'string' && config.url.trim() ? config.url.trim() : undefined; + } +} + +/** + * Resolve the database URL for a project directory — the shared function all + * of `os dev` / `os start` / `os migrate` (and every `createStandaloneStack` + * embedder) resolve through. See the module doc for the priority ladder. + * + * Reads the filesystem (legacy-file probe + artifact consult) but never + * creates anything and never prints — the caller surfaces {@link notice}. + */ +export function resolveProjectDatabaseUrl( + opts: ResolveProjectDatabaseUrlOptions = {}, +): ResolvedProjectDatabaseUrl { + const env = opts.env ?? process.env; + + const explicit = opts.explicitUrl?.trim(); + if (explicit) return { url: normalizeDatabaseUrl(explicit), source: 'explicit' }; + + const envUrl = (env.OS_DATABASE_URL ?? env.DATABASE_URL)?.trim(); + if (envUrl) return { url: normalizeDatabaseUrl(envUrl), source: 'env' }; + const tursoUrl = env.TURSO_DATABASE_URL?.trim(); + if (tursoUrl) return { url: tursoUrl, source: 'env' }; + + // An explicitly in-memory boot gets no file default imposed on it. Only + // `memory` is judged here; unknown driver values are refused downstream + // (`resolveExplicitDriver`) with the full legal-values list. + const driver = (opts.explicitDriver ?? env.OS_DATABASE_DRIVER)?.trim().toLowerCase(); + if (driver === 'memory') return { url: 'memory://', source: 'memory-driver' }; + + const fromConfig = readConfigDeclaredDefault(opts); + if (fromConfig) { + return { url: fromConfig.url, source: 'config-datasource', datasourceName: fromConfig.datasourceName }; + } + + const dataDir = resolvePath(resolveDatabaseStateDir({ ...opts, env }), 'data'); + const unifiedPath = resolvePath(dataDir, UNIFIED_DEFAULT_DB_FILENAME); + if (!existsSync(unifiedPath)) { + for (const legacyName of LEGACY_DEFAULT_DB_FILENAMES) { + const legacyPath = resolvePath(dataDir, legacyName); + if (existsSync(legacyPath)) { + return { + url: `file:${legacyPath}`, + source: 'legacy-file', + notice: + `Reading legacy database file ${legacyPath} — the unified default is now ${unifiedPath} (#6469); ` + + `migrate with: mv "${legacyPath}" "${unifiedPath}" (move any -wal/-shm siblings too), ` + + `or pin it explicitly via OS_DATABASE_URL=file:${legacyPath}`, + }; + } + } + } + return { url: `file:${unifiedPath}`, source: 'unified-default' }; +} diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 97296f14f3..78322168c7 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -19,7 +19,7 @@ * - `mysql[2]://` → SqlDriver (mysql2) * - `mongodb[+srv]://` → MongoDBDriver (optional `@objectstack/driver-mongodb`) * - `libsql://`, `http(s)://*.turso.*` → TursoDriver (optional `@objectstack/driver-turso`) - * - `file:` / no scheme → SqlDriver (better-sqlite3) + * - `file:` / `sqlite://` (alias, #6469) / no scheme → SqlDriver (better-sqlite3) * * Unknown URL schemes throw — we never silently fall back to sqlite, since * that historically created bogus directories on disk (e.g. `mongodb:/`) @@ -56,10 +56,14 @@ import { resolve as resolvePath } from 'node:path'; import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { z } from 'zod'; -import { readEnvWithDeprecation, stampSearchPinyinEnabled } from '@objectstack/types'; +import { stampSearchPinyinEnabled } from '@objectstack/types'; import type { IDatasourceDriverFactory } from '@objectstack/service-datasource'; import { loadArtifactBundle, isHttpUrl } from './load-artifact-bundle.js'; import { loadTursoDriverFactory } from './turso-driver-factory.js'; +import { + resolveProjectDatabaseUrl, + type ProjectDatabaseUrlSource, +} from './resolve-project-database.js'; /** * Resolve the ObjectStack home directory used to store cwd-independent @@ -116,11 +120,13 @@ export const StandaloneStackConfigSchema = z.object({ /** * Project root directory. When set (typically by the CLI after locating * `objectstack.config.ts`), the default sqlite database is placed under - * `/.objectstack/data/standalone.db` instead of the global - * `~/.objectstack/data/standalone.db`, and the metadata FileSystemRepository - * roots at `/.objectstack/metadata`. This keeps per-project - * data scoped to the project folder so different examples / apps don't - * share a single database by accident. + * `/.objectstack/data/objectstack.db` — the UNIFIED default + * every command resolves since #6469 (legacy `dev.db` / `standalone.db` + * are still compat-read, see `resolve-project-database.ts`) — instead of + * the global `~/.objectstack/data/objectstack.db`, and the metadata + * FileSystemRepository roots at `/.objectstack/metadata`. + * This keeps per-project data scoped to the project folder so different + * examples / apps don't share a single database by accident. * * Both halves matter: until #4065 only the database honoured it while the * metadata repository still used `process.cwd()`, so a boot whose @@ -224,7 +230,8 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { `[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` + `Supported schemes: memory://, postgres://, pg://, mysql://, mysql2://, ` + `mongodb://, mongodb+srv://, ` + - `libsql:// (optional @objectstack/driver-turso), file:` + `libsql:// (optional @objectstack/driver-turso), file: ` + + `(sqlite:// is accepted as an alias of file:)` ); } @@ -296,15 +303,49 @@ export interface ResolvedStandaloneDatabase { * the side effects of building the stack. */ sqliteFile: string | null; + /** Which priority tier resolved the URL (#6469 — shared resolution). */ + source: ProjectDatabaseUrlSource; + /** Declared datasource name, when `source` is `config-datasource`. */ + datasourceName?: string; + /** + * One loud line about a legacy-file compat-read (#6469) — surfaced by + * `createStandaloneStack` at boot; a caller resolving without booting + * (the occupancy probe) deliberately does not print it, so one command + * run prints it once. + */ + notice?: string; +} + +/** + * The artifact path this boot would read — `cfg.artifactPath` → + * `OS_ARTIFACT_PATH` → `/dist/objectstack.json`, relative paths anchored + * on the cwd. ONE computation for `createStandaloneStack` (which loads the + * bundle from it) and `resolveStandaloneDatabase` (which consults it for the + * config-declared datasource tier, #6469) — two copies here would let the URL + * resolver read a different config than the boot loads. + */ +function resolveArtifactPathInput(cfg: z.output): string { + const cwd = process.cwd(); + const input = cfg.artifactPath + ?? process.env.OS_ARTIFACT_PATH + ?? resolvePath(cwd, 'dist/objectstack.json'); + return isHttpUrl(input) + ? input + : (input.startsWith('/') ? input : resolvePath(cwd, input)); } /** * Resolve the database target WITHOUT building anything. * - * Same precedence `createStandaloneStack` applies (explicit config → - * `OS_DATABASE_URL`/`DATABASE_URL` → `TURSO_DATABASE_URL` → `OS_HOME` → - * project root → user home), factored out so a caller can answer "which file - * am I about to open?" first. Pure: reads env, touches no filesystem. + * Since #6469 the URL comes from the ONE shared resolution + * ({@link resolveProjectDatabaseUrl}) that `os dev` / `os start` also use: + * explicit config → `OS_DATABASE_URL`/`DATABASE_URL` → `TURSO_DATABASE_URL` → + * explicit `memory` driver → the config-declared default datasource (read from + * the compiled artifact) → the unified default file + * (`/data/objectstack.db`, with a compat-read of the legacy + * `dev.db`/`standalone.db`). State-dir precedence is unchanged: `OS_HOME` → + * project root → user home. No longer purely env-derived: the legacy probe and + * the artifact consult read the filesystem (they create nothing). * * The `TURSO_DATABASE_URL` source only started meaning something in #5820: the * URL was read here and then rejected by `detectDriverFromUrl` as an unsupported @@ -318,7 +359,13 @@ export interface ResolvedStandaloneDatabase { */ export function resolveStandaloneDatabase(config?: StandaloneStackConfig): ResolvedStandaloneDatabase { const cfg = StandaloneStackConfigSchema.parse(config ?? {}); - const url = resolveDatabaseUrl(cfg); + const resolution = resolveProjectDatabaseUrl({ + explicitUrl: cfg.databaseUrl, + explicitDriver: cfg.databaseDriver, + projectRoot: cfg.projectRoot, + artifactPath: resolveArtifactPathInput(cfg), + }); + const url = resolution.url; const explicitDriver = resolveExplicitDriver(cfg); const driver: ResolvedDriverKind = explicitDriver || detectDriverFromUrl(url); const isSqlite = driver === 'sqlite' || driver === 'sqlite-wasm'; @@ -327,20 +374,12 @@ export function resolveStandaloneDatabase(config?: StandaloneStackConfig): Resol url, driver, sqliteFile: filename && filename !== ':memory:' && !filename.startsWith(':') ? filename : null, + source: resolution.source, + ...(resolution.datasourceName ? { datasourceName: resolution.datasourceName } : {}), + ...(resolution.notice ? { notice: resolution.notice } : {}), }; } -function resolveDatabaseUrl(cfg: z.output): string { - return cfg.databaseUrl - ?? readEnvWithDeprecation('OS_DATABASE_URL', 'DATABASE_URL', { silent: true })?.trim() - ?? process.env.TURSO_DATABASE_URL?.trim() - ?? (process.env.OS_HOME?.trim() - ? `file:${resolvePath(resolveObjectStackHome(), 'data/standalone.db')}` - : (cfg.projectRoot - ? `file:${resolvePath(cfg.projectRoot, '.objectstack/data/standalone.db')}` - : `file:${resolvePath(resolveObjectStackHome(), 'data/standalone.db')}`)); -} - /** * The libSQL/Turso auth token for this boot, or `undefined` when none was given. * @@ -374,22 +413,20 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const { DefaultDatasourcePlugin } = await import('./default-datasource-plugin.js'); const { AppPlugin } = await import('./app-plugin.js'); - const cwd = process.cwd(); const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? 'proj_local'; - const artifactPathInput = cfg.artifactPath - ?? process.env.OS_ARTIFACT_PATH - ?? resolvePath(cwd, 'dist/objectstack.json'); - const artifactPath = isHttpUrl(artifactPathInput) - ? artifactPathInput - : (artifactPathInput.startsWith('/') - ? artifactPathInput - : resolvePath(cwd, artifactPathInput)); + const artifactPath = resolveArtifactPathInput(cfg); // `databaseAuthToken` / `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are // consumed by the `turso` kind below (#5820). They used to be declared here // and read by nobody — the same "reads it in, cannot dispatch it out" split // `TURSO_DATABASE_URL` had. - const { url: dbUrl, driver: dbDriver } = resolveStandaloneDatabase(cfg); + const { url: dbUrl, driver: dbDriver, notice: dbNotice } = resolveStandaloneDatabase(cfg); + if (dbNotice) { + // Legacy-file compat-read (#6469): loud, once per boot, on stderr so a + // `--json` command's reserved stdout stays a single parseable document. + // eslint-disable-next-line no-console + console.warn(`[StandaloneStack] ⚠ ${dbNotice}`); + } // Translate the database URL into the `default` datasource DEFINITION // (ADR-0062 D1, #3826). The stack no longer builds a driver: the definition From 2e0c87f1ca4aeaedff586d6ddca0142fe862b6db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:22:58 +0000 Subject: [PATCH 2/2] docs(changeset): unified default database resolution (#6469) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/unified-default-db-resolution.md | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .changeset/unified-default-db-resolution.md diff --git a/.changeset/unified-default-db-resolution.md b/.changeset/unified-default-db-resolution.md new file mode 100644 index 0000000000..528597ac75 --- /dev/null +++ b/.changeset/unified-default-db-resolution.md @@ -0,0 +1,56 @@ +--- +"@objectstack/runtime": minor +"@objectstack/cli": minor +--- + +fix(cli,runtime): one shared default-database resolution for `os dev` / `os start` / `os migrate` (#6469) + +Three commands used to resolve three different default databases in the same +project directory — `os dev` → `.objectstack/data/dev.db`, `os start` → +`.objectstack/data/objectstack.db`, `os migrate *` → +`.objectstack/data/standalone.db` — and none consulted the project config. +Measured harm (hotcrm 17.0.0-rc.5): after `os start` + seed, `os migrate plan` +opened a fresh empty `standalone.db` it had just created and reported **22 +tables of drift against a healthy database** — the inverted failure direction, +pointing an operator at rolling back a database that was fine. + +Per the maintainer ruling (2026-08-08, archived on #6469), all three commands +now resolve through **one** shared function +(`resolveProjectDatabaseUrl`, exported from `@objectstack/runtime`): + +1. explicit `--database` / `--database-url` / programmatic `databaseUrl`; +2. `OS_DATABASE_URL` / legacy `DATABASE_URL` / vendor `TURSO_DATABASE_URL`; +3. explicit in-memory driver selection (`--database-driver memory` / + `OS_DATABASE_DRIVER=memory`) — no file default is imposed; +4. the datasource the project config declares as its default home (a + `datasourceMapping` rule `{ default: true, datasource: }` naming a + declared datasource whose connection is URL-derivable); +5. the **unified default file `objectstack.db`** under the state dir + (`OS_HOME` → `/.objectstack` → `~/.objectstack`). + +**Compatibility — an existing environment never looks like data loss.** When +the unified `objectstack.db` does not exist but a legacy `dev.db` or +`standalone.db` does, the command **reads the legacy file** and prints one +loud line naming exactly which file is being read and the `mv` command that +converges it on the unified name. No interactive prompt (CI-safe), nothing is +deleted or renamed automatically, and the probe order +(`objectstack.db` → `dev.db` → `standalone.db`) is identical across all three +commands — `dev.db` first among the legacies because it holds real dev data, +while `standalone.db` is most likely an empty artifact of the very fork this +fixes. An explicit `OS_DATABASE_URL` pins any file forever, unchanged. + +Also per the ruling: `sqlite://` is now accepted as an alias of `file:` in +database-URL parsing (`sqlite://…` used to die under `os migrate` with +`Unsupported database URL scheme`); genuinely unsupported schemes keep their +precise refusal. Behavioural side effects of unification: `os dev` now honours +`OS_HOME` / `TURSO_DATABASE_URL` for its default like the other two commands +already did, `os dev --fresh`'s ephemeral file is named `objectstack.db`, and +`os db clean` targets the same unified resolution. The #3917 +`sqlite-occupancy` guard's primary scenario (a dev server and `os migrate` +contending for one file) is now real under default paths — previously the two +never opened the same file, so the guard could not fire in the very scenario +its comment described. + +The new cross-command pin (`unified-db-resolution.pin.test.ts`) asserts +`dev` / `start` / `migrate` resolve the SAME URL for the same project root in +every fallback state — the test whose absence let the fork live.