|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `os serve` loads the cluster gate and its driver AS THE HOST APP DECLARES |
| 5 | + * THEM, not from the CLI's own `node_modules`. |
| 6 | + * |
| 7 | + * ── The defect ─────────────────────────────────────────────────────────── |
| 8 | + * |
| 9 | + * Measured on a published EE image: with `OS_CLUSTER_DRIVER=redis` set, boot |
| 10 | + * died with |
| 11 | + * |
| 12 | + * Cannot find package '@objectstack/service-cluster' imported from |
| 13 | + * /repo/objectstack/packages/cli/dist/commands/serve.js |
| 14 | + * |
| 15 | + * The CLI's own `node_modules/@objectstack/` held 48 packages and NEITHER |
| 16 | + * cluster package; both were installed only under the app, which declares them. |
| 17 | + * `serve.ts` reached them through a bare dynamic `import()`, and Node ESM |
| 18 | + * resolves a bare specifier against the IMPORTER's realpath — the CLI's, inside |
| 19 | + * the framework workspace. So the one hop that could not work was CLI to app, |
| 20 | + * while app-side code loaded the very same packages fine. |
| 21 | + * |
| 22 | + * ── Why this is not fixed by declaring the packages ────────────────────── |
| 23 | + * |
| 24 | + * Adding `@objectstack/service-cluster*` to `packages/cli`'s dependencies would |
| 25 | + * silence this driver and leave the class open: the next app-declared optional |
| 26 | + * package the CLI advertises it will load breaks identically, a third-party |
| 27 | + * cluster driver can never work, and the open-core CLI would take a static |
| 28 | + * dependency on packages that ship with a distribution — the exact coupling the |
| 29 | + * non-literal specifier in `serve.ts` exists to avoid. The fix is to resolve |
| 30 | + * from the host app, which is what `createHostImporter` already does for the |
| 31 | + * organizations / capability loads further down `serve`. |
| 32 | + * |
| 33 | + * ── What is pinned here ────────────────────────────────────────────────── |
| 34 | + * |
| 35 | + * 1. The BOUNDARY, behaviourally and hermetically: a package that exists only |
| 36 | + * in a host app's `node_modules` is invisible to a bare import from this |
| 37 | + * file (which sits in `packages/cli`, the same resolution base as the |
| 38 | + * shipped `dist/commands/serve.js`) and IS loadable through the host |
| 39 | + * importer. The fixture package is synthetic on purpose — the contract is |
| 40 | + * "any app-declared optional package", not "these two cluster packages", and |
| 41 | + * a synthetic one needs nothing built. |
| 42 | + * |
| 43 | + * 2. The ORDERING, by source scan: `importFromHost` must be defined ABOVE the |
| 44 | + * cluster block. This is the half that actually regressed, twice — the |
| 45 | + * helper is a `const` in one long boot function, so a load placed above it |
| 46 | + * is not a compile error, it is a silent fall-back to bare resolution. The |
| 47 | + * first time it cost the enterprise organizations load (cloud#1013); the |
| 48 | + * second time it cost EE multi-node boot outright. |
| 49 | + * |
| 50 | + * The source scan reads `serve.ts` from THIS package, so no cross-package test |
| 51 | + * input is declared or needed. |
| 52 | + */ |
| 53 | + |
| 54 | +import { describe, it, expect } from 'vitest'; |
| 55 | +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; |
| 56 | +import { tmpdir } from 'node:os'; |
| 57 | +import { dirname, join, resolve } from 'node:path'; |
| 58 | +import { fileURLToPath } from 'node:url'; |
| 59 | +import { createHostImporter } from '@objectstack/types/node'; |
| 60 | + |
| 61 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 62 | + |
| 63 | +/** `packages/cli/src/commands/serve.ts` — same package, no escaping read. */ |
| 64 | +const SERVE_SOURCE = readFileSync(resolve(HERE, 'serve.ts'), 'utf8'); |
| 65 | + |
| 66 | +/** |
| 67 | + * A host app that DECLARES an optional package and carries it in its own |
| 68 | + * `node_modules` — the shape of every EE app that declares |
| 69 | + * `@objectstack/service-cluster`. Nothing here is built or installed: the |
| 70 | + * package is three files written to a temp dir. |
| 71 | + */ |
| 72 | +function makeHostApp(pkgName: string, declare: boolean): string { |
| 73 | + const root = mkdtempSync(join(tmpdir(), 'os-host-app-')); |
| 74 | + writeFileSync( |
| 75 | + join(root, 'package.json'), |
| 76 | + JSON.stringify({ |
| 77 | + name: 'fixture-host-app', |
| 78 | + version: '1.0.0', |
| 79 | + type: 'module', |
| 80 | + ...(declare ? { dependencies: { [pkgName]: '1.0.0' } } : {}), |
| 81 | + }), |
| 82 | + ); |
| 83 | + const pkgDir = join(root, 'node_modules', ...pkgName.split('/')); |
| 84 | + mkdirSync(pkgDir, { recursive: true }); |
| 85 | + writeFileSync( |
| 86 | + join(pkgDir, 'package.json'), |
| 87 | + JSON.stringify({ name: pkgName, version: '1.0.0', type: 'module', main: 'index.js' }), |
| 88 | + ); |
| 89 | + // The marker export stands in for `checkMultiNodeAllowed`: proof the module |
| 90 | + // that loaded is the app's copy, not something the CLI happened to resolve. |
| 91 | + writeFileSync(join(pkgDir, 'index.js'), 'export const loadedFrom = "host-app";\n'); |
| 92 | + return root; |
| 93 | +} |
| 94 | + |
| 95 | +describe('os serve → app-declared optional package resolution', () => { |
| 96 | + // A name no workspace package can satisfy, so a pass cannot come from the |
| 97 | + // CLI's own node_modules by accident. |
| 98 | + const PKG = '@os-fixture/cluster-driver-probe'; |
| 99 | + |
| 100 | + it('reproduces the asymmetry: an app-only package is invisible to a bare import', async () => { |
| 101 | + // This file resolves from `packages/cli`, exactly as `dist/commands/serve.js` |
| 102 | + // does — the failing hop the EE image measured. |
| 103 | + const bare: string = PKG; |
| 104 | + await expect(import(bare)).rejects.toMatchObject({ |
| 105 | + code: expect.stringMatching(/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/), |
| 106 | + }); |
| 107 | + }); |
| 108 | + |
| 109 | + it('crosses the boundary: the host importer loads what the app declares', async () => { |
| 110 | + const hostRoot = makeHostApp(PKG, true); |
| 111 | + const mod = await createHostImporter(hostRoot)(PKG); |
| 112 | + expect(mod.loadedFrom).toBe('host-app'); |
| 113 | + }); |
| 114 | + |
| 115 | + it('still refuses a package the app does not declare (the gate is unchanged)', async () => { |
| 116 | + // Present in the app's node_modules but absent from its package.json. |
| 117 | + // Reachability must not substitute for declaration (#4719) — this fix moves |
| 118 | + // where a module is resolved FROM, it does not widen what serve accepts. |
| 119 | + const hostRoot = makeHostApp(PKG, false); |
| 120 | + await expect(createHostImporter(hostRoot)(PKG)).rejects.toMatchObject({ |
| 121 | + code: 'MODULE_NOT_FOUND', |
| 122 | + }); |
| 123 | + }); |
| 124 | +}); |
| 125 | + |
| 126 | +describe('os serve → cluster block source shape', () => { |
| 127 | + it('loads the cluster gate and driver through the host importer', () => { |
| 128 | + expect(SERVE_SOURCE).toMatch(/await importFromHost\(__clusterPkg\)/); |
| 129 | + expect(SERVE_SOURCE).toMatch( |
| 130 | + /await importFromHost\(`@objectstack\/service-cluster-\$\{__clusterDriver\}`\)/, |
| 131 | + ); |
| 132 | + }); |
| 133 | + |
| 134 | + it('never reaches the cluster packages through a bare dynamic import', () => { |
| 135 | + // The exact regression, in both spellings the block used. |
| 136 | + expect(SERVE_SOURCE).not.toMatch(/await import\(__clusterPkg\)/); |
| 137 | + expect(SERVE_SOURCE).not.toMatch(/await import\(`@objectstack\/service-cluster-/); |
| 138 | + }); |
| 139 | + |
| 140 | + it('defines importFromHost ABOVE the cluster block that consumes it', () => { |
| 141 | + const definition = SERVE_SOURCE.indexOf('const importFromHost = createHostImporter('); |
| 142 | + const clusterUse = SERVE_SOURCE.indexOf('await importFromHost(__clusterPkg)'); |
| 143 | + |
| 144 | + expect(definition, 'importFromHost definition not found — was it renamed?').toBeGreaterThan(-1); |
| 145 | + expect(clusterUse, 'cluster gate no longer loads via importFromHost').toBeGreaterThan(-1); |
| 146 | + |
| 147 | + // `const` in one long boot function: a use above the definition is a |
| 148 | + // temporal-dead-zone throw at boot, and the load it guards is exactly the |
| 149 | + // one that must not fall back to bare resolution. |
| 150 | + expect( |
| 151 | + definition, |
| 152 | + 'importFromHost is defined AFTER the cluster block. That is the defect this file ' |
| 153 | + + 'pins: every optional load placed above the helper silently resolves from the ' |
| 154 | + + "CLI's own node_modules instead of the host app's. Hoist the helper.", |
| 155 | + ).toBeLessThan(clusterUse); |
| 156 | + }); |
| 157 | + |
| 158 | + it('keeps exactly one host-importer definition, so hoisting cannot fork it', () => { |
| 159 | + const definitions = [...SERVE_SOURCE.matchAll(/const importFromHost\s*=/g)]; |
| 160 | + expect(definitions).toHaveLength(1); |
| 161 | + }); |
| 162 | +}); |
0 commit comments