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
@@ -182,8 +182,24 @@ return { 'ok', tostring(base + 1), has_pending,
182
182
  tostring(redis.call('EXISTS', KEYS[7])) }
183
183
  `
184
184
 
185
- /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
185
+ /**
186
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
187
+ * ARGV: idx, attempt, error, maxFailures, nowMs.
188
+ */
186
189
  const FAIL_ATTEMPT_LUA = `
190
+ local function release_snapshot_pins()
191
+ local refs = redis.call('SMEMBERS', KEYS[5])
192
+ for _, ref in ipairs(refs) do
193
+ local count = redis.call('HINCRBY', KEYS[6], ref, -1)
194
+ if count <= 0 then
195
+ redis.call('HDEL', KEYS[6], ref)
196
+ local checkpoint = cjson.decode(ref)
197
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
198
+ end
199
+ end
200
+ redis.call('DEL', KEYS[5])
201
+ if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
202
+ end
187
203
  local function lane_field(lane, suffix)
188
204
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
189
205
  end
@@ -237,6 +253,7 @@ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
237
253
  if failures >= tonumber(ARGV[4]) then
238
254
  redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
239
255
  redis.call('ZREM', KEYS[4], field)
256
+ release_snapshot_pins()
240
257
  return { 'dead_lettered', tostring(failures) }
241
258
  end
242
259
  return { 'failed', tostring(failures) }
@@ -330,11 +347,25 @@ return result
330
347
  `
331
348
 
332
349
  /**
333
- * KEYS: store, counter, ids, meta, pending, ready, watched.
350
+ * KEYS: store, counter, ids, meta, pending, ready, watched,
351
+ * event snapshot pins, snapshot pin counts.
334
352
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
335
353
  * id, type, payloadJson, lane, settled.
336
354
  */
337
355
  const COMPLETE_ATTEMPT_LUA = `
356
+ local function release_snapshot_pins()
357
+ local refs = redis.call('SMEMBERS', KEYS[8])
358
+ for _, ref in ipairs(refs) do
359
+ local count = redis.call('HINCRBY', KEYS[9], ref, -1)
360
+ if count <= 0 then
361
+ redis.call('HDEL', KEYS[9], ref)
362
+ local checkpoint = cjson.decode(ref)
363
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
364
+ end
365
+ end
366
+ redis.call('DEL', KEYS[8])
367
+ if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
368
+ end
338
369
  local function lane_field(lane, suffix)
339
370
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
340
371
  end
@@ -433,6 +464,7 @@ redis.call('HSET', KEYS[4],
433
464
  parent .. ':ri', returned_json)
434
465
  redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
435
466
  settle(parent)
467
+ release_snapshot_pins()
436
468
  local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
437
469
  for i = 1, n do
438
470
  local id = ARGV[5 + (i - 1) * 5 + 1]
@@ -457,13 +489,61 @@ return { 'ok', tostring(total + 1),
457
489
  tostring(redis.call('EXISTS', KEYS[7])) }
458
490
  `
459
491
 
460
- /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
461
- const PUT_SNAPSHOT_LUA = `
462
- local cur = redis.call('GET', KEYS[1])
463
- if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
464
- return 0
492
+ /**
493
+ * KEYS: head, history, event counter, event meta, pin counts.
494
+ * ARGV: nowMs, event pin key prefix, write count, then per write:
495
+ * index, snapshot JSON, pin count, pin event indexes.
496
+ */
497
+ const PUT_SNAPSHOTS_LUA = `
498
+ local total = tonumber(redis.call('GET', KEYS[3]) or '0')
499
+ local writes = {}
500
+ local offset = 4
501
+ for i = 1, tonumber(ARGV[3]) do
502
+ local index = tonumber(ARGV[offset])
503
+ local snapshot = ARGV[offset + 1]
504
+ local pin_count = tonumber(ARGV[offset + 2])
505
+ local ref = cjson.encode({ KEYS[2], tostring(index) })
506
+ for p = 1, pin_count do
507
+ local event_index = tonumber(ARGV[offset + 2 + p])
508
+ if event_index >= 1 and event_index <= total and
509
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
510
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
511
+ local pin_key = ARGV[2] .. tostring(event_index)
512
+ if redis.call('SADD', pin_key, ref) == 1 then
513
+ redis.call('HINCRBY', KEYS[5], ref, 1)
514
+ end
515
+ end
516
+ end
517
+ writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
518
+ offset = offset + 3 + pin_count
519
+ end
520
+
521
+ local head = redis.call('GET', KEYS[1])
522
+ local head_index = nil
523
+ if head then head_index = tonumber(cjson.decode(head)['index']) end
524
+ local latest = writes[#writes]
525
+ local writes_head = not head_index or latest.index >= head_index
526
+ local advances = not head_index or latest.index > head_index
527
+ local final_head_index = advances and latest.index or head_index
528
+
529
+ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
530
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
531
+ redis.call('ZADD', KEYS[2], head_index, head)
532
+ end
533
+
534
+ for _, write in ipairs(writes) do
535
+ if write.index < final_head_index then
536
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
537
+ if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
538
+ redis.call('ZADD', KEYS[2], write.index, write.snapshot)
539
+ end
540
+ end
541
+ end
542
+
543
+ if writes_head then
544
+ redis.call('SET', KEYS[1], latest.snapshot)
545
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
465
546
  end
466
- redis.call('SET', KEYS[1], ARGV[2])
467
547
  return 1
468
548
  `
