experimental-a2 0.6.0 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +20 -17
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-B2XNevQA.js} +830 -131
  27. package/dist/server-B2XNevQA.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
  29. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-RJO35BMj.d.ts} +24 -62
  34. package/dist/store-RJO35BMj.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +80 -78
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.d.ts.map +1 -1
  43. package/dist/store-postgres.js +231 -182
  44. package/dist/store-postgres.js.map +1 -1
  45. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
  46. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  47. package/dist/store-redis-http.d.ts +1 -1
  48. package/dist/store-redis-http.js +3 -4
  49. package/dist/store-redis-http.js.map +1 -1
  50. package/dist/store-redis.d.ts +1 -1
  51. package/dist/store-redis.js +4 -5
  52. package/dist/store-redis.js.map +1 -1
  53. package/dist/store-sqlite.d.ts +1 -1
  54. package/dist/store-sqlite.d.ts.map +1 -1
  55. package/dist/store-sqlite.js +104 -91
  56. package/dist/store-sqlite.js.map +1 -1
  57. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  58. package/dist/wire-B6te_wns.js.map +1 -0
  59. package/docs/concepts/02-handlers.mdx +4 -0
  60. package/docs/concepts/04-state.mdx +57 -9
  61. package/docs/guides/03-react.mdx +20 -28
  62. package/docs/guides/05-production.mdx +9 -11
  63. package/docs/guides/06-ai-agents.mdx +10 -15
  64. package/docs/guides/09-presence.mdx +14 -19
  65. package/docs/guides/10-transports.mdx +104 -86
  66. package/docs/reference/01-api.mdx +182 -293
  67. package/docs/reference/02-errors.mdx +5 -7
  68. package/package.json +1 -14
  69. package/src/ai-server.ts +36 -15
  70. package/src/client.ts +2 -2
  71. package/src/errors.ts +1 -0
  72. package/src/internal.ts +3 -62
  73. package/src/push-envelope.ts +24 -21
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +315 -312
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +138 -101
  81. package/src/store-postgres.ts +355 -238
  82. package/src/store-redis-core.ts +247 -237
  83. package/src/store-redis-http.ts +1 -2
  84. package/src/store-redis.ts +1 -2
  85. package/src/store-sqlite.ts +191 -153
  86. package/src/store.ts +24 -66
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-CdMqi7mC.d.ts +0 -202
  99. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  100. package/dist/client-Dj5d3SP_.js +0 -1026
  101. package/dist/client-Dj5d3SP_.js.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. package/src/testing.ts +0 -185
@@ -31,6 +31,7 @@ import {
31
31
  type EventCause,
32
32
  type IdSource,
33
33
  type StoredEvent,
34
+ type StoreStateReadRequest,
34
35
  type StoreStateRead,
35
36
  } from './store.ts'
36
37
 
@@ -120,6 +121,26 @@ create table if not exists a2_snapshots (
120
121
  primary key (session_id, reducer_name)
121
122
  );
122
123
 
124
+ create table if not exists a2_snapshot_history (
125
+ session_id text not null,
126
+ reducer_name text not null,
127
+ up_to_index bigint not null,
128
+ state jsonb not null,
129
+ updated_at bigint not null,
130
+ primary key (session_id, reducer_name, up_to_index)
131
+ );
132
+
133
+ create table if not exists a2_snapshot_pins (
134
+ session_id text not null,
135
+ event_index bigint not null,
136
+ reducer_name text not null,
137
+ up_to_index bigint not null,
138
+ primary key (session_id, event_index, reducer_name, up_to_index)
139
+ );
140
+
141
+ create index if not exists a2_snapshot_pins_checkpoint
142
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
143
+
123
144
  create table if not exists a2_presence (
124
145
  ns text not null,
125
146
  participant text not null,
@@ -287,6 +308,103 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
287
308
  })
288
309
  const client = connection.get
289
310
 
