Skip to content

Commit cf0346e

Browse files
fix(driver-sql): os migrate plan on a new database prints no DATABASE_ERROR for the tables whose DDL it deferred (#20821) (#21093)
Fixes #20821 Clause-②: no `os migrate plan` against a database that does not exist yet printed six `[sql-driver] DATABASE_ERROR … no such table` lines on stderr. The plan boots with the SQL driver's DDL deferred, lists every table as pending `create_table`, and then the same boot reads `sys_metadata`, `sys_metadata_activation` and `sys_migration`. Every one of those reads was refused, and every reader already answered from the refusal. Only the driver's warn line was wrong. This follows the seat answer on the card (5924276655): Q1 **A**, a driver-side demotion keyed on the driver's own deferral; Q2 **A**, the pin is scoped to the three deferred tables. ## The six readers (traced with a stack per line at `b253fadfb`; unchanged at this head) | table | reader | when | |:--|:--|:--| | `sys_metadata` | `ObjectQLPlugin.restoreMetadataFromDb` → `ObjectStackProtocolImplementation.loadMetaFromDb` (`isMissingTableError` → `loaded: 0`) | `ObjectQLPlugin.start()`, phase 2 | | `sys_metadata` | `readAuthoredTranslationLayer` (`core`, `fallbacks/authored-translation-sync.ts`) → `null` | `kernel:ready` | | `sys_metadata` | `ObjectQLPlugin.readAuthoredHookRows` via `resyncAuthoredHooksNow` → `null` | `kernel:ready` | | `sys_metadata` | `ObjectQLPlugin.readAuthoredActionRows` via `resyncAuthoredActionsNow` → `null` | `kernel:ready` | | `sys_metadata_activation` | `ObjectStoreActionActivationStore.probe` via `ObjectQLPlugin.hydrateActionActivations` → its own functional warn | `kernel:ready` | | `sys_migration` | `ObjectQL.announceOpenMigrationGates` → `readMigrationFlagVerified` → "not verified, not conclusive" | `kernel:bootstrapped` | The `DATABASE_ERROR` line is written inside `SqlDriver.backendStatementFault`, before any of these readers sees the error. So a reader-side catch cannot remove it. "Not asked" was measured and declined: no reader can see the deferral, because no `IDataDriver` member and no kernel key carries it. Deferred is also not the same as absent: on an existing database the deferral records every object before any `hasTable`. Making "not asked" honest would need a new contract fact, and `Clause-②` would become yes. ## The change `packages/drivers/driver-sql/src/sql-driver.ts`, `SqlDriver.backendStatementFault`, in the warn decision only. A refused statement goes to `logger.debug` instead of `logger.warn` only when all three conditions hold: 1. this driver has DDL deferred (`deferredDdl`); 2. the targeted table is in this driver's own `deferredSchemaObjects`; 3. `isMissingTableError(envelope, object)` holds. This is the one shared predicate, asked over the envelope's declared target. No second message regex. The throw and the envelope (`DATABASE_ERROR`, status 500) are unchanged. Every other refusal still warns: a malformed statement on a table that exists, a missing table the driver did not defer, and anything after `flushDeferredSchemaDdl` (which clears both the flag and the set). The new branch sits beside PR #20818's pre-DDL scope check. It is disjoint from the PR #20988 hunks, the JSON-column gate region and the JSON-membership helpers. ### Kept on purpose - **The stdout WARN from `ObjectQLPlugin.hydrateActionActivations`** (`sys_metadata_activation is registered but could not be read — packaged-ACTION enable/disable is UNAVAILABLE on this deployment …`) is kept. It is the reader's own functional line, not the driver's, and the stdout-identity pin requires it. On a dry run against an absent file it is a false alarm, and that is recorded here. - **The `examples/app-crm` app-hook lines** (`sys_position` ×3, `sys_permission_set` ×3) are kept. They come from the app's `onEnable` `kernel:bootstrapped` hook, which reads tables the plan's composition never declares. That is a different door: #21054 is not addressed here, and the pin does not count those lines. ## Before and after on `examples/app-crm` `node ../../packages/cli/bin/run.js migrate plan [--json] --database-url file:ABSENT.sqlite`. The base is `576afc17b` (before the fix). The head is `ea9309b80`, rebuilt. | reading | base | head | |:--|:--|:--| | `DATABASE_ERROR` lines on stderr | 12 | 6 | | … naming `sys_metadata` / `sys_metadata_activation` / `sys_migration` | 6 (4 / 1 / 1) | **0** | | stderr diff | — | exactly those 6 lines removed, 0 added | | human stdout, normalised for timestamps and `Nms` durations | — | `diff` exit 0 | | the plan block (`ℹ Database:` … `Apply with:`) md5 | `e102064c7b13bce96303c1e9b698693d` | `e102064c7b13bce96303c1e9b698693d` | | `--json` stdout with `duration` removed | — | byte-identical (`cmp` exit 0) | | database file left behind | none | none | ## Tests - **Driver pin, new:** `packages/drivers/driver-sql/src/sql-driver-20821-deferred-ddl-missing-table.test.ts`, on a real SQLite file. Every case asserts the envelope `code` and `status`. - ① DDL deferred, the table in the deferred set and missing: the refusal goes to `debug` (with `no such table`), and `warn` stays empty. After `flushDeferredSchemaDdl` the same read answers `[]` with nothing logged. - ② Control: a malformed read (40,000 bound variables) on an existing table whose DDL is deferred still warns. - ③ Control: a missing table outside the deferred set, on the same deferred driver, still warns. - **CLI pin, new:** `packages/cli/src/commands/migrate/plan.deferred-reads.integration.test.ts` (integration tier: it spawns `bin/run-dev.js`). Its fixture is one host config with a lookup field. On the base, that fixture reproduced exactly the card's 6 lines (4 / 1 / 1). In human mode and under `--json`, it asserts: - 0 `DATABASE_ERROR` lines naming the three tables; - the reads still answered: the open `[value-shape]` gate is announced, and the activation WARN is present; - each of the three tables is still pending `create_table`; - no file is written. - **Suites at `ea9309b80`:** - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`: 204 files passed, 11 skipped; 3285 tests passed, 188 skipped. The skips are the live PG and MySQL cells, which are not provisioned here. - `@objectstack/cli` `--project unit`: 240 files, 3419 tests passed. - `@objectstack/cli` `--project integration`, run on this PR's file plus `schema-migrate.readonly-probe` and `schema-migrate.deferred-ddl`: 3 files, 9 tests passed. - **NOT MEASURED: the full cli integration tier.** Reason: it ran past the 10-minute foreground cap here (exit 124 at 595s). It is declared to CI. - `typecheck` for `driver-sql` and `cli` is green, including `cli`'s `check:test-typecheck` (debt unchanged). The new cli pin is inside `tsc`'s program, because `src` is the `include`. ## Reverse verification (from the committed fix, through `scripts/ablation-replace.mjs`) 1. **The deferred-set condition removed** (anchor `this.deferredSchemaObjects.has(targetedTable) &&`, 1 → 0; blob `c626b59d` → `fecb8704`). The driver pin turned **red on ③ only** (`expected [] to have a length of 1 but got +0`), and ① and ② stayed green. The driver pin imports `src`, so no build was involved. Restore: blob `c626b59d` equals HEAD, and `git diff HEAD` is empty. 2. **The debug branch removed** (the whole `if` block, 1 → 0; blob `c626b59d` → `e7acc7e7`). `driver-sql` was rebuilt. `ablation-dist-preflight --absent 'this driver deferred its DDL'` found the marker absent from all 6 built files. The CLI pin turned **red on both cases** (`expected [ …(6) ] to deeply equal []`). Restore: blob equals HEAD and `git diff HEAD` is empty. After a rebuild, the preflight found the marker present in 2 built files and the tree clean against HEAD, and both pins were green again. This leg was run again at `fd9f151a4`, after the pin's spawn harness changed: same result. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ea9309b80` derived 66 families. All 66 were run, with exit codes recorded before any pipe, and all exited 0. `--ran` reports: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered `PREREQUISITE NOT MET` (exit 3) on the earlier merge head, because nine packages had no `dist/`. Those packages were built and both gates re-ran green. - The derivation warned that the tree was behind `origin/main` and named two of its inputs as changed: `lint.yml` gained timeout keys and comments, and `engine-double-contract.pinned.json` gained entries for other files. Neither changes the family set. No commit since the merge touches `driver-sql` or `cli/src/commands/migrate`. - `node scripts/check-driver-conformance.mjs`, before and after: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 3 touched TypeScript files reports 3 files, 0 errors, 0 warnings. `eslint --print-config` resolves each of them, and none carries `parserOptions.project`. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` is CI's. ## Changeset `.changeset/20821-plan-deferred-ddl-reads.md`: a `patch` for `@objectstack/driver-sql`. `@objectstack/cli` ships only `dist`, `README.md` and `CHANGELOG.md`, and `tsconfig.build.json` excludes `src/**/*.test.ts`, so the pin publishes nothing and gets no entry. ## Acceptance notes - In this worktree, one gate run wrote `examples/app-crm/dist/objectstack.json` (gitignored). With that compiled artifact present, `os migrate plan` takes the artifact boot path: `onEnable` does not run, and the six #21054 lines disappear too. The after-measurement above was taken with that artifact removed, so it matches the base's no-artifact path. This is noted for whoever takes #21054, since whether the app hook runs depends on that file. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e470f8 commit cf0346e

4 files changed

Lines changed: 418 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): `os migrate plan` on a database that does not exist yet no longer prints `DATABASE_ERROR` for the tables whose DDL it deferred (#20821)
6+
7+
`os migrate plan` (and the boot of `os migrate apply`) runs with the SQL driver's DDL deferred: the driver records every table as pending `create_table` and creates none of them. The same boot then reads `sys_metadata`, `sys_metadata_activation` and `sys_migration`. On a new database those tables do not exist yet, so each read was refused, and each refusal printed a line like this on the driver's warn channel (stderr by default):
8+
9+
```text
10+
[sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_metadata' (SQLITE_ERROR) ... no such table: sys_metadata
11+
```
12+
13+
Nothing was wrong: every reader already answers from the refusal, and the plan lists the same tables as pending creates. A dry run on a new database printed six of these lines.
14+
15+
The driver now sends such a refusal to the logger's `debug` channel instead of `warn`, when all three hold:
16+
17+
- this driver has DDL deferred;
18+
- the refused statement targets a table whose DDL this driver deferred;
19+
- the shared `isMissingTableError` predicate from `@objectstack/types` recognises the refusal as that table being missing.
20+
21+
The default logger has no `debug`, so the line is not printed. The refusal is still thrown to the caller with the same envelope (`DATABASE_ERROR`, status 500), and the plan's output is unchanged.
22+
23+
What still warns:
24+
25+
- every other refusal on a deferred driver, such as a malformed statement on a table that exists;
26+
- a missing table that the driver did not defer, such as a table nothing in the boot declares;
27+
- every refusal once the deferred DDL has been applied, or on a driver that never deferred any.
28+
29+
There is nothing to migrate.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20821] `os migrate plan` against a database that does not exist yet prints
5+
* no `DATABASE_ERROR` for the tables whose DDL it deferred.
6+
*
7+
* ## The measured defect
8+
*
9+
* The plan boots with the SQL driver's DDL deferred, so every table is listed
10+
* as `create_table` and none is created. The same boot then reads the tables
11+
* it just listed: `sys_metadata` four times (the overlay restore, the authored
12+
* translation, hook and action re-syncs), `sys_metadata_activation` once (the
13+
* packaged-action ledger probe) and `sys_migration` once (the ADR-0104 gate
14+
* announcement). Every reader already answered from the refusal; the driver
15+
* still printed a `[sql-driver] DATABASE_ERROR` line on stderr for each, six
16+
* alarms on a dry run where nothing was wrong. This fixture reproduced exactly
17+
* those six on the base.
18+
*
19+
* ## What is pinned, and what is deliberately not
20+
*
21+
* - Zero `DATABASE_ERROR` lines naming those three tables, in human mode and
22+
* under `--json`, on an absent file.
23+
* - The reads really happened and were really refused, so the zero is not a
24+
* boot that skipped them: the open value-shape gate is still announced (the
25+
* `sys_migration` read answered "not verified"), and the activation ledger
26+
* still reports itself unreadable (that stdout warning is kept on purpose;
27+
* it is the reader's own functional line, not the driver's).
28+
* - The plan's own answer: the three tables are still pending `create_table`,
29+
* and nothing is written to disk.
30+
*
31+
* ⛔ Only those three tables are counted. A host hook that reads a table the
32+
* plan's composition never declares is a different door with its own card;
33+
* its lines are not this pin's to forbid, and not its to excuse either.
34+
*
35+
* The driver-side conditions (deferred, in the driver's own deferred set, the
36+
* shared missing-table predicate) and their controls are pinned in
37+
* `@objectstack/driver-sql` (`sql-driver-20821-deferred-ddl-missing-table.test.ts`).
38+
*/
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import { spawn } from 'node:child_process';
42+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { dirname, join, resolve } from 'node:path';
45+
import { fileURLToPath } from 'node:url';
46+
47+
const HERE = dirname(fileURLToPath(import.meta.url));
48+
/** The source entry (tsx), as `test/helpers/serve-process.ts` spawns it; `src/` cannot import that helper. */
49+
const CLI = resolve(HERE, '../../../bin/run-dev.js');
50+
51+
/**
52+
* This process's environment for the child, minus the two families
53+
* `test/helpers/serve-process.ts` `childEnv()` strips (its header says why):
54+
* the vitest runner's own variables, and `NODE_PATH`, which moves the child's
55+
* module resolution base. An `undefined` override unsets a variable.
56+
*/
57+
function childEnv(overrides: Record<string, string | undefined>): Record<string, string | undefined> {
58+
const env: Record<string, string | undefined> = {};
59+
for (const [key, value] of Object.entries(process.env)) {
60+
if (key === 'TEST' || key === 'VITEST' || key.startsWith('VITEST_') || key === 'NODE_PATH') continue;
61+
env[key] = value;
62+
}
63+
return { ...env, ...overrides };
64+
}
65+
66+
/** The tables whose DDL the plan defers and whose boot readers it still runs. */
67+
const DEFERRED_READ_TABLES = ['sys_metadata', 'sys_metadata_activation', 'sys_migration'] as const;
68+
69+
const RUN_BUDGET_MS = 90_000;
70+
71+
interface PlanRun {
72+
code: number | null;
73+
stdout: string;
74+
stderr: string;
75+
}
76+
77+
let dir: string;
78+
let dbFile: string;
79+
80+
beforeAll(() => {
81+
dir = mkdtempSync(join(tmpdir(), 'os-20821-'));
82+
dbFile = join(dir, 'absent.sqlite');
83+
// A lookup field makes the value-shape gate applicable, so the boot reads
84+
// `sys_migration` exactly as a real app's boot does.
85+
writeFileSync(
86+
join(dir, 'objectstack.config.ts'),
87+
[
88+
'export default {',
89+
" manifest: { id: 'com.example.os20821', name: 'Deferred reads', version: '0.0.0', type: 'app' },",
90+
' objects: [',
91+
" { name: 'os20821_account', fields: { name: { type: 'text' } } },",
92+
" { name: 'os20821_contact', fields: { name: { type: 'text' }, account: { type: 'lookup', reference: 'os20821_account' } } },",
93+
' ],',
94+
'};',
95+
'',
96+
].join('\n'),
97+
);
98+
});
99+
100+
afterAll(() => {
101+
rmSync(dir, { recursive: true, force: true });
102+
});
103+
104+
function runPlan(extra: string[]): Promise<PlanRun> {
105+
return new Promise((resolve, reject) => {
106+
const child = spawn(
107+
process.execPath,
108+
[CLI, 'migrate', 'plan', ...extra, '--database-url', `file:${dbFile}`],
109+
{
110+
cwd: dir,
111+
env: childEnv({
112+
// No compiled artifact: the host config is the deployment.
113+
OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'),
114+
OS_DATABASE_URL: undefined,
115+
DATABASE_URL: undefined,
116+
TURSO_DATABASE_URL: undefined,
117+
OS_DATABASE_DRIVER: undefined,
118+
}),
119+
stdio: ['ignore', 'pipe', 'pipe'],
120+
},
121+
);
122+
let stdout = '';
123+
let stderr = '';
124+
child.stdout.on('data', (c) => { stdout += String(c); });
125+
child.stderr.on('data', (c) => { stderr += String(c); });
126+
const timer = setTimeout(() => {
127+
child.kill('SIGKILL');
128+
reject(new Error(`os migrate plan did not finish within ${RUN_BUDGET_MS}ms\n${stderr}`));
129+
}, RUN_BUDGET_MS);
130+
child.on('close', (code) => {
131+
clearTimeout(timer);
132+
resolve({ code, stdout, stderr });
133+
});
134+
});
135+
}
136+
137+
/** Every driver `DATABASE_ERROR` line, on either stream, that names one of the three tables. */
138+
function deferredTableDatabaseErrors(run: PlanRun): string[] {
139+
return `${run.stdout}\n${run.stderr}`
140+
.split('\n')
141+
.filter((line) => line.includes('DATABASE_ERROR'))
142+
.filter((line) => DEFERRED_READ_TABLES.some((t) => line.includes(`'${t}'`)));
143+
}
144+
145+
describe('[#20821] os migrate plan on an absent database: no DATABASE_ERROR for the tables it deferred', () => {
146+
it('human mode: zero lines for the three tables, the reads still answered, the plan unchanged', async () => {
147+
expect(existsSync(dbFile)).toBe(false);
148+
149+
const run = await runPlan([]);
150+
151+
expect(run.code, run.stderr).toBe(0);
152+
expect(deferredTableDatabaseErrors(run)).toEqual([]);
153+
154+
// The reads happened and were refused: the readers' own answers are here.
155+
const lines = run.stdout.split('\n');
156+
expect(lines.some((l) => l.includes('[value-shape]') && l.includes('NOT enforced'))).toBe(true);
157+
expect(lines.some((l) => l.includes('WARN') && l.includes('sys_metadata_activation'))).toBe(true);
158+
159+
// The plan's own answer: each of the three is still pending creation.
160+
for (const table of DEFERRED_READ_TABLES) {
161+
expect(lines.some((l) => l.includes(`+ ${table} [create_table`))).toBe(true);
162+
}
163+
expect(existsSync(dbFile)).toBe(false);
164+
}, 120_000);
165+
166+
it('--json: zero lines for the three tables, and the payload still lists them as pending creates', async () => {
167+
const run = await runPlan(['--json']);
168+
169+
expect(run.code, run.stderr).toBe(0);
170+
expect(deferredTableDatabaseErrors(run)).toEqual([]);
171+
172+
const payload = JSON.parse(run.stdout) as {
173+
total: number;
174+
changes: unknown[];
175+
pending: Array<{ table: string; kind: string }>;
176+
};
177+
expect(payload.total).toBe(0);
178+
expect(payload.changes).toEqual([]);
179+
for (const table of DEFERRED_READ_TABLES) {
180+
expect(payload.pending.filter((p) => p.table === table).map((p) => p.kind)).toEqual(['create_table']);
181+
}
182+
expect(existsSync(dbFile)).toBe(false);
183+
}, 120_000);
184+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20821] A missing table whose DDL THIS driver deferred leaves the warn
5+
* channel. Nothing else does.
6+
*
7+
* ## The measured defect
8+
*
9+
* `os migrate plan --database-url file:ABSENT.sqlite` boots with the driver's
10+
* DDL deferred (`setDeferredDdl(true)`), so the plan can list every table as
11+
* `create_table` instead of creating it. The same boot then reads
12+
* `sys_metadata` (four readers), `sys_metadata_activation` and `sys_migration`,
13+
* every one of which the plan has just listed as not existing yet. Each read
14+
* was refused, each reader already answered from the refusal, and each refusal
15+
* also printed a `[sql-driver] DATABASE_ERROR` line on the warn channel: six
16+
* alarms on a dry run where nothing was wrong.
17+
*
18+
* ## What this file pins, from the driver's side
19+
*
20+
* The command-level pin lives in `@objectstack/cli`
21+
* (`src/commands/migrate/plan.deferred-reads.integration.test.ts`). Here the
22+
* three conditions are exercised one at a time, each against a real SQLite
23+
* file, and every case asserts the refusal itself is unchanged: the caller
24+
* still gets the ADR-0112 envelope (`DATABASE_ERROR`, `500`).
25+
*
26+
* ① DDL deferred, table in this driver's deferred set, table missing: the
27+
* refusal goes to `debug`, not `warn`. After the deferred work is flushed
28+
* the table exists and the same read answers with nothing logged.
29+
*
30+
* Controls, each a refusal that must still warn:
31+
*
32+
* ② a malformed read on an EXISTING table whose DDL is deferred (the backend
33+
* refuses the statement, and the table is there);
34+
* ③ a missing table that is NOT in the deferred set, on the same deferred
35+
* driver (nobody in this boot declared it, so its absence is information).
36+
*/
37+
38+
import { describe, it, expect, afterEach } from 'vitest';
39+
import { mkdtempSync, rmSync } from 'node:fs';
40+
import { tmpdir } from 'node:os';
41+
import { join } from 'node:path';
42+
import type { DriverQuery } from '@objectstack/spec/contracts';
43+
import { SqlDriver } from './sql-driver.js';
44+
import type { SqlDriverConfig } from './sql-driver.js';
45+
46+
/** An object this boot declares, standing in for `sys_metadata` and its peers. */
47+
const DEFERRED = { name: 'os20821_deferred', fields: { name: { type: 'text' }, state: { type: 'text' } } };
48+
/** A table nothing in the boot declares. */
49+
const UNDECLARED = 'os20821_undeclared';
50+
51+
type Line = { level: 'warn' | 'debug' | 'info' | 'error'; message: string };
52+
53+
class LineProbe extends SqlDriver {
54+
readonly lines: Line[] = [];
55+
56+
constructor(config: SqlDriverConfig) {
57+
super(config);
58+
// Arrow closures on purpose: this sink records, it is not the receiver test
59+
// (`logger-receiver-detach.test.ts` owns that).
60+
(this as unknown as { logger: Record<Line['level'], (m: string) => void> }).logger = {
61+
warn: (m) => this.lines.push({ level: 'warn', message: String(m) }),
62+
debug: (m) => this.lines.push({ level: 'debug', message: String(m) }),
63+
info: (m) => this.lines.push({ level: 'info', message: String(m) }),
64+
error: (m) => this.lines.push({ level: 'error', message: String(m) }),
65+
};
66+
}
67+
68+
linesFor(level: Line['level'], object: string): string[] {
69+
return this.lines
70+
.filter((l) => l.level === level && l.message.includes(`'${object}'`))
71+
.map((l) => l.message);
72+
}
73+
}
74+
75+
const open: LineProbe[] = [];
76+
const dirs: string[] = [];
77+
78+
function databaseFile(): string {
79+
const dir = mkdtempSync(join(tmpdir(), 'os-20821-'));
80+
dirs.push(dir);
81+
return join(dir, 'app.sqlite');
82+
}
83+
84+
function driver(filename: string): LineProbe {
85+
const d = new LineProbe({
86+
client: 'better-sqlite3',
87+
connection: { filename },
88+
useNullAsDefault: true,
89+
} as SqlDriverConfig);
90+
open.push(d);
91+
return d;
92+
}
93+
94+
/** A driver booted the way `os migrate plan` boots it: DDL deferred, objects declared. */
95+
async function deferredDriver(filename: string): Promise<LineProbe> {
96+
const d = driver(filename);
97+
d.setDeferredDdl(true);
98+
await d.initObjects([DEFERRED] as any);
99+
expect(d.deferredSchemaObjectCount).toBe(1);
100+
return d;
101+
}
102+
103+
async function refusalOf(read: () => Promise<unknown>): Promise<any> {
104+
try {
105+
await read();
106+
} catch (e) {
107+
return e;
108+
}
109+
throw new Error('expected the backend to refuse this read');
110+
}
111+
112+
afterEach(async () => {
113+
while (open.length) await open.pop()?.disconnect().catch(() => {});
114+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
115+
});
116+
117+
describe('[#20821] a missing table whose DDL this driver deferred is not a DATABASE_ERROR', () => {
118+
it('① deferred and missing: the read goes to debug, the caller still gets the envelope, and the flush ends it', async () => {
119+
const d = await deferredDriver(databaseFile());
120+
121+
const refusal = await refusalOf(() => d.find(DEFERRED.name, { where: { state: 'active' } }));
122+
123+
expect(refusal?.code).toBe('DATABASE_ERROR');
124+
expect(refusal?.status).toBe(500);
125+
expect(d.linesFor('warn', DEFERRED.name)).toEqual([]);
126+
// Demoted, not deleted: the dialect text is still on the debug channel.
127+
const demoted = d.linesFor('debug', DEFERRED.name);
128+
expect(demoted).toHaveLength(1);
129+
expect(demoted[0]).toContain('no such table');
130+
131+
// `os migrate apply` flushes the deferred work: the table now exists and
132+
// the same read answers, with nothing logged at any level.
133+
await d.flushDeferredSchemaDdl();
134+
const before = d.lines.length;
135+
expect(await d.find(DEFERRED.name, { where: { state: 'active' } })).toEqual([]);
136+
expect(d.lines.slice(before).filter((l) => l.message.includes(`'${DEFERRED.name}'`))).toEqual([]);
137+
});
138+
});
139+
140+
describe('[#20821] CONTROLS — every other refusal on a deferred driver still warns', () => {
141+
it('② a malformed read on an EXISTING table whose DDL is deferred still warns', async () => {
142+
const file = databaseFile();
143+
const first = driver(file);
144+
await first.initObjects([DEFERRED] as any);
145+
await first.disconnect();
146+
147+
const d = await deferredDriver(file);
148+
// More bound variables than SQLite accepts in one statement: the backend
149+
// refuses the statement, and the table is there.
150+
const tooMany: NonNullable<DriverQuery['where']> = {
151+
id: { $in: Array.from({ length: 40_000 }, (_, i) => `k${i}`) },
152+
};
153+
154+
const refusal = await refusalOf(() => d.find(DEFERRED.name, { where: tooMany }));
155+
156+
expect(refusal?.code).toBe('DATABASE_ERROR');
157+
expect(refusal?.status).toBe(500);
158+
const warned = d.linesFor('warn', DEFERRED.name);
159+
expect(warned).toHaveLength(1);
160+
expect(warned[0]).toContain('DATABASE_ERROR');
161+
expect(d.linesFor('debug', DEFERRED.name)).toEqual([]);
162+
});
163+
164+
it('③ a missing table OUTSIDE the deferred set, on the same deferred driver, still warns', async () => {
165+
const d = await deferredDriver(databaseFile());
166+
167+
const refusal = await refusalOf(() => d.find(UNDECLARED, {}));
168+
169+
expect(refusal?.code).toBe('DATABASE_ERROR');
170+
expect(refusal?.status).toBe(500);
171+
const warned = d.linesFor('warn', UNDECLARED);
172+
expect(warned).toHaveLength(1);
173+
expect(warned[0]).toContain('no such table');
174+
expect(d.linesFor('debug', UNDECLARED)).toEqual([]);
175+
});
176+
});

0 commit comments

Comments
 (0)