Skip to content

Commit 0c6c55e

Browse files
os-litantclaude
andauthored
fix(cli): os serve reports when the SQLite file it is serving is no longer the file at its path (#15730)
* wip(cli): served database file identity watch * test(cli): pin the served database file identity check * test(cli): pin the driver probe's sqliteFile answer * changeset: os serve reports an unlinked served database file --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dec957 commit 0c6c55e

7 files changed

Lines changed: 697 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path.
6+
7+
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.
8+
9+
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).
10+
11+
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.

packages/cli/src/commands/serve-driver-banner.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('describeRegisteredDriver', () => {
4949

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

5454
expect(describeRegisteredDriver(fakeKernel({
5555
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
@@ -133,10 +133,42 @@ describe('describeRegisteredDriver', () => {
133133
expect(describeRegisteredDriver(kernel)).toEqual({
134134
label: 'SqliteWasmDriver',
135135
url: './app.wasm.db',
136+
sqliteFile: './app.wasm.db',
136137
});
137138
});
138139

139140
it('returns null when no known driver is registered', () => {
140141
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
141142
});
143+
144+
// `sqliteFile` is the SAME probe read a second time asking a different
145+
// question: not "what do I print" but "which file on this filesystem", so
146+
// the boot can `stat` it and notice later that the file it opened is no
147+
// longer the file at that path. It is the plumbing between the driver and
148+
// that watch, and nothing else pins it — a probe that stopped answering it
149+
// would leave the watch silently un-armed on every boot, which is
150+
// byte-identical to a deployment where nothing ever went wrong.
151+
describe('sqliteFile — the on-disk file the boot is serving', () => {
152+
it('answers the sqlite connection filename', () => {
153+
expect(describeRegisteredDriver(fakeKernel({
154+
'driver.com.objectstack.driver.sql': {
155+
config: { client: 'better-sqlite3', connection: { filename: '/app/.objectstack/data/objectstack.db' } },
156+
},
157+
}))?.sqliteFile).toBe('/app/.objectstack/data/objectstack.db');
158+
});
159+
160+
it('is absent for every driver that serves no file on disk', () => {
161+
expect(describeRegisteredDriver(fakeKernel({
162+
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: ':memory:' } } },
163+
}))?.sqliteFile).toBeUndefined();
164+
165+
expect(describeRegisteredDriver(fakeKernel({
166+
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
167+
}))?.sqliteFile).toBeUndefined();
168+
169+
expect(describeRegisteredDriver(fakeKernel({
170+
'driver.memory': { constructor: { name: 'InMemoryDriver' }, config: {} },
171+
}))?.sqliteFile).toBeUndefined();
172+
});
173+
});
142174
});

packages/cli/src/commands/serve.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ import {
7474
stackDeclaresMetadata,
7575
bundleDeclaresTranslations,
7676
} from '../utils/stack-collections.js';
77-
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
77+
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
78+
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
7879
// The posture prose `os serve` and `os doctor` BOTH print, declared once
7980
// (#12492) — and, since #12579, the multi-org runtime SPELLING those two
8081
// commands put in front of the same operator, declared once with it.
@@ -2099,6 +2100,11 @@ export default class Serve extends Command {
20992100
// is idempotent, so the second pass over an already-clean URL is a no-op.
21002101
let resolvedDriverLabel: string | undefined;
21012102
let resolvedDatabaseUrl: string | undefined;
2103+
// The on-disk SQLite file this boot serves, when it serves one. Kept apart
2104+
// from `resolvedDatabaseUrl` on purpose: that value is a string for a
2105+
// human and may be redacted or labelled, while this one is handed to
2106+
// `stat` — see `describeDriverSqliteFile`.
2107+
let servedSqliteFilePath: string | undefined;
21022108

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

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

4767+
// ── Watch the served database file's identity ──────────────────
4768+
// Deleting the data directory under a running server (`rm -rf
4769+
// .objectstack/data`, what `demo:reset` does) unlinks the inode without
4770+
// touching this process: SQLite keeps serving the now-invisible file,
4771+
// health keeps answering 200, and a later boot creates a brand-new
4772+
// database at the same path. From then on every filesystem inspection of
4773+
// that path describes a DIFFERENT file than this server answers from —
4774+
// which is how "the row I edited had no effect" and "the user who just
4775+
// authenticated is not in the database" become simultaneously true
4776+
// readings of a healthy deployment.
4777+
//
4778+
// Started only once the boot is otherwise complete, so a failure here
4779+
// can never be mistaken for a boot problem, and only when an on-disk
4780+
// SQLite file is actually being served. It refuses nothing and reports
4781+
// once — see `served-database-file.ts` for why both.
4782+
if (servedSqliteFilePath) {
4783+
const openedDatabaseFile = captureServedDatabaseFile(servedSqliteFilePath);
4784+
if (openedDatabaseFile) {
4785+
watchServedDatabaseFile({
4786+
opened: openedDatabaseFile,
4787+
// `error`, and to stderr: by the degradation-log-level rule this is
4788+
// the durability/consistency class — the system keeps looking
4789+
// normal while what it claims is persisted is not at the path it
4790+
// names.
4791+
onDiverged: (message) => { console.error(chalk.red(message)); },
4792+
});
4793+
}
4794+
}
4795+
47594796
// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.
47604797
// No duplicate handler needed here — just keep the process alive.
47614798

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

61546193
// A memory driver has no address to show — say so, rather than
61556194
// `(unknown)`, which reads as "we looked for one and failed".
6156-
return { label, url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)') };
6195+
//
6196+
// `sqliteFile` is the same config read a second time asking a DIFFERENT
6197+
// question: not "what do I print" but "which file on this filesystem", so
6198+
// a caller can `stat` it. Absent for every driver that serves no on-disk
6199+
// SQLite file, which is the majority of them.
6200+
return {
6201+
label,
6202+
url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)'),
6203+
sqliteFile: describeDriverSqliteFile(cfg),
6204+
};
61576205
}
61586206
return null;
61596207
}

