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
@@ -71,6 +71,26 @@ create table if not exists a2_snapshots (
71
71
  primary key (session_id, reducer_name)
72
72
  );
73
73
 
74
+ create table if not exists a2_snapshot_history (
75
+ session_id text not null,
76
+ reducer_name text not null,
77
+ up_to_index bigint not null,
78
+ state jsonb not null,
79
+ updated_at bigint not null,
80
+ primary key (session_id, reducer_name, up_to_index)
81
+ );
82
+
83
+ create table if not exists a2_snapshot_pins (
84
+ session_id text not null,
85
+ event_index bigint not null,
86
+ reducer_name text not null,
87
+ up_to_index bigint not null,
88
+ primary key (session_id, event_index, reducer_name, up_to_index)
89
+ );
90
+
91
+ create index if not exists a2_snapshot_pins_checkpoint
92
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
93
+
74
94
  create table if not exists a2_presence (
75
95
  ns text not null,
76
96
  participant text not null,
@@ -175,6 +195,90 @@ function postgres(options = {}) {
175
195
  }
176
196
  });
177
197
  const client = connection.get;
198
+ const readStateBatch = async (requests, reducerName) => {
199
+ if (requests.length === 0) return [];
200
+ const c = await client();
201
+ const input = requests.map((request, position) => ({
202
+ position,
203
+ sessionId: request.sessionId,
204
+ throughIndex: request.throughIndex ?? null,
205
+ snapshotThroughIndex: request.snapshotThroughIndex ?? request.throughIndex ?? null
206
+ }));
207
+ const { rows } = await c.query(`with request as materialized (
208
+ select * from jsonb_to_recordset($1::jsonb) as value(
209
+ position integer,
210
+ "sessionId" text,
211
+ "throughIndex" bigint,
212
+ "snapshotThroughIndex" bigint
213
+ )
214
+ ), selected as materialized (
215
+ select request.position,
216
+ request."sessionId" as session_id,
217
+ request."throughIndex" as through_index,
218
+ head.up_to_index as head_index,
219
+ candidate.up_to_index as snapshot_index,
220
+ candidate.state as snapshot_state
221
+ from request
222
+ left join a2_snapshots head
223
+ on head.session_id = request."sessionId"
224
+ and head.reducer_name = $2
225
+ left join lateral (
226
+ select up_to_index, state from (
227
+ select head.up_to_index, head.state
228
+ where request."snapshotThroughIndex" is null
229
+ or head.up_to_index <= request."snapshotThroughIndex"
230
+ union all
231
+ select history.up_to_index, history.state
232
+ from a2_snapshot_history history
233
+ where history.session_id = request."sessionId"
234
+ and history.reducer_name = $2
235
+ and (
236
+ request."snapshotThroughIndex" is null
237
+ or history.up_to_index <= request."snapshotThroughIndex"
238
+ )
239
+ ) candidates
240
+ order by up_to_index desc
241
+ limit 1
242
+ ) candidate on true
243
+ )
244
+ select selected.position, 0 as row_order, selected.session_id,
245
+ selected.head_index, selected.snapshot_index,
246
+ selected.snapshot_state,
247
+ null::bigint as idx, null::text as event_type,
248
+ null::jsonb as payload, null::text as event_id,
249
+ null::bigint as created_at
250
+ from selected
251
+ union all
252
+ select selected.position, 1 as row_order, event.session_id,
253
+ selected.head_index, null::bigint as snapshot_index,
254
+ null::jsonb as snapshot_state,
255
+ event.idx, event.event_type, event.payload,
256
+ event.event_id, event.created_at
257
+ from selected
258
+ join a2_events event on event.session_id = selected.session_id
259
+ where event.idx > coalesce(selected.snapshot_index, 0)
260
+ and (
261
+ selected.through_index is null
262
+ or event.idx <= selected.through_index
263
+ )
264
+ order by position, row_order, idx`, [jsonb(input), reducerName]);
265
+ const reads = requests.map(() => ({
266
+ headIndex: null,
267
+ snapshot: null,
268
+ events: []
269
+ }));
270
+ for (const row of rows) {
271
+ const read = reads[asNumber(row["position"])];
272
+ if (row["row_order"] === 0 || row["row_order"] === "0") {
273
+ read.headIndex = row["head_index"] === null ? null : asNumber(row["head_index"]);
274
+ if (row["snapshot_index"] !== null) read.snapshot = {
275
+ index: asNumber(row["snapshot_index"]),
276
+ state: row["snapshot_state"]
277
+ };
278
+ } else read.events.push(toEvent(row));
279
+ }
280
+ return reads;
281
+ };
178
282
  let mutex = Promise.resolve();
