Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/29308-datasource-route-shadowed-name.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered datasource-name-route-shadowed-refused -->
9 changes: 6 additions & 3 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
`OS_ALLOW_DRIVER_CONNECT_FAILURE=1` boots anyway, in an explicitly degraded
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/datasource.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | ✅ | Driver specific configuration |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$/;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
133 changes: 133 additions & 0 deletions packages/spec/src/data/datasource-route-shadowed-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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 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', () => {
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<string, { pattern?: string; allOf?: Array<{ pattern?: string }> }>;
};
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);
});
});
60 changes: 58 additions & 2 deletions packages/spec/src/data/datasource.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,52 @@ 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: 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.
*/
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
* 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.
Expand Down Expand Up @@ -648,8 +694,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, { 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.',
),

/** Human Label */
label: z.string().optional().describe('Display label'),
Expand Down
Loading
Loading