7124750874
`1.0.1` moves the data layer from `dependencies` to `peerDependencies`, satisfied by the application's own copy. The path that broke `1.0.0` survives only in devDependencies, which pnpm never installs for a git dependency -- verified at the tag's commit rather than taken on trust. One verifier, proven: the data layer and `@ng-org/web` each resolve to the same realpath from the application and from inside the new package, and exactly one `@ng-org/web` exists in the whole store. No overrides, no resolutions, no hoisting flag, no `.npmrc` -- the peer dependency did it alone, which is the point: the singleton becomes structural instead of something checked afterwards. The runtime surface matches the engagement exactly -- five values exported, every other published name type-only and erased. Nothing undeclared, nothing missing. Local wiring stays ONE script rather than a sibling: a provider registry drives the same copy-overlay, the same `--once`, the same watch loop, only the paths differ. Its single-instance assertion became a loop over every package a provider shares with the application, which matters here because that provider's own checkout carries `file:` links -- the very symlink hazard the script exists for. Its header no longer promises what the old one did: a running dev server never picks up a refreshed node_modules package, not even across a real rebuild.
161 lines
7.2 KiB
TypeScript
161 lines
7.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* link-polyfill.ts — Reactive local overlay of a data-layer PROVIDER's checkout into
|
|
* node_modules. One script, one provider per run; only the paths differ between them.
|
|
*
|
|
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
|
|
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
|
|
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
|
|
*
|
|
* WHY a copy-overlay and not a symlink (identical for every provider):
|
|
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
|
|
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
|
|
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
|
|
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
|
|
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
|
|
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
|
|
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
|
|
* shared packages still resolve up to Festipod, single instance preserved.
|
|
*
|
|
* WHAT IT DOES:
|
|
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
|
|
* holding the local checkout's package.json + src (NO node_modules).
|
|
* 2. Asserts the single-instance invariant — every package this provider SHARES with
|
|
* Festipod must resolve to the same realpath from Festipod and from the overlay —
|
|
* and aborts if it would break.
|
|
* 3. Watches the local checkout's src and copies each change into the overlay.
|
|
*
|
|
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
|
|
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
|
|
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
|
|
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
|
|
* The server's resolution of that import is pinned at process start and a rebuild does not
|
|
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
|
|
* server looks exactly like a current one. See
|
|
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
|
|
* an hour to learn. Watching copies the files; it does not make anything reload them.
|
|
*
|
|
* USAGE (reactive dev):
|
|
* Terminal 1: pnpm run link:polyfill # or: pnpm run link:indexing
|
|
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
|
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
|
|
*
|
|
* pnpm run link:indexing --once # overlay + verify, no watch (CI / one-shot)
|
|
* Return to the committed git-installed dependencies: pnpm install
|
|
*/
|
|
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
|
import { watch } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
|
|
interface Provider {
|
|
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
|
|
readonly packageName: string;
|
|
/** Local checkout used when the env override is unset. */
|
|
readonly defaultLocal: string;
|
|
/** Env var overriding the local checkout path. */
|
|
readonly envOverride: string;
|
|
/**
|
|
* Packages this provider SHARES with Festipod and that must stay single-instance.
|
|
* Each is resolved from Festipod and from the overlay; the realpaths must match.
|
|
*/
|
|
readonly singletons: readonly string[];
|
|
}
|
|
|
|
const PROVIDERS: Record<string, Provider> = {
|
|
polyfill: {
|
|
packageName: "@ng-eventually/polyfill",
|
|
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
|
|
envOverride: "NG_EVENTUALLY_LOCAL",
|
|
singletons: ["@ng-org/web"],
|
|
},
|
|
indexing: {
|
|
packageName: "@ng-helpers/indexing",
|
|
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
|
|
envOverride: "NG_HELPERS_LOCAL",
|
|
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
|
|
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
|
|
},
|
|
};
|
|
|
|
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
|
const args = process.argv.slice(2);
|
|
const ONCE = args.includes("--once");
|
|
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
|
|
|
|
function fail(msg: string): never {
|
|
console.error(`✖ link:${KEY} — ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const provider = PROVIDERS[KEY];
|
|
if (!provider) {
|
|
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
|
|
}
|
|
|
|
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
|
|
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
|
|
const SRC_LOCAL = join(LOCAL, "src");
|
|
const SRC_TARGET = join(TARGET, "src");
|
|
|
|
if (!existsSync(join(LOCAL, "package.json"))) {
|
|
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
|
|
}
|
|
if (!existsSync(SRC_LOCAL)) {
|
|
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
|
|
}
|
|
|
|
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
|
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
|
|
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
|
rmSync(TARGET, { recursive: true, force: true });
|
|
}
|
|
mkdirSync(TARGET, { recursive: true });
|
|
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
|
const from = join(LOCAL, meta);
|
|
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
|
}
|
|
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
|
|
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
|
|
|
// 2. Assert the single-instance invariant for every package shared with Festipod.
|
|
const overlayReal = realpathSync(TARGET);
|
|
for (const spec of provider.singletons) {
|
|
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
|
|
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
|
|
console.log(` ${spec} (Festipod): ${fromFestipod}`);
|
|
console.log(` ${spec} (overlay) : ${fromOverlay}`);
|
|
if (fromFestipod !== fromOverlay) {
|
|
fail(
|
|
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
|
|
" The overlay must not contain its own node_modules. Aborting.",
|
|
);
|
|
}
|
|
}
|
|
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
|
|
|
|
if (ONCE) {
|
|
console.log("✓ overlay ready (--once, not watching)");
|
|
process.exit(0);
|
|
}
|
|
|
|
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
|
|
// dev server notice — not even across a rebuild (see the header). Restart it.
|
|
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
|
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
|
if (!filename) return;
|
|
const from = join(SRC_LOCAL, filename);
|
|
const to = join(SRC_TARGET, filename);
|
|
try {
|
|
if (existsSync(from)) {
|
|
mkdirSync(dirname(to), { recursive: true });
|
|
copyFileSync(from, to);
|
|
console.log(` ↻ ${filename}`);
|
|
} else if (existsSync(to)) {
|
|
rmSync(to, { force: true });
|
|
console.log(` ✗ ${filename} (removed)`);
|
|
}
|
|
} catch (err) {
|
|
console.error(` ! failed to sync ${filename}:`, err);
|
|
}
|
|
});
|