git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
80 lines
5.5 KiB
JavaScript
80 lines
5.5 KiB
JavaScript
// Lifecycle Task 2 (#797 / Gitea #1839): what an exact project addition looks like. The organization
|
|
// writer's addProject calls additionProblem on the document it is about to publish, and again on the
|
|
// bytes it will write. The bootstrap executor names the paths it reserves with managedPaths, so the
|
|
// writer and the executor cannot disagree about the layout.
|
|
import { lstatSync, readFileSync } from "node:fs";
|
|
import { isAbsolute, join, normalize } from "node:path";
|
|
import { canonicalJson } from "./util.mjs";
|
|
import { ID_PATTERN } from "./vocabulary.mjs";
|
|
|
|
export const ADDITION_VERB = "project.add";
|
|
const RECEIPT_KEYS = ["data_root", "plan_digest", "project", "request"];
|
|
const ENTRY_KEYS = ["remote", "repo", "root"];
|
|
const plain = x => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
const REQUEST_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
|
|
|
|
// A managed product's code checkout and its records repository, from the data root alone.
|
|
export function managedPaths({ dataRoot, business, project }) {
|
|
return { root: join(dataRoot, "products", business, project, "code"), repo: join(dataRoot, "projects", business, project) };
|
|
}
|
|
|
|
// null when `next` is `old` plus exactly the planned `projects[project]` entry, else the reason.
|
|
export function additionProblem({ old, next, project, entry, receipt }) {
|
|
if (typeof project !== "string" || !ID_PATTERN.test(project)) return "project key is not an id";
|
|
if (!plain(receipt) || Object.keys(receipt).sort().join() !== RECEIPT_KEYS.join()) return "receipt has the wrong keys";
|
|
if (typeof receipt.request !== "string" || receipt.request.length > 160 || !REQUEST_ID.test(receipt.request)) return "receipt request is not an id";
|
|
if (typeof receipt.plan_digest !== "string" || !/^[a-f0-9]{64}$/.test(receipt.plan_digest)) return "receipt plan digest is not a digest";
|
|
if (receipt.project !== project) return "receipt names another project";
|
|
if (typeof receipt.data_root !== "string" || !isAbsolute(receipt.data_root) || normalize(receipt.data_root) !== receipt.data_root) return "receipt data root is not absolute";
|
|
if (!plain(entry) || Object.keys(entry).some(k => !ENTRY_KEYS.includes(k))) return "entry holds more than root, repo and remote";
|
|
if (typeof entry.root !== "string" || typeof entry.repo !== "string" || ("remote" in entry && typeof entry.remote !== "string")) return "entry root and repo are required strings";
|
|
if (!plain(old?.projects) || !plain(next?.projects)) return "business has no project mapping";
|
|
const expected = managedPaths({ dataRoot: receipt.data_root, business: old.id, project });
|
|
if (entry.root !== expected.root || entry.repo !== expected.repo) return "entry is not the managed product layout";
|
|
if (Object.hasOwn(old.projects, project)) return "the business already declares this project";
|
|
if (canonicalJson(next.projects) !== canonicalJson({ ...old.projects, [project]: entry })) return "mapping differs from the old mapping plus the entry";
|
|
const { projects: _next, ...rest } = next, { projects: _old, ...was } = old;
|
|
if (canonicalJson(rest) !== canonicalJson(was)) return "a key outside projects changed";
|
|
return null;
|
|
}
|
|
|
|
// The product marker, <dataRoot>/products/<business>/<project>/bootstrap.json. The bootstrap executor writes it
|
|
// `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the
|
|
// bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can
|
|
// tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the
|
|
// committed activate receipt (#802).
|
|
const MARKER_STATES = ["reserved", "complete"];
|
|
export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json");
|
|
export function readMarker(file) {
|
|
let bytes;
|
|
try {
|
|
if (!lstatSync(file).isFile()) return { state: "invalid" };
|
|
bytes = readFileSync(file, "utf8");
|
|
} catch (e) {
|
|
if (e.code === "ENOENT") return null;
|
|
return { state: "invalid" };
|
|
}
|
|
try {
|
|
const m = JSON.parse(bytes);
|
|
const keys = Object.keys(m ?? {}).sort().join();
|
|
if (plain(m) && keys === "plan_digest,project,request,state,version" && m.version === 1 && MARKER_STATES.includes(m.state) &&
|
|
typeof m.project === "string" && typeof m.request === "string" && /^[a-f0-9]{64}$/.test(m.plan_digest)) return m;
|
|
} catch { /* falls through */ }
|
|
return { state: "invalid" };
|
|
}
|
|
export const markerBytes = ({ state, request, plan_digest, project }) =>
|
|
JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n";
|
|
|
|
// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap
|
|
// published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry
|
|
// there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete`
|
|
// marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable.
|
|
// Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and
|
|
// the launcher asks the bus before it clones one.
|
|
export function managedState({ dataRoot, business, project, entry }) {
|
|
const here = managedPaths({ dataRoot, business, project });
|
|
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
|
|
const marker = readMarker(markerFile({ dataRoot, business, project }));
|
|
return marker?.state === "complete" && marker.project === project ? "complete" : "reserved";
|
|
}
|