179
283
  const withTx = async (fn) => {
180
284
  const c = await client();
@@ -520,6 +624,16 @@ function postgres(options = {}) {
520
624
  index,
521
625
  jsonb(ids)
522
626
  ]);
627
+ await query(`delete from a2_snapshot_pins
628
+ where session_id = $1 and event_index = $2`, [sessionId, index]);
629
+ await query(`delete from a2_snapshot_history history
630
+ where history.session_id = $1
631
+ and not exists (
632
+ select 1 from a2_snapshot_pins pin
633
+ where pin.session_id = history.session_id
634
+ and pin.reducer_name = history.reducer_name
635
+ and pin.up_to_index = history.up_to_index
636
+ )`, [sessionId]);
523
637
  const parentLane = parent["lane"] ?? null;
524
638
  if (parentLane !== null) await query(`update a2_events set lane_ready = true
525
639
  where session_id = $1
@@ -535,8 +649,8 @@ function postgres(options = {}) {
535
649
  }));
536
650
  },
537
651
  async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
538
- return wrap(async () => {
539
- const { rows } = await (await client()).query(`with current as materialized (
652
+ return wrap(() => withTx(async (query) => {
653
+ const { rows } = await query(`with current as materialized (
540
654
  select e.* from a2_events e
541
655
  where e.session_id = $1 and e.idx = $2
542
656
  for update of e
@@ -593,116 +707,131 @@ function postgres(options = {}) {
593
707
  ]);
594
708
  const row = rows[0];
595
709
  if (!row || row["outcome"] === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
710
+ if (row["outcome"] === "dead_lettered") {
711
+ await query(`delete from a2_snapshot_pins
712
+ where session_id = $1 and event_index = $2`, [sessionId, index]);
713
+ await query(`delete from a2_snapshot_history history
714
+ where history.session_id = $1
715
+ and not exists (
716
+ select 1 from a2_snapshot_pins pin
717
+ where pin.session_id = history.session_id
718
+ and pin.reducer_name = history.reducer_name
719
+ and pin.up_to_index = history.up_to_index
720
+ )`, [sessionId]);
721
+ }
596
722
  return {
597
723
  outcome: row["outcome"],
598
724
  failureCount: asNumber(row["failure_count"])
599
725
  };
600
- });
726
+ }));
601
727
  },
602
- async readState(sessionId, reducerName) {
603
- return wrap(async () => {
604
- const { rows } = await (await client()).query(`with snapshot as materialized (
605
- select up_to_index, state
606
- from a2_snapshots
607
- where session_id = $1 and reducer_name = $2
608
- )
609
- select 0 as row_order, 'snapshot' as row_kind,
610
- snapshot.up_to_index as snapshot_index,
611
- snapshot.state as snapshot_state,
612
- null::text as session_id, null::bigint as idx,
613
- null::text as event_type, null::jsonb as payload,
614
- null::text as event_id, null::bigint as created_at
615
- from snapshot
616
- union all
617
- select 1 as row_order, 'event' as row_kind,
618
- null::bigint as snapshot_index,
619
- null::jsonb as snapshot_state,
620
- event.session_id, event.idx, event.event_type,
621
- event.payload, event.event_id, event.created_at
622
- from a2_events event
623
- where event.session_id = $1
624
- and event.idx > coalesce((select up_to_index from snapshot), 0)
625
- order by row_order, idx`, [sessionId, reducerName]);
626
- const snapshot = rows.find((row) => row["row_kind"] === "snapshot");
627
- return {
628
- snapshot: snapshot ? {
629
- index: asNumber(snapshot["snapshot_index"]),
630
- state: snapshot["snapshot_state"]
631
- } : null,
632
- events: rows.filter((row) => row["row_kind"] === "event").map(toEvent)
633
- };
634
- });
728
+ async readState(sessionId, reducerName, stateOptions) {
729
+ return wrap(async () => (await readStateBatch([{
730
+ sessionId,
731
+ ...stateOptions?.throughIndex === void 0 ? {} : { throughIndex: stateOptions.throughIndex },
732
+ ...stateOptions?.snapshotThroughIndex === void 0 ? {} : { snapshotThroughIndex: stateOptions.snapshotThroughIndex }
733
+ }], reducerName))[0]);
635
734
  },
636
- async readStates(sessionIds, reducerName) {
637
- if (sessionIds.length === 0) return [];
638
- return wrap(async () => {
639
- const { rows } = await (await client()).query(`with snapshot as materialized (
640
- select session_id, up_to_index, state
641
- from a2_snapshots
642
- where session_id = any($1::text[]) and reducer_name = $2
643
- )
644
- select 0 as row_order, 'snapshot' as row_kind,
645
- snapshot.session_id,
646
- snapshot.up_to_index as snapshot_index,
647
- snapshot.state as snapshot_state,
648
- null::bigint as idx, null::text as event_type,
649
- null::jsonb as payload, null::text as event_id,
650
- null::bigint as created_at
651
- from snapshot
652
- union all
653
- select 1 as row_order, 'event' as row_kind,
654
- event.session_id,
655
- null::bigint as snapshot_index,
656
- null::jsonb as snapshot_state,
657
- event.idx, event.event_type, event.payload,
658
- event.event_id, event.created_at
659
- from a2_events event
660
- left join snapshot on snapshot.session_id = event.session_id
661
- where event.session_id = any($1::text[])
662
- and event.idx > coalesce(snapshot.up_to_index, 0)
663
- order by row_order, session_id, idx`, [sessionIds, reducerName]);
664
- const bySession = /* @__PURE__ */ new Map();
665
- const readOf = (sessionId) => {
666
- let read = bySession.get(sessionId);
667
- if (!read) {
668
- read = {
669
- snapshot: null,
670
- events: []
671
- };
672
- bySession.set(sessionId, read);
673
- }
674
- return read;
675
- };
676
- for (const row of rows) {
677
- const read = readOf(row["session_id"]);
678
- if (row["row_kind"] === "snapshot") read.snapshot = {
679
- index: asNumber(row["snapshot_index"]),
680
- state: row["snapshot_state"]
681
- };
682
- else read.events.push(toEvent(row));
735
+ async readStates(requests, reducerName) {
736
+ return wrap(() => readStateBatch(requests, reducerName));
737
+ },
738
+ async putSnapshots(sessionId, reducerName, writes) {
739
+ await wrap(() => withTx(async (query) => {
740
+ const normalized = [...new Map(writes.map((write) => [write.index, write])).values()].toSorted((a, b) => a.index - b.index);
741
+ if (normalized.length === 0) return;
742
+ await query("select pg_advisory_xact_lock(hashtext($1), hashtext($2))", [sessionId, reducerName]);
743
+ const requestedPins = normalized.flatMap((write) => (write.pinEventIndexes ?? []).map((eventIndex) => ({
744
+ eventIndex,
745
+ snapshotIndex: write.index
746
+ })));
747
+ if (requestedPins.length > 0) {
748
+ const { rows: events } = await query(`select idx, processed_at, failed_at from a2_events
749
+ where session_id = $1 and idx = any($2::bigint[])
750
+ for update`, [sessionId, [...new Set(requestedPins.map((pin) => pin.eventIndex))]]);
751
+ const unfinished = new Set(events.filter((row) => row["processed_at"] === null && row["failed_at"] === null).map((row) => asNumber(row["idx"])));
752
+ const accepted = requestedPins.filter((pin) => unfinished.has(pin.eventIndex));
753
+ if (accepted.length > 0) await query(`insert into a2_snapshot_pins
754
+ (session_id, event_index, reducer_name, up_to_index)
755
+ select $1, pin."eventIndex", $2, pin."snapshotIndex"
756
+ from jsonb_to_recordset($3::jsonb) as pin(
757
+ "eventIndex" bigint,
758
+ "snapshotIndex" bigint
759
+ )
760
+ on conflict do nothing`, [
761
+ sessionId,
762
+ reducerName,
763
+ jsonb(accepted)
764
+ ]);
683
765
  }
684
- return sessionIds.map((sessionId) => bySession.get(sessionId) ?? {
685
- snapshot: null,
686
- events: []
766
+ const { rows: headRows } = await query(`select up_to_index, state, updated_at from a2_snapshots
767
+ where session_id = $1 and reducer_name = $2
768
+ for update`, [sessionId, reducerName]);
769
+ const currentHead = headRows[0];
770
+ const latestWrite = normalized.at(-1);
771
+ const writesHead = !currentHead || latestWrite.index >= asNumber(currentHead["up_to_index"]);
772
+ const advancesHead = !currentHead || latestWrite.index > asNumber(currentHead["up_to_index"]);
773
+ const finalHeadIndex = advancesHead ? latestWrite.index : asNumber(currentHead["up_to_index"]);
774
+ const { rows: pinRows } = await query(`select distinct up_to_index from a2_snapshot_pins
775
+ where session_id = $1 and reducer_name = $2
776
+ and up_to_index = any($3::bigint[])`, [
777
+ sessionId,
778
+ reducerName,
779
+ [.../* @__PURE__ */ new Set([...normalized.map((write) => write.index), ...currentHead ? [asNumber(currentHead["up_to_index"])] : []])]
780
+ ]);
781
+ const pinned = new Set(pinRows.map((row) => asNumber(row["up_to_index"])));
782
+ const historical = /* @__PURE__ */ new Map();
783
+ if (advancesHead && currentHead && pinned.has(asNumber(currentHead["up_to_index"]))) {
784
+ const index = asNumber(currentHead["up_to_index"]);
785
+ historical.set(index, {
786
+ index,
787
+ state: currentHead["state"],
788
+ updatedAt: asNumber(currentHead["updated_at"])
789
+ });
790
+ }
791
+ const updatedAt = clock.now().getTime();
792
+ for (const write of normalized) if (write.index < finalHeadIndex && pinned.has(write.index)) historical.set(write.index, {
793
+ index: write.index,
794
+ state: write.state,
795
+ updatedAt
687
796
  });
688
- });
689
- },
690
- async putSnapshot(sessionId, reducerName, index, state) {
691
- await wrap(async () => {
692
- await (await client()).query(`insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
693
- values ($1, $2, $3, $4::jsonb, $5)
694
- on conflict (session_id, reducer_name) do update set
695
- up_to_index = excluded.up_to_index,
696
- state = excluded.state,
697
- updated_at = excluded.updated_at
698
- where excluded.up_to_index > a2_snapshots.up_to_index`, [
797
+ if (historical.size > 0) await query(`insert into a2_snapshot_history
798
+ (session_id, reducer_name, up_to_index, state, updated_at)
799
+ select $1, $2, snapshot.index, snapshot.state, snapshot."updatedAt"
800
+ from jsonb_to_recordset($3::jsonb) as snapshot(
801
+ index bigint,
802
+ state jsonb,
803
+ "updatedAt" bigint
804
+ )
805
+ on conflict (session_id, reducer_name, up_to_index) do update set
806
+ state = excluded.state,
807
+ updated_at = excluded.updated_at`, [
699
808
  sessionId,
700
809
  reducerName,
701
- index,
702
- jsonb(state),
703
- clock.now().getTime()
810
+ jsonb([...historical.values()])
704
811
  ]);
705
- });
812
+ if (writesHead) {
813
+ await query(`insert into a2_snapshots
814
+ (session_id, reducer_name, up_to_index, state, updated_at)
815
+ values ($1, $2, $3, $4::jsonb, $5)
816
+ on conflict (session_id, reducer_name) do update set
817
+ up_to_index = excluded.up_to_index,
818
+ state = excluded.state,
819
+ updated_at = excluded.updated_at
820
+ where excluded.up_to_index >= a2_snapshots.up_to_index`, [
821
+ sessionId,
822
+ reducerName,
823
+ latestWrite.index,
824
+ jsonb(latestWrite.state),
825
+ clock.now().getTime()
826
+ ]);
827
+ await query(`delete from a2_snapshot_history
828
+ where session_id = $1 and reducer_name = $2 and up_to_index = $3`, [
829
+ sessionId,
830
+ reducerName,
831
+ latestWrite.index
832
+ ]);
833
+ }
834
+ }));
706
835
  },
707
836
  presence: {
708
837
  async set(ns, participant, values, meta) {