Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,7 @@
"semantic-release": "^25.0.8",
"smol-toml": "^1.7.1",
"tldts": "catalog:",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "catalog:",
"yaml": "^2.9.0"
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { CliError } from "effect/unstable/cli";

Expand DownExpand Up@@ -31,14 +32,88 @@ import {
* forgot to classify".
*/

// Matches every way an error class is defined in this workspace: direct
// `Data.TaggedError("Tag")`, any local `*Error(...)` factory whose heritage
// call carries the tag literal (`CliError("Tag")`, `LoginError("Tag")`, ...),
// and plain `extends Error` classes (identified by class name). Error
// factories must therefore be named `<Something>Error` to stay guarded —
// which also keeps `Data.TaggedClass` event types out of the scan.
const ERROR_DEFINITION_PATTERN =
/TaggedError\(\s*"([A-Za-z0-9_]+)"|class\s+[A-Za-z0-9_]+\s+extends\s+[A-Za-z0-9_.]*Error\(\s*"([A-Za-z0-9_]+)"|class\s+([A-Za-z0-9_]+)\s+extends\s+Error\b/gs;
// The scan below recognizes every way an error class is defined in this
// workspace: direct `Data.TaggedError("Tag")`, any local `*Error(...)` factory
// whose heritage call carries the tag literal (`CliError("Tag")`,
// `LoginError("Tag")`, ...), and plain `extends Error` classes (identified by
// class name). Error factories must therefore be named `<Something>Error` to
// stay guarded — which also keeps `Data.TaggedClass` event types out of the
// scan. It runs on a real TypeScript AST rather than on text, so a definition
// merely *mentioned* in a comment, a string, or a template literal is
// structurally invisible and needs no special casing.

// The simple name of a call's callee: `TaggedError` for both `TaggedError(...)`
// and `Data.TaggedError(...)`.
function calleeName(expression: ts.Expression): string {
if (ts.isIdentifier(expression)) return expression.text;
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
return "";
}

// The value of a plain string literal, seeing through an `as const` assertion
// (`readonly code = "X" as const`). A computed or interpolated string cannot be
// resolved statically, and none exists in this workspace.
function stringLiteralText(expression: ts.Expression | undefined): string | undefined {
const inner =
expression !== undefined && ts.isAsExpression(expression) ? expression.expression : expression;
return inner !== undefined && ts.isStringLiteral(inner) ? inner.text : undefined;
}

function extendsExpression(node: ts.ClassLikeDeclaration): ts.Expression | undefined {
const clause = node.heritageClauses?.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword);
return clause?.types[0]?.expression;
}

function parse(fileName: string, source: string): ts.SourceFile {
return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
}

// Extracts the error identifiers a source file defines: the tag literal of
// every `class X extends <Something>Error("Tag")` heritage call and of every
// free-standing `TaggedError("Tag")` factory call, plus the class name of
// every plain `class X extends Error` (untagged classes are fingerprinted by
// name). A tagged class contributes its tag once — the heritage call is
// claimed by the class rule so the factory rule does not count it again.
function extractErrorTags(source: string, fileName = "scan.ts"): Array<string> {
const tags: Array<string> = [];
const claimed = new Set<ts.Node>();

const visit = (node: ts.Node): void => {
if (ts.isClassLike(node)) {
const heritage = extendsExpression(node);
if (heritage !== undefined && ts.isCallExpression(heritage)) {
const tag = calleeName(heritage.expression).endsWith("Error")
? stringLiteralText(heritage.arguments[0])
: undefined;
if (tag !== undefined) {
tags.push(tag);
claimed.add(heritage);
}
} else if (
heritage !== undefined &&
ts.isIdentifier(heritage) &&
heritage.text === "Error" &&
node.name !== undefined
) {
tags.push(node.name.text);
}
}

if (
ts.isCallExpression(node) &&
!claimed.has(node) &&
calleeName(node.expression).endsWith("TaggedError")
) {
const tag = stringLiteralText(node.arguments[0]);
if (tag !== undefined) tags.push(tag);
}

ts.forEachChild(node, visit);
};

ts.forEachChild(parse(fileName, source), visit);
return tags;
}

function scanErrorTags(root: string): Map<string, Array<string>> {
const tagsByFile = new Map<string, Array<string>>();
Expand All@@ -50,16 +125,49 @@ function scanErrorTags(root: string): Map<string, Array<string>> {
continue;
}
if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue;
const tags = [...readFileSync(path, "utf8").matchAll(ERROR_DEFINITION_PATTERN)].map(
(match) => match[1] ?? match[2] ?? match[3] ?? "",
);
const tags = extractErrorTags(readFileSync(path, "utf8"), path);
if (tags.length > 0) tagsByFile.set(path, tags);
}
};
walk(root);
return tagsByFile;
}

