Add local-first execution authoring support
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||
import type { StructuralRequest } from "./structural-plan.js";
|
||||
import { parseQx, walkSyntax } from "./source.js";
|
||||
import { addImplementation } from "./implementation-edit.js";
|
||||
import { formatQuixosLock } from "../resource-lock/index.js";
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n";
|
||||
const q = (value: string) => JSON.stringify(value);
|
||||
const nixString = (value: string) => q(value).replaceAll("${", "\\${");
|
||||
export async function isExecutionScaffold(root: string, prefix: string): Promise<boolean> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(root, prefix, "quixos.check.json"), "utf8")).backend === "execution";
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Insert ordinary authored code; declarations remain the only export inventory. */
|
||||
export async function executionScaffold(
|
||||
root: string,
|
||||
command: string,
|
||||
spec: ScaffoldRecipe,
|
||||
prefix: string,
|
||||
nixSource: (source: NonNullable<ScaffoldRecipe["tools"]>["helpers"]) => string,
|
||||
): Promise<StructuralRequest> {
|
||||
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||||
throw new Error("Execution scaffold requires a simple name and an ID");
|
||||
if (spec.initialFiles) throw new Error("Edit execution source after scaffolding; initialFiles is not supported");
|
||||
const files: StructuralRequest["files"] = [];
|
||||
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
||||
if (command === "package") {
|
||||
if (!spec.tools || !spec.revision) throw new Error("Execution package requires exact tool sources and a revision");
|
||||
create("package.qx", `package ${spec.name} id ${q(spec.id)} revision ${q(spec.revision)} {\n}\n`);
|
||||
create(
|
||||
"quixos.lock",
|
||||
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
||||
);
|
||||
create("quixos.check.json", json({ backend: "execution" }));
|
||||
create("package.json", json({ name: `@quixos/${spec.name.toLowerCase()}`, private: true, type: "module" }));
|
||||
create(".gitignore", "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n");
|
||||
for (const lane of ["browser", "server", "remote"])
|
||||
create(
|
||||
`src/${lane}.ts`,
|
||||
`import { createRegistry } from "./gen/${lane === "remote" ? "remote" : "qx"}.js";\nexport const registry = createRegistry({});\n`,
|
||||
);
|
||||
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 = { nixpkgs, flake-utils, helpers, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
||||
(import (helpers + "/portable-artifacts.nix")) {
|
||||
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||||
protocol = generator;
|
||||
src = ./.;
|
||||
portable = {
|
||||
entry = "src/server.ts";
|
||||
entries.browser = "src/browser.ts";
|
||||
bindingOutput = "src/gen/qx.ts";
|
||||
};
|
||||
remote = { entry = "src/remote.ts"; bindingOutput = "src/gen/remote.ts"; };
|
||||
};
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
create(
|
||||
"README.md",
|
||||
`# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The coordinator supplies the checked workspace plan\nand runtime dependencies to checkedArtifacts.\n\nThe execution scaffold currently requires the execution artifact build path;\nlegacy checkedServer verification and activation do not install these artifacts.\nA source-only structural check is not runtime qualification.\n`,
|
||||
);
|
||||
} else if (command === "function") {
|
||||
if (!spec.declaration)
|
||||
throw new Error("Execution functions require a declaration with explicit execution effect and targets");
|
||||
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${spec.declaration} }`;
|
||||
const parsed = parseQx(wrapped);
|
||||
const pkg = parsed.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||
const exports = [...walkSyntax(parsed.root)].filter((node) =>
|
||||
["packageFunctionExport", "packageOperationExport"].includes(node.kind),
|
||||
);
|
||||
const declaration = exports[0];
|
||||
if (
|
||||
parsed.diagnostics.length ||
|
||||
exports.length !== 1 ||
|
||||
!declaration ||
|
||||
parsed.root.children.length !== 1 ||
|
||||
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1 ||
|
||||
pkg?.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind))
|
||||
)
|
||||
throw new Error("Expected one function or operation export with an execution contract");
|
||||
const text = (node: { start: number; end: number }) => wrapped.slice(node.start, node.end);
|
||||
const name = declaration.children.find((node) => node.kind === "identifier");
|
||||
const id = declaration.children.find((node) => node.kind === "stringLiteral");
|
||||
const execution = declaration.children.find((node) => node.kind === "executionClause");
|
||||
if (!name || text(name) !== spec.name || !id || JSON.parse(text(id)) !== spec.id || !execution)
|
||||
throw new Error("Declaration name/ID must match; an explicit execution contract is required");
|
||||
const tokens = parsed.tokens
|
||||
.filter((token) => !token.trivia && token.start >= execution.start && token.end <= execution.end)
|
||||
.map((token) => token.text);
|
||||
const remote = tokens[1] === "remote";
|
||||
const targets = remote
|
||||
? ["remote"]
|
||||
: [...walkSyntax(execution)].filter((node) => node.kind === "identifier").map(text);
|
||||
if (
|
||||
!targets.length ||
|
||||
new Set(targets).size !== targets.length ||
|
||||
targets.some((target) => !(remote ? ["remote"] : ["browser", "server"]).includes(target))
|
||||
)
|
||||
throw new Error("Execution scaffold supports browser/server portable targets and remote service handlers");
|
||||
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||
const syntax = parseQx(authored);
|
||||
const packageNode = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||
if (syntax.diagnostics.length || !packageNode) throw new Error("Fix package.qx before adding a handler");
|
||||
for (const node of [...walkSyntax(packageNode)].filter((node) =>
|
||||
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
|
||||
)) {
|
||||
const identifier = node.children.find((child) => child.kind === "identifier");
|
||||
const literal = node.children.find((child) => child.kind === "stringLiteral");
|
||||
if (
|
||||
(identifier && authored.slice(identifier.start, identifier.end) === spec.name) ||
|
||||
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||
)
|
||||
throw new Error("New export requires a unique name and ID");
|
||||
}
|
||||
const packageID = packageNode.children.find((node) => node.kind === "stringLiteral")!;
|
||||
files.push({
|
||||
file: prefix + "package.qx",
|
||||
edits: [
|
||||
{
|
||||
operation: "append",
|
||||
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(packageID.start, packageID.end)) },
|
||||
source: spec.declaration,
|
||||
},
|
||||
],
|
||||
});
|
||||
create(
|
||||
`src/impl/${spec.name}.ts`,
|
||||
`import type { ${remote ? "Remote" : "Portable"}Implementation } from "../gen/${remote ? "remote" : "qx"}.js";\nexport const handler: ${remote ? "Remote" : "Portable"}Implementation[${q(spec.name)}] = async (_context) => { throw new Error(${q("Implement " + spec.name)}); };\n`,
|
||||
);
|
||||
for (const target of targets) {
|
||||
const file = prefix + `src/${target}.ts`;
|
||||
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||
files.push({
|
||||
file,
|
||||
expected: before,
|
||||
replace: addImplementation(before, "createRegistry", spec.name, `./impl/${spec.name}.js`),
|
||||
});
|
||||
}
|
||||
} else
|
||||
throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported");
|
||||
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const node = (value: unknown): value is Node =>
|
||||
* Ambiguous/custom wiring is left alone with an actionable error. */
|
||||
export function addImplementation(
|
||||
text: string,
|
||||
factory: "createRuntime" | "serveMigration",
|
||||
factory: "createRuntime" | "serveMigration" | "createRegistry",
|
||||
key: string,
|
||||
importPath: string,
|
||||
migrationOnly = false,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
||||
|
||||
type Source = { repository: string; commit: string };
|
||||
type PackageEditModel = {
|
||||
@@ -21,7 +22,7 @@ type PackageEditModel = {
|
||||
exports: { name: string; id: string; file: string; migration?: boolean }[];
|
||||
};
|
||||
export type ScaffoldRecipe = {
|
||||
template?: "typescript" | "typescript-react";
|
||||
template?: "typescript" | "typescript-react" | "typescript-execution";
|
||||
source: Source;
|
||||
directory?: string;
|
||||
name?: string;
|
||||
@@ -81,6 +82,13 @@ export const scaffoldRecipe = async (
|
||||
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}/` : "";
|
||||
if (
|
||||
spec.template === "typescript-execution" ||
|
||||
(command !== "package" && (await isExecutionScaffold(root, prefix)))
|
||||
) {
|
||||
if (spec.tools) Object.values(spec.tools).forEach(source);
|
||||
return executionScaffold(root, command, spec, prefix, nixSource);
|
||||
}
|
||||
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 });
|
||||
|
||||
@@ -131,3 +131,7 @@ const compoundCrdt = await compoundCrdtPlan();
|
||||
await mkdir("dist/portable-reader/compound-crdt", { recursive: true });
|
||||
await writeFile("dist/portable-reader/compound-crdt/plan.json", JSON.stringify(compoundCrdt));
|
||||
await writeFile("dist/portable-reader/compound-crdt/kernel.json", JSON.stringify(compileKernelProgram(compoundCrdt)));
|
||||
|
||||
const { emitExecutionScaffold } = await import("./execution-scaffold-fixture.js");
|
||||
const executionScaffold = await emitExecutionScaffold("dist/portable-reader/execution-scaffold/source");
|
||||
await writeFile("dist/portable-reader/execution-scaffold/plan.json", JSON.stringify(executionScaffold));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
|
||||
import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
|
||||
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
|
||||
import { compileExecutionPlan } from "../src/execution-world/plan.js";
|
||||
|
||||
/** Exercise the authoring insertion path, then let the Nix gate build its actual flake output. */
|
||||
export async function emitExecutionScaffold(root: string) {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
execFileSync("git", ["-C", root, "init"], { stdio: "ignore" });
|
||||
const source = { repository: "https://example.test/scaffold.git", commit: "a".repeat(40) };
|
||||
const apply = async (
|
||||
command: Parameters<typeof scaffoldRecipe>[1],
|
||||
spec: Partial<Parameters<typeof scaffoldRecipe>[2]>,
|
||||
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { source, ...spec })));
|
||||
await apply("package", {
|
||||
name: "Scaffold",
|
||||
id: "package:scaffold",
|
||||
revision: "package:scaffold@1",
|
||||
template: "typescript-execution",
|
||||
tools: { quixos: source, protocol: source, helpers: source, sdk: source },
|
||||
});
|
||||
for (const [name, execution] of [
|
||||
["echo", "snapshot targets [browser, server]"],
|
||||
["serverEcho", "snapshot targets [server]"],
|
||||
["remoteEcho", "remote"],
|
||||
]) {
|
||||
await apply("function", {
|
||||
name,
|
||||
id: "scaffold:" + name,
|
||||
declaration: `function ${name} id "scaffold:${name}" : string -> string execution ${execution};`,
|
||||
});
|
||||
const file = path.join(root, `src/impl/${name}.ts`);
|
||||
const placeholder = await fs.readFile(file, "utf8");
|
||||
await fs.writeFile(file, placeholder.replace(/async \(_context\) => \{.*\};/, "async ({ input }) => input;"));
|
||||
}
|
||||
const pkg = compileCapabilityResourceSource(await fs.readFile(path.join(root, "package.qx"), "utf8"), { source });
|
||||
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||
const workspace = compileCapabilitySource(
|
||||
`workspace W id "scaffold-workspace" revision "scaffold-workspace@1" commit "${source.commit}" { import package Scaffold; }`,
|
||||
"workspace.qx",
|
||||
{
|
||||
packages: new Map([["Scaffold", pkg.resource.revision]]),
|
||||
},
|
||||
);
|
||||
assert.ok(workspace.ok, JSON.stringify(workspace.diagnostics));
|
||||
await fs.rm(path.join(root, ".quixos"), { recursive: true, force: true });
|
||||
await fs.rm(path.join(root, ".git"), { recursive: true, force: true });
|
||||
return compileExecutionPlan(workspace.workspace);
|
||||
}
|
||||
@@ -122,3 +122,47 @@ test("imperative scaffolds preserve authored wiring; migration sealing is explic
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("execution scaffolds route typed handlers by declared effect and target without legacy lifecycle files", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-execution-recipe-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
const source = { repository: "https://example.test/execution.git", commit: "a".repeat(40) };
|
||||
const apply = async (
|
||||
command: Parameters<typeof scaffoldRecipe>[1],
|
||||
spec: Partial<Parameters<typeof scaffoldRecipe>[2]>,
|
||||
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { source, ...spec })));
|
||||
await apply("package", {
|
||||
name: "Execution",
|
||||
id: "package:execution",
|
||||
revision: "package:execution@1",
|
||||
template: "typescript-execution",
|
||||
tools: { quixos: source, protocol: source, helpers: source, sdk: source },
|
||||
});
|
||||
for (const file of ["quixos.migrations.json", "src/migrate.ts", "descriptor.quixos-package.txtpb"])
|
||||
await assert.rejects(fs.access(path.join(root, file)));
|
||||
await fs.appendFile(path.join(root, "src/server.ts"), "\n// Preserve authored code\n");
|
||||
const add = (name: string, execution: string) =>
|
||||
apply("function", {
|
||||
name,
|
||||
id: "export:" + name,
|
||||
declaration: `function ${name} id "export:${name}" : unit -> unit execution ${execution};`,
|
||||
});
|
||||
await add("both", "transaction targets [browser, server]");
|
||||
await add("serverOnly", "snapshot targets [server]");
|
||||
await add("asyncWork", "remote");
|
||||
const read = (file: string) => fs.readFile(path.join(root, file), "utf8");
|
||||
assert.match(await read("src/browser.ts"), /"both":/);
|
||||
assert.doesNotMatch(await read("src/browser.ts"), /serverOnly|asyncWork/);
|
||||
assert.match(await read("src/server.ts"), /"serverOnly":/);
|
||||
assert.match(await read("src/server.ts"), /Preserve authored code/);
|
||||
assert.match(await read("src/remote.ts"), /"asyncWork":/);
|
||||
assert.match(await read("src/impl/asyncWork.ts"), /RemoteImplementation/);
|
||||
assert.match(await read("src/impl/both.ts"), /PortableImplementation/);
|
||||
assert.match(await read("flake.nix"), /checkedArtifacts/);
|
||||
assert.match(await read("flake.nix"), /entries.browser/);
|
||||
await assert.rejects(add("both", "transaction targets [browser, server]"), /unique/);
|
||||
await assert.rejects(add("invalid", "snapshot targets [remote]"), /browser\/server/);
|
||||
await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/);
|
||||
await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user