From f891095e0d977ee3a1ca971a9604ae6ea8fa0180 Mon Sep 17 00:00:00 2001 From: djgrant <1670902+djgrant@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:41:35 +0100 Subject: [PATCH 1/2] Harden YieldStar deployment scoping and coordination visibility - Encode the deployment segment of resource store IDs so prefix listing in values() and clear() cannot match another deployment whose ID extends this one past the delimiter, which previously allowed cross-deployment orphan deletion. - Emit a durable reconciler.coordination.waiting warning naming the holding execution before an execution suspends on the deployment coordination store, so waiting behind a crashed holder is visible. - Give notation destroy the same terminal error handling as deploy: credentials guidance and a non-zero exit instead of an unhandled rejection. - Add regression tests for deployment store scoping and the coordination waiting event; update reconciler docs. --- docs/internals/reconciler.md | 4 +- docs/manual/reconciler.md | 2 +- packages/cli/src/destroy.ts | 16 +++- packages/reconciler/src/events.ts | 9 +++ packages/reconciler/src/yieldstar.ts | 76 ++++++++++++------- .../test/yieldstar.integration.test.ts | 67 ++++++++++++++++ 6 files changed, 141 insertions(+), 33 deletions(-) diff --git a/docs/internals/reconciler.md b/docs/internals/reconciler.md index 022622c..f1cd730 100644 --- a/docs/internals/reconciler.md +++ b/docs/internals/reconciler.md @@ -33,8 +33,8 @@ Every provider call, event emission, state read, state write, and coordination t Each resource is stored under `notation/resource-state` with a deployment-scoped ID. Conditional updates and deletes compare the snapshot's UUIDv7 `instanceId` and version, so a stale execution cannot modify a deleted and recreated store. -Deploy and destroy share one `notation/deployment-coordination` store per deployment. `store.take` suspends a competing execution as a durable waiter and wakes it after the holder releases. +Deploy and destroy share one `notation/deployment-coordination` store per deployment. `store.take` suspends a competing execution as a durable waiter and wakes it after the holder releases. Before suspending, the waiter emits `reconciler.coordination.waiting` naming the holding execution ID, so a wait behind a crashed execution is visible instead of silent. ## Events -The durable workflows emit `reconciler.deploy.decision`, `reconciler.drift.detected`, `reconciler.operation.lifecycle`, and `reconciler.orphan-deletion.skipped`. Lifecycle events cover create, read, update, and delete with `start`, `success`, `error`, `skip`, or `dry-run` status. +The durable workflows emit `reconciler.deploy.decision`, `reconciler.drift.detected`, `reconciler.operation.lifecycle`, `reconciler.coordination.waiting`, and `reconciler.orphan-deletion.skipped`. Lifecycle events cover create, read, update, and delete with `start`, `success`, `error`, `skip`, or `dry-run` status. diff --git a/docs/manual/reconciler.md b/docs/manual/reconciler.md index 60c1861..47e7358 100644 --- a/docs/manual/reconciler.md +++ b/docs/manual/reconciler.md @@ -38,7 +38,7 @@ The outer workflow supplies durable step execution, timers, shared stores, waiti Each live resource is one YieldStar store. Absence is represented by no store, not a tombstone. YieldStar's UUIDv7 store `instanceId` and version are authoritative for conditional update and delete; Notation exposes the version as the resource state's `rev`. -Operations against the same `deploymentId` are serialized through a coordination store keyed by `executionId`. Resume a crashed operation with the same execution ID; use a new globally unique execution ID for every new deploy or destroy. +Operations against the same `deploymentId` are serialized through a coordination store keyed by `executionId`. Resume a crashed operation with the same execution ID; use a new globally unique execution ID for every new deploy or destroy. An execution that must wait emits a `reconciler.coordination.waiting` event naming the holder before it suspends, which also identifies a crashed holder that should be resumed instead. Pass the complete desired set on every deployment. Persisted resources absent from that set are deleted through the supplied resource registry. Destroy removes current resources in reverse dependency order and then removes any persisted orphans that the registry can hydrate. diff --git a/packages/cli/src/destroy.ts b/packages/cli/src/destroy.ts index 543c3e6..ba2e6f3 100644 --- a/packages/cli/src/destroy.ts +++ b/packages/cli/src/destroy.ts @@ -27,5 +27,19 @@ export async function destroy( logger.info(`Destroying ${entryPoint}\n`); const executionId = opts.executionId ?? randomUUID(); logger.info(`YieldStar execution ${executionId}`); - await destroyApp({ entryPoint, emit, executionId }); + + try { + await destroyApp({ entryPoint, emit, executionId }); + } catch (err: any) { + if (err.name === "CredentialsProviderError") { + logger.error( + "\nAWS credentials not found.", + "\n\nEnsure you have a default profile set up in ~/.aws/credentials.", + "\n\nIf using another profile run AWS_PROFILE=otherProfile notation destroy.\n", + ); + process.exit(1); + } + logger.error(err); + process.exit(1); + } } diff --git a/packages/reconciler/src/events.ts b/packages/reconciler/src/events.ts index 7b883ac..2cad300 100644 --- a/packages/reconciler/src/events.ts +++ b/packages/reconciler/src/events.ts @@ -33,8 +33,17 @@ export type ReconcilerDriftDetectedEvent = { diff: Record; }; +export type CoordinationWaitingEvent = { + level: "warn"; + event: "reconciler.coordination.waiting"; + deploymentId: string; + executionId: string; + holderExecutionId: string; +}; + export type ReconcilerEvent = | OperationLifecycleEvent + | CoordinationWaitingEvent | ReconcilerDeployEvent | ReconcilerDriftDetectedEvent | import("./resource-registry").MissingResourceRegistryMatchWarningEvent; diff --git a/packages/reconciler/src/yieldstar.ts b/packages/reconciler/src/yieldstar.ts index 6c249ce..a8845bd 100644 --- a/packages/reconciler/src/yieldstar.ts +++ b/packages/reconciler/src/yieldstar.ts @@ -81,18 +81,7 @@ export async function* deployWithYieldStar( step: YieldStarStep, opts: YieldStarDeployOptions, ): AsyncGenerator { - const coordination = yield* step.store(yieldStarDeploymentCoordinationStore, { - id: opts.deploymentId, - initial: { holder: null }, - }); - - yield* coordination.take( - "notation:coordination:acquire", - (state) => state.holder === null || state.holder === opts.executionId, - (draft) => { - draft.holder = opts.executionId; - }, - ); + const coordination = yield* acquireDeploymentCoordination(step, opts); try { const resourceById = new Map( @@ -149,18 +138,7 @@ export async function* destroyWithYieldStar( step: YieldStarStep, opts: YieldStarDestroyOptions, ): AsyncGenerator { - const coordination = yield* step.store(yieldStarDeploymentCoordinationStore, { - id: opts.deploymentId, - initial: { holder: null }, - }); - - yield* coordination.take( - "notation:coordination:acquire", - (state) => state.holder === null || state.holder === opts.executionId, - (draft) => { - draft.holder = opts.executionId; - }, - ); + const coordination = yield* acquireDeploymentCoordination(step, opts); try { const resourceById = new Map( @@ -218,6 +196,43 @@ export async function* destroyWithYieldStar( } } +/** + * Serializes deploy and destroy per deployment. A stale holder (a crashed + * execution that was never resumed) parks this execution as a durable waiter, + * so the wait is surfaced as a warning event before suspending. + */ +async function* acquireDeploymentCoordination( + step: YieldStarStep, + opts: YieldStarOperationOptions, +): AsyncGenerator, any> { + const coordination = yield* step.store(yieldStarDeploymentCoordinationStore, { + id: opts.deploymentId, + initial: { holder: null }, + }); + + const snapshot = yield* coordination.get("notation:coordination:inspect"); + const holder = snapshot.state.holder; + if (holder !== null && holder !== opts.executionId) { + yield* emitDurably(step, "notation:coordination:waiting", opts.emit, () => ({ + level: "warn", + event: "reconciler.coordination.waiting", + deploymentId: opts.deploymentId, + executionId: opts.executionId, + holderExecutionId: holder, + })); + } + + yield* coordination.take( + "notation:coordination:acquire", + (state) => state.holder === null || state.holder === opts.executionId, + (draft) => { + draft.holder = opts.executionId; + }, + ); + + return coordination; +} + async function* reconcileResource( step: YieldStarStep, resource: BaseResource, @@ -646,14 +661,19 @@ function emitOperationLifecycle( export class YieldStarStateBackend { readonly #client: StoreClient; readonly #deploymentId: string; + // The deployment segment is URI-encoded so the ":" delimiter cannot appear + // inside it; otherwise deployment "app" would match stores of "app:blue" + // during prefix listing and delete them as orphans. + readonly #prefix: string; constructor(client: StoreClient, deploymentId: string) { this.#client = client; this.#deploymentId = deploymentId; + this.#prefix = `${encodeURIComponent(deploymentId)}:`; } storeId(resourceId: string) { - return `${this.#deploymentId}:${resourceId}`; + return `${this.#prefix}${resourceId}`; } async get(id: string): Promise { @@ -740,11 +760,10 @@ export class YieldStarStateBackend { } async values(): Promise { - const prefix = `${this.#deploymentId}:`; const ids = await this.#client.listStores(yieldStarResourceStateStore); const snapshots = await Promise.all( ids - .filter((id) => id.startsWith(prefix)) + .filter((id) => id.startsWith(this.#prefix)) .map((id) => this.#tryGetSnapshot(id)), ); return snapshots @@ -760,11 +779,10 @@ export class YieldStarStateBackend { } async clear(): Promise { - const prefix = `${this.#deploymentId}:`; const ids = await this.#client.listStores(yieldStarResourceStateStore); await Promise.all( ids - .filter((id) => id.startsWith(prefix)) + .filter((id) => id.startsWith(this.#prefix)) .map((id) => this.#client.deleteStore({ definition: yieldStarResourceStateStore, diff --git a/packages/reconciler/test/yieldstar.integration.test.ts b/packages/reconciler/test/yieldstar.integration.test.ts index 043d700..6fb5e36 100644 --- a/packages/reconciler/test/yieldstar.integration.test.ts +++ b/packages/reconciler/test/yieldstar.integration.test.ts @@ -253,6 +253,73 @@ describe("YieldStar reconciliation", () => { runtime.close(); }); + it("emits a coordination waiting event when another execution holds the deployment", async () => { + let unblockCreate!: () => void; + const blocked = new Promise((resolve) => { + unblockCreate = resolve; + }); + let started!: () => void; + const createStarted = new Promise((resolve) => { + started = resolve; + }); + const TestResource = resource({ type: "test/yieldstar/coordination" }) + .defineSchema({}) + .defineOperations({ + create: async () => { + started(); + await blocked; + }, + delete: async () => undefined, + }); + const events: ReconcilerEvent[] = []; + const runtime = createRuntime( + [new TestResource({ id: "held" })], + "coordination-waiting", + { emit: (event) => void events.push(event) }, + ); + + const first = runtime.run("holder-execution"); + await createStarted; + await runtime.run("waiter-execution"); + + expect( + events.find( + (event) => event.event === "reconciler.coordination.waiting", + ), + ).toMatchObject({ + level: "warn", + deploymentId: "coordination-waiting", + executionId: "waiter-execution", + holderExecutionId: "holder-execution", + }); + + unblockCreate(); + await first; + runtime.close(); + }); + + it("scopes store listing to the exact deployment despite prefix-like IDs", async () => { + const database = createSqliteDb({ path: ":memory:" }); + const storeClient = new SqliteStoreClient({ + db: database, + schedulerClient: new TestScheduler(), + }); + const app = new YieldStarStateBackend(storeClient, "app"); + const appBlue = new YieldStarStateBackend(storeClient, "app:blue"); + + await app.update("site", 0, statePatch("site")); + await appBlue.update("site", 0, statePatch("site")); + + expect(await app.values()).toHaveLength(1); + expect(await appBlue.values()).toHaveLength(1); + + await app.clear(); + expect(await app.values()).toHaveLength(0); + expect(await appBlue.values()).toHaveLength(1); + expect(await appBlue.get("site")).toBeDefined(); + database.close(); + }); + it("deletes orphaned resources through the registry on a later deployment", async () => { const deleteSpy = vi.fn(async () => undefined); const OrphanResource = resource({ type: "test/yieldstar/orphan" }) From 752c34ecc01e98b08faeecb7d581c22e6f6756bc Mon Sep 17 00:00:00 2001 From: djgrant <1670902+djgrant@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:21:51 +0100 Subject: [PATCH 2/2] Simplify Yieldstar naming and consolidate CLI error wiring - Rename all Notation-owned YieldStar identifiers to Yieldstar (no aliases) - Rename YieldStar prose to Yieldstar across docs, examples, and changeset - Extract shared runWithCliErrorHandling helper from deploy/destroy commands - Leave upstream package imports (@yieldstar/core, yieldstar) unchanged --- .changeset/reconciler.md | 2 +- docs/cli/dashboard.md | 2 +- docs/cli/deploy.md | 6 +- docs/cli/destroy.md | 2 +- docs/internals/reconciler.md | 8 +- docs/internals/state.md | 8 +- docs/manual/introduction.md | 2 +- docs/manual/reconciler.md | 14 ++-- docs/rfcs/reconciler.md | 18 ++-- examples/reconciler/README.md | 4 +- examples/reconciler/src/index.ts | 8 +- packages/cli/src/deploy.ts | 27 ++---- packages/cli/src/destroy.ts | 21 ++--- packages/cli/src/index.ts | 4 +- packages/cli/src/run-with-error-handling.ts | 21 +++++ .../provisioner/workflows/workflow.deploy.ts | 10 +-- .../provisioner/workflows/workflow.destroy.ts | 10 +-- .../provisioner/workflows/workflow.plan.ts | 6 +- .../core/src/provisioner/yieldstar-runtime.ts | 20 ++--- .../provisioner/yieldstar-runtime.test.ts | 10 +-- packages/reconciler/src/index.ts | 2 +- packages/reconciler/src/yieldstar.ts | 84 +++++++++---------- .../test/yieldstar.integration.test.ts | 22 ++--- 23 files changed, 154 insertions(+), 157 deletions(-) create mode 100644 packages/cli/src/run-with-error-handling.ts diff --git a/.changeset/reconciler.md b/.changeset/reconciler.md index 98300a3..f28bafd 100644 --- a/.changeset/reconciler.md +++ b/.changeset/reconciler.md @@ -9,4 +9,4 @@ "@notation/state-sqlite": minor --- -Add durable YieldStar 0.5.0 deploy and destroy workflows, a resident Node SQLite runtime for CLI execution, versioned event streams, backend-neutral dashboard state, and compiled infrastructure graphs. +Add durable Yieldstar 0.5.0 deploy and destroy workflows, a resident Node SQLite runtime for CLI execution, versioned event streams, backend-neutral dashboard state, and compiled infrastructure graphs. diff --git a/docs/cli/dashboard.md b/docs/cli/dashboard.md index d30ab74..2baffaf 100644 --- a/docs/cli/dashboard.md +++ b/docs/cli/dashboard.md @@ -4,7 +4,7 @@ notation dashboard ``` -Starts a local web dashboard for observing the deployment's YieldStar resource stores. +Starts a local web dashboard for observing the deployment's Yieldstar resource stores. ```sh notation dashboard infra/api.ts diff --git a/docs/cli/deploy.md b/docs/cli/deploy.md index 97b8d70..3e81379 100644 --- a/docs/cli/deploy.md +++ b/docs/cli/deploy.md @@ -4,7 +4,7 @@ notation deploy ``` -Compiles and durably deploys the stack through the resident YieldStar 0.5.0 Node runtime. +Compiles and durably deploys the stack through the resident Yieldstar 0.5.0 Node runtime. ```sh notation deploy infra/api.ts @@ -20,7 +20,7 @@ notation deploy infra/api.ts --json > deploy.ndjson ## Durable execution -The command prints its YieldStar execution ID before starting provider work. If the process crashes, resume the same durable heap with that ID: +The command prints its Yieldstar execution ID before starting provider work. If the process crashes, resume the same durable heap with that ID: ```sh notation deploy infra/api.ts --execution-id @@ -36,7 +36,7 @@ Retryable provider conditions and consistency reads suspend on durable SQLite ti 2. **Build resource graph** – the worker imports the compiled output and collects declared resources. -3. **Reconcile** – Notation compares desired resources with YieldStar stores, then creates, updates, recreates, or leaves each resource unchanged. +3. **Reconcile** – Notation compares desired resources with Yieldstar stores, then creates, updates, recreates, or leaves each resource unchanged. 4. **Order dependencies** – dependency levels run in topological order. diff --git a/docs/cli/destroy.md b/docs/cli/destroy.md index 624bd3b..c25a386 100644 --- a/docs/cli/destroy.md +++ b/docs/cli/destroy.md @@ -4,7 +4,7 @@ notation destroy ``` -Compiles the application and runs durable destroy through the resident YieldStar 0.5.0 Node runtime. Resources are removed in reverse dependency order, then registered persisted orphans are removed. +Compiles the application and runs durable destroy through the resident Yieldstar 0.5.0 Node runtime. Resources are removed in reverse dependency order, then registered persisted orphans are removed. ```sh notation destroy infra/api.ts diff --git a/docs/internals/reconciler.md b/docs/internals/reconciler.md index f1cd730..73f89d4 100644 --- a/docs/internals/reconciler.md +++ b/docs/internals/reconciler.md @@ -1,10 +1,10 @@ # Reconciler -The reconciler expresses deployment and destruction as YieldStar async generators. Notation owns desired-state decisions and provider lifecycle; the caller's YieldStar runtime owns durable execution, waiting, shared state, and coordination. +The reconciler expresses deployment and destruction as Yieldstar async generators. Notation owns desired-state decisions and provider lifecycle; the caller's Yieldstar runtime owns durable execution, waiting, shared state, and coordination. ## Deploy flow -`deployWithYieldStar` acquires the deployment coordination store, walks dependency levels in order, decides an action for every resource, executes provider calls as durable steps, persists the result in a resource store, and deletes registered orphans. +`deployWithYieldstar` acquires the deployment coordination store, walks dependency levels in order, decides an action for every resource, executes provider calls as durable steps, persists the result in a resource store, and deletes registered orphans. | Condition | Decision | | --- | --- | @@ -19,13 +19,13 @@ Dry-run deploy performs decisions and emits lifecycle events without calling pro ## Destroy flow -`destroyWithYieldStar` is a first-class durable operation. It acquires the same deployment coordination store as deploy, deletes desired resources in reverse dependency order, deletes hydratable persisted orphans, and conditionally removes each resource store only after the provider delete succeeds or reports that the resource is already absent. +`destroyWithYieldstar` is a first-class durable operation. It acquires the same deployment coordination store as deploy, deletes desired resources in reverse dependency order, deletes hydratable persisted orphans, and conditionally removes each resource store only after the provider delete succeeds or reports that the resource is already absent. Provider delete is a stable durable step. If the process crashes after the provider acknowledges deletion but before state removal, replay uses the cached delete result and continues at the conditional store delete. ## Waiting and replay -Retryable provider errors become YieldStar `RetryableError` delays. The resident Node runtime can remain idle until the SQLite timer queues a wake-up, then rebuild the resource graph and replay completed heap steps. Reads that wait for provider consistency use the same mechanism. +Retryable provider errors become Yieldstar `RetryableError` delays. The resident Node runtime can remain idle until the SQLite timer queues a wake-up, then rebuild the resource graph and replay completed heap steps. Reads that wait for provider consistency use the same mechanism. Every provider call, event emission, state read, state write, and coordination transition has a stable step key. A resumed execution must use the same execution ID. A new deploy or destroy must use a new execution ID so its heap does not alias an earlier operation. diff --git a/docs/internals/state.md b/docs/internals/state.md index 0fd06c0..410fa34 100644 --- a/docs/internals/state.md +++ b/docs/internals/state.md @@ -1,14 +1,14 @@ # State -Notation CLI deploy, destroy, plan, and dashboard use YieldStar 0.5.0 stores in `.notation/workflows.db`. Override the database path with `NOTATION_STATE_PATH`. +Notation CLI deploy, destroy, plan, and dashboard use Yieldstar 0.5.0 stores in `.notation/workflows.db`. Override the database path with `NOTATION_STATE_PATH`. Each live resource is a `notation/resource-state` store scoped by deployment and resource ID. A missing store means the resource is absent. No application tombstone is written. ```ts -const state = new YieldStarStateBackend(storeClient, "infra/api.ts"); +const state = new YieldstarStateBackend(storeClient, "infra/api.ts"); ``` -The runtime assigns a UUIDv7 `instanceId` when a store is created and increments its version on update. Conditional workflow updates and deletes compare both values, preventing a stale snapshot from modifying a deleted and recreated resource. The one-based value exposed as `StateNode.rev` is derived from the authoritative YieldStar store version. +The runtime assigns a UUIDv7 `instanceId` when a store is created and increments its version on update. Conditional workflow updates and deletes compare both values, preventing a stale snapshot from modifying a deleted and recreated resource. The one-based value exposed as `StateNode.rev` is derived from the authoritative Yieldstar store version. ```ts interface StateBackend { @@ -20,6 +20,6 @@ interface StateBackend { } ``` -Coordination is not part of the state backend contract. The outer YieldStar workflow serializes deploy and destroy through a deployment coordination store and records applied store steps for crash-safe replay. +Coordination is not part of the state backend contract. The outer Yieldstar workflow serializes deploy and destroy through a deployment coordination store and records applied store steps for crash-safe replay. `MemoryStateBackend`, `FileStateBackend`, and `SqliteStateBackend` remain data adapters for tests and embedded read/write consumers. They are not CLI execution runtimes and do not provide mutation coordination. diff --git a/docs/manual/introduction.md b/docs/manual/introduction.md index 4a833dc..41cafca 100644 --- a/docs/manual/introduction.md +++ b/docs/manual/introduction.md @@ -13,7 +13,7 @@ todoRouter.get("/todos", getTodos); Notation is a compiler, reconciler, and deployment engine. -The reconciler is also available as an embedded library. A Node.js host can construct resources and compose durable reconciliation inside its own YieldStar workflow without the CLI. +The reconciler is also available as an embedded library. A Node.js host can construct resources and compose durable reconciliation inside its own Yieldstar workflow without the CLI. The compiler runs two passes over your codebase: diff --git a/docs/manual/reconciler.md b/docs/manual/reconciler.md index 47e7358..902a2bb 100644 --- a/docs/manual/reconciler.md +++ b/docs/manual/reconciler.md @@ -1,10 +1,10 @@ # Reconciler -Use `deployWithYieldStar` and `destroyWithYieldStar` when a Node.js application needs durable resource lifecycle operations without starting the Notation CLI. Notation owns reconciliation intent, graph ordering, provider calls, and resource state; the application owns the outer YieldStar workflow and runtime. +Use `deployWithYieldstar` and `destroyWithYieldstar` when a Node.js application needs durable resource lifecycle operations without starting the Notation CLI. Notation owns reconciliation intent, graph ordering, provider calls, and resource state; the application owns the outer Yieldstar workflow and runtime. ```ts import { SqliteSchedulerClient, SqliteStoreClient, SqliteTaskQueueClient, SqliteTimersClient, createSqliteDb } from "@yieldstar/sqlite-runtime/node"; -import { YieldStarStateBackend, deployWithYieldStar, destroyWithYieldStar } from "@notation/reconciler"; +import { YieldstarStateBackend, deployWithYieldstar, destroyWithYieldstar } from "@notation/reconciler"; import { workflow } from "yieldstar"; const database = createSqliteDb({ path: ".notation/workflows.db" }); @@ -13,10 +13,10 @@ const schedulerClient = new SqliteSchedulerClient({ timersClient: new SqliteTimersClient(database), }); const storeClient = new SqliteStoreClient({ db: database, schedulerClient }); -const state = new YieldStarStateBackend(storeClient, "my-application"); +const state = new YieldstarStateBackend(storeClient, "my-application"); export const deploy = workflow(async function* (step, event) { - yield* deployWithYieldStar(step, { + yield* deployWithYieldstar(step, { deploymentId: "my-application", executionId: event.executionId, resources, @@ -25,7 +25,7 @@ export const deploy = workflow(async function* (step, event) { }); export const destroy = workflow(async function* (step, event) { - yield* destroyWithYieldStar(step, { + yield* destroyWithYieldstar(step, { deploymentId: "my-application", executionId: event.executionId, resources, @@ -34,9 +34,9 @@ export const destroy = workflow(async function* (step, event) { }); ``` -The outer workflow supplies durable step execution, timers, shared stores, waiting, scheduling, and coordination. Completed provider calls are replayed from the heap after a crash, retryable provider conditions suspend on a durable timer, and conditional state writes use YieldStar store identity and version. +The outer workflow supplies durable step execution, timers, shared stores, waiting, scheduling, and coordination. Completed provider calls are replayed from the heap after a crash, retryable provider conditions suspend on a durable timer, and conditional state writes use Yieldstar store identity and version. -Each live resource is one YieldStar store. Absence is represented by no store, not a tombstone. YieldStar's UUIDv7 store `instanceId` and version are authoritative for conditional update and delete; Notation exposes the version as the resource state's `rev`. +Each live resource is one Yieldstar store. Absence is represented by no store, not a tombstone. Yieldstar's UUIDv7 store `instanceId` and version are authoritative for conditional update and delete; Notation exposes the version as the resource state's `rev`. Operations against the same `deploymentId` are serialized through a coordination store keyed by `executionId`. Resume a crashed operation with the same execution ID; use a new globally unique execution ID for every new deploy or destroy. An execution that must wait emits a `reconciler.coordination.waiting` event naming the holder before it suspends, which also identifies a crashed holder that should be resumed instead. diff --git a/docs/rfcs/reconciler.md b/docs/rfcs/reconciler.md index eabaa70..a43c2ac 100644 --- a/docs/rfcs/reconciler.md +++ b/docs/rfcs/reconciler.md @@ -1,28 +1,28 @@ -# RFC: Durable YieldStar reconciliation +# RFC: Durable Yieldstar reconciliation **Status:** implemented -**Scope:** `@notation/reconciler`, `@notation/core`, YieldStar 0.5.0 +**Scope:** `@notation/reconciler`, `@notation/core`, Yieldstar 0.5.0 -Notation describes reconciliation intent and resource lifecycle operations. An outer YieldStar workflow supplies durable execution, waiting, state, and coordination by composing `deployWithYieldStar` or `destroyWithYieldStar`. +Notation describes reconciliation intent and resource lifecycle operations. An outer Yieldstar workflow supplies durable execution, waiting, state, and coordination by composing `deployWithYieldstar` or `destroyWithYieldstar`. ## Boundary -Live resource objects remain in the workflow process. They are not serialized into workflow parameters. This keeps provider clients and operation closures under Notation's lifecycle control while YieldStar persists step results and shared state. +Live resource objects remain in the workflow process. They are not serialized into workflow parameters. This keeps provider clients and operation closures under Notation's lifecycle control while Yieldstar persists step results and shared state. -Provider create, update, read, and delete calls are durable steps with stable resource-scoped keys. A process crash after a completed provider call replays the cached result and continues at state persistence instead of repeating the call. Retryable provider conditions become YieldStar delays, allowing the process to wait without polling the provider continuously. +Provider create, update, read, and delete calls are durable steps with stable resource-scoped keys. A process crash after a completed provider call replays the cached result and continues at state persistence instead of repeating the call. Retryable provider conditions become Yieldstar delays, allowing the process to wait without polling the provider continuously. ## State lifecycle -`YieldStarStateBackend` stores one live resource per `notation/resource-state` store. The store ID is scoped by deployment and resource ID. Store absence is resource absence. +`YieldstarStateBackend` stores one live resource per `notation/resource-state` store. The store ID is scoped by deployment and resource ID. Store absence is resource absence. -The runtime-assigned UUIDv7 `instanceId` distinguishes a deleted store from a later store created under the same logical ID. YieldStar's version is the concurrency token and is exposed as Notation's one-based `rev`. Workflow updates use `store.updateFrom` and deletes use `store.deleteFrom`, so both the instance and version must match the snapshot that informed the operation. +The runtime-assigned UUIDv7 `instanceId` distinguishes a deleted store from a later store created under the same logical ID. Yieldstar's version is the concurrency token and is exposed as Notation's one-based `rev`. Workflow updates use `store.updateFrom` and deletes use `store.deleteFrom`, so both the instance and version must match the snapshot that informed the operation. ## Coordination -Each deployment has a `notation/deployment-coordination` store shared by deploy and destroy. The workflow atomically claims it with `store.take`. A concurrent execution suspends as a durable waiter and is woken when the holder releases the store. The same execution can recover an acquisition across the store-commit and heap-write crash gap through YieldStar's applied-step ledger. +Each deployment has a `notation/deployment-coordination` store shared by deploy and destroy. The workflow atomically claims it with `store.take`. A concurrent execution suspends as a durable waiter and is woken when the holder releases the store. The same execution can recover an acquisition across the store-commit and heap-write crash gap through Yieldstar's applied-step ledger. ## Node CLI runtime -`NodeYieldStarRuntime` wires `WorkflowRunner`, `SqliteHeapClient`, `SqliteStoreClient`, `SqliteSchedulerClient`, and `SqliteEventLoop` against one Node SQLite database. CLI deploy and destroy run through this resident runtime and wait for a workflow result across timer and store wake-ups. +`NodeYieldstarRuntime` wires `WorkflowRunner`, `SqliteHeapClient`, `SqliteStoreClient`, `SqliteSchedulerClient`, and `SqliteEventLoop` against one Node SQLite database. CLI deploy and destroy run through this resident runtime and wait for a workflow result across timer and store wake-ups. The CLI prints a new execution ID for each operation. Re-running with `--execution-id ` resumes that operation from its durable heap after a process crash. diff --git a/examples/reconciler/README.md b/examples/reconciler/README.md index 0dfdf4c..0c88c12 100644 --- a/examples/reconciler/README.md +++ b/examples/reconciler/README.md @@ -1,8 +1,8 @@ # Durable reconciler -This example deploys two static sites from an ordinary Node.js program using YieldStar 0.5.0 for durable execution, state, retries, waiting, and deployment coordination. +This example deploys two static sites from an ordinary Node.js program using Yieldstar 0.5.0 for durable execution, state, retries, waiting, and deployment coordination. -[`src/index.ts`](./src/index.ts) owns the outer workflow and Node SQLite runtime. It passes YieldStar's `step` context to `deployWithYieldStar`, while [`src/static-site.ts`](./src/static-site.ts) contains only the desired resources and provider lifecycle operations. +[`src/index.ts`](./src/index.ts) owns the outer workflow and Node SQLite runtime. It passes Yieldstar's `step` context to `deployWithYieldstar`, while [`src/static-site.ts`](./src/static-site.ts) contains only the desired resources and provider lifecycle operations. Run it from the repository root: diff --git a/examples/reconciler/src/index.ts b/examples/reconciler/src/index.ts index edf2b93..67853dd 100644 --- a/examples/reconciler/src/index.ts +++ b/examples/reconciler/src/index.ts @@ -8,9 +8,9 @@ import { createSqliteDb, } from "@yieldstar/sqlite-runtime/node"; import { - YieldStarStateBackend, + YieldstarStateBackend, createResourceRegistry, - deployWithYieldStar, + deployWithYieldstar, } from "@notation/reconciler"; import pino from "pino"; import { createWorkflowRouter, workflow } from "yieldstar"; @@ -24,7 +24,7 @@ const schedulerClient = new SqliteSchedulerClient({ timersClient: new SqliteTimersClient(database), }); const storeClient = new SqliteStoreClient({ db: database, schedulerClient }); -const state = new YieldStarStateBackend(storeClient, "static-sites"); +const state = new YieldstarStateBackend(storeClient, "static-sites"); const resources = [ new StaticSite({ @@ -44,7 +44,7 @@ const resources = [ ]; const deploy = workflow(async function* (step, event) { - yield* deployWithYieldStar(step, { + yield* deployWithYieldstar(step, { deploymentId: "static-sites", executionId: event.executionId, resources, diff --git a/packages/cli/src/deploy.ts b/packages/cli/src/deploy.ts index ce85607..ec1ff9e 100644 --- a/packages/cli/src/deploy.ts +++ b/packages/cli/src/deploy.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import { compile } from "./compile"; import { defaultLogger, type Logger } from "./logger"; import { redirectStdoutToStderr } from "./stdio"; +import { runWithCliErrorHandling } from "./run-with-error-handling"; export type DeployCommandOptions = { json?: boolean; @@ -19,8 +20,6 @@ export async function deploy( opts: DeployCommandOptions = {}, ) { const logger = opts.logger ?? defaultLogger; - // In --json mode console output moves to stderr so stdout carries only the - // NDJSON event stream; capture the real stdout for the emitter first. const emit = opts.json ? createNdjsonEventEmitter(redirectStdoutToStderr().write) : createLoggerReconcilerSubscriber({ logger }); @@ -28,24 +27,10 @@ export async function deploy( await compile(entryPoint, { logger }); logger.info(`Deploying ${entryPoint}`); const executionId = opts.executionId ?? randomUUID(); - logger.info(`YieldStar execution ${executionId}`); + logger.info(`Yieldstar execution ${executionId}`); - try { - await deployApp({ - entryPoint, - emit, - executionId, - }); - } catch (err: any) { - if (err.name === "CredentialsProviderError") { - logger.error( - "\nAWS credentials not found.", - "\n\nEnsure you have a default profile set up in ~/.aws/credentials.", - "\n\nIf using another profile run AWS_PROFILE=otherProfile notation deploy.\n", - ); - process.exit(1); - } - logger.error(err); - process.exit(1); - } + await runWithCliErrorHandling( + () => deployApp({ entryPoint, emit, executionId }), + { logger, command: "deploy" }, + ); } diff --git a/packages/cli/src/destroy.ts b/packages/cli/src/destroy.ts index ba2e6f3..89ea2f1 100644 --- a/packages/cli/src/destroy.ts +++ b/packages/cli/src/destroy.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import { compile } from "./compile"; import { defaultLogger, type Logger } from "./logger"; import { redirectStdoutToStderr } from "./stdio"; +import { runWithCliErrorHandling } from "./run-with-error-handling"; export type DestroyCommandOptions = { json?: boolean; @@ -26,20 +27,10 @@ export async function destroy( await compile(entryPoint, { logger }); logger.info(`Destroying ${entryPoint}\n`); const executionId = opts.executionId ?? randomUUID(); - logger.info(`YieldStar execution ${executionId}`); + logger.info(`Yieldstar execution ${executionId}`); - try { - await destroyApp({ entryPoint, emit, executionId }); - } catch (err: any) { - if (err.name === "CredentialsProviderError") { - logger.error( - "\nAWS credentials not found.", - "\n\nEnsure you have a default profile set up in ~/.aws/credentials.", - "\n\nIf using another profile run AWS_PROFILE=otherProfile notation destroy.\n", - ); - process.exit(1); - } - logger.error(err); - process.exit(1); - } + await runWithCliErrorHandling( + () => destroyApp({ entryPoint, emit, executionId }), + { logger, command: "destroy" }, + ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f437e6b..b623a63 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,7 @@ import { plan } from "./plan"; import { visualise } from "./visualise"; import { watch } from "./watch"; import { startDashboardServer } from "@notation/dashboard"; -import { NodeYieldStarRuntime } from "@notation/core"; +import { NodeYieldstarRuntime } from "@notation/core"; program .command("compile") @@ -22,7 +22,7 @@ program .argument("", "entryPoint") .description("Start Notation Dashboard") .action(async (entryPoint) => { - const runtime = new NodeYieldStarRuntime({ deploymentId: entryPoint }); + const runtime = new NodeYieldstarRuntime({ deploymentId: entryPoint }); await startDashboardServer({ state: runtime.state }); }); diff --git a/packages/cli/src/run-with-error-handling.ts b/packages/cli/src/run-with-error-handling.ts new file mode 100644 index 0000000..51c9f8d --- /dev/null +++ b/packages/cli/src/run-with-error-handling.ts @@ -0,0 +1,21 @@ +import type { Logger } from "./logger"; + +export async function runWithCliErrorHandling( + fn: () => Promise, + opts: { logger: Logger; command: string }, +): Promise { + try { + await fn(); + } catch (err: any) { + if (err.name === "CredentialsProviderError") { + opts.logger.error( + "\nAWS credentials not found.", + "\n\nEnsure you have a default profile set up in ~/.aws/credentials.", + `\n\nIf using another profile run AWS_PROFILE=otherProfile notation ${opts.command}.\n`, + ); + process.exit(1); + } + opts.logger.error(err); + process.exit(1); + } +} diff --git a/packages/core/src/provisioner/workflows/workflow.deploy.ts b/packages/core/src/provisioner/workflows/workflow.deploy.ts index afe3de9..f07ee44 100644 --- a/packages/core/src/provisioner/workflows/workflow.deploy.ts +++ b/packages/core/src/provisioner/workflows/workflow.deploy.ts @@ -1,19 +1,19 @@ import { - deployWithYieldStar, + deployWithYieldstar, createLoggerReconcilerSubscriber, type ReconcilerEventEmitter, type ResourceRegistry, } from "@notation/reconciler"; import { createWorkflowRouter, workflow } from "yieldstar"; import { getResourceGraph } from "src/orchestrator/graph"; -import { NodeYieldStarRuntime } from "../yieldstar-runtime"; +import { NodeYieldstarRuntime } from "../yieldstar-runtime"; export type DeployAppOptions = { entryPoint: string; driftDetection?: boolean; dryRun?: boolean; registry?: ResourceRegistry; - runtime?: NodeYieldStarRuntime; + runtime?: NodeYieldstarRuntime; executionId?: string; databasePath?: string; emit?: ReconcilerEventEmitter; @@ -32,9 +32,9 @@ export async function deployApp({ const graph = await getResourceGraph(entryPoint); const runtime = suppliedRuntime ?? - new NodeYieldStarRuntime({ deploymentId: entryPoint, databasePath }); + new NodeYieldstarRuntime({ deploymentId: entryPoint, databasePath }); const deploy = workflow(async function* (step, event) { - yield* deployWithYieldStar(step, { + yield* deployWithYieldstar(step, { deploymentId: runtime.deploymentId, executionId: event.executionId, resources: graph.resources, diff --git a/packages/core/src/provisioner/workflows/workflow.destroy.ts b/packages/core/src/provisioner/workflows/workflow.destroy.ts index 36ee0e3..af34eee 100644 --- a/packages/core/src/provisioner/workflows/workflow.destroy.ts +++ b/packages/core/src/provisioner/workflows/workflow.destroy.ts @@ -1,17 +1,17 @@ import { - destroyWithYieldStar, + destroyWithYieldstar, createLoggerReconcilerSubscriber, type ReconcilerEventEmitter, type ResourceRegistry, } from "@notation/reconciler"; import { createWorkflowRouter, workflow } from "yieldstar"; import { getResourceGraph } from "src/orchestrator/graph"; -import { NodeYieldStarRuntime } from "../yieldstar-runtime"; +import { NodeYieldstarRuntime } from "../yieldstar-runtime"; export type DestroyAppOptions = { entryPoint: string; registry?: ResourceRegistry; - runtime?: NodeYieldStarRuntime; + runtime?: NodeYieldstarRuntime; executionId?: string; databasePath?: string; emit?: ReconcilerEventEmitter; @@ -28,9 +28,9 @@ export async function destroyApp({ const graph = await getResourceGraph(entryPoint); const runtime = suppliedRuntime ?? - new NodeYieldStarRuntime({ deploymentId: entryPoint, databasePath }); + new NodeYieldstarRuntime({ deploymentId: entryPoint, databasePath }); const destroy = workflow(async function* (step, event) { - yield* destroyWithYieldStar(step, { + yield* destroyWithYieldstar(step, { deploymentId: runtime.deploymentId, executionId: event.executionId, resources: graph.resources, diff --git a/packages/core/src/provisioner/workflows/workflow.plan.ts b/packages/core/src/provisioner/workflows/workflow.plan.ts index 39679c2..c97f4c6 100644 --- a/packages/core/src/provisioner/workflows/workflow.plan.ts +++ b/packages/core/src/provisioner/workflows/workflow.plan.ts @@ -1,13 +1,13 @@ import { createPlan, type Plan } from "@notation/reconciler"; import { getResourceGraph } from "src/orchestrator/graph"; -import { NodeYieldStarRuntime } from "../yieldstar-runtime"; +import { NodeYieldstarRuntime } from "../yieldstar-runtime"; export type { Plan, PlanNode, PlanDecision } from "@notation/reconciler"; export type PlanAppOptions = { entryPoint: string; driftDetection?: boolean; - runtime?: NodeYieldStarRuntime; + runtime?: NodeYieldstarRuntime; databasePath?: string; }; @@ -20,7 +20,7 @@ export async function planApp({ const graph = await getResourceGraph(entryPoint); const runtime = suppliedRuntime ?? - new NodeYieldStarRuntime({ deploymentId: entryPoint, databasePath }); + new NodeYieldstarRuntime({ deploymentId: entryPoint, databasePath }); try { return await createPlan({ resources: graph.resources, diff --git a/packages/core/src/provisioner/yieldstar-runtime.ts b/packages/core/src/provisioner/yieldstar-runtime.ts index 70ef18d..2eeba37 100644 --- a/packages/core/src/provisioner/yieldstar-runtime.ts +++ b/packages/core/src/provisioner/yieldstar-runtime.ts @@ -14,7 +14,7 @@ import { SqliteTimersClient, createSqliteDb, } from "@yieldstar/sqlite-runtime/node"; -import { YieldStarStateBackend } from "@notation/reconciler"; +import { YieldstarStateBackend } from "@notation/reconciler"; import pino, { type Logger } from "pino"; export const DEFAULT_WORKFLOW_STATE_PATH = ".notation/workflows.db"; @@ -23,7 +23,7 @@ export function resolveWorkflowStatePath(): string { return process.env.NOTATION_STATE_PATH ?? DEFAULT_WORKFLOW_STATE_PATH; } -export type NodeYieldStarRuntimeOptions = { +export type NodeYieldstarRuntimeOptions = { deploymentId: string; databasePath?: string; logger?: Logger; @@ -35,10 +35,10 @@ export type RunWorkflowOptions = { params?: Record; }; -/** Resident YieldStar 0.5.0 Node runtime used by Notation application commands. */ -export class NodeYieldStarRuntime { +/** Resident Yieldstar 0.5.0 Node runtime used by Notation application commands. */ +export class NodeYieldstarRuntime { readonly deploymentId: string; - readonly state: YieldStarStateBackend; + readonly state: YieldstarStateBackend; readonly #database: ReturnType; readonly #eventLoop: SqliteEventLoop; readonly #heapClient: SqliteHeapClient; @@ -47,7 +47,7 @@ export class NodeYieldStarRuntime { readonly #logger: Logger; #running = false; - constructor(opts: NodeYieldStarRuntimeOptions) { + constructor(opts: NodeYieldstarRuntimeOptions) { this.deploymentId = opts.deploymentId; this.#logger = opts.logger ?? pino({ level: "silent" }); this.#database = createSqliteDb({ @@ -64,7 +64,7 @@ export class NodeYieldStarRuntime { }); this.#heapClient = new SqliteHeapClient(this.#database); this.#eventLoop = new SqliteEventLoop(this.#database); - this.state = new YieldStarStateBackend( + this.state = new YieldstarStateBackend( this.#storeClient, this.deploymentId, ); @@ -76,7 +76,7 @@ export class NodeYieldStarRuntime { ): Promise { if (this.#running) { throw new Error( - "The Node YieldStar runtime already has an active workflow", + "The Node Yieldstar runtime already has an active workflow", ); } this.#running = true; @@ -113,7 +113,7 @@ export class NodeYieldStarRuntime { rejectCompletion(error); return; } - this.#logger.error({ err: error }, "YieldStar replay failed"); + this.#logger.error({ err: error }, "Yieldstar replay failed"); } }; @@ -136,7 +136,7 @@ export class NodeYieldStarRuntime { close(): void { if (this.#running) { throw new Error( - "Cannot close the Node YieldStar runtime while a workflow is active", + "Cannot close the Node Yieldstar runtime while a workflow is active", ); } this.#eventLoop.stop(); diff --git a/packages/core/test/provisioner/yieldstar-runtime.test.ts b/packages/core/test/provisioner/yieldstar-runtime.test.ts index 0037219..ad64f49 100644 --- a/packages/core/test/provisioner/yieldstar-runtime.test.ts +++ b/packages/core/test/provisioner/yieldstar-runtime.test.ts @@ -1,16 +1,16 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { deployWithYieldStar } from "@notation/reconciler"; +import { deployWithYieldstar } from "@notation/reconciler"; import { resource } from "@notation/resource"; import { createWorkflowRouter, workflow } from "yieldstar"; import { describe, expect, it } from "vitest"; -import { NodeYieldStarRuntime } from "src/provisioner/yieldstar-runtime"; +import { NodeYieldstarRuntime } from "src/provisioner/yieldstar-runtime"; -describe("NodeYieldStarRuntime", () => { +describe("NodeYieldstarRuntime", () => { it("stays resident across a provider delay and resumes from the SQLite event loop", async () => { const directory = await mkdtemp(path.join(tmpdir(), "notation-runtime-")); - const runtime = new NodeYieldStarRuntime({ + const runtime = new NodeYieldstarRuntime({ deploymentId: "resident-wait", databasePath: path.join(directory, "workflows.db"), }); @@ -33,7 +33,7 @@ describe("NodeYieldStarRuntime", () => { }); const resources = [new PendingResource({ id: "pending" })]; const deploy = workflow(async function* (step, event) { - yield* deployWithYieldStar(step, { + yield* deployWithYieldstar(step, { deploymentId: runtime.deploymentId, executionId: event.executionId, resources, diff --git a/packages/reconciler/src/index.ts b/packages/reconciler/src/index.ts index ca2edca..f056de3 100644 --- a/packages/reconciler/src/index.ts +++ b/packages/reconciler/src/index.ts @@ -1,7 +1,7 @@ export type ResourceApi = typeof import("@notation/resource"); export type StateApi = typeof import("@notation/state"); export type DeepObjectDiffApi = typeof import("deep-object-diff"); -export type YieldStarApi = typeof import("yieldstar"); +export type YieldstarApi = typeof import("yieldstar"); export * from "./resource-registry"; export * from "./dependency-graph"; diff --git a/packages/reconciler/src/yieldstar.ts b/packages/reconciler/src/yieldstar.ts index a8845bd..0eded68 100644 --- a/packages/reconciler/src/yieldstar.ts +++ b/packages/reconciler/src/yieldstar.ts @@ -43,23 +43,23 @@ const coordinationStateSchema = plainObjectSchema( (value.holder === null || typeof value.holder === "string"), ); -export const yieldStarResourceStateStore = defineStore( +export const yieldstarResourceStateStore = defineStore( "notation/resource-state", storedResourceStateSchema, ); -export const yieldStarDeploymentCoordinationStore = defineStore( +export const yieldstarDeploymentCoordinationStore = defineStore( "notation/deployment-coordination", coordinationStateSchema, ); -type YieldStarStep = Parameters>[0]; +type YieldstarStep = Parameters>[0]; -export type YieldStarOperationOptions = { +export type YieldstarOperationOptions = { deploymentId: string; executionId: string; resources: BaseResource[]; - state: YieldStarStateBackend; + state: YieldstarStateBackend; registry?: ResourceRegistry; dryRun?: boolean; emit?: ReconcilerEventEmitter; @@ -67,19 +67,19 @@ export type YieldStarOperationOptions = { readPollOptions?: PollOptions; }; -export type YieldStarDeployOptions = YieldStarOperationOptions & { +export type YieldstarDeployOptions = YieldstarOperationOptions & { driftDetection?: boolean; }; -export type YieldStarDestroyOptions = YieldStarOperationOptions; +export type YieldstarDestroyOptions = YieldstarOperationOptions; /** - * Reconciles resources as a custom YieldStar step. The caller owns the outer + * Reconciles resources as a custom Yieldstar step. The caller owns the outer * workflow and runtime; Notation owns resource decisions and lifecycle calls. */ -export async function* deployWithYieldStar( - step: YieldStarStep, - opts: YieldStarDeployOptions, +export async function* deployWithYieldstar( + step: YieldstarStep, + opts: YieldstarDeployOptions, ): AsyncGenerator { const coordination = yield* acquireDeploymentCoordination(step, opts); @@ -134,9 +134,9 @@ export async function* deployWithYieldStar( } /** Durably destroys persisted resources in reverse dependency order. */ -export async function* destroyWithYieldStar( - step: YieldStarStep, - opts: YieldStarDestroyOptions, +export async function* destroyWithYieldstar( + step: YieldstarStep, + opts: YieldstarDestroyOptions, ): AsyncGenerator { const coordination = yield* acquireDeploymentCoordination(step, opts); @@ -202,10 +202,10 @@ export async function* destroyWithYieldStar( * so the wait is surfaced as a warning event before suspending. */ async function* acquireDeploymentCoordination( - step: YieldStarStep, - opts: YieldStarOperationOptions, + step: YieldstarStep, + opts: YieldstarOperationOptions, ): AsyncGenerator, any> { - const coordination = yield* step.store(yieldStarDeploymentCoordinationStore, { + const coordination = yield* step.store(yieldstarDeploymentCoordinationStore, { id: opts.deploymentId, initial: { holder: null }, }); @@ -234,9 +234,9 @@ async function* acquireDeploymentCoordination( } async function* reconcileResource( - step: YieldStarStep, + step: YieldstarStep, resource: BaseResource, - opts: YieldStarDeployOptions, + opts: YieldstarDeployOptions, ): AsyncGenerator { const prefix = `notation:resource:${resource.id}`; let stateNode = yield* step.run(`${prefix}:state:lookup`, () => @@ -244,7 +244,7 @@ async function* reconcileResource( ); let stateStore: WorkflowStore | undefined; let snapshot: - Awaited> | undefined; + Awaited> | undefined; if (stateNode) { stateStore = yield* openResourceState(step, opts.state, resource.id); snapshot = yield* stateStore.get(`${prefix}:state:get`); @@ -383,7 +383,7 @@ async function* reconcileResource( }; if (!stateStore || !snapshot) { - yield* step.store(yieldStarResourceStateStore, { + yield* step.store(yieldstarResourceStateStore, { id: opts.state.storeId(resource.id), initial: nextState, }); @@ -424,9 +424,9 @@ async function* reconcileResource( } async function* deleteResource( - step: YieldStarStep, + step: YieldstarStep, resource: BaseResource, - opts: YieldStarOperationOptions, + opts: YieldstarOperationOptions, suffix: string, ): AsyncGenerator { const prefix = `notation:${suffix}:${resource.id}`; @@ -507,9 +507,9 @@ async function* deleteResource( } async function* readRemote( - step: YieldStarStep, + step: YieldstarStep, resource: BaseResource, - opts: YieldStarOperationOptions, + opts: YieldstarOperationOptions, key: string, ): AsyncGenerator< any, @@ -595,7 +595,7 @@ async function* readRemote( } function runProviderCall( - step: YieldStarStep, + step: YieldstarStep, key: string, call: () => T | Promise, resource: BaseResource, @@ -617,17 +617,17 @@ function runProviderCall( } function openResourceState( - step: YieldStarStep, - state: YieldStarStateBackend, + step: YieldstarStep, + state: YieldstarStateBackend, resourceId: string, ) { - return step.store(yieldStarResourceStateStore, { + return step.store(yieldstarResourceStateStore, { id: state.storeId(resourceId), }); } function emitDurably( - step: YieldStarStep, + step: YieldstarStep, key: string, emit: ReconcilerEventEmitter | undefined, event: () => Parameters[0], @@ -638,7 +638,7 @@ function emitDurably( } function emitOperationLifecycle( - step: YieldStarStep, + step: YieldstarStep, key: string, emit: ReconcilerEventEmitter | undefined, resource: BaseResource, @@ -657,8 +657,8 @@ function emitOperationLifecycle( ); } -/** A Notation state backend backed by YieldStar 0.5 durable stores. */ -export class YieldStarStateBackend { +/** A Notation state backend backed by Yieldstar 0.5 durable stores. */ +export class YieldstarStateBackend { readonly #client: StoreClient; readonly #deploymentId: string; // The deployment segment is URI-encoded so the ":" delimiter cannot appear @@ -694,11 +694,11 @@ export class YieldStarStateBackend { > { try { return await this.#client.getStore({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: storeId, }); } catch (error) { - const ids = await this.#client.listStores(yieldStarResourceStateStore); + const ids = await this.#client.listStores(yieldstarResourceStateStore); if (!ids.includes(storeId)) return undefined; throw error; } @@ -719,7 +719,7 @@ export class YieldStarStateBackend { if (expectedRev !== 0) throw new RevConflict(id, expectedRev, undefined); const initial = { ...patch, id } as StoredResourceState; const created = await this.#client.getOrCreateStore({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: storeId, initial, }); @@ -730,7 +730,7 @@ export class YieldStarStateBackend { if (actualRev !== expectedRev) throw new RevConflict(id, expectedRev, actualRev); const result = await this.#client.updateStoreFrom({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: storeId, snapshot, updater: (draft) => { @@ -752,7 +752,7 @@ export class YieldStarStateBackend { if (actualRev !== expectedRev) throw new RevConflict(id, expectedRev, actualRev); const result = await this.#client.deleteStoreFrom({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: storeId, snapshot, }); @@ -760,7 +760,7 @@ export class YieldStarStateBackend { } async values(): Promise { - const ids = await this.#client.listStores(yieldStarResourceStateStore); + const ids = await this.#client.listStores(yieldstarResourceStateStore); const snapshots = await Promise.all( ids .filter((id) => id.startsWith(this.#prefix)) @@ -773,19 +773,19 @@ export class YieldStarStateBackend { snapshot(id: string) { return this.#client.getStore({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: this.storeId(id), }); } async clear(): Promise { - const ids = await this.#client.listStores(yieldStarResourceStateStore); + const ids = await this.#client.listStores(yieldstarResourceStateStore); await Promise.all( ids .filter((id) => id.startsWith(this.#prefix)) .map((id) => this.#client.deleteStore({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id, }), ), diff --git a/packages/reconciler/test/yieldstar.integration.test.ts b/packages/reconciler/test/yieldstar.integration.test.ts index 6fb5e36..42cf446 100644 --- a/packages/reconciler/test/yieldstar.integration.test.ts +++ b/packages/reconciler/test/yieldstar.integration.test.ts @@ -13,10 +13,10 @@ import pino from "pino"; import { createWorkflowRouter, workflow } from "yieldstar"; import { describe, expect, it, vi } from "vitest"; import { - YieldStarStateBackend, - deployWithYieldStar, - destroyWithYieldStar, - yieldStarResourceStateStore, + YieldstarStateBackend, + deployWithYieldstar, + destroyWithYieldstar, + yieldstarResourceStateStore, } from "../src/yieldstar"; import type { ReconcilerEvent } from "../src/events"; import { @@ -26,7 +26,7 @@ import { const logger = pino({ level: "silent" }); -describe("YieldStar reconciliation", () => { +describe("Yieldstar reconciliation", () => { it("waits durably for a retryable provider and persists after success", async () => { let attempts = 0; const PendingResource = resource({ type: "test/yieldstar/pending" }) @@ -202,7 +202,7 @@ describe("YieldStar reconciliation", () => { await runtime.state.clear(); await runtime.state.update("resource", 0, statePatch("resource")); const staleDelete = await runtime.storeClient.deleteStoreFrom({ - definition: yieldStarResourceStateStore, + definition: yieldstarResourceStateStore, id: runtime.state.storeId("resource"), snapshot: originalSnapshot, }); @@ -304,8 +304,8 @@ describe("YieldStar reconciliation", () => { db: database, schedulerClient: new TestScheduler(), }); - const app = new YieldStarStateBackend(storeClient, "app"); - const appBlue = new YieldStarStateBackend(storeClient, "app:blue"); + const app = new YieldstarStateBackend(storeClient, "app"); + const appBlue = new YieldstarStateBackend(storeClient, "app:blue"); await app.update("site", 0, statePatch("site")); await appBlue.update("site", 0, statePatch("site")); @@ -408,9 +408,9 @@ function createRuntime( db: database, schedulerClient: scheduler, }); - const state = new YieldStarStateBackend(storeClient, deploymentId); + const state = new YieldstarStateBackend(storeClient, deploymentId); const deploy = workflow(async function* (step, event) { - yield* deployWithYieldStar(step, { + yield* deployWithYieldstar(step, { deploymentId, executionId: event.executionId, resources, @@ -422,7 +422,7 @@ function createRuntime( }); }); const destroy = workflow(async function* (step, event) { - yield* destroyWithYieldStar(step, { + yield* destroyWithYieldstar(step, { deploymentId, executionId: event.executionId, resources,