experimental-a2 0.7.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 (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/ai-server.d.ts +1 -1
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +13 -11
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/scheduler-qstash.d.ts +2 -2
  9. package/dist/scheduler-qstash.js +1 -1
  10. package/dist/scheduler-vercel.d.ts +2 -2
  11. package/dist/scheduler-vercel.js +1 -1
  12. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  13. package/dist/server-B2XNevQA.js.map +1 -0
  14. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  15. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  16. package/dist/server.d.ts +3 -3
  17. package/dist/server.js +1 -1
  18. package/dist/store-N8PXxDAS.js.map +1 -1
  19. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  20. package/dist/store-RJO35BMj.d.ts.map +1 -0
  21. package/dist/store-memory.d.ts +1 -1
  22. package/dist/store-memory.d.ts.map +1 -1
  23. package/dist/store-memory.js +79 -19
  24. package/dist/store-memory.js.map +1 -1
  25. package/dist/store-postgres.d.ts +1 -1
  26. package/dist/store-postgres.d.ts.map +1 -1
  27. package/dist/store-postgres.js +230 -101
  28. package/dist/store-postgres.js.map +1 -1
  29. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  30. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  31. package/dist/store-redis-http.d.ts +1 -1
  32. package/dist/store-redis-http.js +2 -2
  33. package/dist/store-redis-http.js.map +1 -1
  34. package/dist/store-redis.d.ts +1 -1
  35. package/dist/store-redis.js +2 -2
  36. package/dist/store-redis.js.map +1 -1
  37. package/dist/store-sqlite.d.ts +1 -1
  38. package/dist/store-sqlite.d.ts.map +1 -1
  39. package/dist/store-sqlite.js +103 -19
  40. package/dist/store-sqlite.js.map +1 -1
  41. package/docs/concepts/02-handlers.mdx +4 -0
  42. package/docs/concepts/04-state.mdx +57 -9
  43. package/docs/guides/06-ai-agents.mdx +2 -1
  44. package/docs/reference/01-api.mdx +39 -16
  45. package/package.json +1 -1
  46. package/src/ai-server.ts +27 -10
  47. package/src/server.ts +242 -87
  48. package/src/store-memory.ts +138 -20
  49. package/src/store-postgres.ts +355 -138
  50. package/src/store-redis-core.ts +201 -27
  51. package/src/store-redis-http.ts +1 -1
  52. package/src/store-redis.ts +1 -1
  53. package/src/store-sqlite.ts +191 -34
  54. package/src/store.ts +27 -9
  55. package/dist/server-286j79Mt.js.map +0 -1
  56. package/dist/server-DgXmORIq.d.ts.map +0 -1
  57. package/dist/store-flRz1OWh.d.ts.map +0 -1
  58. package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
package/src/server.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  Event,
39
39
  EventCause,
40
40
  StoreClaimAvailableResult,
41
+ StoreSnapshotWrite,
41
42
  StoreStateRead,
42
43
  PresenceRow,
43
44
  ReturnedEvent,
@@ -134,6 +135,10 @@ export type SessionSchedule<D extends EventDefs> = (
134
135
  ...events: AppendInput<D>[]
135
136
  ) => Promise<void>
136
137
 
138
+ export type StateOptions = {
139
+ through?: number | 'latest'
140
+ }
141
+
137
142
  /**
138
143
  * The presence members of a session — intersected in via
139
144
  * `WithPresence`, so they exist exactly when the contract declares
@@ -177,7 +182,10 @@ export type Session<
177
182
  append: Append
178
183
  schedule: SessionSchedule<D>
179
184
  history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
180
- state<S>(reducer: Reducer<D, S>): Promise<{ state: S; index: number }>
185
+ state<S>(
186
+ reducer: Reducer<D, S>,
187
+ options?: StateOptions,
188
+ ): Promise<{ state: S; index: number }>
181
189
  /**
182
190
  * A live feed of this session's events, starting after `startAfter`
183
191
  * (exclusive). Server-side only. `server.fetch` exposes it over SSE.
@@ -965,26 +973,85 @@ export function createServer<
965
973
  store: A2Store,
966
974
  sessionId: string,
967
975
  reducerName: string,
976
+ throughIndex?: number,
977
+ snapshotThroughIndex?: number,
968
978
  ): Promise<StoreStateRead> => {
969
979
  try {
970
- return await store.readState(nsId(sessionId), reducerName)
980
+ return await store.readState(
981
+ nsId(sessionId),
982
+ reducerName,
983
+ throughIndex === undefined && snapshotThroughIndex === undefined
984
+ ? undefined
985
+ : {
986
+ ...(throughIndex === undefined ? {} : { throughIndex }),
987
+ ...(snapshotThroughIndex === undefined
988
+ ? {}
989
+ : { snapshotThroughIndex }),
990
+ },
991
+ )
971
992
  } catch {
972
993
  try {
973
- return { snapshot: null, events: await store.read(nsId(sessionId)) }
994
+ return {
995
+ headIndex: null,
996
+ snapshot: null,
997
+ events: await store.read(
998
+ nsId(sessionId),
999
+ throughIndex === undefined ? undefined : { throughIndex },
1000
+ ),
1001
+ }
974
1002
  } catch (err) {
975
1003
  throw asStoreUnavailable(err)
976
1004
  }
977
1005
  }
978
1006
  }
979
1007
 
1008
+ type PendingStateRead = {
1009
+ sessionId: string
1010
+ throughIndex?: number
1011
+ pinEventIndex?: number
1012
+ span: A2SpanHandle
1013
+ resolve: (result: { state: unknown; index: number }) => void
1014
+ reject: (reason: unknown) => void
1015
+ }
1016
+
1017
+ type StateReadPlan = {
1018
+ sessionId: string
1019
+ entries: PendingStateRead[]
1020
+ throughIndex?: number
1021
+ snapshotThroughIndex?: number
1022
+ }
1023
+
1024
+ const planStateReads = (batch: PendingStateRead[]): StateReadPlan[] => {
1025
+ const bySession = new Map<string, PendingStateRead[]>()
1026
+ for (const entry of batch) {
1027
+ const entries = bySession.get(entry.sessionId)
1028
+ if (entries) entries.push(entry)
1029
+ else bySession.set(entry.sessionId, [entry])
1030
+ }
1031
+ const plans: StateReadPlan[] = []
1032
+ for (const [sessionId, entries] of bySession) {
1033
+ const boundaries = entries.flatMap((entry) =>
1034
+ entry.throughIndex === undefined ? [] : [entry.throughIndex],
1035
+ )
1036
+ const plan: StateReadPlan = { sessionId, entries }
1037
+ if (boundaries.length === entries.length) {
1038
+ plan.throughIndex = Math.max(...boundaries)
1039
+ }
1040
+ if (boundaries.length > 0) {
1041
+ plan.snapshotThroughIndex = Math.min(...boundaries)
1042
+ }
1043
+ plans.push(plan)
1044
+ }
1045
+ return plans
1046
+ }
1047
+
980
1048
  // The cache is untrusted: schema rejection refolds from the raw store.
981
- const foldStateRead = async <S>(
1049
+ const foldStatePlan = async (
982
1050
  store: A2Store,
983
- sessionId: string,
984
- reducer: Reducer<D, S>,
1051
+ reducer: Reducer<D, unknown>,
1052
+ plan: StateReadPlan,
985
1053
  stateRead: StoreStateRead,
986
- span: A2SpanHandle,
987
- ): Promise<{ state: S; index: number; folded: number }> => {
1054
+ ): Promise<void> => {
988
1055
  let state = cloneInitial(reducer.initialState)
989
1056
  let index = 0
990
1057
  let snapshotOutcome = 'miss'
@@ -1000,44 +1067,101 @@ export function createServer<
1000
1067
  if (result.issues) {
1001
1068
  snapshotOutcome = 'rejected'
1002
1069
  } else {
1003
- state = result.value as S
1070
+ state = result.value
1004
1071
  index = snap.index
1005
1072
  snapshotOutcome = 'hit'
1006
1073
  }
1007
1074
  } else {
1008
- state = snap.state as S
1075
+ state = snap.state
1009
1076
  index = snap.index
1010
1077
  snapshotOutcome = 'hit'
1011
1078
  }
1012
1079
  }
1013
- span.setAttribute('a2.state.snapshot', snapshotOutcome)
1014
-
1015
1080
  if (snapshotOutcome === 'rejected') {
1016
1081
  try {
1017
- rows = await store.read(nsId(sessionId))
1082
+ rows = await store.read(
1083
+ nsId(plan.sessionId),
1084
+ plan.throughIndex === undefined
1085
+ ? undefined
1086
+ : { throughIndex: plan.throughIndex },
1087
+ )
1018
1088
  } catch (err) {
1019
1089
  throw asStoreUnavailable(err)
1020
1090
  }
1021
1091
  }
1022
- for (const row of rows) {
1023
- state = reducer.fold(state, toPublic(row) as never)
1024
- index = row.index
1092
+ const ordered = plan.entries.toSorted(
1093
+ (a, b) =>
1094
+ (a.throughIndex ?? Number.POSITIVE_INFINITY) -
1095
+ (b.throughIndex ?? Number.POSITIVE_INFINITY),
1096
+ )
1097
+ const writes = new Map<
1098
+ number,
1099
+ { state: unknown; pinEventIndexes: Set<number> }
1100
+ >()
1101
+ let rowPosition = 0
1102
+ for (const entry of ordered) {
1103
+ const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
1104
+ while (rowPosition < rows.length && rows[rowPosition]!.index <= target) {
1105
+ const row = rows[rowPosition]!
1106
+ state = reducer.fold(state, toPublic(row) as never)
1107
+ index = row.index
1108
+ rowPosition += 1
1109
+ }
1110
+ const resultState = cloneInitial(state)
1111
+ const folded = rowPosition
1112
+ entry.span.setAttribute('a2.state.snapshot', snapshotOutcome)
1113
+ entry.span.setAttribute('a2.state.folded', folded)
1114
+ entry.span.setAttribute('a2.state.index', index)
1115
+ if (index > 0 && (folded > 0 || entry.pinEventIndex !== undefined)) {
1116
+ const advancesHead =
1117
+ stateRead.headIndex === null || index > stateRead.headIndex
1118
+ const repairsHead =
1119
+ snapshotOutcome === 'rejected' && index === stateRead.headIndex
1120
+ if (
1121
+ !advancesHead &&
1122
+ !repairsHead &&
1123
+ entry.pinEventIndex === undefined
1124
+ ) {
1125
+ entry.resolve({ state: resultState, index })
1126
+ continue
1127
+ }
1128
+ let write = writes.get(index)
1129
+ if (!write) {
1130
+ write = {
1131
+ state: cloneInitial(resultState),
1132
+ pinEventIndexes: new Set(),
1133
+ }
1134
+ writes.set(index, write)
1135
+ }
1136
+ if (entry.pinEventIndex !== undefined) {
1137
+ write.pinEventIndexes.add(entry.pinEventIndex)
1138
+ }
1139
+ }
1140
+ entry.resolve({ state: resultState, index })
1141
+ }
1142
+ if (writes.size > 0) {
1143
+ const snapshots: StoreSnapshotWrite[] = []
1144
+ for (const [snapshotIndex, write] of writes) {
1145
+ const snapshot: StoreSnapshotWrite = {
1146
+ index: snapshotIndex,
1147
+ state: write.state,
1148
+ }
1149
+ if (write.pinEventIndexes.size > 0) {
1150
+ snapshot.pinEventIndexes = [...write.pinEventIndexes]
1151
+ }
1152
+ snapshots.push(snapshot)
1153
+ }
1154
+ const persistence = Promise.resolve()
1155
+ .then(() =>
1156
+ store.putSnapshots(nsId(plan.sessionId), reducer.name, snapshots),
1157
+ )
1158
+ .catch(() => {})
1159
+ track(persistence)
1160
+ platformWaitUntil(persistence)
1025
1161
  }
1026
- span.setAttribute('a2.state.folded', rows.length)
1027
- span.setAttribute('a2.state.index', index)
1028
- return { state, index, folded: rows.length }
1029
1162
  }
1030
1163
 
1031
1164
  // ── same-tick state-read coalescing ──────────────────────────────
1032
- // Concurrent state() calls for one reducer name coalesce into a
1033
- // single store.readStates() round trip when the adapter has one.
1034
- // Unlike DataLoader there is deliberately NO result cache: a batch
1035
- // exists only between enqueue and flush, and nothing is shared after
1036
- // distribution — every state() call still observes a fresh frontier.
1037
- type PendingStateRead = {
1038
- sessionId: string
1039
- resolve: (read: StoreStateRead | Promise<StoreStateRead>) => void
1040
- }
1041
1165
  const pendingStateReads = new Map<string, PendingStateRead[]>()
1042
1166
 
1043
1167
  // The flush microtask voids this promise, so the function must be
@@ -1045,40 +1169,62 @@ export function createServer<
1045
1169
  // synchronously throwing adapter or a read structuredClone rejects.
1046
1170
  const flushStateReads = async (
1047
1171
  store: A2Store,
1048
- reducerName: string,
1172
+ reducer: Reducer<D, unknown>,
1049
1173
  batch: PendingStateRead[],
1050
1174
  ): Promise<void> => {
1175
+ const plans = planStateReads(batch)
1051
1176
  let reads: StoreStateRead[] | null
1052
1177
  try {
1053
1178
  reads = store.readStates
1054
1179
  ? await store.readStates(
1055
- batch.map((entry) => nsId(entry.sessionId)),
1056
- reducerName,
1180
+ plans.map((plan) => ({
1181
+ sessionId: nsId(plan.sessionId),
1182
+ ...(plan.throughIndex === undefined
1183
+ ? {}
1184
+ : { throughIndex: plan.throughIndex }),
1185
+ ...(plan.snapshotThroughIndex === undefined
1186
+ ? {}
1187
+ : { snapshotThroughIndex: plan.snapshotThroughIndex }),
1188
+ })),
1189
+ reducer.name,
1190
+ )
1191
+ : await Promise.all(
1192
+ plans.map((plan) =>
1193
+ readCachedState(
1194
+ store,
1195
+ plan.sessionId,
1196
+ reducer.name,
1197
+ plan.throughIndex,
1198
+ plan.snapshotThroughIndex,
1199
+ ),
1200
+ ),
1057
1201
  )
1058
- : null
1059
1202
  } catch {
1060
1203
  reads = null
1061
1204
  }
1062
- // A failed or misaligned batch is an ill-behaved cache path, never a
1063
- // batch-shaped error: each caller falls back to its own per-call read
1064
- // (promise adoption carries a real storage failure to that caller).
1065
- if (!reads || reads.length !== batch.length) {
1066
- for (const entry of batch) {
1067
- entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1205
+ if (!reads || reads.length !== plans.length) {
1206
+ try {
1207
+ reads = await Promise.all(
1208
+ plans.map((plan) =>
1209
+ readCachedState(
1210
+ store,
1211
+ plan.sessionId,
1212
+ reducer.name,
1213
+ plan.throughIndex,
1214
+ plan.snapshotThroughIndex,
1215
+ ),
1216
+ ),
1217
+ )
1218
+ } catch (err) {
1219
+ for (const entry of batch) entry.reject(err)
1220
+ return
1068
1221
  }
1069
- return
1070
1222
  }
1071
- // Duplicate ids share one store read; their folds must not share
1072
- // payload objects, exactly as two separate state() calls would not.
1073
- const seen = new Set<string>()
1074
- for (const [position, entry] of batch.entries()) {
1075
- const shared = seen.has(entry.sessionId)
1076
- seen.add(entry.sessionId)
1223
+ for (const [position, plan] of plans.entries()) {
1077
1224
  try {
1078
- const read = reads[position]!
1079
- entry.resolve(shared ? structuredClone(read) : read)
1080
- } catch {
1081
- entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1225
+ await foldStatePlan(store, reducer, plan, reads[position]!)
1226
+ } catch (err) {
1227
+ for (const entry of plan.entries) entry.reject(err)
1082
1228
  }
1083
1229
  }
1084
1230
  }
@@ -1086,9 +1232,13 @@ export function createServer<
1086
1232
  const enqueueStateRead = (
1087
1233
  store: A2Store,
1088
1234
  sessionId: string,
1089
- reducerName: string,
1090
- ): Promise<StoreStateRead> =>
1091
- new Promise((resolve) => {
1235
+ reducer: Reducer<D, unknown>,
1236
+ throughIndex?: number,
1237
+ pinEventIndex?: number,
1238
+ span?: A2SpanHandle,
1239
+ ): Promise<{ state: unknown; index: number }> =>
1240
+ new Promise((resolve, reject) => {
1241
+ const reducerName = reducer.name
1092
1242
  let batch = pendingStateReads.get(reducerName)
1093
1243
  if (!batch) {
1094
1244
  const opened: PendingStateRead[] = []
@@ -1104,16 +1254,25 @@ export function createServer<
1104
1254
  // the last. A deeper-staggered caller just opens the next batch.
1105
1255
  queueMicrotask(() => {
1106
1256
  pendingStateReads.delete(reducerName)
1107
- void flushStateReads(store, reducerName, opened)
1257
+ void flushStateReads(store, reducer, opened)
1108
1258
  })
1109
1259
  batch = opened
1110
1260
  }
1111
- batch.push({ sessionId, resolve })
1261
+ batch.push({
1262
+ sessionId,
1263
+ ...(throughIndex === undefined ? {} : { throughIndex }),
1264
+ ...(pinEventIndex === undefined ? {} : { pinEventIndex }),
1265
+ span: span!,
1266
+ resolve,
1267
+ reject,
1268
+ })
1112
1269
  })
1113
1270
 
1114
1271
  const readState = async <S>(
1115
1272
  sessionId: string,
1116
1273
  reducer: Reducer<D, S>,
1274
+ throughIndex?: number,
1275
+ pinEventIndex?: number,
1117
1276
  ): Promise<{ state: S; index: number }> => {
1118
1277
  const store = await resolveStore()
1119
1278
  return telemetry.span(
@@ -1123,36 +1282,15 @@ export function createServer<
1123
1282
  'a2.session_id': sessionId,
1124
1283
  'a2.state.reducer': reducer.name,
1125
1284
  },
1126
- async (span) => {
1127
- const stateRead = store.readStates
1128
- ? await enqueueStateRead(store, sessionId, reducer.name)
1129
- : await readCachedState(store, sessionId, reducer.name)
1130
- const { state, index, folded } = await foldStateRead(
1285
+ (span) =>
1286
+ enqueueStateRead(
1131
1287
  store,
1132
1288
  sessionId,
1133
- reducer,
1134
- stateRead,
1289
+ reducer as Reducer<D, unknown>,
1290
+ throughIndex,
1291
+ pinEventIndex,
1135
1292
  span,
1136
- )
1137
- // Write-back is a disposable cache. Copy before returning so caller
1138
- // mutations cannot race the background persistence.
1139
- if (folded > 0) {
1140
- const snapshotState = cloneInitial(state)
1141
- const write = Promise.resolve()
1142
- .then(() =>
1143
- store.putSnapshot(
1144
- nsId(sessionId),
1145
- reducer.name,
1146
- index,
1147
- snapshotState,
1148
- ),
1149
- )
1150
- .catch(() => {})
1151
- track(write)
1152
- platformWaitUntil(write)
1153
- }
1154
- return { state, index }
1155
- },
1293
+ ) as Promise<{ state: S; index: number }>,
1156
1294
  )
1157
1295
  }
1158
1296
 
@@ -1176,11 +1314,11 @@ export function createServer<
1176
1314
  const payloads = events.map((event) =>
1177
1315
  cloneScheduledPayload(event.payload),
1178
1316
  )
1179
- const snapshottedEvents = events.map((event, index) => ({
1180
- type: event.type,
1181
- payload: payloads[index],
1182
- ...(event.id === undefined ? {} : { id: event.id }),
1183
- })) as AppendInput<D>[]
1317
+ const snapshottedEvents = events.map((event, index) =>
1318
+ event.id === undefined
1319
+ ? { type: event.type, payload: payloads[index] }
1320
+ : { type: event.type, payload: payloads[index], id: event.id },
1321
+ ) as AppendInput<D>[]
1184
1322
  const validated = validateEvents(
1185
1323
  sessionId,
1186
1324
  structuredClone(snapshottedEvents),
@@ -1591,7 +1729,18 @@ export function createServer<
1591
1729
  schedule: makeSchedule(id, trigger),
1592
1730
  history: async (bounds?: { gte?: number; lte?: number }) =>
1593
1731
  (await readHistory(id, bounds)).map(toPublic),
1594
- state: <S>(reducer: Reducer<D, S>) => readState(id, reducer),
1732
+ state: <S>(reducer: Reducer<D, S>, stateOptions?: StateOptions) => {
1733
+ const through = stateOptions?.through ?? trigger?.index
1734
+ if (through !== undefined && through !== 'latest') {
1735
+ assertStateIndex(through)
1736
+ }
1737
+ return readState(
1738
+ id,
1739
+ reducer,
1740
+ through === 'latest' ? undefined : through,
1741
+ trigger && through !== 'latest' ? trigger.index : undefined,
1742
+ )
1743
+ },
1595
1744
  stream: (opts?: { startAfter?: number; presence?: boolean }) => {
1596
1745
  const startAfter = opts?.startAfter ?? 0
1597
1746
  assertStreamIndex(startAfter)
@@ -2359,6 +2508,12 @@ function assertStreamIndex(value: number): void {
2359
2508
  }
2360
2509
  }
2361
2510
 
2511
+ function assertStateIndex(value: number): void {
2512
+ if (!Number.isSafeInteger(value) || value < 0) {
2513
+ throw new TypeError('state.through must be a non-negative safe integer')
2514
+ }
2515
+ }
2516
+
2362
2517
  function assertAppendName(name: string): void {
2363
2518
  if (typeof name !== 'string' || name.length === 0) {
2364
2519
  throw new TypeError('handler append name must be a non-empty string')
@@ -101,6 +101,12 @@ const toEvent = (row: Row): Event => ({
101
101
  createdAt: new Date(row.createdAt),
102
102
  })
103
103
 
104
+ const snapshotKey = (sessionId: string, reducerName: string): string =>
105
+ `${sessionId}\u0000${reducerName}`
106
+
107
+ const eventPinKey = (sessionId: string, index: number): string =>
108
+ `${sessionId}\u0000${index}`
109
+
104
110
  export function memory(options: MemoryStoreOptions = {}): A2Store {
105
111
  const clock = options.clock ?? SYSTEM_CLOCK
106
112
  const generateId = options.ids ?? RANDOM_IDS
@@ -114,6 +120,12 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
114
120
  string,
115
121
  { index: number; state: unknown; updatedAt: Date }
116
122
  >()
123
+ const historicalSnapshots = new Map<
124
+ string,
125
+ { index: number; state: unknown; updatedAt: Date }
126
+ >()
127
+ const snapshotPins = new Map<string, Set<string>>()
128
+ const snapshotPinCounts = new Map<string, number>()
117
129
  const streamSubscribers = new Map<string, Set<(row: Row) => void>>()
118
130
  /** ns → participant → field → latest surviving write. */
119
131
  const presenceRows = new Map<
@@ -142,6 +154,28 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
142
154
  return rows
143
155
  }
