Skip to content
Merged
32 changes: 32 additions & 0 deletions .changeset/23634-stack-datasource-default-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/spec': minor
---

feat(spec)!: a stack's `datasources` collection refuses a datasource named `default`, the name the host reserves for its primary datasource

Clause-②: no (narrowing)

<!-- adr-0087: registered stack-datasource-host-reserved-name-refused -->

**BREAKING**: an accept-set narrowing on one collection of the stack definition, shipped as `minor` under the launch-window convention for accept-set narrowings. No config that boots today is newly refused: every boot already refused this one.

`default` is the host's primary datasource. The runtime declares and connects it itself, and the app plugin refuses a stack that declares it in every boot posture (with `OS_DATABASE_URL`, with `--dev`, and on the standalone path):

> app 'APP_ID' declares a datasource named 'default' — that name is reserved for the host's primary datasource.

`defineStack` and `os validate` accepted the same stack, so its author met that refusal only after the boot's earlier plugins had started. The stack's `datasources` collection now refuses the entry at authoring, located at `datasources.N.name` (the map form `datasources: { default: { … } }` is refused the same way), with the runtime's sentence and its remedy:

> This stack declares a datasource named 'default' — that name is reserved for the host's primary datasource, and every boot refuses a stack that declares it. To give the project its own primary database, set OS_DATABASE_URL and omit this entry. To add a datasource of the stack's own, declare it under another name (e.g. 'app_primary') and route objects to it explicitly (an object's `datasource`, or a `datasourceMapping` rule).

`DatasourceSchema` is unchanged: it also documents the host's own `default` row, which keeps parsing.

### FROM → TO

| before | what to write instead |
| --- | --- |
| `datasources: [{ name: 'default', driver: 'sqlite', config: { filename } }]`, meant as the project's database | no entry; set `OS_DATABASE_URL` (e.g. `file:./data/app.db`) and the host connects it as `default` |
| the same entry, meant as a datasource of the stack's own | `datasources: [{ name: 'app_primary', … }]`, and route the objects that use it: `datasource: 'app_primary'` on the object, or a `datasourceMapping` rule naming `app_primary` |

**The one-line fix: drop the `default` entry and set `OS_DATABASE_URL`, or rename it and route objects to it.** `os migrate meta --from 17` lists the D3 entry `stack-datasource-host-reserved-name-refused`; the rewrite is by hand, because which of the two the entry meant is the author's call.

**Measured producers.** None in this repository (examples, `create-objectstack`, skills, docs); the one instance found was an agent-written measurement fixture.
133 changes: 133 additions & 0 deletions packages/cli/test/stack-datasource-default-validate-door.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.

/**
* `os validate` — the build door an author runs before shipping — refuses a
* stack that declares a datasource named `default`, run for real (#23634).
*
* The measured starting point: a config declaring
* `datasources: [{ name: 'default', driver: 'sqlite', … }]` validated clean and
* exited 0, while every boot refused it as host-reserved (the app plugin's
* "declares a datasource named 'default' — that name is reserved for the
* host's primary datasource"), with `OS_DATABASE_URL`, with `--dev` and on the
* standalone path. The exit status is what a CI pipeline reads, so this file
* spawns the source CLI and asserts what the shell sees, with the refusal's
* issue code and path from `--json`.
*
* Schema-layer pins (`defineStack`, the map form, the reserved set, and the
* host's own `default` row through `DatasourceSchema`) live in
* `packages/spec/src/stack-datasource-host-reserved-name.test.ts`.
*
* Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`).
* Not named `.e2e`, so it runs on every PR and in the merge queue; two spawns,
* one per fixture. The CLI runs through `bin/run-dev.js` (source, via tsx), and
* `@objectstack/spec` resolves through `exports` to `dist/`.
*/

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';
import { writeDefineStackConfig } from './helpers/define-stack-fixture.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

/** One stack: one object, and one declared sqlite datasource under `name`. */
const stackOf = (name: string) => ({
manifest: {
id: 'com.example.fx',
namespace: 'fx',
name: 'Reserved datasource door probe',
version: '1.0.0',
type: 'app',
engines: { protocol: '^18' },
},
objects: [
{
name: 'fx_task',
label: 'Task',
pluralLabel: 'Tasks',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name', required: true } },
},
],
datasources: [{ name, driver: 'sqlite', config: { filename: `${name}.db` }, autoConnect: true }],
});

const FIXTURES = {
/** The measured shape: the host-reserved name. */
reserved: stackOf('default'),
/** CONTROL: the same entry under a name of the stack's own. */
own: stackOf('fx_primary'),
} as const;

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

interface ValidateError {
code?: string;
path?: unknown[];
message?: string;
}

/** `os validate --json` on failure: the parse's issues ride `errors`. */
const errorsOf = (stdout: string): ValidateError[] => (JSON.parse(stdout) as { errors?: ValidateError[] }).errors ?? [];

const dirs = new Map<keyof typeof FIXTURES, string>();

beforeAll(() => {
for (const [kind, stack] of Object.entries(FIXTURES) as Array<[keyof typeof FIXTURES, unknown]>) {
const dir = mkdtempSync(join(tmpdir(), `os-ds-default-door-${kind}-`));
// `strict: false`: the door's own parse must judge the content, not `defineStack` at load.
writeDefineStackConfig(dir, stack, { strict: false });
dirs.set(kind, dir);
}
});

afterAll(() => {
for (const dir of dirs.values()) rmSync(dir, { recursive: true, force: true });
});

describe('os validate refuses a stack datasource named `default`', () => {
it('exit 1: refused at datasources.0.name with the runtime\'s sentence and its remedy', async () => {
const run = await runCli(['validate', '--json'], dirs.get('reserved')!);
expect(run.code, `expected exit 1:\n${run.stdout}\n${run.stderr}`).toBe(1);
const errors = errorsOf(run.stdout);
const located = errors.map((e) => ({ code: e.code, path: (e.path ?? []).join('.') }));
expect(located).toEqual([{ code: 'custom', path: 'datasources.0.name' }]);
expect(errors[0]!.message).toContain(
"declares a datasource named 'default' — that name is reserved for the host's primary datasource",
);
expect(errors[0]!.message).toContain('OS_DATABASE_URL');
}, 180_000);

it('CONTROL: the same datasource named `fx_primary`, exit 0', async () => {
const run = await runCli(['validate', '--json'], dirs.get('own')!);
expect(run.code, `expected exit 0:\n${run.stdout}\n${run.stderr}`).toBe(0);
expect((JSON.parse(run.stdout) as { valid?: boolean }).valid).toBe(true);
}, 180_000);
});
6 changes: 5 additions & 1 deletion packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 234,
"droppedRefinementSites": 759,
"droppedRefinementSites": 763,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -78,6 +78,7 @@
"manifest.datasets.element.include.element",
"manifest.datasets.element.measures.element",
"manifest.datasets.element.measures.element.filter",
"manifest.datasources",
"manifest.datasources.element",
"manifest.flows.element",
"manifest.flows.element.errorHandling",
Expand Down Expand Up @@ -194,6 +195,7 @@
"data.options[1].manifest.datasets.element.include.element",
"data.options[1].manifest.datasets.element.measures.element",
"data.options[1].manifest.datasets.element.measures.element.filter",
"data.options[1].manifest.datasources",
"data.options[1].manifest.datasources.element",
"data.options[1].manifest.flows.element",
"data.options[1].manifest.flows.element.errorHandling",
Expand Down Expand Up @@ -300,6 +302,7 @@
"options[1].manifest.datasets.element.include.element",
"options[1].manifest.datasets.element.measures.element",
"options[1].manifest.datasets.element.measures.element.filter",
"options[1].manifest.datasources",
"options[1].manifest.datasources.element",
"options[1].manifest.flows.element",
"options[1].manifest.flows.element.errorHandling",
Expand Down Expand Up @@ -361,6 +364,7 @@
"data.packages.element.options[1].manifest.datasets.element.include.element",
"data.packages.element.options[1].manifest.datasets.element.measures.element",
"data.packages.element.options[1].manifest.datasets.element.measures.element.filter",
"data.packages.element.options[1].manifest.datasources",
"data.packages.element.options[1].manifest.datasources.element",
"data.packages.element.options[1].manifest.flows.element",
"data.packages.element.options[1].manifest.flows.element.errorHandling",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'stack-datasource-host-reserved-name-refused',
surface: 'an entry of a stack\'s `datasources` collection named `default` (the array form '
+ '`datasources: [{ name: \'default\', … }]`, or the map form `datasources: { default: { … } }`), '
+ 'in `defineStack`, `os validate` and publish',
replacement: 'for the project\'s own primary database, no entry: set `OS_DATABASE_URL` and the host '
+ 'connects it as `default`. For a datasource of the stack\'s own, the same entry under another '
+ 'snake_case name (e.g. `app_primary`), with the objects that use it routed to it explicitly '
+ '(the object\'s `datasource`, or a `datasourceMapping` rule). ⛔ NOT mechanically convertible: '
+ 'whether the entry was meant as the host\'s database or as one of the stack\'s own is the '
+ 'author\'s call.',
reason:
'`default` is the host\'s primary datasource: the runtime declares and connects it itself, and '
+ 'the app plugin refuses a stack that declares it in every boot posture, with `OS_DATABASE_URL`, '
+ 'with `--dev` and on the standalone path. The authoring doors accepted the same stack, so its '
+ 'author first met the refusal after the boot\'s earlier plugins had started. The stack\'s '
+ '`datasources` collection now refuses the entry with the runtime\'s sentence and remedy. '
+ '`DatasourceSchema` itself is unchanged: it also documents the host\'s own `default` row, which '
+ 'keeps parsing. No config that boots today is newly refused. Measured producers: none in this '
+ 'repository; the one instance found was an agent-written measurement fixture.',
acceptanceCriteria:
'No stack\'s `datasources` collection carries an entry named `default`; `defineStack` and '
+ '`os validate` report no issue at `datasources.N.name`, and the deployment reaches its primary '
+ 'database through `OS_DATABASE_URL` (or a driver plugin) and any datasource of its own under '
+ 'its own name.',
};
113 changes: 113 additions & 0 deletions packages/spec/src/stack-datasource-host-reserved-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The stack's `datasources` collection refuses a datasource named `default`
* (#23634, triage's proxy ruling A).
*
* ## What was wrong
*
* `default` is the host's primary datasource: the runtime declares and
* connects it itself, and `AppPlugin` refuses a stack that declares it in every
* boot posture (with `OS_DATABASE_URL`, with `--dev`, on the standalone path).
* Yet `defineStack` and `os validate` accepted that stack, so the refusal first
* reached its author after the boot's earlier plugins had started.
*
* ## What is pinned
*
* - `defineStack` refuses the array form and the map form with the ADR-0112
* envelope (`STACK_SCHEMA_INVALID`, 422), one issue located at the entry's
* `name`, whose text carries the runtime's sentence and both remedies;
* - the bare schema parse — the `os validate` door's — refuses it too;
* - the reserved set is the runtime's comparison, `name === 'default'`:
* every other spelling of the word is refused by the name pattern already,
* never by this rule;
* - CONTROL: a datasource named `fx_primary` passes;
* - CONTROL: the host's own `default` row still parses through
* `DatasourceSchema`, which the refusal deliberately does not sit on.
*
* The `os validate` exit-code pin lives in
* `packages/cli/test/stack-datasource-default-validate-door.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import { defineStack, ObjectStackDefinitionSchema } from './stack.zod';
import { DatasourceSchema } from './data/datasource.zod';

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;
}
}

const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const };
const sqlite = (name: string) => ({ name, driver: 'sqlite', config: { filename: `${name}.db` }, autoConnect: true });

const RUNTIME_SENTENCE = "declares a datasource named 'default' — that name is reserved for the host's primary datasource";

function expectReservedNameIssue(issues: Envelope['issues'], path: readonly PropertyKey[]): void {
const hits = (issues ?? []).filter((i) => (i.message ?? '').includes(RUNTIME_SENTENCE));
expect(hits).toHaveLength(1);
expect(hits[0]!.code).toBe('custom');
expect(hits[0]!.path).toEqual(path);
expect(hits[0]!.message).toContain('OS_DATABASE_URL');
expect(hits[0]!.message).toContain('declare it under another name');
}

describe('a stack datasource named `default` is refused at authoring', () => {
it('defineStack refuses the array form — STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => {
const refused = refusal(() => defineStack({ manifest, datasources: [sqlite('default')] } as never));
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expect(refused?.status).toBe(422);
expectReservedNameIssue(refused?.issues, ['datasources', 0, 'name']);
});

it('defineStack refuses the map form — the key normalizes to `name`', () => {
const { name: _name, ...body } = sqlite('default');
const refused = refusal(() => defineStack({ manifest, datasources: { default: body } } as never));
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expect(refused?.status).toBe(422);
expectReservedNameIssue(refused?.issues, ['datasources', 0, 'name']);
});

it('names the entry it refuses, beside a legal one', () => {
const refused = refusal(() =>
defineStack({ manifest, datasources: [sqlite('fx_primary'), sqlite('default')] } as never),
);
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expectReservedNameIssue(refused?.issues, ['datasources', 1, 'name']);
});

it('the bare schema parse (the os validate door) refuses it too', () => {
const result = ObjectStackDefinitionSchema.safeParse({ manifest, datasources: [sqlite('default')] });
expect(result.success).toBe(false);
expectReservedNameIssue(result.error?.issues as Envelope['issues'], ['datasources', 0, 'name']);
});

it("the reserved set is the runtime's `name === 'default'`: other spellings meet the name pattern, not this rule", () => {
for (const spelling of ['Default', 'DEFAULT', ' default', 'default ']) {
const result = ObjectStackDefinitionSchema.safeParse({ manifest, datasources: [sqlite(spelling)] });
expect(result.success, spelling).toBe(false);
const messages = (result.error?.issues ?? []).map((i) => i.message);
expect(messages.some((m) => m.includes(RUNTIME_SENTENCE)), spelling).toBe(false);
}
});

it('CONTROL: a datasource named `fx_primary` passes', () => {
const stack = defineStack({ manifest, datasources: [sqlite('fx_primary')] } as never);
expect(stack.datasources?.map((d) => d.name)).toEqual(['fx_primary']);
});

it("CONTROL: the host's own `default` row still parses through DatasourceSchema", () => {
const result = DatasourceSchema.safeParse(sqlite('default'));
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect(result.data?.name).toBe('default');
});
});
Loading
Loading