Skip to content

Commit af52253

Browse files
fix(service-datasource): testing a datasource draft no longer creates its SQLite database file (#27603)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27512 Clause-②: no Metadata-protocol: none Skills-docs: none — Metadata-protocol is none and there is no narrowing arm; nothing an author writes changes. ## 维护者速读 - **改了什么**:数据源草稿的「测试连接」(`POST /api/v1/datasources/test`)遇到不存在的 SQLite 文件时,改为回报「文件不存在」,不再在磁盘上创建这个文件和它缺失的目录。 - **为什么改**:测试连接什么都不保存,却会按「启动写库」的方式打开 SQLite,管理员输错路径就会在服务器上留下空数据库文件(sqlite、sqlite-wasm、turso `file:` 三种驱动实测都会建)。 - **风险与回滚**:只改草稿探测这一条路,判断在探测内部、建驱动之前;导出类型与默认工厂与 main 字节一致,保存数据源、启动恢复、热注册连接池仍照旧建文件。回滚即还原 `datasource-admin-plugin.ts`。 - **先看**:`datasource-admin-plugin.ts` 的 `probe`(调工厂前的 `absentDatabaseFileRefusal` 一行)及文件尾部的 `probedDatabaseFile` / `absentDatabaseFileRefusal`。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/draft-probe-creates-no-database-file.test.ts`,预期 15/15 通过。 - **需要你做的**:无,席位落地。 ## What changes - `DatasourceAdminServicePlugin.probe` (the draft probe behind `POST /api/v1/datasources/test`) judges a file-backed draft before it asks the driver factory for anything: `sqlite` / `sqlite3` and `sqlite-wasm` (`config.filename`, absent ⇒ `:memory:`), and `turso` / `libsql` with a `file:` url (scheme in any letter case, path as driver-turso's local engine opens it). A draft whose file does not exist is answered on the probe's existing verdict door, HTTP `200` with `result.ok: false` and an error naming the file (`SQLite database file 'PATH' does not exist. Testing a connection never creates it: …`); a path that can be neither opened nor created gets driver-sql's own `SqliteDatabaseFileError` text. Nothing is written to find out. - The existence judgement is driver-sql's one write-free check, `resolveSqliteAbsentFileTarget` (an absent file reads `openedEmptyInMemory`; an unopenable one throws), so no second `existsSync` is open-coded. - Internal only: the two helpers are module-private in `datasource-admin-plugin.ts`. `contracts/datasource-driver-factory.ts`, `default-datasource-driver-factory.ts` and every export are byte-identical to `main`. Because the check is the probe's own, it holds whichever driver factory the host registers. - Unchanged: Save, the boot restore, hot pools and `DatasourceConnectionService` build through the factory exactly as before and still create the file; `:memory:` and server databases are never judged; the Setup page already renders `result.error`. ## Tests New `src/__tests__/draft-probe-creates-no-database-file.test.ts`, through the real door (real `HonoHttpServer`, real admin routes, real plugin probe, default driver factory unless the case says otherwise): - 10 cases: each of `sqlite`, `sqlite3`, `sqlite-wasm`, `turso`, `libsql` × a missing flat path and a missing nested path → `200`, `success: true`, `result.ok: false`, error names the file, the file does not exist and the temp root is still empty (no directory made). - A host factory (a fake whose `create` is a spy) is never asked to build any of the five drafts on a missing path; all answer `ok: false`; nothing created. - A path under a regular file (can be neither opened nor created) → `ok: false` naming the file; only the blocker file remains. - Controls: an existing SQLite file still probes `ok: true` and nothing else appears beside it; a `:memory:` draft probes `ok: true`; the default factory, used directly as Save uses it, still creates the file on connect. ## Acceptance notes - **PM assumption measured.** Before the fix, a probe on a missing path created the file AND its missing directories for `sqlite`, `sqlite-wasm` and `turso` (`file:` url); measured through `DatasourceAdminServicePlugin.probe` with the default factory on the built tree (all three answered `ok: true` and left `nested/dir/X.db` behind). `memory` with file persistence creates nothing on a probe (measured), so it is not judged. - **The existing `sqliteAbsentFile: 'empty-in-memory'` option was not used**, as the order said: it is factory-wide and answers `ok` on a missing file. - **Shape changed in review (seat REWORK on `d9b68b90`).** The first shape carried a `fileMustExist` intent on `DatasourceConnectionSpec`, an exported type, which widened the published surface (Clause-② would have been yes). The judgement now lives inside the probe; the contract and the default factory are back to `main`'s bytes, so `Clause-②: no` holds. - **"code + status" read as the probe's verdict door.** The draft probe answers every failure as `200 { result: { ok: false, error } }`; `TestConnectionResult` has no `code` field, and adding one is a response-shape addition this card did not ask for, so it was not made. The door pin asserts status `200`, `ok: false`, the named file and the untouched filesystem; the kind is carried by the error's text, as for every other probe failure. - **Read-only open not added.** Triage said "read-only and must-exist". Must-exist is what stops the file being created. For an EXISTING file, `SqlDriver.connect()` already writes nothing to a file whose pragmas are set (#21734); a read-only open would instead fail the probe on a legacy `auto_vacuum=NONE` file, whose connect issues the setter (read from the code, not measured). Left as is. - **Embedded replica.** A `turso` draft whose local replica is a `file:` url that does not exist yet is now refused by the probe too (a first connect would have created the replica file). Consistent with the card: the probe creates nothing. - The door pin asserts the error names the file and contains `does not exist`: the card's acceptance is literally that the probe reports the file does not exist, and the door carries no code, so that phrase is the one kind-bearing fact the Setup page shows. - Branch is the card thread's harness-pinned `claude/project-thread-yhiysu` (the claim names it). ## Verification All readings on head `d3b198ec` unless the line says otherwise. <details><summary>Red first, then green (the new test file)</summary> - Before any fix (the base `72b08eec` + the first version of the test): `Tests 11 failed | 3 passed (14)`, every door case `expected true to be false` (the probe answered `ok: true` and left the file). - Final head: `Tests 15 passed (15)`. </details> <details><summary>Package suite and typecheck</summary> - `pnpm --filter @objectstack/service-datasource test`: `Test Files 59 passed (59)` · `Tests 999 passed (999)`; `typecheck` exit 0 (tsconfig includes `src/**`, tests included). Lock VERDICT command-exit 0. </details> <details><summary>Ablation (scripts/ablation-replace.mjs, restored blob == HEAD)</summary> - The probe's guard line (`if (fileRefusal) return …`) deleted on `d3b198ec`: mutation landed (anchor 1 → 0, blob `9c6083e9` → `abcba580`) → `Tests 11 failed | 4 passed (15)`: the ten door cases and the host-factory pin red → restored, blob == HEAD `9c6083e9`, `git diff HEAD` empty. - The unopenable-path case stays green under the ablation, as expected: without the guard the real driver's connect refuses that path itself and creates nothing; it pins the probe's answer for it, not the guard. - Subjects are imported from `src/` by relative path: no rebuild in the path. </details> <details><summary>Derived gates and the --ran reconciliation</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `d3b198ec` → 59 commands, each run with its exit captured before any pipe: 59 × `exit 0` (full build present, `service-datasource` rebuilt on this head, so `check:dual-build-cjs-loads` measured). - `--ran`: `59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN`. - The run on the first shape (`bf1d2ab4`) caught `check:dispatcher-error-vocabulary` reading a literal `ENOENT` class field as an unregistered ADR-0112 code; that refusal class no longer exists in this shape. </details> <details><summary>Lint, conformance ledger, public face, merge-tree</summary> - `eslint packages/services/service-datasource --no-inline-config` on `d3b198ec`: exit 0. Repo-wide `pnpm lint` exit 0 on the first head `bf1d2ab4`. - `check:driver-conformance` before (merge base) and after: `55 covered cell(s), 0 in the DEBT ledger, 0 exempt` both times. No driver package is touched. - `git diff` of `contracts/`, `default-datasource-driver-factory.ts` and `index.ts` against the merged `main` (`1ed33609`): empty. - `git merge-tree` against the one other open PR on `service-datasource` (#27574): clean, no shared file. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e33108 commit af52253

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): testing a datasource draft no longer creates its SQLite database file
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
"Test connection" on a datasource draft (`POST /api/v1/datasources/test`) saves nothing, yet for a file-backed SQLite draft it opened the database the way a server boot does, so the file, and every missing directory above it, was created on disk although no datasource was saved. A mistyped path left a stray database wherever the server process could write. Measured on every file-backed driver the default factory builds: `sqlite` / `sqlite3`, `sqlite-wasm`, and `turso` / `libsql` with a `file:` url.
11+
12+
- **What the probe answers now.** A draft whose database file does not exist is refused: the probe answers `ok: false` with an error that names the file and says it does not exist. Nothing is created: neither the file nor its directory.
13+
- **What still works.** A draft naming an existing database file, or `:memory:`, probes as before. Saving a datasource still creates its file on connect, as do the boot restore and every hot pool: only the probe asks for an existing file.
14+
- **Whichever factory builds.** The check is the probe's own and runs before the driver factory is asked for anything, so it holds for a host-registered factory too. Nothing a host or an author can write changes: no exported type or function signature moves.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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, IDatasourceDriverFactory } 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+
* [#27512] The draft probe — `POST /api/v1/datasources/test`, the wizard's
21+
* "Test connection" before Save — persists nothing, so it must leave nothing on
22+
* disk either. For a file-backed SQLite draft it used to open the file the way
23+
* a writing boot does: the file (and every missing directory above it) was
24+
* created, although no datasource was saved. Measured on every file-backed
25+
* SQL-family driver the default factory builds: `sqlite` (better-sqlite3),
26+
* `sqlite-wasm`, and `turso` with a `file:` url.
27+
*
28+
* Driven through the real door: the real `HonoHttpServer`, the real admin
29+
* routes, the real plugin probe and the default driver factory.
30+
*/
31+
32+
const authService = createSessionAuthService();
33+
const grantsEngine = createGrantsEngine();
34+
35+
async function mountDoor(driverFactory: IDatasourceDriverFactory = createDefaultDatasourceDriverFactory()) {
36+
let admin: IDatasourceAdminService | undefined;
37+
const ctx: any = {
38+
getService: (name: string) => {
39+
if (name === 'metadata') return { get: async () => undefined, list: async () => [] };
40+
throw new Error(`no service ${name}`);
41+
},
42+
registerService: (name: string, svc: unknown) => {
43+
if (name === 'datasource-admin') admin = svc as IDatasourceAdminService;
44+
},
45+
trigger: async () => {},
46+
logger: { warn() {}, info() {} },
47+
};
48+
await new DatasourceAdminServicePlugin({ driverFactory }).init(ctx);
49+
50+
const server = new HonoHttpServer(0);
51+
registerDatasourceAdminRoutes(
52+
server,
53+
{
54+
getService: vi.fn((name: string) =>
55+
name === 'auth'
56+
? authService
57+
: name === 'objectql' || name === 'data'
58+
? grantsEngine
59+
: name === 'datasource-admin'
60+
? admin
61+
: undefined,
62+
),
63+
} as any,
64+
'/api/v1',
65+
);
66+
const app = server.getRawApp();
67+
return (draft: Record<string, unknown>) =>
68+
app.fetch(
69+
new Request('http://local/api/v1/datasources/test', {
70+
method: 'POST',
71+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
72+
body: JSON.stringify(draft),
73+
}),
74+
);
75+
}
76+
77+
/** Every file-backed SQL-family driver id, and how its draft names the file. */
78+
const FILE_BACKED: Array<{ driver: string; config: (file: string) => Record<string, unknown> }> = [
79+
{ driver: 'sqlite', config: (file) => ({ filename: file }) },
80+
{ driver: 'sqlite3', config: (file) => ({ filename: file }) },
81+
{ driver: 'sqlite-wasm', config: (file) => ({ filename: file }) },
82+
{ driver: 'turso', config: (file) => ({ url: `file:${file}` }) },
83+
{ driver: 'libsql', config: (file) => ({ url: `file:${file}` }) },
84+
];
85+
86+
let root: string;
87+
beforeEach(() => {
88+
root = mkdtempSync(join(tmpdir(), 'ds-draft-probe-'));
89+
});
90+
afterEach(() => {
91+
rmSync(root, { recursive: true, force: true });
92+
});
93+
94+
describe('POST /api/v1/datasources/test — a draft probe leaves no database file (#27512)', () => {
95+
for (const { driver, config } of FILE_BACKED) {
96+
for (const where of ['probe.db', 'missing/dir/probe.db']) {
97+
it(`${driver}: a missing '${where}' is reported, and neither the file nor its directory is created`, async () => {
98+
const probe = await mountDoor();
99+
const file = join(root, where);
100+
101+
const res = await probe({ name: 'draft', driver, config: config(file) });
102+
103+
// The probe's verdict door: 200 with `result.ok: false`, as for every
104+
// other probe failure — the draft is refused, not the request.
105+
expect(res.status).toBe(200);
106+
const body = (await res.json()) as { success: boolean; data: { result: { ok: boolean; error?: string } } };
107+
expect(body.success).toBe(true);
108+
expect(body.data.result.ok).toBe(false);
109+
// Names the missing file, so the admin knows WHAT the probe refused.
110+
expect(body.data.result.error).toContain(file);
111+
expect(body.data.result.error).toContain('does not exist');
112+
// …and the filesystem is as the probe found it.
113+
expect(existsSync(file)).toBe(false);
114+
expect(readdirSync(root)).toEqual([]);
115+
});
116+
}
117+
}
118+
119+
// Control: the refusal is about ABSENCE, not about file-backed SQLite. A
120+
// database file that exists probes green, and is the only thing in the
121+
// directory afterwards, as before.
122+
it('control: an existing SQLite file still probes ok', async () => {
123+
const probe = await mountDoor();
124+
const file = join(root, 'existing.db');
125+
const { SqlDriver } = await import('@objectstack/driver-sql');
126+
const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true } as any);
127+
await seed.connect();
128+
await seed.disconnect();
129+
expect(existsSync(file)).toBe(true);
130+
131+
const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: file } });
132+
133+
expect(res.status).toBe(200);
134+
const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } };
135+
expect(body.data.result).toMatchObject({ ok: true });
136+
expect(readdirSync(root)).toEqual(['existing.db']);
137+
});
138+
139+
// Control: an in-memory draft names no file, so nothing is refused.
140+
it('control: a :memory: SQLite draft still probes ok', async () => {
141+
const probe = await mountDoor();
142+
const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: ':memory:' } });
143+
expect(res.status).toBe(200);
144+
const body = (await res.json()) as { data: { result: { ok: boolean } } };
145+
expect(body.data.result.ok).toBe(true);
146+
});
147+
});
148+
149+
describe('the judgement is the probe\'s, whichever factory builds (#27512)', () => {
150+
it('a host factory is never asked to build a draft whose file is missing', async () => {
151+
const create = vi.fn(async () => ({ connect: async () => {}, ping: async () => {} }));
152+
const probe = await mountDoor({ supports: () => true, create });
153+
const file = join(root, 'missing/dir/probe.db');
154+
155+
for (const { driver, config } of FILE_BACKED) {
156+
const res = await probe({ name: 'draft', driver, config: config(file) });
157+
const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } };
158+
expect(res.status, driver).toBe(200);
159+
expect(body.data.result.ok, driver).toBe(false);
160+
}
161+
expect(create).not.toHaveBeenCalled();
162+
expect(readdirSync(root)).toEqual([]);
163+
});
164+
165+
it('a path that can be neither opened nor created is refused too, and nothing is written', async () => {
166+
const probe = await mountDoor();
167+
writeFileSync(join(root, 'blocker'), 'a regular file');
168+
const file = join(root, 'blocker/probe.db');
169+
170+
const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: file } });
171+
172+
expect(res.status).toBe(200);
173+
const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } };
174+
expect(body.data.result.ok).toBe(false);
175+
expect(body.data.result.error).toContain(file);
176+
expect(readdirSync(root)).toEqual(['blocker']);
177+
});
178+
179+
// Control: the default factory is untouched — Save, the boot restore and
180+
// every hot pool build through it and still create the file on connect,
181+
// which is what a datasource that is being SAVED is owed.
182+
it('control: the default factory still creates a sqlite target on connect', async () => {
183+
const factory = createDefaultDatasourceDriverFactory();
184+
const file = join(root, 'created.db');
185+
const handle = await factory.create({ driver: 'sqlite', config: { filename: file } });
186+
await handle.connect?.();
187+
await handle.disconnect?.();
188+
expect(existsSync(file)).toBe(true);
189+
});
190+
});

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Plugin, PluginContext } from '@objectstack/core';
44
import { hashSpec, readLegacyOrganizationRows } from '@objectstack/metadata-core';
55
import { applyConversionsToStoredItem } from '@objectstack/spec';
66
import { registerMetadataTypeActions } from '@objectstack/spec/kernel';
7+
import { resolveDriverId } from '@objectstack/spec/data';
78
import type {
89
IDatasourceDriverFactory,
910
TestConnectionResult,
@@ -21,6 +22,7 @@ import {
2122
import { datasourceConnectivityChanged } from './datasource-connectivity-change.js';
2223
import type { DatasourceConnectPolicy } from './contracts/connect-policy.js';
2324
import type { Logger } from './logger.js';
25+
import { resolveTursoUrl } from './turso-driver-config.js';
2426
import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
2527

2628
/**
@@ -872,6 +874,11 @@ export class DatasourceAdminServicePlugin implements Plugin {
872874
if (!factory.supports(input.driver)) {
873875
return { ok: false, error: `No driver factory supports driver '${input.driver}'.` };
874876
}
877+
// [#27512] A probe persists nothing, so it may create nothing either.
878+
// Judged before the factory builds anything: every file-backed driver
879+
// opens its database the way a writing boot does and creates the file.
880+
const fileRefusal = await absentDatabaseFileRefusal(input);
881+
if (fileRefusal) return { ok: false, error: fileRefusal };
875882

876883
let driver: any;
877884
try {
@@ -958,6 +965,62 @@ function servedOrigin(ctx: PluginContext, name: string): 'code' | 'runtime' {
958965
return codeDatasourceNamesOf(ctx)?.has(name) ? 'code' : 'runtime';
959966
}
960967

968+
/**
969+
* The database file a draft opens when its driver is file-backed, or
970+
* `undefined` when it opens none — a server database, a remote libSQL url, an
971+
* id no file-backed driver answers to. `:memory:` is returned as is; the
972+
* judgement below leaves every `:`-prefixed name alone.
973+
*
974+
* Read as the default driver factory reads the same config: `sqlite` and
975+
* `sqlite-wasm` open `config.filename` (`SqliteConfigSchema`, absent ⇒
976+
* `:memory:`), and driver-turso's local engine opens the path of a `file:`
977+
* url, its scheme matched in any letter case (`TursoDriver.toKnexConfig`).
978+
*/
979+
function probedDatabaseFile(input: ProbeInput): string | undefined {
980+
const config = (input.config ?? {}) as Record<string, unknown>;
981+
switch (resolveDriverId(input.driver)) {
982+
case 'sqlite':
983+
case 'sqlite-wasm':
984+
return typeof config.filename === 'string' ? config.filename : ':memory:';
985+
case 'turso': {
986+
const url = resolveTursoUrl({ driver: input.driver, config });
987+
return url.slice(0, 'file:'.length).toLowerCase() === 'file:' ? url.slice('file:'.length) : undefined;
988+
}
989+
default:
990+
return undefined;
991+
}
992+
}
993+
994+
/**
995+
* [#27512] Why the draft probe refuses to open a file-backed draft, or
996+
* `undefined` when it may: the file the draft names does not exist, or can be
997+
* neither opened nor created. Nothing is written to find out.
998+
*
999+
* The judgement is driver-sql's one write-free existence check,
1000+
* `resolveSqliteAbsentFileTarget`: under `'empty-in-memory'` it answers
1001+
* `openedEmptyInMemory` for an absent file and throws its
1002+
* `SqliteDatabaseFileError`, naming the file, for one that can be neither
1003+
* opened nor created — so no second `existsSync` is open-coded here.
1004+
*
1005+
* It lives in the probe, not in the driver factory, so it holds whichever
1006+
* factory the host registered, and a Save, the boot restore and every hot
1007+
* pool still open the file as before and create it.
1008+
*/
1009+
async function absentDatabaseFileRefusal(input: ProbeInput): Promise<string | undefined> {
1010+
const filename = probedDatabaseFile(input);
1011+
if (filename === undefined) return undefined;
1012+
const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql');
1013+
try {
1014+
if (!resolveSqliteAbsentFileTarget(filename, 'empty-in-memory').openedEmptyInMemory) return undefined;
1015+
} catch (err) {
1016+
return errMsg(err);
1017+
}
1018+
return (
1019+
`SQLite database file '${filename}' does not exist. Testing a connection never creates it: `
1020+
+ 'point the draft at an existing database file, or save the datasource to have it created.'
1021+
);
1022+
}
1023+
9611024
function errMsg(err: unknown): string {
9621025
return err instanceof Error ? err.message : String(err);
9631026
}

0 commit comments

Comments
 (0)