311
+ const readStateBatch = async (
312
+ requests: readonly StoreStateReadRequest[],
313
+ reducerName: string,
314
+ ): Promise<StoreStateRead[]> => {
315
+ if (requests.length === 0) return []
316
+ const c = await client()
317
+ const input = requests.map((request, position) => ({
318
+ position,
319
+ sessionId: request.sessionId,
320
+ throughIndex: request.throughIndex ?? null,
321
+ snapshotThroughIndex:
322
+ request.snapshotThroughIndex ?? request.throughIndex ?? null,
323
+ }))
324
+ const { rows } = await c.query(
325
+ `with request as materialized (
326
+ select * from jsonb_to_recordset($1::jsonb) as value(
327
+ position integer,
328
+ "sessionId" text,
329
+ "throughIndex" bigint,
330
+ "snapshotThroughIndex" bigint
331
+ )
332
+ ), selected as materialized (
333
+ select request.position,
334
+ request."sessionId" as session_id,
335
+ request."throughIndex" as through_index,
336
+ head.up_to_index as head_index,
337
+ candidate.up_to_index as snapshot_index,
338
+ candidate.state as snapshot_state
339
+ from request
340
+ left join a2_snapshots head
341
+ on head.session_id = request."sessionId"
342
+ and head.reducer_name = $2
343
+ left join lateral (
344
+ select up_to_index, state from (
345
+ select head.up_to_index, head.state
346
+ where request."snapshotThroughIndex" is null
347
+ or head.up_to_index <= request."snapshotThroughIndex"
348
+ union all
349
+ select history.up_to_index, history.state
350
+ from a2_snapshot_history history
351
+ where history.session_id = request."sessionId"
352
+ and history.reducer_name = $2
353
+ and (
354
+ request."snapshotThroughIndex" is null
355
+ or history.up_to_index <= request."snapshotThroughIndex"
356
+ )
357
+ ) candidates
358
+ order by up_to_index desc
359
+ limit 1
360
+ ) candidate on true
361
+ )
362
+ select selected.position, 0 as row_order, selected.session_id,
363
+ selected.head_index, selected.snapshot_index,
364
+ selected.snapshot_state,
365
+ null::bigint as idx, null::text as event_type,
366
+ null::jsonb as payload, null::text as event_id,
367
+ null::bigint as created_at
368
+ from selected
369
+ union all
370
+ select selected.position, 1 as row_order, event.session_id,
371
+ selected.head_index, null::bigint as snapshot_index,
372
+ null::jsonb as snapshot_state,
373
+ event.idx, event.event_type, event.payload,
374
+ event.event_id, event.created_at
375
+ from selected
376
+ join a2_events event on event.session_id = selected.session_id
377
+ where event.idx > coalesce(selected.snapshot_index, 0)
378
+ and (
379
+ selected.through_index is null
380
+ or event.idx <= selected.through_index
381
+ )
382
+ order by position, row_order, idx`,
383
+ [jsonb(input), reducerName],
384
+ )
385
+ const reads = requests.map<StoreStateRead>(() => ({
386
+ headIndex: null,
387
+ snapshot: null,
388
+ events: [],
389
+ }))
390
+ for (const row of rows) {
391
+ const read = reads[asNumber(row['position'])]!
392
+ if (row['row_order'] === 0 || row['row_order'] === '0') {
393
+ read.headIndex =
394
+ row['head_index'] === null ? null : asNumber(row['head_index'])
395
+ if (row['snapshot_index'] !== null) {
396
+ read.snapshot = {
397
+ index: asNumber(row['snapshot_index']),
398
+ state: row['snapshot_state'],
399
+ }
400
+ }
401
+ } else {
402
+ read.events.push(toEvent(row))
403
+ }
404
+ }
405
+ return reads
406
+ }
407
+
290
408
  // Transactions run on one connection. Pools check one out; a
291
409
  // single-connection client (PGlite) serializes on this mutex instead.
292
410
  let mutex: Promise<unknown> = Promise.resolve()
