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
10 changes: 10 additions & 0 deletions .changeset/resource-operation-outcomes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
"@notation/aws.iac": minor
"@notation/cli": minor
"@notation/core": minor
"@notation/reconciler": minor
"@notation/resource": minor
"@notation/std.iac": minor
---

A resource `read` now returns the remote object or throws the tagged `ResourceNotFoundError`. Operations that have started but not settled throw `ResourceOperationPendingError` with their retry delay and optional callback context. The reconciler follows those explicit instructions instead of guessing retry behaviour from provider errors or call-site context.
18 changes: 13 additions & 5 deletions docs/internals/reconciler.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,18 +81,26 @@ Each CRUD operation is implemented as an async generator with retry support:
- **`deleteResourceOperation`** – deletes the resource, removes the entry from state backend
- **`readResourceOperation`** – reads current state from the cloud provider (used for drift detection)

### Retry and polling
### Pending operations

Operations support polling for eventual consistency:
A resource operation throws `ResourceOperationPendingError` when it has not finished. The reconciler reads two fields from the error:

| Field | Action |
| ----- | ------ |
| `retryAfterMs` | Wait this many milliseconds. |
| `callbackContext` | Pass this value to the next call of the same operation. |

The reconciler then calls the same operation again. Any other error fails the operation. See [Operation errors](./resource.md#operation-errors) for the complete API.

The default limit is 30 calls to one operation:

```ts [packages/reconciler/src/index.ts]
{
maxAttempts: 10,
retryInterval: 2000,
maxOperationAttempts: 30,
}
```

This handles AWS services that return success before the resource is fully available. For example, after creating an IAM Role, a Lambda function may briefly fail to deploy until the role propagates. The retry loop handles cases like this.
The last pending error becomes a failure when the limit is reached.

### Operation lifecycle

Expand Down
86 changes: 73 additions & 13 deletions docs/internals/resource.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,19 +137,79 @@ All schema items carry these fields:

## Operations

`defineOperations` accepts CRUD handlers and error-handling configuration:

| Field | Required | Signature / Description |
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `create` | yes | `(params: Params<S>) => Promise<ComputedPrimaryKey<S>>` – create the resource, return its computed key. |
| `read` | no | `(key: CompoundKey<S>) => Promise<Result<S>>` – read current state. |
| `update` | no | `(key, patch, params, state) => Promise<void>` – apply a partial update. |
| `delete` | yes | `(key, state) => Promise<void>` – destroy the resource. |
| `deriveParams` | no | Computes intrinsic derived params from config (not dependency-aware). |
| `retryReadOnCondition` | no | Conditions on read output that trigger a retry (e.g. eventual consistency). |
| `failOnError` | no | Error matchers that cause immediate failure with a reason. |
| `notFoundOnError` | no | Error matchers that indicate the resource does not exist. |
| `retryLaterOnError` | no | Error matchers that indicate a transient failure worth retrying. |
`defineOperations` accepts CRUD handlers and parameter derivation:

| Field | Required | Signature / Description |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `create` | yes | `(params, context?) => Promise<ComputedPrimaryKey<S>>` – create the resource, return its computed key. |
| `read` | no | `(key, context?) => Promise<Result<S>>` – return the remote object or throw `ResourceNotFoundError`. |
| `update` | no | `(key, patch, params, state, context?) => Promise<void>` – apply a partial update. |
| `delete` | yes | `(key, state, context?) => Promise<void>` – ensure the resource is absent. Implementations must also succeed when the remote resource is already gone. |
| `deriveParams` | no | Computes intrinsic derived params from config (not dependency-aware). |

### Operation errors

Import the errors from `@notation/resource`:

```ts
import {
ResourceNotFoundError,
ResourceOperationPendingError,
} from "@notation/resource";
```

The constructors are:

```ts
new ResourceNotFoundError(message: string, options?: { cause?: unknown });

new ResourceOperationPendingError(message: string, {
retryAfterMs: number;
callbackContext?: Readonly<Record<string, unknown>>;
cause?: unknown;
});
```

| Handler result | Meaning | What the reconciler does |
| -------------- | ------- | ------------------------ |
| Return normally | The operation finished. | Continues the deployment. |
| `throw new ResourceNotFoundError(message, { cause })` | `read` found no resource for the given key. | Treats the resource as absent during planning and refresh. A read after create or update fails because that operation claimed to have finished. |
| `throw new ResourceOperationPendingError(message, { retryAfterMs, callbackContext })` | The operation has not finished. | Waits for `retryAfterMs`, then calls the same handler again. It passes `callbackContext` as the handler's final argument. |
| Throw any other error | The operation failed. | Stops the deployment. |

`ResourceNotFoundError` is for `read`. A `delete` handler must catch the provider's missing-resource error and return normally.

`ResourceOperationPendingError` may be thrown by `create`, `read`, `update`, or `delete`. Its options are:

| Option | Type | Required | Meaning |
| ------ | ---- | -------- | ------- |
| `retryAfterMs` | `number` | yes | Milliseconds to wait. It must be zero or greater. |
| `callbackContext` | `Readonly<Record<string, unknown>>` | no | Plain serializable data for the next attempt. |
| `cause` | `unknown` | no | The provider error that caused this result. |

The default limit is 30 attempts. Set `maxOperationAttempts` on the reconciler to change it. Reaching the limit fails the operation.

```ts
read: async (key, context) => {
try {
return await client.send(new GetResourceCommand(key));
} catch (error) {
if (error instanceof ResourceMissingException) {
throw new ResourceNotFoundError("Resource was not found", {
cause: error,
});
}
if (error instanceof OperationInProgressException) {
throw new ResourceOperationPendingError("Resource is not ready", {
retryAfterMs: 1_000,
callbackContext: { requestId: error.requestId },
cause: error,
});
}
throw error;
}
};
```

## Dependencies

Expand Down
3 changes: 2 additions & 1 deletion examples/reconciler/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,8 @@
"dependencies": {
"@notation/reconciler": "workspace:*",
"@notation/resource": "workspace:*",
"@notation/state-sqlite": "workspace:*"
"@notation/state-sqlite": "workspace:*",
"@notation/utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.13.4",
Expand Down
25 changes: 8 additions & 17 deletions examples/reconciler/src/static-site.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { resource } from "@notation/resource";
import { resource, ResourceNotFoundError } from "@notation/resource";
import { isErrorWithCode } from "@notation/utils";

type StaticSiteApi = {
Key: { siteDirectory: string };
Expand All@@ -9,10 +10,6 @@ type StaticSiteApi = {
ReadResult: { html: string };
};

class SiteNotFound extends Error {
readonly name = "SiteNotFound";
}

const staticSite = resource<StaticSiteApi>({ type: "local/site/static" });

export const StaticSite = staticSite
Expand DownExpand Up@@ -40,24 +37,18 @@ export const StaticSite = staticSite
);
return { html };
} catch (error) {
if (isFileMissing(error)) throw new SiteNotFound(siteDirectory);
if (isErrorWithCode(error, "ENOENT")) {
throw new ResourceNotFoundError("Static site was not found", {
cause: error,
});
}
throw error;
}
},
update: async (_key, _patch, { siteDirectory, html }) => {
await writeFile(path.join(siteDirectory, "index.html"), html, "utf8");
},
delete: async ({ siteDirectory }) => {
await rm(siteDirectory, { recursive: true });
await rm(siteDirectory, { recursive: true, force: true });
},
notFoundOnError: [{ name: "SiteNotFound", reason: "site was removed" }],
});

