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.
Files changed (118) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
  3. package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +2 -2
  6. package/dist/actor-react.d.ts +2 -2
  7. package/dist/actor-react.js +5 -5
  8. package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
  9. package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +4 -4
  12. package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
  13. package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
  14. package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
  15. package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
  16. package/dist/ai-client.d.ts +2 -2
  17. package/dist/ai-client.js +2 -2
  18. package/dist/ai-fx.d.ts +1 -1
  19. package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
  20. package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
  21. package/dist/ai-pi.d.ts +1 -1
  22. package/dist/ai-react.d.ts +4 -4
  23. package/dist/ai-react.js +2 -2
  24. package/dist/ai-server.d.ts +3 -3
  25. package/dist/ai-server.js +4 -4
  26. package/dist/ai.d.ts +1 -1
  27. package/dist/ai.js +1 -1
  28. package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
  29. package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
  30. package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
  31. package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
  32. package/dist/client.d.ts +2 -2
  33. package/dist/client.js +1 -1
  34. package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
  35. package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +3 -3
  38. package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
  39. package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
  40. package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
  41. package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
  42. package/dist/react.d.ts +1 -1
  43. package/dist/react.js +1 -1
  44. package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
  45. package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +2 -2
  48. package/dist/scheduler-vercel.d.ts +2 -2
  49. package/dist/scheduler-vercel.js +1 -1
  50. package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
  51. package/dist/server-B9gu0qi1.d.ts.map +1 -0
  52. package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
  53. package/dist/server-DX7mM6nm.js.map +1 -0
  54. package/dist/server.d.ts +2 -2
  55. package/dist/server.js +1 -1
  56. package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
  57. package/dist/session-clear-XJYpmUpg.js.map +1 -0
  58. package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
  59. package/dist/session-clear-vfonou9S.d.ts.map +1 -0
  60. package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
  61. package/dist/store-DEzcHZOA.d.ts.map +1 -0
  62. package/dist/store-N8PXxDAS.js.map +1 -1
  63. package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
  64. package/dist/store-codec-pAG2oPgQ.js.map +1 -0
  65. package/dist/store-memory.d.ts +1 -1
  66. package/dist/store-memory.d.ts.map +1 -1
  67. package/dist/store-memory.js +11 -4
  68. package/dist/store-memory.js.map +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +26 -7
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/store-redis-http.d.ts +1 -1
  74. package/dist/store-redis-http.js +2 -2
  75. package/dist/{store-redis-notify-CBDVJxA_.js → store-redis-notify-ys_-VrSP.js} +27 -16
  76. package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
  77. package/dist/store-redis.d.ts +1 -1
  78. package/dist/store-redis.js +2 -2
  79. package/dist/store-sqlite.d.ts +1 -1
  80. package/dist/store-sqlite.d.ts.map +1 -1
  81. package/dist/store-sqlite.js +11 -4
  82. package/dist/store-sqlite.js.map +1 -1
  83. package/dist/store-valkey.d.ts +1 -1
  84. package/dist/store-valkey.js +2 -2
  85. package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
  86. package/dist/wire-BFE9sIQf.js.map +1 -0
  87. package/docs/contract/03-sessions.mdx +51 -0
  88. package/docs/reference/02-server.mdx +44 -4
  89. package/docs/reference/06-errors.mdx +1 -0
  90. package/examples/playground/app/a2slack/consumers/search.ts +1 -1
  91. package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
  92. package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
  93. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  94. package/examples/playground/app/a2slack/db/queries.ts +1 -1
  95. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
  96. package/examples/playground/app/a2slack/search-index.ts +1 -1
  97. package/examples/playground/app/a2slack/server.ts +2 -2
  98. package/examples/playground/lib/store.ts +2 -0
  99. package/examples/playground/package.json +1 -1
  100. package/package.json +1 -1
  101. package/src/errors.ts +39 -0
  102. package/src/idempotent-replay.ts +4 -3
  103. package/src/server.ts +32 -9
  104. package/src/store-memory.ts +26 -4
  105. package/src/store-postgres.ts +36 -6
  106. package/src/store-redis-core.ts +40 -15
  107. package/src/store-sqlite.ts +25 -4
  108. package/src/store.ts +10 -0
  109. package/src/wire.ts +1 -0
  110. package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
  111. package/dist/server-DtFrWxi3.js.map +0 -1
  112. package/dist/server-qgez0Xny.d.ts.map +0 -1
  113. package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
  114. package/dist/session-clear-wHd5GCpZ.js.map +0 -1
  115. package/dist/store-D0MhBvg6.d.ts.map +0 -1
  116. package/dist/store-codec-DwrWlgQx.js.map +0 -1
  117. package/dist/store-redis-notify-CBDVJxA_.js.map +0 -1
  118. package/dist/wire-CV-Z7wz-.js.map +0 -1
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { c as ContractEvent, d as PresenceDefs, f as PresenceMap, m as PresenceSnapshot, o as AppendInput, p as PresencePatch, s as Contract, u as EventDefs } from "./reducer-CYPHsSmd.js";
2
- import { a as EventCause, c as PresenceRow, d as StoreStateRead, f as StoredEvent, i as Event, l as StoreAppendResult, n as AppendEvent, o as FailAttemptResult, r as Clock, s as IdSource, t as A2Store, u as StoreClaimAvailableResult } from "./store-D0MhBvg6.js";
3
- import { A as SessionSchedule, B as SchedulerTask, C as SessionConsumeOptions, D as SessionPresence, E as SessionDispatch, F as deliverSchedulerClear, G as UpgradeWebSocket, H as A2PushEvent, I as ScheduledEvent, K as A2Socket, L as SchedulerAppendTask, M as consumer, N as createServer, O as SessionRedriveOptions, P as deliverSchedulerAppend, R as SchedulerClearTask, S as SessionClearResult, T as SessionDeadLetters, U as A2PushPresence, V as A2Operation, W as ServerFetchOptions, _ as ServerConsumers, a as ConsumerAppend, b as SessionAppend, c as ConsumerOptions, d as DeadLetter, f as DrainableServer, g as ScheduleTiming, h as ScheduleDelay, i as Consumer, j as StateOptions, k as SessionRedriveResult, l as ConsumerResult, m as LaneContext, n as A2Server, o as ConsumerContext, p as InlineConsumer, r as AbortSpec, s as ConsumerLane, t as A2Scheduler, u as ConsumerSpec, v as ServerOptions, w as SessionConsumeResult, x as SessionClearOptions, y as Session, z as SchedulerDrainTask } from "./server-qgez0Xny.js";
2
+ import { a as EventCause, c as PresenceRow, d as StoreStateRead, f as StoredEvent, i as Event, l as StoreAppendResult, n as AppendEvent, o as FailAttemptResult, r as Clock, s as IdSource, t as A2Store, u as StoreClaimAvailableResult } from "./store-DEzcHZOA.js";
3
+ import { A as SessionSchedule, B as SchedulerTask, C as SessionConsumeOptions, D as SessionPresence, E as SessionDispatch, F as deliverSchedulerClear, G as UpgradeWebSocket, H as A2PushEvent, I as ScheduledEvent, K as A2Socket, L as SchedulerAppendTask, M as consumer, N as createServer, O as SessionRedriveOptions, P as deliverSchedulerAppend, R as SchedulerClearTask, S as SessionClearResult, T as SessionDeadLetters, U as A2PushPresence, V as A2Operation, W as ServerFetchOptions, _ as ServerConsumers, a as ConsumerAppend, b as SessionAppend, c as ConsumerOptions, d as DeadLetter, f as DrainableServer, g as ScheduleTiming, h as ScheduleDelay, i as Consumer, j as StateOptions, k as SessionRedriveResult, l as ConsumerResult, m as LaneContext, n as A2Server, o as ConsumerContext, p as InlineConsumer, r as AbortSpec, s as ConsumerLane, t as A2Scheduler, u as ConsumerSpec, v as ServerOptions, w as SessionConsumeResult, x as SessionClearOptions, y as Session, z as SchedulerDrainTask } from "./server-B9gu0qi1.js";
4
4
  export { type A2Operation, type A2PushEvent, type A2PushPresence, A2Scheduler, A2Server, type A2Socket, type A2Store, AbortSpec, type AppendEvent, type AppendInput, type Clock, Consumer, ConsumerAppend, ConsumerContext, ConsumerLane, ConsumerOptions, ConsumerResult, ConsumerSpec, type Contract, type ContractEvent, DeadLetter, DrainableServer, type Event, type EventCause, type EventDefs, type FailAttemptResult, type IdSource, InlineConsumer, LaneContext, type PresenceDefs, type PresenceMap, type PresencePatch, type PresenceRow, type PresenceSnapshot, ScheduleDelay, ScheduleTiming, type ScheduledEvent, type SchedulerAppendTask, type SchedulerClearTask, type SchedulerDrainTask, type SchedulerTask, ServerConsumers, type ServerFetchOptions, ServerOptions, Session, SessionAppend, SessionClearOptions, SessionClearResult, SessionConsumeOptions, SessionConsumeResult, SessionDeadLetters, SessionDispatch, SessionPresence, SessionRedriveOptions, SessionRedriveResult, SessionSchedule, StateOptions, type StoreAppendResult, type StoreClaimAvailableResult, type StoreStateRead, type StoredEvent, type UpgradeWebSocket, consumer, createServer, deliverSchedulerAppend, deliverSchedulerClear };
package/dist/server.js CHANGED
@@ -1,2 +1,2 @@
1
- import { i as deliverSchedulerClear, n as createServer, r as deliverSchedulerAppend, t as consumer } from "./server-DtFrWxi3.js";
1
+ import { i as deliverSchedulerClear, n as createServer, r as deliverSchedulerAppend, t as consumer } from "./server-DX7mM6nm.js";
2
2
  export { consumer, createServer, deliverSchedulerAppend, deliverSchedulerClear };
@@ -15,6 +15,18 @@ function supersededAttemptError(sessionId, cause) {
15
15
  const of = cause.consumer === void 0 ? "" : ` for consumer '${cause.consumer}'`;
16
16
  return new A2Error("SUPERSEDED_ATTEMPT", `attempt ${cause.attempt} no longer owns event ${cause.index}${of} in session '${sessionId}'`);
17
17
  }
