Skip to content

Commit a23603e

Browse files
os-litantclaude
andauthored
fix(runtime,metadata): stamp env_local as the default local environment id (#15200)
* fix(runtime,metadata): stamp env_local as the default local environment id Finish the v5.0 `project` to `environment` rename at the two sites it never reached. `packages/cli/CHANGELOG.md` records "Default local env id: `proj_local` -> `env_local`" and `content/docs/deployment/cli.mdx` documents `env_local`, while `createStandaloneStack` and `MetadataPlugin` still stamped `proj_local` — declared != enforced on a published default. - `packages/runtime/src/standalone-stack.ts`: the boot fallback, plus the two in-file comments that narrate what this stack stamps (one of them also carried a stale line anchor, ~515 -> ~567). - `packages/metadata/src/plugin.ts`: the placeholder that fills the `EnvironmentArtifactSchema` validation envelope for a bare definition. Adds the pin the value never had: the id is asserted where it LANDS, on the `MetadataPlugin` and `ObjectQLPlugin` instances the stack composes, together with the precedence above it (`cfg.environmentId` > `OS_ENVIRONMENT_ID` > default). `cloud-connection`'s dual accept and `package-state-store`'s separate `'default'` sanitiser fallback are deliberately untouched. Part of #13366 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore: add changeset for the env_local default stamp Part of #13366 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(changeset): name the bare `os serve` boot as a second affected audience `os serve` sets no OS_ENVIRONMENT_ID for its own boot — it only reads one to name the runtime state file — so a bare `os serve` reaches the changed default too, not only a direct `createStandaloneStack` embedder. Measured at `serve.ts:524` (read-only) against `dev.ts:380` / `start.ts:409` (which export). Part of #13366 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54db16d commit a23603e

4 files changed

Lines changed: 163 additions & 5 deletions

File tree

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

0 commit comments

Comments
 (0)