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.
- package/CHANGELOG.md +19 -0
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +13 -11
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
- package/dist/server-B2XNevQA.js.map +1 -0
- package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
- package/dist/server-DjPhHnbI.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
- package/dist/store-RJO35BMj.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +79 -19
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +230 -101
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
- package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +103 -19
- package/dist/store-sqlite.js.map +1 -1
- package/docs/concepts/02-handlers.mdx +4 -0
- package/docs/concepts/04-state.mdx +57 -9
- package/docs/guides/06-ai-agents.mdx +2 -1
- package/docs/reference/01-api.mdx +39 -16
- package/package.json +1 -1
- package/src/ai-server.ts +27 -10
- package/src/server.ts +242 -87
- package/src/store-memory.ts +138 -20
- package/src/store-postgres.ts +355 -138
- package/src/store-redis-core.ts +201 -27
- package/src/store-redis-http.ts +1 -1
- package/src/store-redis.ts +1 -1
- package/src/store-sqlite.ts +191 -34
- package/src/store.ts +27 -9
- package/dist/server-286j79Mt.js.map +0 -1
- package/dist/server-DgXmORIq.d.ts.map +0 -1
- package/dist/store-flRz1OWh.d.ts.map +0 -1
- 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>(
|
|
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(
|
|
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 {
|
|
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
|
|
1049
|
+
const foldStatePlan = async (
|
|
982
1050
|
store: A2Store,
|
|
983
|
-
|
|
984
|
-
|
|
1051
|
+
reducer: Reducer<D, unknown>,
|
|
1052
|
+
plan: StateReadPlan,
|
|
985
1053
|
stateRead: StoreStateRead,
|
|
986
|
-
|
|
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
|
|
1070
|
+
state = result.value
|
|
1004
1071
|
index = snap.index
|
|
1005
1072
|
snapshotOutcome = 'hit'
|
|
1006
1073
|
}
|
|
1007
1074
|
} else {
|
|
1008
|
-
state = snap.state
|
|
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(
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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,
|
|
1257
|
+
void flushStateReads(store, reducer, opened)
|
|
1108
1258
|
})
|
|
1109
1259
|
batch = opened
|
|
1110
1260
|
}
|
|
1111
|
-
batch.push({
|
|
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
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
|
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')
|
package/src/store-memory.ts
CHANGED
|
@@ -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
|
|
300
|
-
const
|
|
301
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
536
|
-
return
|
|
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
|
|
540
|
-
const key =
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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: {
|