@@ -797,6 +915,22 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
797
915
  where session_id = $3 and idx = $4`,
798
916
  [now, attempt, sessionId, index, jsonb(ids)],
799
917
  )
918
+ await query(
919
+ `delete from a2_snapshot_pins
920
+ where session_id = $1 and event_index = $2`,
921
+ [sessionId, index],
922
+ )
923
+ await query(
924
+ `delete from a2_snapshot_history history
925
+ where history.session_id = $1
926
+ and not exists (
927
+ select 1 from a2_snapshot_pins pin
928
+ where pin.session_id = history.session_id
929
+ and pin.reducer_name = history.reducer_name
930
+ and pin.up_to_index = history.up_to_index
931
+ )`,
932
+ [sessionId],
933
+ )
800
934
  const parentLane = (parent['lane'] as string | null) ?? null
801
935
  if (parentLane !== null) {
802
936
  await query(
@@ -816,10 +950,10 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
816
950
  },
817
951
 
818
952
  async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
819
- return wrap(async () => {
820
- const c = await client()
821
- const { rows } = await c.query(
822
- `with current as materialized (
953
+ return wrap(() =>
954
+ withTx(async (query) => {
955
+ const { rows } = await query(
956
+ `with current as materialized (
823
957
  select e.* from a2_events e
824
958
  where e.session_id = $1 and e.idx = $2
825
959
  for update of e
@@ -867,148 +1001,231 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
867
1001
  (select failure_count from updated),
868
1002
  (select failure_count from current)
869
1003
  ) as failure_count`,
870
- [
871
- sessionId,
872
- index,
873
- attempt,
874
- error,
875
- maxFailures,
876
- clock.now().getTime(),
877
- ],
878
- )
879
- const row = rows[0]
880
- if (!row || row['outcome'] === 'missing') {
881
- throw new TypeError(
882
- `no event at index ${index} in session '${sessionId}'`,
1004
+ [
1005
+ sessionId,
1006
+ index,
1007
+ attempt,
1008
+ error,
1009
+ maxFailures,
1010
+ clock.now().getTime(),
1011
+ ],
883
1012
  )
884
- }
885
- return {
886
- outcome: row['outcome'] as 'failed' | 'dead_lettered' | 'superseded',
887
- failureCount: asNumber(row['failure_count']),
888
- }
889
- })
1013
+ const row = rows[0]
1014
+ if (!row || row['outcome'] === 'missing') {
1015
+ throw new TypeError(
1016
+ `no event at index ${index} in session '${sessionId}'`,
1017
+ )
1018
+ }
1019
+ if (row['outcome'] === 'dead_lettered') {
1020
+ await query(
1021
+ `delete from a2_snapshot_pins
1022
+ where session_id = $1 and event_index = $2`,
1023
+ [sessionId, index],
1024
+ )
1025
+ await query(
1026
+ `delete from a2_snapshot_history history
1027
+ where history.session_id = $1
1028
+ and not exists (
1029
+ select 1 from a2_snapshot_pins pin
1030
+ where pin.session_id = history.session_id
1031
+ and pin.reducer_name = history.reducer_name
1032
+ and pin.up_to_index = history.up_to_index
1033
+ )`,
1034
+ [sessionId],
1035
+ )
1036
+ }
1037
+ return {
1038
+ outcome: row['outcome'] as
1039
+ 'failed' | 'dead_lettered' | 'superseded',
1040
+ failureCount: asNumber(row['failure_count']),
1041
+ }
1042
+ }),
1043
+ )
890
1044
  },
891
1045
 
