|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#20071] A standalone boot reads its OWN `sys_metadata` back into the |
| 4 | +// registry, whatever environment id it stamps. |
| 5 | +// |
| 6 | +// `createStandaloneStack` stamps `environmentId: 'env_local'` (or whatever |
| 7 | +// `OS_ENVIRONMENT_ID` / `cfg.environmentId` names), and `ObjectQLPlugin.start()` |
| 8 | +// used to read ANY environment id as "a per-project kernel whose metadata comes |
| 9 | +// from an artifact or a control-plane proxy" and skip Phase-2 hydration. So an |
| 10 | +// object created and published at runtime kept its `sys_metadata` row across a |
| 11 | +// restart and was never registered again: the data API answered |
| 12 | +// `404 OBJECT_NOT_FOUND` for it (measured end to end by |
| 13 | +// `packages/cli/test/package-restart-acceptance.integration.test.ts`). |
| 14 | +// |
| 15 | +// Two halves, pinned where each is observable: |
| 16 | +// |
| 17 | +// 1. The DECLARATION, read off the plugin the stack hands the kernel — the |
| 18 | +// same seam `standalone-stack-default-environment-id.test.ts` reads the |
| 19 | +// stamped id from. It is asserted under all three ways an id gets stamped, |
| 20 | +// because the defect was precisely that the stamp decided hydration. |
| 21 | +// 2. The EFFECT, on a real kernel booted twice over one SQLite file: an |
| 22 | +// env-wide `object` row AND an env-wide `app` row written through the |
| 23 | +// protocol on boot 1 are in the second boot's registry, and a record |
| 24 | +// inserted on boot 1 is read back through the engine on boot 2. The `app` |
| 25 | +// row is there because the card measured one object only: hydration is |
| 26 | +// type-blind for env-wide rows (`loadMetaFromDb` registers objects through |
| 27 | +// `registerObject` and every other type through `registerItem`), and this |
| 28 | +// makes that reach a measurement instead of a reading of the code. |
| 29 | +// |
| 30 | +// ⛔ Org-scoped rows are NOT asserted here, deliberately: `loadMetaFromDb` |
| 31 | +// hydrates `organization_id IS NULL` rows only (ADR-0005 — a per-org overlay |
| 32 | +// is served on demand, never grafted into the registry every org shares), so an |
| 33 | +// org row absent after a restart is the design, not this defect. |
| 34 | + |
| 35 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 36 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 37 | +import { tmpdir } from 'node:os'; |
| 38 | +import { join } from 'node:path'; |
| 39 | +import type { ObjectQL } from '@objectstack/objectql'; |
| 40 | +import { Runtime } from './runtime.js'; |
| 41 | +import { createStandaloneStack } from './standalone-stack.js'; |
| 42 | + |
| 43 | +// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE |
| 44 | +// LOAD. Each is reached below through a dynamic `import()` inside an `it()` body |
| 45 | +// (the factory and the kernel boot both import lazily) -- vitest clocks those, |
| 46 | +// while collection is clocked against nothing. See |
| 47 | +// `scripts/check-test-source-alias.mjs` (the clocked-window rule). |
| 48 | +import '@objectstack/metadata'; |
| 49 | +import '@objectstack/objectql'; |
| 50 | +import '@objectstack/service-datasource'; |
| 51 | + |
| 52 | +const OBJECTQL_PLUGIN = 'com.objectstack.engine.objectql'; |
| 53 | +const BOOT_TIMEOUT = 120_000; |
| 54 | + |
| 55 | +const OBJECT = 'hydr_widget'; |
| 56 | +const APP = 'hydr_console'; |
| 57 | + |
| 58 | +/** |
| 59 | + * The hydration flag as `ObjectQLPlugin` actually holds it. TypeScript-private, |
| 60 | + * read on purpose: it is the one value `start()` gates Phase 2 on, so a pin that |
| 61 | + * re-declared the literal would stay green through a change that stopped |
| 62 | + * passing it. |
| 63 | + */ |
| 64 | +function hydrationFlag(plugins: readonly unknown[]): { environmentId: unknown; hydrateMetadataFromDb: unknown } { |
| 65 | + const objectql = plugins.find((p: any) => p?.name === OBJECTQL_PLUGIN) as any; |
| 66 | + expect(objectql, `stack must carry ${OBJECTQL_PLUGIN}`).toBeDefined(); |
| 67 | + return { environmentId: objectql.environmentId, hydrateMetadataFromDb: objectql.hydrateMetadataFromDb }; |
| 68 | +} |
| 69 | + |
| 70 | +async function boot(plugins: readonly unknown[]) { |
| 71 | + const runtime = new Runtime({ cluster: false }); |
| 72 | + const kernel = runtime.getKernel(); |
| 73 | + for (const p of plugins) await kernel.use(p as any); |
| 74 | + await kernel.bootstrap(); |
| 75 | + return kernel; |
| 76 | +} |
| 77 | + |
| 78 | +describe('[#20071] createStandaloneStack declares sys_metadata hydration instead of deducing it from the environment id', () => { |
| 79 | + const dirs: string[] = []; |
| 80 | + const kernels: any[] = []; |
| 81 | + let savedEnvId: string | undefined; |
| 82 | + let savedHome: string | undefined; |
| 83 | + |
| 84 | + function tempDir(tag: string): string { |
| 85 | + const dir = mkdtempSync(join(tmpdir(), `os-20071-${tag}-`)); |
| 86 | + dirs.push(dir); |
| 87 | + return dir; |
| 88 | + } |
| 89 | + |
| 90 | + beforeEach(() => { |
| 91 | + savedEnvId = process.env.OS_ENVIRONMENT_ID; |
| 92 | + savedHome = process.env.OS_HOME; |
| 93 | + delete process.env.OS_ENVIRONMENT_ID; |
| 94 | + process.env.OS_HOME = tempDir('home'); |
| 95 | + }); |
| 96 | + |
| 97 | + afterEach(async () => { |
| 98 | + for (const k of kernels.splice(0)) { |
| 99 | + try { await k.shutdown(); } catch { /* noop */ } |
| 100 | + } |
| 101 | + if (savedEnvId === undefined) delete process.env.OS_ENVIRONMENT_ID; |
| 102 | + else process.env.OS_ENVIRONMENT_ID = savedEnvId; |
| 103 | + if (savedHome === undefined) delete process.env.OS_HOME; |
| 104 | + else process.env.OS_HOME = savedHome; |
| 105 | + for (const d of dirs.splice(0)) { |
| 106 | + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } |
| 107 | + } |
| 108 | + }); |
| 109 | + |
| 110 | + it('hands ObjectQLPlugin `hydrateMetadataFromDb: true` under every way an environment id is stamped', async () => { |
| 111 | + const byDefault = await createStandaloneStack({ databaseUrl: 'memory://issue-20071-default' }); |
| 112 | + expect(hydrationFlag(byDefault.plugins)).toEqual({ environmentId: 'env_local', hydrateMetadataFromDb: true }); |
| 113 | + |
| 114 | + process.env.OS_ENVIRONMENT_ID = 'env_from_the_environment'; |
| 115 | + const byEnv = await createStandaloneStack({ databaseUrl: 'memory://issue-20071-env' }); |
| 116 | + expect(hydrationFlag(byEnv.plugins)).toEqual({ |
| 117 | + environmentId: 'env_from_the_environment', |
| 118 | + hydrateMetadataFromDb: true, |
| 119 | + }); |
| 120 | + |
| 121 | + const byConfig = await createStandaloneStack({ |
| 122 | + environmentId: 'env_from_the_config', |
| 123 | + databaseUrl: 'memory://issue-20071-cfg', |
| 124 | + }); |
| 125 | + expect(hydrationFlag(byConfig.plugins)).toEqual({ |
| 126 | + environmentId: 'env_from_the_config', |
| 127 | + hydrateMetadataFromDb: true, |
| 128 | + }); |
| 129 | + }, BOOT_TIMEOUT); |
| 130 | + |
| 131 | + it('an env-wide object and an env-wide app written at runtime are registered again after a restart on the same database file', async () => { |
| 132 | + const dir = tempDir('restart'); |
| 133 | + const databaseUrl = `file:${join(dir, 'hydrate.db')}`; |
| 134 | + const stackConfig = { projectRoot: dir, databaseUrl, skipSeedData: true, runPlatformMigrations: false } as const; |
| 135 | + |
| 136 | + // ── boot 1: author both rows at runtime, and one record ──────────────── |
| 137 | + const first = await boot((await createStandaloneStack(stackConfig)).plugins); |
| 138 | + kernels.push(first); |
| 139 | + const protocol: any = first.getService('protocol'); |
| 140 | + await protocol.saveMetaItem({ |
| 141 | + type: 'object', |
| 142 | + name: OBJECT, |
| 143 | + item: { |
| 144 | + name: OBJECT, |
| 145 | + label: 'Widget', |
| 146 | + sharingModel: 'private', |
| 147 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 148 | + }, |
| 149 | + }); |
| 150 | + await protocol.saveMetaItem({ type: 'app', name: APP, item: { name: APP, label: 'Hydration Console' } }); |
| 151 | + const engine1 = first.getService<ObjectQL>('objectql'); |
| 152 | + // Harness health: both rows are persisted env-wide and active — exactly |
| 153 | + // the population `loadMetaFromDb` selects — so their absence from the |
| 154 | + // next boot's registry can only be the boot's doing. |
| 155 | + for (const [type, name] of [['object', OBJECT], ['app', APP]] as const) { |
| 156 | + const stored: Array<{ state?: unknown; organization_id?: unknown }> = |
| 157 | + await engine1.find('sys_metadata', { where: { type, name } }); |
| 158 | + expect( |
| 159 | + stored.map((r) => ({ state: r.state, organization_id: r.organization_id ?? null })), |
| 160 | + `boot 1 persists ${type}/${name} as one env-wide active row`, |
| 161 | + ).toEqual([{ state: 'active', organization_id: null }]); |
| 162 | + } |
| 163 | + // The object is also serving on boot 1 (its write-through is not gated on |
| 164 | + // the environment id). The app is NOT in this boot's registry: the |
| 165 | + // protocol's write-through for every non-object type returns early on a |
| 166 | + // kernel with an environment id, so on this composition boot hydration |
| 167 | + // is the only path that puts it there — which is what makes it a sharp |
| 168 | + // second leg below. |
| 169 | + expect(engine1.registry.getObject(OBJECT)?.name, 'boot 1 registers the object it just saved').toBe(OBJECT); |
| 170 | + await engine1.insert(OBJECT, { title: 'survives the restart' }); |
| 171 | + await first.shutdown(); |
| 172 | + kernels.splice(kernels.indexOf(first), 1); |
| 173 | + |
| 174 | + // ── boot 2: same file, nothing authored — only hydration can bring them back |
| 175 | + const second = await boot((await createStandaloneStack(stackConfig)).plugins); |
| 176 | + kernels.push(second); |
| 177 | + const engine2 = second.getService<ObjectQL>('objectql'); |
| 178 | + expect(engine2.registry.getObject(OBJECT)?.name, `${OBJECT} is registered after the restart`).toBe(OBJECT); |
| 179 | + expect( |
| 180 | + engine2.registry.getItem<{ name?: string }>('app', APP)?.name, |
| 181 | + `app ${APP} is registered after the restart`, |
| 182 | + ).toBe(APP); |
| 183 | + const rows: Array<{ title?: unknown }> = await engine2.find(OBJECT, {}); |
| 184 | + expect(rows.map((r) => r.title)).toEqual(['survives the restart']); |
| 185 | + }, BOOT_TIMEOUT); |
| 186 | +}); |
0 commit comments