experimental-a2 0.22.0 → 0.24.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/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
- package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +2 -2
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
- package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +4 -4
- package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
- package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
- package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
- package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.js +25 -3
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
- package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-react.d.ts +4 -4
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.js +4 -4
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
- package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
- package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
- package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
- package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
- package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
- package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
- package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
- package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.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 +1 -1
- package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
- package/dist/server-B9gu0qi1.d.ts.map +1 -0
- package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
- package/dist/server-DX7mM6nm.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
- package/dist/session-clear-XJYpmUpg.js.map +1 -0
- package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
- package/dist/session-clear-vfonou9S.d.ts.map +1 -0
- package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
- package/dist/store-DEzcHZOA.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
- package/dist/store-codec-pAG2oPgQ.js.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 +11 -4
- 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 +26 -7
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/{store-redis-notify-CsJw_hfD.js → store-redis-notify-ys_-VrSP.js} +34 -18
- package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +11 -4
- 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/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
- package/dist/wire-BFE9sIQf.js.map +1 -0
- package/docs/client/03-presence.mdx +1 -0
- package/docs/contract/03-sessions.mdx +51 -0
- package/docs/reference/01-core.mdx +1 -0
- package/docs/reference/02-server.mdx +44 -4
- package/docs/reference/06-errors.mdx +1 -0
- package/docs/stores/01-overview.mdx +1 -0
- package/docs/stores/02-redis.mdx +3 -1
- package/docs/stores/07-valkey.mdx +97 -0
- package/examples/playground/app/a2slack/consumers/search.ts +1 -1
- package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/a2slack/db/queries.ts +1 -1
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
- package/examples/playground/app/a2slack/search-index.ts +1 -1
- package/examples/playground/app/a2slack/server.ts +2 -2
- package/examples/playground/lib/store.ts +2 -0
- package/examples/playground/package.json +1 -1
- package/package.json +8 -2
- package/src/ai-fx.ts +46 -3
- package/src/errors.ts +39 -0
- package/src/idempotent-replay.ts +4 -3
- package/src/libfx.d.ts +2 -0
- package/src/server.ts +32 -9
- package/src/store-memory.ts +26 -4
- package/src/store-postgres.ts +36 -6
- package/src/store-redis-core.ts +40 -15
- package/src/store-redis-notify.ts +8 -2
- package/src/store-sqlite.ts +25 -4
- package/src/store-valkey.ts +376 -0
- package/src/store.ts +10 -0
- package/src/wire.ts +1 -0
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
- package/dist/server-DtFrWxi3.js.map +0 -1
- package/dist/server-qgez0Xny.d.ts.map +0 -1
- package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
- package/dist/session-clear-wHd5GCpZ.js.map +0 -1
- package/dist/store-D0MhBvg6.d.ts.map +0 -1
- package/dist/store-codec-DwrWlgQx.js.map +0 -1
- package/dist/store-redis-notify-CsJw_hfD.js.map +0 -1
- package/dist/wire-CV-Z7wz-.js.map +0 -1
package/src/store-postgres.ts
CHANGED
|
@@ -29,7 +29,12 @@
|
|
|
29
29
|
* for the session lock holds, or one a clear left behind.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
A2Error,
|
|
34
|
+
duplicateBatchError,
|
|
35
|
+
lastIndexMismatchError,
|
|
36
|
+
supersededAttemptError,
|
|
37
|
+
} from './errors.ts'
|
|
33
38
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
34
39
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
35
40
|
import {
|
|
@@ -574,7 +579,8 @@ const INSERTED_AFTER = `(
|
|
|
574
579
|
* every event's id belongs to this session already, the batch is a retry: it
|
|
575
580
|
* stops with the committed rows. Some owned aborts, as does scheduled work
|
|
576
581
|
* from at or below the clear marker (`origin`). Rows below the marker own no
|
|
577
|
-
* id.
|
|
582
|
+
* id. Both id outcomes carry the head, which a conditional append's conflict
|
|
583
|
+
* reports when it moved.
|
|
578
584
|
*/
|
|
579
585
|
const APPEND_CHECKS = `
|
|
580
586
|
with boundary as materialized (
|
|
@@ -592,12 +598,12 @@ const APPEND_CHECKS = `
|
|
|
592
598
|
)
|
|
593
599
|
select case
|
|
594
600
|
when (select count(*) from owned) = ${param.bigint('count')} then
|
|
595
|
-
jsonb_build_object('stop', 'replay',
|
|
601
|
+
jsonb_build_object('stop', 'replay', 'head', boundary.base,
|
|
596
602
|
'has_pending', boundary.has_pending,
|
|
597
603
|
'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
|
|
598
604
|
from owned))
|
|
599
605
|
when exists (select 1 from owned) then
|
|
600
|
-
jsonb_build_object('abort', 'mixed', 'owned',
|
|
606
|
+
jsonb_build_object('abort', 'mixed', 'head', boundary.base, 'owned',
|
|
601
607
|
(select count(*) from owned))
|
|
602
608
|
when ${param.bigint('origin')} <= boundary.clear_index then
|
|
603
609
|
jsonb_build_object('abort', 'cleared')
|
|
@@ -630,6 +636,16 @@ const FENCE = `
|
|
|
630
636
|
from cause where cause.ord not in (select ord from held)
|
|
631
637
|
order by cause.ord limit 1`
|
|
632
638
|
|
|
639
|
+
/**
|
|
640
|
+
* A conditional append's head check, under the session lock and after the
|
|
641
|
+
* fences: it aborts with the head unless the head is `ifLastIndex`.
|
|
642
|
+
*/
|
|
643
|
+
const IF_LAST_INDEX = `
|
|
644
|
+
select jsonb_build_object('abort', 'moved', 'index', head.idx)
|
|
645
|
+
from (select coalesce(max(idx), 0) as idx from a2_events
|
|
646
|
+
where session_id = ${SESSION}) head
|
|
647
|
+
where head.idx <> ${param.bigint('ifLastIndex')}`
|
|
648
|
+
|
|
633
649
|
/**
|
|
634
650
|
* Frees the `ids` this session's rows below its clear marker hold, so the
|
|
635
651
|
* batch can take them. It never waits on a row: a reclaim deleting one may
|
|
@@ -1811,6 +1827,11 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1811
1827
|
]
|
|
1812
1828
|
const work = enqueue(events, consumers)
|
|
1813
1829
|
const originIndex = appendOptions?.originIndex
|
|
1830
|
+
const ifLastIndex = appendOptions?.ifLastIndex
|
|
1831
|
+
const conditionalAt = (head: unknown) =>
|
|
1832
|
+
ifLastIndex === undefined
|
|
1833
|
+
? undefined
|
|
1834
|
+
: { sessionId, ifLastIndex, head: asNumber(head) }
|
|
1814
1835
|
// One round trip, holding the session lock only while the server
|
|
1815
1836
|
// runs it: index assignment must not race across writers.
|
|
1816
1837
|
const results = await run({
|
|
@@ -1819,6 +1840,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1819
1840
|
LOCK_SESSION,
|
|
1820
1841
|
APPEND_CHECKS,
|
|
1821
1842
|
causes.length > 0 && FENCE,
|
|
1843
|
+
ifLastIndex !== undefined && IF_LAST_INDEX,
|
|
1822
1844
|
suppliedIds.length > 0 && REUSE_IDS,
|
|
1823
1845
|
INSERT_EVENTS,
|
|
1824
1846
|
...work.statements,
|
|
@@ -1829,6 +1851,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1829
1851
|
ids: suppliedIds,
|
|
1830
1852
|
count: events.length,
|
|
1831
1853
|
origin: originIndex ?? null,
|
|
1854
|
+
ifLastIndex: ifLastIndex ?? null,
|
|
1832
1855
|
// Attempt-currency fence: a fresh consumer append commits only
|
|
1833
1856
|
// while its causal attempt still owns the cursor's head
|
|
1834
1857
|
// (store.ts `append`).
|
|
@@ -1846,9 +1869,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1846
1869
|
switch (reason['abort']) {
|
|
1847
1870
|
case 'mixed': {
|
|
1848
1871
|
const owned = asNumber(reason['owned'])
|
|
1849
|
-
return
|
|
1850
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
1872
|
+
return duplicateBatchError(
|
|
1851
1873
|
`batch mixes ${owned} already-appended and ${events.length - owned} fresh events`,
|
|
1874
|
+
conditionalAt(reason['head']),
|
|
1852
1875
|
)
|
|
1853
1876
|
}
|
|
1854
1877
|
case 'cleared':
|
|
@@ -1860,6 +1883,12 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1860
1883
|
sessionId,
|
|
1861
1884
|
causes[asNumber(reason['ord']) - 1]!,
|
|
1862
1885
|
)
|
|
1886
|
+
case 'moved':
|
|
1887
|
+
return lastIndexMismatchError(
|
|
1888
|
+
sessionId,
|
|
1889
|
+
ifLastIndex!,
|
|
1890
|
+
asNumber(reason['index']),
|
|
1891
|
+
)
|
|
1863
1892
|
case 'held':
|
|
1864
1893
|
return new A2Error(
|
|
1865
1894
|
'STORE_UNAVAILABLE',
|
|
@@ -1881,6 +1910,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1881
1910
|
events: idempotentReplay(
|
|
1882
1911
|
events,
|
|
1883
1912
|
(checked['events'] as Row[]).map(toStored),
|
|
1913
|
+
conditionalAt(checked['head']),
|
|
1884
1914
|
),
|
|
1885
1915
|
hasPending: checked['has_pending'] === true,
|
|
1886
1916
|
}
|
package/src/store-redis-core.ts
CHANGED
|
@@ -53,7 +53,12 @@
|
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
55
|
import type { PresencePatch } from './contract.ts'
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
A2Error,
|
|
58
|
+
duplicateBatchError,
|
|
59
|
+
lastIndexMismatchError,
|
|
60
|
+
supersededAttemptError,
|
|
61
|
+
} from './errors.ts'
|
|
57
62
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
58
63
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
59
64
|
import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
|
|
@@ -705,11 +710,12 @@ end
|
|
|
705
710
|
|
|
706
711
|
/**
|
|
707
712
|
* KEYS: counter.
|
|
708
|
-
* ARGV: sessionId, nowMs, n, served consumers, origin index or '',
|
|
709
|
-
* event: id, type, payloadJson, causeJson or '',
|
|
713
|
+
* ARGV: sessionId, nowMs, n, served consumers, origin index or '',
|
|
714
|
+
* ifLastIndex or '', then per event: id, type, payloadJson, causeJson or '',
|
|
715
|
+
* targets.
|
|
710
716
|
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
711
|
-
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
712
|
-
* ['superseded', position] | ['cleared', originIndex] |
|
|
717
|
+
* ['dup', hasPending, head, ...indexes] | ['partial', count, head] |
|
|
718
|
+
* ['superseded', position] | ['cleared', originIndex] | ['moved', head] |
|
|
713
719
|
* ['index', type, after].
|
|
714
720
|
*/
|
|
715
721
|
const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
@@ -717,7 +723,7 @@ local now, n = ARGV[2], tonumber(ARGV[3])
|
|
|
717
723
|
use_served(ARGV[4])
|
|
718
724
|
if n == 0 then return { 'empty', has_pending() } end
|
|
719
725
|
local ids = {}
|
|
720
|
-
for i = 1, n do ids[i] = ARGV[
|
|
726
|
+
for i = 1, n do ids[i] = ARGV[6 + (i - 1) * 5 + 1] end
|
|
721
727
|
local owners = owners_of(read_bounds(ids))
|
|
722
728
|
local base = total
|
|
723
729
|
local dups = {}
|
|
@@ -725,18 +731,18 @@ for i = 1, n do
|
|
|
725
731
|
if owners[i] then dups[#dups + 1] = owners[i] end
|
|
726
732
|
end
|
|
727
733
|
if #dups == n then
|
|
728
|
-
local res = { 'dup', has_pending() }
|
|
734
|
+
local res = { 'dup', has_pending(), tostring(total) }
|
|
729
735
|
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
730
736
|
return res
|
|
731
737
|
end
|
|
732
|
-
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
738
|
+
if #dups > 0 then return { 'partial', tostring(#dups), tostring(total) } end
|
|
733
739
|
local origin = ARGV[5]
|
|
734
740
|
if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
|
|
735
741
|
-- The attempt-currency fence: a cause's head row must still be its cursor's
|
|
736
742
|
-- head, at that attempt, not dead-lettered.
|
|
737
743
|
local refs, fenced = {}, {}
|
|
738
744
|
for i = 1, n do
|
|
739
|
-
local raw = ARGV[
|
|
745
|
+
local raw = ARGV[6 + (i - 1) * 5 + 4]
|
|
740
746
|
if raw ~= '' then
|
|
741
747
|
local cause = cjson.decode(raw)
|
|
742
748
|
local index = tonumber(cause.index)
|
|
@@ -756,16 +762,18 @@ for k, cause in ipairs(fenced) do
|
|
|
756
762
|
end
|
|
757
763
|
if served_has(cause.consumer) then pending_fast = true end
|
|
758
764
|
end
|
|
765
|
+
local expected = ARGV[6]
|
|
766
|
+
if expected ~= '' and tonumber(expected) ~= total then return { 'moved', tostring(total) } end
|
|
759
767
|
local batch = {}
|
|
760
768
|
for i = 1, n do
|
|
761
|
-
local o =
|
|
769
|
+
local o = 6 + (i - 1) * 5
|
|
762
770
|
batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
|
|
763
771
|
end
|
|
764
772
|
local planned, missing, after = plan(batch, base)
|
|
765
773
|
if not planned then return { 'index', missing, tostring(after) } end
|
|
766
774
|
local marks = {}
|
|
767
775
|
for i = 1, n do
|
|
768
|
-
local o =
|
|
776
|
+
local o = 6 + (i - 1) * 5
|
|
769
777
|
local field = tostring(base + i)
|
|
770
778
|
redis.call('XADD', LOG, field .. '-0',
|
|
771
779
|
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
@@ -2408,6 +2416,11 @@ export function createRedisStoreCore(
|
|
|
2408
2416
|
)
|
|
2409
2417
|
}
|
|
2410
2418
|
const now = clock.now().getTime()
|
|
2419
|
+
const ifLastIndex = appendOptions?.ifLastIndex
|
|
2420
|
+
const conditionalAt = (head: string | undefined) =>
|
|
2421
|
+
ifLastIndex === undefined
|
|
2422
|
+
? undefined
|
|
2423
|
+
: { sessionId, ifLastIndex, head: Number(head) }
|
|
2411
2424
|
const withIds = events.map((e) => ({
|
|
2412
2425
|
id: e.id ?? generateId(),
|
|
2413
2426
|
type: e.type,
|
|
@@ -2427,6 +2440,7 @@ export function createRedisStoreCore(
|
|
|
2427
2440
|
withIds.length,
|
|
2428
2441
|
servedArg(appendOptions?.consumers),
|
|
2429
2442
|
appendOptions?.originIndex ?? '',
|
|
2443
|
+
ifLastIndex ?? '',
|
|
2430
2444
|
...withIds.flatMap((e, i) => [
|
|
2431
2445
|
e.id,
|
|
2432
2446
|
e.type,
|
|
@@ -2453,15 +2467,22 @@ export function createRedisStoreCore(
|
|
|
2453
2467
|
withIds[Number(reply[1]) - 1]!.cause!,
|
|
2454
2468
|
)
|
|
2455
2469
|
}
|
|
2470
|
+
if (verdict === 'moved') {
|
|
2471
|
+
throw lastIndexMismatchError(
|
|
2472
|
+
sessionId,
|
|
2473
|
+
ifLastIndex!,
|
|
2474
|
+
Number(reply[1]),
|
|
2475
|
+
)
|
|
2476
|
+
}
|
|
2456
2477
|
if (verdict === 'partial') {
|
|
2457
|
-
throw
|
|
2458
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
2478
|
+
throw duplicateBatchError(
|
|
2459
2479
|
`batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`,
|
|
2480
|
+
conditionalAt(reply[2]),
|
|
2460
2481
|
)
|
|
2461
2482
|
}
|
|
2462
2483
|
if (verdict === 'dup') {
|
|
2463
2484
|
const hasPending = reply[1] === '1'
|
|
2464
|
-
const firstIndex = Math.min(...reply.slice(
|
|
2485
|
+
const firstIndex = Math.min(...reply.slice(3).map(Number))
|
|
2465
2486
|
// A valid retry occupies exactly events.length consecutive indexes.
|
|
2466
2487
|
const { rows: existing, clearIndex } = await retryAfterGap(() =>
|
|
2467
2488
|
readBoundedRange(
|
|
@@ -2477,7 +2498,11 @@ export function createRedisStoreCore(
|
|
|
2477
2498
|
`session '${sessionId}' was cleared while an append retry replayed; retry it`,
|
|
2478
2499
|
)
|
|
2479
2500
|
}
|
|
2480
|
-
const replayed = idempotentReplay(
|
|
2501
|
+
const replayed = idempotentReplay(
|
|
2502
|
+
events,
|
|
2503
|
+
existing,
|
|
2504
|
+
conditionalAt(reply[2]),
|
|
2505
|
+
)
|
|
2481
2506
|
// The first attempt may have committed without publishing.
|
|
2482
2507
|
await publish(
|
|
2483
2508
|
notifyChannel(sessionId),
|
|
@@ -217,11 +217,17 @@ export function createRedisNotifyStore(options: {
|
|
|
217
217
|
*/
|
|
218
218
|
let subscriberGate: Promise<RedisConnection> | null = null
|
|
219
219
|
const gatedSubscriber = (): Promise<RedisConnection> => {
|
|
220
|
-
|
|
220
|
+
if (subscriberGate) return subscriberGate
|
|
221
|
+
const gate = getSubscriber().then(async (conn) => {
|
|
221
222
|
await conn.call('echo', 'ready')
|
|
222
223
|
return conn
|
|
223
224
|
})
|
|
224
|
-
|
|
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
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
const acquireChannel = async (channel: string): Promise<void> => {
|
package/src/store-sqlite.ts
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
import { mkdirSync } from 'node:fs'
|
|
16
16
|
import { dirname, resolve } from 'node:path'
|
|
17
17
|
import { DatabaseSync } from 'node:sqlite'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
A2Error,
|
|
20
|
+
duplicateBatchError,
|
|
21
|
+
lastIndexMismatchError,
|
|
22
|
+
supersededAttemptError,
|
|
23
|
+
} from './errors.ts'
|
|
19
24
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
20
25
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
21
26
|
import {
|
|
@@ -850,18 +855,27 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
850
855
|
'batch contains the same event id more than once',
|
|
851
856
|
)
|
|
852
857
|
}
|
|
858
|
+
const ifLastIndex = appendOptions?.ifLastIndex
|
|
859
|
+
const conditional =
|
|
860
|
+
ifLastIndex === undefined
|
|
861
|
+
? undefined
|
|
862
|
+
: { sessionId, ifLastIndex, head: headOf(sessionId) }
|
|
853
863
|
if (suppliedIds.length > 0) {
|
|
854
864
|
const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
|
|
855
865
|
if (existing.length > 0) {
|
|
856
866
|
if (existing.length === events.length) {
|
|
857
867
|
return {
|
|
858
|
-
events: idempotentReplay(
|
|
868
|
+
events: idempotentReplay(
|
|
869
|
+
events,
|
|
870
|
+
existing.map(toStored),
|
|
871
|
+
conditional,
|
|
872
|
+
),
|
|
859
873
|
hasPending: hasPending(sessionId, clearIndex, consumers),
|
|
860
874
|
}
|
|
861
875
|
}
|
|
862
|
-
throw
|
|
863
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
876
|
+
throw duplicateBatchError(
|
|
864
877
|
`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
|
|
878
|
+
conditional,
|
|
865
879
|
)
|
|
866
880
|
}
|
|
867
881
|
}
|
|
@@ -880,6 +894,13 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
880
894
|
if (e.cause)
|
|
881
895
|
assertCurrentCause({ sessionId, cause: e.cause, clearIndex })
|
|
882
896
|
}
|
|
897
|
+
if (conditional && conditional.head !== conditional.ifLastIndex) {
|
|
898
|
+
throw lastIndexMismatchError(
|
|
899
|
+
sessionId,
|
|
900
|
+
conditional.ifLastIndex,
|
|
901
|
+
conditional.head,
|
|
902
|
+
)
|
|
903
|
+
}
|
|
883
904
|
|
|
884
905
|
const inserted = insertBatch({
|
|
885
906
|
sessionId,
|