diff --git a/.changeset/standalone-stack-stamps-env-local.md b/.changeset/standalone-stack-stamps-env-local.md new file mode 100644 index 0000000000..ff2dac16a4 --- /dev/null +++ b/.changeset/standalone-stack-stamps-env-local.md @@ -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. diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 07f30140b1..84af55bd2c 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -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, diff --git a/packages/runtime/src/standalone-stack-default-environment-id.test.ts b/packages/runtime/src/standalone-stack-default-environment-id.test.ts new file mode 100644 index 0000000000..7ee57159d3 --- /dev/null +++ b/packages/runtime/src/standalone-stack-default-environment-id.test.ts @@ -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); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index e6a29257ef..a1645f87c3 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -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. * @@ -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 @@ -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