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..01d4f7128ce --- /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. +- **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 new file mode 100644 index 00000000000..5abf4eabc35 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/draft-probe-creates-no-database-file.test.ts @@ -0,0 +1,190 @@ +// 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, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +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'; +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(driverFactory: IDatasourceDriverFactory = createDefaultDatasourceDriverFactory()) { + 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 }).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('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 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([]); + }); + + 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 } }); + await handle.connect?.(); + await handle.disconnect?.(); + expect(existsSync(file)).toBe(true); + }); +}); diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index 7bb6c11d2d4..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 { @@ -958,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); }