469
549
 
@@ -520,11 +600,26 @@ end
520
600
  return live
521
601
  `
522
602
 
523
- /** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
603
+ /**
604
+ * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
605
+ * Returns one atomic multiversion-cache-plus-log-frontier read.
606
+ */
524
607
  const READ_STATE_LUA = `
525
- local snapshot = redis.call('GET', KEYS[1])
526
- local frontier = redis.call('GET', KEYS[2]) or '0'
527
- return { snapshot or '', frontier }
608
+ local head = redis.call('GET', KEYS[1])
609
+ local cap = ARGV[1]
610
+ local snapshot = nil
611
+ if head and (cap == '' or tonumber(cjson.decode(head)['index']) <= tonumber(cap)) then
612
+ snapshot = head
613
+ end
614
+ local upper = cap == '' and '+inf' or cap
615
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
616
+ if #historical > 0 then
617
+ if not snapshot or tonumber(cjson.decode(historical[1])['index']) > tonumber(cjson.decode(snapshot)['index']) then
618
+ snapshot = historical[1]
619
+ end
620
+ end
621
+ local frontier = redis.call('GET', KEYS[3]) or '0'
622
+ return { head or '', snapshot or '', frontier }
528
623
  `
529
624
 
530
625
  // ── wire helpers ─────────────────────────────────────────────────────
@@ -681,6 +776,14 @@ export function createRedisStoreCore(
681
776
  const pendingKey = (s: string): string => `${prefix}:${s}:pending`
682
777
  const readyKey = (s: string): string => `${prefix}:${s}:ready`
683
778
  const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
779
+ const snapHistoryKey = (s: string, r: string): string =>
780
+ `${prefix}:${s}:snap-history:${r}`
781
+ const snapshotEventPinsKey = (s: string, index: number): string =>
782
+ `${prefix}:${s}:snapshot-pins:${index}`
783
+ const snapshotEventPinsPrefix = (s: string): string =>
784
+ `${prefix}:${s}:snapshot-pins:`
785
+ const snapshotPinCountsKey = (s: string): string =>
786
+ `${prefix}:${s}:snapshot-pin-counts`
684
787
  const watchedKey = (s: string): string => `${prefix}:${s}:watched`
685
788
  const presenceKey = (s: string): string => `${prefix}:${s}:presence`
686
789
  const idsKey = `${prefix}:ids`
@@ -1068,6 +1171,8 @@ export function createRedisStoreCore(
1068
1171
  pendingKey(sessionId),
1069
1172
  readyKey(sessionId),
1070
1173
  watchedKey(sessionId),
1174
+ snapshotEventPinsKey(sessionId, index),
1175
+ snapshotPinCountsKey(sessionId),
1071
1176
  ],
1072
1177
  [
1073
1178
  sessionId,
@@ -1160,6 +1265,8 @@ export function createRedisStoreCore(
1160
1265
  metaKey(sessionId),
1161
1266
  pendingKey(sessionId),
1162
1267
  readyKey(sessionId),
1268
+ snapshotEventPinsKey(sessionId, index),
1269
+ snapshotPinCountsKey(sessionId),
1163
1270
  ],
1164
1271
  [index, attempt, error, maxFailures, clock.now().getTime()],
1165
1272
  )) as string[]
@@ -1176,22 +1283,37 @@ export function createRedisStoreCore(
1176
1283
  })
1177
1284
  },
1178
1285
 
1179
- async readState(sessionId, reducerName) {
1286
+ async readState(sessionId, reducerName, stateOptions) {
1180
1287
  return wrap(async () => {
1181
1288
  const reply = (await evalScript(
1182
1289
  READ_STATE_LUA,
1183
- [snapKey(sessionId, reducerName), countKey(sessionId)],
1184
- [],
1185
- )) as [string, string]
1186
- const raw = reply[0]
1290
+ [
1291
+ snapKey(sessionId, reducerName),
1292
+ snapHistoryKey(sessionId, reducerName),
1293
+ countKey(sessionId),
1294
+ ],
1295
+ [
1296
+ stateOptions?.snapshotThroughIndex ??
1297
+ stateOptions?.throughIndex ??
1298
+ '',
1299
+ ],
1300
+ )) as [string, string, string]
1301
+ const rawHead = reply[0]
1302
+ const raw = reply[1]
1303
+ const head =
1304
+ rawHead === ''
1305
+ ? null
1306
+ : (JSON.parse(rawHead) as { index: number; state: unknown })
1187
1307
  const parsed =
1188
1308
  raw === ''
1189
1309
  ? null
1190
1310
  : (JSON.parse(raw) as { index: number; state: unknown })
1191
- const frontier = Number(reply[1])
1311
+ const currentFrontier = Number(reply[2])
1192
1312
  if (
1193
- !Number.isSafeInteger(frontier) ||
1194
- frontier < 0 ||
1313
+ !Number.isSafeInteger(currentFrontier) ||
1314
+ currentFrontier < 0 ||
1315
+ (head !== null &&
1316
+ (!Number.isSafeInteger(head.index) || head.index < 0)) ||
1195
1317
  (parsed !== null &&
1196
1318
  (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
1197
1319
  ) {
@@ -1200,26 +1322,78 @@ export function createRedisStoreCore(
1200
1322
  `redis state frontier for session '${sessionId}' is invalid`,
1201
1323
  )
1202
1324
  }
1325
+ const frontier = Math.min(
1326
+ currentFrontier,
1327
+ stateOptions?.throughIndex ?? currentFrontier,
1328
+ )
1329
+ const snapshot = parsed
1203
1330
  const events = await readEventRange(
1204
1331
  sessionId,
1205
- parsed?.index ?? 0,
1332
+ snapshot?.index ?? 0,
1206
1333
  frontier,
1207
1334
  )
1208
1335
  return {
1209
- snapshot: parsed
1210
- ? { index: parsed.index, state: parsed.state }
1336
+ headIndex: head?.index ?? null,
1337
+ snapshot: snapshot
1338
+ ? { index: snapshot.index, state: snapshot.state }
1211
1339
  : null,
1212
1340
  events,
1213
1341
  }
1214
1342
  })
1215
1343
  },
1216
1344
 
1217
- async putSnapshot(sessionId, reducerName, index, state) {
1345
+ async putSnapshots(sessionId, reducerName, writes) {
1346
+ const byIndex = new Map<
1347
+ number,
1348
+ { index: number; state: unknown; pinEventIndexes: Set<number> }
1349
+ >()
1350
+ for (const write of writes) {
1351
+ const existing = byIndex.get(write.index)
1352
+ if (existing) {
1353
+ existing.state = write.state
1354
+ for (const eventIndex of write.pinEventIndexes ?? []) {
1355
+ existing.pinEventIndexes.add(eventIndex)
1356
+ }
1357
+ } else {
1358
+ byIndex.set(write.index, {
1359
+ index: write.index,
1360
+ state: write.state,
1361
+ pinEventIndexes: new Set(write.pinEventIndexes ?? []),
1362
+ })
1363
+ }
1364
+ }
1365
+ const normalized = [...byIndex.values()].toSorted(
1366
+ (a, b) => a.index - b.index,
1367
+ )
1368
+ if (normalized.length === 0) return
1218
1369
  await wrap(async () => {
1370
+ const args: Array<string | number> = [
1371
+ clock.now().getTime(),
1372
+ snapshotEventPinsPrefix(sessionId),
1373
+ normalized.length,
1374
+ ]
1375
+ for (const write of normalized) {
1376
+ args.push(
1377
+ write.index,
1378
+ json({
1379
+ index: write.index,
1380
+ state: write.state,
1381
+ updatedAt: clock.now().getTime(),
1382
+ }),
1383
+ write.pinEventIndexes.size,
1384
+ ...write.pinEventIndexes,
1385
+ )
1386
+ }
1219
1387
  await evalScript(
1220
- PUT_SNAPSHOT_LUA,
1221
- [snapKey(sessionId, reducerName)],
1222
- [index, json({ index, state, updatedAt: clock.now().getTime() })],
1388
+ PUT_SNAPSHOTS_LUA,
1389
+ [
1390
+ snapKey(sessionId, reducerName),
1391
+ snapHistoryKey(sessionId, reducerName),
1392
+ countKey(sessionId),
1393
+ metaKey(sessionId),
1394
+ snapshotPinCountsKey(sessionId),
1395
+ ],
1396
+ args,
1223
1397
  )
1224
1398
  })
1225
1399
  },
@@ -95,7 +95,7 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
95
95
  completeAttempt: core.completeAttempt,
96
96
  failAttempt: core.failAttempt,
97
97
  readState: core.readState,
98
- putSnapshot: core.putSnapshot,
98
+ putSnapshots: core.putSnapshots,
99
99
  presence: {
100
100
  async set(ns, participant, values, meta) {
101
101
  await core.presence.set(ns, participant, values, meta)
@@ -291,7 +291,7 @@ export function redis(options: RedisStoreOptions = {}): RedisStore {
291
291
  completeAttempt: core.completeAttempt,
292
292
  failAttempt: core.failAttempt,
293
293
  readState: core.readState,
294
- putSnapshot: core.putSnapshot,
294
+ putSnapshots: core.putSnapshots,
295
295
 
296
296
  presence: {
297
297
  async set(ns, participant, values, meta) {
@@ -92,6 +92,26 @@ create table if not exists a2_snapshots (
92
92
  primary key (session_id, reducer_name)
93
93
  ) strict;
94
94
 
95
+ create table if not exists a2_snapshot_history (
96
+ session_id text not null,
97
+ reducer_name text not null,
98
+ up_to_index integer not null,
99
+ state text not null,
100
+ updated_at integer not null,
101
+ primary key (session_id, reducer_name, up_to_index)
102
+ ) strict;
103
+
104
+ create table if not exists a2_snapshot_pins (
105
+ session_id text not null,
106
+ event_index integer not null,
107
+ reducer_name text not null,
108
+ up_to_index integer not null,
109
+ primary key (session_id, event_index, reducer_name, up_to_index)
110
+ ) strict;
111
+
112
+ create index if not exists a2_snapshot_pins_checkpoint
113
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
114
+
95
115
  create table if not exists a2_presence (
96
116
  ns text not null,
97
117
  participant text not null,
@@ -242,10 +262,17 @@ type PresenceDbRow = {
242
262
 
243
263
  type StateReadRow = Partial<EventRow> & {
244
264
  row_kind: 'snapshot' | 'event'
265
+ head_index: number | bigint | null
245
266
  snapshot_index: number | bigint | null
246
267
  snapshot_state: string | null
247
268
  }
248
269
 
270
+ type SnapshotHeadRow = {
271
+ up_to_index: number | bigint
272
+ state: string
273
+ updated_at: number | bigint
274
+ }
275
+
249
276
  type AppendStatusRow = {
250
277
  max: number | bigint
251
278
  has_pending: number | bigint
@@ -747,6 +774,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
747
774
  claim_expires_at = null
748
775
  where session_id = ? and idx = ?`,