packages/cli/src/utils/connection-display.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// shape leaks the credentials the DSN carries.
1313

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

1717
describe('redactConnectionUrl', () => {
1818
it('drops the userinfo segment from a DSN', () => {
@@ -124,3 +124,36 @@ describe('describeDriverConnection', () => {
124124
expect(describeDriverConnection({ client: 'pg', connection: () => ({ host: 'h' }) })).toBeUndefined();
125125
});
126126
});
127+
128+
describe('describeDriverSqliteFile', () => {
129+
// A SECOND question over the same config shapes: not "what do I print" but
130+
// "which file on this filesystem", so the answer can be handed to `stat`.
131+
it('reads the knex sqlite connection filename', () => {
132+
expect(describeDriverSqliteFile({
133+
client: 'better-sqlite3',
134+
connection: { filename: '/app/.objectstack/data/objectstack.db' },
135+
})).toBe('/app/.objectstack/data/objectstack.db');
136+
});
137+
138+
it('reads a filename kept at the top level', () => {
139+
expect(describeDriverSqliteFile({ filename: '/app/data.db' })).toBe('/app/data.db');
140+
});
141+
142+
it('refuses the in-memory database, which is not a file on disk', () => {
143+
expect(describeDriverSqliteFile({ connection: { filename: ':memory:' } })).toBeUndefined();
144+
});
145+
146+
it('refuses the empty name, SQLite\'s private temporary database', () => {
147+
expect(describeDriverSqliteFile({ connection: { filename: ' ' } })).toBeUndefined();
148+
});
149+
150+
it('answers undefined for a driver that serves no file', () => {
151+
expect(describeDriverSqliteFile({ url: 'mongodb://db.example.com/app' })).toBeUndefined();
152+
expect(describeDriverSqliteFile({
153+
client: 'pg',
154+
connection: { host: 'db.example.com', port: 5432, database: 'app' },
155+
})).toBeUndefined();
156+
expect(describeDriverSqliteFile(undefined)).toBeUndefined();
157+
expect(describeDriverSqliteFile('postgres://db.example.com/app')).toBeUndefined();
158+
});
159+
});

packages/cli/src/utils/connection-display.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,30 @@ export function describeDriverConnection(config: unknown): string | undefined {
9898
return readTarget(conn && typeof conn === 'object' ? conn as Record<string, unknown> : undefined)
9999
?? readTarget(cfg);
100100
}
101+
102+
/**
103+
* The on-disk SQLite file a driver config names, or `undefined` when it names
104+
* none.
105+
*
106+
* A SECOND question over the same shapes {@link describeDriverConnection}
107+
* already knows — and deliberately not a re-read of that function's output.
108+
* That one renders a string for a human and is free to redact, abbreviate or
109+
* label (`(in-memory)`, `(unknown)`); this one answers "which file on this
110+
* filesystem", which is an identity, and an identity parsed back out of a
111+
* display string is one redaction rule away from being wrong.
112+
*
113+
* `:memory:` and the empty name (SQLite's private temporary database) are not
114+
* files on disk and are refused here, so a caller can hand the result straight
115+
* to `stat` without re-deciding.
116+
*/
117+
export function describeDriverSqliteFile(config: unknown): string | undefined {
118+
if (!config || typeof config !== 'object') return undefined;
119+
const cfg = config as Record<string, unknown>;
120+
const conn = cfg.connection;
121+
const bag = conn && typeof conn === 'object' ? conn as Record<string, unknown> : cfg;
122+
const filename = bag.filename ?? cfg.filename;
123+
if (typeof filename !== 'string') return undefined;
124+
const trimmed = filename.trim();
125+
if (!trimmed || trimmed === ':memory:') return undefined;
126+
return trimmed;
127+
}

0 commit comments

Comments
 (0)