Skip to content

Commit bd5fc38

Browse files
fix(cli,runtime): 统一 os dev / os start / os migrate 的默认数据库解析 (#6469) (#6744)
* feat(runtime,cli): unify default database resolution across dev/start/migrate (#6469) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx * docs(changeset): unified default database resolution (#6469) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1554b1 commit bd5fc38

12 files changed

Lines changed: 1110 additions & 114 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
fix(cli,runtime): one shared default-database resolution for `os dev` / `os start` / `os migrate` (#6469)
7+
8+
Three commands used to resolve three different default databases in the same
9+
project directory — `os dev``.objectstack/data/dev.db`, `os start`
10+
`.objectstack/data/objectstack.db`, `os migrate *`
11+
`.objectstack/data/standalone.db` — and none consulted the project config.
12+
Measured harm (hotcrm 17.0.0-rc.5): after `os start` + seed, `os migrate plan`
13+
opened a fresh empty `standalone.db` it had just created and reported **22
14+
tables of drift against a healthy database** — the inverted failure direction,
15+
pointing an operator at rolling back a database that was fine.
16+
17+
Per the maintainer ruling (2026-08-08, archived on #6469), all three commands
18+
now resolve through **one** shared function
19+
(`resolveProjectDatabaseUrl`, exported from `@objectstack/runtime`):
20+
21+
1. explicit `--database` / `--database-url` / programmatic `databaseUrl`;
22+
2. `OS_DATABASE_URL` / legacy `DATABASE_URL` / vendor `TURSO_DATABASE_URL`;
23+
3. explicit in-memory driver selection (`--database-driver memory` /
24+
`OS_DATABASE_DRIVER=memory`) — no file default is imposed;
25+
4. the datasource the project config declares as its default home (a
26+
`datasourceMapping` rule `{ default: true, datasource: <name> }` naming a
27+
declared datasource whose connection is URL-derivable);
28+
5. the **unified default file `objectstack.db`** under the state dir
29+
(`OS_HOME``<projectRoot>/.objectstack``~/.objectstack`).
30+
31+
**Compatibility — an existing environment never looks like data loss.** When
32+
the unified `objectstack.db` does not exist but a legacy `dev.db` or
33+
`standalone.db` does, the command **reads the legacy file** and prints one
34+
loud line naming exactly which file is being read and the `mv` command that
35+
converges it on the unified name. No interactive prompt (CI-safe), nothing is
36+
deleted or renamed automatically, and the probe order
37+
(`objectstack.db``dev.db``standalone.db`) is identical across all three
38+
commands — `dev.db` first among the legacies because it holds real dev data,
39+
while `standalone.db` is most likely an empty artifact of the very fork this
40+
fixes. An explicit `OS_DATABASE_URL` pins any file forever, unchanged.
41+
42+
Also per the ruling: `sqlite://` is now accepted as an alias of `file:` in
43+
database-URL parsing (`sqlite://…` used to die under `os migrate` with
44+
`Unsupported database URL scheme`); genuinely unsupported schemes keep their
45+
precise refusal. Behavioural side effects of unification: `os dev` now honours
46+
`OS_HOME` / `TURSO_DATABASE_URL` for its default like the other two commands
47+
already did, `os dev --fresh`'s ephemeral file is named `objectstack.db`, and
48+
`os db clean` targets the same unified resolution. The #3917
49+
`sqlite-occupancy` guard's primary scenario (a dev server and `os migrate`
50+
contending for one file) is now real under default paths — previously the two
51+
never opened the same file, so the guard could not fire in the very scenario
52+
its comment described.
53+
54+
The new cross-command pin (`unified-db-resolution.pin.test.ts`) asserts
55+
`dev` / `start` / `migrate` resolve the SAME URL for the same project root in
56+
every fallback state — the test whose absence let the fork live.

packages/cli/src/commands/db/clean.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Command, Flags } from '@oclif/core';
44
import { statSync, existsSync } from 'node:fs';
55
import chalk from 'chalk';
66
import { printError } from '../../utils/format.js';
7-
import { resolveDefaultDevDbUrl } from '../dev.js';
87
import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js';
98

109
/**
@@ -24,24 +23,29 @@ export default class DbClean extends Command {
2423

2524
static override examples = [
2625
'$ os db clean',
27-
'$ os db clean --database file:./.objectstack/data/dev.db',
26+
'$ os db clean --database file:./.objectstack/data/objectstack.db',
2827
];
2928

3029
static override flags = {
3130
database: Flags.string({
3231
char: 'd',
33-
description: 'SQLite database URL/path (defaults to $OS_DATABASE_URL, then the per-project dev DB)',
32+
description: 'SQLite database URL/path (defaults to $OS_DATABASE_URL, then the project database via the shared #6469 resolution)',
3433
env: 'OS_DATABASE_URL',
3534
}),
3635
};
3736

3837
async run(): Promise<void> {
3938
const { flags } = await this.parse(DbClean);
4039

41-
const raw =
42-
flags.database?.trim() ||
43-
resolveDefaultDevDbUrl({ env: process.env, cwd: process.cwd() }) ||
44-
'';
40+
// The ONE shared resolution (#6469): the file this cleans is the file
41+
// `os dev` / `os start` / `os migrate` actually use in this directory —
42+
// including a legacy `dev.db` / `standalone.db` still being compat-read.
43+
const { resolveProjectDatabaseUrl } = await import('@objectstack/runtime');
44+
const resolved = flags.database?.trim()
45+
? undefined
46+
: resolveProjectDatabaseUrl({ env: process.env, projectRoot: process.cwd() });
47+
if (resolved?.notice) console.log(chalk.yellow(`⚠ ${resolved.notice}`));
48+
const raw = flags.database?.trim() || resolved?.url || '';
4549
const primary = raw.replace(/^file:/i, '').replace(/^sqlite:/i, '');
4650

4751
if (!primary || primary === ':memory:' || primary.startsWith(':')) {
Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,86 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
3+
/**
4+
* `objectstack dev` database resolution — the dev seam of the ONE shared
5+
* resolution (#6469).
6+
*
7+
* This file used to pin `dev`'s OWN default (`.objectstack/data/dev.db`) —
8+
* which is exactly how the three-way default fork lived: each command's test
9+
* pinned its own filename and nothing asserted they agree. The maintainer
10+
* ruling (#6469, 2026-08-08) unified the default on `objectstack.db`, so this
11+
* file now pins the dev seam's mapping onto `resolveProjectDatabaseUrl`;
12+
* cross-command agreement is pinned by `unified-db-resolution.pin.test.ts`.
13+
*/
14+
15+
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
16+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
17+
import { tmpdir } from 'node:os';
418
import path from 'path';
5-
import { resolveDefaultDevDbUrl } from './dev.js';
19+
import { resolveDevDatabase } from './dev.js';
20+
21+
// The dev seam lazy-imports @objectstack/runtime (oclif startup weight, #5726);
22+
// the FIRST import cold-loads that graph and can exceed vitest's 5s default on
23+
// a cold worker — warm it once, like standalone-stack.test.ts's BOOT_TIMEOUT.
24+
beforeAll(async () => { await import('@objectstack/runtime'); }, 60_000);
25+
26+
let cwd: string;
27+
beforeEach(() => { cwd = mkdtempSync(path.join(tmpdir(), 'os-dev-db-')); });
28+
afterEach(() => { try { rmSync(cwd, { recursive: true, force: true }); } catch { /* noop */ } });
29+
30+
const unified = () => `file:${path.join(cwd, '.objectstack', 'data', 'objectstack.db')}`;
631

7-
const CWD = '/proj/app';
8-
const FILE = `file:${path.join(CWD, '.objectstack', 'data', 'dev.db')}`;
32+
describe('resolveDevDatabase — objectstack dev persists by default (#6469 unified)', () => {
33+
it('defaults to the unified project-anchored sqlite file when nothing else is chosen', async () => {
34+
const r = await resolveDevDatabase({ env: {}, cwd });
35+
expect(r).toEqual({ url: unified(), source: 'unified-default' });
36+
});
37+
38+
it('yields to an explicit --database flag (normalized: sqlite:// → file:)', async () => {
39+
expect(await resolveDevDatabase({ databaseFlag: 'postgres://x', env: {}, cwd }))
40+
.toEqual({ url: 'postgres://x', source: 'explicit' });
41+
expect(await resolveDevDatabase({ databaseFlag: 'sqlite:///abs/flag.db', env: {}, cwd }))
42+
.toEqual({ url: 'file:/abs/flag.db', source: 'explicit' });
43+
});
944

10-
describe('resolveDefaultDevDbUrl — objectstack dev persists by default', () => {
11-
it('defaults to a project-anchored sqlite file when nothing else is chosen', () => {
12-
expect(resolveDefaultDevDbUrl({ env: {}, cwd: CWD })).toBe(FILE);
45+
it('yields to --fresh (its own ephemeral temp DB, unified filename)', async () => {
46+
const r = await resolveDevDatabase({ freshDbUrl: 'file:/tmp/x/objectstack.db', env: {}, cwd });
47+
expect(r).toEqual({ url: 'file:/tmp/x/objectstack.db', source: 'explicit' });
1348
});
1449

15-
it('yields to an explicit --database flag', () => {
16-
expect(
17-
resolveDefaultDevDbUrl({ databaseFlag: 'postgres://x', env: {}, cwd: CWD }),
18-
).toBeUndefined();
50+
it('yields to OS_DATABASE_URL / DATABASE_URL / TURSO_DATABASE_URL env', async () => {
51+
expect(await resolveDevDatabase({ env: { OS_DATABASE_URL: 'file:./custom.db' }, cwd }))
52+
.toEqual({ url: 'file:./custom.db', source: 'env' });
53+
expect(await resolveDevDatabase({ env: { DATABASE_URL: 'libsql://x' }, cwd }))
54+
.toEqual({ url: 'libsql://x', source: 'env' });
55+
expect(await resolveDevDatabase({ env: { TURSO_DATABASE_URL: 'libsql://t' }, cwd }))
56+
.toEqual({ url: 'libsql://t', source: 'env' });
1957
});
2058

21-
it('yields to --fresh (its own ephemeral temp DB)', () => {
22-
expect(
23-
resolveDefaultDevDbUrl({ freshDbUrl: 'file:/tmp/x/dev.db', env: {}, cwd: CWD }),
24-
).toBeUndefined();
59+
it('respects an explicit in-memory driver opt-out (no file default imposed)', async () => {
60+
expect(await resolveDevDatabase({ databaseDriverFlag: 'memory', env: {}, cwd }))
61+
.toEqual({ url: 'memory://', source: 'memory-driver' });
62+
expect(await resolveDevDatabase({ env: { OS_DATABASE_DRIVER: 'memory' }, cwd }))
63+
.toEqual({ url: 'memory://', source: 'memory-driver' });
2564
});
2665

27-
it('yields to OS_DATABASE_URL / DATABASE_URL env', () => {
28-
expect(
29-
resolveDefaultDevDbUrl({ env: { OS_DATABASE_URL: 'file:./custom.db' }, cwd: CWD }),
30-
).toBeUndefined();
31-
expect(
32-
resolveDefaultDevDbUrl({ env: { DATABASE_URL: 'libsql://x' }, cwd: CWD }),
33-
).toBeUndefined();
66+
it('treats blank env values as unset (still defaults to the unified file)', async () => {
67+
expect((await resolveDevDatabase({ env: { OS_DATABASE_URL: ' ' }, cwd })).url).toBe(unified());
3468
});
3569

36-
it('respects an explicit in-memory driver opt-out', () => {
37-
expect(
38-
resolveDefaultDevDbUrl({ databaseDriverFlag: 'memory', env: {}, cwd: CWD }),
39-
).toBeUndefined();
40-
expect(
41-
resolveDefaultDevDbUrl({ env: { OS_DATABASE_DRIVER: 'memory' }, cwd: CWD }),
42-
).toBeUndefined();
70+
it('compat-reads a legacy dev.db with one loud notice — never silent, never a prompt', async () => {
71+
const dataDir = path.join(cwd, '.objectstack', 'data');
72+
mkdirSync(dataDir, { recursive: true });
73+
writeFileSync(path.join(dataDir, 'dev.db'), '');
74+
const r = await resolveDevDatabase({ env: {}, cwd });
75+
expect(r.url).toBe(`file:${path.join(dataDir, 'dev.db')}`);
76+
expect(r.source).toBe('legacy-file');
77+
expect(r.notice).toContain('dev.db');
78+
expect(r.notice).toContain('objectstack.db');
4379
});
4480

45-
it('treats blank env values as unset (still defaults to file)', () => {
46-
expect(resolveDefaultDevDbUrl({ env: { OS_DATABASE_URL: ' ' }, cwd: CWD })).toBe(FILE);
81+
it('honours OS_HOME for the state dir (start/migrate already did; dev now agrees)', async () => {
82+
const osHome = path.join(cwd, 'oshome');
83+
const r = await resolveDevDatabase({ env: { OS_HOME: osHome }, cwd });
84+
expect(r.url).toBe(`file:${path.join(osHome, 'data', 'objectstack.db')}`);
4785
});
4886
});

packages/cli/src/commands/dev.ts

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,43 @@ import {
1616
formatMtimeGap,
1717
} from '../utils/dev-restart.js';
1818
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
19+
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';
1920

2021
/**
21-
* Resolve the persistent default database URL for `objectstack dev`.
22+
* Resolve the database URL for `objectstack dev` — dev's flag surface mapped
23+
* onto the ONE shared resolution (`resolveProjectDatabaseUrl`, #6469) that
24+
* `os start` and `os migrate` resolve through too. Priority: `--database` /
25+
* `--fresh`'s ephemeral file → `OS_DATABASE_URL` / `DATABASE_URL` /
26+
* `TURSO_DATABASE_URL` → explicit in-memory driver (`--database-driver memory`
27+
* / `OS_DATABASE_DRIVER=memory`) → the config-declared default datasource →
28+
* the unified default `<state dir>/data/objectstack.db` (legacy `dev.db` /
29+
* `standalone.db` still compat-read, with the loud `notice` line).
2230
*
23-
* `dev` should keep your work between restarts — the historical serve default
31+
* `dev` keeps a persistent default on purpose — the historical serve default
2432
* of `:memory:` wipes all data (and AI-authored metadata) on every restart,
25-
* which makes local app-building unusable. So when the user has NOT chosen a
26-
* database another way, default to a project-anchored sqlite file at
27-
* `<cwd>/.objectstack/data/dev.db` (gitignored, per-project).
33+
* which makes local app-building unusable.
2834
*
29-
* Returns `undefined` (i.e. "don't impose a default") when the user already
30-
* selected a database, so the existing resolution wins:
31-
* - `--database <url>` flag
32-
* - `--fresh` (its own ephemeral temp DB)
33-
* - `OS_DATABASE_URL` / `DATABASE_URL` env
34-
* - an explicit in-memory driver (`--database-driver memory` or
35-
* `OS_DATABASE_DRIVER=memory`)
35+
* This wrapper is dev's ONE resolution seam (pinned, together with start's and
36+
* migrate's, by `unified-db-resolution.pin.test.ts`): it maps inputs, it never
37+
* re-implements any fallback. The runtime import is lazy so oclif's
38+
* import-every-command startup (#5726) does not pay for the runtime graph.
3639
*/
37-
export function resolveDefaultDevDbUrl(opts: {
40+
export async function resolveDevDatabase(opts: {
3841
databaseFlag?: string;
3942
freshDbUrl?: string;
4043
databaseDriverFlag?: string;
4144
env: Record<string, string | undefined>;
4245
cwd: string;
43-
}): string | undefined {
44-
if (opts.databaseFlag || opts.freshDbUrl) return undefined;
45-
const envDbUrl = (opts.env.OS_DATABASE_URL ?? opts.env.DATABASE_URL)?.trim();
46-
if (envDbUrl) return undefined;
47-
const forcedMemory =
48-
opts.databaseDriverFlag === 'memory' || opts.env.OS_DATABASE_DRIVER?.trim() === 'memory';
49-
if (forcedMemory) return undefined;
50-
return `file:${path.join(opts.cwd, '.objectstack', 'data', 'dev.db')}`;
46+
artifactPath?: string;
47+
}): Promise<ResolvedProjectDatabaseUrl> {
48+
const { resolveProjectDatabaseUrl } = await import('@objectstack/runtime');
49+
return resolveProjectDatabaseUrl({
50+
explicitUrl: opts.databaseFlag ?? opts.freshDbUrl,
51+
explicitDriver: opts.databaseDriverFlag,
52+
env: opts.env,
53+
projectRoot: opts.cwd,
54+
artifactPath: opts.artifactPath,
55+
});
5156
}
5257

5358
export default class Dev extends Command {
@@ -228,7 +233,7 @@ export default class Dev extends Command {
228233
// Creates a unique scratch dir that owns the state this command can
229234
// actually place, and nothing more. What it covers, exactly:
230235
// - the dev SQLite DB the CLI resolves for the run
231-
// (OS_HOME → <home>/data/dev.db, published as OS_DATABASE_URL),
236+
// (OS_HOME → <home>/data/objectstack.db, published as OS_DATABASE_URL),
232237
// - the storage-service uploads root, published on the settings
233238
// service's own env name OS_STORAGE_LOCAL_ROOT (#4968),
234239
// - any other state a plugin keys off OS_HOME.
@@ -262,7 +267,9 @@ export default class Dev extends Command {
262267
if (flags.fresh) {
263268
freshHome = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-dev-'));
264269
fs.mkdirSync(path.join(freshHome, 'data'), { recursive: true });
265-
freshDbUrl = `file:${path.join(freshHome, 'data', 'dev.db')}`;
270+
// The unified default filename (#6469) — the same name every command
271+
// resolves, just anchored on this run's ephemeral OS_HOME.
272+
freshDbUrl = `file:${path.join(freshHome, 'data', 'objectstack.db')}`;
266273
freshStorageRoot = path.join(freshHome, 'uploads');
267274
fs.mkdirSync(freshStorageRoot, { recursive: true });
268275
printKV('Fresh OS_HOME', freshHome, '🧪');
@@ -292,23 +299,31 @@ export default class Dev extends Command {
292299
// idempotent (empty-DB only) and never overwrites an existing account.
293300
const seedAdmin = flags['seed-admin'] ?? true;
294301

295-
// Default `dev` to a PERSISTENT, project-anchored sqlite database so
296-
// AI-authored metadata and records survive restarts. The historical
297-
// serve default is `:memory:`, which silently wipes everything on every
298-
// restart — fine for throwaway demos, but it makes local app-building
299-
// unusable (build an app, restart, it's gone). See {@link resolveDefaultDevDbUrl}
300-
// for the opt-out matrix (--fresh / --database / OS_DATABASE_URL / memory driver).
301-
const defaultDevDb = resolveDefaultDevDbUrl({
302+
// Resolve the database through the ONE shared resolution (#6469) —
303+
// `os dev`, `os start` and `os migrate` all land on the same URL for the
304+
// same project directory. `dev` keeps a PERSISTENT default on purpose:
305+
// the historical serve default is `:memory:`, which silently wipes
306+
// everything on every restart — fine for throwaway demos, but it makes
307+
// local app-building unusable (build an app, restart, it's gone). See
308+
// {@link resolveDevDatabase} for the priority ladder.
309+
const resolvedDb = await resolveDevDatabase({
302310
databaseFlag: flags.database,
303311
freshDbUrl,
304312
databaseDriverFlag: flags['database-driver'],
305313
env: process.env,
306314
cwd: process.cwd(),
315+
artifactPath,
307316
});
308-
if (defaultDevDb) {
309-
fs.mkdirSync(path.dirname(defaultDevDb.replace(/^file:/, '')), { recursive: true });
317+
if (resolvedDb.notice) {
318+
// Legacy-file compat-read — one loud line naming the file being read
319+
// and how to converge on the unified default. Never an interactive
320+
// prompt (CI-safe), never silent (#6469).
321+
console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`));
310322
}
311-
const effectiveDb = flags.database ?? freshDbUrl ?? defaultDevDb;
323+
if (resolvedDb.source === 'unified-default') {
324+
fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true });
325+
}
326+
const effectiveDb = resolvedDb.url;
312327
const localEnv: NodeJS.ProcessEnv = {
313328
...process.env,
314329
OS_ENVIRONMENT_ID: environmentId,
@@ -318,14 +333,14 @@ export default class Dev extends Command {
318333
...(seedAdmin && flags['admin-password'] ? { OS_SEED_ADMIN_PASSWORD: flags['admin-password'] } : {}),
319334
...(freshHome ? { OS_HOME: freshHome } : {}),
320335
...(freshStorageRoot ? { OS_STORAGE_LOCAL_ROOT: freshStorageRoot } : {}),
321-
...(effectiveDb ? { OS_DATABASE_URL: effectiveDb } : {}),
336+
OS_DATABASE_URL: effectiveDb,
322337
...(flags['database-driver'] ? { OS_DATABASE_DRIVER: flags['database-driver'] } : {}),
323338
...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}),
324339
...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}),
325340
};
326341
printKV('Environment ID', environmentId, '🎯');
327342
printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦');
328-
if (effectiveDb) printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');
343+
printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');
329344

330345
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
331346
const binPath = process.argv[1];

0 commit comments

Comments
 (0)