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
12 changes: 12 additions & 0 deletions .changeset/objectstack-dev-persist-by-default.md
Original file line number Diff line number Diff line change
@@ -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 `<cwd>/.objectstack/data/dev.db` (gitignored, per-project). Existing opt-outs are unchanged and take precedence: `--fresh` (ephemeral temp DB), `--database <url>`, `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.
10 changes: 4 additions & 6 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 `<project>/.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],
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/commands/dev-default-db.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 50 additions & 1 deletion packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<cwd>/.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 <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`)
*/
export function resolveDefaultDevDbUrl(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')}`;
}

export default class Dev extends Command {
static override description = 'Start development mode with hot-reload';

Expand Down Expand Up @@ -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,
Expand Down