Skip to content

Commit 22f0d65

Browse files
fix(service-datasource)!: Test connection and Save refuse a SQLite filename that is neither :memory: nor a usable file path (#30395)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgUHtcAjbaaBTyTyV5hyHTid)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF ## 维护者速读 - **改了什么:** 新建/编辑 SQLite 数据源时,文件名既不是 `:memory:` 也不是可用文件路径(例如 `:memory:/srv/data/parts.db`、空字符串、建不出来的路径),“测试连接”现在判失败,保存也拒收,提示里写出这个值和原因。 - **为什么改:** 对话框里 `:memory:` 是真实值,用户在后面接着打路径就成了 `:memory:/…`,测试连接却显示成功;存下去后原生 SQLite 每次查询都报错,sqlite-wasm 则开了个内存库,写进去的数据重启就没了。管理员会以为连上了公司数据库。 - **风险与回滚:** 只收紧管理入口的这三类文件名;`:memory:`、已存在的数据库文件、还不存在但能创建的路径都照旧。已存的旧数据源照常启动,只有改它的配置时才会被判。回滚即撤 PR。 - **先看:** `packages/services/service-datasource/src/sqlite-database-filename.ts`(判定本身);`datasource-admin-service.ts` 的 `assertValidConfig`(测试、新建、更新三处共用的那一处)。 - **两分钟验证:** `pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2 src/__tests__/sqlite-filename-neither-memory-nor-path.test.ts`,预期 14 个全绿。 - **需要你做的:** 无,席位落地(收窄,合并前需合同审查)。对话框把 `:memory:` 做成占位符而非真实值,是 ui 车道的另一半,卡留开。 Refs #30331 (server half) Clause-②: yes (narrowing) — `POST /api/v1/datasources/test`, `POST /api/v1/datasources` and a `PATCH /api/v1/datasources/:name` that touches `config` or `driver` refuse a `sqlite` / `sqlite-wasm` `config.filename` that is empty, starts with `:` without being exactly `:memory:`, or names a path that can be neither opened nor created. The test answered `ok: true` for the first two and the save stored all three; both now answer with the value and the reason (`result.ok: false` from the test, `400 DATASOURCE_ADMIN_ERROR` from the save), the envelope every invalid `config` already gets. Metadata-protocol: none Skills-docs: none — `skills/**` and `content/docs/**` teach `filename` as "a database file path, or `:memory:`" (`content/docs/references/data/driver-sqlite.mdx`, `content/docs/data-modeling/drivers.mdx`), which is what the doors now enforce; no line turns false. follow-up owed — `packages/spec/src/data/driver/sqlite.zod.ts` types `filename` as any placeholder-free string, so the build door and `PUT /api/v1/meta/datasource/:name` still accept these values; the refinement belongs in the spec. ## What changes **Measured first, on `main` 8589756**, through the real `POST /api/v1/datasources/test` with `config.filename: ':memory:/tmp/x30331/parts.db'`: `sqlite` answered `{ ok: true, latencyMs: 13 }` and `sqlite-wasm` `{ ok: true, serverVersion: '3.49.1' }`. Why: - Every layer reads a name that starts with `:` as fileless: driver-sql's `resolveSqliteAbsentFileTarget` and `sqliteFilename`, the wasm connection's `isEphemeral`. So the probe's missing-file refusal (#27512) skipped it, and `SqlDriver.connect()` made no directory for it. - `sqlite` (better-sqlite3) does not treat it as in-memory (only exactly `:memory:` is). It opens it as a relative path under the server's working directory, whose `:memory:/…` directory does not exist. `connect()` only warns (`Failed to set PRAGMA auto_vacuum`), and `checkHealth()` answers `false` without throwing, which the probe does not read as a failure. - `sqlite-wasm` opens an ephemeral in-memory database. **What a saved datasource with that value reads** (not measured by the card; measured here through the default factory, connect, write, disconnect, connect again): - `sqlite`: every statement fails with `Cannot open database because the directory does not exist`; health `false`. Nothing is written to disk. - `sqlite-wasm`: health `true`; a table created and a row inserted read back in the same connection; after reconnecting, `no such table`. Writes succeed and are lost at the next connect or restart, with no error. **The fix, in the one place the test and both save doors pass through.** `DatasourceAdminService.assertValidConfig` already judged `config` against the driver contract for `testConnection`, `createDatasource` and `updateDatasource` (when the patch touches `config` or `driver`). It now also asks `sqliteDatabaseFilenameIssue` (new, `sqlite-database-filename.ts`) once the contract has no issues, and becomes async. For `sqlite` / `sqlite-wasm` (via `resolveDriverId`) the filename is usable when it is exactly `:memory:` or absent (the schema's default), or a path that driver-sql's write-free judgement (`resolveSqliteAbsentFileTarget(…, 'empty-in-memory')`) does not refuse. Refused, each with a `config.filename:` line under `Invalid configuration for driver '…'`: - empty: `config.filename: '' is empty and names no database file. …` - `:`-prefixed but not `:memory:`: `config.filename: ':memory:/…' is neither ':memory:' nor a file path. A name that starts with ':' is not read as a file, so this datasource would store nothing: …` - a path that can be neither opened nor created: `config.filename: ` + driver-sql's `SqliteDatabaseFileError` text, which names the file and the operating system's answer. Same envelope and code as every invalid `config` (`400 DATASOURCE_ADMIN_ERROR` from save; `result.ok: false` from the test). No new error code. **Unchanged:** exactly `:memory:` tests ok and saves; an existing database file tests ok and saves; a creatable path that does not exist yet is refused by the test (which never creates it, #27512) and accepted by Save, which creates it on connect. `turso` names its target as a `file:` url and is not judged here. The probe in `datasource-admin-plugin.ts`, the driver factory, and the boot restore are not touched: a stored row is not re-judged at boot. ## Tests `packages/services/service-datasource/src/__tests__/sqlite-filename-neither-memory-nor-path.test.ts`, 14 cases (7 per driver, `sqlite` and `sqlite-wasm`), through the real `HonoHttpServer`, the real admin routes, the real `DatasourceAdminServicePlugin` (its probe and its service) and the default driver factory, with a map-backed metadata service so a save is observable: - `:memory:/…` is refused by Test connection, naming the value and why; nothing is written under the test root. - `:memory:/…` is refused by create (`400 DATASOURCE_ADMIN_ERROR`, nothing stored) and by an update of a saved `:memory:` datasource (stored value unchanged). - a path under a regular file is refused by the test and by create; nothing written. - an empty filename is refused by the test and by create. - controls: exactly `:memory:` tests ok and saves (201); an existing database file tests ok and saves; a creatable missing path is refused by the test with "does not exist", nothing created, and is saved. **Ablation** (`sqliteDatabaseFilenameIssue` short-circuited to `undefined`, source imported by relative path so no `dist/` leg): 8 failed / 6 passed, the 8 refusal cases red and the 6 controls green. Restored with `git checkout`, then 14/14. ## Acceptance notes - **ui residue (not in this PR).** The New datasource dialog still holds `:memory:` as the Filename field's value instead of a placeholder (`ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx`). With this PR the mistake it invites is refused with a message, instead of passing. The card stays open for that half. - **The probe ignores a `false` health check.** `DatasourceAdminServicePlugin.probe` awaits `ping()` (the handle's `checkHealth`) and reports `ok: true` whatever it answers. That is how the native `:memory:/…` draft passed; this PR closes the input, not that reading. No other public input reaching it was measured. - **Stored rows.** A datasource already saved with such a filename keeps loading at boot as it did. An edit that touches its `config` must fix the filename; a `label` / `active`-only edit is not judged, so it can still be taken out of service. - **Save judges the server's filesystem.** An unusable path is judged where the server runs, write-free, the same judgement the test and `os migrate plan` already make. - **Assignee and reviewers:** none set. The dispatch forbids GitHub MCP writes and asks for no review request, so the session's usual reviewer step is left to the seat. ## Verification Head `cca010a64`, the merge of `origin/main` into the branch; the merge touches none of this PR's 4 files. - New suite at head: 14 passed. Full `@objectstack/service-datasource` suite: 72 files, 1183 passed. `pnpm --filter @objectstack/service-datasource run typecheck` exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 61 families; each ran at head with its exit recorded before any pipe: 61 of 61 `exit 0` (`check-plugin-teardown-shape --self-test` after fetching its fixture commit, `check:dual-build-cjs-loads` after the full build). `--ran`: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - `pnpm lint` exit 0. - `turbo run build --filter='!@objectstack/docs'` 119/119 under the verify lock (VERDICT command-exit 0). - Full dogfood battery (narrowing arm), `pnpm --filter @objectstack/dogfood test` under the verify lock: VERDICT command-exit 0, Test Files 419 passed / 3 skipped (422), Tests 3492 passed / 36 skipped (3528). - Ablation: see Tests. ``` pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2 src/__tests__/sqlite-filename-neither-memory-nor-path.test.ts ``` Expect 14 passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6Cb83ajcseWprEF9C9g --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85a8fa0 commit 22f0d65

4 files changed

Lines changed: 320 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: Test connection and Save refuse a SQLite filename that is neither `:memory:` nor a usable file path, where Test connection used to pass it
6+
7+
Clause-②: yes (narrowing) — `POST /api/v1/datasources/test`, `POST /api/v1/datasources` and a `PATCH /api/v1/datasources/:name` that touches `config` or `driver` refuse a `sqlite` / `sqlite-wasm` `config.filename` that is empty, starts with `:` without being exactly `:memory:`, or names a path that can be neither opened nor created. The test answered `ok: true` for the first two and the save stored all three; both now answer with the value and the reason (`result.ok: false` from the test, `400 DATASOURCE_ADMIN_ERROR` from the save), the envelope every invalid `config` already gets.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: SqliteConfigSchema still types filename as a string, and the build and meta doors judge it as before. A stored row keeps loading at boot as it did; only a write through the admin doors that touches its config is judged, so there is nothing at rest for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`): a SQLite filename that names no usable database is refused by Test connection and by Save (#30331).
13+
14+
- **Before.** The New datasource dialog's `:memory:` with a typed path after it (`:memory:/srv/data/parts.db`) tested green. Every layer reads a name that starts with `:` as fileless, so the probe's missing-file check skipped it. Saved, a `sqlite` datasource then opened it as a relative path under the server's working directory whose directory does not exist, so every query failed while the health check answered `false` (which the probe did not read as a failure); a `sqlite-wasm` one opened an ephemeral in-memory database, so writes succeeded and were gone at the next restart. An empty filename tested green too, and a path under a regular file or in a directory the server may not write could be saved.
15+
- **Now.** Test connection answers `ok: false` and Save answers `400 DATASOURCE_ADMIN_ERROR`, under `Invalid configuration for driver '…'`, with a `config.filename:` line that names the value and why: not `:memory:` and not a file path, empty, or the operating system's reason the file cannot be opened or created.
16+
- **Unchanged.** Exactly `:memory:` tests green and saves. An existing database file tests green and saves. A creatable path that does not exist yet is still refused by the test (which never creates it, #27512) and accepted by Save, which creates it on connect. `turso` names its target as a url and is not judged here.
17+
18+
**The remedy.** Use exactly `:memory:` for an ephemeral database, or the path of a database file with nothing in front of it.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
8+
import type { IDatasourceAdminService } from '../contracts/index.js';
9+
import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js';
10+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
11+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
12+
import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js';
13+
14+
// [#10126] Pay the first transform of the dist-resolved workspace deps at module
15+
// load, not inside a clocked `it()` (see `scripts/check-test-source-alias.mjs`).
16+
import '@objectstack/spec/kernel';
17+
import '@objectstack/driver-sql';
18+
19+
/**
20+
* [#30331] A SQLite `config.filename` that is neither exactly `:memory:` nor a
21+
* usable file path is refused by Test connection and by Save, with a message
22+
* that names the value and says why.
23+
*
24+
* The value the card measured is `:memory:/…/parts.db`: the dialog's
25+
* `:memory:` with a typed path after it. Test connection answered `ok: true`.
26+
* Every layer reads a `:`-prefixed name as fileless, so the draft probe's
27+
* missing-file refusal skipped it; better-sqlite3 then opened it as a relative
28+
* path whose directory does not exist (every query fails, the health check
29+
* answers `false`), and sqlite-wasm opened an ephemeral database that loses
30+
* everything at the next connect.
31+
*
32+
* Driven through the real door: the real `HonoHttpServer`, the real admin
33+
* routes, the real plugin (its probe and its service) and the default driver
34+
* factory. The metadata service is a map, so a Save is observable.
35+
*/
36+
37+
const authService = createSessionAuthService();
38+
const grantsEngine = createGrantsEngine();
39+
40+
async function mountDoor() {
41+
let admin: IDatasourceAdminService | undefined;
42+
const rows = new Map<string, unknown>();
43+
const metadata = {
44+
get: async (_type: string, name: string) => rows.get(name),
45+
list: async () => [...rows.values()],
46+
register: async (_type: string, name: string, record: unknown) => {
47+
rows.set(name, record);
48+
},
49+
unregister: async (_type: string, name: string) => {
50+
rows.delete(name);
51+
},
52+
};
53+
const ctx: any = {
54+
getService: (name: string) => {
55+
if (name === 'metadata') return metadata;
56+
throw new Error(`no service ${name}`);
57+
},
58+
registerService: (name: string, svc: unknown) => {
59+
if (name === 'datasource-admin') admin = svc as IDatasourceAdminService;
60+
},
61+
trigger: async () => {},
62+
logger: { warn() {}, info() {} },
63+
};
64+
await new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }).init(ctx);
65+
66+
const server = new HonoHttpServer(0);
67+
registerDatasourceAdminRoutes(
68+
server,
69+
{
70+
getService: vi.fn((name: string) =>
71+
name === 'auth'
72+
? authService
73+
: name === 'objectql' || name === 'data'
74+
? grantsEngine
75+
: name === 'datasource-admin'
76+
? admin
77+
: undefined,
78+
),
79+
} as any,
80+
'/api/v1',
81+
);
82+
const app = server.getRawApp();
83+
const send = async (method: string, path: string, body: unknown) => {
84+
const res = await app.fetch(
85+
new Request(`http://local/api/v1/datasources${path}`, {
86+
method,
87+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
88+
body: JSON.stringify(body),
89+
}),
90+
);
91+
return { status: res.status, body: (await res.json()) as any };
92+
};
93+
return {
94+
rows,
95+
test: async (draft: Record<string, unknown>) => {
96+
const res = await send('POST', '/test', { name: 'draft', ...draft });
97+
expect(res.status).toBe(200);
98+
return res.body.data.result as { ok: boolean; error?: string };
99+
},
100+
create: (draft: Record<string, unknown>) => send('POST', '', draft),
101+
update: (name: string, patch: Record<string, unknown>) => send('PATCH', `/${name}`, patch),
102+
};
103+
}
104+
105+
let root: string;
106+
beforeEach(() => {
107+
root = mkdtempSync(join(tmpdir(), 'ds-30331-'));
108+
});
109+
afterEach(() => {
110+
rmSync(root, { recursive: true, force: true });
111+
});
112+
113+
/** Both drivers that open `config.filename`. */
114+
const DRIVERS = ['sqlite', 'sqlite-wasm'] as const;
115+
116+
function expectRefusal(res: { status: number; body: any }, value: string, why: RegExp) {
117+
expect(res.status).toBe(400);
118+
expect(res.body.success).toBe(false);
119+
// The envelope and code every bad config already answers with (#4410).
120+
expect(res.body.error.code).toBe('DATASOURCE_ADMIN_ERROR');
121+
expect(res.body.error.message).toContain('Invalid configuration for driver');
122+
expect(res.body.error.message).toContain(value);
123+
expect(res.body.error.message).toMatch(why);
124+
}
125+
126+
describe('#30331 — a filename that is neither `:memory:` nor a usable path is refused', () => {
127+
for (const driver of DRIVERS) {
128+
describe(driver, () => {
129+
it('`:memory:/…` is refused by Test connection, naming the value and why', async () => {
130+
const door = await mountDoor();
131+
const value = `:memory:${join(root, 'parts.db')}`;
132+
133+
const result = await door.test({ driver, config: { filename: value } });
134+
135+
expect(result.ok).toBe(false);
136+
expect(result.error).toContain(value);
137+
expect(result.error).toMatch(/neither ':memory:' nor a file path/);
138+
expect(readdirSync(root)).toEqual([]);
139+
});
140+
141+
it('`:memory:/…` is refused by Save (create and update), and nothing is stored', async () => {
142+
const door = await mountDoor();
143+
const value = `:memory:${join(root, 'parts.db')}`;
144+
145+
expectRefusal(await door.create({ name: 'parts', driver, config: { filename: value } }), value, /neither ':memory:' nor a file path/);
146+
expect(door.rows.has('parts')).toBe(false);
147+
148+
const created = await door.create({ name: 'parts', driver, config: { filename: ':memory:' } });
149+
expect(created.status).toBe(201);
150+
expectRefusal(await door.update('parts', { config: { filename: value } }), value, /neither ':memory:' nor a file path/);
151+
expect((door.rows.get('parts') as any).config.filename).toBe(':memory:');
152+
});
153+
154+
it('an unusable path is refused by Test connection and by Save', async () => {
155+
const door = await mountDoor();
156+
writeFileSync(join(root, 'blocker'), 'a regular file');
157+
const value = join(root, 'blocker/parts.db');
158+
159+
const result = await door.test({ driver, config: { filename: value } });
160+
expect(result.ok).toBe(false);
161+
expect(result.error).toContain(value);
162+
expect(result.error).toMatch(/cannot be/);
163+
164+
expectRefusal(await door.create({ name: 'parts', driver, config: { filename: value } }), value, /cannot be/);
165+
expect(door.rows.has('parts')).toBe(false);
166+
expect(readdirSync(root)).toEqual(['blocker']);
167+
});
168+
169+
it('an empty filename is refused by Test connection and by Save', async () => {
170+
const door = await mountDoor();
171+
const result = await door.test({ driver, config: { filename: '' } });
172+
expect(result.ok).toBe(false);
173+
expect(result.error).toMatch(/'' is empty and names no database file/);
174+
expectRefusal(await door.create({ name: 'parts', driver, config: { filename: '' } }), "''", /is empty/);
175+
});
176+
177+
// Controls: what was right stays right.
178+
it('control: exactly `:memory:` still tests ok and saves', async () => {
179+
const door = await mountDoor();
180+
expect((await door.test({ driver, config: { filename: ':memory:' } })).ok).toBe(true);
181+
expect((await door.create({ name: 'scratch', driver, config: { filename: ':memory:' } })).status).toBe(201);
182+
});
183+
184+
it('control: an existing database file still tests ok and saves', async () => {
185+
const door = await mountDoor();
186+
const file = join(root, 'existing.db');
187+
const { SqlDriver } = await import('@objectstack/driver-sql');
188+
const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true } as any);
189+
await seed.connect();
190+
await seed.disconnect();
191+
192+
expect((await door.test({ driver, config: { filename: file } })).ok).toBe(true);
193+
expect((await door.create({ name: 'existing', driver, config: { filename: file } })).status).toBe(201);
194+
});
195+
196+
it('control: a creatable path that does not exist keeps today\'s answers', async () => {
197+
const door = await mountDoor();
198+
const file = join(root, 'missing/dir/new.db');
199+
200+
// Test connection: refused, and nothing created (#27512).
201+
const result = await door.test({ driver, config: { filename: file } });
202+
expect(result.ok).toBe(false);
203+
expect(result.error).toContain('does not exist');
204+
expect(existsSync(file)).toBe(false);
205+
206+
// Save: accepted.
207+
expect((await door.create({ name: 'fresh', driver, config: { filename: file } })).status).toBe(201);
208+
expect((door.rows.get('fresh') as any).config.filename).toBe(file);
209+
});
210+
});
211+
}
212+
});

