From bf1d2ab4a39905e5c07c6e41eb4bc25d68a7796c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:32:50 +0000 Subject: [PATCH 1/3] fix(service-datasource): the draft probe refuses a missing SQLite file instead of creating it POST /api/v1/datasources/test opened a file-backed SQLite draft the way a writing boot does, so the file and its missing directories were created although nothing was saved. The probe now sets fileMustExist on the connection spec, and the default factory refuses a missing file-backed target (sqlite, sqlite-wasm, a file: turso url) before any driver is built. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgTUuJysc6ThTQ8Xb1r --- ...7512-draft-probe-creates-no-sqlite-file.md | 14 ++ ...aft-probe-creates-no-database-file.test.ts | 176 ++++++++++++++++++ .../contracts/datasource-driver-factory.ts | 17 ++ .../src/datasource-admin-plugin.ts | 3 + .../src/default-datasource-driver-factory.ts | 68 +++++++ 5 files changed, 278 insertions(+) create mode 100644 .changeset/27512-draft-probe-creates-no-sqlite-file.md create mode 100644 packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts diff --git a/.changeset/27512-draft-probe-creates-no-sqlite-file.md b/.changeset/27512-draft-probe-creates-no-sqlite-file.md new file mode 100644 index 00000000000..8fa345f23b3 --- /dev/null +++ b/.changeset/27512-draft-probe-creates-no-sqlite-file.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-datasource': patch +--- + +fix(service-datasource): testing a datasource draft no longer creates its SQLite database file + +Clause-②: no +Metadata-protocol: none + +"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. + +- **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. +- **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. +- **For host factories.** `DatasourceConnectionSpec` gains an optional `fileMustExist`, which the draft probe sets. The default factory refuses a missing file-backed target with an error carrying `code: 'ENOENT'` and the `filename`, before it constructs a driver. A host factory that does not read the flag builds as before. diff --git a/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts b/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts new file mode 100644 index 00000000000..6514a5c142c --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import type { IDatasourceAdminService } from '../contracts/index.js'; +import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js'; +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; +import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps at module +// load, not inside a clocked `it()` (see `scripts/check-test-source-alias.mjs`). +import '@objectstack/spec/kernel'; +import '@objectstack/driver-sql'; + +/** + * [#27512] The draft probe — `POST /api/v1/datasources/test`, the wizard's + * "Test connection" before Save — persists nothing, so it must leave nothing on + * disk either. For a file-backed SQLite draft it used to open the file the way + * a writing boot does: the file (and every missing directory above it) was + * created, although no datasource was saved. Measured on every file-backed + * SQL-family driver the default factory builds: `sqlite` (better-sqlite3), + * `sqlite-wasm`, and `turso` with a `file:` url. + * + * Driven through the real door: the real `HonoHttpServer`, the real admin + * routes, the real plugin probe and the default driver factory. + */ + +const authService = createSessionAuthService(); +const grantsEngine = createGrantsEngine(); + +async function mountDoor() { + let admin: IDatasourceAdminService | undefined; + const ctx: any = { + getService: (name: string) => { + if (name === 'metadata') return { get: async () => undefined, list: async () => [] }; + throw new Error(`no service ${name}`); + }, + registerService: (name: string, svc: unknown) => { + if (name === 'datasource-admin') admin = svc as IDatasourceAdminService; + }, + trigger: async () => {}, + logger: { warn() {}, info() {} }, + }; + await new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }).init(ctx); + + const server = new HonoHttpServer(0); + registerDatasourceAdminRoutes( + server, + { + getService: vi.fn((name: string) => + name === 'auth' + ? authService + : name === 'objectql' || name === 'data' + ? grantsEngine + : name === 'datasource-admin' + ? admin + : undefined, + ), + } as any, + '/api/v1', + ); + const app = server.getRawApp(); + return (draft: Record) => + app.fetch( + new Request('http://local/api/v1/datasources/test', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL }, + body: JSON.stringify(draft), + }), + ); +} + +/** Every file-backed SQL-family driver id, and how its draft names the file. */ +const FILE_BACKED: Array<{ driver: string; config: (file: string) => Record }> = [ + { driver: 'sqlite', config: (file) => ({ filename: file }) }, + { driver: 'sqlite3', config: (file) => ({ filename: file }) }, + { driver: 'sqlite-wasm', config: (file) => ({ filename: file }) }, + { driver: 'turso', config: (file) => ({ url: `file:${file}` }) }, + { driver: 'libsql', config: (file) => ({ url: `file:${file}` }) }, +]; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ds-draft-probe-')); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('POST /api/v1/datasources/test — a draft probe leaves no database file (#27512)', () => { + for (const { driver, config } of FILE_BACKED) { + for (const where of ['probe.db', 'missing/dir/probe.db']) { + it(`${driver}: a missing '${where}' is reported, and neither the file nor its directory is created`, async () => { + const probe = await mountDoor(); + const file = join(root, where); + + const res = await probe({ name: 'draft', driver, config: config(file) }); + + // The probe's verdict door: 200 with `result.ok: false`, as for every + // other probe failure — the draft is refused, not the request. + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; data: { result: { ok: boolean; error?: string } } }; + expect(body.success).toBe(true); + expect(body.data.result.ok).toBe(false); + // Names the missing file, so the admin knows WHAT the probe refused. + expect(body.data.result.error).toContain(file); + expect(body.data.result.error).toContain('does not exist'); + // …and the filesystem is as the probe found it. + expect(existsSync(file)).toBe(false); + expect(readdirSync(root)).toEqual([]); + }); + } + } + + // Control: the refusal is about ABSENCE, not about file-backed SQLite. A + // database file that exists probes green, and is the only thing in the + // directory afterwards, as before. + it('control: an existing SQLite file still probes ok', async () => { + const probe = await mountDoor(); + const file = join(root, 'existing.db'); + const { SqlDriver } = await import('@objectstack/driver-sql'); + const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true } as any); + await seed.connect(); + await seed.disconnect(); + expect(existsSync(file)).toBe(true); + + const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: file } }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } }; + expect(body.data.result).toMatchObject({ ok: true }); + expect(readdirSync(root)).toEqual(['existing.db']); + }); + + // Control: an in-memory draft names no file, so nothing is refused. + it('control: a :memory: SQLite draft still probes ok', async () => { + const probe = await mountDoor(); + const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: ':memory:' } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { result: { ok: boolean } } }; + expect(body.data.result.ok).toBe(true); + }); +}); + +describe('createDefaultDatasourceDriverFactory — `fileMustExist` (#27512)', () => { + it('refuses a missing file-backed SQLite target with code ENOENT and creates nothing', async () => { + const factory = createDefaultDatasourceDriverFactory(); + const file = join(root, 'missing/dir/probe.db'); + for (const { driver, config } of FILE_BACKED) { + const err = await Promise.resolve(factory.create({ driver, config: config(file), fileMustExist: true })).then( + () => undefined, + (e: unknown) => e, + ); + expect(err, driver).toBeInstanceOf(Error); + expect((err as { code?: unknown }).code, driver).toBe('ENOENT'); + expect((err as { filename?: unknown }).filename, driver).toBe(file); + } + expect(readdirSync(root)).toEqual([]); + }); + + // Control: without the probe intent the factory still opens as a writing + // boot does — Save, the boot restore and every hot pool keep creating the + // file, which is the behaviour a datasource that is being SAVED is owed. + it('control: without it, a sqlite target is still created on connect', async () => { + const factory = createDefaultDatasourceDriverFactory(); + const file = join(root, 'created.db'); + const handle = await factory.create({ driver: 'sqlite', config: { filename: file } }); + await handle.connect?.(); + await handle.disconnect?.(); + expect(existsSync(file)).toBe(true); + }); +}); diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts index f5c37676f31..63400f1b81b 100644 --- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts @@ -59,6 +59,23 @@ export interface DatasourceConnectionSpec { * own schema comment warns about. */ ssl?: Record; + /** + * The caller only PROBES this connection: a file-backed database (`sqlite`, + * `sqlite-wasm`, a `turso` `file:` url) whose file does not exist is refused, + * never created (#27512). + * + * Set by the draft probe (`POST /datasources/test`), which persists nothing + * and so must leave nothing on disk either. Opening the target as a writing + * boot does created the file, and every missing directory above it, for a + * datasource nobody saved. Absent ⇒ the target is opened as it always was: + * Save, the boot restore and every hot pool keep creating the file on + * connect. Server databases are untouched, since this process cannot bring + * one into existence anyway. + * + * The refusal is thrown from `create()`, before any driver is constructed, + * and carries `code: 'ENOENT'` and the `filename` it judged. + */ + fileMustExist?: boolean; } /** diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index 7bb6c11d2d4..fef3565b719 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -880,6 +880,9 @@ export class DatasourceAdminServicePlugin implements Plugin { config: input.config, secret: input.secret, external: input.external, + // [#27512] A probe persists nothing, so it may create nothing either: + // a file-backed draft whose file is missing is reported, not created. + fileMustExist: true, }); } catch (err) { return { ok: false, error: `Failed to build driver: ${errMsg(err)}` }; diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 8c164aa6178..01f6d2eb3ef 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -487,6 +487,68 @@ function postgresDsnFields(url: string, datasource?: string): Record { + const filename = databaseFileOf(kind, spec); + if (filename === undefined) return; + const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql'); + if (resolveSqliteAbsentFileTarget(filename, 'empty-in-memory').openedEmptyInMemory) { + throw new DatabaseFileAbsentError(filename); + } +} + /** Build the Knex `connection` for a SQL driver from a spec's config + secret. */ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown { const cfg = (spec.config ?? {}) as Record; @@ -1126,6 +1188,12 @@ export function createDefaultDatasourceDriverFactory( // through, so it is where "declared = honoured" is actually guaranteed. assertDatasourcePoolSupported({ driver: spec.driver, pool: spec.pool, name: spec.name }); + // [#27512] A caller that only probes opens nothing it would have to + // create. Judged here, before any arm constructs a driver: every + // file-backed arm below opens as a writing boot does, and creates the + // file — `sqlite`'s native and wasm rungs, `sqlite-wasm`, a `file:` turso. + if (spec.fileMustExist) await assertDatabaseFileExists(kind, spec); + // ADR-0015's ownership mode. The three-source fallback moved to // `resolveDatasourceSchemaMode` in #7314 — unchanged in behaviour, but it // now has a second caller (the shared libSQL config builder, which From d9b68b903aff6b822692438b7adb813cf25d485c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:47:12 +0000 Subject: [PATCH 2/3] fix(service-datasource): the probe's missing-file refusal carries the OS's own ENOENT answer The refusal stamped a literal 'ENOENT', which the dispatcher error-vocabulary gate reads as an unregistered ADR-0112 code. It now takes code and cause from the operating system's stat answer, as driver-sql's SqliteDatabaseFileError does. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgTUuJysc6ThTQ8Xb1r --- .../src/default-datasource-driver-factory.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 01f6d2eb3ef..0122caf1f09 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -45,6 +45,7 @@ * is never persisted or logged here. */ +import { statSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parsePostgresConnectionString } from 'pg-connection-string'; import { resolveDriverId, urlUserinfoUsername, type BuiltinDriverId } from '@objectstack/spec/data'; @@ -489,20 +490,28 @@ function postgresDsnFields(url: string, datasource?: string): Record Date: Thu, 29 Oct 2026 14:43:00 +0000 Subject: [PATCH 3/3] fix(service-datasource): the missing-file judgement moves into the draft probe; the factory contract stays as on main The first shape added fileMustExist to DatasourceConnectionSpec, an exported type, which widened the published surface. The probe now judges the draft's file itself, before the factory builds anything, and answers the refusal as 200 ok:false; the contract and the default factory are back to main's bytes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgTUuJysc6ThTQ8Xb1r --- ...7512-draft-probe-creates-no-sqlite-file.md | 2 +- ...aft-probe-creates-no-database-file.test.ts | 50 +++++++---- .../contracts/datasource-driver-factory.ts | 17 ---- .../src/datasource-admin-plugin.ts | 66 ++++++++++++++- .../src/default-datasource-driver-factory.ts | 83 ------------------- 5 files changed, 96 insertions(+), 122 deletions(-) diff --git a/.changeset/27512-draft-probe-creates-no-sqlite-file.md b/.changeset/27512-draft-probe-creates-no-sqlite-file.md index 8fa345f23b3..01d4f7128ce 100644 --- a/.changeset/27512-draft-probe-creates-no-sqlite-file.md +++ b/.changeset/27512-draft-probe-creates-no-sqlite-file.md @@ -11,4 +11,4 @@ Metadata-protocol: none - **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. - **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. -- **For host factories.** `DatasourceConnectionSpec` gains an optional `fileMustExist`, which the draft probe sets. The default factory refuses a missing file-backed target with an error carrying `code: 'ENOENT'` and the `filename`, before it constructs a driver. A host factory that does not read the flag builds as before. +- **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. diff --git a/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts b/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts index 6514a5c142c..5abf4eabc35 100644 --- a/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts +++ b/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts @@ -1,11 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; -import type { IDatasourceAdminService } from '../contracts/index.js'; +import type { IDatasourceAdminService, IDatasourceDriverFactory } from '../contracts/index.js'; import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js'; import { registerDatasourceAdminRoutes } from '../admin-routes.js'; import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; @@ -32,7 +32,7 @@ import '@objectstack/driver-sql'; const authService = createSessionAuthService(); const grantsEngine = createGrantsEngine(); -async function mountDoor() { +async function mountDoor(driverFactory: IDatasourceDriverFactory = createDefaultDatasourceDriverFactory()) { let admin: IDatasourceAdminService | undefined; const ctx: any = { getService: (name: string) => { @@ -45,7 +45,7 @@ async function mountDoor() { trigger: async () => {}, logger: { warn() {}, info() {} }, }; - await new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }).init(ctx); + await new DatasourceAdminServicePlugin({ driverFactory }).init(ctx); const server = new HonoHttpServer(0); registerDatasourceAdminRoutes( @@ -146,26 +146,40 @@ describe('POST /api/v1/datasources/test — a draft probe leaves no database fil }); }); -describe('createDefaultDatasourceDriverFactory — `fileMustExist` (#27512)', () => { - it('refuses a missing file-backed SQLite target with code ENOENT and creates nothing', async () => { - const factory = createDefaultDatasourceDriverFactory(); +describe('the judgement is the probe\'s, whichever factory builds (#27512)', () => { + it('a host factory is never asked to build a draft whose file is missing', async () => { + const create = vi.fn(async () => ({ connect: async () => {}, ping: async () => {} })); + const probe = await mountDoor({ supports: () => true, create }); const file = join(root, 'missing/dir/probe.db'); + for (const { driver, config } of FILE_BACKED) { - const err = await Promise.resolve(factory.create({ driver, config: config(file), fileMustExist: true })).then( - () => undefined, - (e: unknown) => e, - ); - expect(err, driver).toBeInstanceOf(Error); - expect((err as { code?: unknown }).code, driver).toBe('ENOENT'); - expect((err as { filename?: unknown }).filename, driver).toBe(file); + const res = await probe({ name: 'draft', driver, config: config(file) }); + const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } }; + expect(res.status, driver).toBe(200); + expect(body.data.result.ok, driver).toBe(false); } + expect(create).not.toHaveBeenCalled(); expect(readdirSync(root)).toEqual([]); }); - // Control: without the probe intent the factory still opens as a writing - // boot does — Save, the boot restore and every hot pool keep creating the - // file, which is the behaviour a datasource that is being SAVED is owed. - it('control: without it, a sqlite target is still created on connect', async () => { + it('a path that can be neither opened nor created is refused too, and nothing is written', async () => { + const probe = await mountDoor(); + writeFileSync(join(root, 'blocker'), 'a regular file'); + const file = join(root, 'blocker/probe.db'); + + const res = await probe({ name: 'draft', driver: 'sqlite', config: { filename: file } }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { result: { ok: boolean; error?: string } } }; + expect(body.data.result.ok).toBe(false); + expect(body.data.result.error).toContain(file); + expect(readdirSync(root)).toEqual(['blocker']); + }); + + // Control: the default factory is untouched — Save, the boot restore and + // every hot pool build through it and still create the file on connect, + // which is what a datasource that is being SAVED is owed. + it('control: the default factory still creates a sqlite target on connect', async () => { const factory = createDefaultDatasourceDriverFactory(); const file = join(root, 'created.db'); const handle = await factory.create({ driver: 'sqlite', config: { filename: file } }); diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts index 63400f1b81b..f5c37676f31 100644 --- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts @@ -59,23 +59,6 @@ export interface DatasourceConnectionSpec { * own schema comment warns about. */ ssl?: Record; - /** - * The caller only PROBES this connection: a file-backed database (`sqlite`, - * `sqlite-wasm`, a `turso` `file:` url) whose file does not exist is refused, - * never created (#27512). - * - * Set by the draft probe (`POST /datasources/test`), which persists nothing - * and so must leave nothing on disk either. Opening the target as a writing - * boot does created the file, and every missing directory above it, for a - * datasource nobody saved. Absent ⇒ the target is opened as it always was: - * Save, the boot restore and every hot pool keep creating the file on - * connect. Server databases are untouched, since this process cannot bring - * one into existence anyway. - * - * The refusal is thrown from `create()`, before any driver is constructed, - * and carries `code: 'ENOENT'` and the `filename` it judged. - */ - fileMustExist?: boolean; } /** diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index fef3565b719..9c6083e902f 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -4,6 +4,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { hashSpec, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { registerMetadataTypeActions } from '@objectstack/spec/kernel'; +import { resolveDriverId } from '@objectstack/spec/data'; import type { IDatasourceDriverFactory, TestConnectionResult, @@ -21,6 +22,7 @@ import { import { datasourceConnectivityChanged } from './datasource-connectivity-change.js'; import type { DatasourceConnectPolicy } from './contracts/connect-policy.js'; import type { Logger } from './logger.js'; +import { resolveTursoUrl } from './turso-driver-config.js'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; /** @@ -872,6 +874,11 @@ export class DatasourceAdminServicePlugin implements Plugin { if (!factory.supports(input.driver)) { return { ok: false, error: `No driver factory supports driver '${input.driver}'.` }; } + // [#27512] A probe persists nothing, so it may create nothing either. + // Judged before the factory builds anything: every file-backed driver + // opens its database the way a writing boot does and creates the file. + const fileRefusal = await absentDatabaseFileRefusal(input); + if (fileRefusal) return { ok: false, error: fileRefusal }; let driver: any; try { @@ -880,9 +887,6 @@ export class DatasourceAdminServicePlugin implements Plugin { config: input.config, secret: input.secret, external: input.external, - // [#27512] A probe persists nothing, so it may create nothing either: - // a file-backed draft whose file is missing is reported, not created. - fileMustExist: true, }); } catch (err) { return { ok: false, error: `Failed to build driver: ${errMsg(err)}` }; @@ -961,6 +965,62 @@ function servedOrigin(ctx: PluginContext, name: string): 'code' | 'runtime' { return codeDatasourceNamesOf(ctx)?.has(name) ? 'code' : 'runtime'; } +/** + * The database file a draft opens when its driver is file-backed, or + * `undefined` when it opens none — a server database, a remote libSQL url, an + * id no file-backed driver answers to. `:memory:` is returned as is; the + * judgement below leaves every `:`-prefixed name alone. + * + * Read as the default driver factory reads the same config: `sqlite` and + * `sqlite-wasm` open `config.filename` (`SqliteConfigSchema`, absent ⇒ + * `:memory:`), and driver-turso's local engine opens the path of a `file:` + * url, its scheme matched in any letter case (`TursoDriver.toKnexConfig`). + */ +function probedDatabaseFile(input: ProbeInput): string | undefined { + const config = (input.config ?? {}) as Record; + switch (resolveDriverId(input.driver)) { + case 'sqlite': + case 'sqlite-wasm': + return typeof config.filename === 'string' ? config.filename : ':memory:'; + case 'turso': { + const url = resolveTursoUrl({ driver: input.driver, config }); + return url.slice(0, 'file:'.length).toLowerCase() === 'file:' ? url.slice('file:'.length) : undefined; + } + default: + return undefined; + } +} + +/** + * [#27512] Why the draft probe refuses to open a file-backed draft, or + * `undefined` when it may: the file the draft names does not exist, or can be + * neither opened nor created. Nothing is written to find out. + * + * The judgement is driver-sql's one write-free existence check, + * `resolveSqliteAbsentFileTarget`: under `'empty-in-memory'` it answers + * `openedEmptyInMemory` for an absent file and throws its + * `SqliteDatabaseFileError`, naming the file, for one that can be neither + * opened nor created — so no second `existsSync` is open-coded here. + * + * It lives in the probe, not in the driver factory, so it holds whichever + * factory the host registered, and a Save, the boot restore and every hot + * pool still open the file as before and create it. + */ +async function absentDatabaseFileRefusal(input: ProbeInput): Promise { + const filename = probedDatabaseFile(input); + if (filename === undefined) return undefined; + const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql'); + try { + if (!resolveSqliteAbsentFileTarget(filename, 'empty-in-memory').openedEmptyInMemory) return undefined; + } catch (err) { + return errMsg(err); + } + return ( + `SQLite database file '${filename}' does not exist. Testing a connection never creates it: ` + + 'point the draft at an existing database file, or save the datasource to have it created.' + ); +} + function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 0122caf1f09..8c164aa6178 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -45,7 +45,6 @@ * is never persisted or logged here. */ -import { statSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parsePostgresConnectionString } from 'pg-connection-string'; import { resolveDriverId, urlUserinfoUsername, type BuiltinDriverId } from '@objectstack/spec/data'; @@ -488,82 +487,6 @@ function postgresDsnFields(url: string, datasource?: string): Record { - const filename = databaseFileOf(kind, spec); - if (filename === undefined) return; - const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql'); - if (!resolveSqliteAbsentFileTarget(filename, 'empty-in-memory').openedEmptyInMemory) return; - // Absent: the operating system's own answer for the file becomes the - // refusal's `code` and `cause`. A file created in between answers no error, - // and is opened like any existing one. - try { - statSync(filename); - } catch (cause) { - throw new DatabaseFileAbsentError(filename, cause); - } -} - /** Build the Knex `connection` for a SQL driver from a spec's config + secret. */ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown { const cfg = (spec.config ?? {}) as Record; @@ -1203,12 +1126,6 @@ export function createDefaultDatasourceDriverFactory( // through, so it is where "declared = honoured" is actually guaranteed. assertDatasourcePoolSupported({ driver: spec.driver, pool: spec.pool, name: spec.name }); - // [#27512] A caller that only probes opens nothing it would have to - // create. Judged here, before any arm constructs a driver: every - // file-backed arm below opens as a writing boot does, and creates the - // file — `sqlite`'s native and wasm rungs, `sqlite-wasm`, a `file:` turso. - if (spec.fileMustExist) await assertDatabaseFileExists(kind, spec); - // ADR-0015's ownership mode. The three-source fallback moved to // `resolveDatasourceSchemaMode` in #7314 — unchanged in behaviour, but it // now has a second caller (the shared libSQL config builder, which