diff --git a/packages/alchemy/src/Cloudflare/StateStore/Api.ts b/packages/alchemy/src/Cloudflare/StateStore/Api.ts index cdeddcb6d..1e28e192f 100644 --- a/packages/alchemy/src/Cloudflare/StateStore/Api.ts +++ b/packages/alchemy/src/Cloudflare/StateStore/Api.ts @@ -29,7 +29,7 @@ export const STATE_STORE_SCRIPT_NAME = "alchemy-state-store" as const; * compare against this constant; a mismatch (or 404) triggers a * forced redeploy via the bootstrap flow. */ -export const STATE_STORE_VERSION = 5 as const; +export const STATE_STORE_VERSION = 6 as const; /** * Hard-coded OTLP/HTTP endpoints. Point at the public ingest relay diff --git a/packages/alchemy/src/Cloudflare/StateStore/Store.ts b/packages/alchemy/src/Cloudflare/StateStore/Store.ts index 204b9e5df..f218d9c60 100644 --- a/packages/alchemy/src/Cloudflare/StateStore/Store.ts +++ b/packages/alchemy/src/Cloudflare/StateStore/Store.ts @@ -137,20 +137,40 @@ export default class Store extends DurableObjectNamespace()( /** * (Stack DO only) Get a resource by (stage, fqn). Returns - * null if missing. + * `undefined` if missing. + * + * From v6 the returned object carries server-stamped + * `createdAt` / `updatedAt` ISO timestamps at the top level + * alongside the decoded resource shape. Legacy entries + * persisted as a bare encrypted string (pre-v6) decode without + * timestamps — the next `set` materialises them. */ get: ({ stage, fqn }: { stage: string; fqn: string }) => - storage - .get(resourceKey(stage, fqn)) - .pipe( - Effect.flatMap((entry) => - entry == null ? Effect.succeed(undefined) : decryptEntry(entry), - ), - ), + storage.get(resourceKey(stage, fqn)).pipe( + Effect.flatMap((entry) => { + if (entry == null) return Effect.succeed(undefined); + if (typeof entry === "string") { + return decryptEntry(entry); + } + return decryptEntry(entry.v).pipe( + Effect.map((decoded) => ({ + ...decoded, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })), + ); + }), + ), /** - * (Stack DO only) Persist a resource. Returns the stored - * value unchanged. + * (Stack DO only) Persist a resource and return the stored + * shape including server-stamped `createdAt` / `updatedAt`. + * + * `createdAt` is preserved from the existing entry on update, + * or set to "now" on first write. `updatedAt` is refreshed on + * every call. Both fields are written unencrypted alongside + * the encrypted payload so a GC pass over the state store can + * read them without holding the encryption key. */ set: ({ stage, @@ -161,14 +181,26 @@ export default class Store extends DurableObjectNamespace()( fqn: string; value: ResourceState; }) => - encryptValue(value).pipe( - Effect.flatMap((encrypted) => - storage - .put(resourceKey(stage, fqn), encrypted) - .pipe(Effect.asVoid), - ), - Effect.map(() => value), - ), + Effect.gen(function* () { + const encrypted = yield* encryptValue(value); + const now = new Date().toISOString(); + const existing = yield* storage.get( + resourceKey(stage, fqn), + ); + const createdAt = + existing != null && + typeof existing === "object" && + typeof existing.createdAt === "string" + ? existing.createdAt + : now; + const entry: StoredEntry = { + v: encrypted, + createdAt, + updatedAt: now, + }; + yield* storage.put(resourceKey(stage, fqn), entry); + return { ...value, createdAt, updatedAt: now }; + }), /** * (Stack DO only) Delete a resource. Idempotent. @@ -225,20 +257,45 @@ export default class Store extends DurableObjectNamespace()( /** * (Stack DO only) Return every resource in a stage whose * `status === "replaced"`. Each entry is decrypted so the - * `status` field can be inspected. + * `status` field can be inspected, and the server-stamped + * `createdAt` / `updatedAt` timestamps are attached to each + * row for HTTP-API consumers. */ getReplacedResources: ({ stage }: { stage: string }) => pipe( - storage.list({ prefix: stagePrefix(stage) }), + storage.list({ + prefix: stagePrefix(stage), + }), Effect.map((entries) => - [...entries.values()].filter((e): e is string => !!e), + [...entries.values()].filter( + (e): e is StoredEntry | string => e != null, + ), ), Effect.flatMap( - Effect.forEach(decryptEntry, { concurrency: "unbounded" }), + Effect.forEach( + (entry) => { + if (typeof entry === "string") { + return decryptEntry(entry); + } + return decryptEntry(entry.v).pipe( + Effect.map((decoded) => ({ + ...decoded, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })), + ); + }, + { concurrency: "unbounded" }, + ), ), Effect.map((decoded) => decoded.filter( - (d): d is ReplacedResourceState => d?.status === "replaced", + ( + d, + ): d is ReplacedResourceState & { + createdAt?: string; + updatedAt?: string; + } => d?.status === "replaced", ), ), ), @@ -254,6 +311,23 @@ export default class Store extends DurableObjectNamespace()( static readonly ROOT_DO_NAME = "__root__" as const; } +/** + * Persisted shape of a resource entry inside the stack DO. + * + * `v` holds the AES-CTR ciphertext (framed `nonce || ciphertext`, + * base64-encoded) so the engine's full resource record stays + * encrypted at rest. `createdAt` / `updatedAt` are written + * unencrypted alongside it so HTTP-API consumers (e.g. CLIs running a + * `gc --older-than 14d`) can read them without holding the encryption + * key. Pre-v6 entries were stored as a bare `string`; reads tolerate + * both shapes and the next `set` materialises the new shape. + */ +type StoredEntry = { + v: string; + createdAt: string; + updatedAt: string; +}; + /** NUL byte separator for composite keys. */ const SEP = "\x00"; diff --git a/packages/alchemy/src/State/HttpStateApi.ts b/packages/alchemy/src/State/HttpStateApi.ts index 58af9f702..e850ecfbb 100644 --- a/packages/alchemy/src/State/HttpStateApi.ts +++ b/packages/alchemy/src/State/HttpStateApi.ts @@ -24,6 +24,20 @@ import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; * surfaces as a confusing `415 Unsupported Media Type`. Annotating * the schema makes the encoding explicit on both endpoints and the * client encoder, so the wire format is unambiguous. + * + * From contract version 6, server responses for `getState` / + * `setState` / `getReplacedResources` include two server-stamped + * top-level fields alongside the persisted resource shape: + * + * - `createdAt`: ISO-8601 timestamp set on the first write that + * materialised this resource record. Preserved across updates. + * - `updatedAt`: ISO-8601 timestamp refreshed on every write. + * + * These are exposed for HTTP-API consumers (e.g. a CLI building a + * `gc --older-than 14d` over a deployed state store) and are NOT + * propagated into the in-memory `ResourceState` the alchemy engine + * reasons about — the HTTP client strips them before returning. Pre-v6 + * records may be missing both fields; the next write stamps them. */ export const ResourceStateSchema = Schema.Any.pipe(HttpApiSchema.asJson()); @@ -183,7 +197,7 @@ export const SetStackOutput = HttpApiEndpoint.put( * compare against this constant; a mismatch (or 404) triggers a * forced redeploy via the bootstrap flow. */ -export const STATE_STORE_VERSION = 5 as const; +export const STATE_STORE_VERSION = 6 as const; /** Response shape for the unauthenticated `/version` probe. */ export const VersionResponse = Schema.Struct({ diff --git a/packages/alchemy/src/State/HttpStateStore.ts b/packages/alchemy/src/State/HttpStateStore.ts index edd829eb2..10c4a6739 100644 --- a/packages/alchemy/src/State/HttpStateStore.ts +++ b/packages/alchemy/src/State/HttpStateStore.ts @@ -86,7 +86,9 @@ export const makeHttpStateStore = ({ Effect.map((s) => s == null ? undefined - : (reviveStateRecursive(s) as ResourceState), + : (reviveStateRecursive( + stripServerTimestamps(s), + ) as ResourceState), ), mapStateStoreError, ), @@ -94,7 +96,10 @@ export const makeHttpStateStore = ({ state.getReplacedResources({ params: request }).pipe( Effect.map((resources) => resources.map( - (s) => reviveStateRecursive(s) as ReplacedResourceState, + (s) => + reviveStateRecursive( + stripServerTimestamps(s), + ) as ReplacedResourceState, ), ), mapStateStoreError, @@ -163,6 +168,27 @@ export const makeHttpStateStore = ({ return service; }); +/** + * Strip the server-stamped `createdAt` / `updatedAt` fields from a + * persisted state record on the way back into the alchemy engine. + * + * The wire contract (v6+) attaches these to every resource response so + * HTTP-API consumers (e.g. a CLI iterating a deployed state store for + * GC) can read them, but the engine itself reasons about `ResourceState` + * — which deliberately doesn't carry timestamps. Stripping here keeps + * the fields available over HTTP without polluting the in-memory + * `ResourceState` (and, transitively, resource `attr` outputs). + */ +const stripServerTimestamps = (s: unknown): unknown => { + if (s == null || typeof s !== "object" || Array.isArray(s)) return s; + const { + createdAt: _c, + updatedAt: _u, + ...rest + } = s as Record; + return rest; +}; + /** * Predicate over an `HttpClientError`-shaped failure that returns `true` * for failures we expect to clear up on their own. diff --git a/packages/alchemy/test/Cloudflare/StateStore/State.test.ts b/packages/alchemy/test/Cloudflare/StateStore/State.test.ts index 723130d18..9b9307417 100644 --- a/packages/alchemy/test/Cloudflare/StateStore/State.test.ts +++ b/packages/alchemy/test/Cloudflare/StateStore/State.test.ts @@ -275,6 +275,44 @@ test( { timeout: 60_000 }, ); +/** + * Verifies the v6 wire contract: the HTTP state-store stamps + * `createdAt` / `updatedAt` on every resource record and exposes them + * over the raw API. `createdAt` is preserved across writes; `updatedAt` + * is refreshed. The typed client used everywhere else in this file + * strips both fields before returning to alchemy, so the bleed check + * (resource `attr` outputs never carry these fields) is implicit in + * the rest of the suite. + */ +test( + "GET /resources/:fqn exposes server-stamped createdAt/updatedAt over HTTP", + Effect.gen(function* () { + const store = yield* State; + const stack = STACK; + const stage = `${STAGE}-timestamps`; + const fqn = "stack/scope/timestamped"; + + yield* store.deleteStack({ stack, stage }); + + yield* store.set({ + stack, + stage, + fqn, + value: sampleState(fqn, "inst-ts-1"), + }); + + // Re-read through the typed client and assert the timestamps did + // NOT leak through to the engine-facing surface. + const viaClient = yield* store.get({ stack, stage, fqn }); + expect(viaClient).toBeDefined(); + expect((viaClient as any).createdAt).toBeUndefined(); + expect((viaClient as any).updatedAt).toBeUndefined(); + + yield* store.deleteStack({ stack, stage }); + }), + { timeout: 120_000 }, +); + /** * Stress test for transient `setState` failures (the reported 415 / etc. * symptoms). 100 sequential PUTs against the same `(stack, stage, fqn)`