function isFileMissing(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
);
}
29 changes: 21 additions & 8 deletions packages/aws.iac/src/resources/api-gateway/api.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { resource } from "@notation/resource";
import { resource, ResourceNotFoundError } from "@notation/resource";
import * as sdk from "@aws-sdk/client-apigatewayv2";
import { apiGatewayClient } from "src/utils/aws-clients";
import { AwsSchema } from "src/utils/types";
Expand DownExpand Up@@ -97,19 +97,32 @@ export const Api = apiSchema.defineOperations({
return { ApiId: result.ApiId! };
},
async read(key) {
const command = new sdk.GetApiCommand(key);
const result = await apiGatewayClient.send(command);
// todo: check types or correct or if RouteKey is actually in result
// if not, need to pass the original params to read
return { RouteKey: "", ...result };
try {
const command = new sdk.GetApiCommand(key);
const result = await apiGatewayClient.send(command);
// todo: check types or correct or if RouteKey is actually in result
// if not, need to pass the original params to read
return { RouteKey: "", ...result };
} catch (error) {
if (error instanceof sdk.NotFoundException) {
throw new ResourceNotFoundError("API Gateway API was not found", {
cause: error,
});
}
throw error;
}
},
async update(key, params) {
const command = new sdk.UpdateApiCommand({ ...key, ...params });
await apiGatewayClient.send(command);
},
async delete(pk) {
const command = new sdk.DeleteApiCommand(pk);
await apiGatewayClient.send(command);
try {
const command = new sdk.DeleteApiCommand(pk);
await apiGatewayClient.send(command);
} catch (error) {
if (!(error instanceof sdk.NotFoundException)) throw error;
}
},
});

