| 1 | import * as PgClient from "@effect/sql-pg/PgClient"; |
| 2 | import { |
| 3 | makePostgresState, |
| 4 | type PostgresStateOptions, |
| 5 | } from "@/State/PostgresState"; |
| 6 | import { StateStoreError, type StateService } from "@/State/State"; |
| 7 | import { describe, expect, it } from "alchemy-test"; |
| 8 | import * as Config from "effect/Config"; |
| 9 | import * as Deferred from "effect/Deferred"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Fiber from "effect/Fiber"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as Reactivity from "effect/reactivity/Reactivity"; |
| 15 | import * as SqlClient from "effect/sql/SqlClient"; |
| 16 | import type * as SqlConnection from "effect/sql/SqlConnection"; |
| 17 | import * as SqlError from "effect/sql/SqlError"; |
| 18 | |
| 19 | interface FakeQuery { |
| 20 | text: string; |
| 21 | values: ReadonlyArray<unknown>; |
| 22 | } |
| 23 | |
| 24 | interface FakeConnection extends SqlConnection.Connection { |
| 25 | queries: FakeQuery[]; |
| 26 | released: boolean; |
| 27 | } |
| 28 | |
| 29 | const unsupported = (operation: string) => |
| 30 | Effect.die(new Error(`fake postgres does not support ${operation}`)); |
| 31 | |
| 32 | const sqlFailure = (message: string) => |
| 33 | new SqlError.SqlError({ |
| 34 | reason: new SqlError.ConnectionError({ |
| 35 | cause: new Error(message), |
| 36 | message, |
| 37 | operation: "execute", |
| 38 | }), |
| 39 | }); |
| 40 | |
| 41 | /** |
| 42 | * Hermetic fake of the `@effect/sql` driver surface the store uses: a real |
| 43 | * `SqlClient` built on the real `@effect/sql-pg` statement compiler, whose |
| 44 | * connections are in-memory. It recognizes the store's fixed SQL statements |
| 45 | * and keeps rows in in-memory maps, so tests exercise the real store logic |
| 46 | * (lock acquisition, lease guard, encoding) without a database. |
| 47 | * |
| 48 | * Every acquired connection records its own queries and answers |
| 49 | * `pg_backend_pid()` with its own id, so tests can prove which connection |
| 50 | * ran what and can tell a pool apart from a single shared connection. |
| 51 | * |
| 52 | * What this fake cannot do is model a real pool: `singleConnection: true` |
| 53 | * reproduces `PgClient.makeClient`/`fromClient`, where reserving and |
| 54 | * running a plain statement both land on the same connection, but nothing |
| 55 | * here reproduces pool exhaustion, queueing, or a killed backend. That is |
| 56 | * why the store asks Postgres itself which backend answered the liveness |
| 57 | * check instead of trusting the client to hand out a second connection. |
| 58 | */ |
| 59 | const makeFakePostgres = (options: { singleConnection?: boolean } = {}) => { |
| 60 | const resources = new Map<string, unknown>(); |
| 61 | const outputs = new Map<string, unknown>(); |
| 62 | const connections: FakeConnection[] = []; |
| 63 | const control = { |
| 64 | lockAcquired: true, |
| 65 | lockLive: true, |
| 66 | lockQueries: 0, |
| 67 | failLockQuery: false, |
| 68 | // Holds the lock query in flight: `started` fires when the store issues |
| 69 | // it, and it only answers once `release` completes. |
| 70 | lockGate: undefined as |
| 71 | | { started: Deferred.Deferred<void>; release: Deferred.Deferred<void> } |
| 72 | | undefined, |
| 73 | }; |
| 74 | |
| 75 | const resourceKey = (stack: unknown, stage: unknown, fqn: unknown) => |
| 76 | `${stack} ${stage} ${fqn}`; |
| 77 | const outputKey = (stack: unknown, stage: unknown) => `${stack} ${stage}`; |
| 78 | |
| 79 | const rows = ( |
| 80 | values: ReadonlyArray<SqlConnection.Row>, |
| 81 | ): Effect.Effect<ReadonlyArray<SqlConnection.Row>, SqlError.SqlError> => |
| 82 | Effect.succeed(values); |
| 83 | |
| 84 | const handle = ( |
| 85 | text: string, |
| 86 | values: ReadonlyArray<unknown>, |
| 87 | backendPid: number, |
| 88 | ): Effect.Effect<ReadonlyArray<SqlConnection.Row>, SqlError.SqlError> => { |
| 89 | const sql = text.replaceAll(/\s+/g, " ").trim().toLowerCase(); |
| 90 | if (sql === "begin" || sql === "commit" || sql === "rollback") { |
| 91 | return rows([]); |
| 92 | } |
| 93 | if (sql.includes("pg_try_advisory_lock")) { |
| 94 | control.lockQueries += 1; |
| 95 | if (control.failLockQuery) { |
| 96 | return Effect.fail(sqlFailure("connection terminated unexpectedly")); |
| 97 | } |
| 98 | const acquired = rows([ |
| 99 | { acquired: control.lockAcquired, pid: backendPid }, |
| 100 | ]); |
| 101 | const gate = control.lockGate; |
| 102 | return gate === undefined |
| 103 | ? acquired |
| 104 | : Effect.sync(() => { |
| 105 | Deferred.doneUnsafe(gate.started, Effect.void); |
| 106 | }).pipe( |
| 107 | Effect.andThen(Deferred.await(gate.release)), |
| 108 | Effect.andThen(acquired), |
| 109 | ); |
| 110 | } |
| 111 | if (sql.includes("pg_advisory_xact_lock")) { |
| 112 | return rows([{}]); |
| 113 | } |
| 114 | if (sql.includes("pg_advisory_unlock")) { |
| 115 | return rows([{}]); |
| 116 | } |
| 117 | if (sql.includes("from pg_locks")) { |
| 118 | return rows([{ live: control.lockLive, checker_pid: backendPid }]); |
| 119 | } |
| 120 | if (sql.startsWith("create table")) { |
| 121 | return rows([]); |
| 122 | } |
| 123 | if (sql.startsWith("insert into alchemy_resource_state")) { |
| 124 | const [stack, stage, fqn, json] = values; |
| 125 | resources.set(resourceKey(stack, stage, fqn), JSON.parse(String(json))); |
| 126 | return rows([]); |
| 127 | } |
| 128 | if (sql.startsWith("insert into alchemy_stack_output")) { |
| 129 | const [stack, stage, json] = values; |
| 130 | outputs.set(outputKey(stack, stage), JSON.parse(String(json))); |
| 131 | return rows([]); |
| 132 | } |
| 133 | if (sql.includes("value ->> 'status'")) { |
| 134 | const [stack, stage] = values; |
| 135 | return rows( |
| 136 | Array.from(resources.entries()) |
| 137 | .filter(([key]) => key.startsWith(`${stack} ${stage} `)) |
| 138 | .map(([, value]) => ({ value })) |
| 139 | .filter( |
| 140 | (row) => |
| 141 | (row.value as { status?: string } | undefined)?.status === |
| 142 | "replaced", |
| 143 | ), |
| 144 | ); |
| 145 | } |
| 146 | if (sql.startsWith("select value from alchemy_resource_state")) { |
| 147 | const [stack, stage, fqn] = values; |
| 148 | const value = resources.get(resourceKey(stack, stage, fqn)); |
| 149 | return rows(value === undefined ? [] : [{ value }]); |
| 150 | } |
| 151 | if (sql.startsWith("select value from alchemy_stack_output")) { |
| 152 | const [stack, stage] = values; |
| 153 | const value = outputs.get(outputKey(stack, stage)); |
| 154 | return rows(value === undefined ? [] : [{ value }]); |
| 155 | } |
| 156 | if (sql.startsWith("select fqn from alchemy_resource_state")) { |
| 157 | const [stack, stage] = values; |
| 158 | return rows( |
| 159 | Array.from(resources.keys()) |
| 160 | .filter((key) => key.startsWith(`${stack} ${stage} `)) |
| 161 | .map((key) => ({ fqn: key.split(" ")[2] })) |
| 162 | .sort((a, b) => String(a.fqn).localeCompare(String(b.fqn))), |
| 163 | ); |
| 164 | } |
| 165 | if (sql.startsWith("select stack from alchemy_resource_state")) { |
| 166 | const stacks = new Set<string>(); |
| 167 | for (const key of resources.keys()) stacks.add(key.split(" ")[0]!); |
| 168 | for (const key of outputs.keys()) stacks.add(key.split(" ")[0]!); |
| 169 | return rows( |
| 170 | Array.from(stacks) |
| 171 | .sort() |
| 172 | .map((stack) => ({ stack })), |
| 173 | ); |
| 174 | } |
| 175 | if (sql.startsWith("select stage from alchemy_resource_state")) { |
| 176 | const [stack] = values; |
| 177 | const stages = new Set<string>(); |
| 178 | for (const key of resources.keys()) { |
| 179 | const [s, stage] = key.split(" "); |
| 180 | if (s === stack) stages.add(stage!); |
| 181 | } |
| 182 | for (const key of outputs.keys()) { |
| 183 | const [s, stage] = key.split(" "); |
| 184 | if (s === stack) stages.add(stage!); |
| 185 | } |
| 186 | return rows( |
| 187 | Array.from(stages) |
| 188 | .sort() |
| 189 | .map((stage) => ({ stage })), |
| 190 | ); |
| 191 | } |
| 192 | if (sql.startsWith("delete from alchemy_resource_state")) { |
| 193 | const [stack, stage, fqn] = values; |
| 194 | if (fqn !== undefined) { |
| 195 | resources.delete(resourceKey(stack, stage, fqn)); |
| 196 | } else if (stage !== undefined) { |
| 197 | for (const key of Array.from(resources.keys())) { |
| 198 | if (key.startsWith(`${stack} ${stage} `)) { |
| 199 | resources.delete(key); |
| 200 | } |
| 201 | } |
| 202 | } else { |
| 203 | for (const key of Array.from(resources.keys())) { |
| 204 | if (key.startsWith(`${stack} `)) resources.delete(key); |
| 205 | } |
| 206 | } |
| 207 | return rows([]); |
| 208 | } |
| 209 | if (sql.startsWith("delete from alchemy_stack_output")) { |
| 210 | const [stack, stage] = values; |
| 211 | for (const key of Array.from(outputs.keys())) { |
| 212 | const [s, keyStage] = key.split(" "); |
| 213 | if (s === stack && (stage === undefined || keyStage === stage)) { |
| 214 | outputs.delete(key); |
| 215 | } |
| 216 | } |
| 217 | return rows([]); |
| 218 | } |
| 219 | return Effect.die(new Error(`fake postgres does not recognize: ${sql}`)); |
| 220 | }; |
| 221 | |
| 222 | let nextBackendPid = 100; |
| 223 | |
| 224 | const makeConnection = (): FakeConnection => { |
| 225 | const queries: FakeQuery[] = []; |
| 226 | const backendPid = nextBackendPid++; |
| 227 | const execute = (text: string, values: ReadonlyArray<unknown>) => |
| 228 | Effect.suspend(() => { |
| 229 | queries.push({ text, values }); |
| 230 | return handle(text, values, backendPid); |
| 231 | }); |
| 232 | const connection: FakeConnection = { |
| 233 | queries, |
| 234 | released: false, |
| 235 | execute, |
| 236 | executeUnprepared: execute, |
| 237 | executeRaw: execute, |
| 238 | executeValues: () => unsupported("executeValues"), |
| 239 | executeValuesUnprepared: () => unsupported("executeValuesUnprepared"), |
| 240 | executeStream: () => Stream.fromEffect(unsupported("executeStream")), |
| 241 | }; |
| 242 | connections.push(connection); |
| 243 | return connection; |
| 244 | }; |
| 245 | |
| 246 | // A scoped acquirer, exactly like a pool's: every acquisition hands out a |
| 247 | // fresh connection and marks it released when its scope closes. With |
| 248 | // `singleConnection`, every acquisition returns the same connection — |
| 249 | // what `PgClient.makeClient` does, where `reserve` and plain statements |
| 250 | // share one backend. |
| 251 | const shared = options.singleConnection ? makeConnection() : undefined; |
| 252 | const acquirer: SqlConnection.Acquirer = |
| 253 | shared === undefined |
| 254 | ? Effect.acquireRelease(Effect.sync(makeConnection), (connection) => |
| 255 | Effect.sync(() => { |
| 256 | connection.released = true; |
| 257 | }), |
| 258 | ) |
| 259 | : Effect.succeed(shared); |
| 260 | |
| 261 | const client = SqlClient.make({ |
| 262 | acquirer, |
| 263 | compiler: PgClient.makeCompiler(), |
| 264 | spanAttributes: [], |
| 265 | }).pipe(Effect.provide(Reactivity.layer)); |
| 266 | |
| 267 | return { client, connections, resources, outputs, control }; |
| 268 | }; |
| 269 | |
| 270 | const connectionRunning = ( |
| 271 | connections: FakeConnection[], |
| 272 | fragment: string, |
| 273 | ): FakeConnection | undefined => |
| 274 | connections.find((connection) => |
| 275 | connection.queries.some((query) => query.text.includes(fragment)), |
| 276 | ); |
| 277 | |
| 278 | type FakePostgres = ReturnType<typeof makeFakePostgres>; |
| 279 | |
| 280 | const withStore = <A, E>( |
| 281 | fake: FakePostgres, |
| 282 | options: Omit<PostgresStateOptions, "client">, |
| 283 | use: (store: StateService) => Effect.Effect<A, E>, |
| 284 | ): Effect.Effect<A, E | StateStoreError> => |
| 285 | Effect.gen(function* () { |
| 286 | const scope = yield* Effect.scope; |
| 287 | const store = yield* makePostgresState( |
| 288 | { ...options, client: yield* fake.client }, |
| 289 | scope, |
| 290 | ); |
| 291 | return yield* use(store); |
| 292 | }).pipe(Effect.scoped); |
| 293 | |
| 294 | /** Builds the store without a client, to exercise option validation. */ |
| 295 | const withoutClient = <A, E, E2>( |
| 296 | options: PostgresStateOptions<E2>, |
| 297 | use: (store: StateService) => Effect.Effect<A, E>, |
| 298 | ): Effect.Effect<A, E | StateStoreError> => |
| 299 | Effect.gen(function* () { |
| 300 | const scope = yield* Effect.scope; |
| 301 | const store = yield* makePostgresState(options, scope); |
| 302 | return yield* use(store); |
| 303 | }).pipe(Effect.scoped); |
| 304 | |
| 305 | const request = { stack: "app", stage: "prod", fqn: "app/prod/db" }; |
| 306 | |
| 307 | const sampleState = { |
| 308 | kind: "resource", |
| 309 | status: "created", |
| 310 | logicalId: "db", |
| 311 | output: { password: Redacted.make("s3cret") }, |
| 312 | } as never; |
| 313 | |
| 314 | describe("Postgres state store", { tags: ["unit", "local"] }, () => { |
| 315 | it.effect("requires exactly one of client or url", () => { |
| 316 | const fake = makeFakePostgres(); |
| 317 | return Effect.gen(function* () { |
| 318 | const neither = yield* withoutClient({}, (store) => |
| 319 | store.get(request), |
| 320 | ).pipe(Effect.flip); |
| 321 | expect(neither).toBeInstanceOf(StateStoreError); |
| 322 | expect(neither.message).toContain("exactly one of `client` or `url`"); |
| 323 | |
| 324 | const both = yield* withStore( |
| 325 | fake, |
| 326 | { url: Redacted.make("postgres://localhost/state") }, |
| 327 | (store) => store.get(request), |
| 328 | ).pipe(Effect.flip); |
| 329 | expect(both).toBeInstanceOf(StateStoreError); |
| 330 | expect((both as StateStoreError).message).toContain( |
| 331 | "exactly one of `client` or `url`", |
| 332 | ); |
| 333 | }); |
| 334 | }); |
| 335 | |
| 336 | it.effect("reports an unresolvable url config as a state store error", () => { |
| 337 | // `url` accepts any Effect of a Redacted string, so |
| 338 | // `Config.Redacted(...)` can be passed straight through; a missing |
| 339 | // variable surfaces as a StateStoreError like any other failure. |
| 340 | return withoutClient( |
| 341 | { url: Config.Redacted("ALCHEMY_TEST_MISSING_STATE_DATABASE_URL") }, |
| 342 | (store) => store.get(request), |
| 343 | ).pipe( |
| 344 | Effect.flip, |
| 345 | Effect.map((error) => { |
| 346 | expect(error).toBeInstanceOf(StateStoreError); |
| 347 | expect(error.message).toContain( |
| 348 | "ALCHEMY_TEST_MISSING_STATE_DATABASE_URL", |
| 349 | ); |
| 350 | }), |
| 351 | ); |
| 352 | }); |
| 353 | |
| 354 | it.effect("round-trips resource state including Redacted values", () => { |
| 355 | const fake = makeFakePostgres(); |
| 356 | return withStore(fake, {}, (store) => |
| 357 | Effect.gen(function* () { |
| 358 | expect(yield* store.get(request)).toBeUndefined(); |
| 359 | |
| 360 | yield* store.set({ ...request, value: sampleState }); |
| 361 | const revived = (yield* store.get(request)) as { |
| 362 | status: string; |
| 363 | output: { password: Redacted.Redacted<string> }; |
| 364 | }; |
| 365 | |
| 366 | expect(revived.status).toBe("created"); |
| 367 | expect(Redacted.isRedacted(revived.output.password)).toBe(true); |
| 368 | expect(Redacted.value(revived.output.password)).toBe("s3cret"); |
| 369 | |
| 370 | // The jsonb column stores the encoded (redaction-marked) form, so |
| 371 | // the raw secret round-trips through encodeState, not plain JSON. |
| 372 | const stored = fake.resources.get("app prod app/prod/db"); |
| 373 | expect(JSON.stringify(stored)).toContain("__redacted__"); |
| 374 | }), |
| 375 | ); |
| 376 | }); |
| 377 | |
| 378 | it.effect("serializes the schema migration under an advisory lock", () => { |
| 379 | const fake = makeFakePostgres(); |
| 380 | return withStore(fake, {}, (store) => |
| 381 | Effect.gen(function* () { |
| 382 | yield* store.get(request); |
| 383 | |
| 384 | const migration = connectionRunning(fake.connections, "create table"); |
| 385 | expect(migration).toBeDefined(); |
| 386 | const statements = migration!.queries.map((query) => |
| 387 | query.text.replaceAll(/\s+/g, " ").trim().toLowerCase(), |
| 388 | ); |
| 389 | expect(statements[0]).toBe("begin"); |
| 390 | expect(statements[1]).toContain("pg_advisory_xact_lock"); |
| 391 | expect(migration!.queries[1]?.values).toEqual(["alchemy:schema"]); |
| 392 | expect(statements[2]).toContain( |
| 393 | "create table if not exists alchemy_resource_state", |
| 394 | ); |
| 395 | expect(statements[3]).toContain( |
| 396 | "create table if not exists alchemy_stack_output", |
| 397 | ); |
| 398 | expect(statements[4]).toBe("commit"); |
| 399 | expect(migration!.released).toBe(true); |
| 400 | }), |
| 401 | ); |
| 402 | }); |
| 403 | |
| 404 | it.effect("acquires the advisory lock once per stack/stage", () => { |
| 405 | const fake = makeFakePostgres(); |
| 406 | return withStore(fake, {}, (store) => |
| 407 | Effect.gen(function* () { |
| 408 | yield* store.set({ ...request, value: sampleState }); |
| 409 | yield* store.get(request); |
| 410 | yield* store.list(request); |
| 411 | yield* store.setOutput({ ...request, value: { url: "https://x" } }); |
| 412 | expect(yield* store.getOutput(request)).toEqual({ url: "https://x" }); |
| 413 | |
| 414 | expect(fake.control.lockQueries).toBe(1); |
| 415 | const lock = connectionRunning( |
| 416 | fake.connections, |
| 417 | "pg_try_advisory_lock", |
| 418 | ); |
| 419 | expect(lock?.queries[0]?.values).toEqual(["alchemy:app/prod"]); |
| 420 | }), |
| 421 | ); |
| 422 | }); |
| 423 | |
| 424 | it.effect( |
| 425 | "never runs the lease check on the reserved lock connection", |
| 426 | () => { |
| 427 | const fake = makeFakePostgres(); |
| 428 | return withStore(fake, { leaseCheckTtlMs: 0 }, (store) => |
| 429 | Effect.gen(function* () { |
| 430 | yield* store.set({ ...request, value: sampleState }); |
| 431 | yield* store.get(request); |
| 432 | |
| 433 | const lock = connectionRunning( |
| 434 | fake.connections, |
| 435 | "pg_try_advisory_lock", |
| 436 | ); |
| 437 | expect(lock).toBeDefined(); |
| 438 | // The reserved connection only ever takes the lock; the liveness |
| 439 | // check must ask a different backend, because the reserved |
| 440 | // connection cannot reliably report on itself once its backend |
| 441 | // has been killed server-side. |
| 442 | expect( |
| 443 | lock!.queries.some((query) => query.text.includes("pg_locks")), |
| 444 | ).toBe(false); |
| 445 | expect( |
| 446 | fake.connections |
| 447 | .filter((connection) => connection !== lock) |
| 448 | .some((connection) => |
| 449 | connection.queries.some((query) => |
| 450 | query.text.includes("pg_locks"), |
| 451 | ), |
| 452 | ), |
| 453 | ).toBe(true); |
| 454 | }), |
| 455 | ); |
| 456 | }, |
| 457 | ); |
| 458 | |
| 459 | it.effect( |
| 460 | "refuses a client that cannot verify the lock independently", |
| 461 | () => { |
| 462 | // A single-connection client routes the liveness check straight back to |
| 463 | // the backend holding the lock, which cannot vouch for itself. The |
| 464 | // store notices because the check reports its own backend pid. |
| 465 | const fake = makeFakePostgres({ singleConnection: true }); |
| 466 | return withStore(fake, { leaseCheckTtlMs: 0 }, (store) => |
| 467 | Effect.gen(function* () { |
| 468 | const error = yield* store.get(request).pipe(Effect.flip); |
| 469 | expect(error).toBeInstanceOf(StateStoreError); |
| 470 | expect(error.message).toContain("cannot be verified"); |
| 471 | expect(error.message).toContain("not pool-backed"); |
| 472 | }), |
| 473 | ); |
| 474 | }, |
| 475 | ); |
| 476 | |
| 477 | it.effect("prefixes the lock key with lockKeyPrefix", () => { |
| 478 | const fake = makeFakePostgres(); |
| 479 | return withStore(fake, { lockKeyPrefix: "my-app" }, (store) => |
| 480 | Effect.gen(function* () { |
| 481 | yield* store.get(request); |
| 482 | const lock = connectionRunning( |
| 483 | fake.connections, |
| 484 | "pg_try_advisory_lock", |
| 485 | ); |
| 486 | expect(lock?.queries[0]?.values).toEqual(["my-app:app/prod"]); |
| 487 | }), |
| 488 | ); |
| 489 | }); |
| 490 | |
| 491 | it.effect("fails immediately when another deploy holds the lock", () => { |
| 492 | const fake = makeFakePostgres(); |
| 493 | fake.control.lockAcquired = false; |
| 494 | return withStore(fake, {}, (store) => |
| 495 | Effect.gen(function* () { |
| 496 | const result = yield* store.get(request).pipe(Effect.flip); |
| 497 | expect(result).toBeInstanceOf(StateStoreError); |
| 498 | expect(result.message).toContain( |
| 499 | "another deploy holds the Postgres state lock", |
| 500 | ); |
| 501 | // The reserved connection is released on contention. |
| 502 | const lock = connectionRunning( |
| 503 | fake.connections, |
| 504 | "pg_try_advisory_lock", |
| 505 | ); |
| 506 | expect(lock?.released).toBe(true); |
| 507 | }), |
| 508 | ); |
| 509 | }); |
| 510 | |
| 511 | it.effect( |
| 512 | "releases the reserved connection when the lock query fails", |
| 513 | () => { |
| 514 | const fake = makeFakePostgres(); |
| 515 | // The lock attempt fails at the driver, so the reserved connection must |
| 516 | // go back to the pool rather than leak. |
| 517 | fake.control.failLockQuery = true; |
| 518 | return withStore(fake, {}, (store) => |
| 519 | Effect.gen(function* () { |
| 520 | const error = yield* store.get(request).pipe(Effect.flip); |
| 521 | expect(error).toBeInstanceOf(StateStoreError); |
| 522 | expect(error.message).toContain("connection terminated unexpectedly"); |
| 523 | |
| 524 | const lock = connectionRunning( |
| 525 | fake.connections, |
| 526 | "pg_try_advisory_lock", |
| 527 | ); |
| 528 | expect(lock?.released).toBe(true); |
| 529 | }), |
| 530 | ); |
| 531 | }, |
| 532 | ); |
| 533 | |
| 534 | it.effect( |
| 535 | "registers the unlock finalizer even when interrupted mid-acquisition", |
| 536 | () => { |
| 537 | const fake = makeFakePostgres(); |
| 538 | return Effect.gen(function* () { |
| 539 | const started = yield* Deferred.make<void>(); |
| 540 | const release = yield* Deferred.make<void>(); |
| 541 | fake.control.lockGate = { started, release }; |
| 542 | |
| 543 | const fiber = yield* withStore(fake, {}, (store) => |
| 544 | store.get(request), |
| 545 | ).pipe(Effect.forkChild({ startImmediately: true })); |
| 546 | |
| 547 | // Interrupt while the lock query is still in flight. Taking the |
| 548 | // lock and registering its unlock finalizer is uninterruptible, so |
| 549 | // the store must finish both before the interruption is honored — |
| 550 | // otherwise the session lock would stay held on a connection handed |
| 551 | // back to the pool, and every later deploy of this stack/stage |
| 552 | // would fail on it. |
| 553 | yield* Deferred.await(started); |
| 554 | const interrupting = yield* Fiber.interrupt(fiber).pipe( |
| 555 | Effect.forkChild({ startImmediately: true }), |
| 556 | ); |
| 557 | yield* Deferred.succeed(release, undefined); |
| 558 | yield* Fiber.join(interrupting); |
| 559 | |
| 560 | const lock = connectionRunning( |
| 561 | fake.connections, |
| 562 | "pg_try_advisory_lock", |
| 563 | ); |
| 564 | expect(lock).toBeDefined(); |
| 565 | expect( |
| 566 | lock!.queries.some((query) => |
| 567 | query.text.includes("pg_advisory_unlock"), |
| 568 | ), |
| 569 | ).toBe(true); |
| 570 | expect(lock!.released).toBe(true); |
| 571 | }); |
| 572 | }, |
| 573 | ); |
| 574 | |
| 575 | it.effect("unlocks and releases the lock connection on scope close", () => { |
| 576 | const fake = makeFakePostgres(); |
| 577 | return withStore(fake, {}, (store) => |
| 578 | store.set({ ...request, value: sampleState }), |
| 579 | ).pipe( |
| 580 | Effect.andThen( |
| 581 | Effect.sync(() => { |
| 582 | const lock = connectionRunning( |
| 583 | fake.connections, |
| 584 | "pg_try_advisory_lock", |
| 585 | ); |
| 586 | expect(lock).toBeDefined(); |
| 587 | expect( |
| 588 | lock!.queries.some((query) => |
| 589 | query.text.includes("pg_advisory_unlock"), |
| 590 | ), |
| 591 | ).toBe(true); |
| 592 | expect(lock!.released).toBe(true); |
| 593 | }), |
| 594 | ), |
| 595 | ); |
| 596 | }); |
| 597 | |
| 598 | it.effect("fails loudly when the lock lease is lost mid-run", () => { |
| 599 | const fake = makeFakePostgres(); |
| 600 | return withStore(fake, { leaseCheckTtlMs: 0 }, (store) => |
| 601 | Effect.gen(function* () { |
| 602 | yield* store.set({ ...request, value: sampleState }); |
| 603 | |
| 604 | fake.control.lockLive = false; |
| 605 | const result = yield* store.get(request).pipe(Effect.flip); |
| 606 | expect(result).toBeInstanceOf(StateStoreError); |
| 607 | expect(result.message).toContain("was lost mid-run"); |
| 608 | |
| 609 | // Stage-less operations re-verify held leases too. |
| 610 | const listResult = yield* store.listStacks().pipe(Effect.flip); |
| 611 | expect(listResult.message).toContain("was lost mid-run"); |
| 612 | }), |
| 613 | ); |
| 614 | }); |
| 615 | |
| 616 | it.effect("filters replaced resources in SQL", () => { |
| 617 | const fake = makeFakePostgres(); |
| 618 | return withStore(fake, {}, (store) => |
| 619 | Effect.gen(function* () { |
| 620 | yield* store.set({ ...request, value: sampleState }); |
| 621 | yield* store.set({ |
| 622 | ...request, |
| 623 | fqn: "app/prod/old", |
| 624 | value: { ...(sampleState as object), status: "replaced" } as never, |
| 625 | }); |
| 626 | |
| 627 | const replaced = yield* store.getReplacedResources(request); |
| 628 | expect(replaced).toHaveLength(1); |
| 629 | expect(replaced[0]?.status).toBe("replaced"); |
| 630 | }), |
| 631 | ); |
| 632 | }); |
| 633 | |
| 634 | it.effect("lists stacks, stages, and fqns", () => { |
| 635 | const fake = makeFakePostgres(); |
| 636 | return withStore(fake, {}, (store) => |
| 637 | Effect.gen(function* () { |
| 638 | yield* store.set({ ...request, value: sampleState }); |
| 639 | yield* store.set({ |
| 640 | ...request, |
| 641 | fqn: "app/prod/api", |
| 642 | value: sampleState, |
| 643 | }); |
| 644 | yield* store.setOutput({ |
| 645 | stack: "other", |
| 646 | stage: "dev", |
| 647 | value: { ok: true }, |
| 648 | }); |
| 649 | |
| 650 | expect(yield* store.listStacks()).toEqual(["app", "other"]); |
| 651 | expect(yield* store.listStages("app")).toEqual(["prod"]); |
| 652 | expect(yield* store.list(request)).toEqual([ |
| 653 | "app/prod/api", |
| 654 | "app/prod/db", |
| 655 | ]); |
| 656 | }), |
| 657 | ); |
| 658 | }); |
| 659 | |
| 660 | it.effect("deleteStack removes resource rows and stack outputs", () => { |
| 661 | const fake = makeFakePostgres(); |
| 662 | return withStore(fake, {}, (store) => |
| 663 | Effect.gen(function* () { |
| 664 | yield* store.set({ ...request, value: sampleState }); |
| 665 | yield* store.setOutput({ ...request, value: { ok: true } }); |
| 666 | |
| 667 | yield* store.deleteStack({ stack: "app", stage: "prod" }); |
| 668 | expect(yield* store.get(request)).toBeUndefined(); |
| 669 | expect(yield* store.getOutput(request)).toBeUndefined(); |
| 670 | |
| 671 | yield* store.set({ ...request, value: sampleState }); |
| 672 | yield* store.deleteStack({ stack: "app" }); |
| 673 | expect(fake.resources.size).toBe(0); |
| 674 | expect(fake.outputs.size).toBe(0); |
| 675 | }), |
| 676 | ); |
| 677 | }); |
| 678 | |
| 679 | it.effect("stage-less deleteStack locks every stage before deleting", () => { |
| 680 | const fake = makeFakePostgres(); |
| 681 | // Rows written by an earlier run: this store has not touched the stack |
| 682 | // yet, so it holds no leases when deleteStack starts. |
| 683 | fake.resources.set("app prod app/prod/db", { status: "created" }); |
| 684 | fake.outputs.set("app prod", { ok: true }); |
| 685 | |
| 686 | return withStore(fake, {}, (store) => |
| 687 | Effect.gen(function* () { |
| 688 | yield* store.deleteStack({ stack: "app" }); |
| 689 | |
| 690 | const lock = connectionRunning( |
| 691 | fake.connections, |
| 692 | "pg_try_advisory_lock", |
| 693 | ); |
| 694 | expect(lock?.queries[0]?.values).toEqual(["alchemy:app/prod"]); |
| 695 | expect(fake.resources.size).toBe(0); |
| 696 | expect(fake.outputs.size).toBe(0); |
| 697 | }), |
| 698 | ); |
| 699 | }); |
| 700 | |
| 701 | it.effect("delete removes a single resource", () => { |
| 702 | const fake = makeFakePostgres(); |
| 703 | return withStore(fake, {}, (store) => |
| 704 | Effect.gen(function* () { |
| 705 | yield* store.set({ ...request, value: sampleState }); |
| 706 | yield* store.delete(request); |
| 707 | expect(yield* store.get(request)).toBeUndefined(); |
| 708 | }), |
| 709 | ); |
| 710 | }); |
| 711 | }); |