@vercel/board 0.16.0 → 0.18.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.
Files changed (130) hide show
  1. package/README.md +27 -27
  2. package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
  6. package/dist/attempts-CLG840Oj.mjs +20 -0
  7. package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
  8. package/dist/backend-B-SJ7p6p.d.mts +680 -0
  9. package/dist/backend-a2/contract.d.mts +873 -2
  10. package/dist/backend-a2/contract.mjs +2 -2
  11. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  12. package/dist/backend-a2/scheduler-local.mjs +1 -1
  13. package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
  14. package/dist/backend-a2/scheduler-qstash.mjs +521 -1
  15. package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
  16. package/dist/backend-a2/scheduler-vercel.mjs +203 -1
  17. package/dist/backend-a2/store-memory.d.mts +2 -2
  18. package/dist/backend-a2/store-memory.mjs +3 -3
  19. package/dist/backend-a2/store-postgres.d.mts +1 -1
  20. package/dist/backend-a2/store-postgres.mjs +2850 -2
  21. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  22. package/dist/backend-a2/store-redis-http.mjs +490 -2
  23. package/dist/backend-a2/store-redis.d.mts +10 -2
  24. package/dist/backend-a2/store-redis.mjs +51 -2
  25. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  26. package/dist/backend-a2/store-sqlite.mjs +3 -3
  27. package/dist/backend-a2.d.mts +2706 -3
  28. package/dist/backend-a2.mjs +1 -1
  29. package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
  30. package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
  31. package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
  32. package/dist/backend-http.d.mts +1 -1
  33. package/dist/backend-http.mjs +1 -1
  34. package/dist/backend-http.node.d.mts +1 -1
  35. package/dist/backend-http.node.mjs +1 -1
  36. package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
  37. package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
  38. package/dist/cli.mjs +30 -10
  39. package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
  40. package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
  41. package/dist/conformance.d.mts +1 -1
  42. package/dist/conformance.mjs +1 -1
  43. package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
  44. package/dist/events-CewD4kw7.mjs +75 -0
  45. package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
  46. package/dist/exports-ZsgjzEnM.d.mts +4 -0
  47. package/dist/external-BV_cUncs.mjs +151 -0
  48. package/dist/folds-CRYY14kc.d.mts +534 -0
  49. package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
  50. package/dist/ids-C6AYo1bu.mjs +31 -0
  51. package/dist/index.d.mts +4 -4
  52. package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
  53. package/dist/index.mjs +5 -5
  54. package/dist/index.node.d.mts +4 -4
  55. package/dist/index.node.mjs +3 -3
  56. package/dist/inspect/app.js +31 -31
  57. package/dist/inspect/engine.js +5 -5
  58. package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
  59. package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
  60. package/dist/mentions-CB6ShY4R.mjs +72 -0
  61. package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
  62. package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
  63. package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
  64. package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
  65. package/dist/provider-B3DpYSDq.mjs +220 -0
  66. package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
  67. package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
  68. package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
  69. package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
  70. package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
  71. package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
  72. package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
  73. package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
  74. package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
  75. package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
  76. package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
  77. package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
  78. package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
  79. package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
  80. package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
  81. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
  82. package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
  83. package/dist/store-memory-Bas9Uq5C.mjs +872 -0
  84. package/dist/store-memory-ZLiZbH3R.mjs +2 -0
  85. package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
  86. package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
  87. package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
  88. package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
  89. package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
  90. package/dist/sync-github.d.mts +24 -17
  91. package/dist/sync-github.mjs +89 -22
  92. package/dist/sync-kapso.d.mts +15 -13
  93. package/dist/sync-kapso.mjs +17 -14
  94. package/dist/sync-linear.d.mts +219 -0
  95. package/dist/sync-linear.mjs +311 -0
  96. package/dist/sync-slack.d.mts +12 -17
  97. package/dist/sync-slack.mjs +54 -42
  98. package/dist/sync.chat.d.mts +1 -1
  99. package/dist/sync.chat.mjs +1 -1
  100. package/dist/sync.d.mts +3 -3
  101. package/dist/sync.mjs +5 -4
  102. package/dist/targets-C-wdR5wP.mjs +33 -0
  103. package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
  104. package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
  105. package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
  106. package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
  107. package/docs/01-board/01-mounting.md +1 -1
  108. package/docs/02-sync/01-overview.md +72 -48
  109. package/docs/02-sync/02-functions.md +398 -0
  110. package/docs/02-sync/03-slack.md +30 -25
  111. package/docs/02-sync/04-github.md +62 -26
  112. package/docs/02-sync/05-whatsapp.md +23 -19
  113. package/docs/02-sync/06-providers.md +60 -27
  114. package/docs/02-sync/07-linear.md +129 -0
  115. package/docs/03-guides/04-triage.md +1 -1
  116. package/docs/04-reference/01-cli.md +26 -6
  117. package/docs/04-reference/02-board.md +18 -5
  118. package/docs/04-reference/03-http.md +5 -2
  119. package/docs/04-reference/05-conformance.md +1 -1
  120. package/docs/index.md +5 -10
  121. package/package.json +31 -6
  122. package/dist/a2-D24cvwpN.d.mts +0 -3359
  123. package/dist/access-CJU2-vaa.mjs +0 -226
  124. package/dist/attempts-Bxp9KgPf.mjs +0 -919
  125. package/dist/exports-DLiE7fLY.d.mts +0 -4
  126. package/dist/provider-CuewcDNZ.mjs +0 -205
  127. package/dist/redis-tables-B35vv4CZ.mjs +0 -842
  128. package/dist/reducers-B_vUGQRp.d.mts +0 -1172
  129. package/dist/watchers-hash-CLjsa6od.mjs +0 -225
  130. package/docs/02-sync/02-callbacks.md +0 -265
