1import { makePostgresState } from "@/State/PostgresState";
2import { StateStoreError, type StateService } from "@/State/State";
3import { describe, expect, it } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6
7/**
8 * Live counterpart of `PostgresState.test.ts`. The hermetic suite answers the
9 * store's fixed SQL from an in-memory fake, so the statements Postgres itself
10 * must accept — `hashtextextended` key derivation, `pg_try_advisory_lock`
11 * session semantics, the `pg_locks` holder verification, the transactional
12 * schema migration — only run for real here.
13 *
14 * Guarded like the other live suites: set
15 * `ALCHEMY_RUN_LIVE_POSTGRES_STATE_TESTS=true` and point
16 * `POSTGRES_STATE_TEST_URL` at a scratch Postgres database.
17 */
18const wantsLive = process.env.ALCHEMY_RUN_LIVE_POSTGRES_STATE_TESTS === "true";
19const liveUrl = process.env.POSTGRES_STATE_TEST_URL?.trim();
20const runLive = wantsLive && !!liveUrl;
21
22if (wantsLive && !liveUrl) {
23 it.effect(
24 "requires a database url for the live Postgres state suite",
25 () =>
26 Effect.fail(
27 new Error(
28 [
29 "Live Postgres state suite requested but no database is configured.",
30 "Set POSTGRES_STATE_TEST_URL to a scratch Postgres connection URL,",
31 "then rerun with ALCHEMY_RUN_LIVE_POSTGRES_STATE_TESTS=true.",
32 ].join(" "),
33 ),
34 ),
35 { tags: ["local"] },
36 );
37}
38
39/** Each store gets its own pool, as separate deploy processes would. */
40const withLiveStore = <A, E>(
41 use: (store: StateService) => Effect.Effect<A, E>,
42): Effect.Effect<A, E | StateStoreError> =>
43 Effect.gen(function* () {
44 const scope = yield* Effect.scope;
45 const store = yield* makePostgresState(
46 { url: Redacted.make(liveUrl!) },
47 scope,
48 );
49 return yield* use(store);
50 }).pipe(Effect.scoped);
51
52const stack = "alchemy-postgres-state-live";
53const request = { stack, stage: "test", fqn: `${stack}/test/db` };
54
55const sampleState = {
56 kind: "resource",
57 status: "created",
58 logicalId: "db",
59 output: { password: Redacted.make("s3cret") },
60} as never;
61
62describe.skipIf(!runLive)(
63 "Postgres state store against real Postgres",
64 { tags: ["local"] },
65 () => {
66 it.effect("round-trips resource state through a real database", () =>
67 withLiveStore((store) =>
68 Effect.gen(function* () {
69 // Leftovers from an interrupted earlier run must not fail this one.
70 yield* store.deleteStack({ stack });
71
72 expect(yield* store.get(request)).toBeUndefined();
73
74 yield* store.set({ ...request, value: sampleState });
75 const revived = (yield* store.get(request)) as {
76 output: { password: Redacted.Redacted<string> };
77 };
78 expect(Redacted.isRedacted(revived.output.password)).toBe(true);
79 expect(Redacted.value(revived.output.password)).toBe("s3cret");
80
81 yield* store.delete(request);
82 expect(yield* store.get(request)).toBeUndefined();
83
84 yield* store.deleteStack({ stack });
85 }),
86 ),
87 );
88
89 it.effect(
90 "refuses a second deploy while the stage advisory lock is held",
91 () =>
92 withLiveStore((holder) =>
93 Effect.gen(function* () {
94 // The first operation acquires the session advisory lock for the
95 // (stack, stage) and keeps it for the life of the holder's scope.
96 yield* holder.set({ ...request, value: sampleState });
97
98 const error = yield* withLiveStore((contender) =>
99 contender.get(request),
100 ).pipe(Effect.flip);
101 expect(error).toBeInstanceOf(StateStoreError);
102 expect(error.message).toContain("holds the Postgres state lock");
103
104 yield* holder.deleteStack({ stack });
105 }),
106 ),
107 );
108
109 it.effect("releases the lock when the store's scope closes", () =>
110 Effect.gen(function* () {
111 yield* withLiveStore((store) =>
112 store.set({ ...request, value: sampleState }),
113 );
114 // The previous store's scope has closed, so a fresh deploy proceeds.
115 yield* withLiveStore((store) =>
116 Effect.gen(function* () {
117 expect(yield* store.get(request)).toBeDefined();
118 yield* store.deleteStack({ stack });
119 }),
120 );
121 }),
122 );
123
124 it.effect("migrates the schema concurrently without colliding", () =>
125 // Two fresh pools race the first state operation, so both run the
126 // transactional `create table if not exists` migration at once — the
127 // concurrency that fails on real Postgres without the advisory lock.
128 Effect.all(
129 [
130 withLiveStore((store) =>
131 store.deleteStack({ stack: `${stack}-migrate-a` }),
132 ),
133 withLiveStore((store) =>
134 store.deleteStack({ stack: `${stack}-migrate-b` }),
135 ),
136 ],
137 { concurrency: 2 },
138 ),
139 );
140 },
141);