Skip to content

Commit d4249f4

Browse files
committed
fix(seed): enforce Seed.env against the runtime environment
`Seed.env` was authorable, defaulted and type-checked, but inert. `SeedLoaderService.load` filtered on the LOADER CONFIG's `env`, and none of the six call sites that build a `SeedLoaderRequest` (app boot, per-org replay, hot reload, package apply, draft publish, marketplace install) ever passed one — so `config.env` was always undefined, `filterByEnv` short-circuited, and `dataset.env` was never read at all. A dataset marked `env: ['dev']` seeded into production exactly as if it were marked `['prod']`. Resolve the environment in the loader — the one funnel every seeding path goes through — from NODE_ENV, the environment source this repo already uses everywhere (`os start` defaults it to production, `os dev` sets development, vitest sets test). No new env var, no new authorable key. An explicit `config.env` still wins. When the environment cannot be determined, stay permissive (seed everything, as before) but WARN, naming each environment-scoped dataset and the remedy. Fail-closed would also drop `env: ['prod']` datasets on a host that merely forgot to export NODE_ENV — silent data loss worse than the over-seeding it prevents. Skipped datasets are always named in an info log. Fixes #4704 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
1 parent 9c040f1 commit d4249f4

3 files changed

Lines changed: 464 additions & 3 deletions

File tree