18
+ /** A conditional append whose `ifLastIndex` is not the session's head. */
19
+ function lastIndexMismatchError(sessionId, ifLastIndex, head) {
20
+ return new A2Error("LAST_INDEX_MISMATCH", `session '${sessionId}' is at index ${head}, not ${ifLastIndex}`, { details: { index: head } });
21
+ }
22
+ /**
23
+ * A batch whose ids conflict with committed events. In a conditional append
24
+ * whose head moved, the condition failed first, as when another writer's
25
+ * batch took those ids, so the conflict reports the head instead.
26
+ */
27
+ function duplicateBatchError(message, conditional) {
28
+ return conditional && conditional.head !== conditional.ifLastIndex ? lastIndexMismatchError(conditional.sessionId, conditional.ifLastIndex, conditional.head) : new A2Error("PARTIAL_DUPLICATE_BATCH", message);
29
+ }
18
30
  /**
19
31
  * Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
20
32
  * original as `cause`. A2Errors pass through untouched — they already
@@ -50,6 +62,6 @@ function sessionClearedError(message) {
50
62
  return new A2Error("SESSION_CLEARED", message);
51
63
  }
52
64
  //#endregion
53
- export { NonRetriableError as a, A2Error as i, isSessionCleared as n, asStoreUnavailable as o, sessionClearedError as r, supersededAttemptError as s, CLEAR_EVENT_TYPE as t };
65
+ export { NonRetriableError as a, lastIndexMismatchError as c, A2Error as i, supersededAttemptError as l, isSessionCleared as n, asStoreUnavailable as o, sessionClearedError as r, duplicateBatchError as s, CLEAR_EVENT_TYPE as t };
54
66
 
55
- //# sourceMappingURL=session-clear-wHd5GCpZ.js.map
67
+ //# sourceMappingURL=session-clear-XJYpmUpg.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-clear-XJYpmUpg.js","names":[],"sources":["../src/errors.ts","../src/session-clear.ts"],"sourcesContent":["export type A2ServerErrorCode =\n | 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written\n | 'FORBIDDEN' // external ingress was denied by server.fetch authorization\n | 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map\n | 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events\n | 'SUPERSEDED_ATTEMPT' // consumer append from an attempt a recovery claim or a clear replaced\n | 'SESSION_CLEARED' // a read or call reached across the session's latest clear\n | 'LAST_INDEX_MISMATCH' // a conditional append's ifLastIndex was not the head — nothing was written\n | 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal\n | 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written\n | 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables\n | 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map\n | 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability\n | 'CONSUMER_DEAD_LETTERED' // session.consume reached a head that dead-lettered for that consumer\n\nexport type A2ErrorCode =\n A2ServerErrorCode | 'TIMEOUT' | 'HTTP_ERROR' | 'CONNECTION_FAILED' | 'UNKNOWN'\n\nexport class A2Error extends Error {\n readonly code: A2ErrorCode\n /** e.g. the Standard Schema issues for INVALID_PAYLOAD */\n readonly details: unknown\n\n constructor(\n code: A2ErrorCode,\n message: string,\n options?: { details?: unknown; cause?: unknown },\n ) {\n super(\n message,\n options && 'cause' in options ? { cause: options.cause } : undefined,\n )\n this.name = 'A2Error'\n this.code = code\n this.details = options?.details\n }\n}\n\n/** A consumer write whose attempt no longer owns its triggering event. */\nexport function supersededAttemptError(\n sessionId: string,\n cause: { consumer?: string; index: number; attempt: number },\n): A2Error {\n const of =\n cause.consumer === undefined ? '' : ` for consumer '${cause.consumer}'`\n return new A2Error(\n 'SUPERSEDED_ATTEMPT',\n `attempt ${cause.attempt} no longer owns event ${cause.index}${of} in session '${sessionId}'`,\n )\n}\n\n/** A conditional append whose `ifLastIndex` is not the session's head. */\nexport function lastIndexMismatchError(\n sessionId: string,\n ifLastIndex: number,\n head: number,\n): A2Error {\n return new A2Error(\n 'LAST_INDEX_MISMATCH',\n `session '${sessionId}' is at index ${head}, not ${ifLastIndex}`,\n { details: { index: head } },\n )\n}\n\n/** A conditional append's condition and the head its store found. */\nexport type ConditionalHead = {\n sessionId: string\n ifLastIndex: number\n head: number\n}\n\n/**\n * A batch whose ids conflict with committed events. In a conditional append\n * whose head moved, the condition failed first, as when another writer's\n * batch took those ids, so the conflict reports the head instead.\n */\nexport function duplicateBatchError(\n message: string,\n conditional: ConditionalHead | undefined,\n): A2Error {\n return conditional && conditional.head !== conditional.ifLastIndex\n ? lastIndexMismatchError(\n conditional.sessionId,\n conditional.ifLastIndex,\n conditional.head,\n )\n : new A2Error('PARTIAL_DUPLICATE_BATCH', message)\n}\n\n/**\n * Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the\n * original as `cause`. A2Errors pass through untouched — they already\n * carry their own meaning.\n */\nexport function asStoreUnavailable(err: unknown): A2Error {\n if (err instanceof A2Error) return err\n return new A2Error('STORE_UNAVAILABLE', 'the store backend failed', {\n cause: err,\n })\n}\n\n/**\n * Throw from a consumer to reject retrying: the failure is adjudicated\n * terminal immediately (dead-lettered on the first throw) instead of\n * consuming the ten-failure retry budget. For deterministic failures —\n * a guard that will refuse identically on every attempt — retrying is\n * waste and dead-lettering is the honest outcome. Matched by\n * `instanceof`, so subclasses carry domain meaning.\n */\nexport class NonRetriableError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'NonRetriableError'\n }\n}\n","import { A2Error } from './errors.ts'\nimport type { Event } from './store.ts'\n\n/** A2's own event types start with this; application contracts cannot. */\nexport const RESERVED_EVENT_PREFIX = 'a2.'\n\n/** Reserved type of the marker a clear appends. */\nexport const CLEAR_EVENT_TYPE = 'a2.cleared'\n\n/** The marker `session.stream()` yields where a clear ended a lifetime. */\nexport type SessionCleared = Event & {\n type: typeof CLEAR_EVENT_TYPE\n payload: null\n}\n\n/** Accepts any stream item, including presence patches and snapshots. */\nexport function isSessionCleared(item: object): item is SessionCleared {\n return (item as { type?: unknown }).type === CLEAR_EVENT_TYPE\n}\n\nexport function sessionClearedError(message: string): A2Error {\n return new A2Error('SESSION_CLEARED', message)\n}\n"],"mappings":";AAkBA,IAAa,UAAb,cAA6B,MAAM;CACjC;;CAEA;CAEA,YACE,MACA,SACA,SACA;EACA,MACE,SACA,WAAW,WAAW,UAAU,EAAE,OAAO,QAAQ,MAAM,IAAI,KAAA,CAC7D;EACA,KAAK,OAAO;EACZ,KAAK,OAAO;EACZ,KAAK,UAAU,SAAS;CAC1B;AACF;;AAGA,SAAgB,uBACd,WACA,OACS;CACT,MAAM,KACJ,MAAM,aAAa,KAAA,IAAY,KAAK,kBAAkB,MAAM,SAAS;CACvE,OAAO,IAAI,QACT,sBACA,WAAW,MAAM,QAAQ,wBAAwB,MAAM,QAAQ,GAAG,eAAe,UAAU,EAC7F;AACF;;AAGA,SAAgB,uBACd,WACA,aACA,MACS;CACT,OAAO,IAAI,QACT,uBACA,YAAY,UAAU,gBAAgB,KAAK,QAAQ,eACnD,EAAE,SAAS,EAAE,OAAO,KAAK,EAAE,CAC7B;AACF;;;;;;AAcA,SAAgB,oBACd,SACA,aACS;CACT,OAAO,eAAe,YAAY,SAAS,YAAY,cACnD,uBACE,YAAY,WACZ,YAAY,aACZ,YAAY,IACd,IACA,IAAI,QAAQ,2BAA2B,OAAO;AACpD;;;;;;AAOA,SAAgB,mBAAmB,KAAuB;CACxD,IAAI,eAAe,SAAS,OAAO;CACnC,OAAO,IAAI,QAAQ,qBAAqB,4BAA4B,EAClE,OAAO,IACT,CAAC;AACH;;;;;;;;;AAUA,IAAa,oBAAb,cAAuC,MAAM;CAC3C,YAAY,SAAiB,SAA+B;EAC1D,MAAM,SAAS,OAAO;EACtB,KAAK,OAAO;CACd;AACF;;;;AC3GA,MAAa,mBAAmB;;AAShC,SAAgB,iBAAiB,MAAsC;CACrE,OAAQ,KAA4B,SAAS;AAC/C;AAEA,SAAgB,oBAAoB,SAA0B;CAC5D,OAAO,IAAI,QAAQ,mBAAmB,OAAO;AAC/C"}
@@ -1,4 +1,4 @@
1
- import { i as Event } from "./store-D0MhBvg6.js";
1
+ import { i as Event } from "./store-DEzcHZOA.js";
2
2
  //#region src/session-clear.d.ts
3
3
  /** Reserved type of the marker a clear appends. */
4
4
  declare const CLEAR_EVENT_TYPE = "a2.cleared";
@@ -9,4 +9,4 @@ type SessionCleared = Event & {
9
9
  };
10
10
  //#endregion
11
11
  export { SessionCleared as t };
