Skip to content

Commit 810d42b

Browse files
fix(driver-sql): the first boot of a new database no longer prints a sys_migration DATABASE_ERROR (#20768) (#20818)
Fixes #20768 Clause-②: no On the first boot of a new database, the SQL driver printed one `[sql-driver] DATABASE_ERROR ... no such table: sys_migration` line on its warn channel. The read behind it is the engine's migration-gate read. The driver's own pre-DDL question reaches that read before the schema pass has created any table. The driver now asks that question inside an async scope. Inside the scope, one class of refusal goes to `debug` instead of `warn`: a missing table, recognised by the shared `isMissingTableError` over the envelope's declared target. Every other refusal still warns, and so does a missing table read anywhere else. No gate's answer changes. ## Reproduction on the base `objectstack dev --database file:NEW.sqlite -p PORT` on `examples/app-crm`, base `96e724475c` (every package in the app's closure built), stdout and stderr captured separately: | run | `DATABASE_ERROR` lines | channel | |:--|:--|:--| | first boot of a new file | **1** | stderr (the driver's default `console.warn` sink) | | second boot of the same file | 0 | none | The line, verbatim: ```text [sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_migration' (SQLITE_ERROR). The dialect message below is kept server-side: it carries the compiled statement, and on the dialects that inline them the bound literals too (#7929, #8931): select * from `sys_migration` where `id` = 'adr-0104-file-references' limit 1 - no such table: sys_migration ``` ## The mechanism: which dispatch hypotheses held **H1 held.** For one boot, I added a temporary stack-trace line to the built `packages/objectql/dist` (restored afterwards, `cmp` byte-identical on all four bundles, marker count 0). The first gate read at boot is `ObjectQL.readMigrationFlagVerified`, reached through `readFileReferencesFlagRow` and `haveFileColumnsMoved`. It is called by the closure that `registerDriver` hands the driver, which `SqlDriver.resolveFileColumnsMoved` asks at the start of `SqlDriver.initObjects`, from the schema pass's first `syncSchema`, before any DDL. The other gate readers run after the schema pass has created `sys_migration`, so on the first boot they read without error: `isValueShapesMigrationVerified` from write hooks, and `announceOpenMigrationGates` at `kernel:bootstrapped`. **H2 falsified.** The engine cannot tell whether the table exists without asking the backend: - The driver's registered-table set answers "registered in this process", not "exists". On a second boot the table exists but is not registered yet when the question is asked. Answering "not moved" there without a read would change the answer on a deployment whose columns have moved, which is the failure the resolver exists to prevent. - The schema pass has no plan at that moment: the question is asked before the first `hasTable`. - For an absent table, `conclusive` must be `false`. The same pass creates the table moments later, and a memoized "not verified" would freeze a whole boot's posture from a moment when nothing could answer. That is what the refused read already returns, so it is unchanged. - A catalog probe (`hasTable`) would give "not asked", but it needs a new public driver method or a new `IDataDriver` member. That enlarges a public surface, against this card's `Clause-②: no`, so this PR does not add one. **H3 held.** `SqlDriver.backendStatementFault` writes the line before the engine sees the error. The engine's `findOne` path does not log, and its catch in `readMigrationFlagVerified` answers `{ verified: false, conclusive: false, columnsMoved: false }` silently. So the demotion is in the driver, keyed on `isMissingTableError`, and limited to this read: the async chain of the driver's own question. **H4: measured at `c5ae3a6f8e`.** The second boot prints nothing, as on the base. `os migrate plan --database-url file:X` on the database the boots created prints no `DATABASE_ERROR`. On a path that does not exist yet, the plan printed 7 lines on the base, 2 of them on `sys_migration`, and prints 6 now. The one `sys_migration` line left is the `adr-0104-value-shapes` read from `announceOpenMigrationGates`, outside the driver's question. The other 5 are `sys_metadata` (4) and `sys_metadata_activation` (1). They are not this card. See the acceptance notes. ## Landing site: `packages/drivers/driver-sql`, not `packages/objectql/src/engine.ts` The dispatch expected the engine, with the driver only if a demotion was needed. As H2 and H3 show, the demotion is needed, and the line's producer is the driver. The premature read is the driver's own pre-DDL question. `engine.ts` is unchanged. ## What changed `packages/drivers/driver-sql/src/sql-driver.ts`: - A module-level `AsyncLocalStorage` (`PRE_DDL_QUESTION_SCOPE`). `resolveFileColumnsMoved` runs the resolver inside it, and nothing else does. It is module-level rather than per instance because the question's read goes to whichever driver serves the ledger, which on a multi-datasource composition can be another instance in the same async chain. - `backendStatementFault` composes the envelope first. If the scope is active and `isMissingTableError(envelope, object)` holds, it writes the same dialect text to `this.logger.debug?.(...)` and returns the envelope. Otherwise it warns as before. The throw, the envelope, and what the resolver hears are unchanged. - The logger shape gains an optional `debug` channel. The default sink has none. - `isMissingTableError` is imported from its home, `@objectstack/types`. `@objectstack/metadata/errors` re-exports the same symbol, and driver-sql cannot depend on `@objectstack/metadata`. No second message regex. `.changeset/20768-first-boot-migration-gate-read.md`: `@objectstack/driver-sql` patch. `@objectstack/runtime` gains only a test file, which its `files[]` (`dist`, `README.md`, `CHANGELOG.md`) does not ship, so it gets no changeset entry. ## After: this branch at `c5ae3a6f8e`, built | run | `DATABASE_ERROR` lines | `sys_migration` mentions | server ready | |:--|:--|:--|:--| | `objectstack dev --database file:NEW.sqlite` (`examples/app-crm`), first boot | 0 | 0 | yes | | second boot of that file | 0 | 0 | yes | | `os migrate plan` on that file | 0 | 0 | exit 0 | Normalised for timestamps, port and file path, the first-boot stderr differs from the base run in exactly one line: the removed one. Both ledgers end in the same state after the first boot: both flags verified by the fresh-datastore attestation, and `columns_moved_at` null. ## Tests - **`packages/drivers/driver-sql/src/sql-driver-20768-pre-ddl-question-missing-table.test.ts`** (new, SQLite on a new temp file). The resolver is a stand-in that reads the ledger the way the engine does. - ① First boot: no warn line, one `debug` line (`no such table`). The resolver gets the envelope (`code: 'DATABASE_ERROR'`, `status: 500`), and the arm stays "not moved". - ② Same result when the ledger is served by a second driver instance. - ③ Second boot: a `columns_moved_at` row written between the boots makes the arm "moved", so the read reached the row. Nothing is logged. - Controls, each still `warn`: ④ a malformed read on an existing table inside the question (40,000 bound variables, SQLite refuses the statement); ⑤ a view over a dropped table inside the question (a missing table named by another relation); ⑥ a missing table read outside the question. - **`packages/runtime/src/first-boot-migration-gate-read.integration.test.ts`** (new). A real `ObjectQL` engine over a real `SqlDriver` on a new file, `SysMigration` registered, and `engine.syncSchemas()`, which is the same `syncSchema`, `initObjects`, resolver, gate-read chain. - First and second boot: no `sys_migration` `DATABASE_ERROR` on warn, and one demoted line on the first boot only. - Gate answers asserted on both boots: not moved and not verified on the first; verified after a row is written between boots. - Control: a malformed read on an existing table still warns, and so does a missing table read after the boot. It counts only its own reads' lines. - Suites at `c5ae3a6f8e`: - driver-sql: 201 files passed, 11 skipped (the live PG and MySQL cells, not provisioned here); 3254 tests passed, 188 skipped. - runtime `--project local`: 293 files, 4207 tests passed, 1 skipped. - Typecheck green for driver-sql and runtime. Runtime's `check:test-typecheck` debt is unchanged, and `tsc --listFiles` shows both new tests inside their packages' programs. ## Reverse verification (from the committed fix) `packages/drivers/driver-sql/src/sql-driver.ts` was restored to its base blob with `git restore --source=96e724475c`: on-disk blob `2462eccd` = base, marker count 0. driver-sql was rebuilt, and `ablation-dist-preflight --absent PRE_DDL_QUESTION_SCOPE` found the marker absent from all 6 built files. - Driver pin: **red** on ① and ② (`expected [ Array(1) ] to deeply equal []`). ③ to ⑥ stayed green. - Runtime pin: the boot test **red** (same message). The control stayed green. Restore: `git checkout HEAD -- packages/drivers/driver-sql/src/sql-driver.ts`. Blob `a3647fd1` = HEAD, and `git diff HEAD` was empty. After a rebuild, the preflight found the marker in 2 built files and a clean tree, and both pins were green again. The first run of this verification also turned the runtime control red: it had counted the boot's own `sys_migration` line together with its own. It now counts only its own reads, and the second run above is from that commit. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `c5ae3a6f8e` derives 63 commands, and all 63 ran on that head with exit 0. `--ran` reconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)". On the first pass, `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (9 packages had no `dist/`). I built those 9 and re-ran it, and it exited 0. Lint, narrowed: `eslint --no-inline-config --format json` over the 3 touched `.ts` files counted 3 files, 0 errors, 0 warnings. The population is the config's `packages/**/*.{ts,tsx,mts,cts}` and `**/*.{ts,...}` objects. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on an untouched file. The branch merged `main` twice. The second merge brought in #20794, the serial constraint. Neither merge touched this diff's files, apart from the tracker-number wording in the same `DATABASE_ERROR` line, which this branch now carries as `main` spells it. ## Acceptance notes - `os migrate plan` against a database that does not exist yet still prints 6 `DATABASE_ERROR` lines at `c5ae3a6f8e`: `sys_metadata` 4, `sys_metadata_activation` 1, and `sys_migration` 1. The last is the `adr-0104-value-shapes` read by `announceOpenMigrationGates` at `kernel:bootstrapped`, which on a deferred-DDL plan runs over tables the plan never creates. It is the same false-alarm family, on a different door, and is reported to the seat. It is not fixed here. - "Demoted, not deleted" holds only where a host injects a logger that has `debug`. No production composition hands `SqlDriver` a logger today, so with the default sink the line is dropped. The engine's catch records nothing either. - `PRE_DDL_QUESTION_SCOPE` covers any read in the resolver's async chain, including one a hook starts inside it. That is intended: every such read is issued for the question, before the schema pass has run. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7911485 commit 810d42b

4 files changed

Lines changed: 541 additions & 9 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): the first boot of a new database no longer prints a `DATABASE_ERROR` for `sys_migration` (#20768)
6+
7+
On the first boot of a new database, the SQL driver printed this line once, on its warn channel (stderr by default):
8+
9+
```text
10+
[sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_migration' (SQLITE_ERROR) ... no such table: sys_migration
11+
```
12+
13+
Nothing was wrong. At the start of its first schema sync, before it creates any table, the driver asks whether this deployment's file columns have moved (the ADR-0104 media-arm resolver). The resolver the engine supplies answers by reading `sys_migration`. On a new database that table does not exist yet, so the read is refused and the answer is "not moved", which is correct for an empty store.
14+
15+
The driver now asks that question inside an async scope. Inside it, a read refused because its own target table does not exist goes to the logger's `debug` channel instead of `warn`. The default logger has no `debug`, so the line is not printed. The logger shape gains an optional `debug`. The refusal is still thrown to the resolver, and the resolver's answer is the same as before.
16+
17+
What still warns:
18+
19+
- every other refusal inside that scope, such as a malformed statement on a table that exists, or a missing table named by another relation (a view over a dropped table);
20+
- a missing table read anywhere else, as before.
21+
22+
The missing-table check is the shared `isMissingTableError` from `@objectstack/types`, which `@objectstack/metadata/errors` re-exports. There is nothing to migrate.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20768] A missing table read by a driver's own PRE-DDL question leaves the
5+
* warn channel. Nothing else does.
6+
*
7+
* ## The measured defect
8+
*
9+
* `objectstack dev --database file:NEW.sqlite` on `examples/app-crm` printed
10+
* one line on the first boot of a new database, on the driver's warn channel
11+
* (stderr):
12+
*
13+
* ```text
14+
* [sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_migration' (SQLITE_ERROR) ...
15+
* select * from `sys_migration` where `id` = 'adr-0104-file-references' limit 1 - no such table: sys_migration
16+
* ```
17+
*
18+
* The read is the engine's migration-gate read. The ADR-0104 media-arm
19+
* resolver reaches it, and this driver asks that resolver at the start of its
20+
* first `initObjects`, before its schema pass has created any table. On a new
21+
* database the ledger is not there yet, the resolver answers "not moved", and
22+
* that answer is right. Only the log line was wrong.
23+
*
24+
* ## What this file pins, from the driver's side
25+
*
26+
* The engine-driven boot is pinned in `@objectstack/runtime`
27+
* (`first-boot-migration-gate-read.integration.test.ts`). Here the resolver is
28+
* a stand-in that reads the ledger the way the engine does: one row by id,
29+
* with a failed read answered as "not moved".
30+
*
31+
* ① first boot: the question's read of a table that does not exist yet goes
32+
* to `debug`, not `warn`. The resolver still gets the refusal envelope, and
33+
* the arm is the one it always was.
34+
* ② the same holds when the ledger is served by ANOTHER driver instance: the
35+
* scope follows the async chain, not the instance.
36+
* ③ second boot: the table exists, the read succeeds, nothing is logged, and
37+
* the answer comes from the ledger row.
38+
*
39+
* Controls, each a refusal that must still warn:
40+
*
41+
* ④ a malformed read on an EXISTING table, inside the question;
42+
* ⑤ a missing table named by some OTHER relation (a view over a dropped
43+
* table), inside the question;
44+
* ⑥ a missing table read OUTSIDE the question.
45+
*/
46+
47+
import { describe, it, expect, afterEach } from 'vitest';
48+
import { mkdtempSync, rmSync } from 'node:fs';
49+
import { tmpdir } from 'node:os';
50+
import { join } from 'node:path';
51+
import type { DriverQuery } from '@objectstack/spec/contracts';
52+
import { SqlDriver } from './sql-driver.js';
53+
import type { SqlDriverConfig } from './sql-driver.js';
54+
55+
const LEDGER = 'sys_migration';
56+
const MIGRATION_ID = 'adr-0104-file-references';
57+
/** The ledger's columns the engine's gate read looks at, as a stand-in object. */
58+
const LEDGER_OBJECT = {
59+
name: LEDGER,
60+
fields: {
61+
last_run_at: { type: 'text' },
62+
verified_at: { type: 'text' },
63+
columns_moved_at: { type: 'text' },
64+
blocking: { type: 'number' },
65+
},
66+
};
67+
/** An app object with a media column: the column the arm decides the encoding of. */
68+
const MEDIA_OBJECT = { name: 'os20768_doc', fields: { cover: { type: 'image' }, title: { type: 'text' } } };
69+
70+
type Line = { level: 'warn' | 'debug' | 'info' | 'error'; message: string };
71+
72+
/** Reads the arm the way every writer and every DDL branch reads it. */
73+
class ArmProbe extends SqlDriver {
74+
readonly lines: Line[] = [];
75+
76+
constructor(config: SqlDriverConfig) {
77+
super(config);
78+
// Arrow closures on purpose: this sink records, it is not the receiver test
79+
// (`logger-receiver-detach.test.ts` owns that).
80+
(this as unknown as { logger: Record<Line['level'], (m: string) => void> }).logger = {
81+
warn: (m) => this.lines.push({ level: 'warn', message: String(m) }),
82+
debug: (m) => this.lines.push({ level: 'debug', message: String(m) }),
83+
info: (m) => this.lines.push({ level: 'info', message: String(m) }),
84+
error: (m) => this.lines.push({ level: 'error', message: String(m) }),
85+
};
86+
}
87+
88+
get arm(): boolean {
89+
return (this as unknown as { fileColumnsMoved: boolean }).fileColumnsMoved;
90+
}
91+
92+
databaseErrors(level: Line['level'], object: string): string[] {
93+
return this.lines
94+
.filter((l) => l.level === level && l.message.includes(`'${object}'`))
95+
.map((l) => l.message);
96+
}
97+
}
98+
99+
const open: ArmProbe[] = [];
100+
const dirs: string[] = [];
101+
102+
function newDatabaseFile(): string {
103+
const dir = mkdtempSync(join(tmpdir(), 'os-20768-'));
104+
dirs.push(dir);
105+
// Named but not created: the first driver to connect creates it, as a new
106+
// deployment's first boot does.
107+
return join(dir, 'new.sqlite');
108+
}
109+
110+
function driver(filename: string): ArmProbe {
111+
const d = new ArmProbe({
112+
client: 'better-sqlite3',
113+
connection: { filename },
114+
useNullAsDefault: true,
115+
} as SqlDriverConfig);
116+
open.push(d);
117+
return d;
118+
}
119+
120+
/** A resolver shaped like the engine's: read one ledger row, a failure is "not moved". */
121+
function ledgerResolver(ledger: SqlDriver, observed: { error?: any; row?: any; asked: number }) {
122+
return async (): Promise<boolean> => {
123+
observed.asked += 1;
124+
try {
125+
observed.row = await ledger.findOne(LEDGER, { where: { id: MIGRATION_ID } });
126+
return observed.row?.verified_at != null && observed.row?.columns_moved_at != null;
127+
} catch (e) {
128+
observed.error = e;
129+
return false;
130+
}
131+
};
132+
}
133+
134+
afterEach(async () => {
135+
while (open.length) await open.pop()?.disconnect().catch(() => {});
136+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
137+
});
138+
139+
describe('[#20768] a missing table read by the pre-DDL question is not a DATABASE_ERROR', () => {
140+
it('① first boot: the ledger read goes to debug; the resolver hears the refusal; the arm is unchanged', async () => {
141+
const d = driver(newDatabaseFile());
142+
const observed: { error?: any; row?: any; asked: number } = { asked: 0 };
143+
expect(d.setFileColumnsMovedResolver(ledgerResolver(d, observed))).toBe(true);
144+
145+
await d.initObjects([LEDGER_OBJECT, MEDIA_OBJECT] as any);
146+
147+
// The question was asked, and its read really was refused, with the envelope.
148+
expect(observed.asked).toBe(1);
149+
expect(observed.error?.code).toBe('DATABASE_ERROR');
150+
expect(observed.error?.status).toBe(500);
151+
// No warn line for the ledger ...
152+
expect(d.databaseErrors('warn', LEDGER)).toEqual([]);
153+
// ... and the demoted line exists: demoted, not deleted.
154+
const demoted = d.databaseErrors('debug', LEDGER);
155+
expect(demoted).toHaveLength(1);
156+
expect(demoted[0]).toContain('no such table');
157+
// The arm the resolver's answer set: "not moved", as before.
158+
expect(d.arm).toBe(false);
159+
// The schema pass went on to create the ledger.
160+
expect(await d.find(LEDGER, {})).toEqual([]);
161+
});
162+
163+
it('② the scope follows the async chain: a ledger served by another driver instance is demoted too', async () => {
164+
const asker = driver(newDatabaseFile());
165+
const ledger = driver(newDatabaseFile());
166+
const observed: { error?: any; row?: any; asked: number } = { asked: 0 };
167+
asker.setFileColumnsMovedResolver(ledgerResolver(ledger, observed));
168+
169+
await asker.initObjects([MEDIA_OBJECT] as any);
170+
171+
expect(observed.error?.code).toBe('DATABASE_ERROR');
172+
expect(ledger.databaseErrors('warn', LEDGER)).toEqual([]);
173+
expect(ledger.databaseErrors('debug', LEDGER)).toHaveLength(1);
174+
expect(asker.arm).toBe(false);
175+
});
176+
177+
it('③ second boot: the read succeeds, nothing is logged, and the answer comes from the ledger row', async () => {
178+
const file = newDatabaseFile();
179+
const first = driver(file);
180+
first.setFileColumnsMovedResolver(ledgerResolver(first, { asked: 0 }));
181+
await first.initObjects([LEDGER_OBJECT, MEDIA_OBJECT] as any);
182+
// Between boots, the column move is recorded, so the second boot's answer
183+
// can only be "moved" if its read reached the row.
184+
const now = new Date().toISOString();
185+
await first.create(
186+
LEDGER,
187+
{ id: MIGRATION_ID, last_run_at: now, verified_at: now, columns_moved_at: now, blocking: 0 },
188+
{ bypassTenantAudit: true },
189+
);
190+
await first.disconnect();
191+
192+
const second = driver(file);
193+
const observed: { error?: any; row?: any; asked: number } = { asked: 0 };
194+
second.setFileColumnsMovedResolver(ledgerResolver(second, observed));
195+
await second.initObjects([LEDGER_OBJECT, MEDIA_OBJECT] as any);
196+
197+
expect(observed.error).toBeUndefined();
198+
expect(observed.row?.id).toBe(MIGRATION_ID);
199+
expect(second.arm).toBe(true);
200+
expect(second.lines.filter((l) => l.message.includes('DATABASE_ERROR'))).toEqual([]);
201+
expect(second.databaseErrors('debug', LEDGER)).toEqual([]);
202+
});
203+
});
204+
205+
describe('[#20768] CONTROLS — every other refusal still warns', () => {
206+
it('④ a malformed read on an EXISTING table, inside the question, still warns', async () => {
207+
const file = newDatabaseFile();
208+
const first = driver(file);
209+
await first.initObjects([LEDGER_OBJECT] as any);
210+
await first.disconnect();
211+
212+
const d = driver(file);
213+
const observed: { error?: any; row?: any; asked: number } = { asked: 0 };
214+
// More bound variables than SQLite accepts in one statement: the backend
215+
// refuses the statement, and the table is there.
216+
const tooMany: NonNullable<DriverQuery['where']> = {
217+
id: { $in: Array.from({ length: 40_000 }, (_, i) => `k${i}`) },
218+
};
219+
d.setFileColumnsMovedResolver(async () => {
220+
observed.asked += 1;
221+
try {
222+
await d.find(LEDGER, { where: tooMany });
223+
} catch (e) {
224+
observed.error = e;
225+
}
226+
return false;
227+
});
228+
229+
await d.initObjects([MEDIA_OBJECT] as any);
230+
231+
expect(observed.asked).toBe(1);
232+
expect(observed.error?.code).toBe('DATABASE_ERROR');
233+
expect(observed.error?.status).toBe(500);
234+
const warned = d.databaseErrors('warn', LEDGER);
235+
expect(warned).toHaveLength(1);
236+
expect(warned[0]).toContain('DATABASE_ERROR');
237+
expect(d.databaseErrors('debug', LEDGER)).toEqual([]);
238+
});
239+
240+
it('⑤ a missing table named by ANOTHER relation (a view over a dropped table), inside the question, still warns', async () => {
241+
const file = newDatabaseFile();
242+
const first = driver(file);
243+
await first.initObjects([LEDGER_OBJECT] as any);
244+
await first.execute('create table os20768_gone (id text primary key)');
245+
await first.execute('create view os20768_view as select * from os20768_gone');
246+
await first.execute('drop table os20768_gone');
247+
await first.disconnect();
248+
249+
const d = driver(file);
250+
const observed: { error?: any; asked: number } = { asked: 0 };
251+
d.setFileColumnsMovedResolver(async () => {
252+
observed.asked += 1;
253+
try {
254+
await d.find('os20768_view', {});
255+
} catch (e) {
256+
observed.error = e;
257+
}
258+
return false;
259+
});
260+
261+
await d.initObjects([MEDIA_OBJECT] as any);
262+
263+
expect(observed.error?.code).toBe('DATABASE_ERROR');
264+
expect(observed.error?.status).toBe(500);
265+
expect(d.databaseErrors('warn', 'os20768_view')).toHaveLength(1);
266+
expect(d.databaseErrors('debug', 'os20768_view')).toEqual([]);
267+
});
268+
269+
it('⑥ a missing table read OUTSIDE the question still warns', async () => {
270+
const d = driver(newDatabaseFile());
271+
d.setFileColumnsMovedResolver(ledgerResolver(d, { asked: 0 }));
272+
await d.initObjects([LEDGER_OBJECT] as any);
273+
// The question has been asked and answered; the scope is closed.
274+
let refusal: any;
275+
try {
276+
await d.find('os20768_never_created', {});
277+
} catch (e) {
278+
refusal = e;
279+
}
280+
expect(refusal?.code).toBe('DATABASE_ERROR');
281+
expect(refusal?.status).toBe(500);
282+
const warned = d.databaseErrors('warn', 'os20768_never_created');
283+
expect(warned).toHaveLength(1);
284+
expect(warned[0]).toContain('no such table');
285+
expect(d.databaseErrors('debug', 'os20768_never_created')).toEqual([]);
286+
});
287+
});

0 commit comments

Comments
 (0)