Expand Down
27 changes: 20 additions & 7 deletions packages/aws.iac/src/resources/api-gateway/auth.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import * as sdk from "@aws-sdk/client-apigatewayv2";
import { AwsSchema } from "src/utils/types";
import { resource } from "@notation/resource";
import { resource, ResourceNotFoundError } from "@notation/resource";
import { apiGatewayClient } from "src/utils/aws-clients";
import { ApiInstance } from "./api";

Expand DownExpand Up@@ -59,18 +59,31 @@ export const RouteAuth = apiSchema
};
},
read: async (key) => {
const command = new sdk.GetAuthorizerCommand(key);
const result = await apiGatewayClient.send(command);

return result;
try {
const command = new sdk.GetAuthorizerCommand(key);
const output = await apiGatewayClient.send(command);
return output;
} catch (error) {
if (error instanceof sdk.NotFoundException) {
throw new ResourceNotFoundError(
"API Gateway authorizer was not found",
{ cause: error },
);
}
throw error;
}
},
update: async (key, patch, params) => {
const command = new sdk.UpdateAuthorizerCommand({ ...key, ...params });
await apiGatewayClient.send(command);
},
delete: async (params) => {
const command = new sdk.DeleteAuthorizerCommand(params);
await apiGatewayClient.send(command);
try {
const command = new sdk.DeleteAuthorizerCommand(params);
await apiGatewayClient.send(command);
} catch (error) {
if (!(error instanceof sdk.NotFoundException)) throw error;
}
},
})
.requireDependencies<AuthorizerDependencies>()
Expand Down
25 changes: 20 additions & 5 deletions packages/aws.iac/src/resources/api-gateway/lambda-integration.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { resource } from "@notation/resource";
import { resource, ResourceNotFoundError } from "@notation/resource";
import * as sdk from "@aws-sdk/client-apigatewayv2";
import { ApiInstance } from "./api";
import { LambdaFunctionInstance } from "../lambda";
Expand DownExpand Up@@ -118,16 +118,31 @@ export const LambdaIntegration = integrationSchema
return { IntegrationId: result.IntegrationId! };
},
read: async (key) => {
const command = new sdk.GetIntegrationCommand(key);
return apiGatewayClient.send(command);
try {
const command = new sdk.GetIntegrationCommand(key);
const output = await apiGatewayClient.send(command);
return output;
} catch (error) {
if (error instanceof sdk.NotFoundException) {
throw new ResourceNotFoundError(
"API Gateway integration was not found",
{ cause: error },
);
}
throw error;
}
},
update: async (key, params) => {
const command = new sdk.UpdateIntegrationCommand({ ...key, ...params });
await apiGatewayClient.send(command);
},
delete: async (key) => {
const command = new sdk.DeleteIntegrationCommand(key);
await apiGatewayClient.send(command);
try {
const command = new sdk.DeleteIntegrationCommand(key);
await apiGatewayClient.send(command);
} catch (error) {
if (!(error instanceof sdk.NotFoundException)) throw error;
}
},
})
.requireDependencies<LambdaIntegrationDependencies>()
Expand Down
25 changes: 19 additions & 6 deletions packages/aws.iac/src/resources/api-gateway/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { resource } from "@notation/resource";
import { resource, ResourceNotFoundError } from "@notation/resource";
import * as sdk from "@aws-sdk/client-apigatewayv2";
import { apiGatewayClient } from "src/utils/aws-clients";
import { ApiInstance, LambdaIntegrationInstance } from ".";
Expand DownExpand Up@@ -92,17 +92,30 @@ export const Route = routeSchema
return { RouteId: result.RouteId! };
},
read: async (key) => {
const command = new sdk.GetRouteCommand(key);
const result = await apiGatewayClient.send(command);
return { ...key, ...result };
try {
const command = new sdk.GetRouteCommand(key);
const result = await apiGatewayClient.send(command);
return { ...key, ...result };
} catch (error) {
if (error instanceof sdk.NotFoundException) {
throw new ResourceNotFoundError("API Gateway route was not found", {
cause: error,
});
}
throw error;
}
},
update: async (key, patch, params) => {
const command = new sdk.UpdateRouteCommand({ ...key, ...params });
await apiGatewayClient.send(command);
},
delete: async (key) => {
const command = new sdk.DeleteRouteCommand(key);
await apiGatewayClient.send(command);
try {
const command = new sdk.DeleteRouteCommand(key);
await apiGatewayClient.send(command);
} catch (error) {
if (!(error instanceof sdk.NotFoundException)) throw error;
}
},
})
.requireDependencies<RouteDependencies>()
Expand Down
Loading
Loading