From 3f0dd1d116dec1b6a72d1d570abf267e7bfb64c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:30:43 +0000 Subject: [PATCH 1/3] fix(spec)!: DatasourceSchema.name refuses route-shadowed names (drivers) The reserved set moves to @objectstack/spec as ROUTE_SHADOWED_DATASOURCE_NAMES; service-datasource's admin registrar and create door read the spec list. Claude-Session: https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU Co-authored-by: Claude --- .../src/datasource-route-reserved-names.ts | 29 ++-- .../datasource-route-shadowed-name.test.ts | 126 ++++++++++++++++++ packages/spec/src/data/datasource.zod.ts | 58 +++++++- 3 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/data/datasource-route-shadowed-name.test.ts diff --git a/packages/services/service-datasource/src/datasource-route-reserved-names.ts b/packages/services/service-datasource/src/datasource-route-reserved-names.ts index f7cd8d664c2..15aa5a21077 100644 --- a/packages/services/service-datasource/src/datasource-route-reserved-names.ts +++ b/packages/services/service-datasource/src/datasource-route-reserved-names.ts @@ -18,21 +18,32 @@ * patched, removed and probed (`POST /datasources/test/test`) through its own * doors. * - * ONE list for both halves. The registrar mounts each shadowing literal from - * the constant below, and `DatasourceAdminService` refuses each name in - * {@link ROUTE_SHADOWED_DATASOURCE_NAMES}. The drift pin + * ONE list for every half, and it is the spec's + * (`ROUTE_SHADOWED_DATASOURCE_NAMES` in `@objectstack/spec/data`), so the + * metadata surface refuses the same names: `DatasourceSchema.name` rejects + * each one at `defineStack`, `os validate`, `os build` and the metadata save + * door. Here, the registrar mounts each shadowing literal from a segment typed + * as a member of that list, and `DatasourceAdminService` refuses each name in + * it at create. The drift pin * (`__tests__/datasource-route-reserved-names.test.ts`) enumerates what the * registrar actually mounts and fails both ways: a literal sibling of a - * same-method `/:name` route that is not listed here, and a listed name that - * no longer shadows anything. + * same-method `/:name` route that is not listed, and a listed name that no + * longer shadows anything. */ -/** The driver-catalog literal: `GET /datasources/drivers`, mounted beside `GET /datasources/:name`. */ -export const DRIVER_CATALOG_SEGMENT = 'drivers'; +import { ROUTE_SHADOWED_DATASOURCE_NAMES as SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES } from '@objectstack/spec/data'; + +/** + * The driver-catalog literal: `GET /datasources/drivers`, mounted beside + * `GET /datasources/:name`. Typed as a member of the spec's reserved list, so + * a catalog segment the spec does not reserve does not compile. + */ +export const DRIVER_CATALOG_SEGMENT: (typeof SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES)[number] = 'drivers'; /** * Every literal segment the admin registrar mounts beside a same-method * `/datasources/:name` route — a datasource of one of these names could never - * be reached through that by-name door. + * be reached through that by-name door. The spec's list itself (the same + * frozen array), widened to `string` for the door's membership test. */ -export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = Object.freeze([DRIVER_CATALOG_SEGMENT]); +export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES; diff --git a/packages/spec/src/data/datasource-route-shadowed-name.test.ts b/packages/spec/src/data/datasource-route-shadowed-name.test.ts new file mode 100644 index 00000000000..7f92f76ad9f --- /dev/null +++ b/packages/spec/src/data/datasource-route-shadowed-name.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `DatasourceSchema.name` refuses the route-shadowed names (#29308). + * + * ## What was wrong + * + * The datasource admin API mounts the driver catalog at + * `GET /api/v1/datasources/drivers`, ahead of `GET /api/v1/datasources/:name`, + * so a datasource named `drivers` can never be read by name. The admin create + * door refused it, but `DatasourceSchema.name` accepted it, so the same + * unreachable name was minted from code (`defineStack`) or through the + * metadata save door (`PUT /api/v1/meta/datasource/drivers`). + * + * ## What is pinned + * + * - the reserved set is ONE exported constant, frozen, whose entries are + * identifiers the `name` pattern accepts (so the refusal pattern built from + * them needs no escaping); + * - `DatasourceSchema` refuses every entry: one issue at `name`, an + * `invalid_format` whose message names the name and the shadowing route, + * and carries no tracker number; + * - `defineStack` (the code door) refuses it with the ADR-0112 envelope at + * `datasources.0.name`; + * - the published JSON Schema carries the refusal (a second `pattern`), so a + * JSON-Schema consumer refuses the name too; + * - CONTROLS: near names (`driver`, `drivers_archive`, `my_drivers`), the + * host's `default` row and `test` (a literal that shadows no by-name read) + * still parse. + * + * The admin door's half (the registrar mounts the literal from this list, and + * the create door refuses it) is pinned in + * `packages/services/service-datasource/src/__tests__/datasource-route-reserved-names.test.ts`. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { DatasourceSchema, ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource.zod'; +import { defineStack } from '../stack.zod'; + +const sqlite = (name: string) => ({ name, driver: 'sqlite', config: { filename: `${name}.db` } }); +const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const }; + +type Envelope = Error & { + code?: string; + status?: number; + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; message?: string }>; +}; + +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +describe('ROUTE_SHADOWED_DATASOURCE_NAMES', () => { + it('is the frozen list the admin API shadows today: `drivers`', () => { + expect([...ROUTE_SHADOWED_DATASOURCE_NAMES]).toEqual(['drivers']); + expect(Object.isFrozen(ROUTE_SHADOWED_DATASOURCE_NAMES)).toBe(true); + }); + + it('every entry is an identifier the `name` pattern accepts, so the refusal pattern needs no escaping', () => { + for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) { + expect(name, name).toMatch(/^[a-z_][a-z0-9_]*$/); + } + }); +}); + +describe('DatasourceSchema.name refuses a route-shadowed name', () => { + for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) { + it(`refuses '${name}' with one issue at \`name\` naming the name and the route`, () => { + const result = DatasourceSchema.safeParse(sqlite(name)); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_format'); + expect(issues[0]!.path).toEqual(['name']); + const message = issues[0]!.message; + expect(message.startsWith(`Datasource name '${name}' is reserved: `), message).toBe(true); + expect(message).toContain(`/api/v1/datasources/${name}`); + expect(message).toContain('could never be read by name'); + expect(message).not.toMatch(/#\d{3,}/); + }); + } + + it('the code door: defineStack refuses it with STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => { + const refused = refusal(() => defineStack({ manifest, datasources: [sqlite('drivers')] } as never)); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + const hits = (refused?.issues ?? []).filter((i) => (i.message ?? '').startsWith("Datasource name 'drivers' is reserved")); + expect(hits).toHaveLength(1); + expect(hits[0]!.path).toEqual(['datasources', 0, 'name']); + }); + + it('the published JSON Schema carries the refusal: some `name` pattern rejects each reserved name', () => { + const json = z.toJSONSchema(DatasourceSchema, { io: 'input', unrepresentable: 'any' }) as { + properties: Record }>; + }; + const name = json.properties.name!; + const patterns = [name.pattern, ...(name.allOf ?? []).map((s) => s.pattern)] + .filter((p): p is string => typeof p === 'string') + .map((p) => new RegExp(p)); + expect(patterns.length).toBeGreaterThanOrEqual(2); + for (const reserved of ROUTE_SHADOWED_DATASOURCE_NAMES) { + expect(patterns.every((p) => p.test(reserved)), reserved).toBe(false); + } + for (const legal of ['reporting', 'drivers_archive', 'driver']) { + expect(patterns.every((p) => p.test(legal)), legal).toBe(true); + } + }); + + it('CONTROL: near names, `test` and the host `default` row still parse', () => { + for (const name of ['driver', 'drivers_archive', 'my_drivers', '_drivers', 'test', 'default', 'reporting']) { + const result = DatasourceSchema.safeParse(sqlite(name)); + expect(result.success, `${name}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it('CONTROL: an off-pattern name still meets the identifier pattern, not this refusal', () => { + const result = DatasourceSchema.safeParse(sqlite('Drivers')); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).some((m) => m.includes('is reserved'))).toBe(false); + }); +}); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index d77acfcc6f3..d93042071d4 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -589,6 +589,50 @@ function reportDriverConfigIssues( } } +/** + * Datasource names the datasource admin API can never serve by name. + * + * The admin route family mounts its by-name doors at `/api/v1/datasources/:name` + * and, beside them, literal single-segment routes under the same root. Where a + * literal shares a method with a by-name route, it is mounted first and captures + * the path: `GET /api/v1/datasources/drivers` answers the driver catalog, so a + * datasource named `drivers` would be stored and listed while its detail read + * (the load of its edit form) was never reached. + * + * ONE list for every half. `DatasourceSchema.name` refuses each entry, so + * `defineDatasource`, `defineStack`, `os validate`, `os build` and the metadata + * save door (`PUT /api/v1/meta/datasource/NAME`) all refuse it; the admin + * registrar in `@objectstack/service-datasource` mounts each shadowing literal + * from this list, and its create door refuses each name too. A drift pin there + * holds the list equal to the literals the registrar actually mounts beside a + * same-method by-name route, in both directions. + * + * Not here: `default`, the host's primary datasource. A stack may not declare + * it (`defineStack` refuses it), but the host's own `default` row is a valid + * `DatasourceSchema` record, so it is not a refused name. + */ +export const ROUTE_SHADOWED_DATASOURCE_NAMES = Object.freeze(['drivers'] as const); + +const quotedRouteShadowedNames = ROUTE_SHADOWED_DATASOURCE_NAMES.map((name) => `'${name}'`).join(', '); + +/** + * The refusal `DatasourceSchema.name` answers for a route-shadowed name: it + * names the name and why, in the admin create door's sentence family. + */ +const ROUTE_SHADOWED_NAME_REFUSAL = + `Datasource name ${quotedRouteShadowedNames} is reserved: the datasource admin API serves its own route ` + + `at /api/v1/datasources/${ROUTE_SHADOWED_DATASOURCE_NAMES.join(' and /api/v1/datasources/')}, 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.'; + +/** + * A second pattern on `name`, not a refinement: zod emits both patterns into + * the published JSON Schema (`allOf`), so editors and other JSON-Schema + * consumers refuse the name too. The entries are snake_case identifiers (the + * `name` pattern itself, pinned), so none needs escaping. + */ +const NOT_ROUTE_SHADOWED_NAME = new RegExp(`^(?!(?:${ROUTE_SHADOWED_DATASOURCE_NAMES.join('|')})$)`); + /** * Datasource Schema * Represents a connection to an external data store. @@ -648,8 +692,18 @@ export const DatasourceSchema = lazySchema(() => strictObject( + 'left the datasource connecting on driver defaults rather than failing.', }, { - /** Machine Name */ - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique datasource identifier'), + /** + * Machine Name. Refuses the route-shadowed names + * ({@link ROUTE_SHADOWED_DATASOURCE_NAMES}): the admin API could never read + * a datasource of that name by name. + */ + name: z.string() + .regex(/^[a-z_][a-z0-9_]*$/) + .regex(NOT_ROUTE_SHADOWED_NAME, ROUTE_SHADOWED_NAME_REFUSAL) + .describe( + `Unique datasource identifier (snake_case). Reserved: ${quotedRouteShadowedNames}, a path the ` + + 'datasource admin API serves itself, so a datasource of that name could never be read by name.', + ), /** Human Label */ label: z.string().optional().describe('Display label'), From 4df813f3ea788b1ac5904d651a2271bff8dde85f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:37:46 +0000 Subject: [PATCH 2/3] fix(spec): register the drivers-name refusal; regenerate references; changeset ADR-0087 semantic entry datasource-name-route-shadowed-refused, regenerated api-surface / export-origins / datasource reference, and the drivers.mdx sentence now names the schema refusal. Claude-Session: https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU Co-authored-by: Claude --- .../29308-datasource-route-shadowed-name.md | 21 +++++++++++ content/docs/data-modeling/drivers.mdx | 9 +++-- content/docs/references/data/datasource.mdx | 2 +- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + ....datasource-name-route-shadowed-refused.ts | 36 +++++++++++++++++++ 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 .changeset/29308-datasource-route-shadowed-name.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.datasource-name-route-shadowed-refused.ts diff --git a/.changeset/29308-datasource-route-shadowed-name.md b/.changeset/29308-datasource-route-shadowed-name.md new file mode 100644 index 00000000000..f98a284a23a --- /dev/null +++ b/.changeset/29308-datasource-route-shadowed-name.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-datasource': patch +--- + +fix(spec)!: a datasource named `drivers` is refused at every door that parses a datasource, not only at the admin create door + +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. + +**BREAKING** for a datasource named `drivers`. + +- **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). +- **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`. +- **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. +- **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`). +- **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`. +- **The remedy.** Rename the datasource (for example to `driver_registry`) and point every object `datasource` / `datasourceMapping` rule that names it at the new name. + + diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index fc2b227908e..2089706f05c 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -278,9 +278,12 @@ through the same datasource connection path — one failure verdict, one escape hatch, and a real `status` for the primary DB in **Setup → Datasources**. The name `default` is host-reserved: an app bundle declaring a datasource with that name is rejected at load. -The name `drivers` is reserved at the datasource admin API: creating a runtime -datasource under it is refused, because `GET /api/v1/datasources/drivers` is the -driver catalog, so a datasource of that name could never be opened by name. +The name `drivers` is reserved too, because `GET /api/v1/datasources/drivers` +is the driver catalog, so a datasource of that name could never be opened by +name. `DatasourceSchema.name` refuses every name in `ROUTE_SHADOWED_DATASOURCE_NAMES` +(`@objectstack/spec/data`), so `defineDatasource`, `defineStack`, `os validate` +and the metadata save door refuse it, and so does the datasource admin API's +create door. `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` boots anyway, in an explicitly degraded diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index b33bdf87ee1..19107c79f53 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -28,7 +28,7 @@ const result = DatasourceSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique datasource identifier | +| **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. | | **label** | `string` | optional | Display label | | **driver** | `string` | ✅ | Underlying driver type | | **config** | `Record` | ✅ | Driver specific configuration | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 3d97852faa9..8cb67d4c452 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -627,6 +627,7 @@ "RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS (const)", "RLS_DENY_FILTER (const)", + "ROUTE_SHADOWED_DATASOURCE_NAMES (const)", "RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index d11e4a2590a..18ab093fb43 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -616,6 +616,7 @@ "RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)", "RLS_DENY_FILTER": "src/data/readable-ids.ts#RLS_DENY_FILTER (const)", + "ROUTE_SHADOWED_DATASOURCE_NAMES": "src/data/datasource.zod.ts#ROUTE_SHADOWED_DATASOURCE_NAMES (const)", "RPC_QUERY_ALIAS_SLOTS": "src/data/data-engine.zod.ts#RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-name-route-shadowed-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-name-route-shadowed-refused.ts new file mode 100644 index 00000000000..abfbce49b57 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-name-route-shadowed-refused.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29308. An accept-set narrowing of `DatasourceSchema.name` with no +// conversion: a datasource named after a literal the datasource admin API +// mounts beside its by-name read (`drivers`, the driver catalog) is refused. +// The new name, and every object `datasource` / `datasourceMapping` rule that +// routes to it, only the author can pick. Same class as +// stack-datasource-host-reserved-name-refused. +export const entry: SemanticMigration = { + id: 'datasource-name-route-shadowed-refused', + surface: 'a datasource whose `name` is one of `ROUTE_SHADOWED_DATASOURCE_NAMES` (today `drivers`), ' + + 'e.g. defineDatasource({ name: \'drivers\', driver: \'postgres\', config: { … } }), in ' + + '`defineDatasource`, a stack\'s `datasources`, `os validate`, `os build` and the metadata save door ' + + '(`PUT /api/v1/meta/datasource/drivers`)', + replacement: 'The same datasource under another snake_case name (e.g. `driver_registry`), with every ' + + 'object that names it (`datasource`, or a `datasourceMapping` rule) pointed at the new name. ⛔ NOT ' + + 'mechanically convertible: the new name is the author\'s call.', + reason: + '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` was stored and ' + + 'listed while its detail read (the load of its Setup edit form) always answered the catalog. The ' + + 'admin create door already refused the name; `DatasourceSchema.name` accepted it, so the same ' + + 'unreachable datasource was still minted from code and through the metadata save door. The schema ' + + 'now refuses every name in `ROUTE_SHADOWED_DATASOURCE_NAMES`, the one list the admin routes mount ' + + 'from, with an `invalid_format` issue at `name` naming the name and the route; the published JSON ' + + 'Schema carries the refusal as a second `pattern`. Measured producers: none in this repository.', + acceptanceCriteria: + 'No datasource is named `drivers`; `defineStack`, `os validate` and `os build` report no issue at ' + + '`name` (or `datasources.N.name`) reading "Datasource name \'drivers\' is reserved", and the ' + + 'metadata save door answers 200 for the renamed datasource. A stored `sys_metadata` datasource row ' + + 'named `drivers` still loads, with `_diagnostics` reading `valid: false` and carrying this refusal, ' + + 'and its next metadata save is refused; nothing rewrites it. Through the admin door that row stays ' + + 'listed and removable, and a `label`/`active`-only edit still saves.', +}; From e5f6120b7f55592da59b6e9d2b7f710fe25be6a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:49:55 +0000 Subject: [PATCH 3/3] fix(spec): the drivers-name refusal names only the offending name Build the sentence from the input (zod 4 error function) instead of once for the whole list, pin that it names only itself, and correct the NAME_RE docblock in the admin service, which describes the first of the two patterns. Claude-Session: https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU Co-authored-by: Claude --- .../src/datasource-admin-service.ts | 6 +++++- .../datasource-route-shadowed-name.test.ts | 7 +++++++ packages/spec/src/data/datasource.zod.ts | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index 452539a9cf4..0854b5220be 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -54,7 +54,11 @@ import type { } from './contracts/index.js'; import type { Logger } from './logger.js'; -/** Datasource name rule (mirrors `DatasourceSchema.name`). */ +/** + * Datasource name rule: the identifier pattern, the first of the two patterns on + * `DatasourceSchema.name`. The second, the route-shadowed names, is refused by + * name in `assertValidName` from the spec's `ROUTE_SHADOWED_DATASOURCE_NAMES`. + */ const NAME_RE = /^[a-z_][a-z0-9_]*$/; /** diff --git a/packages/spec/src/data/datasource-route-shadowed-name.test.ts b/packages/spec/src/data/datasource-route-shadowed-name.test.ts index 7f92f76ad9f..6aaa0ba81fb 100644 --- a/packages/spec/src/data/datasource-route-shadowed-name.test.ts +++ b/packages/spec/src/data/datasource-route-shadowed-name.test.ts @@ -83,6 +83,13 @@ describe('DatasourceSchema.name refuses a route-shadowed name', () => { expect(message).toContain('could never be read by name'); expect(message).not.toMatch(/#\d{3,}/); }); + + it(`the refusal for '${name}' names only that name: built from the input, never from the whole list`, () => { + const message = DatasourceSchema.safeParse(sqlite(name)).error!.issues[0]!.message; + // Every quoted name and every literal route the sentence carries is this one. + expect([...message.matchAll(/'([a-z0-9_]+)'/g)].map((m) => m[1])).toEqual([name]); + expect([...message.matchAll(/\/api\/v1\/datasources\/([a-z0-9_]+)/g)].map((m) => m[1])).toEqual([name]); + }); } it('the code door: defineStack refuses it with STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => { diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index d93042071d4..044bc4f30c9 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -616,14 +616,16 @@ export const ROUTE_SHADOWED_DATASOURCE_NAMES = Object.freeze(['drivers'] as cons const quotedRouteShadowedNames = ROUTE_SHADOWED_DATASOURCE_NAMES.map((name) => `'${name}'`).join(', '); /** - * The refusal `DatasourceSchema.name` answers for a route-shadowed name: it - * names the name and why, in the admin create door's sentence family. + * The refusal `DatasourceSchema.name` answers for a route-shadowed name: built + * from the offending input, so it names that one name and its route (never the + * whole list), in the admin create door's sentence family. */ -const ROUTE_SHADOWED_NAME_REFUSAL = - `Datasource name ${quotedRouteShadowedNames} is reserved: the datasource admin API serves its own route ` - + `at /api/v1/datasources/${ROUTE_SHADOWED_DATASOURCE_NAMES.join(' and /api/v1/datasources/')}, 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.'; +function routeShadowedNameRefusal(issue: { input?: unknown }): string { + const name = String(issue.input); + return `Datasource name '${name}' is reserved: the datasource admin API serves its own route at ` + + `/api/v1/datasources/${name}, 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.'; +} /** * A second pattern on `name`, not a refinement: zod emits both patterns into @@ -699,7 +701,7 @@ export const DatasourceSchema = lazySchema(() => strictObject( */ name: z.string() .regex(/^[a-z_][a-z0-9_]*$/) - .regex(NOT_ROUTE_SHADOWED_NAME, ROUTE_SHADOWED_NAME_REFUSAL) + .regex(NOT_ROUTE_SHADOWED_NAME, { error: routeShadowedNameRefusal }) .describe( `Unique datasource identifier (snake_case). Reserved: ${quotedRouteShadowedNames}, a path the ` + 'datasource admin API serves itself, so a datasource of that name could never be read by name.',