@checkstack/backend-api 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Integration test (real Postgres) for the advisory-lock service.
3
+ *
4
+ * This is part of the surgical integration lane (plan §14.4 #1). It pins the
5
+ * one behaviour fakes cannot model faithfully: Postgres session-level advisory
6
+ * locks are tied to the DB *connection* that acquired them, so the holding
7
+ * client must be the same one that releases — and killing the holding
8
+ * connection must auto-release the lock.
9
+ *
10
+ * Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. The
11
+ * `integration` CI job sets that flag and provides a real Postgres service
12
+ * container. Connection comes from `CHECKSTACK_IT_PG_URL` (defaulting to the
13
+ * `docker-compose-dev.yml` Postgres port).
14
+ */
15
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
16
+ import { Pool } from "pg";
17
+ import { createAdvisoryLockService } from "./advisory-lock";
18
+
19
+ const PG_URL =
20
+ process.env.CHECKSTACK_IT_PG_URL ??
21
+ "postgres://postgres:postgres@localhost:5432/postgres";
22
+
23
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
24
+ "advisory-lock (real Postgres)",
25
+ () => {
26
+ let pool: Pool;
27
+
28
+ beforeAll(() => {
29
+ pool = new Pool({ connectionString: PG_URL });
30
+ // A pooled client can error asynchronously while idle (e.g. its backend
31
+ // is terminated by the kill test below). pg emits that on the pool; with
32
+ // no handler it surfaces as an unhandled "Connection terminated
33
+ // unexpectedly" error that fails the whole test file. Swallowing idle-
34
+ // client errors is the documented pg pattern - the tests still assert
35
+ // behaviour through fresh checkouts.
36
+ pool.on("error", () => {});
37
+ });
38
+
39
+ afterAll(async () => {
40
+ await pool.end();
41
+ });
42
+
43
+ it("a second tryAcquire of the same key returns null until release", async () => {
44
+ const service = createAdvisoryLockService(pool);
45
+ const key = `it-advisory-lock:${crypto.randomUUID()}`;
46
+
47
+ const first = await service.tryAcquire(key);
48
+ expect(first).not.toBeNull();
49
+
50
+ // The lock is held — a concurrent acquire of the SAME key must fail.
51
+ const second = await service.tryAcquire(key);
52
+ expect(second).toBeNull();
53
+
54
+ // After release, a third acquire succeeds.
55
+ await first?.release();
56
+ const third = await service.tryAcquire(key);
57
+ expect(third).not.toBeNull();
58
+ await third?.release();
59
+ });
60
+
61
+ it("killing the holding connection auto-releases the lock", async () => {
62
+ const service = createAdvisoryLockService(pool);
63
+ const key = `it-advisory-lock:${crypto.randomUUID()}`;
64
+
65
+ // Acquire on a dedicated client owned by the handle.
66
+ const held = await service.tryAcquire(key);
67
+ expect(held).not.toBeNull();
68
+
69
+ // While held, the key is unavailable.
70
+ const blocked = await service.tryAcquire(key);
71
+ expect(blocked).toBeNull();
72
+
73
+ // Terminate ONLY the backend holding the advisory lock - found via
74
+ // `pg_locks` - from a fresh connection. Dropping that session makes
75
+ // Postgres auto-release the lock. We deliberately do NOT kill every other
76
+ // backend (the old approach): that also terminated the pool's idle
77
+ // connections, whose async "connection terminated" errors flaked the test
78
+ // and left the pool unusable. The handle holds exactly one advisory lock,
79
+ // so this targets precisely the holder.
80
+ const killer = await pool.connect();
81
+ try {
82
+ await killer.query(
83
+ `SELECT pg_terminate_backend(pid)
84
+ FROM pg_locks
85
+ WHERE locktype = 'advisory'
86
+ AND pid <> pg_backend_pid()`,
87
+ );
88
+ } finally {
89
+ killer.release();
90
+ }
91
+
92
+ // The lock should now be acquirable again. Retry briefly because the
93
+ // server takes a moment to reap the terminated backend's session locks.
94
+ let reacquired: Awaited<ReturnType<typeof service.tryAcquire>> = null;
95
+ for (let attempt = 0; attempt < 20 && reacquired === null; attempt++) {
96
+ reacquired = await service.tryAcquire(key);
97
+ if (reacquired === null) {
98
+ await new Promise((resolve) => setTimeout(resolve, 50));
99
+ }
100
+ }
101
+ expect(reacquired).not.toBeNull();
102
+ await reacquired?.release();
103
+
104
+ // The `held` handle still owns its (now-terminated) client. Release it so
105
+ // the dead client is returned to the pool - otherwise `pool.end()` in
106
+ // afterAll blocks waiting for the checked-out client to drain. The unlock
107
+ // query runs against a dead connection and rejects; that's expected.
108
+ await held?.release().catch(() => {});
109
+ });
110
+ },
111
+ );
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ createAdvisoryLockService,
4
+ type AdvisoryLockPool,
5
+ type AdvisoryLockPoolClient,
6
+ } from "./advisory-lock";
7
+
8
+ /**
9
+ * Faithful fake of a `pg.Pool` that models Postgres' per-connection
10
+ * advisory-lock semantics for BOTH lock flavours:
11
+ *
12
+ * SESSION locks (`tryAcquire`):
13
+ * - A key can be held by at most one connection at a time.
14
+ * - `pg_try_advisory_lock` succeeds only if the key is free; it then
15
+ * binds the key to the acquiring connection.
16
+ * - `pg_advisory_unlock` only frees the key if THIS connection holds it
17
+ * (a no-op otherwise) — exactly the bug we are guarding against: an
18
+ * unlock issued on a different connection does nothing.
19
+ *
20
+ * TRANSACTION locks (`withXactLock`):
21
+ * - `pg_advisory_xact_lock` BLOCKS until the key is free, then binds it to
22
+ * the acquiring connection's transaction.
23
+ * - `COMMIT` / `ROLLBACK` release every xact lock held by that connection
24
+ * and wake the next blocked waiter (FIFO) — modelling auto-release and
25
+ * the serialization guarantee.
26
+ *
27
+ * This lets the tests prove the service keeps acquire + release on ONE
28
+ * client and that concurrent `withXactLock` callers serialize.
29
+ */
30
+ interface FakePool extends AdvisoryLockPool {
31
+ checkedOut: number;
32
+ released: number;
33
+ }
34
+
35
+ function makeFakePool(): FakePool {
36
+ // key -> owning connection id (or absent if free)
37
+ const heldBy = new Map<string, number>();
38
+ // xact key -> owning connection id; waiters queued FIFO per key.
39
+ const xactHeldBy = new Map<string, number>();
40
+ const xactWaiters = new Map<string, Array<() => void>>();
41
+ let nextConnId = 0;
42
+ const counters = { checkedOut: 0, released: 0 };
43
+
44
+ // hashtextextended($1, 0) is opaque here — we just key on the raw string,
45
+ // which is faithful since the SQL is deterministic per key.
46
+ function keyOf(values: unknown[] | undefined): string {
47
+ return String(values?.[0]);
48
+ }
49
+
50
+ return {
51
+ get checkedOut() {
52
+ return counters.checkedOut;
53
+ },
54
+ get released() {
55
+ return counters.released;
56
+ },
57
+ async connect(): Promise<AdvisoryLockPoolClient> {
58
+ const connId = nextConnId++;
59
+ counters.checkedOut++;
60
+ const releaseXactLocks = () => {
61
+ for (const [key, owner] of [...xactHeldBy.entries()]) {
62
+ if (owner !== connId) continue;
63
+ xactHeldBy.delete(key);
64
+ const next = xactWaiters.get(key)?.shift();
65
+ if (next) next();
66
+ }
67
+ };
68
+ return {
69
+ async query<T>(queryText: string, values?: unknown[]) {
70
+ if (queryText === "BEGIN") return { rows: [] };
71
+ if (queryText === "COMMIT" || queryText === "ROLLBACK") {
72
+ releaseXactLocks();
73
+ return { rows: [] };
74
+ }
75
+ const key = keyOf(values);
76
+ if (queryText.includes("pg_try_advisory_lock")) {
77
+ const owner = heldBy.get(key);
78
+ const ok = owner === undefined;
79
+ if (ok) heldBy.set(key, connId);
80
+ return { rows: [{ ok } as unknown as T] };
81
+ }
82
+ if (queryText.includes("pg_advisory_xact_lock")) {
83
+ if (!xactHeldBy.has(key)) {
84
+ xactHeldBy.set(key, connId);
85
+ return { rows: [] };
86
+ }
87
+ // Blocked: enqueue and wait until a holder commits/rolls back.
88
+ await new Promise<void>((resolve) => {
89
+ const q = xactWaiters.get(key) ?? [];
90
+ q.push(() => {
91
+ xactHeldBy.set(key, connId);
92
+ resolve();
93
+ });
94
+ xactWaiters.set(key, q);
95
+ });
96
+ return { rows: [] };
97
+ }
98
+ if (queryText.includes("pg_advisory_unlock")) {
99
+ // Only the owning connection can release — model the leak bug.
100
+ if (heldBy.get(key) === connId) heldBy.delete(key);
101
+ return { rows: [{ ok: true } as unknown as T] };
102
+ }
103
+ return { rows: [] };
104
+ },
105
+ release() {
106
+ counters.released++;
107
+ },
108
+ on() {
109
+ // The fake never emits async client errors; the real client's
110
+ // `on('error')` hardening is exercised by the IT against real
111
+ // Postgres (killing the holding connection).
112
+ },
113
+ off() {
114
+ // Counterpart to `on`; the service detaches its error listener on
115
+ // release. No-op here since the fake never attaches one.
116
+ },
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ describe("createAdvisoryLockService", () => {
123
+ it("acquire → second acquire fails while held → release → third acquire succeeds", async () => {
124
+ const pool = makeFakePool();
125
+ const svc = createAdvisoryLockService(pool);
126
+
127
+ const first = await svc.tryAcquire("k");
128
+ expect(first).not.toBeNull();
129
+
130
+ // Held: a second acquire (different pooled connection) must fail.
131
+ const second = await svc.tryAcquire("k");
132
+ expect(second).toBeNull();
133
+
134
+ // Release on the SAME client that acquired (the bug is release no-op'ing
135
+ // because it ran on a different connection).
136
+ await first!.release();
137
+
138
+ const third = await svc.tryAcquire("k");
139
+ expect(third).not.toBeNull();
140
+ await third!.release();
141
+ });
142
+
143
+ it("returns the client to the pool on both the failed-acquire and release paths", async () => {
144
+ const pool = makeFakePool();
145
+ const svc = createAdvisoryLockService(pool);
146
+
147
+ const h = await svc.tryAcquire("k");
148
+ const blocked = await svc.tryAcquire("k"); // fails → must release client
149
+ expect(blocked).toBeNull();
150
+ await h!.release();
151
+
152
+ // 2 connects (one held+released, one failed+released) => 2 releases.
153
+ expect(pool.checkedOut).toBe(2);
154
+ expect(pool.released).toBe(2);
155
+ });
156
+
157
+ it("release is idempotent", async () => {
158
+ const pool = makeFakePool();
159
+ const svc = createAdvisoryLockService(pool);
160
+ const h = await svc.tryAcquire("k");
161
+ await h!.release();
162
+ await h!.release(); // no throw, no double client.release
163
+ expect(pool.released).toBe(1);
164
+ });
165
+
166
+ it("different keys do not block each other", async () => {
167
+ const pool = makeFakePool();
168
+ const svc = createAdvisoryLockService(pool);
169
+ const a = await svc.tryAcquire("a");
170
+ const b = await svc.tryAcquire("b");
171
+ expect(a).not.toBeNull();
172
+ expect(b).not.toBeNull();
173
+ await a!.release();
174
+ await b!.release();
175
+ });
176
+ });
177
+
178
+ describe("createAdvisoryLockService.withXactLock", () => {
179
+ const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
180
+
181
+ it("runs fn, returns its value, and releases the client", async () => {
182
+ const pool = makeFakePool();
183
+ const svc = createAdvisoryLockService(pool);
184
+ const result = await svc.withXactLock({ key: "k", fn: async () => 42 });
185
+ expect(result).toBe(42);
186
+ expect(pool.checkedOut).toBe(1);
187
+ expect(pool.released).toBe(1);
188
+ });
189
+
190
+ it("serializes concurrent calls on the same key (second fn waits for first to commit)", async () => {
191
+ const pool = makeFakePool();
192
+ const svc = createAdvisoryLockService(pool);
193
+ const order: string[] = [];
194
+
195
+ let releaseFirst!: () => void;
196
+ const firstHeld = new Promise<void>((r) => (releaseFirst = r));
197
+
198
+ const p1 = svc.withXactLock({
199
+ key: "k",
200
+ fn: async () => {
201
+ order.push("1-start");
202
+ await firstHeld;
203
+ order.push("1-end");
204
+ },
205
+ });
206
+
207
+ // Let p1 acquire the lock before p2 attempts it.
208
+ await tick();
209
+ const p2 = svc.withXactLock({
210
+ key: "k",
211
+ fn: async () => {
212
+ order.push("2-start");
213
+ },
214
+ });
215
+
216
+ // While p1 holds the lock, p2's fn must NOT have started.
217
+ await tick();
218
+ expect(order).toEqual(["1-start"]);
219
+
220
+ releaseFirst();
221
+ await Promise.all([p1, p2]);
222
+ expect(order).toEqual(["1-start", "1-end", "2-start"]);
223
+ expect(pool.released).toBe(2);
224
+ });
225
+
226
+ it("rolls back and releases the client when fn throws, freeing the lock", async () => {
227
+ const pool = makeFakePool();
228
+ const svc = createAdvisoryLockService(pool);
229
+
230
+ await expect(
231
+ svc.withXactLock({
232
+ key: "k",
233
+ fn: async () => {
234
+ throw new Error("boom");
235
+ },
236
+ }),
237
+ ).rejects.toThrow("boom");
238
+
239
+ // Lock was released on rollback: a subsequent acquire succeeds promptly.
240
+ const after = await svc.withXactLock({ key: "k", fn: async () => "ok" });
241
+ expect(after).toBe("ok");
242
+ expect(pool.released).toBe(2);
243
+ });
244
+
245
+ it("different keys do not serialize", async () => {
246
+ const pool = makeFakePool();
247
+ const svc = createAdvisoryLockService(pool);
248
+ const started: string[] = [];
249
+
250
+ let release!: () => void;
251
+ const held = new Promise<void>((r) => (release = r));
252
+
253
+ const pA = svc.withXactLock({
254
+ key: "a",
255
+ fn: async () => {
256
+ started.push("a");
257
+ await held;
258
+ },
259
+ });
260
+ await tick();
261
+ // Key "b" must run even while "a" is still held.
262
+ await svc.withXactLock({
263
+ key: "b",
264
+ fn: async () => {
265
+ started.push("b");
266
+ },
267
+ });
268
+ expect(started).toContain("b");
269
+
270
+ release();
271
+ await pA;
272
+ });
273
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Postgres advisory-lock helpers with correct connection affinity.
3
+ *
4
+ * Postgres session-level advisory locks (`pg_try_advisory_lock` /
5
+ * `pg_advisory_unlock`) are tied to the DB *session* (connection) that
6
+ * acquired them. The platform runs every plugin query through a
7
+ * schema-scoped proxy that wraps each statement in its own short
8
+ * transaction on a connection borrowed from the shared pool and returned
9
+ * immediately. Acquiring a session lock through that proxy therefore runs
10
+ * the lock on one pooled connection and the unlock on a *different* one —
11
+ * so the unlock no-ops and the lock leaks until the original connection is
12
+ * recycled. This module fixes that two ways:
13
+ *
14
+ * - {@link AdvisoryLockService.tryAcquire} checks out ONE dedicated
15
+ * client from the pool, acquires the session lock on it, and returns a
16
+ * handle that owns that client. `release()` runs the unlock on the SAME
17
+ * client and then returns it to the pool. Use this for long-held locks
18
+ * (e.g. an installer election held across a minutes-long `bun install`)
19
+ * where a long-open transaction would be unacceptable.
20
+ *
21
+ * - {@link AdvisoryLockService.withXactLock} wraps acquire + work + release
22
+ * in a single transaction using `pg_advisory_xact_lock`, which auto-
23
+ * releases at COMMIT/ROLLBACK. Use this for SHORT critical sections (e.g. a
24
+ * find-then-create dedup) where holding a transaction for the duration is
25
+ * fine and the auto-release removes any chance of a leak.
26
+ *
27
+ * BOTH run on the service's pool, which MUST be a pool dedicated to advisory
28
+ * locks (separate from the pool the locked work runs on). A held lock keeps its
29
+ * connection checked out for the lock's lifetime; if lock and work shared one
30
+ * pool, concurrency >= pool size would deadlock (every slot a lock-holder
31
+ * waiting for a work connection). The backend wires this to a dedicated
32
+ * `lockPool`; that pool also sets `idle_in_transaction_session_timeout` /
33
+ * `lock_timeout` so a stalled critical section cannot strand a lock forever.
34
+ *
35
+ * Keys are arbitrary strings hashed to Postgres' 64-bit lock space via
36
+ * `hashtextextended(key, 0)`. Callers SHOULD namespace keys (e.g.
37
+ * `"script-packages.installer"`, `"incident.dedupe:<systemId>"`) since the
38
+ * advisory-lock space is global to the database server, not schema-scoped.
39
+ */
40
+
41
+ /**
42
+ * Minimal pool surface this module needs. Modelled on `pg.Pool` /
43
+ * `pg.PoolClient` without importing `pg` directly so the helper stays a
44
+ * pure type-level contract; the backend wires in the real `adminPool`.
45
+ */
46
+ export interface AdvisoryLockPoolClient {
47
+ query<T>(
48
+ queryText: string,
49
+ values?: unknown[],
50
+ ): Promise<{ rows: T[] }>;
51
+ /** Return the client to the pool. */
52
+ release(): void;
53
+ /**
54
+ * Subscribe to async client errors. A held client (session lock, or an open
55
+ * xact-lock transaction) is checked out for a while; if its backend dies
56
+ * (admin termination, failover, network drop) `pg` emits `'error'` on the
57
+ * client, and an `'error'` with no listener is re-thrown by the EventEmitter
58
+ * and would crash the pod. We attach a listener so that loss degrades
59
+ * gracefully instead. Modelled on `pg.Client.on`.
60
+ */
61
+ on(event: "error", listener: (err: Error) => void): void;
62
+ /**
63
+ * Detach a previously-attached error listener. MUST be called before
64
+ * returning the client to the pool: pooled clients are reused, so attaching a
65
+ * fresh listener on every checkout WITHOUT removing it on release leaks one
66
+ * listener per acquisition on each long-lived physical connection (an
67
+ * unbounded `MaxListenersExceeded` leak). Modelled on `pg.Client.off`.
68
+ */
69
+ off(event: "error", listener: (err: Error) => void): void;
70
+ }
71
+
72
+ export interface AdvisoryLockPool {
73
+ connect(): Promise<AdvisoryLockPoolClient>;
74
+ }
75
+
76
+ /** A held session-level advisory lock that owns its dedicated client. */
77
+ export interface AdvisoryLockHandle {
78
+ /**
79
+ * Release the lock (`pg_advisory_unlock` on the SAME client) and return
80
+ * the client to the pool. Idempotent: a second call is a no-op.
81
+ */
82
+ release(): Promise<void>;
83
+ }
84
+
85
+ export interface AdvisoryLockService {
86
+ /**
87
+ * Try to acquire a session-level advisory lock for `key` on a dedicated
88
+ * pooled client. Returns a handle on success, or `null` if the lock is
89
+ * already held (by this or another process). The handle owns the client
90
+ * until `release()` is called, so callers MUST always release in a
91
+ * `finally`.
92
+ */
93
+ tryAcquire(key: string): Promise<AdvisoryLockHandle | null>;
94
+ /**
95
+ * Run `fn` while holding a transaction-scoped advisory lock for `key`,
96
+ * acquired with `pg_advisory_xact_lock` (which BLOCKS until granted) on a
97
+ * dedicated client from THIS service's pool, and auto-released when that
98
+ * transaction commits/rolls back after `fn` settles.
99
+ *
100
+ * The lock transaction runs on this service's (dedicated lock) pool, while
101
+ * `fn` does its real work on whatever database it already holds (typically
102
+ * the shared admin pool). Because the held lock connection and the work
103
+ * connection come from DIFFERENT pools, the nested acquisition can never
104
+ * deadlock the work pool. Use this for SHORT critical sections that gate a
105
+ * read-then-write on another connection.
106
+ */
107
+ withXactLock<T>(args: { key: string; fn: () => Promise<T> }): Promise<T>;
108
+ }
109
+
110
+ /**
111
+ * Shared no-op `'error'` listener for held clients. A single module-level
112
+ * reference (rather than a fresh closure per acquisition) is what lets `off`
113
+ * detach exactly the listener `on` attached, and avoids allocating one per
114
+ * lock. It captures nothing, so sharing it is safe.
115
+ */
116
+ const swallowClientError = (): void => {};
117
+
118
+ /**
119
+ * Build an {@link AdvisoryLockService} backed by a pool. The backend
120
+ * provides the real admin pool; tests can provide a faithful fake that
121
+ * models per-connection session-lock semantics.
122
+ */
123
+ export function createAdvisoryLockService(
124
+ pool: AdvisoryLockPool,
125
+ ): AdvisoryLockService {
126
+ return {
127
+ async tryAcquire(key) {
128
+ const client = await pool.connect();
129
+ // A held session lock keeps this client checked out (not idle), so the
130
+ // pool's own error handler won't cover it. If this backend is terminated
131
+ // (admin kill / failover) while the lock is held, `pg` emits `'error'`
132
+ // here; without a listener the process crashes. Swallow it - the session
133
+ // lock is auto-released server-side when the backend dies, and a stale
134
+ // `release()` is already a no-op-safe `finally`, so the loss surfaces as
135
+ // the key simply becoming acquirable again. The listener is removed on
136
+ // release so it does not accumulate on the reused pooled connection.
137
+ client.on("error", swallowClientError);
138
+ const releaseClient = () => {
139
+ client.off("error", swallowClientError);
140
+ client.release();
141
+ };
142
+ let acquired = false;
143
+ try {
144
+ const result = await client.query<{ ok: boolean }>(
145
+ "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS ok",
146
+ [key],
147
+ );
148
+ acquired = Boolean(result.rows[0]?.ok);
149
+ } catch (error) {
150
+ releaseClient();
151
+ throw error;
152
+ }
153
+ if (!acquired) {
154
+ // Did not get the lock — return the client immediately. (A failed
155
+ // pg_try_advisory_lock acquires nothing, so there is nothing to
156
+ // unlock.)
157
+ releaseClient();
158
+ return null;
159
+ }
160
+
161
+ let released = false;
162
+ return {
163
+ async release() {
164
+ if (released) return;
165
+ released = true;
166
+ try {
167
+ await client.query(
168
+ "SELECT pg_advisory_unlock(hashtextextended($1, 0))",
169
+ [key],
170
+ );
171
+ } finally {
172
+ releaseClient();
173
+ }
174
+ },
175
+ };
176
+ },
177
+
178
+ async withXactLock({ key, fn }) {
179
+ const client = await pool.connect();
180
+ // Same rationale as tryAcquire: the lock transaction keeps this client
181
+ // checked out (idle in transaction) while `fn` runs, so attach an error
182
+ // listener to survive a backend termination instead of crashing the pod.
183
+ // Removed in the finally so it does not accumulate on the reused client.
184
+ client.on("error", swallowClientError);
185
+ try {
186
+ await client.query("BEGIN");
187
+ try {
188
+ // BLOCKS on this dedicated client until the lock is granted; auto-
189
+ // released by the COMMIT/ROLLBACK below. `fn`'s own work runs on a
190
+ // DIFFERENT pool, so no same-pool nested-acquisition deadlock.
191
+ await client.query(
192
+ "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
193
+ [key],
194
+ );
195
+ const result = await fn();
196
+ await client.query("COMMIT");
197
+ return result;
198
+ } catch (error) {
199
+ // Roll back so the xact lock releases and nothing partial lingers on
200
+ // this connection before it returns to the pool. Best-effort: if the
201
+ // backend already died, ROLLBACK throws but release() still frees the
202
+ // slot and the lock is auto-released server-side.
203
+ try {
204
+ await client.query("ROLLBACK");
205
+ } catch (rollbackError) {
206
+ void rollbackError;
207
+ }
208
+ throw error;
209
+ }
210
+ } finally {
211
+ client.off("error", swallowClientError);
212
+ client.release();
213
+ }
214
+ },
215
+ };
216
+ }
@@ -89,6 +89,15 @@ export interface CollectorStrategy<
89
89
  client: TClient;
90
90
  pluginId: string;
91
91
  runContext?: CollectorRunContext;
92
+ /**
93
+ * Resolved secret env for THIS run (the collector's declared
94
+ * `secretEnv` mapped to values), delivered just-in-time. Injected into
95
+ * the collector's script execution env and never persisted. Empty /
96
+ * absent when the collector declares no secrets. The collector is
97
+ * responsible for masking these values out of its returned output
98
+ * (source-side defense in depth).
99
+ */
100
+ secretEnv?: Record<string, string>;
92
101
  }): Promise<CollectorResult<TResult>>;
93
102
 
94
103
  /**
@@ -16,6 +16,7 @@ import type { PluginArtifactStore } from "./plugin-artifact-store";
16
16
  import type { EventBus } from "./event-bus-types";
17
17
  import type { WebSocketRouteRegistry } from "./ws-registry";
18
18
  import type { ReadinessRegistry } from "./readiness-registry";
19
+ import type { AdvisoryLockService } from "./advisory-lock";
19
20
 
20
21
  export * from "./types";
21
22
 
@@ -66,4 +67,10 @@ export const coreServices = {
66
67
  readinessRegistry: createServiceRef<ReadinessRegistry>(
67
68
  "core.readinessRegistry",
68
69
  ),
70
+ /**
71
+ * Postgres advisory-lock service backed by a dedicated pooled client, so
72
+ * session-level locks keep connection affinity across acquire/release.
73
+ * See {@link AdvisoryLockService}.
74
+ */
75
+ advisoryLock: createServiceRef<AdvisoryLockService>("core.advisoryLock"),
69
76
  };