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
|
@@ -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
|
}
|
|
@@ -299,8 +303,9 @@ export function createRedisNotifyStore(options: {
|
|
|
299
303
|
|
|
300
304
|
presence: {
|
|
301
305
|
async set(ns, participant, values, meta) {
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
306
|
+
const applied = await core.presence.set(ns, participant, values, meta)
|
|
307
|
+
if (!applied) return
|
|
308
|
+
const { patch } = applied
|
|
304
309
|
const channel = presenceChannel(ns)
|
|
305
310
|
const subscription = presenceSubscriptions.get(channel)
|
|
306
311
|
if (subscription) {
|
|
@@ -308,13 +313,16 @@ export function createRedisNotifyStore(options: {
|
|
|
308
313
|
subscription.recovery?.update(patch)
|
|
309
314
|
for (const listener of subscription.listeners) listener(patch)
|
|
310
315
|
}
|
|
316
|
+
await applied.published
|
|
311
317
|
},
|
|
312
318
|
|
|
313
319
|
async read(ns) {
|
|
314
320
|
const channel = presenceChannel(ns)
|
|
315
321
|
await subscriptions.get(channel)?.ready
|
|
322
|
+
const subscription = presenceSubscriptions.get(channel)
|
|
316
323
|
return (
|
|
317
|
-
|
|
324
|
+
subscription?.recovery?.read() ??
|
|
325
|
+
subscription?.poll?.snapshot(() => core.presence.read(ns)) ??
|
|
318
326
|
core.presence.read(ns)
|
|
319
327
|
)
|
|
320
328
|
},
|
|
@@ -417,6 +425,8 @@ export function createRedisNotifyStore(options: {
|
|
|
417
425
|
inbox: [],
|
|
418
426
|
bytes: 0,
|
|
419
427
|
dirty: true,
|
|
428
|
+
held: [],
|
|
429
|
+
gapSince: undefined,
|
|
420
430
|
wake: null,
|
|
421
431
|
stopRead,
|
|
422
432
|
}
|
|
@@ -441,36 +451,51 @@ export function createRedisNotifyStore(options: {
|
|
|
441
451
|
|
|
442
452
|
/**
|
|
443
453
|
* Moves carried events that continue this feed's sequence into
|
|
444
|
-
* the buffer.
|
|
445
|
-
*
|
|
454
|
+
* the buffer. Writers publish after their scripts, so concurrent
|
|
455
|
+
* writers' wake-ups can arrive out of order: they apply in index
|
|
456
|
+
* order, and one past a gap is held until the gap fills or
|
|
457
|
+
* NOTIFY_TIMINGS.reorderMs passes. A message without events past
|
|
458
|
+
* the buffer, or an unreadable one, marks the feed dirty so it
|
|
459
|
+
* catches up with XRANGE.
|
|
446
460
|
*/
|
|
447
461
|
const applyInbox = (): void => {
|
|
448
|
-
|
|
462
|
+
const carried = feed.held.splice(0)
|
|
463
|
+
let announced = 0
|
|
449
464
|
for (const message of feed.inbox.splice(0)) {
|
|
450
465
|
const wake = parseWake(message)
|
|
451
|
-
if (!wake)
|
|
452
|
-
|
|
466
|
+
if (!wake) feed.dirty = true
|
|
467
|
+
else if (wake.entries.length === 0)
|
|
468
|
+
announced = Math.max(announced, wake.frontier)
|
|
469
|
+
else carried.push({ wake, bytes: message.length })
|
|
470
|
+
}
|
|
471
|
+
carried.sort(
|
|
472
|
+
(a, b) => a.wake.entries[0]![0] - b.wake.entries[0]![0],
|
|
473
|
+
)
|
|
474
|
+
feed.bytes = 0
|
|
475
|
+
for (const held of carried) {
|
|
476
|
+
if (
|
|
477
|
+
held.wake.entries[0]![0] >
|
|
478
|
+
(buffer.at(-1)?.index ?? last) + 1
|
|
479
|
+
) {
|
|
480
|
+
feed.held.push(held)
|
|
481
|
+
feed.bytes += held.bytes
|
|
453
482
|
continue
|
|
454
483
|
}
|
|
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
|
-
}
|
|
484
|
+
for (const [index, id, type, payload] of held.wake.entries) {
|
|
485
|
+
if (index <= (buffer.at(-1)?.index ?? last)) continue
|
|
462
486
|
buffer.push({
|
|
463
487
|
id,
|
|
464
488
|
type,
|
|
465
489
|
payload,
|
|
466
490
|
index,
|
|
467
491
|
sessionId,
|
|
468
|
-
createdAt: new Date(wake.createdAt),
|
|
492
|
+
createdAt: new Date(held.wake.createdAt),
|
|
469
493
|
})
|
|
470
494
|
}
|
|
471
|
-
if (wake.frontier > (buffer.at(-1)?.index ?? last))
|
|
472
|
-
feed.dirty = true
|
|
473
495
|
}
|
|
496
|
+
if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true
|
|
497
|
+
if (feed.held.length === 0) feed.gapSince = undefined
|
|
498
|
+
else feed.gapSince ??= Date.now()
|
|
474
499
|
}
|
|
475
500
|
|
|
476
501
|
const iterator: AsyncIterator<Event> = {
|
|
@@ -517,8 +542,14 @@ export function createRedisNotifyStore(options: {
|
|
|
517
542
|
feeds.set(channel, set)
|
|
518
543
|
}
|
|
519
544
|
set.add(feed)
|
|
545
|
+
// A feed starts at the latest clear at the earliest:
|
|
546
|
+
// entries below it await reclaim.
|
|
520
547
|
// oxlint-disable-next-line no-await-in-loop -- one-time setup
|
|
521
|
-
await
|
|
548
|
+
const [, clearIndex] = await Promise.all([
|
|
549
|
+
acquireChannel(channel),
|
|
550
|
+
core.clearIndex(sessionId),
|
|
551
|
+
])
|
|
552
|
+
if (clearIndex - 1 > last) last = clearIndex - 1
|
|
522
553
|
if (closed || severed) continue
|
|
523
554
|
}
|
|
524
555
|
if (options.polling?.()) {
|
|
@@ -554,8 +585,16 @@ export function createRedisNotifyStore(options: {
|
|
|
554
585
|
}
|
|
555
586
|
applyInbox()
|
|
556
587
|
if (buffer.length > 0) continue
|
|
588
|
+
if (
|
|
589
|
+
feed.gapSince !== undefined &&
|
|
590
|
+
Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs
|
|
591
|
+
)
|
|
592
|
+
feed.dirty = true
|
|
557
593
|
if (feed.dirty) {
|
|
558
594
|
feed.dirty = false
|
|
595
|
+
// A read covers whatever the held wake-ups carry.
|
|
596
|
+
feed.held.length = 0
|
|
597
|
+
feed.gapSince = undefined
|
|
559
598
|
if (core.streamEvents) {
|
|
560
599
|
// oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
|
|
561
600
|
const result = await nextRead(last)
|
|
@@ -568,12 +607,21 @@ export function createRedisNotifyStore(options: {
|
|
|
568
607
|
if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
|
|
569
608
|
continue
|
|
570
609
|
}
|
|
571
|
-
// Caught up: park until a message, a reconnect,
|
|
572
|
-
// safety read that heals a lost message.
|
|
610
|
+
// Caught up: park until a message, a reconnect, the end of
|
|
611
|
+
// a held gap, or the safety read that heals a lost message.
|
|
573
612
|
const woken = new Promise<'wake'>((resolve) => {
|
|
574
613
|
feed.wake = () => resolve('wake')
|
|
575
614
|
})
|
|
576
|
-
|
|
615
|
+
const holding = feed.gapSince !== undefined
|
|
616
|
+
pending = defaultSleep(
|
|
617
|
+
holding
|
|
618
|
+
? Math.max(
|
|
619
|
+
0,
|
|
620
|
+
NOTIFY_TIMINGS.reorderMs -
|
|
621
|
+
(Date.now() - feed.gapSince!),
|
|
622
|
+
)
|
|
623
|
+
: NOTIFY_TIMINGS.safetyReadMs,
|
|
624
|
+
)
|
|
577
625
|
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
578
626
|
const outcome = await Promise.race([
|
|
579
627
|
woken,
|
|
@@ -581,7 +629,7 @@ export function createRedisNotifyStore(options: {
|
|
|
581
629
|
])
|
|
582
630
|
pending.cancel()
|
|
583
631
|
pending = null
|
|
584
|
-
if (outcome === 'timer') feed.dirty = true
|
|
632
|
+
if (outcome === 'timer' && !holding) feed.dirty = true
|
|
585
633
|
} catch (err) {
|
|
586
634
|
release()
|
|
587
635
|
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
|
)
|
package/src/store.ts
CHANGED
|
@@ -300,6 +300,8 @@ export interface A2Store {
|
|
|
300
300
|
* lost — return the existing rows as success. If only *some* ids
|
|
301
301
|
* exist, the caller mixed an already-sent batch with fresh events —
|
|
302
302
|
* always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.
|
|
303
|
+
* Ids are unique per session: the same id in another session names a
|
|
304
|
+
* different event and plays no part in these checks.
|
|
303
305
|
*
|
|
304
306
|
* In the same atomic operation, each event's `targets` start cursor work:
|
|
305
307
|
* a lane target queues the event in that consumer's lane, a null lane
|
|
@@ -538,11 +540,15 @@ export interface A2Store {
|
|
|
538
540
|
presence?: {
|
|
539
541
|
/**
|
|
540
542
|
* Field-wise last-writer-wins merge of one participant's values:
|
|
541
|
-
* a field whose
|
|
542
|
-
* sender's stamp) is left untouched
|
|
543
|
-
*
|
|
543
|
+
* a field whose unexpired entry has a strictly newer `at` (the
|
|
544
|
+
* sender's stamp) is left untouched. `null` deletes the value but
|
|
545
|
+
* keeps a tombstone with its `at`, which takes part in the merge
|
|
546
|
+
* like a value until it expires, so a set older than the delete
|
|
547
|
+
* still loses when it arrives later. Every applied write, value or
|
|
548
|
+
* tombstone, refreshes that field's expiry to the storage's own
|
|
544
549
|
* clock plus `ttlMs` — the sender stamp orders writes but never
|
|
545
|
-
* anchors their lifetime.
|
|
550
|
+
* anchors their lifetime. `subscribe` delivers applied writes only,
|
|
551
|
+
* a delete as `null`.
|
|
546
552
|
*/
|
|
547
553
|
set(
|
|
548
554
|
ns: string,
|
|
@@ -551,7 +557,10 @@ export interface A2Store {
|
|
|
551
557
|
meta: { seen: number; at: Date; ttlMs: number },
|
|
552
558
|
): Promise<void>
|
|
553
559
|
|
|
554
|
-
/**
|
|
560
|
+
/**
|
|
561
|
+
* The current map, pruned of rows at or past their `expiresAt`.
|
|
562
|
+
* Tombstones never appear.
|
|
563
|
+
*/
|
|
555
564
|
read(ns: string): Promise<PresenceRow[]>
|
|
556
565
|
|
|
557
566
|
/**
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"actor-HnGmrOZZ.d.ts","names":[],"sources":["../src/actor-shared.ts","../src/actor.ts"],"mappings":";;;;;;;;;;;cAiCa,0BAA0B;;WAE5B;;WAEA;EACT,YAAY,eAAe,mBAAmB;;;;;;;;;KAepC;EACV;EACA;;;;;;;EAOA;;;KAIU,gBAAgB,UAAU,iBAAiB;EACrD,gBAAgB;IAEd;;KAIQ,oBAAoB,QAC7B,WAAW,cAAc,EAAE;;;;;;KAQlB,iBAAiB;GAExB,yBAAA,WAAW;IAAgB,OAAO,EAAE;IAAI;IAAc,IAAI;;;;;;;;;;;;KAanD,UAAU,iBACV,WAAW,eAAe,EAAE,MACjC,QAAQ,EAAE,eACV,OAAO,EAAE;;;;;;;;;KAWJ,uBAAuB;EAAmB;;;;;;;;;;;;KAY1C,kBAAkB,iBAClB,WAAW,eAAe,EAAE,MACjC,OAAO,EAAE,gBAAgB,SAAS,yBAAyB,iBAC3D,OAAO,EAAE,IAAI,SAAS,yBAAyB;;KAI1C,aAAa,UAAU;;EAEjC,OAAO;;EAEP;;EAEA;;EAEA,QAAQ;;EAER,MAAM,UAAU;;EAEhB,UAAU,kBAAkB;;;;;;;;KASlB,uBAAuB,UAAU;;EAE3C;;EAEA;;EAEA,QAAQ;;EAER,SAAS;IAAU,OAAO;IAAY;;;EAEtC,MAAM,UAAU;;EAEhB,UAAU,kBAAkB;;KAGlB,kBAAkB;EAC5B,OAAO;;EAEP;;EAEA;;EAEA;;;KAWU,qBAAqB;EAC/B,kBAAkB,iBAAiB,kBAAkB;;;;;;;;;;;;;;KClE3C,aAAa,UAAU,eAAe,gBAAgB,iBAC5D,KAAK,aAAa,IAAI,OAAO,YAAY,cAAc;EAEvD;EACA,SACE,KAAK,uBAAuB,IAC5B,OAAO,YAAY,cACT;;;;;;;;;;;;;;;;iBAiBF,WAAW,KAAK,GAC9B,SAAS,KAAK,KAAK,OAAO,aAAa;EACpC;EAAkB,SAAS,KAAK,KAAK,OAAO,aAAa;;KAIlD,aAAa,UAAU;;EAEjC;;EAEA,OAAO;;;;;EAKP,aAAa,WAAW,cAAc,aAAa,GAAG;EACtD,QAAQ;EACR,YAAY;EACZ,YAAY;MACR,gBAAgB;EAChB;;;;;;;;;;;;EAYA;;;;;;;KAQM;WACG;WAAwB;;WAExB;WACA;WACA;;WAGA;WACA;WACA;WACA;WACA;;WAGA;WACA;WACA;WACA,QAAQ,SAAS;;KAGpB,kBAAkB,UAAU;;EAEtC,aAAa,WAAW,6BAA6B;;;;;;;;;;;;EAYrD,QAAQ,OAAO;;KAGL;;EAEV;;KAGU,mBAAmB;;EAE7B;;KAGU,gBAAgB;;EAE1B,OAAO;;EAEP;;;;;EAKA;;KAGG,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP,QAAQ,gBAAgB,gBAE3B,OAAO,YAAY,IACnB,UAAU,qBACP,QAAQ,gBAAgB;KAE5B,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP;EAAU;EAAY;MAEzB,OAAO,YAAY,IACnB,UAAU,qBACP;EAAU;EAAY;;;KAGnB,YAAY,UAAU;WACvB;;;;;;;;WAQA,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,aAAa,QAAQ,gBAAgB;;;;;;;;;;;WAWrC,QACP,SAAS,SACT,UAAU,kBAAkB,OACzB,QAAQ;;WAEJ,SAAS,QAAQ;;KAGhB,gBAAgB,UAAU;WAC3B;;;;;;WAMA,WAAW;;WAEX,UAAU,SAAS;;WAEnB,QAAQ,SAAS;;WAEjB,SAAS,QAAQ,qBAAqB,aAAa;EAC5D,MAAM,aAAa,YAAY;;;;;;;iBAsDjB,MAAM,UAAU,eAC9B,SAAS,aAAa,KACrB,gBAAgB"}
|