12
- //# sourceMappingURL=session-clear-D1PFe7xl.d.ts.map
12
+ //# sourceMappingURL=session-clear-vfonou9S.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-clear-vfonou9S.d.ts","names":[],"sources":["../src/session-clear.ts"],"mappings":";;;cAOa;;KAGD,iBAAiB;EAC3B,aAAa;EACb"}
@@ -296,9 +296,19 @@ interface A2Store {
296
296
  * a `cause` at or below the latest clear marker throws
297
297
  * `SUPERSEDED_ATTEMPT`, an `originIndex` there throws `SESSION_CLEARED`,
298
298
  * and ids whose rows lie below the marker are free to reuse.
299
+ *
300
+ * `ifLastIndex` conditions a non-empty batch on the session's head, the
301
+ * last index including any clear marker: when the head is anything else,
302
+ * throw `lastIndexMismatchError` carrying the head and write nothing. It is
303
+ * checked last, after the replay path and both fences, inside the same
304
+ * atomic operation, never as a read before it. A conditional batch whose
305
+ * ids conflict (mixed, or not matching their rows) reports that head
306
+ * instead of `PARTIAL_DUPLICATE_BATCH` when it isn't `ifLastIndex`
307
+ * (`duplicateBatchError`).
299
308
  */
300
309
  append(sessionId: string, events: AppendEvent[], options?: {
301
310
  originIndex?: number;
311
+ ifLastIndex?: number;
302
312
  consumers?: readonly StoreConsumer[];
303
313
  }): Promise<StoreAppendResult>;
304
314
  /**
@@ -529,4 +539,4 @@ interface A2Store {
529
539
  }
530
540
  //#endregion
531
541
  export { EventCause as a, PresenceRow as c, StoreStateRead as d, StoredEvent as f, Event as i, StoreAppendResult as l, AppendEvent as n, FailAttemptResult as o, Clock as r, IdSource as s, A2Store as t, StoreClaimAvailableResult as u };
532
- //# sourceMappingURL=store-D0MhBvg6.d.ts.map
542
+ //# sourceMappingURL=store-DEzcHZOA.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-DEzcHZOA.d.ts","names":[],"sources":["../src/store.ts"],"mappings":";;;KAaY;EACV;EACA;EACA;;EAEA;EACA;EACA,WAAW;;;KAID;EACV;;EAEA;EACA;EACA;;EAEA;;;KAIU,cAAc;;EAExB,OAAO;;;;;;;;;KAUG;EACV;EACA;;EAEA,gBAAgB;;EAEhB,eAAe;;EAEf;;EAEA;;;;;EAKA,gBAAgB;;;;;EAKhB;;EAEA,cAAc;;EAEd;;EAEA;;EAEA,UAAU;;;KAIA,eAAe;;EAEzB;;EAEA;;;KAIU,YAAY,cACtB;;EAEE;;;KAIQ;EACV;EACA;EACA;;;;;;;KAQU;EACV;;;;;;;;EAQA;IACE;IACA;IACA;;;;;;;;;;KAWQ;EACV;EACA;EACA;EACA;EACA,IAAI;EACJ,WAAW;;;KAID;;EAEV;;EAEA;IAAY;IAAe;;;EAE3B,QAAQ;;EAER;;KAGU;EACV;EACA;;EAEA,gBAAgB;;KAGN;EACV;EACA;EACA;;;KAIU;EAEN;EACA,QAAQ;;EAER;;EAEA;EAAiB,SAAS;;EAC1B;;;KAGM;EACR;EAAsB,QAAQ;;EAAoB;;;KAG1C;;EAEV,SAAS;;EAET,YAAY;;;KAIF;EACV;EACA;;;KAIU;EACV;;EAEA;;;KAIU;EACV;EACA;;EAEA;;EAEA,QAAQ;;;;;;EAMR,mBAAmB;;;KAIT,gBAAgB;EAAgB;;;KAGhC;EACV,QAAQ;;;;;;EAMR;;KAGU;EACV;EACA;;EAEA,QAAQ;;KAGE;EACV;;EAEA;;EAEA;;KAGU;EAAuB;EAAiB;;KAExC;EACV;EACA;EACA;;EAEA;EACA,qBAAqB;;KAGX;;EAEV;;EAEA;;;;;;;KAQU;EACV,OAAO;;;KAIG;;;;;;;;;;;;;;;UAsBK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6Cf,OACE,mBACA,QAAQ,eACR;IACE;IACA;IACA,qBAAqB;MAEtB,QAAQ;;;;;;;;EASX,OAAO,SAAS,oBAAoB,QAAQ;;;;;;EAO5C,SAAS,oBAAoB,QAAQ;;;;;EAMrC,aAAa,oBAAoB,QAAQ;;;;;;;;;;;EAYzC,SAAS,SAAS,sBAAsB,QAAQ;;;;;;;;;;;;;;;;EAiBhD,QAAQ;IACN;IACA,UAAU;MAAkB,KAAK,YAAY;;MAC3C;IAAU,QAAQ;IAAc;;;;;;;;EAQpC,QAAQ,oBAAoB,QAAQ;;EAGpC,KACE,mBACA;IAAS;IAAqB;MAC7B,QAAQ;;EAGX,aACE,mBACA;IAAS;IAAqB;MAC7B,QAAQ;;;;;;;;EASX,eAAe;IACb;IACA;IACA;IACA;IACA,mBAAmB;IACnB,qBAAqB;;;;;;;;;IASrB;;;;;IAKA;MACE,QAAQ;;;;;;;;;;EAWZ,YAAY;IACV;IACA;IACA,kBAAkB;MAAY;;IAC9B;IACA;MACE,QAAQ;;;;;;;;EASZ,gBAAgB;IACd;IACA;IACA;IACA;IACA;IACA,QAAQ;IACR,qBAAqB;MACnB,QAAQ;;;;;;;;;EAUZ,YAAY;IACV;IACA;IACA;IACA;IACA;IACA;IACA;IACA;MACE,QAAQ;;;;;;;;EASZ,UACE,mBACA,qBACA;IAAY;IAAuB;MAClC,QAAQ;;;;;;;;EASX,YACE,mBAAmB,yBACnB,sBACC,QAAQ;;;;;;;;EASX,aACE,mBACA,qBACA,oBAAoB,uBACnB;;;;;;;EAQH;;;;;;;;;;;;;IAaE,IACE,YACA,qBACA,QAAQ,gCACR;MAAQ;MAAc,IAAI;MAAM;QAC/B;;;;;IAMH,KAAK,aAAa,QAAQ;;;;;;IAO1B,WAAW,YAAY,UAAU,OAAO;;;;;;;;;EAU1C,OACE,mBACA;IAAS;MACR,cAAc"}
@@ -1 +1 @@
1
- {"version":3,"file":"store-N8PXxDAS.js","names":[],"sources":["../src/store.ts"],"sourcesContent":["/**\n * The A2Store interface — the storage contract every store backend\n * implements. See specs/a2-implementation.md §2–3.\n *\n * This is the whole storage contract: append, read, consumer cursors,\n * failure markers, snapshots, and the live stream. The scheduler\n * needs nothing extra — the armed queue message is its own state, and\n * the store is the only thing it consults.\n */\n\nimport type { PresencePatch } from './contract.ts'\n\n/** A stored event, as the public API exposes it. */\nexport type Event = {\n id: string\n type: string\n payload: unknown\n /** Position in the session's event log, from 1. */\n index: number\n sessionId: string\n createdAt: Date\n}\n\n/** The consumer attempt whose append first persisted a child event. */\nexport type EventCause = {\n consumer: string\n /** The cursor's lane; null for a concurrent consumer's cursor of one event. */\n lane: string | null\n index: number\n attempt: number\n /** Size of one named consumer append, used to reject truncated retries. */\n batchSize?: number\n}\n\n/** What the store persists: immutable event history and its causal edge. */\nexport type StoredEvent = Event & {\n /** Same-session consumer attempt that appended this event; null means root. */\n cause: EventCause | null\n}\n\n/**\n * Where a consumer's cursor stands on its head event. A cursor is one record\n * per (session, consumer, lane): a log cursor for a consumer with one string\n * lane, a lane cursor per lane for a lane function, and a cursor per event\n * (lane null) for a concurrent consumer. Its attempt and failure fields\n * describe the current head and reset when the cursor moves past it.\n */\nexport type HeadDispatch = {\n consumer: string\n lane: string | null\n /** Adapter clock time recorded by the first durable claim of this head. */\n firstClaimedAt: Date | null\n /** Adapter clock time recorded by the most recent durable claim of this head. */\n lastClaimedAt: Date | null\n /** Durable claims of this head, including claims abandoned by hard kills. */\n attemptCount: number\n /** Current holder; null when the head is not claimed. */\n claimHolder: string | null\n /**\n * Adapter clock expiry for the current claim, or, with no holder, the end\n * of a failed attempt's retry hold.\n */\n claimExpiresAt: Date | null\n /**\n * Failed attempts on this head: caught failures, and lapsed claims a later\n * claim took over. This alone drives dead-lettering.\n */\n failureCount: number\n /** Adapter clock time recorded by the most recent caught failure. */\n lastFailedAt: Date | null\n /** Attempt that produced the most recent caught failure. */\n lastFailedAttempt: number | null\n /** The last failure, stringified. */\n lastError: string | null\n /** Adapter clock time recorded when dead-lettered; null otherwise. */\n failedAt: Date | null\n}\n\n/** Where one cursor stands: its position, its head, and the head's dispatch state. */\nexport type StoredCursor = HeadDispatch & {\n /** The last index this cursor finished; a log cursor counts from the latest clear marker. */\n position: number\n /** The event it runs next; null when it has nothing to run. */\n head: number | null\n}\n\n/** A cursor's head event together with its dispatch state. */\nexport type HeadEvent = StoredEvent &\n HeadDispatch & {\n /** Latest clear marker, on claimed heads of a cleared session. */\n clearIndex?: number\n }\n\n/** Names one cursor head: the consumer, its lane, and the head's index. */\nexport type HeadRef = {\n consumer: string\n lane: string | null\n index: number\n}\n\n/**\n * A consumer the caller serves. Work for consumers it does not name is\n * neither pending nor claimed, so a removed consumer's cursors wait for its\n * name to return.\n */\nexport type StoreConsumer = {\n name: string\n /**\n * Set for a consumer with one string lane. Its cursor keeps one position\n * over the log and runs the next event whose type is in `types` (null:\n * every type). A missing cursor is created by the first append that\n * carries one of those types: at the start of the log with `backfill`,\n * otherwise just before that append.\n */\n log?: {\n lane: string\n types: readonly string[] | null\n backfill: boolean\n }\n}\n\n/**\n * One live presence value, as `A2Store.presence.read` returns it. The\n * row is the LWW unit — one participant's one field. `at` is the\n * sender's stamp, the LWW comparator. `expiresAt` is the storage's\n * own clock at the applied set plus its `ttlMs` — never derived from\n * the sender stamp; a row is live strictly before it.\n */\nexport type PresenceRow = {\n participant: string\n field: string\n value: unknown\n seen: number\n at: Date\n expiresAt: Date\n}\n\n/** One consistent cache-plus-tail read for a reducer fold. */\nexport type StoreStateRead = {\n /** The current head checkpoint, even when an older historical snapshot is selected. */\n headIndex: number | null\n /** The greatest cached fold at or before the requested snapshot frontier. */\n snapshot: { index: number; state: unknown } | null\n /** Immutable events strictly after `snapshot.index`, or the full log on a miss. */\n events: Event[]\n /** Latest clear marker; omitted when the session was never cleared. */\n clearIndex?: number\n}\n\nexport type StoreSnapshotWrite = {\n index: number\n state: unknown\n /** Unfinished cursor heads that durably retain this exact checkpoint. */\n pins?: readonly HeadRef[]\n}\n\nexport type StoreStateReadRequest = {\n sessionId: string\n throughIndex?: number\n snapshotThroughIndex?: number\n}\n\n/** The result of atomically claiming every currently eligible head. */\nexport type StoreClaimAvailableResult =\n | {\n outcome: 'claimed'\n events: HeadEvent[]\n /** The session's head when the claim was made: how far behind each event is. */\n head?: number\n }\n | { outcome: 'busy'; retryAt: Date }\n | { outcome: 'settled' }\n\n/** A completion may lose to a newer claim or an earlier completion. */\nexport type CompleteAttemptResult =\n { outcome: 'completed'; events: StoredEvent[] } | { outcome: 'superseded' }\n\n/** The result of one claim-renewal heartbeat, both lists in head order. */\nexport type RenewClaimsResult = {\n /** Listed claims that remain owned by the holder. */\n renewed: HeadRef[]\n /** Listed claims a newer attempt has durably taken, or whose cursor moved on. */\n superseded: HeadRef[]\n}\n\n/** The result of atomically recording a caught failure. */\nexport type FailAttemptResult = {\n outcome: 'failed' | 'dead_lettered' | 'superseded'\n failureCount: number\n}\n\n/** A consumer an appended event starts work for, with its resolved lane. */\nexport type EventTarget = {\n consumer: string\n /** The lane a lane function resolved; null runs the event concurrently. */\n lane: string | null\n}\n\n/** Input to `A2Store.append` — already validated by the machine. */\nexport type AppendEvent = {\n type: string\n payload: unknown\n /** Caller-supplied idempotency key; generated when absent. */\n id?: string\n /** Internal causal edge supplied atomically by consumer `session.append`. */\n cause?: EventCause\n /**\n * Lane-function and concurrent consumers this event starts work for,\n * resolved before persistence. Log-cursor consumers are matched by type\n * from `StoreConsumer.log` instead.\n */\n targets?: readonly EventTarget[]\n}\n\n/** A consumer-returned event with the deterministic id retries require. */\nexport type ReturnedEvent = AppendEvent & { id: string }\n\n/** The rows written by an append and the session's pending state. */\nexport type StoreAppendResult = {\n events: StoredEvent[]\n /**\n * Whether a served consumer has a live head in this session: one that is\n * not dead-lettered. An append whose own batch starts work for a served\n * consumer may report true without checking that work is runnable.\n */\n hasPending: boolean\n}\n\nexport type StoreClearOptions = {\n sessionId: string\n ifLastIndex?: number\n /** The clearing consumer's attempt, fenced like a consumer append. */\n cause?: EventCause\n}\n\nexport type StoreClearResult = {\n cleared: boolean\n /** The head after the operation. */\n index: number\n /** The latest clear marker after the operation, or 0. */\n clearIndex: number\n}\n\nexport type StoreReclaimResult = { deleted: number; done: boolean }\n\nexport type StoreRedriveOptions = {\n sessionId: string\n consumer: string\n index: number\n /** `retry` clears the dead letter; `skip` moves the cursor past it. */\n action: 'retry' | 'skip'\n consumers?: readonly StoreConsumer[]\n}\n\nexport type StoreRedriveResult = {\n /** Whether the head was dead-lettered and this operation resolved it. */\n redriven: boolean\n /** Whether a served consumer has a live head in this session: one that is not dead-lettered. */\n hasPending: boolean\n}\n\n/**\n * An injectable clock. Adapters take one so tests can drive claim\n * expiry, failure timestamps, and (later) stuck-session detection\n * deterministically — against real storage, no mocking.\n */\nexport type Clock = {\n now(): Date\n}\n\n/** An injectable id source for generated event ids. */\nexport type IdSource = () => string\n\nexport const SYSTEM_CLOCK: Clock = {\n now: () => new Date(),\n}\n\nexport const RANDOM_IDS: IdSource = () => crypto.randomUUID()\n\n/**\n * Cursor rules every operation shares:\n *\n * - `consumers` names the consumers the caller serves. Omitted, every cursor\n * counts and no log cursor is created or advanced by type.\n * - A cursor's head is the next event it runs: the lowest unfinished event of\n * its lane, the event itself for a concurrent cursor, or for a log cursor\n * the next event after its position whose type it takes. The clear marker\n * is never a head.\n * - A head is eligible when no live claim or retry hold owns it, it is not\n * dead-lettered, and it lies above the latest clear marker.\n * - Moving a cursor past its head resets the head's attempt and failure\n * fields and releases snapshot pins on it.\n */\nexport interface A2Store {\n /**\n * Accepts a batch; the batch is atomic — one transaction, consecutive\n * `index`es, all-or-nothing. The idempotency key covers the whole\n * operation, not each item: if *every* event's `id` already exists in\n * this session, this is a retry of a committed batch whose ack was\n * lost — return the existing rows as success. If only *some* ids\n * exist, the caller mixed an already-sent batch with fresh events —\n * always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.\n * Ids are unique per session: the same id in another session names a\n * different event and plays no part in these checks.\n *\n * In the same atomic operation, each event's `targets` start cursor work:\n * a lane target queues the event in that consumer's lane, a null lane\n * creates a cursor for that event alone. Each log consumer in `consumers`\n * whose types the batch carries gets its cursor created or, when idle, a\n * head: the next event after its position that it takes, which may\n * precede the batch when an appender that doesn't serve it wrote in\n * between. `hasPending` reflects the whole session for the served consumers,\n * including older work and idempotent retries.\n *\n * An event carrying `cause` is a consumer append, fenced by attempt\n * currency: accept it only while the cause's cursor still has\n * `cause.index` as its head, `cause.attempt` is that head's latest\n * attempt, and the head is not dead-lettered; otherwise throw\n * `A2Error('SUPERSEDED_ATTEMPT')` and write nothing. The check is part of\n * this atomic operation and must serialize against a concurrent\n * `claimAvailable` — an unlocked read of the cursor admits write skew. The\n * idempotent-replay path runs first, so a batch whose ids all exist\n * replays regardless of the current attempt.\n *\n * Clearing stores (a2-implementation.md §17.2) fence one level earlier:\n * a `cause` at or below the latest clear marker throws\n * `SUPERSEDED_ATTEMPT`, an `originIndex` there throws `SESSION_CLEARED`,\n * and ids whose rows lie below the marker are free to reuse.\n */\n append(\n sessionId: string,\n events: AppendEvent[],\n options?: {\n originIndex?: number\n consumers?: readonly StoreConsumer[]\n },\n ): Promise<StoreAppendResult>\n\n /**\n * Optional with `reclaim`. Serialized with `append`: checks\n * `ifLastIndex` against the head and the latest marker, then appends a\n * clear marker at the next index without touching existing rows.\n * Afterwards every event at or below the marker is superseded: no head\n * lies there, and every log cursor's position counts from the marker.\n */\n clear?(options: StoreClearOptions): Promise<StoreClearResult>\n\n /**\n * Deletes one bounded batch of rows below the latest clear marker, with\n * their cursor records, id records, and cached folds. `done` once nothing\n * remains below it. Never waits on a lock that other operations hold.\n */\n reclaim?(sessionId: string): Promise<StoreReclaimResult>\n\n /**\n * Optional with `redrive`. The session's dead-lettered heads above the\n * latest clear marker, in event order, then by consumer name.\n */\n deadLetters?(sessionId: string): Promise<HeadEvent[]>\n\n /**\n * Optional with `deadLetters`. Atomically resolves the consumer's\n * dead-lettered head at `index` above the latest clear marker; anything\n * else is untouched and reports `redriven: false`. `retry` clears\n * `failedAt` and `failureCount`, so the head is claimable again with a\n * fresh failure budget, keeping its claim history and last failure. `skip`\n * moves the cursor past the head with no completion. Both serialize with\n * `claimAvailable` and the consumer-append fence. `hasPending` is read in\n * the same step.\n */\n redrive?(options: StoreRedriveOptions): Promise<StoreRedriveResult>\n\n /**\n * Brings one log consumer's cursor up to date without an append, for\n * `session.consume()`. A missing cursor is created: with `backfill`, at\n * the latest clear marker, taking the first event of its types after it;\n * otherwise at the head, with nothing to run. When the session has nothing\n * after its latest clear (or was never written), a missing cursor stays\n * missing: nothing is written, and the cursor comes back idle at the\n * marker, so a consume of an unknown session leaves no trace. That is\n * exact with `backfill`; without it, the consumer starts at the first\n * consume or serving append that finds events. An\n * idle cursor takes the next event of its types after its position, so it\n * finds events appended by deployments that don't serve it. A cursor with\n * a head, claimed or not, is left as it is. Records the consumer's types,\n * as an append would, and returns the cursor and the session's head.\n */\n wakeLog(options: {\n sessionId: string\n consumer: StoreConsumer & { log: NonNullable<StoreConsumer['log']> }\n }): Promise<{ cursor: StoredCursor; head: number }>\n\n /**\n * The session's cursors: every log and lane cursor, and every concurrent\n * cursor that still has a head, by consumer, then lane (concurrent cursors\n * last, by head). Lane and concurrent cursors whose work lies at or below\n * the latest clear marker are not listed.\n */\n cursors(sessionId: string): Promise<StoredCursor[]>\n\n /** Events for one session, oldest first. Bounds form `(afterIndex, throughIndex]`. */\n read(\n sessionId: string,\n opts?: { afterIndex?: number; throughIndex?: number },\n ): Promise<StoredEvent[]>\n\n /** Public history without causal edges; core falls back to read when absent. */\n readHistory?(\n sessionId: string,\n opts?: { afterIndex?: number; throughIndex?: number },\n ): Promise<Event[]>\n\n /**\n * Atomically claims every eligible head of the served consumers. A live\n * claim or retry hold produces `busy` only when it is the sole remaining\n * obstacle to actionable work. Claimed heads are returned in event order,\n * then by consumer name. Excluded heads stay unclaimed and keep blocking\n * their lane.\n */\n claimAvailable(options: {\n sessionId: string\n holder: string\n ttlMs: number\n expiresAtMs?: number\n exclude?: readonly HeadRef[]\n consumers?: readonly StoreConsumer[]\n /**\n * Taking over a lapsed claim (one whose holder is still recorded, never\n * completed or failed) adds one to `failureCount`. When that reaches\n * `maxFailures`, the head dead-letters instead of being claimed: it\n * comes back in `events` with `failedAt` set and no claim. Its\n * `attemptCount` still advances, fencing the lapsed attempt, and its\n * `lastFailedAttempt` is unchanged.\n */\n maxFailures?: number\n /**\n * Claims only heads at or below this index: a head past it is neither\n * claimed nor reported `busy`, and stays claimable without it.\n */\n through?: number\n }): Promise<StoreClaimAvailableResult>\n\n /**\n * Renews the listed live claims still owned by `holder`. Each listed claim\n * carries the attempt the holder owns. `renewed` lists the claims that\n * remain owned after the operation; `superseded` lists the claims whose\n * head a newer attempt has durably taken, or whose cursor has moved past\n * it. An expired claim no successor has taken appears in neither list — its\n * attempt may still complete (`completeAttempt` is the fence), so it is\n * not reported as lost. Renewal never revives an expired claim.\n */\n renewClaims(options: {\n sessionId: string\n holder: string\n claims: readonly (HeadRef & { attempt: number })[]\n ttlMs: number\n expiresAtMs?: number\n }): Promise<RenewClaimsResult>\n\n /**\n * Atomically completes one current attempt: appends its returned events\n * (with their own targets, waking the served log consumers) and moves the\n * cursor past its head. Retrying a committed completion with the same\n * attempt and deterministic child ids returns the existing children. A\n * stale attempt never appends.\n */\n completeAttempt(options: {\n sessionId: string\n consumer: string\n lane: string | null\n index: number\n attempt: number\n events: ReturnedEvent[]\n consumers?: readonly StoreConsumer[]\n }): Promise<CompleteAttemptResult>\n\n /**\n * Atomically records a caught failure for one claimed attempt. A stale\n * attempt cannot poison a finished head or a newer attempt. Accepted\n * failures record the operation's clock time. A failure that does not\n * dead-letter releases the claim and, with `retryAfterMs`, keeps the head\n * unclaimable for that long: its claim expiry is set with no holder, so\n * `claimAvailable` reports it `busy` until then.\n */\n failAttempt(options: {\n sessionId: string\n consumer: string\n lane: string | null\n index: number\n attempt: number\n error: string\n maxFailures: number\n retryAfterMs?: number\n }): Promise<FailAttemptResult>\n\n /**\n * Reads a reducer snapshot and its event tail as one consistent adapter\n * operation. On a cache miss, `snapshot` is null and `events` is the full\n * log. The snapshot is untrusted; core may reject it and issue a full\n * `read()` when its state schema no longer accepts the cached value.\n * Operational failures propagate without falling back to a full-log read.\n */\n readState(\n sessionId: string,\n reducerName: string,\n options?: { throughIndex?: number; snapshotThroughIndex?: number },\n ): Promise<StoreStateRead>\n\n /**\n * Optional batched `readState` — one consistent snapshot-plus-tail read\n * per session id, aligned positionally with the input (duplicates\n * allowed). Each element has its own frontier; the batch makes no\n * cross-session consistency claim. Core falls back to parallel\n * `readState` calls when absent.\n */\n readStates?(\n requests: readonly StoreStateReadRequest[],\n reducerName: string,\n ): Promise<StoreStateRead[]>\n\n /**\n * Atomically writes disposable reducer checkpoints. The greatest index\n * advances the head cache. Older checkpoints survive only while at least\n * one listed cursor head is still unfinished; moving that cursor past it\n * or dead-lettering it releases the pin and collects unreferenced\n * historical checkpoints.\n */\n putSnapshots(\n sessionId: string,\n reducerName: string,\n snapshots: readonly StoreSnapshotWrite[],\n ): Promise<void>\n\n /**\n * Optional ephemeral-plane capability (specs/a2-implementation.md\n * §15.1) — optional like snapshots are. Values arrive already\n * validated by core; adapters store them opaquely. Presence never\n * touches the event log: no index, no history row, no recovery arm.\n */\n presence?: {\n /**\n * Field-wise last-writer-wins merge of one participant's values:\n * a field whose unexpired entry has a strictly newer `at` (the\n * sender's stamp) is left untouched. `null` deletes the value but\n * keeps a tombstone with its `at`, which takes part in the merge\n * like a value until it expires, so a set older than the delete\n * still loses when it arrives later. Every applied write, value or\n * tombstone, refreshes that field's expiry to the storage's own\n * clock plus `ttlMs` — the sender stamp orders writes but never\n * anchors their lifetime. `subscribe` delivers applied writes only,\n * a delete as `null`.\n */\n set(\n ns: string,\n participant: string,\n values: Record<string, unknown | null>,\n meta: { seen: number; at: Date; ttlMs: number },\n ): Promise<void>\n\n /**\n * The current map, pruned of rows at or past their `expiresAt`.\n * Tombstones never appear.\n */\n read(ns: string): Promise<PresenceRow[]>\n\n /**\n * Adapter-managed patch delivery. Without it, live feeds re-read\n * presence on their poll cadence. HTTP Redis uses adaptive polling\n * here when its endpoint does not support subscriptions.\n */\n subscribe?(ns: string, onPatch: (patch: PresencePatch) => void): () => void\n }\n\n /**\n * A live feed of one session's events, starting after `startAfter`\n * (exclusive). Transport is the backend's choice — in-process pub/sub,\n * polling, LISTEN/NOTIFY — callers never branch on which. The iterable\n * ends when the consumer calls `return()` (e.g. a disconnecting SSE\n * client) and must deliver events appended after subscription.\n */\n stream(\n sessionId: string,\n opts?: { startAfter?: number },\n ): AsyncIterable<Event>\n}\n"],"mappings":";AAiRA,MAAa,eAAsB,EACjC,2BAAW,IAAI,KAAK,EACtB;AAEA,MAAa,mBAA6B,OAAO,WAAW"}
1
+ {"version":3,"file":"store-N8PXxDAS.js","names":[],"sources":["../src/store.ts"],"sourcesContent":["/**\n * The A2Store interface — the storage contract every store backend\n * implements. See specs/a2-implementation.md §2–3.\n *\n * This is the whole storage contract: append, read, consumer cursors,\n * failure markers, snapshots, and the live stream. The scheduler\n * needs nothing extra — the armed queue message is its own state, and\n * the store is the only thing it consults.\n */\n\nimport type { PresencePatch } from './contract.ts'\n\n/** A stored event, as the public API exposes it. */\nexport type Event = {\n id: string\n type: string\n payload: unknown\n /** Position in the session's event log, from 1. */\n index: number\n sessionId: string\n createdAt: Date\n}\n\n/** The consumer attempt whose append first persisted a child event. */\nexport type EventCause = {\n consumer: string\n /** The cursor's lane; null for a concurrent consumer's cursor of one event. */\n lane: string | null\n index: number\n attempt: number\n /** Size of one named consumer append, used to reject truncated retries. */\n batchSize?: number\n}\n\n/** What the store persists: immutable event history and its causal edge. */\nexport type StoredEvent = Event & {\n /** Same-session consumer attempt that appended this event; null means root. */\n cause: EventCause | null\n}\n\n/**\n * Where a consumer's cursor stands on its head event. A cursor is one record\n * per (session, consumer, lane): a log cursor for a consumer with one string\n * lane, a lane cursor per lane for a lane function, and a cursor per event\n * (lane null) for a concurrent consumer. Its attempt and failure fields\n * describe the current head and reset when the cursor moves past it.\n */\nexport type HeadDispatch = {\n consumer: string\n lane: string | null\n /** Adapter clock time recorded by the first durable claim of this head. */\n firstClaimedAt: Date | null\n /** Adapter clock time recorded by the most recent durable claim of this head. */\n lastClaimedAt: Date | null\n /** Durable claims of this head, including claims abandoned by hard kills. */\n attemptCount: number\n /** Current holder; null when the head is not claimed. */\n claimHolder: string | null\n /**\n * Adapter clock expiry for the current claim, or, with no holder, the end\n * of a failed attempt's retry hold.\n */\n claimExpiresAt: Date | null\n /**\n * Failed attempts on this head: caught failures, and lapsed claims a later\n * claim took over. This alone drives dead-lettering.\n */\n failureCount: number\n /** Adapter clock time recorded by the most recent caught failure. */\n lastFailedAt: Date | null\n /** Attempt that produced the most recent caught failure. */\n lastFailedAttempt: number | null\n /** The last failure, stringified. */\n lastError: string | null\n /** Adapter clock time recorded when dead-lettered; null otherwise. */\n failedAt: Date | null\n}\n\n/** Where one cursor stands: its position, its head, and the head's dispatch state. */\nexport type StoredCursor = HeadDispatch & {\n /** The last index this cursor finished; a log cursor counts from the latest clear marker. */\n position: number\n /** The event it runs next; null when it has nothing to run. */\n head: number | null\n}\n\n/** A cursor's head event together with its dispatch state. */\nexport type HeadEvent = StoredEvent &\n HeadDispatch & {\n /** Latest clear marker, on claimed heads of a cleared session. */\n clearIndex?: number\n }\n\n/** Names one cursor head: the consumer, its lane, and the head's index. */\nexport type HeadRef = {\n consumer: string\n lane: string | null\n index: number\n}\n\n/**\n * A consumer the caller serves. Work for consumers it does not name is\n * neither pending nor claimed, so a removed consumer's cursors wait for its\n * name to return.\n */\nexport type StoreConsumer = {\n name: string\n /**\n * Set for a consumer with one string lane. Its cursor keeps one position\n * over the log and runs the next event whose type is in `types` (null:\n * every type). A missing cursor is created by the first append that\n * carries one of those types: at the start of the log with `backfill`,\n * otherwise just before that append.\n */\n log?: {\n lane: string\n types: readonly string[] | null\n backfill: boolean\n }\n}\n\n/**\n * One live presence value, as `A2Store.presence.read` returns it. The\n * row is the LWW unit — one participant's one field. `at` is the\n * sender's stamp, the LWW comparator. `expiresAt` is the storage's\n * own clock at the applied set plus its `ttlMs` — never derived from\n * the sender stamp; a row is live strictly before it.\n */\nexport type PresenceRow = {\n participant: string\n field: string\n value: unknown\n seen: number\n at: Date\n expiresAt: Date\n}\n\n/** One consistent cache-plus-tail read for a reducer fold. */\nexport type StoreStateRead = {\n /** The current head checkpoint, even when an older historical snapshot is selected. */\n headIndex: number | null\n /** The greatest cached fold at or before the requested snapshot frontier. */\n snapshot: { index: number; state: unknown } | null\n /** Immutable events strictly after `snapshot.index`, or the full log on a miss. */\n events: Event[]\n /** Latest clear marker; omitted when the session was never cleared. */\n clearIndex?: number\n}\n\nexport type StoreSnapshotWrite = {\n index: number\n state: unknown\n /** Unfinished cursor heads that durably retain this exact checkpoint. */\n pins?: readonly HeadRef[]\n}\n\nexport type StoreStateReadRequest = {\n sessionId: string\n throughIndex?: number\n snapshotThroughIndex?: number\n}\n\n/** The result of atomically claiming every currently eligible head. */\nexport type StoreClaimAvailableResult =\n | {\n outcome: 'claimed'\n events: HeadEvent[]\n /** The session's head when the claim was made: how far behind each event is. */\n head?: number\n }\n | { outcome: 'busy'; retryAt: Date }\n | { outcome: 'settled' }\n\n/** A completion may lose to a newer claim or an earlier completion. */\nexport type CompleteAttemptResult =\n { outcome: 'completed'; events: StoredEvent[] } | { outcome: 'superseded' }\n\n/** The result of one claim-renewal heartbeat, both lists in head order. */\nexport type RenewClaimsResult = {\n /** Listed claims that remain owned by the holder. */\n renewed: HeadRef[]\n /** Listed claims a newer attempt has durably taken, or whose cursor moved on. */\n superseded: HeadRef[]\n}\n\n/** The result of atomically recording a caught failure. */\nexport type FailAttemptResult = {\n outcome: 'failed' | 'dead_lettered' | 'superseded'\n failureCount: number\n}\n\n/** A consumer an appended event starts work for, with its resolved lane. */\nexport type EventTarget = {\n consumer: string\n /** The lane a lane function resolved; null runs the event concurrently. */\n lane: string | null\n}\n\n/** Input to `A2Store.append` — already validated by the machine. */\nexport type AppendEvent = {\n type: string\n payload: unknown\n /** Caller-supplied idempotency key; generated when absent. */\n id?: string\n /** Internal causal edge supplied atomically by consumer `session.append`. */\n cause?: EventCause\n /**\n * Lane-function and concurrent consumers this event starts work for,\n * resolved before persistence. Log-cursor consumers are matched by type\n * from `StoreConsumer.log` instead.\n */\n targets?: readonly EventTarget[]\n}\n\n/** A consumer-returned event with the deterministic id retries require. */\nexport type ReturnedEvent = AppendEvent & { id: string }\n\n/** The rows written by an append and the session's pending state. */\nexport type StoreAppendResult = {\n events: StoredEvent[]\n /**\n * Whether a served consumer has a live head in this session: one that is\n * not dead-lettered. An append whose own batch starts work for a served\n * consumer may report true without checking that work is runnable.\n */\n hasPending: boolean\n}\n\nexport type StoreClearOptions = {\n sessionId: string\n ifLastIndex?: number\n /** The clearing consumer's attempt, fenced like a consumer append. */\n cause?: EventCause\n}\n\nexport type StoreClearResult = {\n cleared: boolean\n /** The head after the operation. */\n index: number\n /** The latest clear marker after the operation, or 0. */\n clearIndex: number\n}\n\nexport type StoreReclaimResult = { deleted: number; done: boolean }\n\nexport type StoreRedriveOptions = {\n sessionId: string\n consumer: string\n index: number\n /** `retry` clears the dead letter; `skip` moves the cursor past it. */\n action: 'retry' | 'skip'\n consumers?: readonly StoreConsumer[]\n}\n\nexport type StoreRedriveResult = {\n /** Whether the head was dead-lettered and this operation resolved it. */\n redriven: boolean\n /** Whether a served consumer has a live head in this session: one that is not dead-lettered. */\n hasPending: boolean\n}\n\n/**\n * An injectable clock. Adapters take one so tests can drive claim\n * expiry, failure timestamps, and (later) stuck-session detection\n * deterministically — against real storage, no mocking.\n */\nexport type Clock = {\n now(): Date\n}\n\n/** An injectable id source for generated event ids. */\nexport type IdSource = () => string\n\nexport const SYSTEM_CLOCK: Clock = {\n now: () => new Date(),\n}\n\nexport const RANDOM_IDS: IdSource = () => crypto.randomUUID()\n\n/**\n * Cursor rules every operation shares:\n *\n * - `consumers` names the consumers the caller serves. Omitted, every cursor\n * counts and no log cursor is created or advanced by type.\n * - A cursor's head is the next event it runs: the lowest unfinished event of\n * its lane, the event itself for a concurrent cursor, or for a log cursor\n * the next event after its position whose type it takes. The clear marker\n * is never a head.\n * - A head is eligible when no live claim or retry hold owns it, it is not\n * dead-lettered, and it lies above the latest clear marker.\n * - Moving a cursor past its head resets the head's attempt and failure\n * fields and releases snapshot pins on it.\n */\nexport interface A2Store {\n /**\n * Accepts a batch; the batch is atomic — one transaction, consecutive\n * `index`es, all-or-nothing. The idempotency key covers the whole\n * operation, not each item: if *every* event's `id` already exists in\n * this session, this is a retry of a committed batch whose ack was\n * lost — return the existing rows as success. If only *some* ids\n * exist, the caller mixed an already-sent batch with fresh events —\n * always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.\n * Ids are unique per session: the same id in another session names a\n * different event and plays no part in these checks.\n *\n * In the same atomic operation, each event's `targets` start cursor work:\n * a lane target queues the event in that consumer's lane, a null lane\n * creates a cursor for that event alone. Each log consumer in `consumers`\n * whose types the batch carries gets its cursor created or, when idle, a\n * head: the next event after its position that it takes, which may\n * precede the batch when an appender that doesn't serve it wrote in\n * between. `hasPending` reflects the whole session for the served consumers,\n * including older work and idempotent retries.\n *\n * An event carrying `cause` is a consumer append, fenced by attempt\n * currency: accept it only while the cause's cursor still has\n * `cause.index` as its head, `cause.attempt` is that head's latest\n * attempt, and the head is not dead-lettered; otherwise throw\n * `A2Error('SUPERSEDED_ATTEMPT')` and write nothing. The check is part of\n * this atomic operation and must serialize against a concurrent\n * `claimAvailable` — an unlocked read of the cursor admits write skew. The\n * idempotent-replay path runs first, so a batch whose ids all exist\n * replays regardless of the current attempt.\n *\n * Clearing stores (a2-implementation.md §17.2) fence one level earlier:\n * a `cause` at or below the latest clear marker throws\n * `SUPERSEDED_ATTEMPT`, an `originIndex` there throws `SESSION_CLEARED`,\n * and ids whose rows lie below the marker are free to reuse.\n *\n * `ifLastIndex` conditions a non-empty batch on the session's head, the\n * last index including any clear marker: when the head is anything else,\n * throw `lastIndexMismatchError` carrying the head and write nothing. It is\n * checked last, after the replay path and both fences, inside the same\n * atomic operation, never as a read before it. A conditional batch whose\n * ids conflict (mixed, or not matching their rows) reports that head\n * instead of `PARTIAL_DUPLICATE_BATCH` when it isn't `ifLastIndex`\n * (`duplicateBatchError`).\n */\n append(\n sessionId: string,\n events: AppendEvent[],\n options?: {\n originIndex?: number\n ifLastIndex?: number\n consumers?: readonly StoreConsumer[]\n },\n ): Promise<StoreAppendResult>\n\n /**\n * Optional with `reclaim`. Serialized with `append`: checks\n * `ifLastIndex` against the head and the latest marker, then appends a\n * clear marker at the next index without touching existing rows.\n * Afterwards every event at or below the marker is superseded: no head\n * lies there, and every log cursor's position counts from the marker.\n */\n clear?(options: StoreClearOptions): Promise<StoreClearResult>\n\n /**\n * Deletes one bounded batch of rows below the latest clear marker, with\n * their cursor records, id records, and cached folds. `done` once nothing\n * remains below it. Never waits on a lock that other operations hold.\n */\n reclaim?(sessionId: string): Promise<StoreReclaimResult>\n\n /**\n * Optional with `redrive`. The session's dead-lettered heads above the\n * latest clear marker, in event order, then by consumer name.\n */\n deadLetters?(sessionId: string): Promise<HeadEvent[]>\n\n /**\n * Optional with `deadLetters`. Atomically resolves the consumer's\n * dead-lettered head at `index` above the latest clear marker; anything\n * else is untouched and reports `redriven: false`. `retry` clears\n * `failedAt` and `failureCount`, so the head is claimable again with a\n * fresh failure budget, keeping its claim history and last failure. `skip`\n * moves the cursor past the head with no completion. Both serialize with\n * `claimAvailable` and the consumer-append fence. `hasPending` is read in\n * the same step.\n */\n redrive?(options: StoreRedriveOptions): Promise<StoreRedriveResult>\n\n /**\n * Brings one log consumer's cursor up to date without an append, for\n * `session.consume()`. A missing cursor is created: with `backfill`, at\n * the latest clear marker, taking the first event of its types after it;\n * otherwise at the head, with nothing to run. When the session has nothing\n * after its latest clear (or was never written), a missing cursor stays\n * missing: nothing is written, and the cursor comes back idle at the\n * marker, so a consume of an unknown session leaves no trace. That is\n * exact with `backfill`; without it, the consumer starts at the first\n * consume or serving append that finds events. An\n * idle cursor takes the next event of its types after its position, so it\n * finds events appended by deployments that don't serve it. A cursor with\n * a head, claimed or not, is left as it is. Records the consumer's types,\n * as an append would, and returns the cursor and the session's head.\n */\n wakeLog(options: {\n sessionId: string\n consumer: StoreConsumer & { log: NonNullable<StoreConsumer['log']> }\n }): Promise<{ cursor: StoredCursor; head: number }>\n\n /**\n * The session's cursors: every log and lane cursor, and every concurrent\n * cursor that still has a head, by consumer, then lane (concurrent cursors\n * last, by head). Lane and concurrent cursors whose work lies at or below\n * the latest clear marker are not listed.\n */\n cursors(sessionId: string): Promise<StoredCursor[]>\n\n /** Events for one session, oldest first. Bounds form `(afterIndex, throughIndex]`. */\n read(\n sessionId: string,\n opts?: { afterIndex?: number; throughIndex?: number },\n ): Promise<StoredEvent[]>\n\n /** Public history without causal edges; core falls back to read when absent. */\n readHistory?(\n sessionId: string,\n opts?: { afterIndex?: number; throughIndex?: number },\n ): Promise<Event[]>\n\n /**\n * Atomically claims every eligible head of the served consumers. A live\n * claim or retry hold produces `busy` only when it is the sole remaining\n * obstacle to actionable work. Claimed heads are returned in event order,\n * then by consumer name. Excluded heads stay unclaimed and keep blocking\n * their lane.\n */\n claimAvailable(options: {\n sessionId: string\n holder: string\n ttlMs: number\n expiresAtMs?: number\n exclude?: readonly HeadRef[]\n consumers?: readonly StoreConsumer[]\n /**\n * Taking over a lapsed claim (one whose holder is still recorded, never\n * completed or failed) adds one to `failureCount`. When that reaches\n * `maxFailures`, the head dead-letters instead of being claimed: it\n * comes back in `events` with `failedAt` set and no claim. Its\n * `attemptCount` still advances, fencing the lapsed attempt, and its\n * `lastFailedAttempt` is unchanged.\n */\n maxFailures?: number\n /**\n * Claims only heads at or below this index: a head past it is neither\n * claimed nor reported `busy`, and stays claimable without it.\n */\n through?: number\n }): Promise<StoreClaimAvailableResult>\n\n /**\n * Renews the listed live claims still owned by `holder`. Each listed claim\n * carries the attempt the holder owns. `renewed` lists the claims that\n * remain owned after the operation; `superseded` lists the claims whose\n * head a newer attempt has durably taken, or whose cursor has moved past\n * it. An expired claim no successor has taken appears in neither list — its\n * attempt may still complete (`completeAttempt` is the fence), so it is\n * not reported as lost. Renewal never revives an expired claim.\n */\n renewClaims(options: {\n sessionId: string\n holder: string\n claims: readonly (HeadRef & { attempt: number })[]\n ttlMs: number\n expiresAtMs?: number\n }): Promise<RenewClaimsResult>\n\n /**\n * Atomically completes one current attempt: appends its returned events\n * (with their own targets, waking the served log consumers) and moves the\n * cursor past its head. Retrying a committed completion with the same\n * attempt and deterministic child ids returns the existing children. A\n * stale attempt never appends.\n */\n completeAttempt(options: {\n sessionId: string\n consumer: string\n lane: string | null\n index: number\n attempt: number\n events: ReturnedEvent[]\n consumers?: readonly StoreConsumer[]\n }): Promise<CompleteAttemptResult>\n\n /**\n * Atomically records a caught failure for one claimed attempt. A stale\n * attempt cannot poison a finished head or a newer attempt. Accepted\n * failures record the operation's clock time. A failure that does not\n * dead-letter releases the claim and, with `retryAfterMs`, keeps the head\n * unclaimable for that long: its claim expiry is set with no holder, so\n * `claimAvailable` reports it `busy` until then.\n */\n failAttempt(options: {\n sessionId: string\n consumer: string\n lane: string | null\n index: number\n attempt: number\n error: string\n maxFailures: number\n retryAfterMs?: number\n }): Promise<FailAttemptResult>\n\n /**\n * Reads a reducer snapshot and its event tail as one consistent adapter\n * operation. On a cache miss, `snapshot` is null and `events` is the full\n * log. The snapshot is untrusted; core may reject it and issue a full\n * `read()` when its state schema no longer accepts the cached value.\n * Operational failures propagate without falling back to a full-log read.\n */\n readState(\n sessionId: string,\n reducerName: string,\n options?: { throughIndex?: number; snapshotThroughIndex?: number },\n ): Promise<StoreStateRead>\n\n /**\n * Optional batched `readState` — one consistent snapshot-plus-tail read\n * per session id, aligned positionally with the input (duplicates\n * allowed). Each element has its own frontier; the batch makes no\n * cross-session consistency claim. Core falls back to parallel\n * `readState` calls when absent.\n */\n readStates?(\n requests: readonly StoreStateReadRequest[],\n reducerName: string,\n ): Promise<StoreStateRead[]>\n\n /**\n * Atomically writes disposable reducer checkpoints. The greatest index\n * advances the head cache. Older checkpoints survive only while at least\n * one listed cursor head is still unfinished; moving that cursor past it\n * or dead-lettering it releases the pin and collects unreferenced\n * historical checkpoints.\n */\n putSnapshots(\n sessionId: string,\n reducerName: string,\n snapshots: readonly StoreSnapshotWrite[],\n ): Promise<void>\n\n /**\n * Optional ephemeral-plane capability (specs/a2-implementation.md\n * §15.1) — optional like snapshots are. Values arrive already\n * validated by core; adapters store them opaquely. Presence never\n * touches the event log: no index, no history row, no recovery arm.\n */\n presence?: {\n /**\n * Field-wise last-writer-wins merge of one participant's values:\n * a field whose unexpired entry has a strictly newer `at` (the\n * sender's stamp) is left untouched. `null` deletes the value but\n * keeps a tombstone with its `at`, which takes part in the merge\n * like a value until it expires, so a set older than the delete\n * still loses when it arrives later. Every applied write, value or\n * tombstone, refreshes that field's expiry to the storage's own\n * clock plus `ttlMs` — the sender stamp orders writes but never\n * anchors their lifetime. `subscribe` delivers applied writes only,\n * a delete as `null`.\n */\n set(\n ns: string,\n participant: string,\n values: Record<string, unknown | null>,\n meta: { seen: number; at: Date; ttlMs: number },\n ): Promise<void>\n\n /**\n * The current map, pruned of rows at or past their `expiresAt`.\n * Tombstones never appear.\n */\n read(ns: string): Promise<PresenceRow[]>\n\n /**\n * Adapter-managed patch delivery. Without it, live feeds re-read\n * presence on their poll cadence. HTTP Redis uses adaptive polling\n * here when its endpoint does not support subscriptions.\n */\n subscribe?(ns: string, onPatch: (patch: PresencePatch) => void): () => void\n }\n\n /**\n * A live feed of one session's events, starting after `startAfter`\n * (exclusive). Transport is the backend's choice — in-process pub/sub,\n * polling, LISTEN/NOTIFY — callers never branch on which. The iterable\n * ends when the consumer calls `return()` (e.g. a disconnecting SSE\n * client) and must deliver events appended after subscription.\n */\n stream(\n sessionId: string,\n opts?: { startAfter?: number },\n ): AsyncIterable<Event>\n}\n"],"mappings":";AAiRA,MAAa,eAAsB,EACjC,2BAAW,IAAI,KAAK,EACtB;AAEA,MAAa,mBAA6B,OAAO,WAAW"}
@@ -1,10 +1,10 @@
1
- import { i as A2Error } from "./session-clear-wHd5GCpZ.js";
1
+ import { s as duplicateBatchError } from "./session-clear-XJYpmUpg.js";
2
2
  //#region src/idempotent-replay.ts
3
3
  const canonicalJson = (value) => JSON.stringify(value, (_key, candidate) => {
4
4
  if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
5
5
  return Object.fromEntries(Object.entries(candidate).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0));
6
6
  }) ?? "null";
