Skip to content

Commit c9aaee9

Browse files
fix(spec)!: a datasource named drivers is refused at every door that parses a datasource (#29423)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VET38SionkCZ9btiotp9jr)_ Fixes #29308 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none in `skills/**` (`skills/objectstack-data/rules/datasources.md` teaches `default` as host-reserved, and no file under `skills/**` declares or shows a datasource named `drivers`; every taught use stays true). Docs: `content/docs/data-modeling/drivers.mdx` said `drivers` was reserved at the admin API only; it now names the schema refusal and every door. ## 维护者速读 - 改了什么:数据源名 `drivers` 现在在所有解析数据源的入口被拒收(代码声明、`os validate`、元数据保存门 422),不再只在管理接口的新建门拒收;保留名单搬进 spec,成为唯一一份,管理接口的路由和新建门都读它。 - 为什么改:`GET /api/v1/datasources/drivers` 是驱动目录,叫 `drivers` 的数据源永远打不开编辑页;新建门拦住了,但代码和元数据保存门还能造出这个打不开的数据源。 - 风险与回滚:收窄,叫 `drivers` 的数据源再保存会被拒;仓内作者写的此名数据源为 0。已存的行照常加载,`_diagnostics` 标 `valid: false`,管理接口里仍可列出、删除、只改标签。回滚即还原本 PR。 - 先看:`packages/spec/src/data/datasource.zod.ts`(新常量与 `name` 上的第二条 pattern)、`packages/services/service-datasource/src/datasource-route-reserved-names.ts`(改读 spec 常量)、迁移条目 `18.datasource-name-route-shadowed-refused.ts`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/datasource-route-shadowed-name.test.ts`,预期 7 项全绿,其中一项断言报错落在 `name`、以 `Datasource name 'drivers' is reserved` 开头。 - 需要你做的:新公开导出名 `ROUTE_SHADOWED_DATASOURCE_NAMES` 需要你点头(席位会问);其余无,席位落地(落地前走一次契约复核)。 ## What changes - **One list, in the spec.** New export `ROUTE_SHADOWED_DATASOURCE_NAMES` in `packages/spec/src/data/datasource.zod.ts` (`@objectstack/spec/data`), a frozen `['drivers'] as const`: the names the datasource admin API can never serve by name, because a literal route under `/api/v1/datasources/` shares a method with the by-name read and is mounted ahead of it (`GET /api/v1/datasources/drivers` is the driver catalog). - **`DatasourceSchema.name` refuses each entry.** A second `.regex()` on `name` (a negative lookahead built from the list), with the message `Datasource name 'drivers' is reserved: the datasource admin API serves its own route at /api/v1/datasources/drivers, ahead of the by-name read /api/v1/datasources/:name, so a datasource of that name could never be read by name. Pick another name.` One `invalid_format` issue at `name`. A pattern, not a refinement: zod emits both patterns into the published JSON Schema (`allOf`), so no `dropped-refinements.baseline.json` row is added (the build reads 988 sites before and after) and JSON-Schema consumers refuse the name too (pinned). - **The admin routes read the spec list** (the one declared cross-lane file, `packages/services/service-datasource/src/datasource-route-reserved-names.ts`). `ROUTE_SHADOWED_DATASOURCE_NAMES` there is now the spec's frozen array itself (widened to `readonly string[]` for the create door's `includes`), and `DRIVER_CATALOG_SEGMENT`, the segment the registrar mounts the catalog from, is typed `(typeof ROUTE_SHADOWED_DATASOURCE_NAMES)[number]` against the spec list, so a catalog segment the spec does not reserve does not compile. The registrar (`admin-routes.ts`) and the create door (`datasource-admin-service.ts`) are unchanged: they already imported from this file. - **Create door: no double refusal.** `createDatasource` runs `assertValidName` (which refuses `drivers` with its own 400 `DATASOURCE_ADMIN_ERROR` sentence) before any schema parse, so a `POST /api/v1/datasources` for `drivers` still answers that one refusal; the existing door pins hold unchanged (below). - **Semantic migration entry** `datasource-name-route-shadowed-refused` (step 18), modelled on `stack-datasource-host-reserved-name-refused`: refused with a prescription, no conversion (the new name, and every object that routes to it, are the author's call). - **Docs.** `content/docs/data-modeling/drivers.mdx`: the sentence PR #29262 added ("reserved at the datasource admin API") now names the schema refusal and every door. Regenerated: `content/docs/references/data/datasource.mdx`, `packages/spec/api-surface/data.json`, `packages/spec/export-origins/data.json`. - **Not changed.** `default` (the host's own `default` row still parses through `DatasourceSchema`; a stack still may not declare it, `stack.zod.ts`), `test` (the draft-probe literal shadows no by-name read), and names that only contain the word (`driver`, `drivers_archive`, `my_drivers`, `_drivers`), all pinned as controls. ## Tests - New `packages/spec/src/data/datasource-route-shadowed-name.test.ts` (7 cases): the list is frozen and every entry matches the identifier pattern (so the refusal pattern needs no escaping); `DatasourceSchema` refuses each entry with one `invalid_format` issue at `name` whose message names the name and the route and carries no tracker number; `defineStack` refuses it with `STACK_SCHEMA_INVALID` / 422 at `datasources.0.name`; the published JSON Schema's `name` patterns reject each entry and accept `reporting`, `drivers_archive`, `driver`; controls parse. - Forced test edits: **none.** `packages/services/service-datasource/src/__tests__/datasource-route-reserved-names.test.ts` (the drift pin and the door cases) is unchanged and green. ## Acceptance notes - **Save door, measured before and after** (one-shot probe, a scratch `saveMetaItem` test over the stub-engine harness of `protocol.code-defined-datasource-door.test.ts`, both kernel shapes; not committed). Before (`eef116e4`): `PUT datasource/drivers` saved (`success: true`, row stored), and a stored `drivers` row read `_diagnostics.valid: true`. After (`4df813f3`): `INVALID_METADATA` / 422 with `datasource/drivers failed spec validation: name: Datasource name 'drivers' is reserved: …`, nothing stored; the control `drivers_ok` saves; a stored `drivers` row still loads with `_diagnostics: { valid: false, errors: [{ path: 'name', code: 'invalid_format', … }] }`. - **Admin door on a stored `drivers` row** (written before PR #29262): list, `label`/`active`-only PATCH and DELETE still work (the existing pin). A PATCH that touches `config`, `driver`, `pool` or `external`, or carries a secret, is judged against the whole `DatasourceSchema` (`assertValidRecord`) and is now refused with the schema's sentence: the row has to be renamed, which only a new datasource plus re-pointing the objects can do. Same posture as every other spec refusal on a stored row. - **Blast radius: 0.** `git grep` for a datasource named `drivers` across `examples/`, `packages/**` (templates, fixtures), `ui/`, `apps/`, `content/`: one hit, the service-datasource stored-row fixture above. - **New public name for the maintainer**: `ROUTE_SHADOWED_DATASOURCE_NAMES` (the name the service-datasource module already used). Open question in the report. - `packages/lint` does not validate datasource names (`git grep DatasourceSchema packages/lint/src`: 0 hits), so it owes no reading. ## Verification All readings at head `4df813f3` (the last commit), tree `objectstack-ai/objectstack`, each through `os-verify-lock` (VERDICT `command-exit 0` on every line). <details><summary>Suites (spec, the consumers, full dogfood)</summary> - Closure build: `pnpm --filter '@objectstack/dogfood^...' --filter '@objectstack/service-datasource...' --filter '@objectstack/metadata-protocol...' build`, exit 0 (386 s). - `pnpm --filter @objectstack/spec test`: Test Files 792 passed (792), Tests 23398 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/service-datasource test`: Test Files 70 passed (70), Tests 1160 passed (1160), the unchanged `datasource-route-reserved-names.test.ts` among them. Typecheck exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 303 passed, 4 skipped (307), Tests 22136 passed, 26 skipped. - Full dogfood (narrowing arm), `pnpm --filter @objectstack/dogfood test`, no shard, no path: Test Files 393 passed, 3 skipped (396), Tests 3243 passed, 28 skipped (3271), exit 0 (29 m). - `spec build`: `dropped-refinements` reads 988 sites, as on main (the refusal is a pattern, not a refinement). </details> <details><summary>Derived gates (107) and the changeset axes</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 107 commands for this change set; all 107 ran and exited 0. `check:skill-examples` and `check:dual-build-cjs-loads` first answered exit 3 (prerequisite: built dists) and were re-run after `turbo run build --filter='./packages/**'` (exit 0): 281 prose examples type-check; 115 require entry points across 70 packages load. `--ran` reconciliation: 107 derived, 107 run, 0 NOT-MEASURED, 0 UNRUN. - Offline PR-body check: `check-changeset-no-major.mjs --base eef116e --event` (this body's two declaration lines): exit 0, LEVEL AXIS accounted (`@objectstack/spec: minor`, `@objectstack/service-datasource: patch`). - `check:adr-0087-registration`: exit 0, `registered datasource-name-route-shadowed-refused (new here)`. - Local-only `check:dev-prereqs`: exit 1, "the workspace is not built" for 35 `ui/` packages this card never builds (local container state; not a derived family). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e9f72d commit c9aaee9

10 files changed

Lines changed: 282 additions & 16 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
fix(spec)!: a datasource named `drivers` is refused at every door that parses a datasource, not only at the admin create door
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: narrowed
10+
Skills-docs: none in `skills/**` — `skills/objectstack-data/rules/datasources.md` teaches `default` as host-reserved and no file under `skills/**` declares or shows a datasource named `drivers`; every taught use stays true. Docs: `content/docs/data-modeling/drivers.mdx` said `drivers` was reserved at the admin API only; it now names the schema refusal and every door.
11+
12+
**BREAKING** for a datasource named `drivers`.
13+
14+
- **Before.** The datasource admin API mounts the driver catalog at `GET /api/v1/datasources/drivers`, ahead of the by-name read `GET /api/v1/datasources/:name`, so a datasource named `drivers` can never be read by name (its Setup edit form never loads). The admin create door (`POST /api/v1/datasources`) refused the name, but `DatasourceSchema.name` accepted it, so the same unreachable datasource was still declared in code (`defineDatasource`, a stack's `datasources`) and saved through the metadata save door (`PUT /api/v1/meta/datasource/drivers` answered 200).
15+
- **Now.** `DatasourceSchema.name` refuses every name in the new export `ROUTE_SHADOWED_DATASOURCE_NAMES` (`@objectstack/spec/data`, today `['drivers']`) with one `invalid_format` issue at `name`: `Datasource name 'drivers' is reserved: the datasource admin API serves its own route at /api/v1/datasources/drivers, ahead of the by-name read /api/v1/datasources/:name, so a datasource of that name could never be read by name. Pick another name.` So `defineStack`, `os validate`, `os build` and the metadata save door (422 `INVALID_METADATA`, nothing stored) all refuse it. The published JSON Schema carries the refusal as a second `pattern` on `name`.
16+
- **One list.** `@objectstack/service-datasource` now reads the spec's list: the admin registrar mounts the driver catalog from a segment typed as a member of it, and the create door refuses each name in it, as before.
17+
- **Not changed.** `default` (the host's primary datasource row still parses; a stack still may not declare it), `test`, and names that only contain the word (`driver`, `drivers_archive`, `my_drivers`).
18+
- **Stored rows.** A stored datasource row named `drivers` still loads, with `_diagnostics` reading `valid: false` and carrying the refusal, and its next metadata save is refused. Through the admin door it stays listed and removable, and a `label`/`active`-only edit still saves. Nothing rewrites it: the new name, and every object that routes to it, are the author's call. The upgrade guide entry is `datasource-name-route-shadowed-refused`.
19+
- **The remedy.** Rename the datasource (for example to `driver_registry`) and point every object `datasource` / `datasourceMapping` rule that names it at the new name.
20+
21+
<!-- adr-0087: registered datasource-name-route-shadowed-refused -->

‎content/docs/data-modeling/drivers.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,12 @@ through the same datasource connection path — one failure verdict, one escape
278278
hatch, and a real `status` for the primary DB in **Setup → Datasources**. The
279279
name `default` is host-reserved: an app bundle declaring a datasource with that
280280
name is rejected at load.
281-
The name `drivers` is reserved at the datasource admin API: creating a runtime
282-
datasource under it is refused, because `GET /api/v1/datasources/drivers` is the
283-
driver catalog, so a datasource of that name could never be opened by name.
281+
The name `drivers` is reserved too, because `GET /api/v1/datasources/drivers`
282+
is the driver catalog, so a datasource of that name could never be opened by
283+
name. `DatasourceSchema.name` refuses every name in `ROUTE_SHADOWED_DATASOURCE_NAMES`
284+
(`@objectstack/spec/data`), so `defineDatasource`, `defineStack`, `os validate`
285+
and the metadata save door refuse it, and so does the datasource admin API's
286+
create door.
284287

285288
<Callout type="warn">
286289
`OS_ALLOW_DRIVER_CONNECT_FAILURE=1` boots anyway, in an explicitly degraded

‎content/docs/references/data/datasource.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = DatasourceSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **name** | `string` | ✅ | Unique datasource identifier |
31+
| **name** | `string` | ✅ | Unique datasource identifier (snake_case). Reserved: 'drivers', a path the datasource admin API serves itself, so a datasource of that name could never be read by name. |
3232
| **label** | `string` | optional | Display label |
3333
| **driver** | `string` | ✅ | Underlying driver type |
3434
| **config** | `Record<string, any>` | ✅ | Driver specific configuration |

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ import type {
5454
} from './contracts/index.js';
5555
import type { Logger } from './logger.js';
5656

57-
/** Datasource name rule (mirrors `DatasourceSchema.name`). */
57+
/**
58+
* Datasource name rule: the identifier pattern, the first of the two patterns on
59+
* `DatasourceSchema.name`. The second, the route-shadowed names, is refused by
60+
* name in `assertValidName` from the spec's `ROUTE_SHADOWED_DATASOURCE_NAMES`.
61+
*/
5862
const NAME_RE = /^[a-z_][a-z0-9_]*$/;
5963

6064
/**

‎packages/services/service-datasource/src/datasource-route-reserved-names.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,32 @@
1818
* patched, removed and probed (`POST /datasources/test/test`) through its own
1919
* doors.
2020
*
21-
* ONE list for both halves. The registrar mounts each shadowing literal from
22-
* the constant below, and `DatasourceAdminService` refuses each name in
23-
* {@link ROUTE_SHADOWED_DATASOURCE_NAMES}. The drift pin
21+
* ONE list for every half, and it is the spec's
22+
* (`ROUTE_SHADOWED_DATASOURCE_NAMES` in `@objectstack/spec/data`), so the
23+
* metadata surface refuses the same names: `DatasourceSchema.name` rejects
24+
* each one at `defineStack`, `os validate`, `os build` and the metadata save
25+
* door. Here, the registrar mounts each shadowing literal from a segment typed
26+
* as a member of that list, and `DatasourceAdminService` refuses each name in
27+
* it at create. The drift pin
2428
* (`__tests__/datasource-route-reserved-names.test.ts`) enumerates what the
2529
* registrar actually mounts and fails both ways: a literal sibling of a
26-
* same-method `/:name` route that is not listed here, and a listed name that
27-
* no longer shadows anything.
30+
* same-method `/:name` route that is not listed, and a listed name that no
31+
* longer shadows anything.
2832
*/
2933

30-
/** The driver-catalog literal: `GET /datasources/drivers`, mounted beside `GET /datasources/:name`. */
31-
export const DRIVER_CATALOG_SEGMENT = 'drivers';
34+
import { ROUTE_SHADOWED_DATASOURCE_NAMES as SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES } from '@objectstack/spec/data';
35+
36+
/**
37+
* The driver-catalog literal: `GET /datasources/drivers`, mounted beside
38+
* `GET /datasources/:name`. Typed as a member of the spec's reserved list, so
39+
* a catalog segment the spec does not reserve does not compile.
40+
*/
41+
export const DRIVER_CATALOG_SEGMENT: (typeof SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES)[number] = 'drivers';
3242

3343
/**
3444
* Every literal segment the admin registrar mounts beside a same-method
3545
* `/datasources/:name` route — a datasource of one of these names could never
36-
* be reached through that by-name door.
46+
* be reached through that by-name door. The spec's list itself (the same
47+
* frozen array), widened to `string` for the door's membership test.
3748
*/
38-
export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = Object.freeze([DRIVER_CATALOG_SEGMENT]);
49+
export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES;

‎packages/spec/api-surface/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@
627627
"RETIRED_FILTER_OPERATORS (const)",
628628
"RETIRED_SUB_DAY_INTERVALS (const)",
629629
"RLS_DENY_FILTER (const)",
630+
"ROUTE_SHADOWED_DATASOURCE_NAMES (const)",
630631
"RPC_QUERY_ALIAS_SLOTS (const)",
631632
"RUNTIME_OWNED_FIELD_TYPES (const)",
632633
"RangeOperatorSchema (const)",

‎packages/spec/export-origins/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,7 @@
616616
"RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)",
617617
"RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)",
618618
"RLS_DENY_FILTER": "src/data/readable-ids.ts#RLS_DENY_FILTER (const)",
619+
"ROUTE_SHADOWED_DATASOURCE_NAMES": "src/data/datasource.zod.ts#ROUTE_SHADOWED_DATASOURCE_NAMES (const)",
619620
"RPC_QUERY_ALIAS_SLOTS": "src/data/data-engine.zod.ts#RPC_QUERY_ALIAS_SLOTS (const)",
620621
"RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)",
621622
"RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `DatasourceSchema.name` refuses the route-shadowed names (#29308).
5+
*
6+
* ## What was wrong
7+
*
8+
* The datasource admin API mounts the driver catalog at
9+
* `GET /api/v1/datasources/drivers`, ahead of `GET /api/v1/datasources/:name`,
10+
* so a datasource named `drivers` can never be read by name. The admin create
11+
* door refused it, but `DatasourceSchema.name` accepted it, so the same
12+
* unreachable name was minted from code (`defineStack`) or through the
13+
* metadata save door (`PUT /api/v1/meta/datasource/drivers`).
14+
*
15+
* ## What is pinned
16+
*
17+
* - the reserved set is ONE exported constant, frozen, whose entries are
18+
* identifiers the `name` pattern accepts (so the refusal pattern built from
19+
* them needs no escaping);
20+
* - `DatasourceSchema` refuses every entry: one issue at `name`, an
21+
* `invalid_format` whose message names the name and the shadowing route,
22+
* and carries no tracker number;
23+
* - `defineStack` (the code door) refuses it with the ADR-0112 envelope at
24+
* `datasources.0.name`;
25+
* - the published JSON Schema carries the refusal (a second `pattern`), so a
26+
* JSON-Schema consumer refuses the name too;
27+
* - CONTROLS: near names (`driver`, `drivers_archive`, `my_drivers`), the
28+
* host's `default` row and `test` (a literal that shadows no by-name read)
29+
* still parse.
30+
*
31+
* The admin door's half (the registrar mounts the literal from this list, and
32+
* the create door refuses it) is pinned in
33+
* `packages/services/service-datasource/src/__tests__/datasource-route-reserved-names.test.ts`.
34+
*/
35+
import { describe, it, expect } from 'vitest';
36+
import { z } from 'zod';
37+
import { DatasourceSchema, ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource.zod';
38+
import { defineStack } from '../stack.zod';
39+
40+
const sqlite = (name: string) => ({ name, driver: 'sqlite', config: { filename: `${name}.db` } });
41+
const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const };
42+
43+
type Envelope = Error & {
44+
code?: string;
45+
status?: number;
46+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; message?: string }>;
47+
};
48+
49+
function refusal(fn: () => unknown): Envelope | null {
50+
try {
51+
fn();
52+
return null;
53+
} catch (e) {
54+
return e as Envelope;
55+
}
56+
}
57+
58+
describe('ROUTE_SHADOWED_DATASOURCE_NAMES', () => {
59+
it('is the frozen list the admin API shadows today: `drivers`', () => {
60+
expect([...ROUTE_SHADOWED_DATASOURCE_NAMES]).toEqual(['drivers']);
61+
expect(Object.isFrozen(ROUTE_SHADOWED_DATASOURCE_NAMES)).toBe(true);
62+
});
63+
64+
it('every entry is an identifier the `name` pattern accepts, so the refusal pattern needs no escaping', () => {
65+
for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) {
66+
expect(name, name).toMatch(/^[a-z_][a-z0-9_]*$/);
67+
}
68+
});
69+
});
70+
71+
describe('DatasourceSchema.name refuses a route-shadowed name', () => {
72+
for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) {
73+
it(`refuses '${name}' with one issue at \`name\` naming the name and the route`, () => {
74+
const result = DatasourceSchema.safeParse(sqlite(name));
75+
expect(result.success).toBe(false);
76+
const issues = result.error!.issues;
77+
expect(issues).toHaveLength(1);
78+
expect(issues[0]!.code).toBe('invalid_format');
79+
expect(issues[0]!.path).toEqual(['name']);
80+
const message = issues[0]!.message;
81+
expect(message.startsWith(`Datasource name '${name}' is reserved: `), message).toBe(true);
82+
expect(message).toContain(`/api/v1/datasources/${name}`);
83+
expect(message).toContain('could never be read by name');
84+
expect(message).not.toMatch(/#\d{3,}/);
85+
});
86+
87+
it(`the refusal for '${name}' names only that name: built from the input, never from the whole list`, () => {
88+
const message = DatasourceSchema.safeParse(sqlite(name)).error!.issues[0]!.message;
89+
// Every quoted name and every literal route the sentence carries is this one.
90+
expect([...message.matchAll(/'([a-z0-9_]+)'/g)].map((m) => m[1])).toEqual([name]);
91+
expect([...message.matchAll(/\/api\/v1\/datasources\/([a-z0-9_]+)/g)].map((m) => m[1])).toEqual([name]);
92+
});
93+
}
94+
95+
it('the code door: defineStack refuses it with STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => {
96+
const refused = refusal(() => defineStack({ manifest, datasources: [sqlite('drivers')] } as never));
97+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
98+
expect(refused?.status).toBe(422);
99+
const hits = (refused?.issues ?? []).filter((i) => (i.message ?? '').startsWith("Datasource name 'drivers' is reserved"));
100+
expect(hits).toHaveLength(1);
101+
expect(hits[0]!.path).toEqual(['datasources', 0, 'name']);
102+
});
103+
104+
it('the published JSON Schema carries the refusal: some `name` pattern rejects each reserved name', () => {
105+
const json = z.toJSONSchema(DatasourceSchema, { io: 'input', unrepresentable: 'any' }) as {
106+
properties: Record<string, { pattern?: string; allOf?: Array<{ pattern?: string }> }>;
107+
};
108+
const name = json.properties.name!;
109+
const patterns = [name.pattern, ...(name.allOf ?? []).map((s) => s.pattern)]
110+
.filter((p): p is string => typeof p === 'string')
111+
.map((p) => new RegExp(p));
112+
expect(patterns.length).toBeGreaterThanOrEqual(2);
113+
for (const reserved of ROUTE_SHADOWED_DATASOURCE_NAMES) {
114+
expect(patterns.every((p) => p.test(reserved)), reserved).toBe(false);
115+
}
116+
for (const legal of ['reporting', 'drivers_archive', 'driver']) {
117+
expect(patterns.every((p) => p.test(legal)), legal).toBe(true);
118+
}
119+
});
120+
121+
it('CONTROL: near names, `test` and the host `default` row still parse', () => {
122+
for (const name of ['driver', 'drivers_archive', 'my_drivers', '_drivers', 'test', 'default', 'reporting']) {
123+
const result = DatasourceSchema.safeParse(sqlite(name));
124+
expect(result.success, `${name}: ${JSON.stringify(result.error?.issues)}`).toBe(true);
125+
}
126+
});
127+
128+
it('CONTROL: an off-pattern name still meets the identifier pattern, not this refusal', () => {
129+
const result = DatasourceSchema.safeParse(sqlite('Drivers'));
130+
expect(result.success).toBe(false);
131+
expect(result.error!.issues.map((i) => i.message).some((m) => m.includes('is reserved'))).toBe(false);
132+
});
133+
});

