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
47 changes: 47 additions & 0 deletions .changeset/standalone-stack-stamps-env-local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
"@objectstack/metadata": patch
---

fix(runtime,metadata): the default local environment id is `env_local`, not `proj_local` (#13366)

The v5.0 `project` to `environment` rename changed the default local environment
id and shipped that change on the surfaces most people meet: `packages/cli`'s
`CHANGELOG.md` records "Default local env id: `proj_local` -> `env_local`", the
`os dev` / `os start` / `os serve` commands emit `env_local`, and
`content/docs/deployment/cli.mdx` documents `env_local` as the default. Two
sites never received it and kept stamping `proj_local`.

FROM: `createStandaloneStack()` — with no `environmentId` in its config and no
`OS_ENVIRONMENT_ID` in the environment — stamped `proj_local` on the kernel it
composed, and `MetadataPlugin` used `proj_local` to fill the environment-artifact
validation envelope for a bare definition.

TO: both stamp `env_local`.

WHO SEES IT. Two audiences, both on the DEFAULT path — no `environmentId` in
the config and no `OS_ENVIRONMENT_ID` in the environment:

1. a host that calls `createStandaloneStack` / `createDefaultHostConfig`
**directly**;
2. a **bare `os serve`** — one not spawned by `os dev` / `os start`. Those two
commands export `OS_ENVIRONMENT_ID=env_local` into the child process, which
the fallback yields to, so a boot they start never reached the changed line.
`os serve` sets no such variable for its own boot: it only READS one to name
the runtime state file. So a bare `os serve` used to run a kernel stamped
`proj_local` while publishing `runtime.env_local.json` beside it; the two now
agree.

Where the id is observable — row scoping in `ObjectQLPlugin`, the
`X-Environment-Id` header, `sys_metadata.environment_id` — such an embedder now
sees `env_local` where it saw `proj_local`, so an install with rows already
written under the old id should set `environmentId: 'proj_local'` (or
`OS_ENVIRONMENT_ID=proj_local`) explicitly to keep them addressed. That escape
hatch is unchanged and still wins over the default.

NOT CHANGED, deliberately: `@objectstack/cloud-connection` still treats BOTH
spellings as the local sentinel, so a persisted `OS_ENVIRONMENT_ID=proj_local`
config keeps being recognised as local rather than presented to the control
plane as a cloud environment id; and `package-state-store`'s separate `'default'`
fallback keeps its own spelling, because renaming it would re-key persisted
package-disable state files.
2 changes: 1 addition & 1 deletion packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,7 @@ export class MetadataPlugin implements Plugin {
const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label));
const canonical = JSON.stringify(def, Object.keys(def).sort());
const checksum = createHash('sha256').update(canonical).digest('hex');
const environmentId = this.options.environmentId ?? 'proj_local';
const environmentId = this.options.environmentId ?? 'env_local';
EnvironmentArtifactSchema.parse({
schemaVersion: '0.1',
environmentId,
Expand Down
111 changes: 111 additions & 0 deletions packages/runtime/src/standalone-stack-default-environment-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#13366] The default environment id a standalone boot stamps, pinned at the
// place it is OBSERVABLE: the two plugins `createStandaloneStack` hands it to.
//
// Why this file exists at all. The v5.0 `project` to `environment` rename
// shipped the CLI default `env_local` — `packages/cli/CHANGELOG.md` records
// "Default local env id: `proj_local` -> `env_local`" and
// `content/docs/deployment/cli.mdx` documents `env_local` — but the runtime's
// own fallback kept stamping `proj_local`. Nothing pinned it, in either
// spelling, so `declared != enforced` held on a published default for a whole
// major line without one test going red. That is the gap this closes: the
// literal now has an assertion attached to the code path that emits it.
//
// It reads the id off `result.plugins` rather than off a copy of the constant,
// because the value is only interesting where it LANDS. `MetadataPlugin` takes
// it as `options.environmentId` and `ObjectQLPlugin` as a row-scope key; a
// pin that re-declared the string would stay green through a change that
// stopped passing it to either.
//
// ⛔ These cases must NOT be read as "the CLI default". `os dev` / `os start`
// export `OS_ENVIRONMENT_ID` into the child boot, so a CLI-spawned kernel never
// reaches this fallback — the CLI's own default is pinned separately (the
// `runtime.env_local.json` publication tests in packages/cli). What this file
// owns is the DIRECT-EMBEDDER path: `createStandaloneStack()` with no config
// and no env var, which is the surface a `createStandaloneStack` host observes.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStandaloneStack } from './standalone-stack.js';

const BOOT_TIMEOUT = 60_000;

// The two plugin ids the stack composes. Matched by the plugin's own declared
// `name`, not by array position: the composition order is documented as a
// dependency-graph outcome elsewhere in this package, and an index would pin
// that instead of this.
const METADATA_PLUGIN = 'com.objectstack.metadata';
const OBJECTQL_PLUGIN = 'com.objectstack.engine.objectql';

/**
* The id as each plugin actually received it.
*
* `MetadataPlugin` keeps it under `options.environmentId`; `ObjectQLPlugin`
* copies it to its own `environmentId` field. Both are TypeScript-private —
* hence the casts — and reading them is deliberate: they are the last point at
* which the stamped value is still identifiable before it dissolves into row
* scoping and an artifact-validation envelope.
*/
function stampedIds(plugins: any[]): { metadata: unknown; objectql: unknown } {
const metadata = plugins.find((p) => p?.name === METADATA_PLUGIN);
const objectql = plugins.find((p) => p?.name === OBJECTQL_PLUGIN);
expect(metadata, `stack must carry ${METADATA_PLUGIN}`).toBeDefined();
expect(objectql, `stack must carry ${OBJECTQL_PLUGIN}`).toBeDefined();
return {
metadata: (metadata as any).options?.environmentId,
objectql: (objectql as any).environmentId,
};
}

describe('[#13366] createStandaloneStack — default environment id', () => {
let dir: string;
let savedEnvId: string | undefined;
let savedHome: string | undefined;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-standalone-envid-'));
savedEnvId = process.env.OS_ENVIRONMENT_ID;
savedHome = process.env.OS_HOME;
delete process.env.OS_ENVIRONMENT_ID;
process.env.OS_HOME = dir;
});

afterEach(() => {
if (savedEnvId === undefined) delete process.env.OS_ENVIRONMENT_ID;
else process.env.OS_ENVIRONMENT_ID = savedEnvId;
if (savedHome === undefined) delete process.env.OS_HOME;
else process.env.OS_HOME = savedHome;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
});

it('stamps `env_local` when neither the config nor OS_ENVIRONMENT_ID names one', async () => {
const stack = await createStandaloneStack({ databaseUrl: 'memory://standalone-envid-default' });
// The literal, at both landing sites. `proj_local` here is the pre-#13366
// value and is what this case exists to keep from coming back.
expect(stampedIds(stack.plugins)).toEqual({ metadata: 'env_local', objectql: 'env_local' });
}, BOOT_TIMEOUT);

it('OS_ENVIRONMENT_ID still overrides the default', async () => {
process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment';
const stack = await createStandaloneStack({ databaseUrl: 'memory://standalone-envid-env' });
expect(stampedIds(stack.plugins)).toEqual({
metadata: 'env_from_the_environment',
objectql: 'env_from_the_environment',
});
}, BOOT_TIMEOUT);

it('an explicit `cfg.environmentId` still outranks OS_ENVIRONMENT_ID', async () => {
process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment';
const stack = await createStandaloneStack({
environmentId: 'env_from_the_config',
databaseUrl: 'memory://standalone-envid-cfg',
});
expect(stampedIds(stack.plugins)).toEqual({
metadata: 'env_from_the_config',
objectql: 'env_from_the_config',
});
}, BOOT_TIMEOUT);
});
8 changes: 4 additions & 4 deletions packages/runtime/src/standalone-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ export const StandaloneStackConfigSchema = z.object({
* Defaults to `true`, and that default is the fix: a standalone kernel
* OWNS its local platform tables, which is what the gate in
* `assembleMetadataProtocol` always meant to say. It used to deduce that
* from `environmentId === undefined`, and line ~515 below stamps
* `'proj_local'` on every boot — so the block never ran and #8686's
* from `environmentId === undefined`, and line ~567 below stamps
* `'env_local'` on every boot — so the block never ran and #8686's
* "covers every existing deployment" half covered no self-hosted install
* at all.
*
Expand Down Expand Up @@ -564,7 +564,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
const { DefaultDatasourcePlugin } = await import('./default-datasource-plugin.js');
const { AppPlugin } = await import('./app-plugin.js');

const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? 'proj_local';
const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? 'env_local';
const artifactPath = resolveArtifactPathInput(cfg);

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