experimental-a2 0.21.1 → 0.23.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 +32 -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 +234 -62
- 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-CBDVJxA_.js} +545 -399
- package/dist/store-redis-notify-CBDVJxA_.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/store-valkey.d.ts +33 -0
- package/dist/store-valkey.d.ts.map +1 -0
- package/dist/store-valkey.js +218 -0
- package/dist/store-valkey.js.map +1 -0
- 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/client/03-presence.mdx +1 -0
- package/docs/reference/01-core.mdx +4 -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/01-overview.mdx +1 -0
- package/docs/stores/02-redis.mdx +18 -10
- package/docs/stores/03-redis-http.mdx +6 -4
- package/docs/stores/04-postgres.mdx +2 -2
- package/docs/stores/07-valkey.mdx +97 -0
- 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 +8 -2
- 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 +365 -78
- 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/libfx.d.ts +2 -0
- 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 +82 -28
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +34 -40
- package/src/store-valkey.ts +376 -0
- 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
|
@@ -53,6 +53,9 @@ type Feed = {
|
|
|
53
53
|
inbox: string[]
|
|
54
54
|
bytes: number
|
|
55
55
|
dirty: boolean
|
|
56
|
+
/** Wake-ups past a gap, waiting up to NOTIFY_TIMINGS.reorderMs for it. */
|
|
57
|
+
held: Array<{ wake: Wake; bytes: number }>
|
|
58
|
+
gapSince: number | undefined
|
|
56
59
|
wake: (() => void) | null
|
|
57
60
|
stopRead: () => Promise<void>
|
|
58
61
|
}
|
|
@@ -61,15 +64,14 @@ type Feed = {
|
|
|
61
64
|
const INBOX_BYTES = 512 * 1024
|
|
62
65
|
|
|
63
66
|
type WakeEntry = [index: number, id: string, type: string, payload: unknown]
|
|
67
|
+
type Wake = { frontier: number; createdAt: number; entries: WakeEntry[] }
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* Parses a wake-up message: `{"f":frontier}` or a bare frontier number
|
|
67
71
|
* announces events without carrying them; `{"f","t","e"}` carries them.
|
|
68
72
|
* Returns null for anything else, which callers treat as a hint to catch up.
|
|
69
73
|
*/
|
|
70
|
-
const parseWake = (
|
|
71
|
-
message: string,
|
|
72
|
-
): { frontier: number; createdAt: number; entries: WakeEntry[] } | null => {
|
|
74
|
+
const parseWake = (message: string): Wake | null => {
|
|
73
75
|
let parsed: unknown
|
|
74
76
|
try {
|
|
75
77
|
parsed = JSON.parse(message)
|
|
@@ -187,6 +189,8 @@ export function createRedisNotifyStore(options: {
|
|
|
187
189
|
feed.bytes += message.length
|
|
188
190
|
} else {
|
|
189
191
|
feed.inbox.length = 0
|
|
192
|
+
feed.held.length = 0
|
|
193
|
+
feed.gapSince = undefined
|
|
190
194
|
feed.bytes = 0
|
|
191
195
|
feed.dirty = true
|
|
192
196
|
}
|
|
@@ -213,11 +217,17 @@ export function createRedisNotifyStore(options: {
|
|
|
213
217
|
*/
|
|
214
218
|
let subscriberGate: Promise<RedisConnection> | null = null
|
|
215
219
|
const gatedSubscriber = (): Promise<RedisConnection> => {
|
|
216
|
-
|
|
220
|
+
if (subscriberGate) return subscriberGate
|
|
221
|
+
const gate = getSubscriber().then(async (conn) => {
|
|
217
222
|
await conn.call('echo', 'ready')
|
|
218
223
|
return conn
|
|
219
224
|
})
|
|
220
|
-
|
|
225
|
+
subscriberGate = gate
|
|
226
|
+
// A failed check is retried by the next channel command.
|
|
227
|
+
void gate.catch(() => {
|
|
228
|
+
if (subscriberGate === gate) subscriberGate = null
|
|
229
|
+
})
|
|
230
|
+
return gate
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
const acquireChannel = async (channel: string): Promise<void> => {
|
|
@@ -299,8 +309,9 @@ export function createRedisNotifyStore(options: {
|
|
|
299
309
|
|
|
300
310
|
presence: {
|
|
301
311
|
async set(ns, participant, values, meta) {
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
312
|
+
const applied = await core.presence.set(ns, participant, values, meta)
|
|
313
|
+
if (!applied) return
|
|
314
|
+
const { patch } = applied
|
|
304
315
|
const channel = presenceChannel(ns)
|
|
305
316
|
const subscription = presenceSubscriptions.get(channel)
|
|
306
317
|
if (subscription) {
|
|
@@ -308,13 +319,16 @@ export function createRedisNotifyStore(options: {
|
|
|
308
319
|
subscription.recovery?.update(patch)
|
|
309
320
|
for (const listener of subscription.listeners) listener(patch)
|
|
310
321
|
}
|
|
322
|
+
await applied.published
|
|
311
323
|
},
|
|
312
324
|
|
|
313
325
|
async read(ns) {
|
|
314
326
|
const channel = presenceChannel(ns)
|
|
315
327
|
await subscriptions.get(channel)?.ready
|
|
328
|
+
const subscription = presenceSubscriptions.get(channel)
|
|
316
329
|
return (
|
|
317
|
-
|
|
330
|
+
subscription?.recovery?.read() ??
|
|
331
|
+
subscription?.poll?.snapshot(() => core.presence.read(ns)) ??
|
|
318
332
|
core.presence.read(ns)
|
|
319
333
|
)
|
|
320
334
|
},
|
|
@@ -417,6 +431,8 @@ export function createRedisNotifyStore(options: {
|
|
|
417
431
|
inbox: [],
|
|
418
432
|
bytes: 0,
|
|
419
433
|
dirty: true,
|
|
434
|
+
held: [],
|
|
435
|
+
gapSince: undefined,
|
|
420
436
|
wake: null,
|
|
421
437
|
stopRead,
|
|
422
438
|
}
|
|
@@ -441,36 +457,51 @@ export function createRedisNotifyStore(options: {
|
|
|
441
457
|
|
|
442
458
|
/**
|
|
443
459
|
* Moves carried events that continue this feed's sequence into
|
|
444
|
-
* the buffer.
|
|
445
|
-
*
|
|
460
|
+
* the buffer. Writers publish after their scripts, so concurrent
|
|
461
|
+
* writers' wake-ups can arrive out of order: they apply in index
|
|
462
|
+
* order, and one past a gap is held until the gap fills or
|
|
463
|
+
* NOTIFY_TIMINGS.reorderMs passes. A message without events past
|
|
464
|
+
* the buffer, or an unreadable one, marks the feed dirty so it
|
|
465
|
+
* catches up with XRANGE.
|
|
446
466
|
*/
|
|
447
467
|
const applyInbox = (): void => {
|
|
448
|
-
|
|
468
|
+
const carried = feed.held.splice(0)
|
|
469
|
+
let announced = 0
|
|
449
470
|
for (const message of feed.inbox.splice(0)) {
|
|
450
471
|
const wake = parseWake(message)
|
|
451
|
-
if (!wake)
|
|
452
|
-
|
|
472
|
+
if (!wake) feed.dirty = true
|
|
473
|
+
else if (wake.entries.length === 0)
|
|
474
|
+
announced = Math.max(announced, wake.frontier)
|
|
475
|
+
else carried.push({ wake, bytes: message.length })
|
|
476
|
+
}
|
|
477
|
+
carried.sort(
|
|
478
|
+
(a, b) => a.wake.entries[0]![0] - b.wake.entries[0]![0],
|
|
479
|
+
)
|
|
480
|
+
feed.bytes = 0
|
|
481
|
+
for (const held of carried) {
|
|
482
|
+
if (
|
|
483
|
+
held.wake.entries[0]![0] >
|
|
484
|
+
(buffer.at(-1)?.index ?? last) + 1
|
|
485
|
+
) {
|
|
486
|
+
feed.held.push(held)
|
|
487
|
+
feed.bytes += held.bytes
|
|
453
488
|
continue
|
|
454
489
|
}
|
|
455
|
-
for (const [index, id, type, payload] of wake.entries) {
|
|
456
|
-
|
|
457
|
-
if (index < expected) continue
|
|
458
|
-
if (index > expected) {
|
|
459
|
-
feed.dirty = true
|
|
460
|
-
break
|
|
461
|
-
}
|
|
490
|
+
for (const [index, id, type, payload] of held.wake.entries) {
|
|
491
|
+
if (index <= (buffer.at(-1)?.index ?? last)) continue
|
|
462
492
|
buffer.push({
|
|
463
493
|
id,
|
|
464
494
|
type,
|
|
465
495
|
payload,
|
|
466
496
|
index,
|
|
467
497
|
sessionId,
|
|
468
|
-
createdAt: new Date(wake.createdAt),
|
|
498
|
+
createdAt: new Date(held.wake.createdAt),
|
|
469
499
|
})
|
|
470
500
|
}
|
|
471
|
-
if (wake.frontier > (buffer.at(-1)?.index ?? last))
|
|
472
|
-
feed.dirty = true
|
|
473
501
|
}
|
|
502
|
+
if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true
|
|
503
|
+
if (feed.held.length === 0) feed.gapSince = undefined
|
|
504
|
+
else feed.gapSince ??= Date.now()
|
|
474
505
|
}
|
|
475
506
|
|
|
476
507
|
const iterator: AsyncIterator<Event> = {
|
|
@@ -517,8 +548,14 @@ export function createRedisNotifyStore(options: {
|
|
|
517
548
|
feeds.set(channel, set)
|
|
518
549
|
}
|
|
519
550
|
set.add(feed)
|
|
551
|
+
// A feed starts at the latest clear at the earliest:
|
|
552
|
+
// entries below it await reclaim.
|
|
520
553
|
// oxlint-disable-next-line no-await-in-loop -- one-time setup
|
|
521
|
-
await
|
|
554
|
+
const [, clearIndex] = await Promise.all([
|
|
555
|
+
acquireChannel(channel),
|
|
556
|
+
core.clearIndex(sessionId),
|
|
557
|
+
])
|
|
558
|
+
if (clearIndex - 1 > last) last = clearIndex - 1
|
|
522
559
|
if (closed || severed) continue
|
|
523
560
|
}
|
|
524
561
|
if (options.polling?.()) {
|
|
@@ -554,8 +591,16 @@ export function createRedisNotifyStore(options: {
|
|
|
554
591
|
}
|
|
555
592
|
applyInbox()
|
|
556
593
|
if (buffer.length > 0) continue
|
|
594
|
+
if (
|
|
595
|
+
feed.gapSince !== undefined &&
|
|
596
|
+
Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs
|
|
597
|
+
)
|
|
598
|
+
feed.dirty = true
|
|
557
599
|
if (feed.dirty) {
|
|
558
600
|
feed.dirty = false
|
|
601
|
+
// A read covers whatever the held wake-ups carry.
|
|
602
|
+
feed.held.length = 0
|
|
603
|
+
feed.gapSince = undefined
|
|
559
604
|
if (core.streamEvents) {
|
|
560
605
|
// oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
|
|
561
606
|
const result = await nextRead(last)
|
|
@@ -568,12 +613,21 @@ export function createRedisNotifyStore(options: {
|
|
|
568
613
|
if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
|
|
569
614
|
continue
|
|
570
615
|
}
|
|
571
|
-
// Caught up: park until a message, a reconnect,
|
|
572
|
-
// safety read that heals a lost message.
|
|
616
|
+
// Caught up: park until a message, a reconnect, the end of
|
|
617
|
+
// a held gap, or the safety read that heals a lost message.
|
|
573
618
|
const woken = new Promise<'wake'>((resolve) => {
|
|
574
619
|
feed.wake = () => resolve('wake')
|
|
575
620
|
})
|
|
576
|
-
|
|
621
|
+
const holding = feed.gapSince !== undefined
|
|
622
|
+
pending = defaultSleep(
|
|
623
|
+
holding
|
|
624
|
+
? Math.max(
|
|
625
|
+
0,
|
|
626
|
+
NOTIFY_TIMINGS.reorderMs -
|
|
627
|
+
(Date.now() - feed.gapSince!),
|
|
628
|
+
)
|
|
629
|
+
: NOTIFY_TIMINGS.safetyReadMs,
|
|
630
|
+
)
|
|
577
631
|
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
578
632
|
const outcome = await Promise.race([
|
|
579
633
|
woken,
|
|
@@ -581,7 +635,7 @@ export function createRedisNotifyStore(options: {
|
|
|
581
635
|
])
|
|
582
636
|
pending.cancel()
|
|
583
637
|
pending = null
|
|
584
|
-
if (outcome === 'timer') feed.dirty = true
|
|
638
|
+
if (outcome === 'timer' && !holding) feed.dirty = true
|
|
585
639
|
} catch (err) {
|
|
586
640
|
release()
|
|
587
641
|
if (closed) return { value: undefined, done: true }
|
package/src/store-redis.ts
CHANGED
|
@@ -15,9 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Works with any Redis-protocol server on a single instance or a
|
|
17
17
|
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `call`/`duplicate`/`on`/`disconnect`.
|
|
18
|
+
* `ioredis` is an optional peer dependency; pass `url`, or inject any client
|
|
19
|
+
* exposing `call`/`duplicate`/`on`/`disconnect`.
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
import { A2Error } from './errors.ts'
|
package/src/store-sqlite.ts
CHANGED
|
@@ -75,7 +75,9 @@ create table if not exists a2_events (
|
|
|
75
75
|
primary key (session_id, idx)
|
|
76
76
|
) strict;
|
|
77
77
|
|
|
78
|
-
create unique index if not exists
|
|
78
|
+
create unique index if not exists a2_events_session_event_id
|
|
79
|
+
on a2_events (session_id, event_id);
|
|
80
|
+
drop index if exists a2_events_event_id;
|
|
79
81
|
create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
|
|
80
82
|
|
|
81
83
|
create table if not exists a2_cursors (
|
|
@@ -485,6 +487,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
485
487
|
set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
|
|
486
488
|
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
487
489
|
)
|
|
490
|
+
// A JSON null value is a tombstone: a delete's `at`, kept until it
|
|
491
|
+
// expires so an older set arriving after it still loses.
|
|
488
492
|
const upsertPresence = db.prepare(
|
|
489
493
|
`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
|
|
490
494
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -493,11 +497,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
493
497
|
seen = excluded.seen,
|
|
494
498
|
at = excluded.at,
|
|
495
499
|
expires_at = excluded.expires_at
|
|
496
|
-
where a2_presence.at <= excluded.at
|
|
497
|
-
)
|
|
498
|
-
const deletePresence = db.prepare(
|
|
499
|
-
`delete from a2_presence
|
|
500
|
-
where ns = ? and participant = ? and field = ? and at <= ?`,
|
|
500
|
+
where a2_presence.at <= excluded.at or a2_presence.expires_at <= ?`,
|
|
501
501
|
)
|
|
502
502
|
const sweepPresence = db.prepare(
|
|
503
503
|
'delete from a2_presence where ns = ? and expires_at <= ?',
|
|
@@ -505,13 +505,13 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
505
505
|
const selectPresence = db.prepare(
|
|
506
506
|
`select participant, field, value, seen, at, expires_at
|
|
507
507
|
from a2_presence
|
|
508
|
-
where ns = ? and expires_at > ?
|
|
508
|
+
where ns = ? and expires_at > ? and value != 'null'
|
|
509
509
|
order by participant, field`,
|
|
510
510
|
)
|
|
511
511
|
const selectByIds = (count: number) =>
|
|
512
512
|
db.prepare(
|
|
513
513
|
`select * from a2_events
|
|
514
|
-
where event_id in (${placeholders(count)})
|
|
514
|
+
where session_id = ? and event_id in (${placeholders(count)})
|
|
515
515
|
order by idx`,
|
|
516
516
|
)
|
|
517
517
|
|
|
@@ -561,8 +561,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
/**
|
|
564
|
-
* The rows owning `ids`.
|
|
565
|
-
*
|
|
564
|
+
* The session's rows owning `ids`. Rows below its clear marker do not
|
|
565
|
+
* own their ids: they are deleted here so the ids can be reused.
|
|
566
566
|
*/
|
|
567
567
|
const liveOwnersOf = (
|
|
568
568
|
sessionId: string,
|
|
@@ -570,10 +570,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
570
570
|
ids: readonly string[],
|
|
571
571
|
): EventRow[] => {
|
|
572
572
|
if (ids.length === 0) return []
|
|
573
|
-
const rows = selectByIds(ids.length).all(
|
|
573
|
+
const rows = selectByIds(ids.length).all(
|
|
574
|
+
sessionId,
|
|
575
|
+
...ids,
|
|
576
|
+
) as unknown as EventRow[]
|
|
574
577
|
return rows.filter((row) => {
|
|
575
|
-
if (
|
|
576
|
-
return true
|
|
578
|
+
if (Number(row.idx) >= clearIndex) return true
|
|
577
579
|
deleteEventById.run(sessionId, row.event_id)
|
|
578
580
|
return false
|
|
579
581
|
})
|
|
@@ -851,15 +853,6 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
851
853
|
if (suppliedIds.length > 0) {
|
|
852
854
|
const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
|
|
853
855
|
if (existing.length > 0) {
|
|
854
|
-
const foreign = existing.find(
|
|
855
|
-
(row) => row.session_id !== sessionId,
|
|
856
|
-
)
|
|
857
|
-
if (foreign) {
|
|
858
|
-
throw new A2Error(
|
|
859
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
860
|
-
`event id '${foreign.event_id}' already exists in another session`,
|
|
861
|
-
)
|
|
862
|
-
}
|
|
863
856
|
if (existing.length === events.length) {
|
|
864
857
|
return {
|
|
865
858
|
events: idempotentReplay(events, existing.map(toStored)),
|
|
@@ -1244,7 +1237,10 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1244
1237
|
const existing =
|
|
1245
1238
|
ids.length === 0
|
|
1246
1239
|
? []
|
|
1247
|
-
: (selectByIds(ids.length).all(
|
|
1240
|
+
: (selectByIds(ids.length).all(
|
|
1241
|
+
sessionId,
|
|
1242
|
+
...ids,
|
|
1243
|
+
) as unknown as EventRow[])
|
|
1248
1244
|
const byId = new Map(existing.map((row) => [row.event_id, row]))
|
|
1249
1245
|
const ordered = ids.map((id) => byId.get(id))
|
|
1250
1246
|
if (
|
|
@@ -1252,7 +1248,6 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1252
1248
|
const cause = row ? decodeCause(row.cause) : null
|
|
1253
1249
|
return (
|
|
1254
1250
|
!row ||
|
|
1255
|
-
row.session_id !== sessionId ||
|
|
1256
1251
|
cause?.consumer !== consumer ||
|
|
1257
1252
|
cause.lane !== lane ||
|
|
1258
1253
|
cause.index !== index ||
|
|
@@ -1681,23 +1676,22 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1681
1676
|
const atMs = meta.at.getTime()
|
|
1682
1677
|
// Expiry anchors on the storage clock — the sender's `at`
|
|
1683
1678
|
// orders writes but never anchors their lifetime.
|
|
1684
|
-
const
|
|
1679
|
+
const nowMs = clock.now().getTime()
|
|
1680
|
+
const expiresAtMs = nowMs + meta.ttlMs
|
|
1685
1681
|
for (const [field, value] of entries) {
|
|
1686
|
-
// Field-wise LWW
|
|
1687
|
-
//
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
)
|
|
1700
|
-
}
|
|
1682
|
+
// Field-wise LWW against values and tombstones alike: an
|
|
1683
|
+
// unexpired stored row with a strictly newer `at` survives;
|
|
1684
|
+
// ties go to the incoming write (memory parity).
|
|
1685
|
+
upsertPresence.run(
|
|
1686
|
+
ns,
|
|
1687
|
+
participant,
|
|
1688
|
+
field,
|
|
1689
|
+
JSON.stringify(value) ?? 'null',
|
|
1690
|
+
meta.seen,
|
|
1691
|
+
atMs,
|
|
1692
|
+
expiresAtMs,
|
|
1693
|
+
nowMs,
|
|
1694
|
+
)
|
|
1701
1695
|
}
|
|
1702
1696
|
}),
|
|
1703
1697
|
)
|