749
776
  ).run(now, attempt, JSON.stringify(ids), sessionId, index)
777
+ db.prepare(
778
+ 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
779
+ ).run(sessionId, index)
780
+ db.prepare(
781
+ `delete from a2_snapshot_history
782
+ where session_id = ?
783
+ and not exists (
784
+ select 1 from a2_snapshot_pins pin
785
+ where pin.session_id = a2_snapshot_history.session_id
786
+ and pin.reducer_name = a2_snapshot_history.reducer_name
787
+ and pin.up_to_index = a2_snapshot_history.up_to_index
788
+ )`,
789
+ ).run(sessionId)
750
790
  if (parent.lane !== null) {
751
791
  promoteLaneHead.run(sessionId, sessionId, parent.lane)
752
792
  }
@@ -835,6 +875,21 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
835
875
  sessionId,
836
876
  index,
837
877
  )
878
+ if (deadLettered) {
879
+ db.prepare(
880
+ 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
881
+ ).run(sessionId, index)
882
+ db.prepare(
883
+ `delete from a2_snapshot_history
884
+ where session_id = ?
885
+ and not exists (
886
+ select 1 from a2_snapshot_pins pin
887
+ where pin.session_id = a2_snapshot_history.session_id
888
+ and pin.reducer_name = a2_snapshot_history.reducer_name
889
+ and pin.up_to_index = a2_snapshot_history.up_to_index
890
+ )`,
891
+ ).run(sessionId)
892
+ }
838
893
  return {
839
894
  outcome: deadLettered ? 'dead_lettered' : 'failed',
840
895
  failureCount: nextFailureCount,
@@ -843,18 +898,32 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
843
898
  )