892
- async readState(sessionId, reducerName) {
893
- return wrap(async () => {
894
- const c = await client()
895
- const { rows } = await c.query(
896
- `with snapshot as materialized (
897
- select up_to_index, state
898
- from a2_snapshots
899
- where session_id = $1 and reducer_name = $2
900
- )
901
- select 0 as row_order, 'snapshot' as row_kind,
902
- snapshot.up_to_index as snapshot_index,
903
- snapshot.state as snapshot_state,
904
- null::text as session_id, null::bigint as idx,
905
- null::text as event_type, null::jsonb as payload,
906
- null::text as event_id, null::bigint as created_at
907
- from snapshot
908
- union all
909
- select 1 as row_order, 'event' as row_kind,
910
- null::bigint as snapshot_index,
911
- null::jsonb as snapshot_state,
912
- event.session_id, event.idx, event.event_type,
913
- event.payload, event.event_id, event.created_at
914
- from a2_events event
915
- where event.session_id = $1
916
- and event.idx > coalesce((select up_to_index from snapshot), 0)
917
- order by row_order, idx`,
918
- [sessionId, reducerName],
919
- )
920
- const snapshot = rows.find((row) => row['row_kind'] === 'snapshot')
921
- return {
922
- snapshot: snapshot
923
- ? {
924
- index: asNumber(snapshot['snapshot_index']),
925
- state: snapshot['snapshot_state'],
926
- }
927
- : null,
928
- events: rows
929
- .filter((row) => row['row_kind'] === 'event')
930
- .map(toEvent),
931
- }
932
- })
1046
+ async readState(sessionId, reducerName, stateOptions) {
1047
+ return wrap(
1048
+ async () =>
1049
+ (
1050
+ await readStateBatch(
1051
+ [
1052
+ {
1053
+ sessionId,
1054
+ ...(stateOptions?.throughIndex === undefined
1055
+ ? {}
1056
+ : { throughIndex: stateOptions.throughIndex }),
1057
+ ...(stateOptions?.snapshotThroughIndex === undefined
1058
+ ? {}
1059
+ : {
1060
+ snapshotThroughIndex: stateOptions.snapshotThroughIndex,
1061
+ }),
1062
+ },
1063
+ ],
1064
+ reducerName,
1065
+ )
1066
+ )[0]!,
1067
+ )
933
1068
  },
934
1069
 
935
- async readStates(sessionIds, reducerName) {
936
- if (sessionIds.length === 0) return []
937
- return wrap(async () => {
938
- const c = await client()
939
- // One statement, one MVCC snapshot — each session's tail is
940
- // bounded by the same snapshot row the read returns.
941
- const { rows } = await c.query(
942
- `with snapshot as materialized (
943
- select session_id, up_to_index, state
944
- from a2_snapshots
945
- where session_id = any($1::text[]) and reducer_name = $2
946
- )
947
- select 0 as row_order, 'snapshot' as row_kind,
948
- snapshot.session_id,
949
- snapshot.up_to_index as snapshot_index,
950
- snapshot.state as snapshot_state,
951
- null::bigint as idx, null::text as event_type,
952
- null::jsonb as payload, null::text as event_id,
953
- null::bigint as created_at
954
- from snapshot
955
- union all
956
- select 1 as row_order, 'event' as row_kind,
957
- event.session_id,
958
- null::bigint as snapshot_index,
959
- null::jsonb as snapshot_state,
960
- event.idx, event.event_type, event.payload,
961
- event.event_id, event.created_at
962
- from a2_events event
963
- left join snapshot on snapshot.session_id = event.session_id
964
- where event.session_id = any($1::text[])
965
- and event.idx > coalesce(snapshot.up_to_index, 0)
966
- order by row_order, session_id, idx`,
967
- [sessionIds, reducerName],
968
- )
969
- const bySession = new Map<string, StoreStateRead>()
970
- const readOf = (sessionId: string): StoreStateRead => {
971
- let read = bySession.get(sessionId)
972
- if (!read) {
973
- read = { snapshot: null, events: [] }
974
- bySession.set(sessionId, read)
975
- }
976
- return read
977
- }
978
- for (const row of rows) {
979
- const read = readOf(row['session_id'] as string)
980
- if (row['row_kind'] === 'snapshot') {
981
- read.snapshot = {
982
- index: asNumber(row['snapshot_index']),
983
- state: row['snapshot_state'],
1070
+ async readStates(requests, reducerName) {
1071
+ return wrap(() => readStateBatch(requests, reducerName))
1072
+ },
1073
+
1074
+ async putSnapshots(sessionId, reducerName, writes) {
1075
+ await wrap(() =>
1076
+ withTx(async (query) => {
1077
+ const normalized = [
1078
+ ...new Map(writes.map((write) => [write.index, write])).values(),
1079
+ ].toSorted((a, b) => a.index - b.index)
1080
+ if (normalized.length === 0) return
1081
+
1082
+ await query(
1083
+ 'select pg_advisory_xact_lock(hashtext($1), hashtext($2))',
1084
+ [sessionId, reducerName],
1085
+ )
1086
+
1087
+ const requestedPins = normalized.flatMap((write) =>
1088
+ (write.pinEventIndexes ?? []).map((eventIndex) => ({
1089
+ eventIndex,
1090
+ snapshotIndex: write.index,
1091
+ })),
1092
+ )
1093
+ if (requestedPins.length > 0) {
1094
+ const eventIndexes = [
1095
+ ...new Set(requestedPins.map((pin) => pin.eventIndex)),
1096
+ ]
1097
+ const { rows: events } = await query(
1098
+ `select idx, processed_at, failed_at from a2_events
1099
+ where session_id = $1 and idx = any($2::bigint[])
1100
+ for update`,
1101
+ [sessionId, eventIndexes],
1102
+ )
1103
+ const unfinished = new Set(
1104
+ events
1105
+ .filter(
1106
+ (row) =>
1107
+ row['processed_at'] === null && row['failed_at'] === null,
1108
+ )
1109
+ .map((row) => asNumber(row['idx'])),
1110
+ )
1111
+ const accepted = requestedPins.filter((pin) =>
1112
+ unfinished.has(pin.eventIndex),
1113
+ )
1114
+ if (accepted.length > 0) {
1115
+ await query(
1116
+ `insert into a2_snapshot_pins
1117
+ (session_id, event_index, reducer_name, up_to_index)
1118
+ select $1, pin."eventIndex", $2, pin."snapshotIndex"
1119
+ from jsonb_to_recordset($3::jsonb) as pin(
1120
+ "eventIndex" bigint,
1121
+ "snapshotIndex" bigint
1122
+ )
1123
+ on conflict do nothing`,
1124
+ [sessionId, reducerName, jsonb(accepted)],
1125
+ )
984
1126
  }
985
- } else {
986
- read.events.push(toEvent(row))
987
1127
  }
988
- }
989
- return sessionIds.map(
990
- (sessionId) =>
991
- bySession.get(sessionId) ?? { snapshot: null, events: [] },
992
- )
993
- })
994
- },
995
1128
 
996
- async putSnapshot(sessionId, reducerName, index, state) {
997
- await wrap(async () => {
998
- const c = await client()
999
- // Guarded upsert: a slower concurrent writer must never clobber
1000
- // a further-along snapshot (spec §3).
1001
- await c.query(
1002
- `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
1003
- values ($1, $2, $3, $4::jsonb, $5)
1004
- on conflict (session_id, reducer_name) do update set
1005
- up_to_index = excluded.up_to_index,
1006
- state = excluded.state,
1007
- updated_at = excluded.updated_at
1008
- where excluded.up_to_index > a2_snapshots.up_to_index`,
1009
- [sessionId, reducerName, index, jsonb(state), clock.now().getTime()],
1010
- )
1011
- })
1129
+ const { rows: headRows } = await query(
1130
+ `select up_to_index, state, updated_at from a2_snapshots
1131
+ where session_id = $1 and reducer_name = $2
1132
+ for update`,
1133
+ [sessionId, reducerName],
1134
+ )
1135
+ const currentHead = headRows[0]
1136
+ const latestWrite = normalized.at(-1)!
1137
+ const writesHead =
1138
+ !currentHead ||
1139
+ latestWrite.index >= asNumber(currentHead['up_to_index'])
1140
+ const advancesHead =
1141
+ !currentHead ||
1142
+ latestWrite.index > asNumber(currentHead['up_to_index'])
1143
+ const finalHeadIndex = advancesHead
1144
+ ? latestWrite.index
1145
+ : asNumber(currentHead['up_to_index'])
1146
+ const relevantIndexes = [
1147
+ ...new Set([
1148
+ ...normalized.map((write) => write.index),
1149
+ ...(currentHead ? [asNumber(currentHead['up_to_index'])] : []),
1150
+ ]),
1151
+ ]
1152
+ const { rows: pinRows } = await query(
1153
+ `select distinct up_to_index from a2_snapshot_pins
1154
+ where session_id = $1 and reducer_name = $2
1155
+ and up_to_index = any($3::bigint[])`,
1156
+ [sessionId, reducerName, relevantIndexes],
1157
+ )
1158
+ const pinned = new Set(
1159
+ pinRows.map((row) => asNumber(row['up_to_index'])),
1160
+ )
1161
+ const historical = new Map<
1162
+ number,
1163
+ { index: number; state: unknown; updatedAt: number }
1164
+ >()
1165
+ if (
1166
+ advancesHead &&
1167
+ currentHead &&
1168
+ pinned.has(asNumber(currentHead['up_to_index']))
1169
+ ) {
1170
+ const index = asNumber(currentHead['up_to_index'])
1171
+ historical.set(index, {
1172
+ index,
1173
+ state: currentHead['state'],
1174
+ updatedAt: asNumber(currentHead['updated_at']),
1175
+ })
1176
+ }
1177
+ const updatedAt = clock.now().getTime()
1178
+ for (const write of normalized) {
1179
+ if (write.index < finalHeadIndex && pinned.has(write.index)) {
1180
+ historical.set(write.index, {
1181
+ index: write.index,
1182
+ state: write.state,
1183
+ updatedAt,
1184
+ })
1185
+ }
1186
+ }
1187
+ if (historical.size > 0) {
1188
+ await query(
1189
+ `insert into a2_snapshot_history
1190
+ (session_id, reducer_name, up_to_index, state, updated_at)
1191
+ select $1, $2, snapshot.index, snapshot.state, snapshot."updatedAt"
1192
+ from jsonb_to_recordset($3::jsonb) as snapshot(
1193
+ index bigint,
1194
+ state jsonb,
1195
+ "updatedAt" bigint
1196
+ )
1197
+ on conflict (session_id, reducer_name, up_to_index) do update set
1198
+ state = excluded.state,
1199
+ updated_at = excluded.updated_at`,
1200
+ [sessionId, reducerName, jsonb([...historical.values()])],
1201
+ )
1202
+ }
1203
+ if (writesHead) {
1204
+ await query(
1205
+ `insert into a2_snapshots
1206
+ (session_id, reducer_name, up_to_index, state, updated_at)
1207
+ values ($1, $2, $3, $4::jsonb, $5)
1208
+ on conflict (session_id, reducer_name) do update set
1209
+ up_to_index = excluded.up_to_index,
1210
+ state = excluded.state,
1211
+ updated_at = excluded.updated_at
1212
+ where excluded.up_to_index >= a2_snapshots.up_to_index`,
1213
+ [
1214
+ sessionId,
1215
+ reducerName,
1216
+ latestWrite.index,
1217
+ jsonb(latestWrite.state),
1218
+ clock.now().getTime(),
1219
+ ],
1220
+ )
1221
+ await query(
1222
+ `delete from a2_snapshot_history
1223
+ where session_id = $1 and reducer_name = $2 and up_to_index = $3`,
1224
+ [sessionId, reducerName, latestWrite.index],
1225
+ )
1226
+ }
1227
+ }),
1228
+ )
1012
1229
  },
1013
1230
 
1014
1231
  presence: {
@@ -1086,106 +1303,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1086
1303
  },
1087
1304
  },
1088
1305
 
1089
- inspect: {
1090
- async listSessions(inspectionOptions) {
1091
- return wrap(async () => {
1092
- const c = await client()
1093
- const conditions = ['left(session_id, length($1)) = $1']
1094
- const params: unknown[] = [inspectionOptions.prefix]
1095
- if (inspectionOptions.cursor !== undefined) {
1096
- params.push(inspectionOptions.cursor)
1097
- conditions.push(`session_id > $${params.length}`)
1098
- }
1099
- params.push(inspectionOptions.limit + 1)
1100
- const { rows } = await c.query(
1101
- `select
1102
- session_id,
1103
- count(*) as event_count,
1104
- count(*) filter (where processed_at is null and failed_at is null) as pending_count,
1105
- count(*) filter (where failed_at is not null) as failed_count,
1106
- sum(attempt_count) as attempt_count,
1107
- sum(failure_count) as failure_count,
1108
- min(created_at) as first_event_at,
1109
- max(greatest(
1110
- created_at,
1111
- coalesce(first_claimed_at, 0),
1112
- coalesce(last_claimed_at, 0),
1113
- coalesce(last_failed_at, 0),
1114
- coalesce(processed_at, 0),
1115
- coalesce(failed_at, 0)
1116
- )) as updated_at
1117
- from a2_events
1118
- where ${conditions.join(' and ')}
1119
- group by session_id
1120
- order by session_id
1121
- limit $${params.length}`,
1122
- params,
1123
- )
1124
- const hasMore = rows.length > inspectionOptions.limit
1125
- const visible = rows.slice(0, inspectionOptions.limit)
1126
- return {
1127
- sessions: visible.map((row) => ({
1128
- sessionId: row['session_id'] as string,
1129
- eventCount: asNumber(row['event_count']),
1130
- pendingCount: asNumber(row['pending_count']),
1131
- failedCount: asNumber(row['failed_count']),
1132
- attemptCount: asNumber(row['attempt_count']),
1133
- failureCount: asNumber(row['failure_count']),
1134
- firstEventAt: new Date(asNumber(row['first_event_at'])),
1135
- updatedAt: new Date(asNumber(row['updated_at'])),
1136
- })),
1137
- cursor: hasMore
1138
- ? ((visible.at(-1)?.['session_id'] as string | undefined) ?? null)
1139
- : null,
1140
- }
1141
- })
1142
- },
1143
- async readEvents(sessionId, pageOptions) {
1144
- return wrap(async () => {
1145
- const c = await client()
1146
- const currentFrontier = asNumber(
1147
- (
1148
- await c.query(
1149
- 'select coalesce(max(idx), 0) as frontier from a2_events where session_id = $1',
1150
- [sessionId],
1151
- )
1152
- ).rows[0]?.['frontier'] ?? 0,
1153
- )
1154
- const throughIndex = Math.min(
1155
- pageOptions.throughIndex ?? currentFrontier,
1156
- currentFrontier,
1157
- )
1158
- const { rows } = await c.query(
1159
- `select * from a2_events
1160
- where session_id = $1 and idx > $2 and idx <= $3
1161
- order by idx limit $4`,
1162
- [
1163
- sessionId,
1164
- pageOptions.afterIndex,
1165
- throughIndex,
1166
- pageOptions.limit,
1167
- ],
1168
- )
1169
- return { events: rows.map(toStored), throughIndex }
1170
- })
1171
- },
1172
- async listSnapshots(sessionId) {
1173
- return wrap(async () => {
1174
- const c = await client()
1175
- const { rows } = await c.query(
1176
- `select reducer_name, up_to_index, updated_at
1177
- from a2_snapshots where session_id = $1 order by reducer_name`,
1178
- [sessionId],
1179
- )
1180
- return rows.map((row) => ({
1181
- reducerName: row['reducer_name'] as string,
1182
- index: asNumber(row['up_to_index']),
1183
- updatedAt: new Date(asNumber(row['updated_at'])),
1184
- }))
1185
- })
1186
- },
1187
- },
1188
-
1189
1306
  stream(sessionId, opts) {
1190
1307
  const readAfter = (afterIndex: number): Promise<StoredEvent[]> =>
1191
1308
  wrap(async () => {