144
156
 
157
+ const checkpointKey = (
158
+ sessionId: string,
159
+ reducerName: string,
160
+ index: number,
161
+ ): string => `${snapshotKey(sessionId, reducerName)}\u0000${index}`
162
+
163
+ const releaseSnapshotPins = (sessionId: string, eventIndex: number): void => {
164
+ const pinKey = eventPinKey(sessionId, eventIndex)
165
+ const checkpoints = snapshotPins.get(pinKey)
166
+ if (!checkpoints) return
167
+ for (const key of checkpoints) {
168
+ const next = (snapshotPinCounts.get(key) ?? 1) - 1
169
+ if (next === 0) {
170
+ snapshotPinCounts.delete(key)
171
+ historicalSnapshots.delete(key)
172
+ } else {
173
+ snapshotPinCounts.set(key, next)
174
+ }
175
+ }
176
+ snapshotPins.delete(pinKey)
177
+ }
178
+
145
179
  const dispatchOf = (sessionId: string): SessionDispatch => {
146
180
  let state = dispatch.get(sessionId)
147
181
  if (!state) {
@@ -295,16 +329,41 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
295
329
  const readStateOf = (
296
330
  sessionId: string,
297
331
  reducerName: string,
332
+ throughIndex?: number,
333
+ snapshotThroughIndex?: number,
298
334
  ): StoreStateRead => {
299
- const snap = snapshots.get(`${sessionId}\u0000${reducerName}`)
300
- const snapshot = snap
301
- ? { index: snap.index, state: structuredClone(snap.state) }
335
+ const key = snapshotKey(sessionId, reducerName)
336
+ const snapshotFrontier = snapshotThroughIndex ?? throughIndex
337
+ const head = snapshots.get(key)
338
+ let candidate =
339
+ head && (snapshotFrontier === undefined || head.index <= snapshotFrontier)
340
+ ? head
341
+ : null
342
+ if (snapshotFrontier !== undefined) {
343
+ const prefix = `${key}\u0000`
344
+ for (const [historicalKey, snapshot] of historicalSnapshots) {
345
+ if (
346
+ historicalKey.startsWith(prefix) &&
347
+ snapshot.index <= snapshotFrontier &&
348
+ (!candidate || snapshot.index > candidate.index)
349
+ ) {
350
+ candidate = snapshot
351
+ }
352
+ }
353
+ }
354
+ const snapshot = candidate
355
+ ? { index: candidate.index, state: structuredClone(candidate.state) }
302
356
  : null
303
357
  const afterIndex = snapshot?.index ?? 0
304
358
  return {
359
+ headIndex: head?.index ?? null,
305
360
  snapshot,
306
361
  events: (sessions.get(sessionId) ?? [])
307
- .filter((row) => row.index > afterIndex)
362
+ .filter(
363
+ (row) =>
364
+ row.index > afterIndex &&
365
+ (throughIndex === undefined || row.index <= throughIndex),
366
+ )
308
367
  .map(toEvent),
309
368
  }
310
369
  }
@@ -488,6 +547,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
488
547
  parent.claimHolder = null
489
548
  parent.claimExpiresAt = null
490
549
  settle(sessionId, parent)
550
+ releaseSnapshotPins(sessionId, index)
491
551
  notifyStreams(sessionId, inserted)
492
552
  return { outcome: 'completed', events: inserted.map(toStored) }
493
553
  },
@@ -520,6 +580,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
520
580
  if (row.failureCount >= maxFailures) {
521
581
  row.failedAt = new Date(failedAt)
522
582
  dispatchOf(sessionId).ready.delete(row.index)
583
+ releaseSnapshotPins(sessionId, index)
523
584
  return {
524
585
  outcome: 'dead_lettered',
525
586
  failureCount: row.failureCount,
@@ -528,26 +589,83 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
528
589
  return { outcome: 'failed', failureCount: row.failureCount }
529
590
  },
530
591
 
531
- async readState(sessionId, reducerName) {
532
- return readStateOf(sessionId, reducerName)
592
+ async readState(sessionId, reducerName, stateOptions) {
593
+ return readStateOf(
594
+ sessionId,
595
+ reducerName,
596
+ stateOptions?.throughIndex,
597
+ stateOptions?.snapshotThroughIndex,
598
+ )
533
599
  },
534
600
 
535
- async readStates(sessionIds, reducerName) {
536
- return sessionIds.map((sessionId) => readStateOf(sessionId, reducerName))
601
+ async readStates(requests, reducerName) {
602
+ return requests.map((request) =>
603
+ readStateOf(
604
+ request.sessionId,
605
+ reducerName,
606
+ request.throughIndex,
607
+ request.snapshotThroughIndex,
608
+ ),
609
+ )
537
610
  },
538
611
 
539
- async putSnapshot(sessionId, reducerName, index, state) {
540
- const key = `${sessionId}\u0000${reducerName}`
541
- const existing = snapshots.get(key)
542
- // Guarded upsert: a slower concurrent writer must never clobber a
543
- // further-along snapshot. A lost race costs a few refolded events
544
- // next read — never correctness.
545
- if (existing && existing.index >= index) return
546
- snapshots.set(key, {
547
- index,
548
- state: structuredClone(state),
549
- updatedAt: clock.now(),
550
- })
612
+ async putSnapshots(sessionId, reducerName, writes) {
613
+ const key = snapshotKey(sessionId, reducerName)
614
+ for (const write of writes.toSorted((a, b) => a.index - b.index)) {
615
+ const checkpoint = checkpointKey(sessionId, reducerName, write.index)
616
+ for (const eventIndex of write.pinEventIndexes ?? []) {
617
+ const event = sessions.get(sessionId)?.[eventIndex - 1]
618
+ if (!event || event.processedAt !== null || event.failedAt !== null) {
619
+ continue
620
+ }
621
+ const pinKey = eventPinKey(sessionId, eventIndex)
622
+ let pinned = snapshotPins.get(pinKey)
623
+ if (!pinned) {
624
+ pinned = new Set()
625
+ snapshotPins.set(pinKey, pinned)
626
+ }
627
+ if (!pinned.has(checkpoint)) {
628
+ pinned.add(checkpoint)
629
+ snapshotPinCounts.set(
630
+ checkpoint,
631
+ (snapshotPinCounts.get(checkpoint) ?? 0) + 1,
632
+ )
633
+ }
634
+ }
635
+
636
+ const current = snapshots.get(key)
637
+ if (!current || write.index >= current.index) {
638
+ if (current && write.index > current.index) {
639
+ const currentCheckpoint = checkpointKey(
640
+ sessionId,
641
+ reducerName,
642
+ current.index,
643
+ )
644
+ if ((snapshotPinCounts.get(currentCheckpoint) ?? 0) > 0) {
645
+ historicalSnapshots.set(currentCheckpoint, {
646
+ index: current.index,
647
+ state: structuredClone(current.state),
648
+ updatedAt: new Date(current.updatedAt),
649
+ })
650
+ }
651
+ }
652
+ snapshots.set(key, {
653
+ index: write.index,
654
+ state: structuredClone(write.state),
655
+ updatedAt: clock.now(),
656
+ })
657
+ historicalSnapshots.delete(checkpoint)
658
+ } else if (
659
+ write.index < current.index &&
660
+ (snapshotPinCounts.get(checkpoint) ?? 0) > 0
661
+ ) {
662
+ historicalSnapshots.set(checkpoint, {
663
+ index: write.index,
664
+ state: structuredClone(write.state),
665
+ updatedAt: clock.now(),
666
+ })
667
+ }
668
+ }
551
669
  },
552
670
 
553
671
  presence: {