experimental-a2 0.23.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 +8 -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-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-CBDVJxA_.js → store-redis-notify-ys_-VrSP.js} +27 -16
- 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 +1 -1
- package/dist/store-valkey.js +2 -2
- package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
- package/dist/wire-BFE9sIQf.js.map +1 -0
- package/docs/contract/03-sessions.mdx +51 -0
- package/docs/reference/02-server.mdx +44 -4
- package/docs/reference/06-errors.mdx +1 -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 +1 -1
- package/src/errors.ts +39 -0
- package/src/idempotent-replay.ts +4 -3
- 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-sqlite.ts +25 -4
- 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-CBDVJxA_.js.map +0 -1
- package/dist/wire-CV-Z7wz-.js.map +0 -1
|
@@ -340,7 +340,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
340
340
|
payload: { id: channel, by: pick(USERS) },
|
|
341
341
|
})
|
|
342
342
|
await until(
|
|
343
|
-
() => idle(store, ['welcome',
|
|
343
|
+
() => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
344
344
|
{
|
|
345
345
|
timeoutMs: 60_000,
|
|
346
346
|
},
|
|
@@ -350,7 +350,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
350
350
|
|
|
351
351
|
const posted = await post(to, MESSAGES, CONCURRENCY)
|
|
352
352
|
const catchUpMs = await until(
|
|
353
|
-
() => idle(store, ['welcome',
|
|
353
|
+
() => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
354
354
|
{ timeoutMs: 10 * 60_000, everyMs: 100 },
|
|
355
355
|
)
|
|
356
356
|
const log = await logCounts(to[0]!)
|
|
@@ -361,7 +361,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
361
361
|
await followers.close()
|
|
362
362
|
const tables = await tableCounts()
|
|
363
363
|
const db = await a2slack.database()
|
|
364
|
-
const feedPosition = await a2slack.queries.position(db,
|
|
364
|
+
const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
|
|
365
365
|
|
|
366
366
|
const [root] = await rows<{ id: string }>(
|
|
367
367
|
"select id from a2slack_messages where thread_id is null and channel = 'room-1' order by log_index desc limit 1",
|
|
@@ -449,23 +449,23 @@ it('rebuild: a renamed feed fills empty tables from the whole log', async () =>
|
|
|
449
449
|
const db = await a2slack.database()
|
|
450
450
|
await db.execute(
|
|
451
451
|
sql.raw(
|
|
452
|
-
|
|
452
|
+
`truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = '${a2slack.queries.FEED}'`,
|
|
453
453
|
),
|
|
454
454
|
)
|
|
455
455
|
const { telemetry, runs } = recorder()
|
|
456
|
-
const
|
|
456
|
+
const feedRebuild = a2slack
|
|
457
457
|
.consumer({
|
|
458
458
|
contract: a2slack.model.workspace,
|
|
459
|
-
name: 'a2slack-feed-
|
|
460
|
-
lane: 'feed-
|
|
459
|
+
name: 'a2slack-feed-rebuild',
|
|
460
|
+
lane: 'feed-rebuild',
|
|
461
461
|
backfill: true,
|
|
462
462
|
})
|
|
463
463
|
.run(async ({ event }) => {
|
|
464
464
|
await a2slack.feedEvent(await a2slack.database(), event)
|
|
465
465
|
})
|
|
466
|
-
const [server] = servers(redisStore, [
|
|
466
|
+
const [server] = servers(redisStore, [feedRebuild], telemetry, 1)
|
|
467
467
|
const t0 = performance.now()
|
|
468
|
-
const consumed = await session(server!).consume(
|
|
468
|
+
const consumed = await session(server!).consume(feedRebuild)
|
|
469
469
|
const ms = performance.now() - t0
|
|
470
470
|
const after = await tableCounts()
|
|
471
471
|
const result = {
|
|
@@ -524,9 +524,12 @@ it('poison: one bad event dead-letters its consumer alone, and redrive resumes i
|
|
|
524
524
|
await post(to, 50, 5)
|
|
525
525
|
|
|
526
526
|
const t0 = performance.now()
|
|
527
|
-
await until(
|
|
528
|
-
|
|
529
|
-
|
|
527
|
+
await until(
|
|
528
|
+
() => idle(redisStore, [a2slack.queries.FEED, 'a2slack-notify']),
|
|
529
|
+
{
|
|
530
|
+
timeoutMs: 60_000,
|
|
531
|
+
},
|
|
532
|
+
)
|
|
530
533
|
const othersCaughtUpMs = performance.now() - t0
|
|
531
534
|
const stuck = (await redisStore.cursors('a2slack\u001fworkspace')).find(
|
|
532
535
|
(cursor) => cursor.consumer === 'drill-seen',
|
|
@@ -625,14 +628,14 @@ it('outage: Postgres goes away mid-load, and every event still lands once', asyn
|
|
|
625
628
|
})
|
|
626
629
|
await outage
|
|
627
630
|
await until(
|
|
628
|
-
() => idle(redisStore, ['welcome',
|
|
631
|
+
() => idle(redisStore, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
629
632
|
{ timeoutMs: 5 * 60_000, everyMs: 200 },
|
|
630
633
|
)
|
|
631
634
|
const idleAt = performance.now()
|
|
632
635
|
const log = await logCounts(to[0]!)
|
|
633
636
|
const tables = await tableCounts()
|
|
634
637
|
const db = await a2slack.database()
|
|
635
|
-
const feedPosition = await a2slack.queries.position(db,
|
|
638
|
+
const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
|
|
636
639
|
const deadLetters = await session(to[0]!).deadLetters()
|
|
637
640
|
const result = {
|
|
638
641
|
appended: 3_000,
|
|
@@ -12,7 +12,7 @@ import { Redis, s } from '@upstash/redis'
|
|
|
12
12
|
import { searchFeed } from './db/queries'
|
|
13
13
|
import type { SearchResult } from './model'
|
|
14
14
|
|
|
15
|
-
const NAME = 'a2slack-messages-
|
|
15
|
+
const NAME = 'a2slack-messages-v2'
|
|
16
16
|
const PREFIX = `${NAME}:message:`
|
|
17
17
|
|
|
18
18
|
const schema = s.object({
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* either writes somewhere else or returns events into the log:
|
|
5
5
|
*
|
|
6
6
|
* - `welcome` posts a new channel's opening message.
|
|
7
|
-
* - `a2slack-feed` keeps the tables every page reads (db/schema.ts).
|
|
8
|
-
* - `a2slack-search-
|
|
7
|
+
* - `a2slack-feed-v2` keeps the tables every page reads (db/schema.ts).
|
|
8
|
+
* - `a2slack-search-v2` keeps the search index in Upstash Redis Search,
|
|
9
9
|
* where it's configured (search-index.ts).
|
|
10
10
|
* - `a2slack-notify` works out who should hear about each message, and
|
|
11
11
|
* returns a notification addressed to each of them.
|
|
@@ -8,6 +8,8 @@ const postgresUrl = process.env.DATABASE_URL?.replace(
|
|
|
8
8
|
'sslmode=verify-full',
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
// A new keyPrefix starts every log over: a2slack's feed (consumers/feed.ts)
|
|
12
|
+
// has to start over with it, under a new name and with its tables cleared.
|
|
11
13
|
export const store: RedisStore | PostgresStore | undefined = redisUrl
|
|
12
14
|
? redis({ url: redisUrl, keyPrefix: 'a2-playground-tagged' })
|
|
13
15
|
: postgresUrl
|
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type A2ServerErrorCode =
|
|
|
5
5
|
| 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
|
|
6
6
|
| 'SUPERSEDED_ATTEMPT' // consumer append from an attempt a recovery claim or a clear replaced
|
|
7
7
|
| 'SESSION_CLEARED' // a read or call reached across the session's latest clear
|
|
8
|
+
| 'LAST_INDEX_MISMATCH' // a conditional append's ifLastIndex was not the head — nothing was written
|
|
8
9
|
| 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
|
|
9
10
|
| 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
|
|
10
11
|
| 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables
|
|
@@ -48,6 +49,44 @@ export function supersededAttemptError(
|
|
|
48
49
|
)
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** A conditional append whose `ifLastIndex` is not the session's head. */
|
|
53
|
+
export function lastIndexMismatchError(
|
|
54
|
+
sessionId: string,
|
|
55
|
+
ifLastIndex: number,
|
|
56
|
+
head: number,
|
|
57
|
+
): A2Error {
|
|
58
|
+
return new A2Error(
|
|
59
|
+
'LAST_INDEX_MISMATCH',
|
|
60
|
+
`session '${sessionId}' is at index ${head}, not ${ifLastIndex}`,
|
|
61
|
+
{ details: { index: head } },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A conditional append's condition and the head its store found. */
|
|
66
|
+
export type ConditionalHead = {
|
|
67
|
+
sessionId: string
|
|
68
|
+
ifLastIndex: number
|
|
69
|
+
head: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A batch whose ids conflict with committed events. In a conditional append
|
|
74
|
+
* whose head moved, the condition failed first, as when another writer's
|
|
75
|
+
* batch took those ids, so the conflict reports the head instead.
|
|
76
|
+
*/
|
|
77
|
+
export function duplicateBatchError(
|
|
78
|
+
message: string,
|
|
79
|
+
conditional: ConditionalHead | undefined,
|
|
80
|
+
): A2Error {
|
|
81
|
+
return conditional && conditional.head !== conditional.ifLastIndex
|
|
82
|
+
? lastIndexMismatchError(
|
|
83
|
+
conditional.sessionId,
|
|
84
|
+
conditional.ifLastIndex,
|
|
85
|
+
conditional.head,
|
|
86
|
+
)
|
|
87
|
+
: new A2Error('PARTIAL_DUPLICATE_BATCH', message)
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
/**
|
|
52
91
|
* Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
|
|
53
92
|
* original as `cause`. A2Errors pass through untouched — they already
|
package/src/idempotent-replay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { duplicateBatchError, type ConditionalHead } from './errors.ts'
|
|
2
2
|
import type { AppendEvent, StoredEvent } from './store.ts'
|
|
3
3
|
|
|
4
4
|
const canonicalJson = (value: unknown): string =>
|
|
@@ -20,6 +20,7 @@ const canonicalJson = (value: unknown): string =>
|
|
|
20
20
|
export const idempotentReplay = (
|
|
21
21
|
events: readonly AppendEvent[],
|
|
22
22
|
existing: readonly StoredEvent[],
|
|
23
|
+
conditional?: ConditionalHead,
|
|
23
24
|
): StoredEvent[] => {
|
|
24
25
|
const byId = new Map(existing.map((event) => [event.id, event]))
|
|
25
26
|
const ordered = events.map((event) =>
|
|
@@ -42,9 +43,9 @@ export const idempotentReplay = (
|
|
|
42
43
|
)
|
|
43
44
|
})
|
|
44
45
|
) {
|
|
45
|
-
throw
|
|
46
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
46
|
+
throw duplicateBatchError(
|
|
47
47
|
'already-appended events do not match the retried batch',
|
|
48
|
+
conditional,
|
|
48
49
|
)
|
|
49
50
|
}
|
|
50
51
|
|
package/src/server.ts
CHANGED
|
@@ -155,6 +155,15 @@ export type SessionDispatch<D extends EventDefs> = (
|
|
|
155
155
|
export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
|
|
156
156
|
/** Commit, then hand pending work directly to configured scheduler. */
|
|
157
157
|
dispatch: SessionDispatch<D>
|
|
158
|
+
/**
|
|
159
|
+
* Append only while `index` is the session's head, checked in the same
|
|
160
|
+
* atomic step as the write. Otherwise nothing is written and it throws
|
|
161
|
+
* `LAST_INDEX_MISMATCH`, with the head as `details.index`.
|
|
162
|
+
*/
|
|
163
|
+
ifLastIndex(
|
|
164
|
+
index: number,
|
|
165
|
+
...events: AppendInput<D>[]
|
|
166
|
+
): Promise<ContractEvent<D>[]>
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
export type ConsumerAppend<D extends EventDefs> = (
|
|
@@ -1395,6 +1404,9 @@ export function createServer<
|
|
|
1395
1404
|
})
|
|
1396
1405
|
}
|
|
1397
1406
|
|
|
1407
|
+
/** What the store checks before it writes, inside the append's atomic step. */
|
|
1408
|
+
type AppendCondition = { originIndex?: number; ifLastIndex?: number }
|
|
1409
|
+
|
|
1398
1410
|
/**
|
|
1399
1411
|
* Validate + write. `onAppended` runs inside the telemetry span, so
|
|
1400
1412
|
* the drain it schedules is created in the append's active context —
|
|
@@ -1409,7 +1421,7 @@ export function createServer<
|
|
|
1409
1421
|
cause?: EventCause,
|
|
1410
1422
|
generatedIds?: readonly boolean[],
|
|
1411
1423
|
requireDurableInitialArm = false,
|
|
1412
|
-
|
|
1424
|
+
condition: AppendCondition = {},
|
|
1413
1425
|
): Promise<ContractEvent<D>[]> =>
|
|
1414
1426
|
telemetry.span(
|
|
1415
1427
|
'a2.append',
|
|
@@ -1442,7 +1454,7 @@ export function createServer<
|
|
|
1442
1454
|
try {
|
|
1443
1455
|
const result = await store.append(nsId(sessionId), validated, {
|
|
1444
1456
|
consumers: storeConsumers,
|
|
1445
|
-
...
|
|
1457
|
+
...condition,
|
|
1446
1458
|
})
|
|
1447
1459
|
rows = result.events
|
|
1448
1460
|
// A consumer appending events no consumer takes adds no work, and
|
|
@@ -2243,7 +2255,7 @@ export function createServer<
|
|
|
2243
2255
|
throw new TypeError('schedule.clear requires an ifLastIndex')
|
|
2244
2256
|
}
|
|
2245
2257
|
const { ifLastIndex, ...timing } = clearOptions
|
|
2246
|
-
|
|
2258
|
+
assertIfLastIndex(ifLastIndex)
|
|
2247
2259
|
const dueAt = scheduleDueAt(
|
|
2248
2260
|
timing as ScheduleTiming,
|
|
2249
2261
|
triggerCreatedAt ?? Date.now(),
|
|
@@ -2848,7 +2860,7 @@ export function createServer<
|
|
|
2848
2860
|
attempt: ClearingAttempt | undefined,
|
|
2849
2861
|
): Promise<SessionClearResult> => {
|
|
2850
2862
|
const ifLastIndex = clearOptions?.ifLastIndex
|
|
2851
|
-
if (ifLastIndex !== undefined)
|
|
2863
|
+
if (ifLastIndex !== undefined) assertIfLastIndex(ifLastIndex)
|
|
2852
2864
|
const store = await resolveStore()
|
|
2853
2865
|
if (!store.clear || !store.reclaim) {
|
|
2854
2866
|
throw new TypeError(
|
|
@@ -4077,7 +4089,7 @@ export function createServer<
|
|
|
4077
4089
|
mode: 'inline' | 'dispatch',
|
|
4078
4090
|
events: AppendInput<D>[],
|
|
4079
4091
|
requireDurableInitialArm = false,
|
|
4080
|
-
|
|
4092
|
+
condition: AppendCondition = {},
|
|
4081
4093
|
): Promise<ContractEvent<D>[]> => {
|
|
4082
4094
|
return appendCore(
|
|
4083
4095
|
sessionId,
|
|
@@ -4088,7 +4100,7 @@ export function createServer<
|
|
|
4088
4100
|
undefined,
|
|
4089
4101
|
undefined,
|
|
4090
4102
|
requireDurableInitialArm,
|
|
4091
|
-
|
|
4103
|
+
condition,
|
|
4092
4104
|
)
|
|
4093
4105
|
}
|
|
4094
4106
|
|
|
@@ -4100,8 +4112,19 @@ export function createServer<
|
|
|
4100
4112
|
appendExternal(id, 'inline', events)
|
|
4101
4113
|
const appendDispatch = async (...events: AppendInput<D>[]) =>
|
|
4102
4114
|
appendExternal(id, 'dispatch', events)
|
|
4115
|
+
const appendIfLastIndex = async (
|
|
4116
|
+
index: number,
|
|
4117
|
+
...events: AppendInput<D>[]
|
|
4118
|
+
) => {
|
|
4119
|
+
assertIfLastIndex(index)
|
|
4120
|
+
if (events.length === 0) {
|
|
4121
|
+
throw new TypeError('append.ifLastIndex requires at least one event')
|
|
4122
|
+
}
|
|
4123
|
+
return appendExternal(id, 'inline', events, false, { ifLastIndex: index })
|
|
4124
|
+
}
|
|
4103
4125
|
const append = Object.assign(appendInline, {
|
|
4104
4126
|
dispatch: appendDispatch,
|
|
4127
|
+
ifLastIndex: appendIfLastIndex,
|
|
4105
4128
|
}) as SessionAppend<D>
|
|
4106
4129
|
return Object.assign(makeSession(id, append, null), {
|
|
4107
4130
|
deadLetters: () => listDeadLetters(id),
|
|
@@ -4169,7 +4192,7 @@ export function createServer<
|
|
|
4169
4192
|
'inline',
|
|
4170
4193
|
[...events],
|
|
4171
4194
|
true,
|
|
4172
|
-
originIndex,
|
|
4195
|
+
originIndex === undefined ? {} : { originIndex },
|
|
4173
4196
|
)
|
|
4174
4197
|
} catch (error) {
|
|
4175
4198
|
// The scheduling consumer's lifetime was cleared: an acknowledged no-op.
|
|
@@ -4186,7 +4209,7 @@ export function createServer<
|
|
|
4186
4209
|
(await readHistory(sessionId, { gte })).map(toPublic),
|
|
4187
4210
|
schedulerClear: async (sessionId, ifLastIndex) => {
|
|
4188
4211
|
assertSessionId(sessionId)
|
|
4189
|
-
|
|
4212
|
+
assertIfLastIndex(ifLastIndex)
|
|
4190
4213
|
await clearSession(sessionId, { ifLastIndex }, undefined)
|
|
4191
4214
|
},
|
|
4192
4215
|
})
|
|
@@ -4232,7 +4255,7 @@ function assertRedriveIndex(value: number): void {
|
|
|
4232
4255
|
}
|
|
4233
4256
|
}
|
|
4234
4257
|
|
|
4235
|
-
function
|
|
4258
|
+
function assertIfLastIndex(value: number): void {
|
|
4236
4259
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
4237
4260
|
throw new TypeError('ifLastIndex must be a non-negative safe integer')
|
|
4238
4261
|
}
|
package/src/store-memory.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { PresencePatch } from './contract.ts'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
A2Error,
|
|
12
|
+
duplicateBatchError,
|
|
13
|
+
lastIndexMismatchError,
|
|
14
|
+
supersededAttemptError,
|
|
15
|
+
} from './errors.ts'
|
|
11
16
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
12
17
|
import { nullProtoRecord } from './internal.ts'
|
|
13
18
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
@@ -664,6 +669,12 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
664
669
|
return { events: [], hasPending: hasPending(sessionId, consumers) }
|
|
665
670
|
}
|
|
666
671
|
|
|
672
|
+
const ifLastIndex = appendOptions?.ifLastIndex
|
|
673
|
+
const conditional =
|
|
674
|
+
ifLastIndex === undefined
|
|
675
|
+
? undefined
|
|
676
|
+
: { sessionId, ifLastIndex, head: headOf(sessions.get(sessionId)) }
|
|
677
|
+
|
|
667
678
|
// Batch-scoped idempotency (a2-implementation.md §3): all ids
|
|
668
679
|
// already present → lost-ack retry, return the original rows;
|
|
669
680
|
// some present → the caller mixed sent and fresh events.
|
|
@@ -671,13 +682,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
671
682
|
if (existing.length > 0) {
|
|
672
683
|
if (existing.length === events.length) {
|
|
673
684
|
return {
|
|
674
|
-
events: idempotentReplay(
|
|
685
|
+
events: idempotentReplay(
|
|
686
|
+
events,
|
|
687
|
+
existing.map(toStored),
|
|
688
|
+
conditional,
|
|
689
|
+
),
|
|
675
690
|
hasPending: hasPending(sessionId, consumers),
|
|
676
691
|
}
|
|
677
692
|
}
|
|
678
|
-
throw
|
|
679
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
693
|
+
throw duplicateBatchError(
|
|
680
694
|
`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
|
|
695
|
+
conditional,
|
|
681
696
|
)
|
|
682
697
|
}
|
|
683
698
|
|
|
@@ -692,6 +707,13 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
692
707
|
for (const event of events) {
|
|
693
708
|
if (event.cause) assertCurrentCause(sessionId, event.cause)
|
|
694
709
|
}
|
|
710
|
+
if (conditional && conditional.head !== conditional.ifLastIndex) {
|
|
711
|
+
throw lastIndexMismatchError(
|
|
712
|
+
sessionId,
|
|
713
|
+
conditional.ifLastIndex,
|
|
714
|
+
conditional.head,
|
|
715
|
+
)
|
|
716
|
+
}
|
|
695
717
|
|
|
696
718
|
const inserted = insert(sessionId, events, clock.now(), consumers)
|
|
697
719
|
notifyStreams(sessionId, inserted)
|
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
|
}
|