.changeset/seed-env-enforced.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(seed): enforce `Seed.env` — environment-scoped datasets no longer seed everywhere
6+
7+
`Seed.env` was authorable, defaulted and type-checked, but inert. `SeedLoaderService`
8+
filtered on the **loader config's** `env`, and none of the six call sites that build a
9+
`SeedLoaderRequest` (app boot, per-org replay, hot reload, package apply, draft publish,
10+
marketplace install) ever passed one — so `config.env` was always `undefined`, the filter
11+
short-circuited, and `dataset.env` was never read. A dataset marked `env: ['dev']` seeded
12+
into production exactly as if it were marked `['prod']`, which is the dangerous direction:
13+
the rows most likely to carry that marking are demo users, fake customers and seeded
14+
credentials.
15+
16+
The loader now resolves the environment itself, at the one funnel every seeding path goes
17+
through:
18+
19+
- **Source is `NODE_ENV`** — the environment source this repo already uses everywhere
20+
(`os start` defaults it to `production`, `os dev` / `serve --dev` set `development`,
21+
vitest sets `test`). No new environment variable and no new authorable key. `production`
22+
/ `development` / `test` and the seed-enum spellings `prod` / `dev` are accepted,
23+
case-insensitively.
24+
- **An explicit `config.env` still wins**, so a host can seed "as" another environment.
25+
- **A dataset that declares no `env`** (the schema default `['prod','dev','test']`) seeds
26+
in every environment, exactly as before — no existing deployment loses rows.
27+
- **When the environment cannot be determined** (NODE_ENV unset, or a value like
28+
`staging`), the loader stays permissive and seeds everything — but logs a **warning**
29+
naming each environment-scoped dataset, the accepted `NODE_ENV` values and the
30+
`config.env` escape hatch. Fail-open is deliberate: fail-closed would also drop an
31+
`env: ['prod']` dataset on a production host that merely forgot to export `NODE_ENV`,
32+
a silent data-loss regression worse than the over-seeding it prevents.
33+
- **Skipped datasets are always named** in an `info` log, so "my demo rows are missing" is
34+
one log line to answer rather than a mystery.
35+
36+
The resolved environment is also what seed CEL expressions now bind `env` to, so a seed's
37+
`env` and the loader's filter can no longer disagree.
38+
39+
No API or schema change: `Seed.env` and `SeedLoaderConfig.env` are unchanged, and no
40+
package export was added.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { SeedLoaderService } from './seed-loader.js';
5+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
7+
/**
8+
* `Seed.env` is ENFORCED, not merely authorable (framework#4704).
9+
*
10+
* The key was authorable, defaulted and type-checked for releases while being
11+
* completely inert: `filterByEnv` gated on the LOADER CONFIG's env, and none of
12+
* the six call sites that build a `SeedLoaderRequest` (app boot, per-org
13+
* replay, hot reload, package apply, draft publish, marketplace install) ever
14+
* passed one — so `config.env` was always `undefined`, the filter
15+
* short-circuited, and `dataset.env` was never read. A dataset marked
16+
* `env: ['dev']` seeded into production exactly as if it were marked
17+
* `['prod']` — and the rows most likely to carry that marking are demo users,
18+
* fake customers and seeded credentials.
19+
*
20+
* The loader now resolves the environment itself, from NODE_ENV — the repo's
21+
* one established environment source (`os start` defaults it to `production`,
22+
* `os dev` sets `development`, vitest sets `test`).
23+
*
24+
* These tests pin the three outcomes SEPARATELY, because a fix that only
25+
* proved the first would be indistinguishable from one that dropped every
26+
* dataset, or from one that dropped none:
27+
* 1. scoped dataset + matching environment → seeded
28+
* 2. scoped dataset + non-matching environment → NOT seeded
29+
* 3. unscoped dataset (schema default) → seeded everywhere
30+
* 4. environment indeterminate → seeded, but LOUDLY
31+
*/
32+
33+
function createLogger() {
34+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
35+
}
36+
37+
function createEngine() {
38+
const store: Record<string, any[]> = {};
39+
let idCounter = 0;
40+
41+
const engine = {
42+
find: vi.fn(async (objectName: string, query?: any) => {
43+
let records = store[objectName] || [];
44+
if (query?.where) {
45+
records = records.filter((r) =>
46+
Object.entries(query.where).every(([k, v]) => r[k] === v),
47+
);
48+
}
49+
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
50+
return records;
51+
}),
52+
findOne: vi.fn(async () => null),
53+
insert: vi.fn(async (objectName: string, data: any) => {
54+
if (!store[objectName]) store[objectName] = [];
55+
if (Array.isArray(data)) {
56+
const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d }));
57+
store[objectName].push(...records);
58+
return records;
59+
}
60+
const record = { id: `gen-${++idCounter}`, ...data };
61+
store[objectName].push(record);
62+
return record;
63+
}),
64+
update: vi.fn(async (_o: string, data: any) => data),
65+
delete: vi.fn(async () => ({ deleted: 1 })),
66+
count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
67+
aggregate: vi.fn(async () => []),
68+
} as unknown as IDataEngine;
69+
70+
return { engine, store };
71+
}
72+
73+
function createMetadata(): IMetadataService {
74+
const objects: Record<string, any> = {
75+
account: { name: 'account', fields: { name: { type: 'text' } } },
76+
demo_user: { name: 'demo_user', fields: { name: { type: 'text' } } },
77+
};
78+
return {
79+
getObject: vi.fn(async (name: string) => objects[name]),
80+
listObjects: vi.fn(async () => Object.values(objects)),
81+
register: vi.fn(async () => {}),
82+
get: vi.fn(async () => undefined),
83+
list: vi.fn(async () => []),
84+
unregister: vi.fn(async () => {}),
85+
exists: vi.fn(async () => false),
86+
listNames: vi.fn(async () => []),
87+
} as unknown as IMetadataService;
88+
}
89+
90+
const BASE_CONFIG = {
91+
dryRun: false,
92+
haltOnError: false,
93+
multiPass: true,
94+
defaultMode: 'upsert',
95+
batchSize: 1000,
96+
transaction: false,
97+
} as any;
98+
99+
/** A production-safe dataset (schema default: every environment). */
100+
const ACCOUNT_DATASET = {
101+
object: 'account',
102+
externalId: 'name',
103+
mode: 'upsert',
104+
env: ['prod', 'dev', 'test'],
105+
records: [{ name: 'Acme Corporation' }],
106+
};
107+
108+
/** The dangerous kind: demo rows the author scoped to development only. */
109+
const DEMO_DATASET = {
110+
object: 'demo_user',
111+
externalId: 'name',
112+
mode: 'upsert',
113+
env: ['dev'],
114+
records: [{ name: 'Demo Person' }],
115+
};
116+
117+
async function seedUnder(nodeEnv: string | undefined, seeds: any[], configOverrides: any = {}) {
118+
if (nodeEnv === undefined) delete process.env.NODE_ENV;
119+
else process.env.NODE_ENV = nodeEnv;
120+
121+
const { engine, store } = createEngine();
122+
const logger = createLogger();
123+
const result = await new SeedLoaderService(engine, createMetadata(), logger).load({
124+
seeds,
125+
config: { ...BASE_CONFIG, ...configOverrides },
126+
} as any);
127+
128+
return { result, store, logger };
129+
}
130+
131+
const seededObjects = (store: Record<string, any[]>) => Object.keys(store).sort();
132+
133+
describe('Seed.env is enforced against the runtime environment (#4704)', () => {
134+
let savedNodeEnv: string | undefined;
135+
136+
beforeEach(() => {
137+
savedNodeEnv = process.env.NODE_ENV;
138+
});
139+
140+
afterEach(() => {
141+
if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
142+
else process.env.NODE_ENV = savedNodeEnv;
143+
});
144+
145+
// ── 1. The regression itself ────────────────────────────────────────────
146+
147+
it('does NOT seed an env:[dev] dataset under NODE_ENV=production', async () => {
148+
const { result, store } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]);
149+
150+
expect(seededObjects(store)).toEqual(['account']);
151+
expect(store.demo_user).toBeUndefined();
152+
expect(result.summary.objectsProcessed).toBe(1);
153+
expect(result.summary.totalInserted).toBe(1);
154+
});
155+
156+
it('DOES seed the same env:[dev] dataset under NODE_ENV=development', async () => {
157+
const { result, store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]);
158+
159+
expect(seededObjects(store)).toEqual(['account', 'demo_user']);
160+
expect(store.demo_user).toHaveLength(1);
161+
expect(result.summary.objectsProcessed).toBe(2);
162+
expect(result.summary.totalInserted).toBe(2);
163+
});
164+
165+
// The two above must DIFFER — a fix that dropped everything, or nothing,
166+
// would satisfy one of them alone.
167+
it('produces a different result in production than in development', async () => {
168+
const prod = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]);
169+
const dev = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]);
170+
171+
expect(seededObjects(prod.store)).not.toEqual(seededObjects(dev.store));
172+
expect(prod.result.summary.totalInserted).toBeLessThan(dev.result.summary.totalInserted);
173+
});
174+
175+
// ── 2. Unscoped datasets are untouched (no behaviour change) ────────────
176+
177+
it('seeds a dataset carrying the schema default in EVERY environment', async () => {
178+
for (const nodeEnv of ['production', 'development', 'test']) {
179+
const { store } = await seedUnder(nodeEnv, [ACCOUNT_DATASET]);
180+
expect(store.account, `account should seed under NODE_ENV=${nodeEnv}`).toHaveLength(1);
181+
}
182+
});
183+
184+
it('treats a dataset with no env key at all as unrestricted (schema default)', async () => {
185+
const noEnv = { object: 'account', externalId: 'name', mode: 'upsert', records: [{ name: 'Acme Corporation' }] };
186+
const { store } = await seedUnder('production', [noEnv]);
187+
188+
expect(store.account).toHaveLength(1);
189+
});
190+
191+
it('honours a multi-environment scope that includes the running environment', async () => {
192+
const prodAndTest = { ...DEMO_DATASET, env: ['prod', 'test'] };
193+
const underProd = await seedUnder('production', [prodAndTest]);
194+
const underDev = await seedUnder('development', [prodAndTest]);
195+
196+
expect(underProd.store.demo_user).toHaveLength(1);
197+
expect(underDev.store.demo_user).toBeUndefined();
198+
});
199+
200+
// ── 3. Indeterminate environment: permissive, but loud ──────────────────
201+
202+
it('seeds everything BUT warns loudly when NODE_ENV is unset', async () => {
203+
const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]);
204+
205+
// Permissive: pre-fix behaviour preserved, no deployment silently loses rows.
206+
expect(seededObjects(store)).toEqual(['account', 'demo_user']);
207+
208+
// Loud: names the datasets AND the remedy.
209+
const warning = logger.warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Cannot determine the runtime environment'));
210+
expect(warning).toBeDefined();
211+
expect(warning).toContain('demo_user');
212+
expect(warning).toContain('NODE_ENV');
213+
expect(warning).toContain('config.env');
214+
// The unscoped dataset is not the operator's problem — don't name it.
215+
expect(warning).not.toContain('account (env:');
216+
});
217+
218+
it('warns the same way for a NODE_ENV that names no seed environment', async () => {
219+
const { store, logger } = await seedUnder('staging', [DEMO_DATASET]);
220+
221+
expect(store.demo_user).toHaveLength(1);
222+
expect(
223+
logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')),
224+
).toBe(true);
225+
});
226+
227+
it('stays SILENT when the environment is indeterminate but nothing is scoped', async () => {
228+
const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET]);
229+
230+
expect(store.account).toHaveLength(1);
231+
expect(
232+
logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')),
233+
).toBe(false);
234+
});
235+
236+
// The three indeterminate/determinate outcomes must be mutually distinguishable.
237+
it('distinguishes filtered, permissive-and-warned, and clean loads', async () => {
238+
const filtered = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]);
239+
const permissive = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]);
240+
const clean = await seedUnder('production', [ACCOUNT_DATASET]);
241+
242+
const warned = (l: any) =>
243+
l.warn.mock.calls.some((c: any[]) => String(c[0]).includes('Cannot determine the runtime environment'));
244+
245+
expect([seededObjects(filtered.store), warned(filtered.logger)]).toEqual([['account'], false]);
246+
expect([seededObjects(permissive.store), warned(permissive.logger)]).toEqual([['account', 'demo_user'], true]);
247+
expect([seededObjects(clean.store), warned(clean.logger)]).toEqual([['account'], false]);
248+
});
249+
250+
// ── 4. Explicit config.env still wins ───────────────────────────────────
251+
252+
it('lets an explicit config.env override NODE_ENV', async () => {
253+
// Host says "seed as production" while running under a dev NODE_ENV.
254+
const { store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET], { env: 'prod' });
255+
256+
expect(seededObjects(store)).toEqual(['account']);
257+
});
258+
259+
it('accepts the seed-enum spellings of NODE_ENV', async () => {
260+
const { store } = await seedUnder('prod', [ACCOUNT_DATASET, DEMO_DATASET]);
261+
expect(seededObjects(store)).toEqual(['account']);
262+
263+
const dev = await seedUnder('DEV', [ACCOUNT_DATASET, DEMO_DATASET]);
264+
expect(seededObjects(dev.store)).toEqual(['account', 'demo_user']);
265+
});
266+
267+
// ── 5. Skipping is reported, never mysterious ───────────────────────────
268+
269+
it('names every skipped dataset so missing demo rows are one log line to explain', async () => {
270+
const { logger } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]);
271+
272+
const info = logger.info.mock.calls.map((c) => String(c[0])).find((m) => m.includes('skipped'));
273+
expect(info).toBeDefined();
274+
expect(info).toContain('demo_user');
275+
expect(info).toContain("'prod'");
276+
});
277+
});

0 commit comments

Comments
 (0)