experimental-a2 0.21.1 → 0.22.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 +21 -0
- package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor-shared-DFET5q16.js.map +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
- package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
- package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +211 -61
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/ai-harness-dQeJaR1t.js.map +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -4
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/client-CUMbS_38.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
- package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
- package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
- package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +15 -17
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.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 +31 -40
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CsJw_hfD.js} +538 -397
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +3 -4
- 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 +16 -18
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
- package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +5 -2
- package/docs/agents/03-conversations.mdx +9 -8
- package/docs/agents/04-harnesses.mdx +7 -4
- package/docs/reference/01-core.mdx +3 -2
- package/docs/reference/02-server.mdx +5 -1
- package/docs/reference/04-ai.mdx +13 -4
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/server/01-consumers.mdx +4 -1
- package/docs/server/06-guarantees.mdx +7 -4
- package/docs/stores/02-redis.mdx +16 -10
- package/docs/stores/03-redis-http.mdx +6 -4
- package/docs/stores/04-postgres.mdx +2 -2
- package/docs/upgrades/01-overview.mdx +1 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a2slack/db/a2.ts +7 -4
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/chat/server.ts +2 -2
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/sql/postgres/drizzle-schema.ts +7 -4
- package/sql/postgres/schema.sql +24 -3
- package/src/actor-shared.ts +10 -2
- package/src/ai-agent.ts +13 -0
- package/src/ai-fx.ts +321 -77
- package/src/ai-harness.ts +7 -0
- package/src/client.ts +19 -2
- package/src/deterministic-id.ts +1 -1
- package/src/internal.ts +42 -11
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +2 -2
- package/src/server.ts +49 -15
- package/src/store-memory.ts +24 -32
- package/src/store-postgres.ts +43 -53
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +595 -439
- package/src/store-redis-notify.ts +74 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +34 -40
- package/src/store.ts +14 -5
- package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
- package/dist/client-9IeTsiAs.js.map +0 -1
- package/dist/server-DvIB8kj5.js.map +0 -1
- package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
- package/dist/store-Z2qjw50n.d.ts.map +0 -1
- package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
- package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
- package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
package/src/internal.ts
CHANGED
|
@@ -299,6 +299,21 @@ export const DRAIN_TIMINGS: {
|
|
|
299
299
|
failureRetryMaxMs: 30_000,
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* How many kernels a harness keeps, per agent, for the next step of a session
|
|
304
|
+
* in its process. Mutable only as a white-box test seam; production always
|
|
305
|
+
* uses the default.
|
|
306
|
+
*/
|
|
307
|
+
export const HARNESS_PARKING: { maxKernels: number } = { maxKernels: 32 }
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Per server: called when a drain of a session ends with nothing left to run
|
|
311
|
+
* for it in this process, so the layer above can let go of what it kept for
|
|
312
|
+
* that session's next step.
|
|
313
|
+
*/
|
|
314
|
+
export const drainSettledHooks: WeakMap<object, (sessionId: string) => void> =
|
|
315
|
+
new WeakMap()
|
|
316
|
+
|
|
302
317
|
/**
|
|
303
318
|
* Stream liveness knobs — the server-side SSE heartbeat and the
|
|
304
319
|
* client-side stall watchdog. Paired by design: the client declares a
|
|
@@ -323,16 +338,22 @@ export const POLL_TIMINGS: {
|
|
|
323
338
|
}
|
|
324
339
|
|
|
325
340
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
341
|
+
* Timings for the notify-driven redis live stream. A parked feed re-reads
|
|
342
|
+
* the store every `safetyReadMs` even without a pub/sub wake-up, so a lost
|
|
343
|
+
* notification (subscriber reconnect gap, dropped PUBLISH) costs latency,
|
|
344
|
+
* never an event. Writers publish after their scripts, so wake-ups from
|
|
345
|
+
* concurrent writers can arrive out of order: a feed holds a gap for
|
|
346
|
+
* `reorderMs` before catching up with a read, and a write waits at most
|
|
347
|
+
* `publishMs` for its PUBLISH. Mutable only as a white-box test seam.
|
|
331
348
|
*/
|
|
332
349
|
export const NOTIFY_TIMINGS: {
|
|
333
350
|
safetyReadMs: number
|
|
351
|
+
reorderMs: number
|
|
352
|
+
publishMs: number
|
|
334
353
|
} = {
|
|
335
354
|
safetyReadMs: 10_000,
|
|
355
|
+
reorderMs: 50,
|
|
356
|
+
publishMs: 1_000,
|
|
336
357
|
}
|
|
337
358
|
|
|
338
359
|
/**
|
|
@@ -422,15 +443,25 @@ export const SOCKET_TIMINGS: {
|
|
|
422
443
|
}
|
|
423
444
|
|
|
424
445
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* (decision #36's rule): coupling the cadence to the POST ack tied
|
|
428
|
-
* remote cursor smoothness to RTT, and the ack serves no correctness
|
|
429
|
-
* purpose on a fire-and-forget LWW plane. Mutable only as a white-box
|
|
430
|
-
* test seam.
|
|
446
|
+
* Presence timings. Owned, not configurable (decision #36's rule).
|
|
447
|
+
* Mutable only as a white-box test seam.
|
|
431
448
|
*/
|
|
432
449
|
export const PRESENCE_TIMINGS: {
|
|
450
|
+
/**
|
|
451
|
+
* Client send cadence — a leading send immediately, then at most one
|
|
452
|
+
* merged send per interval (~15Hz). Coupling the cadence to the POST
|
|
453
|
+
* ack tied remote cursor smoothness to RTT, and the ack serves no
|
|
454
|
+
* correctness purpose on a fire-and-forget LWW plane.
|
|
455
|
+
*/
|
|
433
456
|
sendIntervalMs: number
|
|
457
|
+
/**
|
|
458
|
+
* How long, in local receipt time, a presence applier keeps a deleted
|
|
459
|
+
* field's stamp so an older set delivered after the delete still
|
|
460
|
+
* loses. Reorders span milliseconds; the bound keeps a long-lived
|
|
461
|
+
* client with churning participants from accumulating tombstones.
|
|
462
|
+
*/
|
|
463
|
+
tombstoneMs: number
|
|
434
464
|
} = {
|
|
435
465
|
sendIntervalMs: 66,
|
|
466
|
+
tombstoneMs: 60_000,
|
|
436
467
|
}
|
package/src/presence-recovery.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PresencePatch } from './contract.ts'
|
|
2
|
+
import { presenceTombstones } from './presence-tombstones.ts'
|
|
2
3
|
import type { PresenceRow } from './store.ts'
|
|
3
4
|
|
|
4
5
|
type Field = Pick<PresenceRow, 'participant' | 'field' | 'seen' | 'at'>
|
|
@@ -22,6 +23,7 @@ export function presenceRecovery({
|
|
|
22
23
|
update(patch: PresencePatch): void
|
|
23
24
|
} {
|
|
24
25
|
const fields = new Map<string, Field>()
|
|
26
|
+
const deleted = presenceTombstones()
|
|
25
27
|
const reading = new Set<Set<string>>()
|
|
26
28
|
let generation = 0
|
|
27
29
|
let latest: Promise<PresenceRow[]> | undefined
|
|
@@ -49,11 +51,14 @@ export function presenceRecovery({
|
|
|
49
51
|
const rows = await read()
|
|
50
52
|
if (!isActive()) return rows
|
|
51
53
|
if (run !== generation) return latest!
|
|
54
|
+
// A row older than its field's deletion must not resurrect it.
|
|
55
|
+
const current = rows.filter((row) => !deleted.outranks(row))
|
|
52
56
|
const live = new Set<string>()
|
|
53
|
-
for (const row of
|
|
57
|
+
for (const row of current) {
|
|
54
58
|
const id = key(row)
|
|
55
59
|
live.add(id)
|
|
56
60
|
if (changed.has(id)) continue
|
|
61
|
+
deleted.drop(row)
|
|
57
62
|
remember(row)
|
|
58
63
|
if (reconcile)
|
|
59
64
|
deliver({
|
|
@@ -68,6 +73,7 @@ export function presenceRecovery({
|
|
|
68
73
|
if (live.has(id) || changed.has(id) || fields.get(id) !== prior)
|
|
69
74
|
continue
|
|
70
75
|
fields.delete(id)
|
|
76
|
+
deleted.keep(prior)
|
|
71
77
|
deliver({
|
|
72
78
|
participant: prior.participant,
|
|
73
79
|
values: { [prior.field]: null },
|
|
@@ -76,7 +82,7 @@ export function presenceRecovery({
|
|
|
76
82
|
})
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
|
-
return
|
|
85
|
+
return current
|
|
80
86
|
} finally {
|
|
81
87
|
reading.delete(changed)
|
|
82
88
|
if (reading.size === 0) latest = undefined
|
|
@@ -102,18 +108,19 @@ export function presenceRecovery({
|
|
|
102
108
|
update(patch) {
|
|
103
109
|
for (const [field, value] of Object.entries(patch.values)) {
|
|
104
110
|
if (value === undefined) continue
|
|
105
|
-
const
|
|
111
|
+
const write = { participant: patch.participant, field, at: patch.at }
|
|
112
|
+
const id = key(write)
|
|
106
113
|
const previous = fields.get(id)
|
|
107
114
|
if (previous && previous.at.getTime() > patch.at.getTime()) continue
|
|
115
|
+
if (deleted.outranks(write)) continue
|
|
108
116
|
for (const changed of reading) changed.add(id)
|
|
109
|
-
if (value === null)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
117
|
+
if (value === null) {
|
|
118
|
+
fields.delete(id)
|
|
119
|
+
deleted.keep(write)
|
|
120
|
+
} else {
|
|
121
|
+
deleted.drop(write)
|
|
122
|
+
remember({ ...write, seen: patch.seen })
|
|
123
|
+
}
|
|
117
124
|
}
|
|
118
125
|
},
|
|
119
126
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { PRESENCE_TIMINGS } from './internal.ts'
|
|
2
|
+
|
|
3
|
+
type PresenceField = { participant: string; field: string }
|
|
4
|
+
type PresenceWrite = PresenceField & { at: Date }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The deletion stamps a presence applier keeps per (participant, field),
|
|
8
|
+
* so a null keeps beating older sets that arrive after it. Each lives
|
|
9
|
+
* `PRESENCE_TIMINGS.tombstoneMs` of local receipt time and is pruned
|
|
10
|
+
* lazily on use, without timers.
|
|
11
|
+
*/
|
|
12
|
+
export type PresenceTombstones = {
|
|
13
|
+
/** Whether the field's deletion is strictly newer than the write. */
|
|
14
|
+
outranks(write: PresenceWrite): boolean
|
|
15
|
+
/** Keep the deletion; the caller has already ordered it past any prior one. */
|
|
16
|
+
keep(deletion: PresenceWrite): void
|
|
17
|
+
/** Forget the field's deletion once a write at or past it applies. */
|
|
18
|
+
drop(field: PresenceField): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const key = ({ participant, field }: PresenceField): string =>
|
|
22
|
+
JSON.stringify([participant, field])
|
|
23
|
+
|
|
24
|
+
export function presenceTombstones(): PresenceTombstones {
|
|
25
|
+
// Insertion order is receipt order, so pruning stops at the first
|
|
26
|
+
// tombstone still inside the window.
|
|
27
|
+
const stamps = new Map<string, { atMs: number; keptAtMs: number }>()
|
|
28
|
+
const prune = (): void => {
|
|
29
|
+
const cutoff = Date.now() - PRESENCE_TIMINGS.tombstoneMs
|
|
30
|
+
for (const [id, stamp] of stamps) {
|
|
31
|
+
if (stamp.keptAtMs > cutoff) return
|
|
32
|
+
stamps.delete(id)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
outranks(write) {
|
|
37
|
+
if (stamps.size === 0) return false
|
|
38
|
+
prune()
|
|
39
|
+
const stamp = stamps.get(key(write))
|
|
40
|
+
return stamp !== undefined && stamp.atMs > write.at.getTime()
|
|
41
|
+
},
|
|
42
|
+
keep(deletion) {
|
|
43
|
+
prune()
|
|
44
|
+
const id = key(deletion)
|
|
45
|
+
stamps.delete(id)
|
|
46
|
+
stamps.set(id, { atMs: deletion.at.getTime(), keptAtMs: Date.now() })
|
|
47
|
+
},
|
|
48
|
+
drop(field) {
|
|
49
|
+
if (stamps.size > 0) stamps.delete(key(field))
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/push-envelope.ts
CHANGED
|
@@ -107,8 +107,8 @@ export function parsePushEvents(events: unknown): ParsedPushEvent[] {
|
|
|
107
107
|
)
|
|
108
108
|
}
|
|
109
109
|
// A2 derives the ids of what consumers append, return, and schedule as
|
|
110
|
-
// UUIDv5s, from inputs a caller can know: one pushed first
|
|
111
|
-
// place. Callers' own ids are v4 (crypto.randomUUID) or v8 (idempotentId).
|
|
110
|
+
// UUIDv5s, from inputs a caller can know: one pushed first into the same
|
|
111
|
+
// session would take its place. Callers' own ids are v4 (crypto.randomUUID) or v8 (idempotentId).
|
|
112
112
|
if (id !== undefined && DERIVED_ID.test(id)) {
|
|
113
113
|
throw invalid(
|
|
114
114
|
`events[${i}].id is shaped like the ids A2 derives for consumers (a UUIDv5); use crypto.randomUUID() or idempotentId()`,
|
package/src/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
runtimeConsumerFactory,
|
|
35
35
|
runtimeConsumers,
|
|
36
36
|
serverInternals,
|
|
37
|
+
drainSettledHooks,
|
|
37
38
|
type DrainOutcome,
|
|
38
39
|
type DrainResult,
|
|
39
40
|
} from './internal.ts'
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
type SessionCleared,
|
|
44
45
|
} from './session-clear.ts'
|
|
45
46
|
import { boundedCache } from './bounded-cache.ts'
|
|
47
|
+
import { presenceTombstones } from './presence-tombstones.ts'
|
|
46
48
|
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
47
49
|
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
48
50
|
import {
|
|
@@ -2529,11 +2531,13 @@ export function createServer<
|
|
|
2529
2531
|
}
|
|
2530
2532
|
|
|
2531
2533
|
// What this feed has already painted, per (participant, field) —
|
|
2532
|
-
// the comparison state for re-read diffs
|
|
2534
|
+
// the comparison state for re-read diffs and for queued patches,
|
|
2535
|
+
// with the stamps of painted nulls kept beside it.
|
|
2533
2536
|
const painted = new Map<
|
|
2534
2537
|
string,
|
|
2535
2538
|
Map<string, { seen: number; atMs: number; value: string }>
|
|
2536
2539
|
>()
|
|
2540
|
+
const paintedDeletes = presenceTombstones()
|
|
2537
2541
|
|
|
2538
2542
|
// One patch per (participant, field), never merged — a merged
|
|
2539
2543
|
// stamp would let a stale field inherit a fresher sibling's
|
|
@@ -2582,10 +2586,12 @@ export function createServer<
|
|
|
2582
2586
|
patches.push({
|
|
2583
2587
|
participant,
|
|
2584
2588
|
values: { [field]: null },
|
|
2585
|
-
// A cleared field has no surviving row
|
|
2586
|
-
//
|
|
2589
|
+
// A cleared field has no surviving row, so it keeps its
|
|
2590
|
+
// last painted stamps: only senders' clocks order the
|
|
2591
|
+
// plane, and an observer-stamped delete could outrank the
|
|
2592
|
+
// sender's next set.
|
|
2587
2593
|
seen: prior.seen,
|
|
2588
|
-
at: new Date(),
|
|
2594
|
+
at: new Date(prior.atMs),
|
|
2589
2595
|
})
|
|
2590
2596
|
}
|
|
2591
2597
|
if (fields.size === 0) painted.delete(participant)
|
|
@@ -2596,10 +2602,13 @@ export function createServer<
|
|
|
2596
2602
|
const paint = (patch: PresencePatch): void => {
|
|
2597
2603
|
let fields = painted.get(patch.participant)
|
|
2598
2604
|
for (const [field, value] of Object.entries(patch.values)) {
|
|
2605
|
+
const write = { participant: patch.participant, field, at: patch.at }
|
|
2599
2606
|
if (value === null || value === undefined) {
|
|
2600
2607
|
fields?.delete(field)
|
|
2608
|
+
if (value === null) paintedDeletes.keep(write)
|
|
2601
2609
|
continue
|
|
2602
2610
|
}
|
|
2611
|
+
paintedDeletes.drop(write)
|
|
2603
2612
|
if (!fields) {
|
|
2604
2613
|
fields = new Map()
|
|
2605
2614
|
painted.set(patch.participant, fields)
|
|
@@ -2644,11 +2653,23 @@ export function createServer<
|
|
|
2644
2653
|
}
|
|
2645
2654
|
wakeQueue?.()
|
|
2646
2655
|
}
|
|
2656
|
+
// A queued patch older than what this feed painted for its field —
|
|
2657
|
+
// the live value or its deletion — would repaint the past.
|
|
2658
|
+
const outdated = (
|
|
2659
|
+
write: Pick<PresenceRow, 'participant' | 'field' | 'at'>,
|
|
2660
|
+
): boolean => {
|
|
2661
|
+
const prior = painted.get(write.participant)?.get(write.field)
|
|
2662
|
+
return (
|
|
2663
|
+
(prior !== undefined && prior.atMs > write.at.getTime()) ||
|
|
2664
|
+
paintedDeletes.outranks(write)
|
|
2665
|
+
)
|
|
2666
|
+
}
|
|
2647
2667
|
const dequeue = (): PresencePatch | null => {
|
|
2648
2668
|
for (const [participant, fields] of queue) {
|
|
2649
2669
|
for (const [field, stamp] of fields) {
|
|
2650
2670
|
fields.delete(field)
|
|
2651
2671
|
if (fields.size === 0) queue.delete(participant)
|
|
2672
|
+
if (outdated({ participant, field, at: stamp.at })) continue
|
|
2652
2673
|
return {
|
|
2653
2674
|
participant,
|
|
2654
2675
|
values: { [field]: stamp.value },
|
|
@@ -3430,7 +3451,29 @@ export function createServer<
|
|
|
3430
3451
|
appendsEvents: boolean
|
|
3431
3452
|
}
|
|
3432
3453
|
|
|
3433
|
-
|
|
3454
|
+
type DrainOptions = {
|
|
3455
|
+
watchdogDueAt?: number | undefined
|
|
3456
|
+
signal?: DrainSignal | undefined
|
|
3457
|
+
/** Return a failure after arming as `failed` instead of throwing it. */
|
|
3458
|
+
handOffFailure?: boolean
|
|
3459
|
+
/** Claim and run only this consumer's heads, for `consume`. */
|
|
3460
|
+
only?: RegisteredConsumer | undefined
|
|
3461
|
+
/** With `only`: claim no head past it; what's past it is the background's. */
|
|
3462
|
+
through?: number | undefined
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
const drainSession = async (
|
|
3466
|
+
sessionId: string,
|
|
3467
|
+
drainOptions: DrainOptions = {},
|
|
3468
|
+
): Promise<DrainResult> => {
|
|
3469
|
+
const result = await runDrain(sessionId, drainOptions)
|
|
3470
|
+
// A settled `only` drain finished one consumer's heads, not the session's.
|
|
3471
|
+
if (result.settled && drainOptions.only === undefined)
|
|
3472
|
+
drainSettledHooks.get(self)?.(sessionId)
|
|
3473
|
+
return result
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
const runDrain = (
|
|
3434
3477
|
sessionId: string,
|
|
3435
3478
|
{
|
|
3436
3479
|
watchdogDueAt,
|
|
@@ -3438,16 +3481,7 @@ export function createServer<
|
|
|
3438
3481
|
handOffFailure = false,
|
|
3439
3482
|
only,
|
|
3440
3483
|
through,
|
|
3441
|
-
}: {
|
|
3442
|
-
watchdogDueAt?: number | undefined
|
|
3443
|
-
signal?: DrainSignal | undefined
|
|
3444
|
-
/** Return a failure after arming as `failed` instead of throwing it. */
|
|
3445
|
-
handOffFailure?: boolean
|
|
3446
|
-
/** Claim and run only this consumer's heads, for `consume`. */
|
|
3447
|
-
only?: RegisteredConsumer | undefined
|
|
3448
|
-
/** With `only`: claim no head past it; what's past it is the background's. */
|
|
3449
|
-
through?: number | undefined
|
|
3450
|
-
} = {},
|
|
3484
|
+
}: DrainOptions = {},
|
|
3451
3485
|
): Promise<DrainResult> =>
|
|
3452
3486
|
telemetry.span(
|
|
3453
3487
|
'a2.drain',
|
package/src/store-memory.ts
CHANGED
|
@@ -74,6 +74,7 @@ type Cursor = {
|
|
|
74
74
|
types: readonly string[] | null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** A null `value` is a tombstone: it keeps a delete's `at` until it expires. */
|
|
77
78
|
type PresenceEntry = {
|
|
78
79
|
value: unknown
|
|
79
80
|
seen: number
|
|
@@ -88,6 +89,8 @@ type SessionLog = {
|
|
|
88
89
|
offset: number
|
|
89
90
|
/** Latest clear marker, or 0. */
|
|
90
91
|
clearIndex: number
|
|
92
|
+
/** The session's unique index on event ids, like the SQL schemas'. */
|
|
93
|
+
byEventId: Map<string, Row>
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
const RECLAIM_BATCH = 256
|
|
@@ -219,8 +222,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
219
222
|
const sessions = new Map<string, SessionLog>()
|
|
220
223
|
/** Session id → cursor key → cursor. */
|
|
221
224
|
const cursors = new Map<string, Map<string, Cursor>>()
|
|
222
|
-
/** Global unique index on event ids, like the SQL schema's. */
|
|
223
|
-
const byEventId = new Map<string, Row>()
|
|
224
225
|
/** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
|
|
225
226
|
const snapshots = new Map<
|
|
226
227
|
string,
|
|
@@ -254,7 +255,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
254
255
|
const logOf = (sessionId: string): SessionLog => {
|
|
255
256
|
let log = sessions.get(sessionId)
|
|
256
257
|
if (!log) {
|
|
257
|
-
log = { rows: [], offset: 0, clearIndex: 0 }
|
|
258
|
+
log = { rows: [], offset: 0, clearIndex: 0, byEventId: new Map() }
|
|
258
259
|
sessions.set(sessionId, log)
|
|
259
260
|
}
|
|
260
261
|
return log
|
|
@@ -270,12 +271,11 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
270
271
|
const clearIndexOf = (sessionId: string): number =>
|
|
271
272
|
sessions.get(sessionId)?.clearIndex ?? 0
|
|
272
273
|
|
|
273
|
-
/** The row owning `id`, ignoring
|
|
274
|
+
/** The session's row owning `id`, ignoring rows below its clear. */
|
|
274
275
|
const ownerOf = (sessionId: string, id: string): Row | undefined => {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
: row
|
|
276
|
+
const log = sessions.get(sessionId)
|
|
277
|
+
const row = log?.byEventId.get(id)
|
|
278
|
+
return log && row && row.index >= log.clearIndex ? row : undefined
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
const cursorsOf = (sessionId: string): Map<string, Cursor> => {
|
|
@@ -437,17 +437,9 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
437
437
|
'batch contains the same event id more than once',
|
|
438
438
|
)
|
|
439
439
|
}
|
|
440
|
-
|
|
440
|
+
return supplied
|
|
441
441
|
.map((candidate) => ownerOf(sessionId, candidate.id))
|
|
442
442
|
.filter((row): row is Row => row !== undefined)
|
|
443
|
-
const foreign = existing.find((row) => row.sessionId !== sessionId)
|
|
444
|
-
if (foreign) {
|
|
445
|
-
throw new A2Error(
|
|
446
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
447
|
-
`event id '${foreign.id}' already exists in another session`,
|
|
448
|
-
)
|
|
449
|
-
}
|
|
450
|
-
return existing
|
|
451
443
|
}
|
|
452
444
|
|
|
453
445
|
/** Records the types a declared log consumer takes on its cursor. */
|
|
@@ -576,7 +568,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
576
568
|
}
|
|
577
569
|
for (const row of inserted) {
|
|
578
570
|
log.rows.push(row)
|
|
579
|
-
byEventId.set(row.id, row)
|
|
571
|
+
log.byEventId.set(row.id, row)
|
|
580
572
|
}
|
|
581
573
|
enqueue(sessionId, inserted, events, base, consumers)
|
|
582
574
|
return inserted
|
|
@@ -760,7 +752,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
760
752
|
log.offset += removed.length
|
|
761
753
|
for (const row of removed) {
|
|
762
754
|
// A reused id belongs to its new row now.
|
|
763
|
-
if (byEventId.get(row.id) === row) byEventId.delete(row.id)
|
|
755
|
+
if (log.byEventId.get(row.id) === row) log.byEventId.delete(row.id)
|
|
764
756
|
}
|
|
765
757
|
const done = log.offset === log.clearIndex - 1
|
|
766
758
|
// Writes below the marker are dropped, so one sweep at the end suffices.
|
|
@@ -1226,26 +1218,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
1226
1218
|
|
|
1227
1219
|
// Expiry anchors on the storage's own clock — the sender's
|
|
1228
1220
|
// `at` orders writes but never extends or shortens a lifetime.
|
|
1229
|
-
const
|
|
1221
|
+
const now = clock.now()
|
|
1222
|
+
const expiresAtMs = now.getTime() + meta.ttlMs
|
|
1230
1223
|
// Field-keyed and caller-named — null-prototype, like every
|
|
1231
1224
|
// presence map (see `nullProtoRecord`).
|
|
1232
1225
|
const applied: Record<string, unknown> = nullProtoRecord()
|
|
1233
1226
|
let appliedCount = 0
|
|
1234
1227
|
for (const [field, value] of Object.entries(values)) {
|
|
1235
|
-
// Field-wise LWW by `at
|
|
1228
|
+
// Field-wise LWW by `at` against unexpired values and
|
|
1229
|
+
// tombstones alike; ties go to the incoming write, so
|
|
1236
1230
|
// same-stamp sets keep set-then-read intuition.
|
|
1237
1231
|
const existing = fields.get(field)
|
|
1238
|
-
if (existing && existing.at > meta.at)
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
expiresAt: new Date(expiresAtMs),
|
|
1247
|
-
})
|
|
1248
|
-
}
|
|
1232
|
+
if (existing && existing.expiresAt > now && existing.at > meta.at)
|
|
1233
|
+
continue
|
|
1234
|
+
fields.set(field, {
|
|
1235
|
+
value: structuredClone(value),
|
|
1236
|
+
seen: meta.seen,
|
|
1237
|
+
at: new Date(meta.at),
|
|
1238
|
+
expiresAt: new Date(expiresAtMs),
|
|
1239
|
+
})
|
|
1249
1240
|
applied[field] = structuredClone(value)
|
|
1250
1241
|
appliedCount += 1
|
|
1251
1242
|
}
|
|
@@ -1279,6 +1270,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
1279
1270
|
fields.delete(field)
|
|
1280
1271
|
continue
|
|
1281
1272
|
}
|
|
1273
|
+
if (entry.value === null) continue
|
|
1282
1274
|
rows.push({
|
|
1283
1275
|
participant,
|
|
1284
1276
|
field,
|
package/src/store-postgres.ts
CHANGED
|
@@ -570,9 +570,9 @@ const INSERTED_AFTER = `(
|
|
|
570
570
|
|
|
571
571
|
/**
|
|
572
572
|
* An append's checks, under the session lock: the session's head and pending
|
|
573
|
-
* state, and what its supplied `ids` say.
|
|
574
|
-
* this session already, the batch is a retry: it
|
|
575
|
-
* rows. Some owned
|
|
573
|
+
* state, and what its supplied `ids` say. Ids are unique per session. When
|
|
574
|
+
* every event's id belongs to this session already, the batch is a retry: it
|
|
575
|
+
* stops with the committed rows. Some owned aborts, as does scheduled work
|
|
576
576
|
* from at or below the clear marker (`origin`). Rows below the marker own no
|
|
577
577
|
* id.
|
|
578
578
|
*/
|
|
@@ -586,15 +586,11 @@ const APPEND_CHECKS = `
|
|
|
586
586
|
select event.session_id, event.idx, event.event_type, event.payload,
|
|
587
587
|
event.event_id, event.created_at, event.cause
|
|
588
588
|
from a2_events event, boundary
|
|
589
|
-
where event.
|
|
590
|
-
and
|
|
591
|
-
|
|
589
|
+
where event.session_id = ${SESSION}
|
|
590
|
+
and event.event_id = any(${param.texts('ids')})
|
|
591
|
+
and event.idx >= boundary.clear_index
|
|
592
592
|
)
|
|
593
593
|
select case
|
|
594
|
-
when exists (select 1 from owned where session_id <> ${SESSION}) then
|
|
595
|
-
jsonb_build_object('abort', 'foreign', 'event_id', (
|
|
596
|
-
select event_id from owned where session_id <> ${SESSION}
|
|
597
|
-
order by idx limit 1))
|
|
598
594
|
when (select count(*) from owned) = ${param.bigint('count')} then
|
|
599
595
|
jsonb_build_object('stop', 'replay',
|
|
600
596
|
'has_pending', boundary.has_pending,
|
|
@@ -1083,9 +1079,9 @@ const RETURN_CHECKS = `
|
|
|
1083
1079
|
jsonb_build_object('stop', 'superseded')
|
|
1084
1080
|
when exists (
|
|
1085
1081
|
select 1 from a2_events event
|
|
1086
|
-
where event.
|
|
1087
|
-
and
|
|
1088
|
-
|
|
1082
|
+
where event.session_id = ${SESSION}
|
|
1083
|
+
and event.event_id = any(${param.texts('ids')})
|
|
1084
|
+
and event.idx >= ${clearIndexOf(SESSION)}
|
|
1089
1085
|
) then jsonb_build_object('abort', 'owned')
|
|
1090
1086
|
else jsonb_build_object('base', (
|
|
1091
1087
|
select coalesce(max(idx), 0) from a2_events
|
|
@@ -1737,8 +1733,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1737
1733
|
if (events.length === 0) return { outcome: 'completed', events: [] }
|
|
1738
1734
|
const { rows: existing } = await query(
|
|
1739
1735
|
`select session_id, idx, event_type, payload, event_id, created_at, cause
|
|
1740
|
-
from a2_events where event_id = any($
|
|
1741
|
-
[ids],
|
|
1736
|
+
from a2_events where session_id = $1 and event_id = any($2::text[])`,
|
|
1737
|
+
[sessionId, ids],
|
|
1742
1738
|
)
|
|
1743
1739
|
const byId = new Map(
|
|
1744
1740
|
existing.map((row) => [row['event_id'] as string, row]),
|
|
@@ -1754,7 +1750,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1754
1750
|
const cause = row ? decodeCause(row['cause']) : null
|
|
1755
1751
|
return (
|
|
1756
1752
|
!row ||
|
|
1757
|
-
row['session_id'] !== sessionId ||
|
|
1758
1753
|
cause?.consumer !== consumer ||
|
|
1759
1754
|
cause.lane !== lane ||
|
|
1760
1755
|
cause.index !== index ||
|
|
@@ -1849,11 +1844,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1849
1844
|
},
|
|
1850
1845
|
abort: (reason) => {
|
|
1851
1846
|
switch (reason['abort']) {
|
|
1852
|
-
case 'foreign':
|
|
1853
|
-
return new A2Error(
|
|
1854
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
1855
|
-
`event id '${String(reason['event_id'])}' already exists in another session`,
|
|
1856
|
-
)
|
|
1857
1847
|
case 'mixed': {
|
|
1858
1848
|
const owned = asNumber(reason['owned'])
|
|
1859
1849
|
return new A2Error(
|
|
@@ -2710,40 +2700,38 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
2710
2700
|
const atMs = meta.at.getTime()
|
|
2711
2701
|
// Expiry anchors on the storage clock — the sender's `at`
|
|
2712
2702
|
// orders writes but never anchors their lifetime.
|
|
2713
|
-
const
|
|
2703
|
+
const nowMs = clock.now().getTime()
|
|
2704
|
+
const expiresAtMs = nowMs + meta.ttlMs
|
|
2714
2705
|
const applied: Record<string, unknown> = nullProtoRecord()
|
|
2715
2706
|
const write = async (query: Query): Promise<void> => {
|
|
2716
2707
|
for (const [field, value] of entries) {
|
|
2717
|
-
// Field-wise LWW
|
|
2718
|
-
//
|
|
2708
|
+
// Field-wise LWW against values and tombstones (a JSON null
|
|
2709
|
+
// value, a delete's `at` kept until it expires) alike: an
|
|
2710
|
+
// unexpired stored row with a strictly newer `at` survives;
|
|
2711
|
+
// ties go to the incoming write (memory parity).
|
|
2719
2712
|
// oxlint-disable-next-line no-await-in-loop -- independent per-field registers
|
|
2720
|
-
const result = await (
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
meta.seen,
|
|
2743
|
-
atMs,
|
|
2744
|
-
expiresAtMs,
|
|
2745
|
-
],
|
|
2746
|
-
))
|
|
2713
|
+
const result = await query(
|
|
2714
|
+
`insert into a2_presence
|
|
2715
|
+
(ns, participant, field, value, seen, at, expires_at)
|
|
2716
|
+
values ($1, $2, $3, $4::jsonb, $5, $6, $7)
|
|
2717
|
+
on conflict (ns, participant, field) do update set
|
|
2718
|
+
value = excluded.value,
|
|
2719
|
+
seen = excluded.seen,
|
|
2720
|
+
at = excluded.at,
|
|
2721
|
+
expires_at = excluded.expires_at
|
|
2722
|
+
where a2_presence.at <= excluded.at
|
|
2723
|
+
or a2_presence.expires_at <= $8${notifications ? ' returning field' : ''}`,
|
|
2724
|
+
[
|
|
2725
|
+
ns,
|
|
2726
|
+
participant,
|
|
2727
|
+
field,
|
|
2728
|
+
jsonb(value),
|
|
2729
|
+
meta.seen,
|
|
2730
|
+
atMs,
|
|
2731
|
+
expiresAtMs,
|
|
2732
|
+
nowMs,
|
|
2733
|
+
],
|
|
2734
|
+
)
|
|
2747
2735
|
if (result.rows.length) applied[field] = structuredClone(value)
|
|
2748
2736
|
}
|
|
2749
2737
|
}
|
|
@@ -2781,7 +2769,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
2781
2769
|
)
|
|
2782
2770
|
select participant, field, value, seen, at, expires_at
|
|
2783
2771
|
from a2_presence
|
|
2784
|
-
where ns = $1 and expires_at > $2
|
|
2772
|
+
where ns = $1 and expires_at > $2 and value <> 'null'::jsonb
|
|
2785
2773
|
order by participant, field`,
|
|
2786
2774
|
[ns, now],
|
|
2787
2775
|
)
|
|
@@ -3025,7 +3013,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
3025
3013
|
const subscription = presenceSubscriptions.get(ns)
|
|
3026
3014
|
await subscription?.ready
|
|
3027
3015
|
await subscription?.poll.ready
|
|
3028
|
-
return
|
|
3016
|
+
return subscription
|
|
3017
|
+
? subscription.poll.snapshot(() => read(ns))
|
|
3018
|
+
: read(ns)
|
|
3029
3019
|
}
|
|
3030
3020
|
presence.subscribe = (ns, onPatch) => {
|
|
3031
3021
|
if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|