From b2df2aecc5c4ef2fa8791b1b342ed410d9a5b0b7 Mon Sep 17 00:00:00 2001 From: os-warren Date: Tue, 1 Sep 2026 10:08:03 +0000 Subject: [PATCH] Make the demo seed opt-in, default off, and add `pnpm demo` Duly is meant to be a general product, and a general product does not install 459 rows of a fictional manufacturer's org chart into every fresh deployment. Someone evaluating Duly for their own company wants an empty app to put their own duties into; someone evaluating the idea wants the demo. Those are two intentions, and now they are two commands. - `src/data/index.ts` gates the whole demo array on `DULY_DEMO_SEED`. Off means genuinely empty, not "empty of users". - `scripts/demo.mjs` (`pnpm demo`) sequences the two boots this needs on a brand-new database and VERIFIES the handover with a real sign-in, so a half-finished run fails loudly instead of looking like it worked. - Both `dev` and `demo` boot with `--compile`: the gate is read at compile time and `os dev` otherwise reuses whichever artifact is on disk. - `test/demo-seed-opt-in.test.ts` fails if the default path stops being empty; `test/seed.test.ts` now opts in explicitly. - README documents the two commands as first-class choices. The login bug on a clean `git clone && pnpm dev` falls out of this for free: with no `sys_user` rows in the default seed the database is zero-user at `kernel:ready`, so `plugin-auth` mints the dev admin exactly as documented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- README.md | 28 +++- package.json | 3 +- scripts/demo.mjs | 242 ++++++++++++++++++++++++++++++++++ src/data/index.ts | 93 ++++++++++++- test/demo-seed-opt-in.test.ts | 115 ++++++++++++++++ test/seed.test.ts | 46 ++++++- 6 files changed, 514 insertions(+), 13 deletions(-) create mode 100755 scripts/demo.mjs create mode 100644 test/demo-seed-opt-in.test.ts diff --git a/README.md b/README.md index 6c946f7..e4b13bf 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,32 @@ pnpm dev The Console is at `http://localhost:3000/_console/`, the REST API at `http://localhost:3000/api/v1`, and the app is itself an MCP server at -`/api/v1/mcp`. +`/api/v1/mcp`. Sign in as `admin@objectos.ai` / `admin123`. + +### Two ways to start it + +Duly ships empty. Evaluating it *for your own organisation* and evaluating *the +idea* are different things, so they are different commands: + +| Command | What you get | +|:---|:---| +| `pnpm dev` | An **empty Duly**. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from. | +| `pnpm demo` | The same app **preloaded with a worked example**: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen. | + +`pnpm demo` prepares the database and then starts the server; it is one +command and it works on a clean checkout. Everything it writes is ordinary +data, so you can edit or delete any of it. + +To go back to an empty app, delete the local database and start again: + +```bash +rm -rf .objectstack/data +pnpm dev +``` + +Nothing about the fictional organisation is real: every address is on an +RFC 2606 reserved domain, and no real company, person, site or regulation is +named anywhere in it. Every metadata directory is pre-wired into `objectstack.config.ts`, empty ones included: add your entry to the named array in your own `src//index.ts` and @@ -75,6 +100,7 @@ src/datasets/ src/dashboards/ the semantic layer and what reads it src/security/ positions, permission sets, sharing rules src/mappings/ src/data/ catalog import and seed fixtures src/translations/ en (source) · zh-CN +scripts/ pnpm demo — prepare the database, then start with the example loaded docs/product/ positioning, data model, design principles ``` diff --git a/package.json b/package.json index 77d46e4..1a2b123 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "description": "Duly — recurring obligation and duty management on ObjectStack.", "license": "Apache-2.0", "scripts": { - "dev": "objectstack dev", + "dev": "objectstack dev --compile", + "demo": "node scripts/demo.mjs", "start": "objectstack start", "build": "objectstack build", "validate": "objectstack validate", diff --git a/scripts/demo.mjs b/scripts/demo.mjs new file mode 100755 index 0000000..f183174 --- /dev/null +++ b/scripts/demo.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `pnpm demo` — start Duly with the demo organisation loaded, in ONE command, +// on a clean checkout. +// +// ── Why this is a script and not `objectstack dev` with a flag ───────────── +// +// The demo dataset is opt-in and off by default (see src/data/index.ts for the +// product reasoning). Turning it on is one environment variable, and on a +// database that already has an account that is genuinely all it takes. +// +// On a BRAND-NEW database it is not. `@objectstack/plugin-auth` mints the dev +// admin on a zero-user database from the `kernel:ready` hook, which fires +// after the declarative seed has already run — so a first boot that carries +// the demo's thirteen `sys_user` rows is never zero-user by the time that +// check runs, the admin is never created, and because the check is "any human +// row exists" it is never created on a later boot either. The result looks +// like a working app with no way into it. +// +// So this sequences the two boots the evaluator would otherwise have to know +// about: once with the demo OFF, which leaves the database zero-user long +// enough for the admin to be minted, then again with it ON. The first boot is +// quiet. The handover between them is VERIFIED — a real sign-in against the +// priming server — because the failure this guards against is a seeded +// database with no login, which is worse than the bug it replaces: it looks +// like it worked. +// +// Filed upstream as objectstack-ai/objectstack#14157. When that lands, the +// priming boot below is deleted and this file becomes one spawn. +// +// ── Why both boots pass `--compile` ──────────────────────────────────────── +// +// The seed is baked into `dist/objectstack.json` at compile time, and `os dev` +// reuses an existing artifact rather than recompiling it (`--compile` defaults +// to false; it auto-compiles only when the artifact is MISSING). Without +// `--compile` the second boot here would serve the artifact the priming boot +// just built — the one with no seed — and `pnpm demo` would print success and +// show an empty app. `pnpm dev` passes it for the mirror-image reason. + +import { spawn } from 'node:child_process'; +import { createServer } from 'node:net'; + +const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; + +// The same credentials and the same env overrides `@objectstack/plugin-auth` +// itself reads, so an operator who has changed them is not silently probing +// for an account that was never going to exist. +const ADMIN_EMAIL = process.env.OS_SEED_ADMIN_EMAIL?.trim() || 'admin@objectos.ai'; +const ADMIN_PASSWORD = process.env.OS_SEED_ADMIN_PASSWORD?.trim() || 'admin123'; + +/** How long the priming boot gets to come up and mint the admin. */ +const PRIMING_TIMEOUT_MS = 180_000; +/** How long a SIGTERM gets to bring the priming boot down before SIGKILL. */ +const SHUTDOWN_GRACE_MS = 10_000; +/** Tail of the priming boot's output kept for the failure path. */ +const LOG_TAIL_LINES = 40; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * A port the priming server can have to itself. + * + * The priming boot must not land on the port the user is about to run the demo + * on, and it must not collide with whatever else is already listening — so it + * asks the OS for a free one rather than guessing. `os dev` auto-shifts off a + * busy port, which would leave the sign-in probe below talking to nothing. + */ +const freePort = () => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); + +/** + * The handover check: can you actually log in yet? + * + * Not "did the server start" and not "did a line appear in the log" — the + * whole point of the priming boot is a loginable account, so that is what is + * asserted. `localhost` (not `127.0.0.1`) with a matching `Origin`: dev trusts + * `http://localhost:*`, and better-auth rejects anything else with 403 + * INVALID_ORIGIN. + */ +const canSignIn = async (port) => { + const origin = `http://localhost:${port}`; + try { + const response = await fetch(`${origin}/api/v1/auth/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + signal: AbortSignal.timeout(5_000), + }); + return response.status === 200; + } catch { + // Not up yet, or up and not answering. Either way: not ready. + return false; + } +}; + +const fail = (headline, detail, log) => { + console.error(''); + console.error(`❌ pnpm demo failed — ${headline}`); + console.error(''); + for (const line of detail) console.error(` ${line}`); + if (log.length) { + console.error(''); + console.error(` Last ${Math.min(log.length, LOG_TAIL_LINES)} lines of the priming boot:`); + console.error(''); + for (const line of log.slice(-LOG_TAIL_LINES)) console.error(` | ${line}`); + } + console.error(''); + process.exit(1); +}; + +/** + * Boot once with the demo OFF, wait until the dev admin can sign in, stop. + * + * Idempotent: on a database that already has an account this boot mints + * nothing, the first probe succeeds, and it costs one short boot. + */ +const primeAdminAccount = async () => { + const port = await freePort(); + + // Deleted rather than set to a falsy string: this must be off regardless of + // how the gate spells "off", and regardless of what the operator exported. + const env = { ...process.env }; + delete env[DEMO_SEED_ENV_VAR]; + + const child = spawn('objectstack', ['dev', '--compile', '--port', String(port)], { + env, + // Quiet, but kept: nothing is printed unless the sequence fails, and then + // all of it is. + stdio: ['ignore', 'pipe', 'pipe'], + // Its own process group, so the whole tree goes down with it. `os dev` + // spawns a `serve` child; signalling only the parent orphans the server + // and leaves it holding the port and the database. + detached: true, + }); + + const log = []; + const collect = (chunk) => { + for (const line of String(chunk).split('\n')) if (line.trim()) log.push(line.trimEnd()); + }; + child.stdout.on('data', collect); + child.stderr.on('data', collect); + + let exited = null; + child.once('exit', (code, signal) => { + exited = { code, signal }; + }); + child.once('error', (error) => { + exited = { code: null, signal: null, error }; + }); + + const stop = async () => { + if (exited) return; + const down = new Promise((resolve) => child.once('exit', resolve)); + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + /* already gone */ + } + let settled = false; + await Promise.race([down.then(() => { settled = true; }), sleep(SHUTDOWN_GRACE_MS)]); + if (settled) return; + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + /* already gone */ + } + await Promise.race([down, sleep(2_000)]); + }; + + const deadline = Date.now() + PRIMING_TIMEOUT_MS; + while (Date.now() < deadline) { + if (exited) { + fail( + 'the preparation step exited before an admin account existed.', + [ + exited.error + ? `Could not start \`objectstack dev\`: ${exited.error.message}` + : `\`objectstack dev\` exited with ${exited.signal ? `signal ${exited.signal}` : `code ${exited.code}`}.`, + 'Nothing was seeded. Fix the error above and run `pnpm demo` again.', + ], + log, + ); + } + if (await canSignIn(port)) { + await stop(); + return; + } + await sleep(1_000); + } + + await stop(); + fail( + `no account could sign in after ${PRIMING_TIMEOUT_MS / 1000}s, so the demo was NOT loaded.`, + [ + `Expected \`${ADMIN_EMAIL}\` to be loginable after the preparation boot.`, + '', + 'The most likely cause is a database that already holds accounts from an', + 'earlier run, which stops a fresh dev admin from being created. Start over:', + '', + ' rm -rf .objectstack/data && pnpm demo', + '', + 'Nothing was seeded by this run — the database is exactly as it was.', + ], + log, + ); +}; + +/** Boot with the demo ON, in the foreground. This is the server you keep. */ +const startDemo = () => { + // Extra arguments are forwarded, so `pnpm demo -- --port 4000` works. + const passthrough = process.argv.slice(2); + const child = spawn('objectstack', ['dev', '--compile', ...passthrough], { + env: { ...process.env, [DEMO_SEED_ENV_VAR]: '1' }, + // Inherited, and NOT detached: the demo server shares this terminal's + // process group so Ctrl+C reaches it the way it would `pnpm dev`. + stdio: 'inherit', + }); + child.once('error', (error) => { + fail('the demo server could not be started.', [error.message], []); + }); + child.once('exit', (code, signal) => { + process.exit(signal ? 1 : (code ?? 0)); + }); +}; + +console.log(''); +console.log(' Duly demo — two steps, then the server is yours.'); +console.log(''); +console.log(' 1/2 preparing an admin account (quiet, a few seconds)…'); +await primeAdminAccount(); +console.log(' 1/2 done — admin account ready.'); +console.log(' 2/2 starting Duly with the demo organisation loaded…'); +console.log(''); +startDemo(); diff --git a/src/data/index.ts b/src/data/index.ts index 0667ed8..b0fd53b 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -41,7 +41,11 @@ export { }; /** - * The demo seed — what `pnpm dev` opens on with an empty database. + * The demo dataset — a fictional manufacturer, its org chart, what each role + * owes, and six months of history behind it. + * + * It is what `pnpm demo` opens on. It is NOT what `pnpm dev` opens on; see + * `dulySeeds` below for the gate and for why the default is empty. * * ── Order ───────────────────────────────────────────────────────────────── * The loader sorts datasets topologically by reference before it runs them, so @@ -62,12 +66,13 @@ export { * * ── Environment ─────────────────────────────────────────────────────────── * Every dataset takes `Seed.env`'s default — `['prod', 'dev', 'test']` — so - * the demo loads wherever the app is booted, which is what makes it testable - * as well as demonstrable. Scoping it to `dev` would be defensible for a - * shipping product; it is the wrong trade for an app whose entire purpose - * right now is to be looked at and evaluated. + * the demo loads wherever the app is booted once it has been ASKED for, which + * is what makes it testable as well as demonstrable. Scoping it to `dev` + * instead would answer a different question than the one the opt-in answers: + * `env` decides which deployments a seed is *eligible* for, `DULY_DEMO_SEED` + * decides whether this deployment wanted a demo at all. */ -export const dulySeeds: Seed[] = [ +export const demoSeeds: Seed[] = [ // 1. The org, first — everything below resolves its people and units here. businessUnitSeed, userSeed, @@ -88,3 +93,79 @@ export const dulySeeds: Seed[] = [ // 5. The personal work log — deliberately attached to nothing scoreable. logEntrySeed, ]; + +// ─── The demo is opt-in, and off by default ───────────────────────────────── +// +// `data` carries the demo only when `DULY_DEMO_SEED` is set. `pnpm demo` sets +// it; `pnpm dev` does not. +// +// This is a product decision, not a switch bolted on to route around a defect. +// Duly is meant to be a general product, and a general product does not +// install 459 rows of a fictional manufacturer's org chart into every fresh +// deployment. Someone evaluating Duly FOR THEIR OWN COMPANY wants an empty app +// they can put their own duties into — handing them somebody else's org chart +// to delete first is not a neutral default, it is a different product than the +// one they asked for. Someone evaluating THE IDEA wants the demo, fully +// populated, immediately. Those are two intentions, and they get two commands. +// +// "Off" means genuinely empty, not "empty of users". A default seed that still +// wrote duties would be the same product mistake at a smaller scale, so the +// gate wraps the WHOLE array rather than filtering rows out of it. +// +// ── What this also settles, and what changes when the platform lands ─────── +// +// `@objectstack/plugin-auth` mints the dev admin (`admin@objectos.ai`) on a +// ZERO-USER database, from the `kernel:ready` hook — which fires AFTER the +// app's declarative seed has run. While the demo's thirteen `sys_user` rows +// were in the default path, a clean `git clone && pnpm dev` was never +// zero-user by the time that check ran: the admin was never created, and +// because the gate is "any human row exists" it was never created on a later +// boot either. `sign-in` returned 401, `sys_account` was empty, and +// `bootstrap-status` reported `{"hasOwner": true}` so the console offered no +// first-admin flow to recover through. +// +// With the demo off by default that path is simply gone. The default boot +// leaves the database zero-user and the admin is minted exactly as +// documented — nobody typing `pnpm dev` has to know any of the above. +// +// The ordering itself is the platform's question and is filed as +// objectstack-ai/objectstack#14157. When it lands, exactly ONE thing here +// collapses: `pnpm demo` stops having to sequence two boots (see +// `scripts/demo.mjs`) and becomes a single boot with the flag set. This gate +// stays. It was never really about the bug. +// +// ── One mechanical detail: the flag is read at COMPILE time ──────────────── +// +// The seed is baked into `dist/objectstack.json`, so this gate is evaluated +// when the artifact is compiled — not when the server starts — and `os dev` +// reuses an existing artifact instead of recompiling it. Both `pnpm dev` and +// `pnpm demo` therefore boot with `--compile`, so every boot's artifact +// matches the flag that boot was started with. Without that, `pnpm demo` +// followed by `pnpm dev` would silently serve the previous run's demo +// artifact, and the default would not be a default. + +/** The environment variable that asks for the demo dataset. */ +export const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; + +// The one Node global this app reads. `@types/node` is deliberately not a +// dependency of a metadata package, so the single property the gate needs is +// declared narrowly and locally rather than pulling the whole Node type +// surface in for it. Module-scoped, so it shadows nothing globally. +declare const process: { env: Record }; + +/** Opt-in spellings. Anything else — including unset — means off. */ +const OPT_IN = ['1', 'true', 'on', 'yes']; + +/** Whether this compile was asked for the demo dataset. */ +export const demoSeedRequested = (): boolean => + OPT_IN.includes((process.env[DEMO_SEED_ENV_VAR] ?? '').trim().toLowerCase()); + +/** + * What `defineStack({ data })` installs: nothing at all, unless the demo was + * asked for. + * + * `test/demo-seed-opt-in.test.ts` fails if this stops being empty by default — + * which is what keeps the demo from drifting back into the default path one + * convenient row at a time. + */ +export const dulySeeds: Seed[] = demoSeedRequested() ? demoSeeds : []; diff --git a/test/demo-seed-opt-in.test.ts b/test/demo-seed-opt-in.test.ts new file mode 100644 index 0000000..43b75e5 --- /dev/null +++ b/test/demo-seed-opt-in.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEMO_SEED_ENV_VAR } from '../src/data/index.js'; + +/** + * The default seed is EMPTY, and stays empty. + * + * `pnpm dev` opens an empty Duly you put your own duties into; `pnpm demo` + * opens the fictional manufacturer. That split is a product decision (argued + * where the gate lives, in `src/data/index.ts`), and the thing that erodes it + * is not a rewrite — it is one convenient row at a time. A single + * "just the org units, they're harmless" dataset in the default path puts a + * stranger's company back into every fresh deployment, and, because + * `@objectstack/plugin-auth` only mints the dev admin on a ZERO-USER database, + * a single `sys_user` row there also takes the login with it. + * + * So this suite asserts the shape rather than the story: what the running app + * is handed by default, what the flag turns on, and that the flag is the only + * thing that turns it on. + * + * Note the assertions are against `stack.data` — what `defineStack` actually + * installs — and not only against the barrel's export. Wiring the config to + * `demoSeeds` instead of `dulySeeds` would leave every barrel-level assertion + * green and put all 459 rows back in the default path. + */ + +/** Datasets, whatever the surface calls them. */ +type Dataset = { object: string; records: readonly unknown[] }; + +const rowsIn = (datasets: readonly Dataset[]): number => + datasets.reduce((total, dataset) => total + dataset.records.length, 0); + +/** A fresh evaluation of the barrel, reading the environment as it is now. */ +const loadBarrel = async () => { + vi.resetModules(); + return import('../src/data/index.js'); +}; + +/** A fresh evaluation of the whole config, ditto. */ +const loadStackData = async (): Promise => { + vi.resetModules(); + const stack = (await import('../objectstack.config.js')).default; + return ((stack as { data?: readonly Dataset[] }).data ?? []) as readonly Dataset[]; +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the default seed is empty', () => { + it('installs no datasets at all when nothing asked for the demo', async () => { + const { dulySeeds } = await loadBarrel(); + // Not "no users" — nothing. A default seed that still wrote duties into a + // fresh deployment would be the same product mistake at a smaller scale. + expect(dulySeeds).toEqual([]); + }); + + it('and the stack the runtime boots declares no data either', async () => { + const data = await loadStackData(); + expect(rowsIn(data), 'rows the default `pnpm dev` writes into a fresh database').toBe(0); + expect(data.map((dataset) => dataset.object)).toEqual([]); + }); + + it('in particular it seeds no sys_user, which is what keeps the dev admin loginable', async () => { + // Named separately from the count above because this is the row whose + // presence is not merely off-message: it is what stopped the platform's + // zero-user admin seed from ever running on a clean checkout. + const data = await loadStackData(); + expect(data.filter((dataset) => dataset.object === 'sys_user')).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the flag turns it on', () => { + it('hands the whole demo to the stack when the demo is asked for', async () => { + vi.stubEnv(DEMO_SEED_ENV_VAR, '1'); + const { dulySeeds, demoSeeds } = await loadBarrel(); + // Identity, not equality: the gate selects the demo array, it does not + // build a second one that could drift away from it. + expect(dulySeeds).toBe(demoSeeds); + + const data = await loadStackData(); + expect(rowsIn(data)).toBe(rowsIn(demoSeeds as unknown as readonly Dataset[])); + }); + + it('and the demo is a real dataset, so "empty by default" cannot be met by deleting it', async () => { + // Without this, emptying `demoSeeds` would satisfy every assertion above. + vi.stubEnv(DEMO_SEED_ENV_VAR, '1'); + const data = await loadStackData(); + expect(rowsIn(data), 'the demo organisation, its catalog and its history').toBeGreaterThan(400); + expect(data.some((dataset) => dataset.object === 'sys_user'), 'the demo has people').toBe(true); + expect(data.some((dataset) => dataset.object === 'duly_task'), 'the demo has history').toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('only an explicit opt-in turns it on', () => { + it.each(['1', 'true', 'on', 'yes', 'TRUE', ' 1 '])('%j asks for the demo', async (value) => { + vi.stubEnv(DEMO_SEED_ENV_VAR, value); + const { dulySeeds } = await loadBarrel(); + expect(dulySeeds.length).toBeGreaterThan(0); + }); + + it.each(['', '0', 'false', 'off', 'no', 'maybe'])('%j does not', async (value) => { + // `DULY_DEMO_SEED=0` in particular: an operator who explicitly turned the + // demo off must not get it because a truthiness check read "0" as set. + vi.stubEnv(DEMO_SEED_ENV_VAR, value); + const { dulySeeds } = await loadBarrel(); + expect(dulySeeds).toEqual([]); + }); +}); diff --git a/test/seed.test.ts b/test/seed.test.ts index 4a99e17..be027a9 100644 --- a/test/seed.test.ts +++ b/test/seed.test.ts @@ -1,16 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AppPlugin, ObjectKernel, SeedLoaderService, createStandaloneStack } from '@objectstack/runtime'; import { SeedLoaderRequestSchema } from '@objectstack/spec/data'; -import stack from '../objectstack.config.js'; import { FREQUENCIES, periodBounds, periodKeyFor, type Frequency } from '../src/functions/period.js'; import { ADMIN, PEOPLE, UNITS } from '../src/data/demo-org.js'; import { CATALOG_ITEMS, DUTIES } from '../src/data/demo-catalog.js'; import { AD_HOC_TASKS, ASSIGNMENTS } from '../src/data/demo-assignments.js'; import { SEEDED_TASKS, SKIPS, TODAY } from '../src/data/demo-history.js'; -import { dulySeeds } from '../src/data/index.js'; /** * The demo seed, asserted against a REAL BOOTED KERNEL with the declarative @@ -41,6 +39,28 @@ import { dulySeeds } from '../src/data/index.js'; * "re-running the seed on a populated DB does not duplicate" → `idempotence` */ +/** + * The demo seed is OPT-IN and off by default — see the gate in + * `src/data/index.ts` for why the product wants it that way. This suite is the + * one that asks for it, so it sets the variable in `beforeAll` and imports the + * config dynamically afterwards. + * + * Spelled out here rather than imported from `src/data/index.js` deliberately: + * that module reads the variable ONCE, when it is first evaluated, and a static + * import would evaluate it before `beforeAll` could set anything — after which + * the dynamic import below would hand back the cached, EMPTY barrel and every + * count in this file would be zero. A rename that desyncs this string fails on + * the assertion right after the import, naming the variable; + * `test/demo-seed-opt-in.test.ts` imports the real constant. + */ +const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; + +type SeedDataset = { object: string; records: Record[] }; + +/** Both assigned in `beforeAll`, after the opt-in is set. */ +let stack: Record; +let dulySeeds: SeedDataset[]; + const SYSTEM = { isSystem: true } as const; const DAY = 24 * 60 * 60 * 1000; @@ -63,6 +83,17 @@ const userId = async (name: string): Promise => { }; beforeAll(async () => { + // Ask for the demo BEFORE anything evaluates the config: the gate in + // src/data/index.ts reads the environment at module-evaluation time, because + // the seed is baked into the compiled artifact rather than chosen at boot. + vi.stubEnv(DEMO_SEED_ENV_VAR, '1'); + stack = (await import('../objectstack.config.js')).default as unknown as Record; + ({ dulySeeds } = (await import('../src/data/index.js')) as unknown as { dulySeeds: SeedDataset[] }); + expect( + (stack.data as unknown[] | undefined)?.length ?? 0, + `this suite boots the demo, so ${DEMO_SEED_ENV_VAR} must be set before the config is imported`, + ).toBeGreaterThan(0); + const { plugins } = await createStandaloneStack({ databaseDriver: 'memory', skipSeedData: true, @@ -75,8 +106,12 @@ beforeAll(async () => { }); kernel = new ObjectKernel(); for (const plugin of plugins) await kernel.use(plugin); - // skipSeedData FALSE, and `stack` unmodified — the app's own `dulySeeds` - // going through the platform's own loader is exactly what is under test. + // skipSeedData FALSE, and `stack` unmodified — the app's own `dulySeeds`, + // resolved by the opt-in gate above, going through the platform's own loader + // is exactly what is under test. Nothing here substitutes the demo array in + // by hand: that would leave the config's own wiring unexercised, and wiring + // it to the ungated `demoSeeds` is precisely how 459 rows would find their + // way back into the default `pnpm dev` path. await kernel.use(new AppPlugin(stack as any, undefined, { skipSeedData: false })); await kernel.bootstrap(); data = kernel.getService('data'); @@ -100,6 +135,7 @@ beforeAll(async () => { afterAll(async () => { await kernel?.shutdown?.(); + vi.unstubAllEnvs(); }); // ───────────────────────────────────────────────────────────────────────────