diff --git a/.changeset/cli-dev-sqlite-seed-admin.md b/.changeset/cli-dev-sqlite-seed-admin.md new file mode 100644 index 000000000..3ae75bf0d --- /dev/null +++ b/.changeset/cli-dev-sqlite-seed-admin.md @@ -0,0 +1,18 @@ +--- +"@objectstack/cli": minor +--- + +`objectstack dev` now defaults to SQLite and auto-seeds an admin. + +- **Default driver → SQLite.** With no `OS_DATABASE_URL`/`OS_DATABASE_DRIVER`, + dev now prefers `SqlDriver(sqlite, :memory:)` over the pure-JS `InMemoryDriver` + for production-like SQL semantics. It probes by opening a connection (knex + loads `better-sqlite3` lazily at first query) and falls back to + `InMemoryDriver` **with a warning** if the native binary is unavailable — + closing a hole where the surrounding silent catch could leave the kernel with + no driver. +- **`--seed-admin` defaults ON in dev.** Idempotent and non-destructive: POSTs + the public sign-up endpoint, creating `admin@objectos.ai` only on an empty DB + (then promoted to platform admin) and skipping when the email already exists + (422/400), so a custom password is never overwritten. Disable with + `--no-seed-admin`. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index af675c3f0..5709ad91b 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -71,7 +71,7 @@ export default class Dev extends Command { default: false, }), 'seed-admin': Flags.boolean({ - description: 'After the server is ready, POST /api/v1/auth/sign-up/email to seed an admin account. Default: on when --fresh.', + description: 'After the server is ready, POST /api/v1/auth/sign-up/email to seed an admin account. Default: on (idempotent — creates the admin only on an empty DB, skips if the email already exists). Disable with --no-seed-admin.', allowNo: true, }), 'admin-email': Flags.string({ @@ -206,12 +206,19 @@ export default class Dev extends Command { ); // ── Seed admin account after the server is ready ──────────────── - // `--seed-admin` defaults to ON when `--fresh` is set; otherwise - // OFF (so we don't surprise long-lived dev DBs with extra users). - // Uses better-auth's public sign-up endpoint, which the dev-mode - // bootstrap bypass (auth-manager.ts) lets through even when - // OS_DISABLE_SIGNUP=true for the very first user. - const seedAdmin = flags['seed-admin'] ?? flags.fresh; + // `--seed-admin` defaults to ON in dev. The seed is idempotent and + // NON-DESTRUCTIVE: it POSTs the public sign-up endpoint, which creates + // `admin@objectos.ai` only on an EMPTY DB (then `bootstrapPlatformAdmin` + // in @objectstack/plugin-security promotes that first user to platform + // admin). When the email already exists — i.e. a persistent dev DB you + // signed up against before — better-auth returns 422/400 and we skip + // (see the `r.status === 422 || 400` branch below), so a custom password + // is never overwritten. There's therefore no need to gate on ephemeral + // vs persistent: seeding an empty DB is exactly what you want, and a + // populated DB is left untouched. + // The dev-mode bootstrap bypass (auth-manager.ts) lets the very first + // sign-up through even when OS_DISABLE_SIGNUP=true. + const seedAdmin = flags['seed-admin'] ?? true; if (seedAdmin) { void this.seedAdminAccount({ port: port ?? '3000', diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 9398efb1a..fef6ac747 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -593,12 +593,52 @@ export default class Serve extends Command { resolvedDriverLabel = 'SqlDriver(mysql2)'; resolvedDatabaseUrl = databaseUrl; } else if (isDev) { - // Default in dev: in-memory driver - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - await kernel.use(new DriverPlugin(new InMemoryDriver())); - trackPlugin('MemoryDriver'); - resolvedDriverLabel = 'InMemoryDriver'; - resolvedDatabaseUrl = '(in-memory)'; + // Default in dev: prefer SQLite for production-like SQL semantics, + // falling back to the pure-JS in-memory driver (mingo) only when the + // native `better-sqlite3` binary is unavailable — not built, ABI + // mismatch after a Node upgrade, or a blocked prebuild download. + // + // knex loads its client lazily (at first query, not at construction), + // so the only reliable signal inside this registration window is to + // actually open a connection: connect() runs `SELECT 1`, which forces + // better-sqlite3 to load. If that throws we drop to the in-memory + // driver instead of letting the failure surface much later — as a + // missing-module crash on the first real query — or be swallowed by + // the silent catch below, leaving the kernel with no driver at all. + let sqliteDriver: any; + let sqliteOk = false; + try { + const { SqlDriver } = await import('@objectstack/driver-sql'); + sqliteDriver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await sqliteDriver.connect(); + sqliteOk = true; + } catch { + sqliteOk = false; + if (sqliteDriver?.disconnect) { + try { await sqliteDriver.disconnect(); } catch { /* ignore */ } + } + } + + if (sqliteOk) { + await kernel.use(new DriverPlugin(sqliteDriver)); + trackPlugin('SqlDriver'); + resolvedDriverLabel = 'SqlDriver(sqlite)'; + resolvedDatabaseUrl = ':memory:'; + } else { + const { InMemoryDriver } = await import('@objectstack/driver-memory'); + await kernel.use(new DriverPlugin(new InMemoryDriver())); + trackPlugin('MemoryDriver'); + resolvedDriverLabel = 'InMemoryDriver'; + resolvedDatabaseUrl = '(in-memory)'; + console.warn(chalk.yellow( + ' ⚠ better-sqlite3 unavailable — dev falling back to InMemoryDriver (mingo, not real SQL).\n' + + ' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.' + )); + } } } catch (e: any) { // silent