Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/unified-default-db-resolution.md
Original file line number Diff line number Diff line change
@@ -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: <name> }` naming a
declared datasource whose connection is URL-derivable);
5. the **unified default file `objectstack.db`** under the state dir
(`OS_HOME` → `<projectRoot>/.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.
18 changes: 11 additions & 7 deletions packages/cli/src/commands/db/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -24,24 +23,29 @@ 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',
}),
};

async run(): Promise<void> {
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(':')) {
Expand Down
100 changes: 69 additions & 31 deletions packages/cli/src/commands/dev-default-db.test.ts
Original file line number Diff line number Diff line change
@@ -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')}`);
});
});
85 changes: 50 additions & 35 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<state dir>/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
* `<cwd>/.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 <url>` 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<string, string | undefined>;
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<ResolvedProjectDatabaseUrl> {
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 {
Expand Down Expand Up @@ -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 → <home>/data/dev.db, published as OS_DATABASE_URL),
// (OS_HOME → <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.
Expand Down Expand Up @@ -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, '🧪');
Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand Down
Loading
Loading