Skip to content

Commit 332ada0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-13799-batch6b-sink-plus-battery
2 parents 5143b70 + a23603e commit 332ada0

17 files changed

Lines changed: 1297 additions & 73 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `PageTranslation.components` now says why `element:text`'s `content` is not one of its keys (#14412)
6+
7+
The per-component translation face names its deliberate exclusions with reasons — `help` because no component in the model declares it, `subtitle` because `page:header` is already addressed by page name. `content` was neither named nor excluded. An author looking for a bundle key for the one string `element:text` renders therefore found an absence, and an absence reads exactly like an oversight.
8+
9+
It is not one, and the schema comment now records that beside the other two. `element:text` declares `content: I18nLabelSchema` (`ui/component.zod.ts`), so the string is localizable at its own authoring site as an inline `{ en, 'zh-CN' }` locale map — the route `sys-user.page.ts` itself uses. Adding it to the bundle face would be the face widening the `submitLabel` retirement declined for the identical shape (#10926, ADR-0049).
10+
11+
No key was added and no behaviour changed: the bundle face is still `title` / `description` / `label` / `placeholder` / `emptyText`, and `translatePage` resolves exactly what it resolved before. Bundles, extractor output and existing page definitions are unaffected.
12+
13+
Known and tracked separately: inline locale maps are invisible to `os i18n extract` and `check:i18n-coverage`, so page prose written this way is not counted by coverage tooling. That is true of every inline `I18nLabel` field rather than this component alone, and is carried as #14749.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/metadata": patch
4+
---
5+
6+
fix(runtime,metadata): the default local environment id is `env_local`, not `proj_local` (#13366)
7+
8+
The v5.0 `project` to `environment` rename changed the default local environment
9+
id and shipped that change on the surfaces most people meet: `packages/cli`'s
10+
`CHANGELOG.md` records "Default local env id: `proj_local` -> `env_local`", the
11+
`os dev` / `os start` / `os serve` commands emit `env_local`, and
12+
`content/docs/deployment/cli.mdx` documents `env_local` as the default. Two
13+
sites never received it and kept stamping `proj_local`.
14+
15+
FROM: `createStandaloneStack()` — with no `environmentId` in its config and no
16+
`OS_ENVIRONMENT_ID` in the environment — stamped `proj_local` on the kernel it
17+
composed, and `MetadataPlugin` used `proj_local` to fill the environment-artifact
18+
validation envelope for a bare definition.
19+
20+
TO: both stamp `env_local`.
21+
22+
WHO SEES IT. Two audiences, both on the DEFAULT path — no `environmentId` in
23+
the config and no `OS_ENVIRONMENT_ID` in the environment:
24+
25+
1. a host that calls `createStandaloneStack` / `createDefaultHostConfig`
26+
**directly**;
27+
2. a **bare `os serve`** — one not spawned by `os dev` / `os start`. Those two
28+
commands export `OS_ENVIRONMENT_ID=env_local` into the child process, which
29+
the fallback yields to, so a boot they start never reached the changed line.
30+
`os serve` sets no such variable for its own boot: it only READS one to name
31+
the runtime state file. So a bare `os serve` used to run a kernel stamped
32+
`proj_local` while publishing `runtime.env_local.json` beside it; the two now
33+
agree.
34+
35+
Where the id is observable — row scoping in `ObjectQLPlugin`, the
36+
`X-Environment-Id` header, `sys_metadata.environment_id` — such an embedder now
37+
sees `env_local` where it saw `proj_local`, so an install with rows already
38+
written under the old id should set `environmentId: 'proj_local'` (or
39+
`OS_ENVIRONMENT_ID=proj_local`) explicitly to keep them addressed. That escape
40+
hatch is unchanged and still wins over the default.
41+
42+
NOT CHANGED, deliberately: `@objectstack/cloud-connection` still treats BOTH
43+
spellings as the local sentinel, so a persisted `OS_ENVIRONMENT_ID=proj_local`
44+
config keeps being recognised as local rather than presented to the control
45+
plane as a cloud environment id; and `package-state-store`'s separate `'default'`
46+
fallback keeps its own spelling, because renaming it would re-key persisted
47+
package-disable state files.

packages/metadata/src/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ export class MetadataPlugin implements Plugin {
915915
const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label));
916916
const canonical = JSON.stringify(def, Object.keys(def).sort());
917917
const checksum = createHash('sha256').update(canonical).digest('hex');
918-
const environmentId = this.options.environmentId ?? 'proj_local';
918+
const environmentId = this.options.environmentId ?? 'env_local';
919919
EnvironmentArtifactSchema.parse({
920920
schemaVersion: '0.1',
921921
environmentId,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#13366] The default environment id a standalone boot stamps, pinned at the
4+
// place it is OBSERVABLE: the two plugins `createStandaloneStack` hands it to.
5+
//
6+
// Why this file exists at all. The v5.0 `project` to `environment` rename
7+
// shipped the CLI default `env_local` — `packages/cli/CHANGELOG.md` records
8+
// "Default local env id: `proj_local` -> `env_local`" and
9+
// `content/docs/deployment/cli.mdx` documents `env_local` — but the runtime's
10+
// own fallback kept stamping `proj_local`. Nothing pinned it, in either
11+
// spelling, so `declared != enforced` held on a published default for a whole
12+
// major line without one test going red. That is the gap this closes: the
13+
// literal now has an assertion attached to the code path that emits it.
14+
//
15+
// It reads the id off `result.plugins` rather than off a copy of the constant,
16+
// because the value is only interesting where it LANDS. `MetadataPlugin` takes
17+
// it as `options.environmentId` and `ObjectQLPlugin` as a row-scope key; a
18+
// pin that re-declared the string would stay green through a change that
19+
// stopped passing it to either.
20+
//
21+
// ⛔ These cases must NOT be read as "the CLI default". `os dev` / `os start`
22+
// export `OS_ENVIRONMENT_ID` into the child boot, so a CLI-spawned kernel never
23+
// reaches this fallback — the CLI's own default is pinned separately (the
24+
// `runtime.env_local.json` publication tests in packages/cli). What this file
25+
// owns is the DIRECT-EMBEDDER path: `createStandaloneStack()` with no config
26+
// and no env var, which is the surface a `createStandaloneStack` host observes.
27+
28+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
29+
import { mkdtempSync, rmSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { join } from 'node:path';
32+
import { createStandaloneStack } from './standalone-stack.js';
33+
34+
const BOOT_TIMEOUT = 60_000;
35+
36+
// The two plugin ids the stack composes. Matched by the plugin's own declared
37+
// `name`, not by array position: the composition order is documented as a
38+
// dependency-graph outcome elsewhere in this package, and an index would pin
39+
// that instead of this.
40+
const METADATA_PLUGIN = 'com.objectstack.metadata';
41+
const OBJECTQL_PLUGIN = 'com.objectstack.engine.objectql';
42+
43+
/**
44+
* The id as each plugin actually received it.
45+
*
46+
* `MetadataPlugin` keeps it under `options.environmentId`; `ObjectQLPlugin`
47+
* copies it to its own `environmentId` field. Both are TypeScript-private —
48+
* hence the casts — and reading them is deliberate: they are the last point at
49+
* which the stamped value is still identifiable before it dissolves into row
50+
* scoping and an artifact-validation envelope.
51+
*/
52+
function stampedIds(plugins: any[]): { metadata: unknown; objectql: unknown } {
53+
const metadata = plugins.find((p) => p?.name === METADATA_PLUGIN);
54+
const objectql = plugins.find((p) => p?.name === OBJECTQL_PLUGIN);
55+
expect(metadata, `stack must carry ${METADATA_PLUGIN}`).toBeDefined();
56+
expect(objectql, `stack must carry ${OBJECTQL_PLUGIN}`).toBeDefined();
57+
return {
58+
metadata: (metadata as any).options?.environmentId,
59+
objectql: (objectql as any).environmentId,
60+
};
61+
}
62+
63+
describe('[#13366] createStandaloneStack — default environment id', () => {
64+
let dir: string;
65+
let savedEnvId: string | undefined;
66+
let savedHome: string | undefined;
67+
68+
beforeEach(() => {
69+
dir = mkdtempSync(join(tmpdir(), 'os-standalone-envid-'));
70+
savedEnvId = process.env.OS_ENVIRONMENT_ID;
71+
savedHome = process.env.OS_HOME;
72+
delete process.env.OS_ENVIRONMENT_ID;
73+
process.env.OS_HOME = dir;
74+
});
75+
76+
afterEach(() => {
77+
if (savedEnvId === undefined) delete process.env.OS_ENVIRONMENT_ID;
78+
else process.env.OS_ENVIRONMENT_ID = savedEnvId;
79+
if (savedHome === undefined) delete process.env.OS_HOME;
80+
else process.env.OS_HOME = savedHome;
81+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
82+
});
83+
84+
it('stamps `env_local` when neither the config nor OS_ENVIRONMENT_ID names one', async () => {
85+
const stack = await createStandaloneStack({ databaseUrl: 'memory://standalone-envid-default' });
86+
// The literal, at both landing sites. `proj_local` here is the pre-#13366
87+
// value and is what this case exists to keep from coming back.
88+
expect(stampedIds(stack.plugins)).toEqual({ metadata: 'env_local', objectql: 'env_local' });
89+
}, BOOT_TIMEOUT);
90+
91+
it('OS_ENVIRONMENT_ID still overrides the default', async () => {
92+
process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment';
93+
const stack = await createStandaloneStack({ databaseUrl: 'memory://standalone-envid-env' });
94+
expect(stampedIds(stack.plugins)).toEqual({
95+
metadata: 'env_from_the_environment',
96+
objectql: 'env_from_the_environment',
97+
});
98+
}, BOOT_TIMEOUT);
99+
100+
it('an explicit `cfg.environmentId` still outranks OS_ENVIRONMENT_ID', async () => {
101+
process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment';
102+
const stack = await createStandaloneStack({
103+
environmentId: 'env_from_the_config',
104+
databaseUrl: 'memory://standalone-envid-cfg',
105+
});
106+
expect(stampedIds(stack.plugins)).toEqual({
107+
metadata: 'env_from_the_config',
108+
objectql: 'env_from_the_config',
109+
});
110+
}, BOOT_TIMEOUT);
111+
});

packages/runtime/src/standalone-stack.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,8 @@ export const StandaloneStackConfigSchema = z.object({
222222
* Defaults to `true`, and that default is the fix: a standalone kernel
223223
* OWNS its local platform tables, which is what the gate in
224224
* `assembleMetadataProtocol` always meant to say. It used to deduce that
225-
* from `environmentId === undefined`, and line ~515 below stamps
226-
* `'proj_local'` on every boot — so the block never ran and #8686's
225+
* from `environmentId === undefined`, and line ~567 below stamps
226+
* `'env_local'` on every boot — so the block never ran and #8686's
227227
* "covers every existing deployment" half covered no self-hosted install
228228
* at all.
229229
*
@@ -564,7 +564,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
564564
const { DefaultDatasourcePlugin } = await import('./default-datasource-plugin.js');
565565
const { AppPlugin } = await import('./app-plugin.js');
566566

567-
const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? 'proj_local';
567+
const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? 'env_local';
568568
const artifactPath = resolveArtifactPathInput(cfg);
569569

570570
// `databaseAuthToken` / `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are
@@ -747,7 +747,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
747747
...(cfg.projectRoot ? { rootDir: cfg.projectRoot } : {}),
748748
}),
749749
// [#9380] `runPlatformMigrations` is declared here, not deduced from
750-
// `environmentId`: this stack stamps `'proj_local'` above, and the
750+
// `environmentId`: this stack stamps `'env_local'` above, and the
751751
// assembly's old `environmentId === undefined` gate read that as "a
752752
// per-project cloud kernel" and disarmed the three boot repairs on
753753
// every self-hosted install. A standalone kernel owns its local

packages/spec/src/system/translation.zod.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -875,8 +875,9 @@ const translationDataShape = () => ({
875875
* `translation-component-submit-label-removed` conversion strips the key
876876
* from stored bundles.
877877
*
878-
* Two deliberate exclusions, both of which a mirror of the issue's proposed
879-
* shape would have got wrong:
878+
* Three deliberate exclusions — the first two because a mirror of the
879+
* issue's proposed shape would have got them wrong, the third because the
880+
* key was asked for and declined:
880881
*
881882
* - **`help` is not here** — no component in the model declares it. It
882883
* would parse clean and translate nothing, which is the ADR-0078 shape
@@ -886,6 +887,14 @@ const translationDataShape = () => ({
886887
* that component is addressed by page name above. Declaring it in both
887888
* places would give one string two spellings, which is how the
888889
* dashboards/pages asymmetry started.
890+
* - **`content` is not here** — `element:text`'s one authored string is
891+
* declared `content: I18nLabelSchema` (`ui/component.zod.ts`), so it is
892+
* localizable at its own authoring site, and adding it to this face would
893+
* be the face widening the `submitLabel` retirement declined for the
894+
* identical shape (#10926). The inline locale map is the ruled route for
895+
* page prose, not a workaround. That such maps are invisible to
896+
* `os i18n extract` and `check:i18n-coverage` is real, and is its own
897+
* question about the extractor (#14749) rather than a second key here.
889898
*
890899
* `properties` is an open record and custom component types are legal, so
891900
* these keys are also the route for a bespoke component that speaks the

scripts/check-dts-emitted.mjs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,57 @@ function run(dir) {
233233
// handshake is a flag rather than a returned sentinel.
234234
let selfTestReachedVerdict = false;
235235

236+
// ── The self-test's own battery roster and floor (#13489) ──────────────────
237+
//
238+
// `failures.length === 0` used to be this self-test's ONLY success condition, so
239+
// "every case held" and "the cases never ran" printed the same line. Closed the
240+
// way PR #13487 validated on check-doc-authoring: what is pinned is the
241+
// registered NAMES, not a number. The floor requires the OPENED set to equal the
242+
// DECLARED set with each battery at or above its own count.
243+
//
244+
// This file declares ONE battery, opened at the top of the self-test body. It
245+
// carries fewer than the two named section banners the sectioning criterion
246+
// needs, and ⛔ a comment is NOT promoted to a section head — that is a
247+
// judgement per comment this transplant does not make. The hoisted single
248+
// battery is the shape PR #14896 and PR #15003 landed for exactly this case.
249+
//
250+
// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3
251+
// keeps a total "right" the moment a sibling grows.
252+
//
253+
// The count is a FLOOR, not an equality — adding cases is ordinary work and must
254+
// not red. A battery BELOW its floor means cases stopped running; the remedy is
255+
// to find what stopped registering.
256+
const SELF_TEST_BATTERIES = Object.freeze({
257+
'check-dts-emitted self-test': 8,
258+
});
259+
260+
// DELETING an entry silences that battery's floor exactly as effectively as
261+
// zeroing it, so the roster's own size is pinned too.
262+
const SELF_TEST_BATTERY_FLOOR = 1;
263+
264+
// The key an assertion is filed under when no battery is open. It is not a
265+
// declared battery, so it reds by the same set difference rather than silently
266+
// inflating whichever battery happened to run last.
267+
const UNATTRIBUTED_BATTERY = '(no battery open)';
268+
236269
function selfTest() {
270+
// The battery ledger this self-test's floor is evaluated against (#13489).
271+
// `battery()` opens a battery; every assertion below is attributed to the one
272+
// most recently opened, so a section that stops running stops registering and
273+
// names ITSELF at the floor rather than going quiet.
274+
const batterySeen = new Map();
275+
let openBattery = null;
276+
const battery = (name) => {
277+
openBattery = name;
278+
};
279+
const registerCase = () => {
280+
const b = openBattery ?? UNATTRIBUTED_BATTERY;
281+
batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1);
282+
};
283+
battery('check-dts-emitted self-test');
237284
const failures = [];
238285
const eq = (label, actual, expected) => {
286+
registerCase();
239287
const a = JSON.stringify(actual);
240288
const e = JSON.stringify(expected);
241289
if (a !== e) failures.push(`${label}\n expected ${e}\n actual ${a}`);
@@ -295,6 +343,50 @@ function selfTest() {
295343
['empty:dist/index.d.ts'],
296344
);
297345

346+
// ── The floor: every declared battery RAN, and ran its cases (#13489) ────
347+
//
348+
// Evaluated after every battery has had its chance and BEFORE the verdict, so
349+
// the success line below can only be printed by a run in which the set of
350+
// batteries that registered assertions EQUALS the set declared. A set
351+
// difference names WHICH battery stopped; a count says only that something did.
352+
const floorFailure = (message) => { failures.push(message); };
353+
const declaredBatteries = Object.keys(SELF_TEST_BATTERIES);
354+
let floorBreached = false;
355+
if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) {
356+
floorBreached = true;
357+
floorFailure(
358+
`SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned `
359+
+ `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`,
360+
);
361+
}
362+
for (const [name, count] of batterySeen) {
363+
if (declaredBatteries.includes(name)) continue;
364+
floorBreached = true;
365+
floorFailure(
366+
`self-test battery "${name}" registered ${count} case(s) but is not declared in `
367+
+ 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.',
368+
);
369+
}
370+
for (const name of declaredBatteries) {
371+
const count = batterySeen.get(name) ?? 0;
372+
if (count >= SELF_TEST_BATTERIES[name]) continue;
373+
floorBreached = true;
374+
floorFailure(
375+
count === 0
376+
? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. `
377+
+ 'The verdict below would have claimed those cases hold.'
378+
: `self-test battery "${name}" registered ${count} case(s), below its pinned floor of `
379+
+ `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`,
380+
);
381+
}
382+
if (floorBreached) {
383+
floorFailure(
384+
'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the '
385+
+ 'number. Find what stopped registering (an early return, a deleted block, a guard that now '
386+
+ 'skips) and restore it.',
387+
);
388+
}
389+
298390
if (failures.length > 0) {
299391
console.error(`\nx check-dts-emitted self-test: ${failures.length} failure(s)\n`);
300392
for (const f of failures) console.error(` - ${f}\n`);

0 commit comments

Comments
 (0)