7
- const idempotentReplay = (events, existing) => {
7
+ const idempotentReplay = (events, existing, conditional) => {
8
8
  const byId = new Map(existing.map((event) => [event.id, event]));
9
9
  const ordered = events.map((event) => event.id === void 0 ? void 0 : byId.get(event.id));
10
10
  const firstIndex = ordered[0]?.index;
@@ -12,7 +12,7 @@ const idempotentReplay = (events, existing) => {
12
12
  if (firstIndex === void 0 || batchSize !== void 0 && batchSize !== events.length || ordered.some((stored, index) => {
13
13
  const event = events[index];
14
14
  return stored === void 0 || stored.index !== firstIndex + index || stored.type !== event.type || stored.cause?.batchSize !== event.cause?.batchSize || canonicalJson(stored.payload) !== canonicalJson(event.payload);
15
- })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "already-appended events do not match the retried batch");
15
+ })) throw duplicateBatchError("already-appended events do not match the retried batch", conditional);
16
16
  return ordered;
17
17
  };
18
18
  //#endregion
@@ -62,4 +62,4 @@ const idleLogCursor = (consumer, lane, position) => ({
62
62
  //#endregion
63
63
  export { idempotentReplay as a, idleLogCursor as i, decodeCause as n, decodeReturnedEventIds as r, cursorKeyOf as t };
64
64
 
65
- //# sourceMappingURL=store-codec-DwrWlgQx.js.map
65
+ //# sourceMappingURL=store-codec-pAG2oPgQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-codec-pAG2oPgQ.js","names":[],"sources":["../src/idempotent-replay.ts","../src/store-codec.ts"],"sourcesContent":["import { duplicateBatchError, type ConditionalHead } from './errors.ts'\nimport type { AppendEvent, StoredEvent } from './store.ts'\n\nconst canonicalJson = (value: unknown): string =>\n JSON.stringify(value, (_key, candidate: unknown) => {\n if (\n candidate === null ||\n typeof candidate !== 'object' ||\n Array.isArray(candidate)\n ) {\n return candidate\n }\n return Object.fromEntries(\n Object.entries(candidate).toSorted(([left], [right]) =>\n left < right ? -1 : left > right ? 1 : 0,\n ),\n )\n }) ?? 'null'\n\nexport const idempotentReplay = (\n events: readonly AppendEvent[],\n existing: readonly StoredEvent[],\n conditional?: ConditionalHead,\n): StoredEvent[] => {\n const byId = new Map(existing.map((event) => [event.id, event]))\n const ordered = events.map((event) =>\n event.id === undefined ? undefined : byId.get(event.id),\n )\n const firstIndex = ordered[0]?.index\n const batchSize = events[0]?.cause?.batchSize\n\n if (\n firstIndex === undefined ||\n (batchSize !== undefined && batchSize !== events.length) ||\n ordered.some((stored, index) => {\n const event = events[index]!\n return (\n stored === undefined ||\n stored.index !== firstIndex + index ||\n stored.type !== event.type ||\n stored.cause?.batchSize !== event.cause?.batchSize ||\n canonicalJson(stored.payload) !== canonicalJson(event.payload)\n )\n })\n ) {\n throw duplicateBatchError(\n 'already-appended events do not match the retried batch',\n conditional,\n )\n }\n\n return ordered as StoredEvent[]\n}\n","import type { EventCause, StoredCursor } from './store.ts'\n\nexport const decodeReturnedEventIds = (value: unknown): string[] => {\n const decoded: unknown = typeof value === 'string' ? JSON.parse(value) : value\n if (\n !Array.isArray(decoded) ||\n decoded.some((eventId) => typeof eventId !== 'string')\n ) {\n throw new TypeError('stored event has invalid returned event ids')\n }\n return decoded\n}\n\nconst positive = (value: unknown): value is number =>\n Number.isInteger(value) && Number(value) >= 1\n\n/** A stored causal edge, from its JSON text or parsed value. */\nexport const decodeCause = (value: unknown): EventCause | null => {\n if (value === null || value === undefined) return null\n const decoded = (typeof value === 'string' ? JSON.parse(value) : value) as {\n consumer?: unknown\n lane?: unknown\n index?: unknown\n attempt?: unknown\n batchSize?: unknown\n }\n if (\n typeof decoded.consumer !== 'string' ||\n decoded.consumer.length === 0 ||\n (decoded.lane !== null && typeof decoded.lane !== 'string') ||\n !positive(decoded.index) ||\n !positive(decoded.attempt) ||\n (decoded.batchSize !== undefined && !positive(decoded.batchSize))\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n return {\n consumer: decoded.consumer,\n lane: decoded.lane,\n index: Number(decoded.index),\n attempt: Number(decoded.attempt),\n ...(decoded.batchSize === undefined\n ? {}\n : { batchSize: Number(decoded.batchSize) }),\n }\n}\n\n/** The key of a cursor within its consumer: its lane, or its one event. */\nexport const cursorKeyOf = (lane: string | null, index: number): string =>\n lane === null ? `#${index}` : `L${lane}`\n\n/**\n * A log cursor a store doesn't hold, at `position` with nothing to run:\n * what `wakeLog` returns for a session with nothing after its latest clear,\n * where it writes nothing.\n */\nexport const idleLogCursor = (\n consumer: string,\n lane: string,\n position: number,\n): StoredCursor => ({\n consumer,\n lane,\n position,\n head: null,\n firstClaimedAt: null,\n lastClaimedAt: null,\n attemptCount: 0,\n claimHolder: null,\n claimExpiresAt: null,\n failureCount: 0,\n lastFailedAt: null,\n lastFailedAttempt: null,\n lastError: null,\n failedAt: null,\n})\n"],"mappings":";;AAGA,MAAM,iBAAiB,UACrB,KAAK,UAAU,QAAQ,MAAM,cAAuB;CAClD,IACE,cAAc,QACd,OAAO,cAAc,YACrB,MAAM,QAAQ,SAAS,GAEvB,OAAO;CAET,OAAO,OAAO,YACZ,OAAO,QAAQ,SAAS,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,WAC3C,OAAO,QAAQ,KAAK,OAAO,QAAQ,IAAI,CACzC,CACF;AACF,CAAC,KAAK;AAER,MAAa,oBACX,QACA,UACA,gBACkB;CAClB,MAAM,OAAO,IAAI,IAAI,SAAS,KAAK,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;CAC/D,MAAM,UAAU,OAAO,KAAK,UAC1B,MAAM,OAAO,KAAA,IAAY,KAAA,IAAY,KAAK,IAAI,MAAM,EAAE,CACxD;CACA,MAAM,aAAa,QAAQ,EAAE,EAAE;CAC/B,MAAM,YAAY,OAAO,EAAE,EAAE,OAAO;CAEpC,IACE,eAAe,KAAA,KACd,cAAc,KAAA,KAAa,cAAc,OAAO,UACjD,QAAQ,MAAM,QAAQ,UAAU;EAC9B,MAAM,QAAQ,OAAO;EACrB,OACE,WAAW,KAAA,KACX,OAAO,UAAU,aAAa,SAC9B,OAAO,SAAS,MAAM,QACtB,OAAO,OAAO,cAAc,MAAM,OAAO,aACzC,cAAc,OAAO,OAAO,MAAM,cAAc,MAAM,OAAO;CAEjE,CAAC,GAED,MAAM,oBACJ,0DACA,WACF;CAGF,OAAO;AACT;;;AClDA,MAAa,0BAA0B,UAA6B;CAClE,MAAM,UAAmB,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;CACzE,IACE,CAAC,MAAM,QAAQ,OAAO,KACtB,QAAQ,MAAM,YAAY,OAAO,YAAY,QAAQ,GAErD,MAAM,IAAI,UAAU,6CAA6C;CAEnE,OAAO;AACT;AAEA,MAAM,YAAY,UAChB,OAAO,UAAU,KAAK,KAAK,OAAO,KAAK,KAAK;;AAG9C,MAAa,eAAe,UAAsC;CAChE,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;CAClD,MAAM,UAAW,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;CAOjE,IACE,OAAO,QAAQ,aAAa,YAC5B,QAAQ,SAAS,WAAW,KAC3B,QAAQ,SAAS,QAAQ,OAAO,QAAQ,SAAS,YAClD,CAAC,SAAS,QAAQ,KAAK,KACvB,CAAC,SAAS,QAAQ,OAAO,KACxB,QAAQ,cAAc,KAAA,KAAa,CAAC,SAAS,QAAQ,SAAS,GAE/D,MAAM,IAAI,UAAU,mCAAmC;CAEzD,OAAO;EACL,UAAU,QAAQ;EAClB,MAAM,QAAQ;EACd,OAAO,OAAO,QAAQ,KAAK;EAC3B,SAAS,OAAO,QAAQ,OAAO;EAC/B,GAAI,QAAQ,cAAc,KAAA,IACtB,CAAC,IACD,EAAE,WAAW,OAAO,QAAQ,SAAS,EAAE;CAC7C;AACF;;AAGA,MAAa,eAAe,MAAqB,UAC/C,SAAS,OAAO,IAAI,UAAU,IAAI;;;;;;AAOpC,MAAa,iBACX,UACA,MACA,cACkB;CAClB;CACA;CACA;CACA,MAAM;CACN,gBAAgB;CAChB,eAAe;CACf,cAAc;CACd,aAAa;CACb,gBAAgB;CAChB,cAAc;CACd,cAAc;CACd,mBAAmB;CACnB,WAAW;CACX,UAAU;AACZ"}
@@ -1,4 +1,4 @@
1
- import { r as Clock, s as IdSource, t as A2Store } from "./store-D0MhBvg6.js";
1
+ import { r as Clock, s as IdSource, t as A2Store } from "./store-DEzcHZOA.js";
2
2
  //#region src/store-memory.d.ts
3
3
  type MemoryStoreOptions = {
4
4
  /** Injectable clock — every stored timestamp comes from here. */
@@ -1 +1 @@
1
- {"version":3,"file":"store-memory.d.ts","names":[],"sources":["../src/store-memory.ts"],"mappings":";;KAiCY;;EAEV,QAAQ;;EAER,MAAM;;iBAoLQ,OAAO,UAAS,qBAA0B"}
1
+ {"version":3,"file":"store-memory.d.ts","names":[],"sources":["../src/store-memory.ts"],"mappings":";;KAsCY;;EAEV,QAAQ;;EAER,MAAM;;iBAoLQ,OAAO,UAAS,qBAA0B"}
@@ -1,6 +1,6 @@
1
- import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
1
+ import { c as lastIndexMismatchError, i as A2Error, l as supersededAttemptError, r as sessionClearedError, s as duplicateBatchError, t as CLEAR_EVENT_TYPE } from "./session-clear-XJYpmUpg.js";
2
2
  import { y as nullProtoRecord } from "./internal-CvsrprnU.js";
3
- import { a as idempotentReplay, i as idleLogCursor } from "./store-codec-DwrWlgQx.js";
3
+ import { a as idempotentReplay, i as idleLogCursor } from "./store-codec-pAG2oPgQ.js";
4
4
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
5
5
  //#region src/store-memory.ts
6
6
  const RECLAIM_BATCH = 256;
@@ -363,17 +363,24 @@ function memory(options = {}) {
363
363
  events: [],
364
364
  hasPending: hasPending(sessionId, consumers)
365
365
  };
366
+ const ifLastIndex = appendOptions?.ifLastIndex;
367
+ const conditional = ifLastIndex === void 0 ? void 0 : {
368
+ sessionId,
369
+ ifLastIndex,
370
+ head: headOf(sessions.get(sessionId))
371
+ };
366
372
  const existing = checkBatchIds(sessionId, events);
367
373
  if (existing.length > 0) {
368
374
  if (existing.length === events.length) return {
369
- events: idempotentReplay(events, existing.map(toStored)),
375
+ events: idempotentReplay(events, existing.map(toStored), conditional),
370
376
  hasPending: hasPending(sessionId, consumers)
371
377
  };
372
- throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
378
+ throw duplicateBatchError(`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`, conditional);
373
379
  }
374
380
  const originIndex = appendOptions?.originIndex;
375
381
  if (originIndex !== void 0 && originIndex <= clearIndexOf(sessionId)) throw sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
376
382
  for (const event of events) if (event.cause) assertCurrentCause(sessionId, event.cause);
383
+ if (conditional && conditional.head !== conditional.ifLastIndex) throw lastIndexMismatchError(sessionId, conditional.ifLastIndex, conditional.head);
377
384
  const inserted = insert(sessionId, events, clock.now(), consumers);
378
385
  notifyStreams(sessionId, inserted);
379
386
  return {