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
11 changes: 11 additions & 0 deletions .changeset/serve-unlinked-database-file-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/cli": patch
---

`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path.

Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle.

A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now).

It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine.
34 changes: 33 additions & 1 deletion packages/cli/src/commands/serve-driver-banner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('describeRegisteredDriver', () => {

expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: './data.db' } } },
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db' });
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db', sqliteFile: './data.db' });

expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
Expand Down Expand Up @@ -133,10 +133,42 @@ describe('describeRegisteredDriver', () => {
expect(describeRegisteredDriver(kernel)).toEqual({
label: 'SqliteWasmDriver',
url: './app.wasm.db',
sqliteFile: './app.wasm.db',
});
});

it('returns null when no known driver is registered', () => {
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
});

// `sqliteFile` is the SAME probe read a second time asking a different
// question: not "what do I print" but "which file on this filesystem", so
// the boot can `stat` it and notice later that the file it opened is no
// longer the file at that path. It is the plumbing between the driver and
// that watch, and nothing else pins it — a probe that stopped answering it
// would leave the watch silently un-armed on every boot, which is
// byte-identical to a deployment where nothing ever went wrong.
describe('sqliteFile — the on-disk file the boot is serving', () => {
it('answers the sqlite connection filename', () => {
expect(describeRegisteredDriver(fakeKernel({
'driver.com.objectstack.driver.sql': {
config: { client: 'better-sqlite3', connection: { filename: '/app/.objectstack/data/objectstack.db' } },
},
}))?.sqliteFile).toBe('/app/.objectstack/data/objectstack.db');
});

it('is absent for every driver that serves no file on disk', () => {
expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: ':memory:' } } },
}))?.sqliteFile).toBeUndefined();

expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
}))?.sqliteFile).toBeUndefined();

expect(describeRegisteredDriver(fakeKernel({
'driver.memory': { constructor: { name: 'InMemoryDriver' }, config: {} },
}))?.sqliteFile).toBeUndefined();
});
});
});
54 changes: 51 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ import {
stackDeclaresMetadata,
bundleDeclaresTranslations,
} from '../utils/stack-collections.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
// The posture prose `os serve` and `os doctor` BOTH print, declared once
// (#12492) — and, since #12579, the multi-org runtime SPELLING those two
// commands put in front of the same operator, declared once with it.
Expand Down Expand Up @@ -2099,6 +2100,11 @@ export default class Serve extends Command {
// is idempotent, so the second pass over an already-clean URL is a no-op.
let resolvedDriverLabel: string | undefined;
let resolvedDatabaseUrl: string | undefined;
// The on-disk SQLite file this boot serves, when it serves one. Kept apart
// from `resolvedDatabaseUrl` on purpose: that value is a string for a
// human and may be redacted or labelled, while this one is handed to
// `stat` — see `describeDriverSqliteFile`.
let servedSqliteFilePath: string | undefined;

// Resolve the kernel logger level up front. It decides more than the
// logger's own threshold: it decides whether the boot-quiet window below
Expand Down Expand Up @@ -2718,6 +2724,7 @@ export default class Serve extends Command {
trackPlugin(resolution.trackName);
resolvedDriverLabel = resolution.label;
resolvedDatabaseUrl = resolution.displayUrl;
servedSqliteFilePath = resolution.sqliteFilePath;

// ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry`
// datasource — a sibling SQLite file the engine routes every
Expand Down Expand Up @@ -4588,6 +4595,7 @@ export default class Serve extends Command {
if (probe) {
resolvedDriverLabel = probe.label;
resolvedDatabaseUrl = probe.url;
servedSqliteFilePath = probe.sqliteFile;
}
} catch {
// best-effort only
Expand Down Expand Up @@ -4756,6 +4764,35 @@ export default class Serve extends Command {
// old order lost, and the reason the repair is not reader-side polling.
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner));

// ── Watch the served database file's identity ──────────────────
// Deleting the data directory under a running server (`rm -rf
// .objectstack/data`, what `demo:reset` does) unlinks the inode without
// touching this process: SQLite keeps serving the now-invisible file,
// health keeps answering 200, and a later boot creates a brand-new
// database at the same path. From then on every filesystem inspection of
// that path describes a DIFFERENT file than this server answers from —
// which is how "the row I edited had no effect" and "the user who just
// authenticated is not in the database" become simultaneously true
// readings of a healthy deployment.
//
// Started only once the boot is otherwise complete, so a failure here
// can never be mistaken for a boot problem, and only when an on-disk
// SQLite file is actually being served. It refuses nothing and reports
// once — see `served-database-file.ts` for why both.
if (servedSqliteFilePath) {
const openedDatabaseFile = captureServedDatabaseFile(servedSqliteFilePath);
if (openedDatabaseFile) {
watchServedDatabaseFile({
opened: openedDatabaseFile,
// `error`, and to stderr: by the degradation-log-level rule this is
// the durability/consistency class — the system keeps looking
// normal while what it claims is persisted is not at the path it
// names.
onDiverged: (message) => { console.error(chalk.red(message)); },
});
}
}

// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.
// No duplicate handler needed here — just keep the process alive.

Expand Down Expand Up @@ -6121,7 +6158,9 @@ function emitMultiNodeCapTelemetry(
* arrives in, so a DSN-declared datasource no longer falls through to
* `(unknown)`, and no shape prints credentials.
*/
export function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
export function describeRegisteredDriver(
kernel: any,
): { label: string; url: string; sqliteFile?: string } | null {
const candidates = [
'driver.com.objectstack.driver.sql',
'driver.com.objectstack.driver.mongodb',
Expand Down Expand Up @@ -6153,7 +6192,16 @@ export function describeRegisteredDriver(kernel: any): { label: string; url: str

// A memory driver has no address to show — say so, rather than
// `(unknown)`, which reads as "we looked for one and failed".
return { label, url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)') };
//
// `sqliteFile` is the same config read a second time asking a DIFFERENT
// question: not "what do I print" but "which file on this filesystem", so
// a caller can `stat` it. Absent for every driver that serves no on-disk
// SQLite file, which is the majority of them.
return {
label,
url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)'),
sqliteFile: describeDriverSqliteFile(cfg),
};
}
return null;
}
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/src/utils/connection-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// shape leaks the credentials the DSN carries.

import { describe, it, expect } from 'vitest';
import { redactConnectionUrl, describeDriverConnection } from './connection-display.js';
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js';

describe('redactConnectionUrl', () => {
it('drops the userinfo segment from a DSN', () => {
Expand Down Expand Up @@ -124,3 +124,36 @@ describe('describeDriverConnection', () => {
expect(describeDriverConnection({ client: 'pg', connection: () => ({ host: 'h' }) })).toBeUndefined();
});
});

describe('describeDriverSqliteFile', () => {
// A SECOND question over the same config shapes: not "what do I print" but
// "which file on this filesystem", so the answer can be handed to `stat`.
it('reads the knex sqlite connection filename', () => {
expect(describeDriverSqliteFile({
client: 'better-sqlite3',
connection: { filename: '/app/.objectstack/data/objectstack.db' },
})).toBe('/app/.objectstack/data/objectstack.db');
});

it('reads a filename kept at the top level', () => {
expect(describeDriverSqliteFile({ filename: '/app/data.db' })).toBe('/app/data.db');
});

it('refuses the in-memory database, which is not a file on disk', () => {
expect(describeDriverSqliteFile({ connection: { filename: ':memory:' } })).toBeUndefined();
});

it('refuses the empty name, SQLite\'s private temporary database', () => {
expect(describeDriverSqliteFile({ connection: { filename: ' ' } })).toBeUndefined();
});

it('answers undefined for a driver that serves no file', () => {
expect(describeDriverSqliteFile({ url: 'mongodb://db.example.com/app' })).toBeUndefined();
expect(describeDriverSqliteFile({
client: 'pg',
connection: { host: 'db.example.com', port: 5432, database: 'app' },
})).toBeUndefined();
expect(describeDriverSqliteFile(undefined)).toBeUndefined();
expect(describeDriverSqliteFile('postgres://db.example.com/app')).toBeUndefined();
});
});
27 changes: 27 additions & 0 deletions packages/cli/src/utils/connection-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,30 @@ export function describeDriverConnection(config: unknown): string | undefined {
return readTarget(conn && typeof conn === 'object' ? conn as Record<string, unknown> : undefined)
?? readTarget(cfg);
}

/**
* The on-disk SQLite file a driver config names, or `undefined` when it names
* none.
*
* A SECOND question over the same shapes {@link describeDriverConnection}
* already knows — and deliberately not a re-read of that function's output.
* That one renders a string for a human and is free to redact, abbreviate or
* label (`(in-memory)`, `(unknown)`); this one answers "which file on this
* filesystem", which is an identity, and an identity parsed back out of a
* display string is one redaction rule away from being wrong.
*
* `:memory:` and the empty name (SQLite's private temporary database) are not
* files on disk and are refused here, so a caller can hand the result straight
* to `stat` without re-deciding.
*/
export function describeDriverSqliteFile(config: unknown): string | undefined {
if (!config || typeof config !== 'object') return undefined;
const cfg = config as Record<string, unknown>;
const conn = cfg.connection;
const bag = conn && typeof conn === 'object' ? conn as Record<string, unknown> : cfg;
const filename = bag.filename ?? cfg.filename;
if (typeof filename !== 'string') return undefined;
const trimmed = filename.trim();
if (!trimmed || trimmed === ':memory:') return undefined;
return trimmed;
}
Loading
Loading