844
899
  },
845
900
 
846
- async readState(sessionId, reducerName) {
901
+ async readState(sessionId, reducerName, stateOptions) {
847
902
  return wrap(() => {
903
+ const throughIndex = stateOptions?.throughIndex ?? null
904
+ const snapshotThroughIndex =
905
+ stateOptions?.snapshotThroughIndex ?? throughIndex
848
906
  const rows = db
849
907
  .prepare(
850
- `with snapshot as materialized (
851
- select up_to_index, state
908
+ `with head as materialized (
909
+ select up_to_index, state, updated_at
852
910
  from a2_snapshots
853
- where session_id = ? and reducer_name = ?
911
+ where session_id = ? and reducer_name = ?
912
+ ), snapshot as materialized (
913
+ select up_to_index, state from (
914
+ select up_to_index, state from head
915
+ where (? is null or up_to_index <= ?)
916
+ union all
917
+ select up_to_index, state
918
+ from a2_snapshot_history
919
+ where session_id = ? and reducer_name = ?
920
+ and (? is null or up_to_index <= ?)
921
+ ) order by up_to_index desc limit 1
854
922
  )
855
923
  select 0 as row_order, 'snapshot' as row_kind,
856
- snapshot.up_to_index as snapshot_index,
857
- snapshot.state as snapshot_state,
924
+ (select up_to_index from head) as head_index,
925
+ (select up_to_index from snapshot) as snapshot_index,
926
+ (select state from snapshot) as snapshot_state,
858
927
  null as session_id, null as idx, null as event_type,
859
928
  null as payload, null as event_id, null as created_at,
860
929
  null as cause, null as lane, null as lane_ready,
@@ -866,25 +935,44 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
866
935
  null as last_failed_attempt, null as last_error,
867
936
  null as failed_at, null as claim_holder,
868
937
  null as claim_expires_at
869
- from snapshot
870
938
  union all
871
939
  select 1 as row_order, 'event' as row_kind,
940
+ (select up_to_index from head) as head_index,
872
941
  null as snapshot_index, null as snapshot_state,
873
942
  event.*
874
943
  from a2_events as event
875
944
  where event.session_id = ?
876
945
  and event.idx > coalesce((select up_to_index from snapshot), 0)
946
+ and (? is null or event.idx <= ?)
877
947
  order by row_order, idx`,
878
948
  )
879
- .all(sessionId, reducerName, sessionId) as unknown as StateReadRow[]
949
+ .all(
950
+ sessionId,
951
+ reducerName,
952
+ snapshotThroughIndex,
953
+ snapshotThroughIndex,
954
+ sessionId,
955
+ reducerName,
956
+ snapshotThroughIndex,
957
+ snapshotThroughIndex,
958
+ sessionId,
959
+ throughIndex,
960
+ throughIndex,
961
+ ) as unknown as StateReadRow[]
880
962
  const snapshot = rows.find((row) => row.row_kind === 'snapshot')
881
963
  return {
882
- snapshot: snapshot
883
- ? {
884
- index: Number(snapshot.snapshot_index),
885
- state: JSON.parse(snapshot.snapshot_state!),
886
- }
887
- : null,
964
+ headIndex:
965
+ snapshot?.head_index === null || snapshot?.head_index === undefined
966
+ ? null
967
+ : Number(snapshot.head_index),
968
+ snapshot:
969
+ snapshot?.snapshot_index !== null &&
970
+ snapshot?.snapshot_index !== undefined
971
+ ? {
972
+ index: Number(snapshot.snapshot_index),
973
+ state: JSON.parse(snapshot.snapshot_state!),
974
+ }
975
+ : null,
888
976
  events: rows
889
977
  .filter(
890
978
  (row): row is StateReadRow & EventRow => row.row_kind === 'event',
@@ -894,26 +982,95 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
894
982
  })
895
983
  },
896
984
 
897
- async putSnapshot(sessionId, reducerName, index, state) {
898
- wrap(() => {
899
- // Guarded upsert: a slower concurrent writer must never clobber
900
- // a further-along snapshot (spec §3).
901
- db.prepare(
902
- `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
903
- values (?, ?, ?, ?, ?)
904
- on conflict (session_id, reducer_name) do update set
905
- up_to_index = excluded.up_to_index,
906
- state = excluded.state,
907
- updated_at = excluded.updated_at
908
- where excluded.up_to_index > a2_snapshots.up_to_index`,
909
- ).run(
910
- sessionId,
911
- reducerName,
912
- index,
913
- JSON.stringify(state) ?? 'null',
914
- clock.now().getTime(),
915
- )
916
- })
985
+ async putSnapshots(sessionId, reducerName, writes) {
986
+ await wrap(() =>
987
+ tx(() => {
988
+ const selectHead = db.prepare(
989
+ `select up_to_index, state, updated_at from a2_snapshots
990
+ where session_id = ? and reducer_name = ?`,
991
+ )
992
+ const unfinished = db.prepare(
993
+ `select 1 from a2_events
994
+ where session_id = ? and idx = ?
995
+ and processed_at is null and failed_at is null`,
996
+ )
997
+ const insertPin = db.prepare(
998
+ `insert or ignore into a2_snapshot_pins
999
+ (session_id, event_index, reducer_name, up_to_index)
1000
+ values (?, ?, ?, ?)`,
1001
+ )
1002
+ const hasPin = db.prepare(
1003
+ `select 1 from a2_snapshot_pins
1004
+ where session_id = ? and reducer_name = ? and up_to_index = ?
1005
+ limit 1`,
1006
+ )
1007
+ const putHistory = db.prepare(
1008
+ `insert into a2_snapshot_history
1009
+ (session_id, reducer_name, up_to_index, state, updated_at)
1010
+ values (?, ?, ?, ?, ?)
1011
+ on conflict (session_id, reducer_name, up_to_index) do update set
1012
+ state = excluded.state, updated_at = excluded.updated_at`,
1013
+ )
1014
+ const putHead = db.prepare(
1015
+ `insert into a2_snapshots
1016
+ (session_id, reducer_name, up_to_index, state, updated_at)
1017
+ values (?, ?, ?, ?, ?)
1018
+ on conflict (session_id, reducer_name) do update set
1019
+ up_to_index = excluded.up_to_index,
1020
+ state = excluded.state,
1021
+ updated_at = excluded.updated_at
1022
+ where excluded.up_to_index >= a2_snapshots.up_to_index`,
1023
+ )
1024
+ const deleteHistory = db.prepare(
1025
+ `delete from a2_snapshot_history
1026
+ where session_id = ? and reducer_name = ? and up_to_index = ?`,
1027
+ )
1028
+ for (const write of writes.toSorted((a, b) => a.index - b.index)) {
1029
+ for (const eventIndex of write.pinEventIndexes ?? []) {
1030
+ if (unfinished.get(sessionId, eventIndex)) {
1031
+ insertPin.run(sessionId, eventIndex, reducerName, write.index)
1032
+ }
1033
+ }
1034
+ const head = selectHead.get(sessionId, reducerName) as
1035
+ SnapshotHeadRow | undefined
1036
+ const now = clock.now().getTime()
1037
+ if (!head || write.index >= Number(head.up_to_index)) {
1038
+ if (
1039
+ head &&
1040
+ write.index > Number(head.up_to_index) &&
1041
+ hasPin.get(sessionId, reducerName, Number(head.up_to_index))
1042
+ ) {
1043
+ putHistory.run(
1044
+ sessionId,
1045
+ reducerName,
1046
+ Number(head.up_to_index),
1047
+ head.state,
1048
+ Number(head.updated_at),
1049
+ )
1050
+ }
1051
+ putHead.run(
1052
+ sessionId,
1053
+ reducerName,
1054
+ write.index,
1055
+ JSON.stringify(write.state) ?? 'null',
1056
+ now,
1057
+ )
1058
+ deleteHistory.run(sessionId, reducerName, write.index)
1059
+ } else if (
1060
+ write.index < Number(head.up_to_index) &&
1061
+ hasPin.get(sessionId, reducerName, write.index)
1062
+ ) {
1063
+ putHistory.run(
1064
+ sessionId,
1065
+ reducerName,
1066
+ write.index,
1067
+ JSON.stringify(write.state) ?? 'null',
1068
+ now,
1069
+ )
1070
+ }
1071
+ }
1072
+ }),
1073
+ )
917
1074
  },
918
1075
 
919
1076
  presence: {