59735c5e38
Replace opaque props with checked generic presentation contracts and lazy typed interface references. Generate readonly/writable field APIs and component checks. Preserve CRDT editing through explicit resolved getter/setter contracts, binding- fenced delta RPCs, native watches and replica-aware field adapters. Custom setters retain semantic writes; storage snapshots never grant write authority. Cover concurrent edits, lost acknowledgements, readonly contracts and authorization. Add receiver-free static factory dispatch, state-field binding shorthand, and conformance-based creation. Migrate TODO, editable scaffolds and authoring guides. Verify language/codegen, SDK, RPC, browser lifecycle, local scaffolds, production browser bundling and CRDT persistence with temporary PostgreSQL.
422 lines
20 KiB
TypeScript
422 lines
20 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { contentDigest } from "../capability-model/evolution.js";
|
|
import {
|
|
validateMigrationCatalog,
|
|
type MigrationCatalog,
|
|
type MigrationDeclaration,
|
|
} from "../capability-model/migrations.js";
|
|
import { formatQuixosLock, type GitSource } from "../resource-lock/index.js";
|
|
import { parseQx, walkSyntax } from "./source.js";
|
|
import type { StructuralRequest } from "./structural-plan.js";
|
|
import { addImplementation } from "./implementation-edit.js";
|
|
import { reactPlatformTypes } from "../bindings/react-platform.js";
|
|
|
|
type Source = { repository: string; commit: string };
|
|
type PackageEditModel = {
|
|
generatedBy: "qx-scaffold-v1";
|
|
name: string;
|
|
id: string;
|
|
revision: string;
|
|
exports: { name: string; id: string; file: string; migration?: boolean }[];
|
|
};
|
|
export type ScaffoldRecipe = {
|
|
template?: "typescript" | "typescript-react";
|
|
source: Source;
|
|
directory?: string;
|
|
name?: string;
|
|
id?: string;
|
|
revision?: string;
|
|
declaration?: string;
|
|
/** Initial authored files for a composed recipe; only used for a new package. */
|
|
initialFiles?: Record<string, string>;
|
|
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
|
nixifyPluginUrl?: string;
|
|
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
|
|
};
|
|
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
|
const source = (value: Source): GitSource => {
|
|
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string")
|
|
throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
|
|
const url = new URL(value.repository);
|
|
if (
|
|
url.protocol !== "https:" ||
|
|
url.username ||
|
|
url.password ||
|
|
url.search ||
|
|
url.hash ||
|
|
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)
|
|
)
|
|
throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
|
return { resolver: "git", ...value };
|
|
};
|
|
const nixSource = (value: Source) =>
|
|
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
|
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
|
const safeName = (name: string | undefined): string => {
|
|
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
|
|
return name;
|
|
};
|
|
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
|
const target = path.join(root, file);
|
|
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`))
|
|
throw new Error("Scaffold input escapes repository");
|
|
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
|
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
|
|
return value;
|
|
};
|
|
|
|
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
|
* Implementation files are created once; later edits preserve authored wiring. */
|
|
export const scaffoldRecipe = async (
|
|
root: string,
|
|
command: "package" | "function" | "migration" | "refresh",
|
|
spec: ScaffoldRecipe,
|
|
): Promise<StructuralRequest> => {
|
|
if (command === "refresh")
|
|
throw new Error(
|
|
"Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.",
|
|
);
|
|
source(spec.source);
|
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
|
|
throw new Error("Scaffold directory must be contained");
|
|
const prefix = spec.directory ? `${spec.directory}/` : "";
|
|
const files: StructuralRequest["files"] = [];
|
|
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
|
const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content });
|
|
let packageModel: PackageEditModel;
|
|
let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" };
|
|
if (command === "package") {
|
|
const name = safeName(spec.name);
|
|
if (spec.template && !["typescript", "typescript-react"].includes(spec.template))
|
|
throw new Error("Unknown package template");
|
|
const react = spec.template === "typescript-react";
|
|
if (!spec.id || !spec.revision || !spec.tools)
|
|
throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
|
Object.values(spec.tools).forEach(source);
|
|
packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] };
|
|
catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] };
|
|
if (react)
|
|
packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" });
|
|
create(
|
|
"package.qx",
|
|
`package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`,
|
|
);
|
|
create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] }));
|
|
create(
|
|
"package.json",
|
|
json({
|
|
name: `@quixos/${name.toLowerCase()}`,
|
|
version: "0.1.0",
|
|
private: true,
|
|
type: "module",
|
|
packageManager: "yarn@4.18.0",
|
|
scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" },
|
|
dependencies: {
|
|
"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`,
|
|
},
|
|
devDependencies: {
|
|
"@types/node": "^24",
|
|
typescript: "^7.0.2",
|
|
...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}),
|
|
},
|
|
}),
|
|
);
|
|
create(
|
|
"tsconfig.json",
|
|
json({
|
|
compilerOptions: {
|
|
target: "ES2023",
|
|
module: "NodeNext",
|
|
moduleResolution: "NodeNext",
|
|
strict: true,
|
|
types: ["node", ...(react ? ["react"] : [])],
|
|
outDir: "dist",
|
|
rootDir: "src",
|
|
skipLibCheck: true,
|
|
...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}),
|
|
},
|
|
include: ["src/**/*.ts", "src/**/*.tsx"],
|
|
}),
|
|
);
|
|
if (react) {
|
|
create(
|
|
"src/component.tsx",
|
|
`// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\n}\n`,
|
|
);
|
|
create(
|
|
"src/impl/sourceGet.ts",
|
|
`import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`,
|
|
);
|
|
create(
|
|
"src/browser-assets.d.ts",
|
|
`declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`,
|
|
);
|
|
create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes);
|
|
}
|
|
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
|
create(
|
|
".yarnrc.yml",
|
|
`nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`,
|
|
);
|
|
const nixifyPluginUrl =
|
|
spec.nixifyPluginUrl ??
|
|
"https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
|
|
const plugin = new URL(nixifyPluginUrl);
|
|
if (
|
|
plugin.protocol !== "https:" ||
|
|
plugin.username ||
|
|
plugin.password ||
|
|
plugin.search ||
|
|
plugin.hash ||
|
|
!/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)
|
|
)
|
|
throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
|
|
generated("quixos.toolchain.json", json({ generatedBy: "qx-scaffold-v1", nixifyPluginUrl }));
|
|
create(
|
|
"quixos.check.json",
|
|
json({
|
|
backend: "typescript",
|
|
bindingOutput: "src/gen/qx.ts",
|
|
...(react
|
|
? {
|
|
options: { react: { propsExports: [] } },
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
create(
|
|
"flake.nix",
|
|
`{
|
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
|
inputs.flake-utils.follows = "protocol/flake-utils";
|
|
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
|
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
|
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
|
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
|
bundle = { entry = ${JSON.stringify(react ? "src/server.ts" : "dist/server.js")}; ${react ? "browserSources = true;" : ""} };
|
|
migrationEntrypoint = "dist/migrate.js";
|
|
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
|
};
|
|
}\n`,
|
|
);
|
|
} else {
|
|
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
|
// Derive the edit model from authored declarations; it is never persisted.
|
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
|
const syntax = parseQx(authored);
|
|
if (syntax.diagnostics.length)
|
|
throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
|
|
const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl");
|
|
if (!declaration) throw new Error("Expected a package declaration");
|
|
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
|
const literals = declaration.children.filter((node) => node.kind === "stringLiteral");
|
|
packageModel = {
|
|
generatedBy: "qx-scaffold-v1",
|
|
name: text(declaration.children.find((node) => node.kind === "identifier")!),
|
|
id: JSON.parse(text(literals[0])),
|
|
revision: JSON.parse(text(literals[1])),
|
|
exports: [],
|
|
};
|
|
for (const node of walkSyntax(declaration)) {
|
|
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind))
|
|
continue;
|
|
const name = safeName(text(node.children.find((child) => child.kind === "identifier")!));
|
|
const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!));
|
|
if (packageModel.exports.some((entry) => entry.id === id || entry.name === name))
|
|
throw new Error("Duplicate package export name or ID");
|
|
const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id);
|
|
packageModel.exports.push({
|
|
name,
|
|
id,
|
|
file: migration?.implementation.file ?? `src/impl/${name}.ts`,
|
|
...(migration ? { migration: true } : {}),
|
|
});
|
|
}
|
|
{
|
|
const name = safeName(spec.name);
|
|
if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name))
|
|
throw new Error("New export requires a unique name and ID");
|
|
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
|
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
|
|
const exports = [...walkSyntax(parsed.root)].filter((node) =>
|
|
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
|
|
);
|
|
if (parsed.diagnostics.length || exports.length !== 1)
|
|
throw new Error("Expected one valid package export declaration");
|
|
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
|
|
const node = exports[0];
|
|
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
|
|
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration))
|
|
throw new Error(
|
|
"Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint",
|
|
);
|
|
if (
|
|
wrapped.slice(
|
|
node.children.find((child) => child.kind === "identifier")!.start,
|
|
node.children.find((child) => child.kind === "identifier")!.end,
|
|
) !== name ||
|
|
JSON.parse(
|
|
wrapped.slice(
|
|
node.children.find((child) => child.kind === "stringLiteral")!.start,
|
|
node.children.find((child) => child.kind === "stringLiteral")!.end,
|
|
),
|
|
) !== spec.id
|
|
)
|
|
throw new Error("Declaration name/ID must match its registration");
|
|
files.push({
|
|
file: prefix + "package.qx",
|
|
edits: [
|
|
{ operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.id }, source: declaration },
|
|
],
|
|
});
|
|
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
|
const implementation =
|
|
command === "migration"
|
|
? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
|
|
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
|
create(file, implementation);
|
|
packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) });
|
|
if (command === "migration") {
|
|
if (!spec.migration)
|
|
throw new Error("Migration scaffold requires retained contracts and an explicit transition");
|
|
const { contracts, ...transition } = spec.migration;
|
|
for (const [digest, contract] of Object.entries(contracts)) {
|
|
if (
|
|
contentDigest(contract) !== digest ||
|
|
(catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)
|
|
)
|
|
throw new Error("Retained migration contract mismatch");
|
|
catalog.contracts[digest] = contract;
|
|
}
|
|
catalog.migrations.push({
|
|
...transition,
|
|
implementation: { exportId: spec.id, file, digest: contentDigest(implementation) },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id)));
|
|
generated("quixos.migrations.json", json(catalog));
|
|
if (command !== "package") {
|
|
const entry = packageModel.exports.at(-1)!;
|
|
const file = prefix + "src/server.ts";
|
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
|
files.push({
|
|
file,
|
|
expected: before,
|
|
replace: addImplementation(
|
|
before,
|
|
"createRuntime",
|
|
entry.name,
|
|
`./${entry.file.slice(4, -3)}.js`,
|
|
!!entry.migration,
|
|
),
|
|
});
|
|
if (entry.migration) {
|
|
const file = prefix + "src/migrate.ts";
|
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
|
files.push({
|
|
file,
|
|
expected: before,
|
|
replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`),
|
|
});
|
|
}
|
|
} else {
|
|
create(
|
|
"src/server.ts",
|
|
`import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` +
|
|
packageModel.exports
|
|
.filter((entry) => !entry.migration)
|
|
.map(
|
|
(entry, index) =>
|
|
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
|
|
)
|
|
.join("") +
|
|
`servePackageRuntime(createRuntime({\n` +
|
|
packageModel.exports
|
|
.map(
|
|
(entry) =>
|
|
` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`,
|
|
)
|
|
.join("\n") +
|
|
`\n}));\n`,
|
|
);
|
|
const migrations = packageModel.exports.filter((entry) => entry.migration);
|
|
create(
|
|
"src/migrate.ts",
|
|
`import {serveMigration} from "@quixos/camino-package-runtime";\n` +
|
|
migrations
|
|
.map(
|
|
(entry, index) =>
|
|
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
|
|
)
|
|
.join("") +
|
|
`await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`,
|
|
);
|
|
}
|
|
generated(
|
|
"descriptor.quixos-package.txtpb",
|
|
`# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
|
|
packageModel.exports
|
|
.map(
|
|
(entry) =>
|
|
`exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`,
|
|
)
|
|
.join(""),
|
|
);
|
|
if (spec.initialFiles) {
|
|
if (command !== "package") throw new Error("initialFiles is only valid when creating a package");
|
|
const authored = spec.initialFiles["package.qx"];
|
|
if (authored !== undefined) {
|
|
const parsed = parseQx(authored);
|
|
const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl");
|
|
const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? [];
|
|
const identifier = declaration?.children.find((n) => n.kind === "identifier");
|
|
if (
|
|
parsed.diagnostics.length ||
|
|
literals.length !== 2 ||
|
|
!identifier ||
|
|
authored.slice(identifier.start, identifier.end) !== spec.name ||
|
|
JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id ||
|
|
JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision
|
|
)
|
|
throw new Error("Initial package declaration must match the provisioned name, ID and revision");
|
|
}
|
|
for (const [file, content] of Object.entries(spec.initialFiles)) {
|
|
if (
|
|
typeof content !== "string" ||
|
|
["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file)
|
|
)
|
|
throw new Error(`Not an initial authored file: ${file}`);
|
|
if (file === "quixos.check.json") {
|
|
const check = JSON.parse(content);
|
|
if (
|
|
check.backend !== "typescript" ||
|
|
check.bindingOutput !== "src/gen/qx.ts" ||
|
|
Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key))
|
|
)
|
|
throw new Error(
|
|
"Initial check configuration may customize binding options, not the scaffold verification backend or output",
|
|
);
|
|
}
|
|
const existing = files.findIndex((entry) => entry.file === prefix + file);
|
|
if (existing >= 0) files.splice(existing, 1);
|
|
create(file, content);
|
|
}
|
|
// A composed React recipe supplies its own modules and complete server.
|
|
if (reactRecipe(spec)) {
|
|
for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"])
|
|
if (!(file in spec.initialFiles)) {
|
|
const index = files.findIndex((entry) => entry.file === prefix + file);
|
|
if (index >= 0) files.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
|
};
|
|
|
|
const reactRecipe = (spec: ScaffoldRecipe) =>
|
|
spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];
|