@@ -1,5 +1,2853 @@
1
- import { r as sqlTables } from "../sql-DbiEnqM0.mjs";
2
- import { postgres as postgres$1 } from "experimental-a2/store-postgres";
1
+ import { n as CLEAR_EVENT_TYPE, o as sessionClearedError, s as supersededAttemptError, t as A2Error } from "../session-clear-wHd5GCpZ-BnKSxReK.mjs";
2
+ import { m as nullProtoRecord, n as NOTIFY_TIMINGS } from "../internal-CvsrprnU-CRY89I-b.mjs";
3
+ import { n as defaultSleep, r as pollingStream } from "../store-polling-BZt2Rf3D-BiEM6_HB.mjs";
4
+ import { r as platformWaitUntil } from "../platform-B4TnJtWu-CwcTyKg2.mjs";
5
+ import { t as retryableLazy } from "../retryable-lazy-DZWmHpii-Bye-W8q7.mjs";
6
+ import { r as sqlTables } from "../sql-U9WTxrwJ.mjs";
7
+ import { a as idleLogCursor, i as idempotentReplay, n as decodeCause, r as decodeReturnedEventIds, t as cursorKeyOf } from "../store-codec-DwrWlgQx-CYxGW0aD.mjs";
8
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "../store-N8PXxDAS-BWBgzBHp.mjs";
9
+ import { t as pollPresence } from "../store-presence-polling-JuAy2Ck2-CYpuK75X.mjs";
10
+ import { createHash } from "node:crypto";
11
+ //#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/store-postgres.js
12
+ const postgresNotificationChannel = ({ kind, sessionId }) => `a2_${kind}_${createHash("md5").update(sessionId).digest("hex")}`;
13
+ function postgresNotifications(createConnection) {
14
+ const channels = /* @__PURE__ */ new Map();
15
+ const ending = /* @__PURE__ */ new Set();
16
+ const end = (connection) => {
17
+ if (!connection) return;
18
+ const pending = connection.end().catch(() => {}).finally(() => {
19
+ ending.delete(pending);
20
+ });
21
+ ending.add(pending);
22
+ };
23
+ let current;
24
+ let task;
25
+ let retry;
26
+ let delay = 100;
27
+ let connectedAt = 0;
28
+ let closed = false;
29
+ const queues = /* @__PURE__ */ new WeakMap();
30
+ const query = ({ connection, text }) => {
31
+ const result = (queues.get(connection) ?? Promise.resolve()).then(() => {
32
+ if (current !== connection) throw new Error("Postgres listener disconnected");
33
+ return connection.query(text);
34
+ });
35
+ queues.set(connection, result.catch(() => {}));
36
+ return result;
37
+ };
38
+ const fail = ({ connection, error }) => {
39
+ if (connection !== current) return;
40
+ current = void 0;
41
+ end(connection);
42
+ for (const entry of channels.values()) {
43
+ entry.listening = false;
44
+ for (const watcher of entry.watchers) watcher.reject(error);
45
+ }
46
+ if (closed || channels.size === 0 || retry) return;
47
+ if (Date.now() - connectedAt >= NOTIFY_TIMINGS.safetyReadMs) delay = 100;
48
+ retry = defaultSleep(delay);
49
+ delay = Math.min(delay * 2, NOTIFY_TIMINGS.safetyReadMs);
50
+ retry.promise.then(() => {
51
+ retry = void 0;
52
+ reconcile();
53
+ });
54
+ };
55
+ const reconcile = () => {
56
+ if (closed || task || retry || channels.size === 0) return;
57
+ let attempt;
58
+ task = (async () => {
59
+ if (!current) {
60
+ connectedAt = Date.now();
61
+ const connection = await createConnection();
62
+ if (closed || channels.size === 0) {
63
+ await connection.end();
64
+ return;
65
+ }
66
+ current = connection;
67
+ attempt = connection;
68
+ connectedAt = Date.now();
69
+ connection.onNotification(({ channel, payload }) => {
70
+ if (current !== connection) return;
71
+ for (const watcher of channels.get(channel)?.watchers ?? []) watcher.notify(payload ?? "");
72
+ });
73
+ connection.onError((error) => fail({
74
+ connection,
75
+ error
76
+ }));
77
+ connection.onEnd(() => fail({
78
+ connection,
79
+ error: /* @__PURE__ */ new Error("Postgres listener disconnected")
80
+ }));
81
+ await connection.connect();
82
+ if (current !== connection) return;
83
+ }
84
+ const connection = current;
85
+ attempt = connection;
86
+ for (const [channel, entry] of channels) {
87
+ if (entry.listening) continue;
88
+ entry.listening = true;
89
+ entry.ready = query({
90
+ connection,
91
+ text: `LISTEN "${channel.replaceAll("\"", "\"\"")}"`
92
+ });
93
+ await entry.ready;
94
+ if (current !== connection) return;
95
+ for (const watcher of entry.watchers) {
96
+ watcher.resolve();
97
+ watcher.notify("");
98
+ }
99
+ }
100
+ })().catch((error) => {
101
+ if (attempt === current) fail({
102
+ connection: attempt,
103
+ error
104
+ });
105
+ }).finally(() => {
106
+ task = void 0;
107
+ if ([...channels.values()].some((entry) => !entry.listening)) reconcile();
108
+ });
109
+ };
110
+ return {
111
+ watch({ channel, notify }) {
112
+ let resolve;
113
+ let reject;
114
+ const ready = new Promise((yes, no) => {
115
+ resolve = yes;
116
+ reject = no;
117
+ });
118
+ const watcher = {
119
+ notify,
120
+ resolve,
121
+ reject
122
+ };
123
+ let entry = channels.get(channel);
124
+ if (!entry) {
125
+ entry = {
126
+ listening: false,
127
+ watchers: /* @__PURE__ */ new Set()
128
+ };
129
+ channels.set(channel, entry);
130
+ }
131
+ entry.watchers.add(watcher);
132
+ if (entry.listening) entry.ready?.then(resolve, reject);
133
+ reconcile();
134
+ let stopped = false;
135
+ return {
136
+ ready,
137
+ stop() {
138
+ if (stopped) return;
139
+ stopped = true;
140
+ resolve();
141
+ entry.watchers.delete(watcher);
142
+ if (entry.watchers.size > 0) return;
143
+ channels.delete(channel);
144
+ if (channels.size === 0) {
145
+ retry?.cancel();
146
+ const connection = current;
147
+ current = void 0;
148
+ end(connection);
149
+ } else if (entry.listening) {
150
+ const connection = current;
151
+ if (connection) query({
152
+ connection,
153
+ text: `UNLISTEN "${channel.replaceAll("\"", "\"\"")}"`
154
+ }).catch((error) => fail({
155
+ connection,
156
+ error
157
+ }));
158
+ }
159
+ }
160
+ };
161
+ },
162
+ async close() {
163
+ closed = true;
164
+ retry?.cancel();
165
+ for (const entry of channels.values()) for (const watcher of entry.watchers) watcher.resolve();
166
+ channels.clear();
167
+ const connection = current;
168
+ current = void 0;
169
+ end(connection);
170
+ await Promise.all(ending);
171
+ }
172
+ };
173
+ }
174
+ /**
175
+ * The Postgres schema contract. A2 never runs DDL in a Postgres database:
176
+ * `sql/postgres/schema.sql`, or the tables in `drizzle-schema.ts` next to it,
177
+ * create
178
+ * the tables, and `notifications.sql` the triggers `listenConnectionString`
179
+ * needs. A store checks nothing up front. A query that finds a table or column
180
+ * missing fails anyway, so the store only turns that failure into one that
181
+ * says what to apply. Missing triggers fail no query, so a listening store
182
+ * looks for them when its listener starts.
183
+ */
184
+ const PACKAGE = "node_modules/experimental-a2";
185
+ const SEE_DOCS = `See ${PACKAGE}/docs/stores/04-postgres.mdx.`;
186
+ /** Postgres error codes a store that hasn't been set up raises. */
187
+ const UNDEFINED_TABLE = "42P01";
188
+ const UNDEFINED_COLUMN = "42703";
189
+ const UNDEFINED_FUNCTION = "42883";
190
+ /** `a2_batch` refuses to run outside read committed. */
191
+ const BATCH_ISOLATION = "A2002";
192
+ const INSUFFICIENT_PRIVILEGE = "42501";
193
+ /** NUL, which Postgres can't store: in jsonb (`\u0000`), or in text. */
194
+ const UNTRANSLATABLE_CHARACTER = "22P05";
195
+ const CHARACTER_NOT_IN_REPERTOIRE = "22021";
196
+ /** The Postgres error in `error`'s cause chain, for clients that wrap theirs. */
197
+ const postgresError = (error) => {
198
+ for (let current = error, depth = 0; depth < 5; depth += 1) {
199
+ if (typeof current !== "object" || current === null) return void 0;
200
+ const { code, message, cause } = current;
201
+ if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return {
202
+ code,
203
+ message: typeof message === "string" ? message : code
204
+ };
205
+ current = cause;
206
+ }
207
+ };
208
+ /** Explains a query that failed because A2's tables aren't set up, or on a NUL, or returns `error`. */
209
+ const explainPostgresError = (error) => {
210
+ const found = postgresError(error);
211
+ if (found?.code === UNDEFINED_TABLE || found?.code === UNDEFINED_COLUMN || found?.code === UNDEFINED_FUNCTION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2's tables or its function are missing, or from an earlier release, and A2 doesn't create or change them at runtime. Apply ${PACKAGE}/sql/postgres/schema.sql to this database, or migrate it to the tables in drizzle-schema.ts next to it, with schema.sql's a2_batch function. ${SEE_DOCS}`, { cause: error });
212
+ if (found?.code === INSUFFICIENT_PRIVILEGE) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: the role this store connects as needs select, insert, update, and delete on A2's tables, and execute on a2_batch. Create them as that role, or grant it those privileges. ${SEE_DOCS}`, { cause: error });
213
+ if (found?.code === UNTRANSLATABLE_CHARACTER || found?.code === CHARACTER_NOT_IN_REPERTOIRE) return new A2Error("INVALID_PAYLOAD", `${found.message}: Postgres can't store a NUL character (\\u0000) in text or jsonb, so nothing was written. Remove it before appending.`, { cause: error });
214
+ if (found?.code === BATCH_ISOLATION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2 reads what it locks in read committed, Postgres's default, and its connections here default to a stricter level. Set default_transaction_isolation to 'read committed' for the role or database this store connects to. ${SEE_DOCS}`, { cause: error });
215
+ return error;
216
+ };
217
+ const explained = (promise) => promise.catch((error) => {
218
+ throw explainPostgresError(error);
219
+ });
220
+ /** `client`, with every query's setup failures explained. */
221
+ const explainingPostgresClient = (client) => ({
222
+ query: (text, values) => explained(client.query(text, values)),
223
+ ...client.connect && { connect: async () => {
224
+ const connection = await client.connect();
225
+ return {
226
+ query: (text, values) => explained(connection.query(text, values)),
227
+ release: () => connection.release()
228
+ };
229
+ } },
230
+ ...client.end && { end: () => client.end() }
231
+ });
232
+ /** Fails when the triggers `listenConnectionString` listens to are missing. */
233
+ const checkPostgresTriggers = async (client) => {
234
+ const { rows } = await client.query(`select count(*) as triggers from pg_trigger
235
+ where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
236
+ or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence'))`);
237
+ if (Number(rows[0]?.["triggers"]) < 2) throw new A2Error("STORE_NOT_CONFIGURED", `listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, apply ${PACKAGE}/sql/postgres/notifications.sql to this database. ${SEE_DOCS}`);
238
+ };
239
+ const failure = (error) => error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "Postgres subscription failed", { cause: error });
240
+ function postgresNotificationScope(load) {
241
+ const state = {
242
+ shared: retryableLazy(load),
243
+ stops: /* @__PURE__ */ new Set(),
244
+ closed: false
245
+ };
246
+ const scope = {
247
+ watch(options) {
248
+ let stopped = false;
249
+ let cancel;
250
+ const cancelled = new Promise((resolve) => {
251
+ cancel = resolve;
252
+ });
253
+ let subscription;
254
+ const stop = () => {
255
+ stopped = true;
256
+ cancel();
257
+ subscription?.stop();
258
+ state.stops.delete(stop);
259
+ };
260
+ state.stops.add(stop);
261
+ return {
262
+ ready: Promise.race([cancelled, state.shared.get().then(async ({ value }) => {
263
+ if (stopped || state.closed) return;
264
+ subscription = value.watch(options);
265
+ await subscription.ready;
266
+ })]).catch((error) => {
267
+ if (!stopped && !state.closed) throw failure(error);
268
+ }),
269
+ stop
270
+ };
271
+ },
272
+ stream({ channel, read, startAfter }) {
273
+ return { [Symbol.asyncIterator]() {
274
+ let stopped = false;
275
+ let after = startAfter;
276
+ let buffer = [];
277
+ let position = 0;
278
+ let version = 0;
279
+ let waiting;
280
+ let lease;
281
+ const stop = () => {
282
+ stopped = true;
283
+ buffer = [];
284
+ position = 0;
285
+ waiting?.cancel();
286
+ lease?.stop();
287
+ state.stops.delete(stop);
288
+ };
289
+ state.stops.add(stop);
290
+ return {
291
+ async next() {
292
+ try {
293
+ if (stopped || state.closed) return {
294
+ done: true,
295
+ value: void 0
296
+ };
297
+ lease ??= scope.watch({
298
+ channel,
299
+ notify: () => {
300
+ version += 1;
301
+ waiting?.cancel();
302
+ }
303
+ });
304
+ await lease.ready;
305
+ for (;;) {
306
+ if (stopped || state.closed) return {
307
+ done: true,
308
+ value: void 0
309
+ };
310
+ const row = buffer[position];
311
+ if (row) {
312
+ after = row.index;
313
+ buffer[position] = void 0;
314
+ position += 1;
315
+ if (position === buffer.length) {
316
+ buffer = [];
317
+ position = 0;
318
+ }
319
+ return {
320
+ done: false,
321
+ value: row
322
+ };
323
+ }
324
+ const before = version;
325
+ const rows = await read(after);
326
+ if (stopped || state.closed) return {
327
+ done: true,
328
+ value: void 0
329
+ };
330
+ buffer = rows;
331
+ position = 0;
332
+ if (buffer.length || before !== version) continue;
333
+ waiting = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
334
+ await waiting.promise;
335
+ }
336
+ } catch (error) {
337
+ if (stopped || state.closed) return {
338
+ done: true,
339
+ value: void 0
340
+ };
341
+ stop();
342
+ throw failure(error);
343
+ }
344
+ },
345
+ async return() {
346
+ stop();
347
+ return {
348
+ done: true,
349
+ value: void 0
350
+ };
351
+ }
352
+ };
353
+ } };
354
+ },
355
+ async close() {
356
+ if (state.closed) return;
357
+ state.closed = true;
358
+ for (const stop of state.stops) stop();
359
+ await (await state.shared.peek()?.catch(() => void 0))?.release();
360
+ }
361
+ };
362
+ return scope;
363
+ }
364
+ function postgresPoolClient(pool) {
365
+ let timer;
366
+ let resolve;
367
+ const finish = () => {
368
+ clearTimeout(timer);
369
+ timer = void 0;
370
+ resolve?.();
371
+ resolve = void 0;
372
+ };
373
+ const keepAlive = () => {
374
+ const idleTimeout = pool.options.idleTimeoutMillis;
375
+ if (pool.ending || pool.idleCount === 0 || typeof idleTimeout !== "number" || !Number.isFinite(idleTimeout) || idleTimeout <= 0) return;
376
+ finish();
377
+ const pending = new Promise((done) => {
378
+ resolve = done;
379
+ timer = setTimeout(finish, idleTimeout + 100);
380
+ timer.unref();
381
+ });
382
+ platformWaitUntil(pending);
383
+ };
384
+ pool.on("remove", () => {
385
+ if (pool.totalCount === 0) finish();
386
+ });
387
+ return {
388
+ async query(text, values) {
389
+ try {
390
+ return await pool.query(text, values);
391
+ } finally {
392
+ keepAlive();
393
+ }
394
+ },
395
+ async connect() {
396
+ const client = await pool.connect();
397
+ return {
398
+ query: (text, values) => client.query(text, values),
399
+ release() {
400
+ try {
401
+ client.release();
402
+ } finally {
403
+ keepAlive();
404
+ }
405
+ }
406
+ };
407
+ },
408
+ async end() {
409
+ try {
410
+ await pool.end();
411
+ } finally {
412
+ finish();
413
+ }
414
+ }
415
+ };
416
+ }
417
+ const registryKey = Symbol.for("experimental-a2.postgres-resources.v3");
418
+ const registry = () => {
419
+ let value = Reflect.get(globalThis, registryKey);
420
+ if (!value) {
421
+ value = {
422
+ pools: /* @__PURE__ */ new Map(),
423
+ injected: /* @__PURE__ */ new WeakMap(),
424
+ listeners: /* @__PURE__ */ new Map(),
425
+ identities: /* @__PURE__ */ new WeakMap(),
426
+ nextIdentity: 0
427
+ };
428
+ Reflect.set(globalThis, registryKey, value);
429
+ }
430
+ return value;
431
+ };
432
+ const acquire = ({ cache, key, create }) => {
433
+ let entry = cache.get(key);
434
+ if (!entry) {
435
+ entry = {
436
+ value: create(),
437
+ refs: 0
438
+ };
439
+ cache.set(key, entry);
440
+ }
441
+ entry.refs += 1;
442
+ const held = entry;
443
+ let released = false;
444
+ return {
445
+ value: entry.value,
446
+ async release() {
447
+ if (released) return;
448
+ released = true;
449
+ held.refs -= 1;
450
+ if (held.refs !== 0) return;
451
+ if (cache.get(key) === held) cache.delete(key);
452
+ await held.value.close();
453
+ }
454
+ };
455
+ };
456
+ function postgresConnectionKey({ connectionString, defaults }) {
457
+ const shared = registry();
458
+ const visited = /* @__PURE__ */ new Set();
459
+ const identity = (value) => {
460
+ let id = shared.identities.get(value);
461
+ if (id === void 0) {
462
+ id = ++shared.nextIdentity;
463
+ shared.identities.set(value, id);
464
+ }
465
+ return id;
466
+ };
467
+ const encode = (value) => {
468
+ if (typeof value === "function") return ["reference", identity(value)];
469
+ if (value === null || typeof value !== "object") return [typeof value, String(value)];
470
+ if (Buffer.isBuffer(value)) return ["buffer", value.toString("base64")];
471
+ const prototype = Object.getPrototypeOf(value);
472
+ if (visited.has(value) || prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) return ["reference", identity(value)];
473
+ visited.add(value);
474
+ const result = [Array.isArray(value) ? "array" : "object", Object.getOwnPropertyNames(value).toSorted().map((key) => [key, encode(Reflect.get(value, key))])];
475
+ visited.delete(value);
476
+ return result;
477
+ };
478
+ const environment = Object.keys(process.env).filter((key) => key.startsWith("PG")).toSorted().map((key) => [key, process.env[key]]);
479
+ return createHash("sha256").update(JSON.stringify([
480
+ connectionString,
481
+ environment,
482
+ encode(defaults)
483
+ ])).digest("hex");
484
+ }
485
+ const queries = (raw) => {
486
+ let pending = Promise.resolve();
487
+ const initialized = /* @__PURE__ */ new Map();
488
+ const exclusive = (fn) => {
489
+ const result = pending.then(fn);
490
+ pending = result.catch(() => {});
491
+ return result;
492
+ };
493
+ return {
494
+ raw,
495
+ client: raw.connect ? raw : { query: (text, values) => exclusive(() => raw.query(text, values)) },
496
+ exclusive,
497
+ initialize({ key, run }) {
498
+ const existing = initialized.get(key);
499
+ if (existing) return existing;
500
+ const attempt = run();
501
+ initialized.set(key, attempt);
502
+ attempt.catch(() => {
503
+ if (initialized.get(key) === attempt) initialized.delete(key);
504
+ });
505
+ return attempt;
506
+ },
507
+ async close() {
508
+ await pending;
509
+ await raw.end?.();
510
+ }
511
+ };
512
+ };
513
+ const sharePostgresPool = ({ key, create }) => acquire({
514
+ cache: registry().pools,
515
+ key,
516
+ create: () => queries(create())
517
+ });
518
+ const sharePostgresClient = (client) => acquire({
519
+ cache: registry().injected,
520
+ key: client,
521
+ create: () => queries(client)
522
+ });
523
+ const sharePostgresListener = ({ key, create }) => acquire({
524
+ cache: registry().listeners,
525
+ key,
526
+ create
527
+ });
528
+ /**
529
+ * experimental-a2/store-postgres — postgres store backend (the production path).
530
+ *
531
+ * Implements the A2Store interface over any client that speaks the tiny
532
+ * `PostgresClient` shape below — a `pg` Pool matches it structurally
533
+ * (and is what `connectionString` creates, lazily; `pg` is an optional
534
+ * peer dependency). The conformance suite in test/conformance is the
535
+ * executable contract, run against PGlite (real Postgres, in-process)
536
+ * and against a real server when `TEST_DATABASE_URL` is set.
537
+ *
538
+ * Semantics mirror the sqlite backend: `index` is stored as `idx`,
539
+ * timestamps are epoch milliseconds written from the injected clock —
540
+ * never SQL `now()`, and dispatch state lives on consumer cursors, not on
541
+ * events. Streams poll by default; an optional direct connection enables
542
+ * LISTEN/NOTIFY for events and presence. Appends serialize per session via
543
+ * `pg_advisory_xact_lock`, so concurrent writers can't race the index
544
+ * assignment, and each runs in one round trip (`a2_batch`, below), so the
545
+ * lock is held only while the server runs it.
546
+ *
547
+ * Lock discipline. Completions lock their cursor first, then the session lock
548
+ * when they append, or when their cursor goes idle (`completeAttempt`);
549
+ * everything else that takes the session lock takes it first. Every
550
+ * operation that locks a cursor row locks it only in the exact state it acts
551
+ * on (a head, an attempt). Under the session lock, an append never writes a
552
+ * cursor row that exists: a lane or log cursor's runs are rows keyed by the
553
+ * head each run started at (its epoch), so starting an idle cursor inserts a
554
+ * new run and retires the idle row with `skip locked`. An append therefore
555
+ * never waits on a cursor row, in particular not on one a completion waiting
556
+ * for the session lock holds, or one a clear left behind.
557
+ */
558
+ const asNumber = (value) => Number(value);
559
+ const asNullableNumber = (value) => value === null || value === void 0 ? null : asNumber(value);
560
+ /**
561
+ * The session's latest clear marker, or 0, as a primary-key subquery. Read
562
+ * it in a statement that runs after the operation's lock: a statement's
563
+ * snapshot starts with the statement, so one that waited for the lock could
564
+ * miss a clear that committed meanwhile.
565
+ */
566
+ const clearIndexOf = (session) => `coalesce((select clear_index from a2_clears where session_id = ${session}), 0)`;
567
+ /**
568
+ * Whether a served consumer has a cursor with a head in the session. A null
569
+ * `served` array serves every consumer.
570
+ */
571
+ const pendingIn = (session, served) => `exists(
572
+ select 1 from a2_cursors pending
573
+ where pending.session_id = ${session}
574
+ and pending.head > ${clearIndexOf(session)}
575
+ and pending.failed_at is null
576
+ and (${served}::text[] is null or pending.consumer = any(${served}::text[]))
577
+ )`;
578
+ /** A fresh head's dispatch columns, for `set` clauses. */
579
+ const FRESH_DISPATCH = `
580
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
581
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
582
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
583
+ failed_at = null`;
584
+ /** The head event's columns a claim, dead letter, or listing joins in, prefixed `e_`. */
585
+ const HEAD_COLUMNS = `
586
+ event.idx as e_idx, event.event_type as e_event_type,
587
+ event.payload as e_payload, event.event_id as e_event_id,
588
+ event.created_at as e_created_at, event.cause as e_cause`;
589
+ const RECLAIM_BATCH = 256;
590
+ /**
591
+ * A cursor run reclaim deletes once the session is cleared through `$2`:
592
+ * one from before the clear, or one a later run of its cursor replaced. A
593
+ * log cursor's last run stays: it records that the consumer counts from the
594
+ * marker.
595
+ */
596
+ const SUPERSEDED_CURSOR = `
597
+ cur.session_id = $1 and (
598
+ (cur.epoch < $2 and cur.kind <> 'log')
599
+ or ((cur.head is null or cur.head <= $2) and exists (
600
+ select 1 from a2_cursors later
601
+ where later.session_id = cur.session_id
602
+ and later.cursor_key = cur.cursor_key
603
+ and later.consumer = cur.consumer
604
+ and later.epoch > cur.epoch
605
+ ))
606
+ )`;
607
+ /**
608
+ * A value of `a2_batch`'s one parameter, `$1`: a JSON object the store builds
609
+ * for each call. JSON null reads as SQL null.
610
+ */
611
+ const param = {
612
+ text: (key) => `($1->>'${key}')`,
613
+ bigint: (key) => `($1->>'${key}')::bigint`,
614
+ int: (key) => `($1->>'${key}')::int`,
615
+ boolean: (key) => `($1->>'${key}')::boolean`,
616
+ jsonb: (key) => `nullif($1->'${key}', 'null'::jsonb)`,
617
+ /** A JSON array of strings as text[], or null when the value isn't an array. */
618
+ texts: (key) => `(case when jsonb_typeof($1->'${key}') = 'array'
619
+ then array(select jsonb_array_elements_text($1->'${key}')) end)`
620
+ };
621
+ /** Every batch names its session. */
622
+ const SESSION = param.text("session");
623
+ /**
624
+ * The next event after `after` a log cursor takes, as a scalar subquery:
625
+ * `types` is its JSON array, or null for every type but the clear marker
626
+ * (`clearType`). Each listed type is one probe of `a2_events_types`, so a
627
+ * consumer of rare types never scans the events it skips.
628
+ */
629
+ const nextLogHead = ({ types, after, clearType = param.text("clearType") }) => `
630
+ case when ${types} is null then (
631
+ select event.idx from a2_events event
632
+ where event.session_id = ${SESSION} and event.idx > ${after}
633
+ and event.event_type <> ${clearType}
634
+ order by event.idx limit 1
635
+ ) else (
636
+ select min(next.idx)
637
+ from jsonb_array_elements_text(${types}) as wanted(event_type)
638
+ cross join lateral (
639
+ select event.idx from a2_events event
640
+ where event.session_id = ${SESSION} and event.event_type = wanted.event_type
641
+ and event.idx > ${after}
642
+ order by event.idx limit 1
643
+ ) next
644
+ where wanted.event_type <> ${clearType}
645
+ ) end`;
646
+ /** The latest run of `target`'s cursor, as a lateral subquery. */
647
+ const LATEST_RUN = (target) => `
648
+ select cur.epoch, cur.position, cur.head from a2_cursors cur
649
+ where cur.session_id = ${SESSION} and cur.cursor_key = ${target}.cursor_key
650
+ and cur.consumer = ${target}.consumer
651
+ order by cur.epoch desc limit 1`;
652
+ /**
653
+ * Retires the runs `target` replaces, those idle or superseded by the clear
654
+ * marker `clearIndex`, skipping any another operation holds: a later reclaim
655
+ * deletes those.
656
+ */
657
+ const RETIRE_RUNS = (target, clearIndex) => `
658
+ idle as materialized (
659
+ select cur.ctid from a2_cursors cur
660
+ join ${target} on cur.cursor_key = ${target}.cursor_key
661
+ and cur.consumer = ${target}.consumer
662
+ where cur.session_id = ${SESSION}
663
+ and (cur.head is null or cur.head <= ${clearIndex})
664
+ for update of cur skip locked
665
+ ), retired as (
666
+ delete from a2_cursors old using idle where old.ctid = idle.ctid
667
+ )`;
668
+ const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
669
+ const toEvent = (row) => ({
670
+ id: row["event_id"],
671
+ type: row["event_type"],
672
+ payload: row["payload"],
673
+ index: asNumber(row["idx"]),
674
+ sessionId: row["session_id"],
675
+ createdAt: new Date(asNumber(row["created_at"]))
676
+ });
677
+ const toStored = (row) => ({
678
+ ...toEvent(row),
679
+ cause: decodeCause(row["cause"])
680
+ });
681
+ const toDispatch = (row) => ({
682
+ consumer: row["consumer"],
683
+ lane: row["lane"] ?? null,
684
+ firstClaimedAt: toDate(row["first_claimed_at"]),
685
+ lastClaimedAt: toDate(row["last_claimed_at"]),
686
+ attemptCount: asNumber(row["attempt_count"]),
687
+ claimHolder: row["claim_holder"] ?? null,
688
+ claimExpiresAt: toDate(row["claim_expires_at"]),
689
+ failureCount: asNumber(row["failure_count"]),
690
+ lastFailedAt: toDate(row["last_failed_at"]),
691
+ lastFailedAttempt: asNullableNumber(row["last_failed_attempt"]),
692
+ lastError: row["last_error"] ?? null,
693
+ failedAt: toDate(row["failed_at"])
694
+ });
695
+ const freshDispatch = (consumer, lane) => ({
696
+ consumer,
697
+ lane,
698
+ firstClaimedAt: null,
699
+ lastClaimedAt: null,
700
+ attemptCount: 0,
701
+ claimHolder: null,
702
+ claimExpiresAt: null,
703
+ failureCount: 0,
704
+ lastFailedAt: null,
705
+ lastFailedAttempt: null,
706
+ lastError: null,
707
+ failedAt: null
708
+ });
709
+ /** A cursor row joined with its head event's `e_` columns. */
710
+ const toHead = (row) => ({
711
+ ...toStored({
712
+ session_id: row["session_id"],
713
+ idx: row["e_idx"],
714
+ event_type: row["e_event_type"],
715
+ payload: row["e_payload"],
716
+ event_id: row["e_event_id"],
717
+ created_at: row["e_created_at"],
718
+ cause: row["e_cause"]
719
+ }),
720
+ ...toDispatch(row)
721
+ });
722
+ /** A cursor run as `cursors()` reports it, given the session's clear marker. */
723
+ const toCursor = (row, clearIndex) => {
724
+ const position = asNumber(row["position"]);
725
+ if (row["kind"] !== "log" || asNumber(row["epoch"]) > clearIndex) return Object.assign(toDispatch(row), {
726
+ position,
727
+ head: asNullableNumber(row["head"])
728
+ });
729
+ return Object.assign(freshDispatch(row["consumer"], row["lane"]), {
730
+ position: Math.max(position, clearIndex),
731
+ head: null
732
+ });
733
+ };
734
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
735
+ const byHead = (a, b) => a.index - b.index || compareText(a.consumer, b.consumer);
736
+ /** Consumer, then lane, with concurrent cursors last by head. */
737
+ const byCursor = (a, b) => compareText(a.consumer, b.consumer) || (a.lane === null ? b.lane === null ? (a.head ?? 0) - (b.head ?? 0) : 1 : b.lane === null ? -1 : compareText(a.lane, b.lane));
738
+ const servedNames = (consumers) => consumers === void 0 ? null : consumers.map((consumer) => consumer.name);
739
+ const wrap = async (fn) => {
740
+ try {
741
+ return await fn();
742
+ } catch (err) {
743
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
744
+ throw new A2Error("STORE_UNAVAILABLE", "postgres store operation failed", { cause: err });
745
+ }
746
+ };
747
+ const jsonb = (value) => JSON.stringify(value) ?? "null";
748
+ const loadPg = async () => import("pg").catch(() => {
749
+ throw new A2Error("STORE_NOT_CONFIGURED", "Postgres connections created by A2 require the optional 'pg' package");
750
+ });
751
+ const createListener = ({ pg, connectionString }) => postgresNotifications(async () => {
752
+ const listener = new pg.default.Client({
753
+ connectionString,
754
+ application_name: "a2-listener",
755
+ connectionTimeoutMillis: 1e4
756
+ });
757
+ return {
758
+ connect: () => listener.connect(),
759
+ query: (text) => listener.query(text),
760
+ end: () => listener.end(),
761
+ onNotification: (callback) => {
762
+ listener.on("notification", callback);
763
+ },
764
+ onError: (callback) => {
765
+ listener.on("error", callback);
766
+ },
767
+ onEnd: (callback) => {
768
+ listener.on("end", callback);
769
+ }
770
+ };
771
+ });
772
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
773
+ const releasePins = async ({ query, sessionId, consumer, cursorKey, index }) => {
774
+ await query(`delete from a2_snapshot_pins
775
+ where session_id = $1 and event_index = $2
776
+ and consumer = $3 and cursor_key = $4`, [
777
+ sessionId,
778
+ index,
779
+ consumer,
780
+ cursorKey
781
+ ]);
782
+ await query(`delete from a2_snapshot_history history
783
+ where history.session_id = $1
784
+ and not exists (
785
+ select 1 from a2_snapshot_pins pin
786
+ where pin.session_id = history.session_id
787
+ and pin.reducer_name = history.reducer_name
788
+ and pin.up_to_index = history.up_to_index
789
+ )`, [sessionId]);
790
+ };
791
+ const lockSession = (query, sessionId) => query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
792
+ /** Whether the session has an event at `index`. */
793
+ const hasEvent = async (query, sessionId, index) => (await query("select 1 from a2_events where session_id = $1 and idx = $2", [sessionId, index])).rows.length > 0;
794
+ /**
795
+ * The attempt-currency fence shared by consumer appends and clears, under
796
+ * the session lock. The cursor is read locked (`for share` by an append,
797
+ * `for update` by a clear, which then releases the head): it serializes
798
+ * with every writer of the cursor (claims, completions, failures), so a
799
+ * stale read cannot pass the fence against a takeover committing beside
800
+ * it. The lock is only taken while the head and attempt still match.
801
+ */
802
+ const assertCurrentCause = async ({ query, sessionId, cause, clearIndex, lock }) => {
803
+ if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
804
+ const { rows } = await query(`select 1 from a2_cursors
805
+ where session_id = $1 and cursor_key = $2 and consumer = $3
806
+ and head = $4 and attempt_count = $5 and failed_at is null
807
+ for ${lock}`, [
808
+ sessionId,
809
+ cursorKeyOf(cause.lane, cause.index),
810
+ cause.consumer,
811
+ cause.index,
812
+ cause.attempt
813
+ ]);
814
+ if (rows.length === 0) throw supersededAttemptError(sessionId, cause);
815
+ };
816
+ const BATCH = "select r from a2_batch($1::text[], $2::jsonb) as r";
817
+ /**
818
+ * Each statement as a batch sends it, whitespace collapsed: a batch sends all
819
+ * its statements on every call. They hold no comments or spaced literals, and
820
+ * they're this module's constants, so the cache stays this size.
821
+ */
822
+ const compacted = /* @__PURE__ */ new Map();
823
+ const compact = (statement) => {
824
+ let sent = compacted.get(statement);
825
+ if (sent === void 0) {
826
+ if (statement.includes("--")) throw new TypeError("a batch statement holds a comment");
827
+ sent = statement.replace(/\s+/g, " ").trim();
828
+ compacted.set(statement, sent);
829
+ }
830
+ return sent;
831
+ };
832
+ /** The SQLSTATE `a2_batch` raises an `abort` row with, as its detail. */
833
+ const BATCH_ABORTED = "A2001";
834
+ /** The row an aborted `a2_batch` call raised, from `error` or its cause chain. */
835
+ const abortedBatch = (error) => {
836
+ for (let current = error, depth = 0; depth < 5; depth += 1) {
837
+ if (typeof current !== "object" || current === null) return void 0;
838
+ const { code, detail, cause } = current;
839
+ if (code === BATCH_ABORTED && typeof detail === "string") return JSON.parse(detail);
840
+ current = cause;
841
+ }
842
+ };
843
+ const LOCK_SESSION = `
844
+ select jsonb_build_object('locked', true)
845
+ from pg_advisory_xact_lock(hashtext(${SESSION}))`;
846
+ const INDEX = param.bigint("index");
847
+ const CONSUMER = param.text("consumer");
848
+ /**
849
+ * The index before the events this call inserted, the last `count` of the
850
+ * session's: read under the session lock, after the insert.
851
+ */
852
+ const INSERTED_AFTER = `(
853
+ select coalesce(max(idx), 0) - ${param.bigint("count")}
854
+ from a2_events where session_id = ${SESSION})`;
855
+ /**
856
+ * An append's checks, under the session lock: the session's head and pending
857
+ * state, and what its supplied `ids` say. Ids are unique per session. When
858
+ * every event's id belongs to this session already, the batch is a retry: it
859
+ * stops with the committed rows. Some owned aborts, as does scheduled work
860
+ * from at or below the clear marker (`origin`). Rows below the marker own no
861
+ * id.
862
+ */
863
+ const APPEND_CHECKS = `
864
+ with boundary as materialized (
865
+ select coalesce(max(idx), 0) as base,
866
+ ${clearIndexOf(SESSION)} as clear_index,
867
+ ${pendingIn(SESSION, param.texts("served"))} as has_pending
868
+ from a2_events where session_id = ${SESSION}
869
+ ), owned as materialized (
870
+ select event.session_id, event.idx, event.event_type, event.payload,
871
+ event.event_id, event.created_at, event.cause
872
+ from a2_events event, boundary
873
+ where event.session_id = ${SESSION}
874
+ and event.event_id = any(${param.texts("ids")})
875
+ and event.idx >= boundary.clear_index
876
+ )
877
+ select case
878
+ when (select count(*) from owned) = ${param.bigint("count")} then
879
+ jsonb_build_object('stop', 'replay',
880
+ 'has_pending', boundary.has_pending,
881
+ 'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
882
+ from owned))
883
+ when exists (select 1 from owned) then
884
+ jsonb_build_object('abort', 'mixed', 'owned',
885
+ (select count(*) from owned))
886
+ when ${param.bigint("origin")} <= boundary.clear_index then
887
+ jsonb_build_object('abort', 'cleared')
888
+ else jsonb_build_object('base', boundary.base,
889
+ 'has_pending', boundary.has_pending)
890
+ end
891
+ from boundary`;
892
+ /**
893
+ * The attempt-currency fence of a consumer append, under the session lock.
894
+ * Each cause's cursor is read `for share`: that serializes with every writer
895
+ * of the cursor (claims, completions, failures), so a stale read cannot pass
896
+ * the fence against a takeover committing beside it. A row is locked only
897
+ * while its head and attempt still match. The first cause that fails aborts.
898
+ */
899
+ const FENCE = `
900
+ with cause as (
901
+ select * from jsonb_to_recordset($1->'causes')
902
+ as cause(ord int, consumer text, cursor_key text, idx bigint, attempt int)
903
+ ), held as materialized (
904
+ select cause.ord from cause
905
+ join a2_cursors cur
906
+ on cur.session_id = ${SESSION} and cur.cursor_key = cause.cursor_key
907
+ and cur.consumer = cause.consumer and cur.head = cause.idx
908
+ and cur.attempt_count = cause.attempt and cur.failed_at is null
909
+ where cause.idx > ${clearIndexOf(SESSION)}
910
+ for share of cur
911
+ )
912
+ select jsonb_build_object('abort', 'superseded', 'ord', cause.ord)
913
+ from cause where cause.ord not in (select ord from held)
914
+ order by cause.ord limit 1`;
915
+ /**
916
+ * Frees the `ids` this session's rows below its clear marker hold, so the
917
+ * batch can take them. It never waits on a row: a reclaim deleting one may
918
+ * hold it, and one it skips aborts.
919
+ */
920
+ const REUSE_IDS = `
921
+ with stale as materialized (
922
+ select event.idx from a2_events event
923
+ where event.session_id = ${SESSION}
924
+ and event.event_id = any(${param.texts("ids")})
925
+ and event.idx < ${clearIndexOf(SESSION)}
926
+ order by event.idx
927
+ for update skip locked
928
+ ), deleted as (
929
+ delete from a2_events event using stale
930
+ where event.session_id = ${SESSION} and event.idx = stale.idx
931
+ returning event.idx
932
+ )
933
+ select jsonb_build_object('abort', 'held')
934
+ where (select count(*) from deleted) < (
935
+ select count(*) from a2_events event
936
+ where event.session_id = ${SESSION}
937
+ and event.event_id = any(${param.texts("ids")})
938
+ and event.idx < ${clearIndexOf(SESSION)})`;
939
+ /** Inserts `events` after the session's head, under the session lock. */
940
+ const INSERT_EVENTS = `
941
+ with inserted as (
942
+ insert into a2_events
943
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
944
+ select ${SESSION}, base.base + event.ord, event.type,
945
+ coalesce(event.payload, 'null'::jsonb), event.id,
946
+ ${param.bigint("now")}, event.cause
947
+ from (select coalesce(max(idx), 0) as base from a2_events
948
+ where session_id = ${SESSION}) base,
949
+ jsonb_to_recordset($1->'events')
950
+ as event(ord bigint, type text, payload jsonb, id text, cause jsonb)
951
+ returning 1
952
+ )
953
+ select jsonb_build_object('inserted', count(*)) from inserted`;
954
+ /** A cursor for each concurrent target: the event alone. */
955
+ const START_EVENTS = `
956
+ with started as (
957
+ insert into a2_cursors
958
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
959
+ select ${SESSION}, target.consumer, '#' || target.idx, target.idx, null,
960
+ 'event', target.idx - 1, target.idx
961
+ from (
962
+ select single.consumer, ${INSERTED_AFTER} + single.ord as idx
963
+ from jsonb_to_recordset($1->'singles')
964
+ as single(consumer text, ord bigint)
965
+ ) target
966
+ on conflict do nothing
967
+ returning 1
968
+ )
969
+ select jsonb_build_object('started', count(*)) from started`;
970
+ /** Queues each lane target's event in its lane. */
971
+ const QUEUE_LANES = `
972
+ with queued as (
973
+ insert into a2_lane_events (session_id, consumer, lane, idx)
974
+ select ${SESSION}, queued.consumer, queued.lane,
975
+ ${INSERTED_AFTER} + queued.ord
976
+ from jsonb_to_recordset($1->'queued')
977
+ as queued(consumer text, lane text, ord bigint)
978
+ on conflict do nothing
979
+ returning 1
980
+ )
981
+ select jsonb_build_object('queued', count(*)) from queued`;
982
+ /**
983
+ * Starts a run for each lane cursor the batch queues work for that has none
984
+ * running: one that is idle, superseded by the clear marker, or new. It starts
985
+ * at the lane's first event in the batch and counts from the marker.
986
+ */
987
+ const START_LANES = `
988
+ with boundary as materialized (
989
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
990
+ ), target as (
991
+ select first.consumer, first.cursor_key, first.lane,
992
+ boundary.base + first.ord as idx
993
+ from boundary, jsonb_to_recordset($1->'lanes')
994
+ as first(consumer text, cursor_key text, lane text, ord bigint)
995
+ ), started as materialized (
996
+ select target.*, latest.position
997
+ from target
998
+ left join lateral (${LATEST_RUN("target")}) latest on true
999
+ where latest.head is null
1000
+ or latest.head <= (select clear_index from boundary)
1001
+ ), ${RETIRE_RUNS("started", "(select clear_index from boundary)")},
1002
+ inserted as (
1003
+ insert into a2_cursors
1004
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
1005
+ select ${SESSION}, started.consumer, started.cursor_key, started.idx,
1006
+ started.lane, 'lane',
1007
+ greatest(coalesce(started.position, 0),
1008
+ (select clear_index from boundary)),
1009
+ started.idx
1010
+ from started
1011
+ on conflict do nothing
1012
+ returning 1
1013
+ )
1014
+ select jsonb_build_object('lanes', count(*)) from inserted`;
1015
+ /**
1016
+ * Starts a run for each served log cursor the batch carries types for that
1017
+ * has none running. A missing cursor starts at the batch's first event of its
1018
+ * types, or at the beginning of the log with backfill. One that exists starts
1019
+ * at the next event it takes after its position: a deployment that does not
1020
+ * serve the consumer may have appended some while it was idle. A cursor from
1021
+ * before the clear marker counts from it.
1022
+ */
1023
+ const WAKE_LOGS = `
1024
+ with boundary as materialized (
1025
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
1026
+ ), target as (
1027
+ select log.consumer, log.cursor_key, log.lane,
1028
+ boundary.base + log.ord as first, log.types, log.backfill
1029
+ from boundary, jsonb_to_recordset($1->'logs')
1030
+ as log(consumer text, cursor_key text, lane text, ord bigint,
1031
+ types jsonb, backfill boolean)
1032
+ ), woken as materialized (
1033
+ select target.*,
1034
+ case when latest.epoch is not null
1035
+ then greatest(latest.position,
1036
+ (select clear_index from boundary))
1037
+ when target.backfill then (select clear_index from boundary)
1038
+ else (select base from boundary) end as position,
1039
+ latest.epoch is not null or target.backfill as resumes
1040
+ from target
1041
+ left join lateral (${LATEST_RUN("target")}) latest on true
1042
+ where latest.head is null
1043
+ or latest.head <= (select clear_index from boundary)
1044
+ ), ${RETIRE_RUNS("woken", "(select clear_index from boundary)")}, run as (
1045
+ select woken.*,
1046
+ case when woken.resumes then coalesce(${nextLogHead({
1047
+ types: "woken.types",
1048
+ after: "woken.position"
1049
+ })}, woken.first) else woken.first end as head
1050
+ from woken
1051
+ ), inserted as (
1052
+ insert into a2_cursors
1053
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1054
+ types)
1055
+ select ${SESSION}, run.consumer, run.cursor_key, run.head, run.lane, 'log',
1056
+ run.position, run.head, run.types
1057
+ from run
1058
+ on conflict do nothing
1059
+ returning 1
1060
+ )
1061
+ select jsonb_build_object('logs', count(*)) from inserted`;
1062
+ /**
1063
+ * The heads a claim may take, up to `through` when there is one, as
1064
+ * `from ... where` for `cur`. `for update` makes a claim racing another wait
1065
+ * and then re-check the row it waited for.
1066
+ */
1067
+ const ELIGIBLE = `
1068
+ from a2_cursors cur
1069
+ where cur.session_id = ${SESSION}
1070
+ and cur.head > ${clearIndexOf(SESSION)}
1071
+ and (${param.bigint("through")} is null
1072
+ or cur.head <= ${param.bigint("through")})
1073
+ and cur.failed_at is null
1074
+ and (${param.texts("served")} is null
1075
+ or cur.consumer = any(${param.texts("served")}))
1076
+ and not exists (
1077
+ select 1 from jsonb_to_recordset($1->'excluded')
1078
+ as skip(consumer text, cursor_key text)
1079
+ where skip.consumer = cur.consumer and skip.cursor_key = cur.cursor_key
1080
+ )`;
1081
+ /** A lapsed claim's takeover that spends the last failed attempt dead-letters the head instead. */
1082
+ const EXHAUSTED = `(cur.claim_holder is not null
1083
+ and cur.failure_count + 1 >= ${param.int("maxFailures")})`;
1084
+ /**
1085
+ * Claims every eligible head, returning each with its event. A head the claim
1086
+ * waited on may have moved to an event its snapshot predates: that row comes
1087
+ * back without one (`e_idx` null), for `claimAvailable` to read. It stops
1088
+ * there unless one dead-lettered, whose pins `RELEASE_DEAD` then releases, or
1089
+ * none was claimed, for `RETRY_AT`.
1090
+ */
1091
+ const CLAIM = `
1092
+ with eligible as materialized (
1093
+ select cur.consumer, cur.cursor_key, cur.epoch
1094
+ ${ELIGIBLE}
1095
+ and (cur.claim_expires_at is null
1096
+ or cur.claim_expires_at <= ${param.bigint("now")})
1097
+ order by cur.head, cur.consumer
1098
+ for update of cur
1099
+ ), claimed as (
1100
+ update a2_cursors cur set
1101
+ attempt_count = cur.attempt_count + 1,
1102
+ failure_count = cur.failure_count
1103
+ + case when cur.claim_holder is null then 0 else 1 end,
1104
+ failed_at = case when ${EXHAUSTED} then ${param.bigint("now")}
1105
+ else cur.failed_at end,
1106
+ first_claimed_at = case when ${EXHAUSTED} then cur.first_claimed_at
1107
+ else coalesce(cur.first_claimed_at, ${param.bigint("now")}) end,
1108
+ last_claimed_at = case when ${EXHAUSTED} then cur.last_claimed_at
1109
+ else ${param.bigint("now")} end,
1110
+ claim_holder = case when ${EXHAUSTED} then null
1111
+ else ${param.text("holder")} end,
1112
+ claim_expires_at = case when ${EXHAUSTED} then null
1113
+ else ${param.bigint("expiresAt")} end
1114
+ from eligible
1115
+ where cur.session_id = ${SESSION}
1116
+ and cur.cursor_key = eligible.cursor_key
1117
+ and cur.consumer = eligible.consumer
1118
+ and cur.epoch = eligible.epoch
1119
+ returning cur.*
1120
+ ), heads as (
1121
+ select claimed.*, ${HEAD_COLUMNS},
1122
+ ${clearIndexOf(SESSION)} as clear_index,
1123
+ (select coalesce(max(idx), 0) from a2_events
1124
+ where session_id = ${SESSION}) as session_head
1125
+ from claimed
1126
+ left join a2_events event
1127
+ on event.session_id = ${SESSION} and event.idx = claimed.head
1128
+ )
1129
+ select r from (
1130
+ select 0 as ord, to_jsonb(heads) as r from heads
1131
+ union all
1132
+ select 1, jsonb_build_object('stop', 'claimed')
1133
+ where exists (select 1 from heads)
1134
+ and not exists (select 1 from heads where failed_at is not null)
1135
+ ) claim
1136
+ order by ord`;
1137
+ /**
1138
+ * Releases the pins of the heads dead-lettered at `now`, the claim's among
1139
+ * them, in a snapshot after the claim's locks, with the history no other pin
1140
+ * keeps.
1141
+ */
1142
+ const RELEASE_DEAD = `
1143
+ with dead as materialized (
1144
+ select cur.consumer, cur.cursor_key, cur.head from a2_cursors cur
1145
+ where cur.session_id = ${SESSION} and cur.head is not null
1146
+ and cur.failed_at = ${param.bigint("now")} and cur.claim_holder is null
1147
+ ), unpinned as (
1148
+ delete from a2_snapshot_pins pin using dead
1149
+ where pin.session_id = ${SESSION} and pin.event_index = dead.head
1150
+ and pin.consumer = dead.consumer and pin.cursor_key = dead.cursor_key
1151
+ returning 1
1152
+ ), unkept as (
1153
+ delete from a2_snapshot_history history
1154
+ where history.session_id = ${SESSION}
1155
+ and exists (select 1 from dead)
1156
+ and not exists (
1157
+ select 1 from a2_snapshot_pins pin
1158
+ where pin.session_id = history.session_id
1159
+ and pin.reducer_name = history.reducer_name
1160
+ and pin.up_to_index = history.up_to_index
1161
+ and not exists (
1162
+ select 1 from dead
1163
+ where pin.event_index = dead.head
1164
+ and pin.consumer = dead.consumer
1165
+ and pin.cursor_key = dead.cursor_key)
1166
+ )
1167
+ returning 1
1168
+ )
1169
+ select jsonb_build_object('released', (select count(*) from unpinned))`;
1170
+ /** When nothing was claimed: the earliest live claim or retry hold that blocks a head. */
1171
+ const RETRY_AT = `
1172
+ select jsonb_build_object('retry_at', min(cur.claim_expires_at))
1173
+ ${ELIGIBLE}
1174
+ and cur.claim_expires_at > ${param.bigint("now")}`;
1175
+ /** The cursor row `alias` whose head this attempt holds. */
1176
+ const heldBy = (alias) => `
1177
+ ${alias}.session_id = ${SESSION}
1178
+ and ${alias}.cursor_key = ${param.text("cursorKey")}
1179
+ and ${alias}.consumer = ${CONSUMER} and ${alias}.head = ${INDEX}
1180
+ and ${alias}.attempt_count = ${param.int("attempt")}
1181
+ and ${alias}.failed_at is null and ${alias}.claim_holder is not null`;
1182
+ /**
1183
+ * Locks the cursor while this attempt holds its head, and stops unless it
1184
+ * does: a completion locks its cursor first. A cleared head is never locked.
1185
+ */
1186
+ const HOLD = `
1187
+ with boundary as materialized (
1188
+ select ${clearIndexOf(SESSION)} as clear_index
1189
+ ), held as materialized (
1190
+ select cur.kind from a2_cursors cur, boundary
1191
+ where ${heldBy("cur")} and ${INDEX} > boundary.clear_index
1192
+ for update of cur
1193
+ )
1194
+ select case
1195
+ when ${INDEX} <= boundary.clear_index then
1196
+ jsonb_build_object('stop', 'superseded')
1197
+ when not exists (select 1 from held) then
1198
+ jsonb_build_object('stop', 'unheld')
1199
+ else jsonb_build_object('held', (select kind from held limit 1))
1200
+ end
1201
+ from boundary`;
1202
+ /** A log cursor's types once it moves: the served consumer's (`types`, when `retype`), else its own. */
1203
+ const logTypesOf = (cur) => `(case when ${param.boolean("retype")} then ${param.jsonb("types")}
1204
+ else ${cur}.types end)`;
1205
+ /**
1206
+ * The head cursor `cur` moves to from its head: its lane's next unfinished
1207
+ * event, the next event of a log cursor's types, or none for a concurrent
1208
+ * cursor. The lane's lookup is ordered by its whole key, so the lane's
1209
+ * primary key serves it.
1210
+ */
1211
+ const nextHeadOf = (cur) => `
1212
+ case ${cur}.kind
1213
+ when 'lane' then (
1214
+ select lane.idx from a2_lane_events lane
1215
+ where lane.session_id = ${SESSION} and lane.consumer = ${cur}.consumer
1216
+ and lane.lane = ${cur}.lane and not lane.done
1217
+ and lane.idx > ${cur}.head
1218
+ order by lane.consumer, lane.lane, lane.idx limit 1)
1219
+ when 'log' then (${nextLogHead({
1220
+ types: logTypesOf(cur),
1221
+ after: `${cur}.head`
1222
+ })})
1223
+ end`;
1224
+ /**
1225
+ * Takes the session lock when the held cursor would go idle: a lane or log
1226
+ * cursor with no next head. See `completeAttempt` for why a completion that
1227
+ * moves to a next head needs no lock.
1228
+ */
1229
+ const LOCK_IF_IDLE = `
1230
+ select jsonb_build_object('locked',
1231
+ pg_advisory_xact_lock(hashtext(${SESSION}))::text is not null)
1232
+ from a2_cursors cur
1233
+ where ${heldBy("cur")} and cur.kind <> 'event'
1234
+ and (${nextHeadOf("cur")}) is null`;
1235
+ /**
1236
+ * Moves the cursor `target` selects past its head `index`, if its head lies
1237
+ * above the clear marker: marks the lane's event done (with this attempt's
1238
+ * receipt, `receipt`), releases the head's snapshot pins with the history no
1239
+ * other pin keeps, and gives the cursor its next head with fresh dispatch
1240
+ * columns (a log or concurrent cursor keeps the receipt on the run). The
1241
+ * marker and the next head are read in one snapshot, after the cursor's lock.
1242
+ * Without a row it writes nothing, and stops if `stop`.
1243
+ */
1244
+ const advance = ({ target, receipt, missing }) => `
1245
+ with target as materialized (
1246
+ select cur.* from a2_cursors cur
1247
+ where ${target} and ${INDEX} > ${clearIndexOf(SESSION)}
1248
+ order by cur.cursor_key limit 1
1249
+ for update of cur
1250
+ ), done as (
1251
+ update a2_lane_events lane set done = true${receipt ? `, completed_attempt = ${param.int("attempt")},
1252
+ returned_event_ids = ${param.jsonb("ids")}` : ""}
1253
+ from target
1254
+ where target.kind = 'lane' and lane.session_id = ${SESSION}
1255
+ and lane.consumer = target.consumer and lane.lane = target.lane
1256
+ and lane.idx = target.head
1257
+ returning 1
1258
+ ), unpinned as (
1259
+ delete from a2_snapshot_pins pin using target
1260
+ where pin.session_id = ${SESSION} and pin.event_index = target.head
1261
+ and pin.consumer = target.consumer and pin.cursor_key = target.cursor_key
1262
+ returning 1
1263
+ ), unkept as (
1264
+ delete from a2_snapshot_history history using target
1265
+ where history.session_id = ${SESSION}
1266
+ and not exists (
1267
+ select 1 from a2_snapshot_pins pin
1268
+ where pin.session_id = history.session_id
1269
+ and pin.reducer_name = history.reducer_name
1270
+ and pin.up_to_index = history.up_to_index
1271
+ and not (pin.event_index = target.head
1272
+ and pin.consumer = target.consumer
1273
+ and pin.cursor_key = target.cursor_key)
1274
+ )
1275
+ returning 1
1276
+ ), moved as (
1277
+ update a2_cursors cur
1278
+ set position = target.head, head = ${nextHeadOf("target")},
1279
+ ${FRESH_DISPATCH},
1280
+ types = case when target.kind = 'log'
1281
+ then ${logTypesOf("target")} else cur.types end${receipt ? `,
1282
+ completed_idx = case when target.kind = 'lane'
1283
+ then cur.completed_idx else target.head end,
1284
+ completed_attempt = case when target.kind = 'lane'
1285
+ then cur.completed_attempt
1286
+ else ${param.int("attempt")} end,
1287
+ returned_event_ids = case when target.kind = 'lane'
1288
+ then cur.returned_event_ids
1289
+ else ${param.jsonb("ids")} end` : ""}
1290
+ from target
1291
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1292
+ and cur.consumer = target.consumer and cur.epoch = target.epoch
1293
+ returning cur.head
1294
+ )
1295
+ select case when exists (select 1 from moved)
1296
+ then jsonb_build_object('advanced', true)
1297
+ else jsonb_build_object(${missing === "continue" ? `'advanced', false` : `'${missing}', 'superseded'`}) end`;
1298
+ /** Completing a held head, once the cursor's lock is taken. */
1299
+ const COMPLETE = advance({
1300
+ target: heldBy("cur"),
1301
+ receipt: true,
1302
+ missing: "stop"
1303
+ });
1304
+ /**
1305
+ * Completing a held head after appending its events, under the session lock:
1306
+ * the head can't have moved, but if it had, the events must not stand.
1307
+ */
1308
+ const COMPLETE_APPENDED = advance({
1309
+ target: heldBy("cur"),
1310
+ receipt: true,
1311
+ missing: "abort"
1312
+ });
1313
+ /**
1314
+ * A completion's checks before it appends, under the session lock, which the
1315
+ * cursor's lock preceded: a head the clear marker passed meanwhile stops, and
1316
+ * a returned id a live row owns aborts.
1317
+ */
1318
+ const RETURN_CHECKS = `
1319
+ select case
1320
+ when ${INDEX} <= ${clearIndexOf(SESSION)} then
1321
+ jsonb_build_object('stop', 'superseded')
1322
+ when exists (
1323
+ select 1 from a2_events event
1324
+ where event.session_id = ${SESSION}
1325
+ and event.event_id = any(${param.texts("ids")})
1326
+ and event.idx >= ${clearIndexOf(SESSION)}
1327
+ ) then jsonb_build_object('abort', 'owned')
1328
+ else jsonb_build_object('base', (
1329
+ select coalesce(max(idx), 0) from a2_events
1330
+ where session_id = ${SESSION}))
1331
+ end`;
1332
+ /** The consumer's dead-lettered head at `index`, locked. */
1333
+ const deadAt = (alias) => `
1334
+ ${alias}.session_id = ${SESSION} and ${alias}.consumer = ${CONSUMER}
1335
+ and ${alias}.head = ${INDEX} and ${alias}.failed_at is not null`;
1336
+ /**
1337
+ * Locks the dead letter, or stops: the statements after it act only on the
1338
+ * row this one locked.
1339
+ */
1340
+ const LOCK_DEAD = `
1341
+ with dead as materialized (
1342
+ select cur.cursor_key from a2_cursors cur
1343
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1344
+ order by cur.cursor_key limit 1
1345
+ for update
1346
+ )
1347
+ select case when exists (select 1 from dead)
1348
+ then jsonb_build_object('redriven', true)
1349
+ else jsonb_build_object('stop', 'alive', 'redriven', false,
1350
+ 'has_pending',
1351
+ ${pendingIn(SESSION, param.texts("served"))}) end`;
1352
+ /** Clears a dead letter, keeping its claim history and last failure. */
1353
+ const RETRY_DEAD = `
1354
+ with target as materialized (
1355
+ select cur.cursor_key, cur.epoch from a2_cursors cur
1356
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1357
+ order by cur.cursor_key limit 1
1358
+ for update of cur
1359
+ ), retried as (
1360
+ update a2_cursors cur set failed_at = null, failure_count = 0
1361
+ from target
1362
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1363
+ and cur.consumer = ${CONSUMER} and cur.epoch = target.epoch
1364
+ returning 1
1365
+ )
1366
+ select jsonb_build_object('retried', count(*)) from retried`;
1367
+ const SKIP_DEAD = advance({
1368
+ target: deadAt("cur"),
1369
+ receipt: false,
1370
+ missing: "continue"
1371
+ });
1372
+ const PENDING = `
1373
+ select jsonb_build_object('has_pending',
1374
+ ${pendingIn(SESSION, param.texts("served"))})`;
1375
+ /**
1376
+ * One log cursor as `wakeLog` finds it: the session's head and clear marker,
1377
+ * the cursor's latest run, where a run would start (`start`), and the next
1378
+ * event it takes from there unless the run has a head. `retyped` says whether
1379
+ * the run's types are stale. CTEs, for a `with`.
1380
+ */
1381
+ const LOG_CURSOR = `
1382
+ boundary as materialized (
1383
+ select coalesce(max(idx), 0) as session_head,
1384
+ ${clearIndexOf(SESSION)} as clear_index
1385
+ from a2_events where session_id = ${SESSION}
1386
+ ), latest as materialized (
1387
+ select boundary.*, cur.*,
1388
+ case when cur.epoch is not null
1389
+ then greatest(cur.position, boundary.clear_index)
1390
+ when ${param.boolean("backfill")} then boundary.clear_index
1391
+ else boundary.session_head end as start
1392
+ from boundary
1393
+ left join lateral (
1394
+ select * from a2_cursors cur
1395
+ where cur.session_id = ${SESSION}
1396
+ and cur.cursor_key = ${param.text("cursorKey")}
1397
+ and cur.consumer = ${CONSUMER}
1398
+ order by cur.epoch desc limit 1
1399
+ ) cur on true
1400
+ ), found as materialized (
1401
+ select latest.*,
1402
+ latest.types is distinct from ${param.jsonb("types")} as retyped,
1403
+ case when latest.head > latest.clear_index then null
1404
+ else ${nextLogHead({
1405
+ types: param.jsonb("types"),
1406
+ after: "latest.start"
1407
+ })} end as next_head
1408
+ from latest
1409
+ )`;
1410
+ /**
1411
+ * A run with a head gets no next one: it is left as it is, claim and all,
1412
+ * and so is an idle run with nothing new to take. Only their types change.
1413
+ */
1414
+ const KEEPS_RUN = "found.epoch is not null and found.next_head is null";
1415
+ /** No run yet, and nothing after the latest clear: none is started. */
1416
+ const NO_RUN_NEEDED = "found.epoch is null and found.session_head = found.clear_index";
1417
+ /** `wakeLog` without the lock: stops when there is nothing to write. */
1418
+ const READ_LOG = `
1419
+ with ${LOG_CURSOR}
1420
+ select case when ${KEEPS_RUN} and not found.retyped
1421
+ then jsonb_build_object('stop', 'unchanged',
1422
+ 'cursor', to_jsonb(found))
1423
+ when ${NO_RUN_NEEDED}
1424
+ then jsonb_build_object('stop', 'empty', 'empty', true,
1425
+ 'head', found.session_head)
1426
+ else jsonb_build_object('stale', true) end
1427
+ from found`;
1428
+ /**
1429
+ * `wakeLog` under the session lock. A kept run takes the consumer's types,
1430
+ * unless another operation holds its row: the session lock is held, so this
1431
+ * waits for none. Otherwise a run starts at `start`, retiring the runs it
1432
+ * replaces. One with a head is keyed by it; one with nothing to run is idle
1433
+ * at its position, as a run that finished its last head is, keyed by that
1434
+ * position so the next run, which starts after it, sorts later.
1435
+ */
1436
+ const WAKE_LOG = `
1437
+ with ${LOG_CURSOR}, held as materialized (
1438
+ select cur.ctid from a2_cursors cur, found
1439
+ where ${KEEPS_RUN} and found.retyped
1440
+ and cur.session_id = ${SESSION}
1441
+ and cur.cursor_key = ${param.text("cursorKey")}
1442
+ and cur.consumer = ${CONSUMER} and cur.epoch = found.epoch
1443
+ and cur.types is distinct from ${param.jsonb("types")}
1444
+ for update of cur skip locked
1445
+ ), retype as (
1446
+ update a2_cursors cur set types = ${param.jsonb("types")} from held
1447
+ where cur.ctid = held.ctid
1448
+ ), target as (
1449
+ select ${param.text("cursorKey")} as cursor_key, ${CONSUMER} as consumer
1450
+ from found where not (${KEEPS_RUN}) and not (${NO_RUN_NEEDED})
1451
+ ), ${RETIRE_RUNS("target", "(select clear_index from found)")}, started as (
1452
+ insert into a2_cursors
1453
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1454
+ types)
1455
+ select ${SESSION}, target.consumer, target.cursor_key,
1456
+ coalesce(found.next_head, found.start), ${param.text("lane")}, 'log',
1457
+ found.start, found.next_head, ${param.jsonb("types")}
1458
+ from found, target
1459
+ returning *
1460
+ )
1461
+ select jsonb_build_object(
1462
+ 'cursor', coalesce((select to_jsonb(started) from started),
1463
+ to_jsonb(found)),
1464
+ 'head', found.session_head, 'clear_index', found.clear_index,
1465
+ 'empty', ${NO_RUN_NEEDED})
1466
+ from found`;
1467
+ /**
1468
+ * The statements that start the cursor work a batch of `events` carries,
1469
+ * with their parameters: each event by its position in the batch, from 1,
1470
+ * since its index is assigned under the lock. `started` says whether it
1471
+ * starts work for a served consumer.
1472
+ */
1473
+ const enqueue = (events, consumers) => {
1474
+ const served = consumers && new Set(consumers.map(({ name }) => name));
1475
+ let started = false;
1476
+ const singles = [];
1477
+ const queued = [];
1478
+ const lanes = /* @__PURE__ */ new Map();
1479
+ events.forEach((event, offset) => {
1480
+ const ord = offset + 1;
1481
+ for (const target of event.targets ?? []) {
1482
+ if (!served || served.has(target.consumer)) started = true;
1483
+ if (target.lane === null) {
1484
+ singles.push({
1485
+ consumer: target.consumer,
1486
+ ord
1487
+ });
1488
+ continue;
1489
+ }
1490
+ queued.push({
1491
+ consumer: target.consumer,
1492
+ lane: target.lane,
1493
+ ord
1494
+ });
1495
+ const key = cursorKeyOf(target.lane, ord);
1496
+ const first = `${target.consumer}\u0000${key}`;
1497
+ if (!lanes.has(first)) lanes.set(first, {
1498
+ consumer: target.consumer,
1499
+ cursor_key: key,
1500
+ lane: target.lane,
1501
+ ord
1502
+ });
1503
+ }
1504
+ });
1505
+ const logs = [];
1506
+ for (const consumer of consumers ?? []) {
1507
+ const log = consumer.log;
1508
+ if (!log) continue;
1509
+ const offset = events.findIndex((event) => event.type !== "a2.cleared" && (log.types === null || log.types.includes(event.type)));
1510
+ if (offset === -1) continue;
1511
+ logs.push({
1512
+ consumer: consumer.name,
1513
+ cursor_key: cursorKeyOf(log.lane, 0),
1514
+ lane: log.lane,
1515
+ ord: offset + 1,
1516
+ types: log.types,
1517
+ backfill: log.backfill
1518
+ });
1519
+ }
1520
+ if (logs.length > 0) started = true;
1521
+ return {
1522
+ statements: [
1523
+ singles.length > 0 && START_EVENTS,
1524
+ queued.length > 0 && QUEUE_LANES,
1525
+ queued.length > 0 && START_LANES,
1526
+ logs.length > 0 && WAKE_LOGS
1527
+ ],
1528
+ params: {
1529
+ singles,
1530
+ queued,
1531
+ lanes: [...lanes.values()],
1532
+ logs,
1533
+ clearType: CLEAR_EVENT_TYPE
1534
+ },
1535
+ started
1536
+ };
1537
+ };
1538
+ /**
1539
+ * The rows a batch inserted at `base + 1`, as the store returns them.
1540
+ * `events` are what the batch sent.
1541
+ */
1542
+ const insertedRows = ({ sessionId, events, base, now }) => events.map((event, offset) => ({
1543
+ id: event.id,
1544
+ type: event.type,
1545
+ payload: structuredClone(event.payload),
1546
+ index: base + 1 + offset,
1547
+ sessionId,
1548
+ createdAt: new Date(now),
1549
+ cause: event.cause ? { ...event.cause } : null
1550
+ }));
1551
+ /** A served log consumer's types, for a cursor that moves past its head. */
1552
+ const retypeOf = (consumer, consumers) => {
1553
+ const log = consumers?.find(({ name }) => name === consumer)?.log;
1554
+ return log ? {
1555
+ retype: true,
1556
+ types: log.types
1557
+ } : {
1558
+ retype: false,
1559
+ types: null
1560
+ };
1561
+ };
1562
+ function postgres$1(options = {}) {
1563
+ const clock = options.clock ?? SYSTEM_CLOCK;
1564
+ const generateId = options.ids ?? RANDOM_IDS;
1565
+ const instanceId = crypto.randomUUID();
1566
+ let closed = false;
1567
+ if (options.listenConnectionString === "") throw new TypeError("listenConnectionString must be a direct Postgres connection string");
1568
+ if (!options.client && !options.connectionString) throw new TypeError("postgres() needs a connectionString or an injected client");
1569
+ const injected = options.client ? sharePostgresClient(options.client) : void 0;
1570
+ const connection = retryableLazy(async () => {
1571
+ let lease = injected;
1572
+ if (!lease) {
1573
+ const pg = await loadPg();
1574
+ const connectionString = options.connectionString;
1575
+ const key = postgresConnectionKey({
1576
+ connectionString,
1577
+ defaults: pg.default.defaults
1578
+ });
1579
+ lease = sharePostgresPool({
1580
+ key,
1581
+ create: () => {
1582
+ const pool = new pg.default.Pool({ connectionString });
1583
+ pool.on("error", () => {});
1584
+ return postgresPoolClient(pool);
1585
+ }
1586
+ });
1587
+ }
1588
+ return {
1589
+ lease,
1590
+ client: explainingPostgresClient(lease.value.client)
1591
+ };
1592
+ });
1593
+ const client = async () => {
1594
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
1595
+ const { client: explaining } = await connection.get();
1596
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
1597
+ return explaining;
1598
+ };
1599
+ const notifications = options.listenConnectionString ? postgresNotificationScope(async () => {
1600
+ const c = await client();
1601
+ await (await connection.get()).lease.value.initialize({
1602
+ key: "triggers",
1603
+ run: () => checkPostgresTriggers(c)
1604
+ });
1605
+ const pg = await loadPg();
1606
+ const connectionString = options.listenConnectionString;
1607
+ const key = postgresConnectionKey({
1608
+ connectionString,
1609
+ defaults: pg.default.defaults
1610
+ });
1611
+ return sharePostgresListener({
1612
+ key,
1613
+ create: () => createListener({
1614
+ pg,
1615
+ connectionString
1616
+ })
1617
+ });
1618
+ }) : void 0;
1619
+ const presenceSubscriptions = /* @__PURE__ */ new Map();
1620
+ const readStateBatch = async (requests, reducerName) => {
1621
+ if (requests.length === 0) return [];
1622
+ const c = await client();
1623
+ const input = requests.map((request, position) => ({
1624
+ position,
1625
+ sessionId: request.sessionId,
1626
+ throughIndex: request.throughIndex ?? null,
1627
+ snapshotThroughIndex: request.snapshotThroughIndex ?? request.throughIndex ?? null
1628
+ }));
1629
+ const { rows } = await c.query(`with request as materialized (
1630
+ select * from jsonb_to_recordset($1::jsonb) as value(
1631
+ position integer,
1632
+ "sessionId" text,
1633
+ "throughIndex" bigint,
1634
+ "snapshotThroughIndex" bigint
1635
+ )
1636
+ ), bounded as materialized (
1637
+ select request.*, coalesce(clears.clear_index, 0) as clear_index
1638
+ from request
1639
+ left join a2_clears clears on clears.session_id = request."sessionId"
1640
+ ), selected as materialized (
1641
+ select request.position,
1642
+ request."sessionId" as session_id,
1643
+ request."throughIndex" as through_index,
1644
+ request.clear_index,
1645
+ head.up_to_index as head_index,
1646
+ candidate.up_to_index as snapshot_index,
1647
+ candidate.state as snapshot_state
1648
+ from bounded request
1649
+ left join a2_snapshots head
1650
+ on head.session_id = request."sessionId"
1651
+ and head.reducer_name = $2
1652
+ and head.up_to_index >= request.clear_index
1653
+ left join lateral (
1654
+ select up_to_index, state from (
1655
+ select head.up_to_index, head.state
1656
+ where request."snapshotThroughIndex" is null
1657
+ or head.up_to_index <= request."snapshotThroughIndex"
1658
+ union all
1659
+ select history.up_to_index, history.state
1660
+ from a2_snapshot_history history
1661
+ where history.session_id = request."sessionId"
1662
+ and history.reducer_name = $2
1663
+ and history.up_to_index >= request.clear_index
1664
+ and (
1665
+ request."snapshotThroughIndex" is null
1666
+ or history.up_to_index <= request."snapshotThroughIndex"
1667
+ )
1668
+ ) candidates
1669
+ order by up_to_index desc
1670
+ limit 1
1671
+ ) candidate on true
1672
+ )
1673
+ select selected.position, 0 as row_order, selected.session_id,
1674
+ selected.head_index, selected.snapshot_index,
1675
+ selected.snapshot_state, selected.clear_index,
1676
+ null::bigint as idx, null::text as event_type,
1677
+ null::jsonb as payload, null::text as event_id,
1678
+ null::bigint as created_at
1679
+ from selected
1680
+ union all
1681
+ select selected.position, 1 as row_order, event.session_id,
1682
+ selected.head_index, null::bigint as snapshot_index,
1683
+ null::jsonb as snapshot_state, selected.clear_index,
1684
+ event.idx, event.event_type, event.payload,
1685
+ event.event_id, event.created_at
1686
+ from selected
1687
+ join a2_events event on event.session_id = selected.session_id
1688
+ where event.idx > greatest(
1689
+ coalesce(selected.snapshot_index, 0),
1690
+ selected.clear_index - 1
1691
+ )
1692
+ and (
1693
+ selected.through_index is null
1694
+ or event.idx <= selected.through_index
1695
+ )
1696
+ order by position, row_order, idx`, [jsonb(input), reducerName]);
1697
+ const reads = requests.map(() => ({
1698
+ headIndex: null,
1699
+ snapshot: null,
1700
+ events: []
1701
+ }));
1702
+ for (const row of rows) {
1703
+ const read = reads[asNumber(row["position"])];
1704
+ if (row["row_order"] === 0 || row["row_order"] === "0") {
1705
+ const clearIndex = asNumber(row["clear_index"]);
1706
+ if (clearIndex > 0) read.clearIndex = clearIndex;
1707
+ read.headIndex = row["head_index"] === null ? null : asNumber(row["head_index"]);
1708
+ if (row["snapshot_index"] !== null) read.snapshot = {
1709
+ index: asNumber(row["snapshot_index"]),
1710
+ state: row["snapshot_state"]
1711
+ };
1712
+ } else read.events.push(toEvent(row));
1713
+ }
1714
+ return reads;
1715
+ };
1716
+ const begin = notifications ? `begin; set local a2.presence_source = '${instanceId}'` : "begin";
1717
+ const withTx = async (fn) => {
1718
+ const c = await client();
1719
+ if (c.connect) {
1720
+ const conn = await c.connect();
1721
+ try {
1722
+ await conn.query(begin);
1723
+ const result = await fn((text, values) => conn.query(text, values));
1724
+ await conn.query("commit");
1725
+ return result;
1726
+ } catch (err) {
1727
+ await conn.query("rollback").catch(() => {});
1728
+ throw err;
1729
+ } finally {
1730
+ conn.release();
1731
+ }
1732
+ }
1733
+ const resource = (await connection.get()).lease.value;
1734
+ return resource.exclusive(async () => {
1735
+ const raw = resource.raw;
1736
+ await raw.query(begin);
1737
+ try {
1738
+ const result = await fn((text, values) => raw.query(text, values).catch((error) => {
1739
+ throw explainPostgresError(error);
1740
+ }));
1741
+ await raw.query("commit");
1742
+ return result;
1743
+ } catch (err) {
1744
+ await raw.query("rollback").catch(() => {});
1745
+ throw err;
1746
+ }
1747
+ });
1748
+ };
1749
+ /**
1750
+ * Runs `statements` (the falsy ones dropped) as one `a2_batch` call with
1751
+ * `params`, and the session's id as `session`. Its rows come back in order;
1752
+ * an aborted call throws what `abort` makes of the aborting row.
1753
+ */
1754
+ const run = async ({ sessionId, statements, params, abort = () => new A2Error("STORE_UNAVAILABLE", "postgres store operation failed") }) => {
1755
+ const c = await client();
1756
+ try {
1757
+ const { rows } = await c.query(BATCH, [statements.flatMap((statement) => statement === false ? [] : [compact(statement)]), jsonb({
1758
+ ...params,
1759
+ session: sessionId
1760
+ })]);
1761
+ return rows.map((row) => row["r"]);
1762
+ } catch (error) {
1763
+ const reason = abortedBatch(error);
1764
+ if (reason) throw abort(reason);
1765
+ throw error;
1766
+ }
1767
+ };
1768
+ /** Whether `index` is at or below the boundary, read in a fresh statement. */
1769
+ const clearedAt = async ({ query, sessionId, index }) => {
1770
+ const { rows } = await query(`select ${clearIndexOf("$1")} as clear_index`, [sessionId]);
1771
+ return index <= asNumber(rows[0]?.["clear_index"]);
1772
+ };
1773
+ /**
1774
+ * A completion that does not hold its head: a stale attempt, or a retry
1775
+ * after an unknown acknowledgement, which replays the committed children.
1776
+ * Takes no lock.
1777
+ */
1778
+ const settleUnheld = async ({ query, sessionId, consumer, lane, index, attempt, events }) => {
1779
+ const superseded = { outcome: "superseded" };
1780
+ const { rows: cursorRows } = await query(`select latest.kind, latest.head, covering.completed_idx,
1781
+ covering.completed_attempt, covering.returned_event_ids
1782
+ from (
1783
+ select kind, head from a2_cursors
1784
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1785
+ order by epoch desc limit 1
1786
+ ) latest
1787
+ left join lateral (
1788
+ select completed_idx, completed_attempt, returned_event_ids
1789
+ from a2_cursors
1790
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1791
+ and epoch <= $4
1792
+ order by epoch desc limit 1
1793
+ ) covering on true`, [
1794
+ sessionId,
1795
+ cursorKeyOf(lane, index),
1796
+ consumer,
1797
+ index
1798
+ ]);
1799
+ const cursor = cursorRows[0];
1800
+ if (!cursor) {
1801
+ if (await hasEvent(query, sessionId, index)) return superseded;
1802
+ if (await clearedAt({
1803
+ query,
1804
+ sessionId,
1805
+ index
1806
+ })) return superseded;
1807
+ throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1808
+ }
1809
+ const ids = events.map((event) => event.id);
1810
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
1811
+ if (asNullableNumber(cursor["head"]) === index) return superseded;
1812
+ let completion;
1813
+ if (cursor["kind"] === "lane") {
1814
+ const { rows } = await query(`select completed_attempt, returned_event_ids from a2_lane_events
1815
+ where session_id = $1 and consumer = $2 and lane = $3 and idx = $4`, [
1816
+ sessionId,
1817
+ consumer,
1818
+ lane,
1819
+ index
1820
+ ]);
1821
+ completion = rows[0] && {
1822
+ attempt: asNullableNumber(rows[0]["completed_attempt"]),
1823
+ ids: rows[0]["returned_event_ids"]
1824
+ };
1825
+ } else if (asNullableNumber(cursor["completed_idx"]) === index) completion = {
1826
+ attempt: asNullableNumber(cursor["completed_attempt"]),
1827
+ ids: cursor["returned_event_ids"]
1828
+ };
1829
+ if (!completion || completion.attempt !== attempt) return superseded;
1830
+ const returnedIds = completion.ids === null || completion.ids === void 0 ? null : decodeReturnedEventIds(completion.ids);
1831
+ if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
1832
+ if (events.length === 0) return {
1833
+ outcome: "completed",
1834
+ events: []
1835
+ };
1836
+ const { rows: existing } = await query(`select session_id, idx, event_type, payload, event_id, created_at, cause
1837
+ from a2_events where session_id = $1 and event_id = any($2::text[])`, [sessionId, ids]);
1838
+ const byId = new Map(existing.map((row) => [row["event_id"], row]));
1839
+ const ordered = ids.map((id) => byId.get(id));
1840
+ if (ordered.some((row) => !row) && await clearedAt({
1841
+ query,
1842
+ sessionId,
1843
+ index
1844
+ })) return superseded;
1845
+ if (ordered.some((row) => {
1846
+ const cause = row ? decodeCause(row["cause"]) : null;
1847
+ return !row || cause?.consumer !== consumer || cause.lane !== lane || cause.index !== index || cause.attempt !== attempt;
1848
+ })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
1849
+ return {
1850
+ outcome: "completed",
1851
+ events: idempotentReplay(events, ordered.map(toStored))
1852
+ };
1853
+ };
1854
+ const store = {
1855
+ async append(sessionId, events, appendOptions) {
1856
+ const consumers = appendOptions?.consumers;
1857
+ const served = servedNames(consumers);
1858
+ if (events.length === 0) return wrap(async () => {
1859
+ const { rows } = await (await client()).query(`select ${pendingIn("$1", "$2")} as has_pending`, [sessionId, served]);
1860
+ return {
1861
+ events: [],
1862
+ hasPending: rows[0]?.["has_pending"] === true
1863
+ };
1864
+ });
1865
+ return wrap(async () => {
1866
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
1867
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
1868
+ const now = clock.now().getTime();
1869
+ const rows = events.map((event, offset) => ({
1870
+ ord: offset + 1,
1871
+ id: event.id ?? generateId(),
1872
+ type: event.type,
1873
+ payload: event.payload,
1874
+ cause: event.cause ?? null
1875
+ }));
1876
+ const causes = [...new Map(events.flatMap((event) => event.cause ? [[jsonb(event.cause), event.cause]] : [])).values()];
1877
+ const work = enqueue(events, consumers);
1878
+ const originIndex = appendOptions?.originIndex;
1879
+ const checked = (await run({
1880
+ sessionId,
1881
+ statements: [
1882
+ LOCK_SESSION,
1883
+ APPEND_CHECKS,
1884
+ causes.length > 0 && FENCE,
1885
+ suppliedIds.length > 0 && REUSE_IDS,
1886
+ INSERT_EVENTS,
1887
+ ...work.statements
1888
+ ],
1889
+ params: {
1890
+ ...work.params,
1891
+ served,
1892
+ ids: suppliedIds,
1893
+ count: events.length,
1894
+ origin: originIndex ?? null,
1895
+ causes: causes.map((cause, offset) => ({
1896
+ ord: offset + 1,
1897
+ consumer: cause.consumer,
1898
+ cursor_key: cursorKeyOf(cause.lane, cause.index),
1899
+ idx: cause.index,
1900
+ attempt: cause.attempt
1901
+ })),
1902
+ events: rows,
1903
+ now
1904
+ },
1905
+ abort: (reason) => {
1906
+ switch (reason["abort"]) {
1907
+ case "mixed": {
1908
+ const owned = asNumber(reason["owned"]);
1909
+ return new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`);
1910
+ }
1911
+ case "cleared": return sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
1912
+ case "superseded": return supersededAttemptError(sessionId, causes[asNumber(reason["ord"]) - 1]);
1913
+ case "held": return new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
1914
+ default: return new A2Error("STORE_UNAVAILABLE", "postgres store operation failed");
1915
+ }
1916
+ }
1917
+ })).find((row) => row["stop"] === "replay" || "base" in row);
1918
+ if (checked["stop"] === "replay") return {
1919
+ events: idempotentReplay(events, checked["events"].map(toStored)),
1920
+ hasPending: checked["has_pending"] === true
1921
+ };
1922
+ return {
1923
+ events: insertedRows({
1924
+ sessionId,
1925
+ events: rows,
1926
+ base: asNumber(checked["base"]),
1927
+ now
1928
+ }),
1929
+ hasPending: checked["has_pending"] === true || work.started
1930
+ };
1931
+ });
1932
+ },
1933
+ async wakeLog({ sessionId, consumer }) {
1934
+ const { log } = consumer;
1935
+ return wrap(async () => {
1936
+ const found = (await run({
1937
+ sessionId,
1938
+ statements: [
1939
+ READ_LOG,
1940
+ LOCK_SESSION,
1941
+ WAKE_LOG
1942
+ ],
1943
+ params: {
1944
+ cursorKey: cursorKeyOf(log.lane, 0),
1945
+ consumer: consumer.name,
1946
+ lane: log.lane,
1947
+ types: log.types,
1948
+ backfill: log.backfill,
1949
+ clearType: CLEAR_EVENT_TYPE
1950
+ }
1951
+ })).at(-1);
1952
+ if (found["empty"] === true) {
1953
+ const head = asNumber(found["head"]);
1954
+ return {
1955
+ cursor: idleLogCursor(consumer.name, log.lane, head),
1956
+ head
1957
+ };
1958
+ }
1959
+ const cursor = found["cursor"];
1960
+ const clearIndex = asNumber(found["stop"] === "unchanged" ? cursor["clear_index"] : found["clear_index"]);
1961
+ return {
1962
+ cursor: toCursor(cursor, clearIndex),
1963
+ head: asNumber(found["stop"] === "unchanged" ? cursor["session_head"] : found["head"])
1964
+ };
1965
+ });
1966
+ },
1967
+ async cursors(sessionId) {
1968
+ return wrap(async () => {
1969
+ const { rows } = await (await client()).query(`with boundary as materialized (
1970
+ select ${clearIndexOf("$1")} as clear_index
1971
+ )
1972
+ select cur.*, boundary.clear_index
1973
+ from a2_cursors cur, boundary
1974
+ where cur.session_id = $1
1975
+ and (
1976
+ cur.head > boundary.clear_index
1977
+ or (cur.kind <> 'event' and not exists (
1978
+ select 1 from a2_cursors later
1979
+ where later.session_id = cur.session_id
1980
+ and later.cursor_key = cur.cursor_key
1981
+ and later.consumer = cur.consumer
1982
+ and later.epoch > cur.epoch
1983
+ ) and (
1984
+ cur.kind = 'log'
1985
+ or (cur.head is null and cur.position > boundary.clear_index)
1986
+ ))
1987
+ )`, [sessionId]);
1988
+ return rows.map((row) => toCursor(row, asNumber(row["clear_index"]))).toSorted(byCursor);
1989
+ });
1990
+ },
1991
+ async read(sessionId, opts) {
1992
+ return wrap(async () => {
1993
+ const c = await client();
1994
+ const conditions = ["session_id = $1"];
1995
+ const params = [sessionId];
1996
+ if (opts?.afterIndex !== void 0) {
1997
+ params.push(opts.afterIndex);
1998
+ conditions.push(`idx > $${params.length}`);
1999
+ }
2000
+ if (opts?.throughIndex !== void 0) {
2001
+ params.push(opts.throughIndex);
2002
+ conditions.push(`idx <= $${params.length}`);
2003
+ }
2004
+ conditions.push(`idx >= ${clearIndexOf("$1")}`);
2005
+ const { rows } = await c.query(`select session_id, idx, event_type, payload, event_id, created_at, cause
2006
+ from a2_events where ${conditions.join(" and ")} order by idx`, params);
2007
+ return rows.map(toStored);
2008
+ });
2009
+ },
2010
+ async readHistory(sessionId, opts) {
2011
+ return wrap(async () => {
2012
+ if (opts?.throughIndex !== void 0 && opts.throughIndex <= (opts.afterIndex ?? 0)) return [];
2013
+ const c = await client();
2014
+ const conditions = ["session_id = $1"];
2015
+ const params = [sessionId];
2016
+ if (opts?.afterIndex !== void 0) {
2017
+ params.push(opts.afterIndex);
2018
+ conditions.push(`idx > $${params.length}`);
2019
+ }
2020
+ if (opts?.throughIndex !== void 0) {
2021
+ params.push(opts.throughIndex);
2022
+ conditions.push(`idx <= $${params.length}`);
2023
+ }
2024
+ conditions.push(`idx >= ${clearIndexOf("$1")}`);
2025
+ const { rows } = await c.query(`select session_id, idx, event_id, event_type, payload, created_at
2026
+ from a2_events where ${conditions.join(" and ")} order by idx`, params);
2027
+ return rows.map(toEvent);
2028
+ });
2029
+ },
2030
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude = [], consumers, maxFailures, through }) {
2031
+ return wrap(async () => {
2032
+ const now = clock.now().getTime();
2033
+ const results = await run({
2034
+ sessionId,
2035
+ statements: [
2036
+ CLAIM,
2037
+ RELEASE_DEAD,
2038
+ RETRY_AT
2039
+ ],
2040
+ params: {
2041
+ now,
2042
+ served: servedNames(consumers),
2043
+ excluded: exclude.map((ref) => ({
2044
+ consumer: ref.consumer,
2045
+ cursor_key: cursorKeyOf(ref.lane, ref.index)
2046
+ })),
2047
+ holder,
2048
+ expiresAt: expiresAtMs ?? now + ttlMs,
2049
+ maxFailures: maxFailures ?? null,
2050
+ through: through ?? null
2051
+ }
2052
+ });
2053
+ const rows = results.filter((row) => "e_idx" in row);
2054
+ const unseen = rows.filter((row) => row["e_idx"] === null);
2055
+ if (unseen.length > 0) {
2056
+ const { rows: events } = await (await client()).query(`select ${HEAD_COLUMNS} from a2_events event
2057
+ where event.session_id = $1 and event.idx = any($2::bigint[])`, [sessionId, unseen.map((row) => row["head"])]);
2058
+ const byIndex = new Map(events.map((event) => [asNumber(event["e_idx"]), event]));
2059
+ for (const row of unseen) Object.assign(row, byIndex.get(asNumber(row["head"])));
2060
+ }
2061
+ const heads = rows.filter((row) => row["e_idx"] !== null);
2062
+ if (heads.length > 0) {
2063
+ const events = heads.map((row) => {
2064
+ const head = toHead(row);
2065
+ const clearIndex = asNumber(row["clear_index"]);
2066
+ if (clearIndex > 0) head.clearIndex = clearIndex;
2067
+ return head;
2068
+ }).toSorted(byHead);
2069
+ return {
2070
+ outcome: "claimed",
2071
+ events,
2072
+ head: Math.max(...heads.map((row) => asNumber(row["session_head"])), ...events.map((event) => event.index))
2073
+ };
2074
+ }
2075
+ const retryAt = results.at(-1)["retry_at"];
2076
+ return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
2077
+ outcome: "busy",
2078
+ retryAt: new Date(asNumber(retryAt))
2079
+ };
2080
+ });
2081
+ },
2082
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
2083
+ if (claims.length === 0) return {
2084
+ renewed: [],
2085
+ superseded: []
2086
+ };
2087
+ return wrap(async () => {
2088
+ const c = await client();
2089
+ const now = clock.now().getTime();
2090
+ const expiresAt = expiresAtMs ?? now + ttlMs;
2091
+ const ordered = claims.toSorted((a, b) => a.index - b.index || compareText(a.consumer, b.consumer));
2092
+ const { rows } = await c.query(`with listed as (
2093
+ select * from unnest($3::text[], $4::text[], $5::bigint[], $6::int[])
2094
+ with ordinality as listed(consumer, cursor_key, idx, attempt, ord)
2095
+ ), renewed as (
2096
+ update a2_cursors cur set claim_expires_at = $7
2097
+ from listed
2098
+ where cur.session_id = $1
2099
+ and cur.cursor_key = listed.cursor_key
2100
+ and cur.consumer = listed.consumer
2101
+ and cur.head = listed.idx
2102
+ and listed.idx > ${clearIndexOf("$1")}
2103
+ and cur.attempt_count = listed.attempt
2104
+ and cur.failed_at is null
2105
+ and cur.claim_holder = $2
2106
+ and cur.claim_expires_at > $8
2107
+ returning cur.cursor_key, cur.consumer, cur.head
2108
+ )
2109
+ select listed.ord, latest.head, latest.position, latest.attempt_count,
2110
+ latest.epoch is not null as found,
2111
+ exists(
2112
+ select 1 from renewed
2113
+ where renewed.cursor_key = listed.cursor_key
2114
+ and renewed.consumer = listed.consumer
2115
+ and renewed.head = listed.idx
2116
+ ) as renewed,
2117
+ listed.idx <= ${clearIndexOf("$1")} as cleared
2118
+ from listed
2119
+ left join lateral (
2120
+ select cur.epoch, cur.head, cur.position, cur.attempt_count
2121
+ from a2_cursors cur
2122
+ where cur.session_id = $1
2123
+ and cur.cursor_key = listed.cursor_key
2124
+ and cur.consumer = listed.consumer
2125
+ order by cur.epoch desc limit 1
2126
+ ) latest on true
2127
+ order by listed.ord`, [
2128
+ sessionId,
2129
+ holder,
2130
+ ordered.map((claim) => claim.consumer),
2131
+ ordered.map((claim) => cursorKeyOf(claim.lane, claim.index)),
2132
+ ordered.map((claim) => claim.index),
2133
+ ordered.map((claim) => claim.attempt),
2134
+ expiresAt,
2135
+ now
2136
+ ]);
2137
+ const renewed = [];
2138
+ const superseded = [];
2139
+ for (const row of rows) {
2140
+ const { attempt, ...ref } = ordered[asNumber(row["ord"]) - 1];
2141
+ if (row["renewed"] === true) {
2142
+ renewed.push(ref);
2143
+ continue;
2144
+ }
2145
+ if (row["cleared"] === true) {
2146
+ superseded.push(ref);
2147
+ continue;
2148
+ }
2149
+ if (row["found"] !== true) continue;
2150
+ const head = asNullableNumber(row["head"]);
2151
+ if (head !== ref.index && asNumber(row["position"]) < ref.index) continue;
2152
+ if (head !== ref.index || asNumber(row["attempt_count"]) > attempt) superseded.push(ref);
2153
+ }
2154
+ return {
2155
+ renewed,
2156
+ superseded
2157
+ };
2158
+ });
2159
+ },
2160
+ async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
2161
+ return wrap(async () => {
2162
+ const ids = events.map((event) => event.id);
2163
+ const duplicated = new Set(ids).size !== ids.length;
2164
+ const cause = {
2165
+ consumer,
2166
+ lane,
2167
+ index,
2168
+ attempt
2169
+ };
2170
+ const now = clock.now().getTime();
2171
+ const rows = events.map((event, offset) => ({
2172
+ ord: offset + 1,
2173
+ id: event.id,
2174
+ type: event.type,
2175
+ payload: event.payload,
2176
+ cause
2177
+ }));
2178
+ const work = events.length > 0 && !duplicated ? enqueue(events, consumers) : void 0;
2179
+ const results = await run({
2180
+ sessionId,
2181
+ statements: duplicated ? [HOLD] : work ? [
2182
+ HOLD,
2183
+ LOCK_SESSION,
2184
+ RETURN_CHECKS,
2185
+ REUSE_IDS,
2186
+ INSERT_EVENTS,
2187
+ ...work.statements,
2188
+ COMPLETE_APPENDED
2189
+ ] : [
2190
+ HOLD,
2191
+ LOCK_IF_IDLE,
2192
+ COMPLETE
2193
+ ],
2194
+ params: {
2195
+ ...work?.params,
2196
+ ...retypeOf(consumer, consumers),
2197
+ cursorKey: cursorKeyOf(lane, index),
2198
+ consumer,
2199
+ index,
2200
+ attempt,
2201
+ ids,
2202
+ count: events.length,
2203
+ events: rows,
2204
+ now,
2205
+ clearType: CLEAR_EVENT_TYPE
2206
+ },
2207
+ abort: (reason) => reason["abort"] === "owned" ? new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id") : new A2Error("STORE_UNAVAILABLE", reason["abort"] === "held" ? `an event id being reused is still held by cleared work in session '${sessionId}'` : "postgres store operation failed")
2208
+ });
2209
+ const held = results[0];
2210
+ if (held["stop"] === "superseded") return { outcome: "superseded" };
2211
+ if (held["stop"] === "unheld") {
2212
+ const c = await client();
2213
+ return settleUnheld({
2214
+ query: (text, values) => c.query(text, values),
2215
+ sessionId,
2216
+ consumer,
2217
+ lane,
2218
+ index,
2219
+ attempt,
2220
+ events
2221
+ });
2222
+ }
2223
+ if (duplicated) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
2224
+ if (results.at(-1)["stop"] === "superseded") return { outcome: "superseded" };
2225
+ const checked = results.find((row) => "base" in row);
2226
+ return {
2227
+ outcome: "completed",
2228
+ events: checked ? insertedRows({
2229
+ sessionId,
2230
+ events: rows,
2231
+ base: asNumber(checked["base"]),
2232
+ now
2233
+ }) : []
2234
+ };
2235
+ });
2236
+ },
2237
+ async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
2238
+ return wrap(() => withTx(async (query) => {
2239
+ const now = clock.now().getTime();
2240
+ const cursorKey = cursorKeyOf(lane, index);
2241
+ const { rows } = await query(`with current as materialized (
2242
+ select cur.* from a2_cursors cur
2243
+ where cur.session_id = $1 and cur.cursor_key = $2
2244
+ and cur.consumer = $3 and cur.head = $4
2245
+ and cur.head > ${clearIndexOf("$1")}
2246
+ for update of cur
2247
+ ), updated as (
2248
+ update a2_cursors cur set
2249
+ failure_count = cur.failure_count + 1,
2250
+ last_error = $6,
2251
+ last_failed_at = $8,
2252
+ last_failed_attempt = $5,
2253
+ failed_at = case
2254
+ when cur.failure_count + 1 >= $7 then $8
2255
+ else cur.failed_at
2256
+ end,
2257
+ claim_holder = null,
2258
+ claim_expires_at = case
2259
+ when cur.failure_count + 1 >= $7 then null
2260
+ else $9::bigint
2261
+ end
2262
+ from current
2263
+ where cur.session_id = $1 and cur.cursor_key = $2
2264
+ and cur.consumer = $3 and cur.epoch = current.epoch
2265
+ and current.attempt_count = $5
2266
+ and current.failed_at is null
2267
+ and current.claim_holder is not null
2268
+ returning cur.failure_count, cur.failed_at
2269
+ )
2270
+ select case
2271
+ when $4 <= ${clearIndexOf("$1")} then 'cleared'
2272
+ when not exists (select 1 from current) then 'missing'
2273
+ when exists (
2274
+ select 1 from updated where failed_at is not null
2275
+ ) then 'dead_lettered'
2276
+ when exists (select 1 from updated) then 'failed'
2277
+ when exists (
2278
+ select 1 from current where attempt_count <> $5
2279
+ ) then 'superseded'
2280
+ when exists (
2281
+ select 1 from current
2282
+ where claim_holder is null and last_failed_attempt = $5
2283
+ and failed_at is null
2284
+ ) then 'failed'
2285
+ when exists (
2286
+ select 1 from current where failed_at is not null
2287
+ ) then 'dead_lettered'
2288
+ else 'superseded'
2289
+ end as outcome,
2290
+ coalesce(
2291
+ (select failure_count from updated),
2292
+ (select failure_count from current)
2293
+ ) as failure_count`, [
2294
+ sessionId,
2295
+ cursorKey,
2296
+ consumer,
2297
+ index,
2298
+ attempt,
2299
+ error,
2300
+ maxFailures,
2301
+ now,
2302
+ retryAfterMs === void 0 ? null : now + retryAfterMs
2303
+ ]);
2304
+ const row = rows[0];
2305
+ if (row?.["outcome"] === "cleared") return {
2306
+ outcome: "superseded",
2307
+ failureCount: 0
2308
+ };
2309
+ if (!row || row["outcome"] === "missing") {
2310
+ const { rows: cursorRows } = await query(`select 1 from a2_cursors
2311
+ where session_id = $1 and cursor_key = $2 and consumer = $3`, [
2312
+ sessionId,
2313
+ cursorKey,
2314
+ consumer
2315
+ ]);
2316
+ if (cursorRows.length > 0 || await hasEvent(query, sessionId, index) || await clearedAt({
2317
+ query,
2318
+ sessionId,
2319
+ index
2320
+ })) return {
2321
+ outcome: "superseded",
2322
+ failureCount: 0
2323
+ };
2324
+ throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
2325
+ }
2326
+ if (row["outcome"] === "dead_lettered") await releasePins({
2327
+ query,
2328
+ sessionId,
2329
+ consumer,
2330
+ cursorKey,
2331
+ index
2332
+ });
2333
+ return {
2334
+ outcome: row["outcome"],
2335
+ failureCount: asNumber(row["failure_count"])
2336
+ };
2337
+ }));
2338
+ },
2339
+ async deadLetters(sessionId) {
2340
+ return wrap(async () => {
2341
+ const { rows } = await (await client()).query(`select cur.*, ${HEAD_COLUMNS}
2342
+ from a2_cursors cur
2343
+ join a2_events event
2344
+ on event.session_id = cur.session_id and event.idx = cur.head
2345
+ where cur.session_id = $1
2346
+ and cur.failed_at is not null
2347
+ and cur.head > ${clearIndexOf("$1")}`, [sessionId]);
2348
+ return rows.map(toHead).toSorted(byHead);
2349
+ });
2350
+ },
2351
+ async redrive({ sessionId, consumer, index, action, consumers }) {
2352
+ return wrap(async () => {
2353
+ const results = await run({
2354
+ sessionId,
2355
+ statements: [
2356
+ action === "skip" && LOCK_SESSION,
2357
+ LOCK_DEAD,
2358
+ action === "skip" ? SKIP_DEAD : RETRY_DEAD,
2359
+ PENDING
2360
+ ],
2361
+ params: {
2362
+ ...retypeOf(consumer, consumers),
2363
+ consumer,
2364
+ index,
2365
+ served: servedNames(consumers),
2366
+ clearType: CLEAR_EVENT_TYPE
2367
+ }
2368
+ });
2369
+ return {
2370
+ redriven: results.find((row) => "redriven" in row)?.["redriven"] === true,
2371
+ hasPending: results.at(-1)["has_pending"] === true
2372
+ };
2373
+ });
2374
+ },
2375
+ async readState(sessionId, reducerName, stateOptions) {
2376
+ return wrap(async () => (await readStateBatch([{
2377
+ sessionId,
2378
+ ...stateOptions?.throughIndex === void 0 ? {} : { throughIndex: stateOptions.throughIndex },
2379
+ ...stateOptions?.snapshotThroughIndex === void 0 ? {} : { snapshotThroughIndex: stateOptions.snapshotThroughIndex }
2380
+ }], reducerName))[0]);
2381
+ },
2382
+ async readStates(requests, reducerName) {
2383
+ return wrap(() => readStateBatch(requests, reducerName));
2384
+ },
2385
+ async putSnapshots(sessionId, reducerName, writes) {
2386
+ await wrap(() => withTx(async (query) => {
2387
+ const requested = [...new Map(writes.map((write) => [write.index, write])).values()].toSorted((a, b) => a.index - b.index);
2388
+ if (requested.length === 0) return;
2389
+ await query("select pg_advisory_xact_lock(hashtext($1), hashtext($2))", [sessionId, reducerName]);
2390
+ const { rows: headRows } = await query(`select boundary.clear_index, head.up_to_index, head.state, head.updated_at
2391
+ from (select ${clearIndexOf("$1")} as clear_index) boundary
2392
+ left join lateral (
2393
+ select up_to_index, state, updated_at from a2_snapshots
2394
+ where session_id = $1 and reducer_name = $2
2395
+ for update
2396
+ ) head on true`, [sessionId, reducerName]);
2397
+ const clearIndex = asNumber(headRows[0]?.["clear_index"]);
2398
+ const normalized = requested.filter((write) => write.index >= clearIndex);
2399
+ if (normalized.length === 0) return;
2400
+ const currentHead = headRows[0]?.["up_to_index"] == null ? void 0 : headRows[0];
2401
+ const requestedPins = normalized.flatMap((write) => (write.pins ?? []).map((pin) => ({
2402
+ eventIndex: pin.index,
2403
+ consumer: pin.consumer,
2404
+ cursorKey: cursorKeyOf(pin.lane, pin.index),
2405
+ snapshotIndex: write.index
2406
+ })));
2407
+ if (requestedPins.length > 0) {
2408
+ const heads = [...new Map(requestedPins.map((pin) => [`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`, pin])).values()];
2409
+ const { rows: live } = await query(`select cur.consumer, cur.cursor_key, cur.head
2410
+ from a2_cursors cur
2411
+ join unnest($2::text[], $3::text[], $4::bigint[])
2412
+ as pin(consumer, cursor_key, idx)
2413
+ on cur.consumer = pin.consumer
2414
+ and cur.cursor_key = pin.cursor_key
2415
+ and cur.head = pin.idx
2416
+ where cur.session_id = $1 and cur.head > $5
2417
+ and cur.failed_at is null
2418
+ order by cur.cursor_key, cur.consumer
2419
+ for share of cur`, [
2420
+ sessionId,
2421
+ heads.map((pin) => pin.consumer),
2422
+ heads.map((pin) => pin.cursorKey),
2423
+ heads.map((pin) => pin.eventIndex),
2424
+ clearIndex
2425
+ ]);
2426
+ const unfinished = new Set(live.map((row) => `${String(row["consumer"])}\u0000${String(row["cursor_key"])}\u0000${asNumber(row["head"])}`));
2427
+ const accepted = requestedPins.filter((pin) => unfinished.has(`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`));
2428
+ if (accepted.length > 0) await query(`insert into a2_snapshot_pins
2429
+ (session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
2430
+ select $1, pin."eventIndex", pin.consumer, pin."cursorKey",
2431
+ $2, pin."snapshotIndex"
2432
+ from jsonb_to_recordset($3::jsonb) as pin(
2433
+ "eventIndex" bigint,
2434
+ consumer text,
2435
+ "cursorKey" text,
2436
+ "snapshotIndex" bigint
2437
+ )
2438
+ on conflict do nothing`, [
2439
+ sessionId,
2440
+ reducerName,
2441
+ jsonb(accepted)
2442
+ ]);
2443
+ }
2444
+ const latestWrite = normalized.at(-1);
2445
+ const writesHead = !currentHead || latestWrite.index >= asNumber(currentHead["up_to_index"]);
2446
+ const advancesHead = !currentHead || latestWrite.index > asNumber(currentHead["up_to_index"]);
2447
+ const finalHeadIndex = advancesHead ? latestWrite.index : asNumber(currentHead["up_to_index"]);
2448
+ const { rows: pinRows } = await query(`select distinct up_to_index from a2_snapshot_pins
2449
+ where session_id = $1 and reducer_name = $2
2450
+ and up_to_index = any($3::bigint[])`, [
2451
+ sessionId,
2452
+ reducerName,
2453
+ [.../* @__PURE__ */ new Set([...normalized.map((write) => write.index), ...currentHead ? [asNumber(currentHead["up_to_index"])] : []])]
2454
+ ]);
2455
+ const pinned = new Set(pinRows.map((row) => asNumber(row["up_to_index"])));
2456
+ const historical = /* @__PURE__ */ new Map();
2457
+ if (advancesHead && currentHead && asNumber(currentHead["up_to_index"]) >= clearIndex && pinned.has(asNumber(currentHead["up_to_index"]))) {
2458
+ const index = asNumber(currentHead["up_to_index"]);
2459
+ historical.set(index, {
2460
+ index,
2461
+ state: currentHead["state"],
2462
+ updatedAt: asNumber(currentHead["updated_at"])
2463
+ });
2464
+ }
2465
+ const updatedAt = clock.now().getTime();
2466
+ for (const write of normalized) if (write.index < finalHeadIndex && pinned.has(write.index)) historical.set(write.index, {
2467
+ index: write.index,
2468
+ state: write.state,
2469
+ updatedAt
2470
+ });
2471
+ if (historical.size > 0) await query(`insert into a2_snapshot_history
2472
+ (session_id, reducer_name, up_to_index, state, updated_at)
2473
+ select $1, $2, snapshot.index, snapshot.state, snapshot."updatedAt"
2474
+ from jsonb_to_recordset($3::jsonb) as snapshot(
2475
+ index bigint,
2476
+ state jsonb,
2477
+ "updatedAt" bigint
2478
+ )
2479
+ on conflict (session_id, reducer_name, up_to_index) do update set
2480
+ state = excluded.state,
2481
+ updated_at = excluded.updated_at`, [
2482
+ sessionId,
2483
+ reducerName,
2484
+ jsonb([...historical.values()])
2485
+ ]);
2486
+ if (writesHead) {
2487
+ await query(`insert into a2_snapshots
2488
+ (session_id, reducer_name, up_to_index, state, updated_at)
2489
+ values ($1, $2, $3, $4::jsonb, $5)
2490
+ on conflict (session_id, reducer_name) do update set
2491
+ up_to_index = excluded.up_to_index,
2492
+ state = excluded.state,
2493
+ updated_at = excluded.updated_at
2494
+ where excluded.up_to_index >= a2_snapshots.up_to_index`, [
2495
+ sessionId,
2496
+ reducerName,
2497
+ latestWrite.index,
2498
+ jsonb(latestWrite.state),
2499
+ clock.now().getTime()
2500
+ ]);
2501
+ await query(`delete from a2_snapshot_history
2502
+ where session_id = $1 and reducer_name = $2 and up_to_index = $3`, [
2503
+ sessionId,
2504
+ reducerName,
2505
+ latestWrite.index
2506
+ ]);
2507
+ }
2508
+ }));
2509
+ },
2510
+ presence: {
2511
+ async set(ns, participant, values, meta) {
2512
+ const entries = Object.entries(values);
2513
+ if (entries.length === 0) return;
2514
+ await wrap(async () => {
2515
+ const atMs = meta.at.getTime();
2516
+ const nowMs = clock.now().getTime();
2517
+ const expiresAtMs = nowMs + meta.ttlMs;
2518
+ const applied = nullProtoRecord();
2519
+ const write = async (query) => {
2520
+ for (const [field, value] of entries) if ((await query(`insert into a2_presence
2521
+ (ns, participant, field, value, seen, at, expires_at)
2522
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7)
2523
+ on conflict (ns, participant, field) do update set
2524
+ value = excluded.value,
2525
+ seen = excluded.seen,
2526
+ at = excluded.at,
2527
+ expires_at = excluded.expires_at
2528
+ where a2_presence.at <= excluded.at
2529
+ or a2_presence.expires_at <= $8${notifications ? " returning field" : ""}`, [
2530
+ ns,
2531
+ participant,
2532
+ field,
2533
+ jsonb(value),
2534
+ meta.seen,
2535
+ atMs,
2536
+ expiresAtMs,
2537
+ nowMs
2538
+ ])).rows.length) applied[field] = structuredClone(value);
2539
+ };
2540
+ if (notifications) await withTx(write);
2541
+ else {
2542
+ const c = await client();
2543
+ await write((text, parameters) => c.query(text, parameters));
2544
+ }
2545
+ if (Object.keys(applied).length) {
2546
+ const patch = {
2547
+ participant,
2548
+ values: applied,
2549
+ seen: meta.seen,
2550
+ at: new Date(atMs)
2551
+ };
2552
+ const subscription = presenceSubscriptions.get(ns);
2553
+ subscription?.poll.update(patch);
2554
+ for (const listener of subscription?.listeners ?? []) listener(patch);
2555
+ }
2556
+ });
2557
+ },
2558
+ async read(ns) {
2559
+ return wrap(async () => {
2560
+ const c = await client();
2561
+ const now = clock.now().getTime();
2562
+ const { rows } = await c.query(`with swept as (
2563
+ delete from a2_presence
2564
+ where ns = $1 and expires_at <= $2
2565
+ )
2566
+ select participant, field, value, seen, at, expires_at
2567
+ from a2_presence
2568
+ where ns = $1 and expires_at > $2 and value <> 'null'::jsonb
2569
+ order by participant, field`, [ns, now]);
2570
+ return rows.map((row) => ({
2571
+ participant: row["participant"],
2572
+ field: row["field"],
2573
+ value: row["value"],
2574
+ seen: asNumber(row["seen"]),
2575
+ at: new Date(asNumber(row["at"])),
2576
+ expiresAt: new Date(asNumber(row["expires_at"]))
2577
+ }));
2578
+ });
2579
+ }
2580
+ },
2581
+ stream(sessionId, opts) {
2582
+ const readAfter = (afterIndex) => wrap(async () => {
2583
+ const { rows } = await (await client()).query(`select session_id, idx, event_type, payload, event_id, created_at
2584
+ from a2_events
2585
+ where session_id = $1 and idx > $2
2586
+ and idx >= ${clearIndexOf("$1")}
2587
+ order by idx`, [sessionId, afterIndex]);
2588
+ return rows.map(toEvent);
2589
+ });
2590
+ if (notifications) return notifications.stream({
2591
+ channel: postgresNotificationChannel({
2592
+ kind: "events",
2593
+ sessionId
2594
+ }),
2595
+ read: readAfter,
2596
+ startAfter: opts?.startAfter ?? 0
2597
+ });
2598
+ return pollingStream(readAfter, opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
2599
+ },
2600
+ async clear({ sessionId, ifLastIndex, cause }) {
2601
+ return wrap(() => withTx(async (query) => {
2602
+ await lockSession(query, sessionId);
2603
+ const { rows: headRows } = await query(`select coalesce(max(idx), 0) as head,
2604
+ ${clearIndexOf("$1")} as clear_index
2605
+ from a2_events where session_id = $1`, [sessionId]);
2606
+ const head = asNumber(headRows[0]?.["head"]);
2607
+ const clearIndex = asNumber(headRows[0]?.["clear_index"]);
2608
+ if (cause) await assertCurrentCause({
2609
+ query,
2610
+ sessionId,
2611
+ cause,
2612
+ clearIndex,
2613
+ lock: "update"
2614
+ });
2615
+ if (ifLastIndex !== void 0 && clearIndex === ifLastIndex + 1) return {
2616
+ cleared: true,
2617
+ index: head,
2618
+ clearIndex
2619
+ };
2620
+ if (ifLastIndex !== void 0 && ifLastIndex !== head) return {
2621
+ cleared: false,
2622
+ index: head,
2623
+ clearIndex
2624
+ };
2625
+ if (head === 0 || clearIndex === head) return {
2626
+ cleared: true,
2627
+ index: head,
2628
+ clearIndex
2629
+ };
2630
+ const marker = head + 1;
2631
+ await query(`insert into a2_events
2632
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
2633
+ values ($1, $2, $3, 'null'::jsonb, $4, $5, null)`, [
2634
+ sessionId,
2635
+ marker,
2636
+ CLEAR_EVENT_TYPE,
2637
+ generateId(),
2638
+ clock.now().getTime()
2639
+ ]);
2640
+ await query(`with released as (
2641
+ update a2_cursors
2642
+ set head = null, ${FRESH_DISPATCH}
2643
+ where session_id = $1 and cursor_key = $3 and consumer = $4
2644
+ and head = $5
2645
+ )
2646
+ insert into a2_clears (session_id, clear_index) values ($1, $2)
2647
+ on conflict (session_id) do update set clear_index = excluded.clear_index`, [
2648
+ sessionId,
2649
+ marker,
2650
+ cause ? cursorKeyOf(cause.lane, cause.index) : null,
2651
+ cause?.consumer ?? null,
2652
+ cause?.index ?? null
2653
+ ]);
2654
+ return {
2655
+ cleared: true,
2656
+ index: marker,
2657
+ clearIndex: marker
2658
+ };
2659
+ }));
2660
+ },
2661
+ async reclaim(sessionId) {
2662
+ return wrap(() => withTx(async (query) => {
2663
+ const { rows } = await query(`with boundary as materialized (
2664
+ select ${clearIndexOf("$1")} as clear_index
2665
+ ), batch as materialized (
2666
+ select idx from a2_events
2667
+ where session_id = $1
2668
+ and idx < (select clear_index from boundary)
2669
+ order by idx
2670
+ limit $2
2671
+ for update skip locked
2672
+ ), deleted as (
2673
+ delete from a2_events event using batch
2674
+ where event.session_id = $1 and event.idx = batch.idx
2675
+ returning event.idx
2676
+ ), pins as (
2677
+ delete from a2_snapshot_pins pin using deleted
2678
+ where pin.session_id = $1 and pin.event_index = deleted.idx
2679
+ ), lanes as materialized (
2680
+ -- Each lane's queue through the batch, by the lane's own key.
2681
+ select lane.ctid from a2_cursors cur
2682
+ join a2_lane_events lane
2683
+ on lane.session_id = cur.session_id
2684
+ and lane.consumer = cur.consumer and lane.lane = cur.lane
2685
+ where cur.session_id = $1 and cur.kind = 'lane'
2686
+ and lane.idx <= (select max(idx) from batch)
2687
+ for update of lane skip locked
2688
+ ), gone_lanes as (
2689
+ delete from a2_lane_events target using lanes
2690
+ where target.ctid = lanes.ctid
2691
+ ), singles as materialized (
2692
+ select cur.ctid from a2_cursors cur
2693
+ join batch on cur.cursor_key = '#' || batch.idx
2694
+ where cur.session_id = $1 and cur.kind = 'event'
2695
+ for update of cur skip locked
2696
+ ), gone_singles as (
2697
+ delete from a2_cursors target using singles
2698
+ where target.ctid = singles.ctid
2699
+ )
2700
+ select (select count(*) from deleted) as deleted,
2701
+ (select clear_index from boundary) as clear_index`, [sessionId, RECLAIM_BATCH]);
2702
+ const deleted = asNumber(rows[0]?.["deleted"]);
2703
+ const clearIndex = asNumber(rows[0]?.["clear_index"]);
2704
+ if (clearIndex === 0) return {
2705
+ deleted: 0,
2706
+ done: true
2707
+ };
2708
+ const { rows: remaining } = await query("select 1 from a2_events where session_id = $1 and idx < $2 limit 1", [sessionId, clearIndex]);
2709
+ if (remaining.length > 0) return {
2710
+ deleted,
2711
+ done: false
2712
+ };
2713
+ const { rows: swept } = await query(`with lanes as materialized (
2714
+ select ctid from a2_lane_events
2715
+ where session_id = $1 and idx < $2
2716
+ for update skip locked
2717
+ ), cursors as materialized (
2718
+ select cur.ctid from a2_cursors cur
2719
+ where ${SUPERSEDED_CURSOR}
2720
+ for update skip locked
2721
+ ), pins as materialized (
2722
+ select ctid from a2_snapshot_pins
2723
+ where session_id = $1 and up_to_index < $2
2724
+ for update skip locked
2725
+ ), history as materialized (
2726
+ select ctid from a2_snapshot_history
2727
+ where session_id = $1 and up_to_index < $2
2728
+ for update skip locked
2729
+ ), heads as materialized (
2730
+ select ctid from a2_snapshots
2731
+ where session_id = $1 and up_to_index < $2
2732
+ for update skip locked
2733
+ ), gone_lanes as (
2734
+ delete from a2_lane_events target using lanes
2735
+ where target.ctid = lanes.ctid
2736
+ ), gone_cursors as (
2737
+ delete from a2_cursors target using cursors
2738
+ where target.ctid = cursors.ctid
2739
+ ), gone_pins as (
2740
+ delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
2741
+ ), gone_history as (
2742
+ delete from a2_snapshot_history target using history
2743
+ where target.ctid = history.ctid
2744
+ ), gone_heads as (
2745
+ delete from a2_snapshots target using heads where target.ctid = heads.ctid
2746
+ )
2747
+ select (select count(*) from a2_lane_events
2748
+ where session_id = $1 and idx < $2)
2749
+ + (select count(*) from a2_cursors cur
2750
+ where ${SUPERSEDED_CURSOR})
2751
+ + (select count(*) from a2_snapshot_pins
2752
+ where session_id = $1 and up_to_index < $2)
2753
+ + (select count(*) from a2_snapshot_history
2754
+ where session_id = $1 and up_to_index < $2)
2755
+ + (select count(*) from a2_snapshots
2756
+ where session_id = $1 and up_to_index < $2)
2757
+ - (select count(*) from lanes)
2758
+ - (select count(*) from cursors)
2759
+ - (select count(*) from pins)
2760
+ - (select count(*) from history)
2761
+ - (select count(*) from heads) as skipped`, [sessionId, clearIndex]);
2762
+ return {
2763
+ deleted,
2764
+ done: asNumber(swept[0]?.["skipped"]) === 0
2765
+ };
2766
+ }));
2767
+ },
2768
+ async close() {
2769
+ if (closed) return;
2770
+ closed = true;
2771
+ for (const subscription of presenceSubscriptions.values()) subscription.stop();
2772
+ presenceSubscriptions.clear();
2773
+ await notifications?.close();
2774
+ const opened = await connection.peek()?.catch(() => void 0);
2775
+ await (injected ?? opened?.lease)?.release();
2776
+ }
2777
+ };
2778
+ if (notifications) {
2779
+ const presence = store.presence;
2780
+ const read = presence.read;
2781
+ presence.read = async (ns) => {
2782
+ const subscription = presenceSubscriptions.get(ns);
2783
+ await subscription?.ready;
2784
+ await subscription?.poll.ready;
2785
+ return subscription ? subscription.poll.snapshot(() => read(ns)) : read(ns);
2786
+ };
2787
+ presence.subscribe = (ns, onPatch) => {
2788
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
2789
+ let subscription = presenceSubscriptions.get(ns);
2790
+ if (!subscription) {
2791
+ const listeners = /* @__PURE__ */ new Set();
2792
+ let dirty = false;
2793
+ let ended = false;
2794
+ let waiting;
2795
+ const lease = notifications.watch({
2796
+ channel: postgresNotificationChannel({
2797
+ kind: "presence",
2798
+ sessionId: ns
2799
+ }),
2800
+ notify: (source) => {
2801
+ if (source === instanceId) return;
2802
+ dirty = true;
2803
+ waiting?.cancel();
2804
+ }
2805
+ });
2806
+ const poll = pollPresence({
2807
+ read: async () => {
2808
+ await lease.ready;
2809
+ if (ended || closed) return [];
2810
+ dirty = false;
2811
+ return read(ns);
2812
+ },
2813
+ deliver: (patch) => {
2814
+ for (const listener of listeners) listener(patch);
2815
+ },
2816
+ sleep: () => {
2817
+ waiting = defaultSleep(dirty ? 0 : NOTIFY_TIMINGS.safetyReadMs);
2818
+ return waiting;
2819
+ }
2820
+ });
2821
+ lease.ready.catch(() => poll.stop());
2822
+ subscription = {
2823
+ listeners,
2824
+ ready: lease.ready,
2825
+ poll,
2826
+ stop() {
2827
+ ended = true;
2828
+ poll.stop();
2829
+ lease.stop();
2830
+ waiting?.cancel();
2831
+ }
2832
+ };
2833
+ presenceSubscriptions.set(ns, subscription);
2834
+ }
2835
+ subscription.listeners.add(onPatch);
2836
+ let stopped = false;
2837
+ return () => {
2838
+ if (stopped) return;
2839
+ stopped = true;
2840
+ subscription.listeners.delete(onPatch);
2841
+ if (subscription.listeners.size === 0) {
2842
+ presenceSubscriptions.delete(ns);
2843
+ subscription.stop();
2844
+ }
2845
+ };
2846
+ };
2847
+ }
2848
+ return store;
2849
+ }
2850
+ //#endregion
3
2851
  //#region src/store/postgres-db.ts
4
2852
  const SCHEMA_FILE = "node_modules/@vercel/board/sql/postgres/schema.sql";
5
2853
  /** The board's own error for tables that aren't there: the fix is a file to apply. */