‎packages/services/service-datasource/src/datasource-admin-service.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { redactDatasourceConfig, restoreRedactedConfig } from './datasource-conf
4444
import { planCredentialMigration } from './datasource-credential-migration.js';
4545
import { borrowedConnectorOf } from './datasource-connector-credential.js';
4646
import { ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource-route-reserved-names.js';
47+
import { sqliteDatabaseFilenameIssue } from './sqlite-database-filename.js';
4748
import type {
4849
IDatasourceAdminService,
4950
DatasourceDraft,
@@ -804,7 +805,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
804805
// report a green "Connection successful" for a datasource that points
805806
// somewhere else entirely — the wizard's version of #4410's core bug.
806807
try {
807-
this.assertValidConfig(input.driver, input.config);
808+
await this.assertValidConfig(input.driver, input.config);
808809
} catch (err) {
809810
return { ok: false, error: err instanceof Error ? err.message : String(err) };
810811
}
@@ -851,7 +852,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
851852
this.assertNoUndeclaredTopLevelKey(`Invalid datasource '${input.name}'.`, input);
852853
assertNoInlineSslKey(`Invalid datasource '${input.name}'.`, input);
853854
if (!input.driver) throw new Error('A driver is required to create a datasource.');
854-
this.assertValidConfig(input.driver, input.config);
855+
await this.assertValidConfig(input.driver, input.config);
855856
// The wizard is a publish door for `pool` too (#5714). Rejected BEFORE the
856857
// record is persisted: `tryRegisterPool` swallows its failures into a
857858
// warning, so a datasource saved with an unhonourable pool would sit in the
@@ -941,7 +942,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
941942
// #4410 would otherwise become uneditable, including the `active: false`
942943
// that takes a misconfigured datasource out of service.
943944
if (patch.config !== undefined || patch.driver !== undefined) {
944-
this.assertValidConfig(merged.driver, merged.config);
945+
await this.assertValidConfig(merged.driver, merged.config);
945946
}
946947
// Same judgement, same "only when this write touches the pairing" rule
947948
// (#5714): a new `pool`, or a new `driver` that reinterprets the stored
@@ -1366,14 +1367,23 @@ export class DatasourceAdminService implements IDatasourceAdminService {
13661367
*
13671368
* A driver the platform ships no contract for passes untouched, matching the
13681369
* spec gate's boundary rather than inventing a stricter one for the UI.
1370+
*
1371+
* [#30331] A SQLite `config.filename` that is neither `:memory:` nor a
1372+
* usable file path is refused here too, in the same envelope: the contract
1373+
* types it as a string, and this is the one judgement the test, the create
1374+
* and the update doors share (see {@link sqliteDatabaseFilenameIssue}).
13691375
*/
1370-
private assertValidConfig(driver: string, config: unknown): void {
1376+
private async assertValidConfig(driver: string, config: unknown): Promise<void> {
13711377
const result = validateDriverConfig(driver, config);
1372-
if (!result.known || result.issues.length === 0) return;
1373-
const detail = result.issues
1374-
.map((issue) => (issue.path.length ? `config.${issue.path.join('.')}: ${issue.message}` : issue.message))
1375-
.join('\n');
1376-
throw new Error(`Invalid configuration for driver '${driver}'.\n${detail}`);
1378+
const issues = result.known
1379+
? result.issues.map((issue) => (issue.path.length ? `config.${issue.path.join('.')}: ${issue.message}` : issue.message))
1380+
: [];
1381+
if (issues.length === 0) {
1382+
const filenameIssue = await sqliteDatabaseFilenameIssue(driver, config);
1383+
if (filenameIssue) issues.push(filenameIssue);
1384+
}
1385+
if (issues.length === 0) return;
1386+
throw new Error(`Invalid configuration for driver '${driver}'.\n${issues.join('\n')}`);
13771387
}
13781388

13791389
/**
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { resolveDriverId } from '@objectstack/spec/data';
4+
5+
/** The one name SQLite opens as an ephemeral, fileless database. */
6+
const IN_MEMORY = ':memory:';
7+
8+
/**
9+
* [#30331] Why a SQLite datasource's `config.filename` names no database the
10+
* datasource can use, or `undefined` when it does. The value is either exactly
11+
* `:memory:` or a file path that exists or can be created; anything else is
12+
* refused with the value and the reason.
13+
*
14+
* Judged where the test, the create and the update doors all judge `config`
15+
* (`DatasourceAdminService.assertValidConfig`), so a draft that tests red is
16+
* also refused at Save, and one that tests green can be saved.
17+
*
18+
* ## Why `:memory:/…` passed
19+
*
20+
* Measured on `main` 8589756a, with `:memory:/tmp/x/parts.db` (the dialog's
21+
* `:memory:` value with a typed path after it):
22+
*
23+
* - Every layer reads a name that starts with `:` as fileless (driver-sql's
24+
* `sqliteFilename` and `resolveSqliteAbsentFileTarget`, the wasm driver's
25+
* ephemeral check), so the draft probe's missing-file refusal (#27512)
26+
* skipped it and `SqlDriver.connect()` made no directory for it.
27+
* - `sqlite` (better-sqlite3) then opens the name as a relative path under
28+
* the server's working directory, whose `:memory:/…` directory does not
29+
* exist: `connect()` only warns, every query fails with "Cannot open
30+
* database because the directory does not exist", and its health check
31+
* answers `false`, which the probe did not read as a failure.
32+
* - `sqlite-wasm` opens an ephemeral in-memory database: reads and writes
33+
* succeed, and everything is gone at the next connect or restart.
34+
*
35+
* ## What counts as usable
36+
*
37+
* The file judgement is driver-sql's write-free one,
38+
* `resolveSqliteAbsentFileTarget`, under `'empty-in-memory'`: it throws the
39+
* `SqliteDatabaseFileError` naming the file for a path that can be neither
40+
* opened nor created (#24961), and answers for an absent one without creating
41+
* it. An absent but creatable path is usable: Save keeps creating it on
42+
* connect, and the draft probe keeps refusing to (#27512).
43+
*
44+
* `sqlite` and `sqlite-wasm` only: both open `config.filename`. `turso` names
45+
* its target as a url, which libSQL parses itself.
46+
*/
47+
export async function sqliteDatabaseFilenameIssue(driver: string, config: unknown): Promise<string | undefined> {
48+
const id = resolveDriverId(driver);
49+
if (id !== 'sqlite' && id !== 'sqlite-wasm') return undefined;
50+
const filename = (config as { filename?: unknown } | null | undefined)?.filename;
51+
// Absent is the schema's own `:memory:` default; a non-string is the
52+
// driver contract's refusal, already given.
53+
if (typeof filename !== 'string' || filename === IN_MEMORY) return undefined;
54+
if (filename === '') {
55+
return `config.filename: '' is empty and names no database file. Use '${IN_MEMORY}' for an ephemeral `
56+
+ 'in-memory database, or the path of a database file.';
57+
}
58+
if (filename.startsWith(':')) {
59+
return `config.filename: '${filename}' is neither '${IN_MEMORY}' nor a file path. `
60+
+ `A name that starts with ':' is not read as a file, so this datasource would store nothing: `
61+
+ `use exactly '${IN_MEMORY}' for an ephemeral in-memory database, or the path of a database file `
62+
+ 'with nothing in front of it.';
63+
}
64+
const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql');
65+
try {
66+
resolveSqliteAbsentFileTarget(filename, 'empty-in-memory');
67+
} catch (err) {
68+
return `config.filename: ${err instanceof Error ? err.message : String(err)}`;
69+
}
70+
return undefined;
71+
}

0 commit comments

Comments
 (0)