diff --git a/.changeset/objectstack-dev-persist-by-default.md b/.changeset/objectstack-dev-persist-by-default.md new file mode 100644 index 000000000..5f3ea7982 --- /dev/null +++ b/.changeset/objectstack-dev-persist-by-default.md @@ -0,0 +1,12 @@ +--- +'@objectstack/cli': minor +'@objectstack/example-showcase': patch +--- + +fix(cli): `objectstack dev` persists data by default (no more `:memory:` wipe on restart) + +`objectstack dev` historically fell back to a `:memory:` SQLite database when no `--database` / `OS_DATABASE_URL` was given, so **every restart silently wiped all data and AI-authored metadata** — you'd build an app, restart, and it would be gone, which makes local app-building unusable. + +`dev` now defaults to a persistent, project-anchored SQLite file at `/.objectstack/data/dev.db` (gitignored, per-project). Existing opt-outs are unchanged and take precedence: `--fresh` (ephemeral temp DB), `--database `, `OS_DATABASE_URL`/`DATABASE_URL`, or an explicit in-memory driver (`--database-driver memory` / `OS_DATABASE_DRIVER=memory`). Resolution is extracted into the testable `resolveDefaultDevDbUrl()` helper. + +The **app-showcase** example drops its explicit `:memory:` datasource override (which would otherwise route data back to memory and defeat the new default), so it persists across restarts out of the box. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 2123b48dc..743a0cbd7 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -24,7 +24,6 @@ import { } from './src/security/index.js'; import { allThemes } from './src/themes/index.js'; import { ShowcaseTranslationBundle } from './src/translations/index.js'; -import { allDatasources } from './src/datasources/index.js'; import { allPortals } from './src/portals/index.js'; import { ShowcaseSeedData } from './src/data/index.js'; @@ -91,11 +90,10 @@ export default defineStack({ ], // Infrastructure - datasources: allDatasources, - datasourceMapping: [ - { namespace: 'showcase', datasource: 'showcase_primary' }, - { default: true, datasource: 'showcase_primary' }, - ], + // No explicit datasource: the standalone CLI anchors a persistent sqlite + // database at `/.objectstack/data/standalone.db`, so data and + // AI-authored metadata survive restarts (a `:memory:` datasource would wipe + // everything on every restart, which makes local app-building unusable). // i18n translations: [ShowcaseTranslationBundle], diff --git a/packages/cli/src/commands/dev-default-db.test.ts b/packages/cli/src/commands/dev-default-db.test.ts new file mode 100644 index 000000000..0c3628be4 --- /dev/null +++ b/packages/cli/src/commands/dev-default-db.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { resolveDefaultDevDbUrl } from './dev.js'; + +const CWD = '/proj/app'; +const FILE = `file:${path.join(CWD, '.objectstack', 'data', 'dev.db')}`; + +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 an explicit --database flag', () => { + expect( + resolveDefaultDevDbUrl({ databaseFlag: 'postgres://x', env: {}, cwd: CWD }), + ).toBeUndefined(); + }); + + it('yields to --fresh (its own ephemeral temp DB)', () => { + expect( + resolveDefaultDevDbUrl({ freshDbUrl: 'file:/tmp/x/dev.db', env: {}, cwd: CWD }), + ).toBeUndefined(); + }); + + 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('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('treats blank env values as unset (still defaults to file)', () => { + expect(resolveDefaultDevDbUrl({ env: { OS_DATABASE_URL: ' ' }, cwd: CWD })).toBe(FILE); + }); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 4b53672cf..401bc51a5 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -10,6 +10,39 @@ import path from 'path'; import { printHeader, printKV, printStep, printError } from '../utils/format.js'; import { readEnvWithDeprecation } from '@objectstack/types'; +/** + * Resolve the persistent default database URL for `objectstack dev`. + * + * `dev` should keep your work between restarts — 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). + * + * 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`) + */ +export function resolveDefaultDevDbUrl(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')}`; +} + export default class Dev extends Command { static override description = 'Start development mode with hot-reload'; @@ -186,7 +219,23 @@ export default class Dev extends Command { // idempotent (empty-DB only) and never overwrites an existing account. const seedAdmin = flags['seed-admin'] ?? true; - const effectiveDb = flags.database ?? freshDbUrl; + // 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({ + databaseFlag: flags.database, + freshDbUrl, + databaseDriverFlag: flags['database-driver'], + env: process.env, + cwd: process.cwd(), + }); + if (defaultDevDb) { + fs.mkdirSync(path.dirname(defaultDevDb.replace(/^file:/, '')), { recursive: true }); + } + const effectiveDb = flags.database ?? freshDbUrl ?? defaultDevDb; const localEnv: NodeJS.ProcessEnv = { ...process.env, OS_ENVIRONMENT_ID: environmentId,