describe("extractErrorTags", () => {
it("finds tagged, factory-tagged and plain error class definitions", () => {
const source = [
'export class TaggedThingError extends Data.TaggedError("TaggedThingError") {}',
'export class FactoryThingError extends CliError("FactoryTag") {}',
"export class PlainThingError extends Error {}",
'const Base = Data.TaggedError("FreeStandingTag");',
].join("\n");
expect(extractErrorTags(source)).toEqual([
"TaggedThingError",
"FactoryTag",
"PlainThingError",
"FreeStandingTag",
]);
});

it("ignores definitions that only appear in comments", () => {
const source = [
"// class Fake extends Error",
'/* e.g. Data.TaggedError("FakeTag") */',
"const x = 1;",
].join("\n");
expect(extractErrorTags(source)).toEqual([]);
});

it("ignores definitions that only appear inside string and template literals", () => {
const source = [
'const a = "class Fake extends Error";',
'const b = `Data.TaggedError("FakeTag")`;',
"const c = 'class AlsoFake extends Error';",
].join("\n");
expect(extractErrorTags(source)).toEqual([]);
});
});

const kindValues = new Set<string>(Object.values(CliErrorKind));
const categoryValues = new Set<string>(Object.values(CliErrorCategory));
const suggestionValues = new Set<string>(Object.values(CliSuggestionType));
Expand DownExpand Up@@ -203,22 +311,51 @@ describe("workspace package error tags have external adapters", () => {
// what keeps the two halves of the contract joined — the (class, tag, code)
// triples in the model must agree with the exported map, the code list, and the
// code-keyed classification table.
const MANAGED_TAGGED_CLASS_PATTERN =
/class\s+([A-Za-z0-9_]+)\s+extends\s+Data\.TaggedError\(\s*"([A-Za-z0-9_]+)",?\s*\)[\s\S]*?readonly\s+code\s*=\s*"([A-Z0-9_]+)"/g;
interface ManagedErrorClass {
readonly className: string;
readonly tag: string;
readonly code: string;
}

// Collects the (class, tag, code) triples of every `class X extends
// Data.TaggedError("Tag")` that also declares a string-literal `code` member.
function scanManagedErrorClasses(path: string): Array<ManagedErrorClass> {
const classes: Array<ManagedErrorClass> = [];
const visit = (node: ts.Node): void => {
if (ts.isClassDeclaration(node) && node.name !== undefined) {
const heritage = extendsExpression(node);
const tag =
heritage !== undefined &&
ts.isCallExpression(heritage) &&
calleeName(heritage.expression) === "TaggedError"
? stringLiteralText(heritage.arguments[0])
: undefined;
const code = stringLiteralText(
node.members
.filter(ts.isPropertyDeclaration)
.find((member) => ts.isIdentifier(member.name) && member.name.text === "code")
?.initializer,
);
if (tag !== undefined && code !== undefined) {
classes.push({ className: node.name.text, tag, code });
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(parse(path, readFileSync(path, "utf8")), visit);
return classes;
}

describe("managed registry error codes are classified", () => {
it("packages/stack/src/managed/model.ts", () => {
const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts");
const matches = [...readFileSync(modelPath, "utf8").matchAll(MANAGED_TAGGED_CLASS_PATTERN)];
// One match per declared code: a class written in a shape this regex cannot
const scanned = scanManagedErrorClasses(modelPath);
// One class per declared code: a class written in a shape this scan cannot
// see would otherwise pass vacuously instead of failing loudly.
expect(matches.length).toBe(MANAGED_ERROR_CODES.length);
expect(scanned.length).toBe(MANAGED_ERROR_CODES.length);
const declaredCodes = new Set<string>(MANAGED_ERROR_CODES);
const scannedCodes = new Set<string>();
for (const match of matches) {
const className = match[1] ?? "";
const tag = match[2] ?? "";
const code = match[3] ?? "";
for (const { className, tag, code } of scanned) {
scannedCodes.add(code);
expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe(
className,
Expand Down
32 changes: 27 additions & 5 deletions packages/stack/docs/architecture.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -314,7 +314,11 @@ That marker protocol is the one place in the managed surface that uses raw `node
of the `FileSystem` service the policy layer reclaims stack state through: writing a temporary file,
hardlinking it into place, re-reading the winning marker on `EEXIST`, and removing the temporary path
is a single indivisible claim, and the hardlink with that `EEXIST` contract is not part of the platform
service's surface.
service's surface. The claim itself is `claimFileAtomically` in `managed/atomic-claim.ts`, shared with
`StateManager`'s single-stack state claim so both settle a race the same way; a filesystem without
hardlinks (`EPERM` or `ENOTSUP`) falls back to an exclusive create, which still decides the race but
publishes without the hardlink's all-or-nothing guarantee. The marker protocol owns what a lost race
means: the identity claim adopts the winning marker, while a claimed stack state is a failure.

No mutable runtime state or credential value is stored in that marker. Read-only discovery does
not create it. The registry stores only an opaque credential reference, never resolved plaintext
Expand DownExpand Up@@ -456,7 +460,11 @@ effects: the fiber scheduler preempts at its operation budget, and a fiber parke
`COMMIT` would let another fiber `BEGIN IMMEDIATE` on the same connection — SQLite refuses the nested
transaction, and either fiber's `COMMIT` could publish the other's writes. Keeping the whole
transaction in one JavaScript turn is therefore what makes a partially applied decision
unobservable and keeps interruption from ever landing inside a transaction.
unobservable and keeps interruption from ever landing inside a transaction. Synchrony cannot rule out
the other way two transactions could meet, a decision that re-enters the repository, so the handles
currently inside a transaction are tracked and a re-entering `BEGIN` is refused before it runs:
SQLite has no nested transactions, and unwinding the inner attempt would roll back the outer
decision's writes.

The database handle's lifetime is a scope. `sqliteManagedStackRepositoryLayer` acquires the handle
with `Effect.acquireRelease`, so opening the file and registering its close are one step nothing can
Expand All@@ -472,7 +480,10 @@ the caller's bound on the entire wait and is applied as a timeout around the rep
the poll instead of being checked between polls. Both shipped adapters answer synchronously, so a look
at the pending row always completes; with an embedder-supplied asynchronous repository that timeout can
preempt a look that is still in flight. That is safe — a look has no side effects — but it means the
option bounds the wait, not the number of looks that finish.
option bounds the wait, not the number of looks that finish. The answer the repeat stops on is checked
rather than asserted through a type refinement: a recurrence bound added to that schedule later would
hand back the final still-pending answer, and the check turns that into a defect instead of an
unpublished stack presented as a published one.

Interruption is part of the contract, not an afterthought. Provisioning owns a pending row, an
operation claim, and the directories it created, so its create path runs under
Expand All@@ -482,7 +493,16 @@ releases its claim the same way. An interrupted call stays interrupted rather th
failure of the work — a caller's own timeout is not a `ManagedStackInitializationError` — and recovery
re-raises interruption instead of recording a retained claim or a reconciliation failure that never
happened, so the operation the next pass should still recover does not look like one recovery already
gave up on.
gave up on. That rule covers the steps whose exits recovery absorbs one at a time — the liveness probe,
the runtime inspection, the state reclamation — not just the pass as a whole.

The single deliberate exception is the claim release on a failed operation's way out: it discards
whatever it raises, its own interruption included, because the caller's outcome is the failure the
operation actually suffered and a release reporting interruption would replace it. The mask itself
begins after the pending row and its claim exist, which is sound only because both shipped adapters
decide synchronously and offer no suspension point during that write. An asynchronous embedder
repository interrupted mid-prepare would leave a pending row and a claim nothing compensates, so the
mask has to be extended over row creation before asynchronous repositories become real.

An Effect consumer provides the composed layer, which is the primary API:

Expand DownExpand Up@@ -537,7 +557,9 @@ alongside those interruptions rather than after them, a statement already on its
race the close and fail against a closed handle: a caller that closes while work is outstanding must
read those rejections as "did not complete", not as evidence about the registry. A call made after
`close()` rejects with an `Error` saying the handle is closed, rather than with the runtime's own bare
internal string. The handle is also an `AsyncDisposable`, so
internal string. That diagnosis comes from the handle's own closed state, never from what a rejection
says, so a caller's callback that refuses with a string mentioning disposal still reaches the caller
as itself. The handle is also an `AsyncDisposable`, so
`await using service = await createManagedStackService()` closes it on every path out of the block. The
facade hands back the very repository the service uses, so an embedder can read the registry without
opening a second handle on it.
Expand Down
28 changes: 12 additions & 16 deletions packages/stack/src/StateManager.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import { Data, Effect, Layer, Schema, Context } from "effect";
import { FileSystem, Path } from "effect";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, rmSync } from "node:fs";
import { link, unlink, writeFile } from "node:fs/promises";
import { claimFileAtomically } from "./managed/atomic-claim.ts";
import { AllocatedPortsSchema, type AllocatedPorts } from "./PortAllocator.ts";
import {
PartialVersionManifestSchema,
Expand DownExpand Up@@ -345,27 +344,24 @@ function makeClaim(deps: StateManagerDeps) {
const dir = deps.stackDir(state.name);
yield* deps.fs.makeDirectory(dir, { recursive: true });
const statePath = deps.stateFile(state.name);
const temporaryPath = `${statePath}.claim-${process.pid}-${randomUUID()}`;
yield* Effect.tryPromise({
try: async () => {
await writeFile(temporaryPath, encodePrettyJson(encodeStackState(state)), { flag: "wx" });
try {
await link(temporaryPath, statePath);
} finally {
await unlink(temporaryPath).catch(() => undefined);
}
},
const outcome = yield* Effect.tryPromise({
try: () => claimFileAtomically(statePath, encodePrettyJson(encodeStackState(state))),
catch: (cause) =>
new StateClaimError({
name: state.name,
path: statePath,
reason:
cause instanceof Error && "code" in cause && cause.code === "EEXIST"
? "already-claimed"
: "io-error",
reason: "io-error",
cause,
}),
});
if (outcome === "already-exists") {
return yield* new StateClaimError({
name: state.name,
path: statePath,
reason: "already-claimed",
cause: undefined,
});
}
}).pipe(
Effect.catchTag("PlatformError", (cause) =>
Effect.fail(
Expand Down
Loading
Loading