Skip to content

Commit 4dde0ae

Browse files
fix(spec)!: SqliteConfigSchema.filename refuses '' and a ':'-prefixed name other than ':memory:' at the build and metadata doors (#30605)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgUkEKsyGSzebnh2wdV (card thread of claim 6663609014) Fixes #30400 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/**` teaches no SQLite `filename` value, and `content/docs/**` teaches only `:memory:` and file paths, which stay accepted. The prescription ships in the refusal text, the changeset's FROM → TO and the upgrade-guide entry. ## 维护者速读 - 改了什么:SQLite / SQLite-WASM 数据源的 `filename` 现在在 spec 里就拒收空串和「以 `:` 开头但不是 `:memory:`」的值(例如 `:memory:/srv/data/parts.db`),代码声明的数据源(`os build` / `defineStack`)和 `PUT /api/v1/meta/datasource/NAME` 都会报错并给出改法。 - 为什么改:这类值哪里都打不开真实文件:原生 sqlite 每条语句失败,sqlite-wasm 写入在重连后全部丢失;之前只有数据源管理界面拦,另外两个入口照样存。 - 风险与回滚:已存在这种配置的代码数据源会在构建时报错(本来就不能用);回滚即还原 `sqlite.zod.ts` 的 refinement 与服务端文件,无数据迁移。 - 先看:`packages/spec/src/data/driver/sqlite.zod.ts`(`sqliteFilenameRefusal`)、`packages/services/service-datasource/src/sqlite-database-filename.ts`(删掉的两段改由 spec 判)、`packages/spec/src/data/driver/sqlite-filename-names-a-database.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/driver/sqlite-filename-names-a-database.test.ts`,预期 44 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/spec`** — `SqliteConfigSchema.filename` and `SqliteWasmConfigSchema.filename` share one builder, `sqliteFilename()`, which adds a refinement after the existing placeholder refusal: `''` and any value that starts with `:` without being exactly `:memory:` get one `custom` issue at `filename` that names the value, says why (a leading `:` is not read as a file, so the datasource would store nothing) and prescribes the fix (`:memory:` for an ephemeral database, or a file path). The default stays `:memory:`; `.describe()` / `.meta()` are unchanged, so no generated artifact moved. The filesystem judgement (a path that can be neither opened nor created) stays in the runtime: the schema cannot touch the file system. - **Both doors that judge a datasource against the spec alone now refuse it**: the build door (`ObjectStackDefinitionSchema`, which `os build` parses in `packages/cli/src/commands/compile.ts` and `defineStack` raises as `STACK_SCHEMA_INVALID`), and the metadata door (`PUT /api/v1/meta/datasource/NAME`, which judges with the registered `datasource` type schema, `DatasourceSchema`, whose config refinement replays the driver contract and answers `422 INVALID_METADATA`). - **One definition** — `@objectstack/service-datasource`'s `sqlite-database-filename.ts` loses its own `''` and `:`-prefix arms. `assertValidConfig` already runs the driver-contract parse (`validateDriverConfig`) first and calls the filename judge only when that is clean, so the admin doors now get those two refusals from the spec, word for word as before (`config.filename:` + the same sentence). Its filesystem arm is kept. The function's docblock states the precondition and the ⛔ against restating the arms. - **ADR-0087** — semantic migration `sqlite-filename-names-no-database-refused` (step 18): no mechanical rewrite, because whether the author meant `:memory:` or the path typed after it is a judgement about the datasource. - **Changeset** `30400-sqlite-filename-names-a-database.md`: `@objectstack/spec` minor, `@objectstack/service-datasource` minor, BREAKING banner, FROM → TO, disposition `registered sqlite-filename-names-no-database-refused`. ## Verification Readings on `objectstack-ai/objectstack`. Final head `5c12bcd4` (origin/main merged in): spec `check:generated` 14/14, the new pins 44/44 and `service-datasource` 72/72 files (1195 tests) were re-run there. Steps naming `9e360bb3` were taken on the pre-merge head; the derived-gate re-sweep on the final head is in the os-dev-report. <details><summary>New pins — 44/44</summary> `pnpm --filter @objectstack/spec exec vitest run src/data/driver/sqlite-filename-names-a-database.test.ts` → `Tests 44 passed (44)`. Per schema (sqlite, sqlite-wasm): the registry answers this schema for the driver id; six refused values (`:memory:/srv/data/parts.db`, `:memory:parts.db`, `:mem`, `::memory:`, `:`, `''`), each one issue, `code: 'custom'`, `path: ['filename']`, message opening with the value and carrying the prescription, no tracker number; the why sentence; composition with the placeholder refusal (`:${DATA_DIR}/app.db` reports both); the alias table (`path:` is still `unrecognized_keys` pointing at `filename`, and the renamed value meets the refusal); controls `:memory:`, `data/app.db`, `./.objectstack/data/dev.db`, `/var/lib/objectstack/app.db`, `C:\data\app.db`, `file:app.db`, `memory.db` parse unchanged and the absent key still defaults to `:memory:`. Doors: `getMetadataTypeSchema('datasource')` is `DatasourceSchema` and refuses at `config.filename`; `ObjectStackDefinitionSchema` refuses at `datasources.0.config.filename`; `defineStack` raises `STACK_SCHEMA_INVALID` / 422 there; both doors still accept `:memory:` and a file path. The metadata door's HTTP envelope for a replayed driver-config refusal (`422 INVALID_METADATA` located under `config.`) is pinned live by the existing `packages/qa/dogfood/test/datasource-meta-door-url-credential-trimmed.dogfood.test.ts` (same `DatasourceSchema` → `validateDriverConfig` path, at `config.url`). </details> <details><summary>Ablation 1 — spec source (pre-merge head 9e360bb)</summary> `node scripts/ablation-replace.mjs --file packages/spec/src/data/driver/sqlite.zod.ts --anchor "if (refusal) ctx.addIssue({ code: 'custom', message: refusal });" --replacement "void refusal; void ctx; …"`: anchor 1 → 0, blob 33624c6 → 0a5f9556. Pin file: `Tests 24 failed | 20 passed (44)` — every refusal, why, composition, renamed-alias and door case red; every control green. Restored: blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Ablation 2 — spec dist, read by the service door test (one definition)</summary> Same anchor, replacement carrying `(globalThis as any).ABLATED_30400 = true;`; `OS_SKIP_DTS=1 pnpm --filter @objectstack/spec build`; `ablation-dist-preflight.mjs @objectstack/spec ABLATED_30400` → marker present in 2 built files. `service-datasource` `sqlite-filename-neither-memory-nor-path.test.ts` → exactly the six `:memory:/…` and empty-filename cases red (Test connection and Save, both drivers); the filesystem-arm cases and controls green — the service holds no second copy. Restore leg: blob == HEAD, full spec build, preflight `--absent` → marker absent from all 592 built files, tree clean; the file is 14/14 again. </details> <details><summary>Suites</summary> - `@objectstack/spec` test (pre-merge head): `Test Files 816 passed (816)`, `Tests 25687 passed | 1 todo`; typecheck exit 0; `check:test-typecheck` OK (52 files / 240 errors / 131 pinned signatures held). - `@objectstack/service-datasource` test: `Test Files 72 passed (72)`, `Tests 1191 passed (1191)`; typecheck exit 0. - Consumers that parse a SQLite config (pre-merge head): `@objectstack/driver-sql` 238 passed / 12 skipped files (3859 tests passed); `@objectstack/driver-sqlite-wasm` 40/40 (750); `@objectstack/runtime` 544/544 (8658 passed, 19 skipped). Fixture sweep (`git grep` for `filename` set to `''` or a `:`-prefixed value other than `:memory:` outside dist): no SQLite fixture writes one; the only hits are the #30331 door test (which asserts the refusal) and unrelated `filename` keys (email attachments, print, storage). - `@objectstack/spec check:generated` (after a spec build): 14/14 up to date (the first run's `check:test-typecheck` was a heap exit without diagnostics; re-run with an 8 GB heap, OK). - eslint (`--no-inline-config`) on the four changed source files: exit 0. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0, `registered sqlite-filename-names-no-database-refused (new here)`. </details> <details><summary>Derived gates — 84 derived, 84 run, 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 9e360bb: 84 families. 81 exit 0 on the first sweep; `check:api-surface` and `check:dual-source-exports` answered exit 3 (spec dist removed under them by a parallel consumer build) and `check:dual-build-cjs-loads` exit 3 (no full build yet) — prerequisites, not verdicts; after a full `turbo run build` all three exit 0. `--ran` → `84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED (a DERIVED zero)`. </details> Dogfood battery: run on the final head; its counts are in the os-dev-report on #30400. ## Acceptance notes - **The JSON Schema projection does not carry the refusal.** A `superRefine` does not project, so the driver catalog's `configSchema` (`GET /api/v1/datasources/drivers`) still types `filename` as a plain string. A `pattern` would let a JSON-Schema form refuse inline, but it cannot name the value and it changes what the Setup dialog validates (`ui/**`, `domain:ui#5`); left out on purpose. - **A producer that can emit a now-refused value**: `sqliteFilenameFromUrl` (`packages/runtime/src/standalone-stack.ts`) turns `OS_DATABASE_URL=file::memory:?cache=shared` into `:memory:?cache=shared`. That value already opened no file (every layer reads the leading `:` as fileless); it is now refused wherever it is judged against the spec. Not changed here. - `.changeset/30331-sqlite-filename-neither-memory-nor-path.md` (unreleased) says the build and meta doors judge `filename` "as before"; true when written, superseded by this changeset in the same release. Not edited (another card's changeset). - Branch is the harness-assigned `claude/project-thread-eau1kb`, not `claude/issue-30400-*`; the claim amendment names it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUkEKsyGSzebnh2wdV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc51811 commit 4dde0ae

5 files changed

Lines changed: 332 additions & 18 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-datasource': minor
4+
---
5+
6+
fix(spec)!: a SQLite datasource's `config.filename` must name a database: `''` and a name that starts with `:` without being exactly `:memory:` are refused by the stack parse and the metadata save door, not only by the datasource admin doors
7+
8+
Clause-②: no (narrowing) — `SqliteConfigSchema.filename` and `SqliteWasmConfigSchema.filename` refuse `''` and any `:`-prefixed value other than exactly `:memory:`, so `defineStack` / `os validate` / `os build` and `PUT /api/v1/meta/datasource/NAME` refuse a `sqlite` / `sqlite-wasm` datasource they accepted before. Nothing is widened.
9+
Metadata-protocol: narrowed
10+
Skills-docs: none — `skills/**` teaches no SQLite `filename` value, and `content/docs/**` teaches only `:memory:` and file paths, which stay accepted. The prescription ships in the refusal text, the FROM → TO below and the upgrade-guide entry.
11+
12+
<!-- adr-0087: registered sqlite-filename-names-no-database-refused -->
13+
14+
**BREAKING** accept-set narrowing on `SqliteConfigSchema.filename` and `SqliteWasmConfigSchema.filename` (`@objectstack/spec`), and so at every door that judges a datasource against the spec: `defineStack`, `os validate`, `os build` and `PUT /api/v1/meta/datasource/NAME`.
15+
16+
- **Before.** Both schemas typed `filename` as any placeholder-free string. `:memory:/srv/data/parts.db` (the New datasource dialog's prefilled `:memory:` with a path typed after it) and `''` parsed. Every layer reads a name that starts with `:` as fileless, so no file was ever opened: a `sqlite` datasource opened it as a relative path whose directory does not exist and every statement failed, and a `sqlite-wasm` one opened a throwaway in-memory database whose writes were gone at the next connect. The datasource admin doors (Test connection, create, update) already refused both; a datasource declared in a stack or saved through the metadata door was stored anyway, and the admin door's update would not take it back.
17+
- **Now.** The parse answers one issue at `filename` (`config.filename` on a datasource, `datasources.N.config.filename` in a stack) that names the value, says why and prescribes the fix: `'…' is neither ':memory:' nor a file path. A name that starts with ':' is not read as a file, so this datasource would store nothing: use exactly ':memory:' …`, or `'' is empty and names no database file. …`. `defineStack` raises it as `STACK_SCHEMA_INVALID` (422); the metadata door answers `422 INVALID_METADATA`.
18+
- **Unchanged.** Exactly `:memory:`, an absent `filename` (still defaulting to `:memory:`), and relative, absolute, Windows-drive and `file:` paths parse as before. Whether a path can be opened or created is still judged only at runtime, by the datasource admin doors; the schema cannot touch the file system.
19+
- **`@objectstack/service-datasource`.** The admin doors give the same answers as before, word for word. The two arms the string alone decides now come from the driver-contract parse they already run, so `sqlite-database-filename.ts` keeps only its filesystem arm: one definition, in the spec.
20+
- **`@objectstack/spec`.** The upgrade-guide entry `sqlite-filename-names-no-database-refused` (a semantic migration: no automatic rewrite, because whether the author meant an ephemeral database or the path typed after the prefix is a judgement about the datasource).
21+
22+
**FROM → TO.** `config: { filename: ':memory:/srv/data/parts.db' }` → `filename: '/srv/data/parts.db'` to keep the data in that file, or `filename: ':memory:'` for an ephemeral database; `filename: ''` → `':memory:'` or a file path.

‎packages/services/service-datasource/src/sqlite-database-filename.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const IN_MEMORY = ':memory:';
99
* [#30331] Why a SQLite datasource's `config.filename` names no database the
1010
* datasource can use, or `undefined` when it does. The value is either exactly
1111
* `:memory:` or a file path that exists or can be created; anything else is
12-
* refused with the value and the reason.
12+
* refused with the value and the reason. Precondition: `config` already
13+
* passed its driver contract (see "Which arms live here").
1314
*
1415
* Judged where the test, the create and the update doors all judge `config`
1516
* (`DatasourceAdminService.assertValidConfig`), so a draft that tests red is
@@ -43,24 +44,28 @@ const IN_MEMORY = ':memory:';
4344
*
4445
* `sqlite` and `sqlite-wasm` only: both open `config.filename`. `turso` names
4546
* its target as a url, which libSQL parses itself.
47+
*
48+
* ## Which arms live here
49+
*
50+
* The filesystem arm only. The two arms the string alone decides (`''`, and a
51+
* name that starts with `:` without being exactly `:memory:`) are refined on
52+
* `SqliteConfigSchema.filename` / `SqliteWasmConfigSchema.filename` (#30400),
53+
* so the build door and `PUT /api/v1/meta/datasource/:name` refuse them too.
54+
* `assertValidConfig` runs that driver-contract parse first and calls this only
55+
* when it is clean, so those two values arrive here never: ⛔ do not restate
56+
* them; the spec is the one copy.
4657
*/
4758
export async function sqliteDatabaseFilenameIssue(driver: string, config: unknown): Promise<string | undefined> {
4859
const id = resolveDriverId(driver);
4960
if (id !== 'sqlite' && id !== 'sqlite-wasm') return undefined;
5061
const filename = (config as { filename?: unknown } | null | undefined)?.filename;
5162
// Absent is the schema's own `:memory:` default; a non-string is the
5263
// driver contract's refusal, already given.
64+
// `''` and a `:`-prefixed name other than exactly `:memory:` are the driver
65+
// contract's own refusals (#30400, `SqliteConfigSchema.filename`), reported
66+
// by the parse that runs before this; `resolveSqliteAbsentFileTarget` reads
67+
// both as fileless and answers nothing for them.
5368
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-
}
6469
const { resolveSqliteAbsentFileTarget } = await import('@objectstack/driver-sql');
6570
try {
6671
resolveSqliteAbsentFileTarget(filename, 'empty-in-memory');
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #30400 — a SQLite `filename` must name a database: exactly `:memory:`, or a
5+
* file path. `''` and a `:`-prefixed name other than exactly `:memory:` are
6+
* refused by `SqliteConfigSchema` and `SqliteWasmConfigSchema`.
7+
*
8+
* ## What was wrong
9+
*
10+
* Both schemas typed `filename` as any placeholder-free string. The New
11+
* datasource dialog's prefilled `:memory:` with a path typed after it
12+
* (`:memory:/srv/data/parts.db`) passed, and every layer reads a leading `:`
13+
* as fileless: native sqlite opened it as a relative path whose directory does
14+
* not exist (every statement fails), and sqlite-wasm opened a throwaway
15+
* in-memory database (writes vanish on reconnect). The datasource admin doors
16+
* refuse it at runtime since #30331, but two doors validate against the spec
17+
* schema alone and kept storing it:
18+
*
19+
* - the build door: `os build` parses the stack with
20+
* `ObjectStackDefinitionSchema` (`packages/cli/src/commands/compile.ts`),
21+
* and `defineStack` raises the same parse as `STACK_SCHEMA_INVALID`;
22+
* - the metadata door: `PUT /api/v1/meta/datasource/:name` judges the item
23+
* with the registered `datasource` type schema
24+
* (`getMetadataTypeSchema('datasource')`, `DatasourceSchema`) and answers
25+
* its issues as `422 INVALID_METADATA`.
26+
*
27+
* ## What is pinned
28+
*
29+
* - refusals on both schemas: one `custom` issue at `filename`, whose message
30+
* names the value, says why, and prescribes the fix; no tracker number;
31+
* - both doors' schemas carry the refusal, located under the datasource;
32+
* - a misspelled alias (`path:`) is still refused as an unknown key pointing
33+
* at `filename`, and the renamed value meets this refusal;
34+
* - CONTROLS: `:memory:`, the default, relative and absolute paths, a
35+
* Windows drive path and a `file:` name still parse unchanged.
36+
*
37+
* The filesystem arm (a path that can be neither opened nor created) is not
38+
* judgeable from the string and stays in the runtime, pinned in
39+
* `packages/services/service-datasource/src/__tests__/sqlite-filename-neither-memory-nor-path.test.ts`.
40+
*/
41+
import { describe, expect, it } from 'vitest';
42+
43+
import { DatasourceSchema } from '../datasource.zod';
44+
import { defineStack, ObjectStackDefinitionSchema } from '../../stack.zod';
45+
import { getMetadataTypeSchema } from '../../kernel/metadata-type-schemas';
46+
import { getDriverConfigSchema } from './config-registry.zod';
47+
import { SqliteConfigSchema, SqliteWasmConfigSchema } from './sqlite.zod';
48+
49+
const SCHEMAS = [
50+
{ driver: 'sqlite', schema: SqliteConfigSchema },
51+
{ driver: 'sqlite-wasm', schema: SqliteWasmConfigSchema },
52+
] as const;
53+
54+
/** The refused values, and the sentence each refusal opens with. */
55+
const REFUSED = [
56+
{ value: ':memory:/srv/data/parts.db', opens: "':memory:/srv/data/parts.db' is neither ':memory:' nor a file path." },
57+
{ value: ':memory:parts.db', opens: "':memory:parts.db' is neither ':memory:' nor a file path." },
58+
{ value: ':mem', opens: "':mem' is neither ':memory:' nor a file path." },
59+
{ value: '::memory:', opens: "'::memory:' is neither ':memory:' nor a file path." },
60+
{ value: ':', opens: "':' is neither ':memory:' nor a file path." },
61+
{ value: '', opens: "'' is empty and names no database file." },
62+
] as const;
63+
64+
/** The values that name a database, and stay accepted byte for byte. */
65+
const ACCEPTED = [
66+
':memory:',
67+
'data/app.db',
68+
'./.objectstack/data/dev.db',
69+
'/var/lib/objectstack/app.db',
70+
'C:\\data\\app.db',
71+
'file:app.db',
72+
'memory.db',
73+
] as const;
74+
75+
type Envelope = Error & {
76+
code?: string;
77+
status?: number;
78+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; message?: string }>;
79+
};
80+
81+
function refusal(fn: () => unknown): Envelope | null {
82+
try {
83+
fn();
84+
return null;
85+
} catch (e) {
86+
return e as Envelope;
87+
}
88+
}
89+
90+
const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const };
91+
92+
describe('SQLite `filename` refuses a value that names no database', () => {
93+
for (const { driver, schema } of SCHEMAS) {
94+
describe(driver, () => {
95+
it('is the schema the driver registry answers for the driver id', () => {
96+
expect(getDriverConfigSchema(driver)).toBe(schema);
97+
});
98+
99+
for (const { value, opens } of REFUSED) {
100+
it(`refuses ${JSON.stringify(value)}: one custom issue at \`filename\` naming the value, why, and the fix`, () => {
101+
const result = schema.safeParse({ filename: value });
102+
expect(result.success).toBe(false);
103+
const issues = result.error!.issues;
104+
expect(issues).toHaveLength(1);
105+
expect(issues[0]!.code).toBe('custom');
106+
expect(issues[0]!.path).toEqual(['filename']);
107+
const message = issues[0]!.message;
108+
expect(message.startsWith(opens), message).toBe(true);
109+
// The prescription: `:memory:` for an ephemeral database, or a file path.
110+
expect(message).toContain("':memory:' for an ephemeral");
111+
expect(message).toContain('the path of a database file');
112+
expect(message).not.toMatch(/#\d{3,}/);
113+
});
114+
}
115+
116+
it('a `:`-prefixed refusal says why: a leading `:` is not read as a file', () => {
117+
const message = schema.safeParse({ filename: ':memory:/tmp/x.db' }).error!.issues[0]!.message;
118+
expect(message).toContain("A name that starts with ':' is not read as a file, so this datasource would store nothing");
119+
expect(message).toContain("use exactly ':memory:'");
120+
});
121+
122+
it('composes with the placeholder refusal: a `:${…}` value reports both', () => {
123+
const result = schema.safeParse({ filename: ':${DATA_DIR}/app.db' });
124+
expect(result.success).toBe(false);
125+
expect(result.error!.issues).toHaveLength(2);
126+
expect(result.error!.issues.every((i) => i.path.join('.') === 'filename')).toBe(true);
127+
expect(result.error!.issues.some((i) => i.message.includes('is neither'))).toBe(true);
128+
});
129+
130+
it('a misspelled alias is still refused as an unknown key pointing at `filename`; renamed, it meets the refusal', () => {
131+
const aliased = schema.safeParse({ path: ':memory:/tmp/x.db' });
132+
expect(aliased.success).toBe(false);
133+
expect(aliased.error!.issues).toHaveLength(1);
134+
expect(aliased.error!.issues[0]!.code).toBe('unrecognized_keys');
135+
expect(aliased.error!.issues[0]!.message).toContain('filename');
136+
137+
const renamed = schema.safeParse({ filename: ':memory:/tmp/x.db' });
138+
expect(renamed.success).toBe(false);
139+
expect(renamed.error!.issues[0]!.path).toEqual(['filename']);
140+
});
141+
142+
for (const value of ACCEPTED) {
143+
it(`CONTROL: ${JSON.stringify(value)} still parses unchanged`, () => {
144+
const result = schema.safeParse({ filename: value });
145+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
146+
expect(result.data!.filename).toBe(value);
147+
});
148+
}
149+
150+
it('CONTROL: an absent `filename` still defaults to `:memory:`', () => {
151+
const result = schema.safeParse({});
152+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
153+
expect(result.data!.filename).toBe(':memory:');
154+
});
155+
});
156+
}
157+
});
158+
159+
describe('the doors that judge a datasource against the spec alone refuse it', () => {
160+
for (const { driver } of SCHEMAS) {
161+
const row = (filename: string) => ({ name: 'parts', driver, config: { filename } });
162+
163+
it(`${driver} · metadata door: the registered \`datasource\` type schema refuses it at config.filename`, () => {
164+
const typeSchema = getMetadataTypeSchema('datasource');
165+
expect(typeSchema).toBe(DatasourceSchema);
166+
const result = typeSchema!.safeParse(row(':memory:/srv/data/parts.db'));
167+
expect(result.success).toBe(false);
168+
const hits = result.error!.issues.filter((i) => i.path.join('.') === 'config.filename');
169+
expect(hits).toHaveLength(1);
170+
expect(hits[0]!.code).toBe('custom');
171+
expect(hits[0]!.message).toContain("':memory:/srv/data/parts.db' is neither ':memory:' nor a file path");
172+
173+
const empty = typeSchema!.safeParse(row(''));
174+
expect(empty.success).toBe(false);
175+
expect(empty.error!.issues.map((i) => i.path.join('.'))).toEqual(['config.filename']);
176+
});
177+
178+
it(`${driver} · build door: the stack parse refuses it at datasources.0.config.filename`, () => {
179+
const result = ObjectStackDefinitionSchema.safeParse({ manifest, datasources: [row(':memory:/srv/data/parts.db')] });
180+
expect(result.success).toBe(false);
181+
const hits = result.error!.issues.filter((i) => i.path.join('.') === 'datasources.0.config.filename');
182+
expect(hits).toHaveLength(1);
183+
expect(hits[0]!.code).toBe('custom');
184+
expect(hits[0]!.message).toContain('is neither');
185+
});
186+
187+
it(`${driver} · build door: defineStack raises STACK_SCHEMA_INVALID / 422 at datasources.0.config.filename`, () => {
188+
const refused = refusal(() => defineStack({ manifest, datasources: [row('')] } as never));
189+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
190+
expect(refused?.status).toBe(422);
191+
const hits = (refused?.issues ?? []).filter((i) => (i.path ?? []).join('.') === 'datasources.0.config.filename');
192+
expect(hits).toHaveLength(1);
193+
expect(hits[0]!.message).toContain("'' is empty and names no database file");
194+
});
195+
196+
it(`${driver} · CONTROL: both doors still accept \`:memory:\` and a file path`, () => {
197+
for (const filename of [':memory:', 'data/parts.db']) {
198+
const meta = getMetadataTypeSchema('datasource')!.safeParse(row(filename));
199+
expect(meta.success, `${filename}: ${JSON.stringify(meta.error?.issues)}`).toBe(true);
200+
const stack = ObjectStackDefinitionSchema.safeParse({ manifest, datasources: [row(filename)] });
201+
expect(stack.success, `${filename}: ${JSON.stringify(stack.error?.issues)}`).toBe(true);
202+
}
203+
});
204+
}
205+
});

0 commit comments

Comments
 (0)