Skip to content

Commit e3db58a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23772-principal-session-mint
2 parents d568152 + 6362c66 commit e3db58a

20 files changed

Lines changed: 477 additions & 43 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a stack's `datasources` collection refuses a datasource named `default`, the name the host reserves for its primary datasource
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered stack-datasource-host-reserved-name-refused -->
10+
11+
**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.
12+
13+
`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):
14+
15+
> app 'APP_ID' declares a datasource named 'default' — that name is reserved for the host's primary datasource.
16+
17+
`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:
18+
19+
> 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).
20+
21+
`DatasourceSchema` is unchanged: it also documents the host's own `default` row, which keeps parsing.
22+
23+
### FROM → TO
24+
25+
| before | what to write instead |
26+
| --- | --- |
27+
| `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` |
28+
| 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` |
29+
30+
**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.
31+
32+
**Measured producers.** None in this repository (examples, `create-objectstack`, skills, docs); the one instance found was an agent-written measurement fixture.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
refactor(plugin-hono-server,runtime): the Hono doors and the HTTP dispatcher read the duplicate-record wire code off the shared error classification instead of spelling it themselves (#23714)
7+
8+
**No behaviour change.** A repeated unique value still answers `409 UNIQUE_VIOLATION` at both Hono doors (a `HonoHttpServer` route or raw mount, and `createHonoApp`'s catch arms through `declaredEnvelopeForThrow`) and at the dispatcher's doors (`HttpDispatcher`'s thrown-error envelope), with the same `message`, the same `details` (`developerMessage`, `object`, and `field` when the driver named the column) and the same envelope keys. The answers were compared byte for byte at those doors before and after. A client or an app author has nothing to change.
9+
10+
**What changed.** Both translations used to spell the wire code as a string literal. They now read it off the classification the REST server answers with (`structuredCodeAnswer` in `@objectstack/types`), as `@objectstack/mcp` already does. So the code has one source, and neither package stamps a code that the error-code ledger does not list under it. The engine's in-process code is unchanged: `DuplicateRecordError.code` is still `DUPLICATE_RECORD`.
11+
12+
Clause-②: no

‎content/docs/data-modeling/field-type-decision-tree.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ flowchart TD
143143
| Type | Use When | Example |
144144
|:---|:---|:---|
145145
| `number` | Any numeric value (set `precision`/`scale` for decimals) | `price`, `weight`, `quantity` |
146-
| `currency` | Money amounts with currency code | `amount`, `total_price` |
146+
| `currency` | Money amounts, stored as a bare number (currency: fixed on the field, or the tenant default) | `amount`, `total_price` |
147147
| `percent` | Percentage values, stored as fractions 0–1 (`0.85` = 85%) | `completion`, `discount` |
148148
| `rating` | Star ratings (typically 1–5) | `satisfaction`, `difficulty` |
149149
| `slider` | Numeric value via slider UI | `volume`, `priority_level` |

‎content/docs/protocol/objectql/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ ObjectQL provides 20+ field types that encode business semantics:
118118
| `lookup` | Relationship | `account_id` references `account` |
119119
| `formula` | Calculated value | `total = quantity * price` |
120120
| `summary` | Aggregate children | `total_opportunities` counts child records |
121-
| `currency` | Money with exchange rates | `{amount: 1000, currency: 'USD'}` |
121+
| `currency` | Money, in the field's fixed currency or the tenant default | `1000` (a bare number) |
122122
| `address` | Geocoding & distance | `{street, city, lat, lng}` |
123123

124124
See [Types Reference](/docs/protocol/objectql/types) for complete list.

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ revenue:
2727
label: Annual Revenue
2828
precision: 18
2929
# Automatically knows:
30-
# - Store amount + currency code
30+
# - Store a bare number, no per-record currency code: it shows in the
31+
# tenant default currency (plain if none is set) or one fixed on the
32+
# field, at that currency's ISO 4217 decimal places
3133
# - UI shows currency symbol
3234
# - Validate numeric precision
3335
# - Format for display ($1,234.56)
@@ -286,7 +288,9 @@ quantity:
286288
---
287289

288290
#### `currency`
289-
Money amount with currency code.
291+
Money amount, stored as a bare number. The currency is field configuration,
292+
never stored per value: `defaultCurrency` under `currencyMode: fixed`, or the
293+
tenant default currency under `dynamic`, the default.
290294

291295
```yaml
292296
annual_revenue:
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+
* `os validate` — the build door an author runs before shipping — refuses a
5+
* stack that declares a datasource named `default`, run for real (#23634).
6+
*
7+
* The measured starting point: a config declaring
8+
* `datasources: [{ name: 'default', driver: 'sqlite', … }]` validated clean and
9+
* exited 0, while every boot refused it as host-reserved (the app plugin's
10+
* "declares a datasource named 'default' — that name is reserved for the
11+
* host's primary datasource"), with `OS_DATABASE_URL`, with `--dev` and on the
12+
* standalone path. The exit status is what a CI pipeline reads, so this file
13+
* spawns the source CLI and asserts what the shell sees, with the refusal's
14+
* issue code and path from `--json`.
15+
*
16+
* Schema-layer pins (`defineStack`, the map form, the reserved set, and the
17+
* host's own `default` row through `DatasourceSchema`) live in
18+
* `packages/spec/src/stack-datasource-host-reserved-name.test.ts`.
19+
*
20+
* Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`).
21+
* Not named `.e2e`, so it runs on every PR and in the merge queue; two spawns,
22+
* one per fixture. The CLI runs through `bin/run-dev.js` (source, via tsx), and
23+
* `@objectstack/spec` resolves through `exports` to `dist/`.
24+
*/
25+
26+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
27+
import { execFile } from 'node:child_process';
28+
import { mkdtempSync, rmSync } from 'node:fs';
29+
import { tmpdir } from 'node:os';
30+
import { join, resolve } from 'node:path';
31+
import { fileURLToPath } from 'node:url';
32+
import { childEnv } from './helpers/serve-process.js';
33+
import { writeDefineStackConfig } from './helpers/define-stack-fixture.js';
34+
35+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
36+
const CLI = resolve(HERE, '../bin/run-dev.js');
37+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
38+
39+
/** One stack: one object, and one declared sqlite datasource under `name`. */
40+
const stackOf = (name: string) => ({
41+
manifest: {
42+
id: 'com.example.fx',
43+
namespace: 'fx',
44+
name: 'Reserved datasource door probe',
45+
version: '1.0.0',
46+
type: 'app',
47+
engines: { protocol: '^18' },
48+
},
49+
objects: [
50+
{
51+
name: 'fx_task',
52+
label: 'Task',
53+
pluralLabel: 'Tasks',
54+
sharingModel: 'private',
55+
fields: { name: { type: 'text', label: 'Name', required: true } },
56+
},
57+
],
58+
datasources: [{ name, driver: 'sqlite', config: { filename: `${name}.db` }, autoConnect: true }],
59+
});
60+
61+
const FIXTURES = {
62+
/** The measured shape: the host-reserved name. */
63+
reserved: stackOf('default'),
64+
/** CONTROL: the same entry under a name of the stack's own. */
65+
own: stackOf('fx_primary'),
66+
} as const;
67+
68+
interface Run {
69+
code: number;
70+
stdout: string;
71+
stderr: string;
72+
}
73+
74+
function runCli(args: string[], cwd: string): Promise<Run> {
75+
return new Promise((resolvePromise) => {
76+
execFile(
77+
TSX,
78+
[CLI, ...args],
79+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
80+
(err, stdout, stderr) => {
81+
resolvePromise({
82+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
83+
stdout: String(stdout),
84+
stderr: String(stderr),
85+
});
86+
},
87+
);
88+
});
89+
}
90+
91+
interface ValidateError {
92+
code?: string;
93+
path?: unknown[];
94+
message?: string;
95+
}
96+
97+
/** `os validate --json` on failure: the parse's issues ride `errors`. */
98+
const errorsOf = (stdout: string): ValidateError[] => (JSON.parse(stdout) as { errors?: ValidateError[] }).errors ?? [];
99+
100+
const dirs = new Map<keyof typeof FIXTURES, string>();
101+
102+
beforeAll(() => {
103+
for (const [kind, stack] of Object.entries(FIXTURES) as Array<[keyof typeof FIXTURES, unknown]>) {
104+
const dir = mkdtempSync(join(tmpdir(), `os-ds-default-door-${kind}-`));
105+
// `strict: false`: the door's own parse must judge the content, not `defineStack` at load.
106+
writeDefineStackConfig(dir, stack, { strict: false });
107+
dirs.set(kind, dir);
108+
}
109+
});
110+
111+
afterAll(() => {
112+
for (const dir of dirs.values()) rmSync(dir, { recursive: true, force: true });
113+
});
114+
115+
describe('os validate refuses a stack datasource named `default`', () => {
116+
it('exit 1: refused at datasources.0.name with the runtime\'s sentence and its remedy', async () => {
117+
const run = await runCli(['validate', '--json'], dirs.get('reserved')!);
118+
expect(run.code, `expected exit 1:\n${run.stdout}\n${run.stderr}`).toBe(1);
119+
const errors = errorsOf(run.stdout);
120+
const located = errors.map((e) => ({ code: e.code, path: (e.path ?? []).join('.') }));
121+
expect(located).toEqual([{ code: 'custom', path: 'datasources.0.name' }]);
122+
expect(errors[0]!.message).toContain(
123+
"declares a datasource named 'default' — that name is reserved for the host's primary datasource",
124+
);
125+
expect(errors[0]!.message).toContain('OS_DATABASE_URL');
126+
}, 180_000);
127+
128+
it('CONTROL: the same datasource named `fx_primary`, exit 0', async () => {
129+
const run = await runCli(['validate', '--json'], dirs.get('own')!);
130+
expect(run.code, `expected exit 0:\n${run.stdout}\n${run.stderr}`).toBe(0);
131+
expect((JSON.parse(run.stdout) as { valid?: boolean }).valid).toBe(true);
132+
}, 180_000);
133+
});

‎packages/plugins/plugin-hono-server/src/adapter.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,12 @@ function toLoggableError(thrown: unknown): Error {
300300
* So this seam decides the envelope's `code` for that class BEFORE the arm
301301
* is consulted, keyed exactly as the arm keys it: the registered code AND the
302302
* class name ({@link isEngineDuplicateRecordEnvelope}, `@objectstack/types`,
303-
* the same predicate the import runner asks), never message text. Then the
304-
* arm agrees with the envelope, and its fields ride `details` under the rule
305-
* {@link declaredArmContext} states for every arm. A producer that merely
306-
* SPEAKS `DUPLICATE_RECORD` (a hook throwing the registered code without
307-
* being the engine's class) keeps its own code and its own body. The
303+
* the same predicate the import runner asks), never message text. The code
304+
* itself is read off that arm (`structuredCodeAnswer`, #23714), never spelled
305+
* here. Then the arm agrees with the envelope, and its fields ride `details`
306+
* under the rule {@link declaredArmContext} states for every arm. A producer
307+
* that merely SPEAKS `DUPLICATE_RECORD` (a hook throwing the registered code
308+
* without being the engine's class) keeps its own code and its own body. The
308309
* `message` stays the thrown one, as on every other code at these doors.
309310
*
310311
* ## Exported for the other Hono door (#23365)
@@ -359,7 +360,16 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
359360
// unique-constraint envelope, which crosses HTTP as the one ruled wire
360361
// spelling (see "One wire spelling" above). Decided here, before the arm
361362
// is consulted, so the arm's agreement rule below is unchanged.
362-
const code = isEngineDuplicateRecordEnvelope(thrown) ? 'UNIQUE_VIOLATION' : resolved.code;
363+
//
364+
// [#23714] That wire code is read off the shared classification's arm
365+
// for the class (`structuredCodeAnswer`, the arm `RestServer` answers
366+
// with), so this package spells no copy of it: the stamp stays where the
367+
// error-code ledger lists it, under `@objectstack/types`. The thrown code
368+
// is kept only if that arm ever answered no string code for the class.
369+
const duplicateArmCode = isEngineDuplicateRecordEnvelope(thrown)
370+
? structuredCodeAnswer(thrown)?.body.code
371+
: undefined;
372+
const code = typeof duplicateArmCode === 'string' ? duplicateArmCode : resolved.code;
363373

364374
// [#23531, #23587] The structured context `RestServer`'s classification
365375
// states for this refusal joins the producer's own context in `details`.

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,16 +394,22 @@ type ClassifiedServiceLookup =
394394
* as `message` and `code`.
395395
*
396396
* The arm must AGREE with the envelope already decided, as at the Hono doors:
397-
* its status must be the thrown one and its code `UNIQUE_VIOLATION`, so it can
397+
* its status must be the thrown one and its code the envelope's, so it can
398398
* only add context, never change the answer. Disagreeing, it adds nothing.
399+
* The envelope's code for this class is itself read off the same arm
400+
* (`HttpDispatcher.errorFromThrown`, #23714), so this function spells no copy
401+
* of it either.
399402
*
400403
* ⛔ Deliberately not every arm. The dispatcher's other 409s
401404
* (`CONCURRENT_UPDATE`, `DELETE_RESTRICTED`) keep the body they have; widening
402405
* the context this door carries for them is a change of its own.
403406
*/
404-
function duplicateArmContext(thrown: unknown, status: number): Record<string, unknown> | undefined {
407+
function duplicateArmContext(
408+
thrown: unknown,
409+
envelope: { status: number; code: string },
410+
): Record<string, unknown> | undefined {
405411
const arm = structuredCodeAnswer(thrown);
406-
if (arm === undefined || arm.status !== status || arm.body.code !== 'UNIQUE_VIOLATION') return undefined;
412+
if (arm === undefined || arm.status !== envelope.status || arm.body.code !== envelope.code) return undefined;
407413
const context = Object.fromEntries(
408414
Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'),
409415
);
@@ -1210,9 +1216,11 @@ export class HttpDispatcher {
12101216
* So this method decides the code for that class before writing it, keyed
12111217
* exactly as those doors key it: the registered code AND the class name
12121218
* ({@link isEngineDuplicateRecordEnvelope}, `@objectstack/types`), never
1213-
* message text. A producer that merely SPEAKS `DUPLICATE_RECORD` (a
1214-
* handler or hook throwing the registered code without being the
1215-
* engine's class) keeps its own code and its own body. Beside the code,
1219+
* message text. The code itself is read off `RestServer`'s arm
1220+
* (`structuredCodeAnswer`, #23714), never spelled here. A producer that
1221+
* merely SPEAKS `DUPLICATE_RECORD` (a handler or hook throwing the
1222+
* registered code without being the engine's class) keeps its own code
1223+
* and its own body. Beside the code,
12161224
* the envelope's `details` carries what `RestServer`'s arm carries beside
12171225
* its own, read off that one classification ({@link duplicateArmContext})
12181226
* rather than listed here: `developerMessage`, `object`, and `field` when
@@ -1231,9 +1239,17 @@ export class HttpDispatcher {
12311239
// [#23686] The code this envelope carries: the resolver's, except the
12321240
// engine's unique-constraint envelope, which crosses HTTP as the one
12331241
// ruled wire spelling (see "One wire spelling" above).
1242+
//
1243+
// [#23714] That wire code is read off the shared classification's arm
1244+
// for the class (`structuredCodeAnswer`, the arm `RestServer` answers
1245+
// with), so this package spells no copy of it: the stamp stays where
1246+
// the error-code ledger lists it, under `@objectstack/types`. The
1247+
// resolver's code is kept only if that arm ever answered no string
1248+
// code for the class.
12341249
const engineDuplicate = isEngineDuplicateRecordEnvelope(e);
1235-
const code = engineDuplicate ? 'UNIQUE_VIOLATION' : thrown.code;
1236-
const armContext = engineDuplicate ? duplicateArmContext(e, thrown.status) : undefined;
1250+
const duplicateArmCode = engineDuplicate ? structuredCodeAnswer(e)?.body.code : undefined;
1251+
const code = typeof duplicateArmCode === 'string' ? duplicateArmCode : thrown.code;
1252+
const armContext = engineDuplicate ? duplicateArmContext(e, { status: thrown.status, code }) : undefined;
12371253
const details = armContext ? { ...thrown.details, ...armContext } : thrown.details;
12381254
// [#9106] `code`, the NARROWED spelling — the same one the REST door
12391255
// has served since #8016, so the two doors now agree on `error.code`

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 234,
6-
"droppedRefinementSites": 759,
6+
"droppedRefinementSites": 763,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -78,6 +78,7 @@
7878
"manifest.datasets.element.include.element",
7979
"manifest.datasets.element.measures.element",
8080
"manifest.datasets.element.measures.element.filter",
81+
"manifest.datasources",
8182
"manifest.datasources.element",
8283
"manifest.flows.element",
8384
"manifest.flows.element.errorHandling",
@@ -194,6 +195,7 @@
194195
"data.options[1].manifest.datasets.element.include.element",
195196
"data.options[1].manifest.datasets.element.measures.element",
196197
"data.options[1].manifest.datasets.element.measures.element.filter",
198+
"data.options[1].manifest.datasources",
197199
"data.options[1].manifest.datasources.element",
198200
"data.options[1].manifest.flows.element",
199201
"data.options[1].manifest.flows.element.errorHandling",
@@ -300,6 +302,7 @@
300302
"options[1].manifest.datasets.element.include.element",
301303
"options[1].manifest.datasets.element.measures.element",
302304
"options[1].manifest.datasets.element.measures.element.filter",
305+
"options[1].manifest.datasources",
303306
"options[1].manifest.datasources.element",
304307
"options[1].manifest.flows.element",
305308
"options[1].manifest.flows.element.errorHandling",
@@ -361,6 +364,7 @@
361364
"data.packages.element.options[1].manifest.datasets.element.include.element",
362365
"data.packages.element.options[1].manifest.datasets.element.measures.element",
363366
"data.packages.element.options[1].manifest.datasets.element.measures.element.filter",
367+
"data.packages.element.options[1].manifest.datasources",
364368
"data.packages.element.options[1].manifest.datasources.element",
365369
"data.packages.element.options[1].manifest.flows.element",
366370
"data.packages.element.options[1].manifest.flows.element.errorHandling",

0 commit comments

Comments
 (0)