‎packages/spec/src/data/datasource.zod.ts‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,52 @@ function reportDriverConfigIssues(
589589
}
590590
}
591591

592+
/**
593+
* Datasource names the datasource admin API can never serve by name.
594+
*
595+
* The admin route family mounts its by-name doors at `/api/v1/datasources/:name`
596+
* and, beside them, literal single-segment routes under the same root. Where a
597+
* literal shares a method with a by-name route, it is mounted first and captures
598+
* the path: `GET /api/v1/datasources/drivers` answers the driver catalog, so a
599+
* datasource named `drivers` would be stored and listed while its detail read
600+
* (the load of its edit form) was never reached.
601+
*
602+
* ONE list for every half. `DatasourceSchema.name` refuses each entry, so
603+
* `defineDatasource`, `defineStack`, `os validate`, `os build` and the metadata
604+
* save door (`PUT /api/v1/meta/datasource/NAME`) all refuse it; the admin
605+
* registrar in `@objectstack/service-datasource` mounts each shadowing literal
606+
* from this list, and its create door refuses each name too. A drift pin there
607+
* holds the list equal to the literals the registrar actually mounts beside a
608+
* same-method by-name route, in both directions.
609+
*
610+
* Not here: `default`, the host's primary datasource. A stack may not declare
611+
* it (`defineStack` refuses it), but the host's own `default` row is a valid
612+
* `DatasourceSchema` record, so it is not a refused name.
613+
*/
614+
export const ROUTE_SHADOWED_DATASOURCE_NAMES = Object.freeze(['drivers'] as const);
615+
616+
const quotedRouteShadowedNames = ROUTE_SHADOWED_DATASOURCE_NAMES.map((name) => `'${name}'`).join(', ');
617+
618+
/**
619+
* The refusal `DatasourceSchema.name` answers for a route-shadowed name: built
620+
* from the offending input, so it names that one name and its route (never the
621+
* whole list), in the admin create door's sentence family.
622+
*/
623+
function routeShadowedNameRefusal(issue: { input?: unknown }): string {
624+
const name = String(issue.input);
625+
return `Datasource name '${name}' is reserved: the datasource admin API serves its own route at `
626+
+ `/api/v1/datasources/${name}, ahead of the by-name read /api/v1/datasources/:name, so a datasource `
627+
+ 'of that name could never be read by name. Pick another name.';
628+
}
629+
630+
/**
631+
* A second pattern on `name`, not a refinement: zod emits both patterns into
632+
* the published JSON Schema (`allOf`), so editors and other JSON-Schema
633+
* consumers refuse the name too. The entries are snake_case identifiers (the
634+
* `name` pattern itself, pinned), so none needs escaping.
635+
*/
636+
const NOT_ROUTE_SHADOWED_NAME = new RegExp(`^(?!(?:${ROUTE_SHADOWED_DATASOURCE_NAMES.join('|')})$)`);
637+
592638
/**
593639
* Datasource Schema
594640
* Represents a connection to an external data store.
@@ -648,8 +694,18 @@ export const DatasourceSchema = lazySchema(() => strictObject(
648694
+ 'left the datasource connecting on driver defaults rather than failing.',
649695
},
650696
{
651-
/** Machine Name */
652-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique datasource identifier'),
697+
/**
698+
* Machine Name. Refuses the route-shadowed names
699+
* ({@link ROUTE_SHADOWED_DATASOURCE_NAMES}): the admin API could never read
700+
* a datasource of that name by name.
701+
*/
702+
name: z.string()
703+
.regex(/^[a-z_][a-z0-9_]*$/)
704+
.regex(NOT_ROUTE_SHADOWED_NAME, { error: routeShadowedNameRefusal })
705+
.describe(
706+
`Unique datasource identifier (snake_case). Reserved: ${quotedRouteShadowedNames}, a path the `
707+
+ 'datasource admin API serves itself, so a datasource of that name could never be read by name.',
708+
),
653709

654710
/** Human Label */
655711
label: z.string().optional().describe('Display label'),

0 commit comments

Comments
 (0)