experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1,106 @@
1
+ /** One event carried by a delayed append task. */
2
+ export type ScheduledEvent = {
3
+ id: string
4
+ type: string
5
+ payload: unknown
6
+ }
7
+
8
+ /** A durable wake-up for pending handler work. */
9
+ export type SchedulerDrainTask = {
10
+ version: 1
11
+ kind: 'drain'
12
+ contract: string
13
+ sessionId: string
14
+ /** Earliest useful delivery as epoch milliseconds. */
15
+ dueAt: number
16
+ }
17
+
18
+ /** A durable delayed append. */
19
+ export type SchedulerAppendTask = {
20
+ version: 1
21
+ kind: 'append'
22
+ /** Stable identity for provider-level deduplication. */
23
+ id: string
24
+ contract: string
25
+ sessionId: string
26
+ /** Earliest useful delivery as epoch milliseconds. */
27
+ dueAt: number
28
+ events: readonly ScheduledEvent[]
29
+ }
30
+
31
+ /** The versioned message protocol shared by scheduler adapters. */
32
+ export type SchedulerTask = SchedulerDrainTask | SchedulerAppendTask
33
+
34
+ const recordOf = (value: unknown): Record<string, unknown> | null =>
35
+ value !== null && typeof value === 'object' && !Array.isArray(value)
36
+ ? (value as Record<string, unknown>)
37
+ : null
38
+
39
+ const validName = (value: unknown): value is string =>
40
+ typeof value === 'string' && value.length > 0
41
+
42
+ const validDueAt = (value: unknown): value is number =>
43
+ typeof value === 'number' && Number.isInteger(value) && value % 1_000 === 0
44
+
45
+ const baseTask = (
46
+ record: Record<string, unknown>,
47
+ ): { contract: string; sessionId: string } | null =>
48
+ Object.hasOwn(record, 'contract') &&
49
+ Object.hasOwn(record, 'sessionId') &&
50
+ validName(record['contract']) &&
51
+ validName(record['sessionId'])
52
+ ? { contract: record['contract'], sessionId: record['sessionId'] }
53
+ : null
54
+
55
+ /**
56
+ * Validate one delivered task.
57
+ * Adapters call this only after their transport authentication has succeeded.
58
+ */
59
+ export function normalizeSchedulerTask(value: unknown): SchedulerTask | null {
60
+ const record = recordOf(value)
61
+ if (!record) return null
62
+ const base = baseTask(record)
63
+ if (!base) return null
64
+
65
+ if (record['version'] !== 1) return null
66
+ if (record['kind'] === 'drain') {
67
+ const dueAt = record['dueAt']
68
+ if (!validDueAt(dueAt)) return null
69
+ return { version: 1, kind: 'drain', ...base, dueAt }
70
+ }
71
+ if (record['kind'] !== 'append') return null
72
+
73
+ const { dueAt, events, id } = record
74
+ if (!validName(id) || !validDueAt(dueAt)) return null
75
+ if (!Array.isArray(events) || events.length === 0) return null
76
+ const parsedEvents: ScheduledEvent[] = []
77
+ for (const event of events) {
78
+ const item = recordOf(event)
79
+ if (
80
+ !item ||
81
+ typeof item['id'] !== 'string' ||
82
+ typeof item['type'] !== 'string' ||
83
+ !Object.hasOwn(item, 'payload')
84
+ ) {
85
+ return null
86
+ }
87
+ parsedEvents.push({
88
+ id: item['id'],
89
+ type: item['type'],
90
+ payload: item['payload'],
91
+ })
92
+ }
93
+ if (
94
+ new Set(parsedEvents.map((event) => event.id)).size !== parsedEvents.length
95
+ ) {
96
+ return null
97
+ }
98
+ return {
99
+ version: 1,
100
+ kind: 'append',
101
+ id,
102
+ ...base,
103
+ dueAt,
104
+ events: parsedEvents,
105
+ }
106
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * experimental-a2/scheduler-vercel — queue-backed scheduler over Vercel Queues.
3
+ *
4
+ * `vercelQueues()` returns an `A2Scheduler` (a2-implementation.md §9):
5
+ *
6
+ * - drain tasks round the requested watchdog time up to a one-second slot.
7
+ * One message per `(topic, contract, session, dueAt)` coalesces appends,
8
+ * claim renewals and racing callbacks. `DuplicateMessageError` is
9
+ * success because that due-time slot is already durable.
10
+ * - append tasks hash their stable task id with the topic for provider
11
+ * deduplication. Their six-day maximum delay leaves a full day for
12
+ * delivery and retries inside the provider's seven-day retention.
13
+ * They target the publishing deployment unless explicitly configured
14
+ * as deploymentless. Drain tasks always remain deployment-scoped.
15
+ * - `handler(...servers)` is the delivery route: look up the server for
16
+ * the contract named in the message and drain it. Settled drains ack;
17
+ * contention arms a later watchdog before acking; handler failure keeps
18
+ * the current message and redelivers with backoff.
19
+ *
20
+ * `@vercel/queue` is an optional peer dependency, imported lazily. The
21
+ * `transport` option is the injection seam — tests run a real little
22
+ * in-memory queue through the same interface.
23
+ */
24
+
25
+ import { createHash } from 'node:crypto'
26
+ import { SYSTEM_CLOCK, type Clock } from './store.ts'
27
+ import {
28
+ assertSchedulerTargets,
29
+ classifySchedulerSendFailure,
30
+ DRAIN_TIMINGS,
31
+ serverInternals,
32
+ } from './internal.ts'
33
+ import { retryableLazy } from './retryable-lazy.ts'
34
+ import {
35
+ normalizeSchedulerTask,
36
+ type SchedulerDrainTask,
37
+ type SchedulerTask,
38
+ } from './scheduler-task.ts'
39
+ import {
40
+ deliverSchedulerAppend,
41
+ type A2Scheduler,
42
+ type DrainableServer,
43
+ } from './server.ts'
44
+
45
+ /**
46
+ * The "not settled yet" signal for a stalled handler — an expected state,
47
+ * not a transport failure. The default transport classifies it in
48
+ * `@vercel/queue`'s `retry` option and reschedules the delivery quietly
49
+ * (`{ afterSeconds }` → visibility change → 200 response, no error log)
50
+ * instead of letting it surface as a thrown 500. Busy deliveries use a
51
+ * fresh watchdog instead. Custom transports should treat this class the
52
+ * same way when their protocol has a polite reschedule.
53
+ */
54
+ export class DrainUnsettled extends Error {}
55
+
56
+ export type QueueDeliveryMetadata = {
57
+ messageId: string
58
+ deliveryCount: number
59
+ }
60
+
61
+ type VercelQueueMessage = SchedulerTask & {
62
+ /** Provider-private state used to address callback lease operations. */
63
+ _a2Vercel?: {
64
+ version: 1
65
+ deployment: 'deployment' | 'deploymentless'
66
+ }
67
+ }
68
+
69
+ /** What this module needs from a Vercel Queues-compatible transport. */
70
+ export type QueueTransport = {
71
+ send(
72
+ topic: string,
73
+ message: SchedulerTask,
74
+ options?: {
75
+ delaySeconds?: number
76
+ idempotencyKey?: string
77
+ retentionSeconds?: number
78
+ },
79
+ /** Resolved A2 affinity. Custom transports own its provider mapping. */
80
+ routing?: {
81
+ deployment: 'deployment' | 'deploymentless'
82
+ },
83
+ ): Promise<unknown>
84
+ /**
85
+ * Wrap the delivery handler into a route. The handler throws
86
+ * `DrainUnsettled` when the session needs redelivery — transports
87
+ * with a polite reschedule (visibility change) should use it there
88
+ * instead of surfacing an error; anything else thrown is a genuine
89
+ * failure and should stay loud.
90
+ */
91
+ handleCallback(
92
+ handler: (
93
+ message: unknown,
94
+ metadata: QueueDeliveryMetadata,
95
+ ) => Promise<void>,
96
+ ): (req: Request) => Promise<Response>
97
+ /** Is this send error the idempotency-key collision? (= success) */
98
+ isDuplicate(error: unknown): boolean
99
+ }
100
+
101
+ export type VercelQueuesOptions = {
102
+ /** Queue topic matching `[A-Za-z0-9_-]+`. Default `'a2'`. */
103
+ topic?: string
104
+ /**
105
+ * Deployment affinity for scheduled append tasks. Recovery drain tasks
106
+ * always target the publishing deployment. Default `'deployment'`.
107
+ */
108
+ scheduledAppends?: 'deployment' | 'deploymentless'
109
+ /** Injectable transport (tests). Default: `@vercel/queue`. */
110
+ transport?: QueueTransport
111
+ /** Injectable clock for due-time calculations (tests). */
112
+ clock?: Clock
113
+ }
114
+
115
+ /** Vercel Queues rejects shorter visibility changes. */
116
+ const MIN_VISIBILITY_SECONDS = 30
117
+
118
+ /** Vercel Queues' maximum message retention and delivery delay. */
119
+ const MAX_RETENTION_SECONDS = 7 * 24 * 60 * 60
120
+
121
+ /** Leave the final retention day for delivery and retries. */
122
+ const MAX_DELAY_SECONDS = 6 * 24 * 60 * 60
123
+
124
+ const TOPIC_PATTERN = /^[A-Za-z0-9_-]+$/
125
+ const RETRYABLE_HTTP_CLIENT_STATUSES = new Set([408, 409, 425, 429])
126
+ /** The SDK's send path maps these statuses to name-only error instances. */
127
+ const TERMINAL_VERCEL_SEND_ERROR_NAMES = new Set([
128
+ 'BadRequestError',
129
+ 'UnauthorizedError',
130
+ 'ForbiddenError',
131
+ ])
132
+
133
+ const recordOf = (value: unknown): Record<string, unknown> | null =>
134
+ value !== null && typeof value === 'object' && !Array.isArray(value)
135
+ ? (value as Record<string, unknown>)
136
+ : null
137
+
138
+ const httpStatusOf = (error: unknown): number | undefined => {
139
+ try {
140
+ const record = recordOf(error)
141
+ if (!record) return undefined
142
+ const response = recordOf(record['response'])
143
+ const statuses = [
144
+ record['status'],
145
+ record['statusCode'],
146
+ response?.['status'],
147
+ ].filter(
148
+ (value): value is number =>
149
+ typeof value === 'number' &&
150
+ Number.isInteger(value) &&
151
+ value >= 100 &&
152
+ value <= 599,
153
+ )
154
+ const [status] = statuses
155
+ if (status === undefined) return undefined
156
+ return statuses.every((candidate) => candidate === status)
157
+ ? status
158
+ : undefined
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ const errorNameOf = (error: unknown): string | undefined => {
165
+ try {
166
+ const name = recordOf(error)?.['name']
167
+ return typeof name === 'string' ? name : undefined
168
+ } catch {
169
+ return undefined
170
+ }
171
+ }
172
+
173
+ const classifyVercelSendFailure = (error: unknown): unknown => {
174
+ const status = httpStatusOf(error)
175
+ const terminal =
176
+ status === undefined
177
+ ? TERMINAL_VERCEL_SEND_ERROR_NAMES.has(errorNameOf(error) ?? '')
178
+ : status >= 400 &&
179
+ status < 500 &&
180
+ !RETRYABLE_HTTP_CLIENT_STATUSES.has(status)
181
+ return classifySchedulerSendFailure(
182
+ error,
183
+ terminal ? 'terminal' : 'retryable',
184
+ )
185
+ }
186
+
187
+ const digestId = (parts: readonly string[]): string =>
188
+ `a2_${createHash('sha256').update(JSON.stringify(parts)).digest('hex')}`
189
+
190
+ /** Base of the unsettled redelivery backoff, doubled per delivery. */
191
+ const UNSETTLED_BACKOFF_BASE_SECONDS = 5
192
+
193
+ /** Redelivery backoff for retry states: base, 2×, 4×… bounded. */
194
+ const unsettledBackoffSeconds = (deliveryCount: number): number =>
195
+ Math.min(
196
+ Math.max(
197
+ UNSETTLED_BACKOFF_BASE_SECONDS * 2 ** Math.max(deliveryCount - 1, 0),
198
+ MIN_VISIBILITY_SECONDS,
199
+ ),
200
+ 60,
201
+ )
202
+
203
+ const vercelQueueTransport = async (): Promise<QueueTransport> => {
204
+ const queue = await import('@vercel/queue').catch(() => {
205
+ throw classifySchedulerSendFailure(
206
+ new Error(
207
+ "experimental-a2/scheduler-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport",
208
+ ),
209
+ 'terminal',
210
+ )
211
+ })
212
+ const deploymentClient = new queue.QueueClient()
213
+ const deploymentlessClient = new queue.QueueClient({ deploymentId: null })
214
+ return {
215
+ send: (topic, message, options, routing) => {
216
+ const deployment = routing?.deployment ?? 'deployment'
217
+ const body: VercelQueueMessage = {
218
+ ...message,
219
+ _a2Vercel: { version: 1, deployment },
220
+ }
221
+ return (
222
+ deployment === 'deploymentless'
223
+ ? deploymentlessClient
224
+ : deploymentClient
225
+ ).send(topic, body, options)
226
+ },
227
+ handleCallback: (handler) => {
228
+ const callbackOptions = {
229
+ // A hard-killed callback normally has a fresh claim watchdog. If
230
+ // its successor send also failed, make the old unacked delivery's
231
+ // fallback as prompt as Vercel Queues permits.
232
+ visibilityTimeoutSeconds: MIN_VISIBILITY_SECONDS,
233
+ // Not-settled-yet is an expected state: reschedule the (still
234
+ // unacked) delivery via a visibility change — 200, no error
235
+ // log. Everything else returns undefined → the SDK's default
236
+ // throw path, which is loud on purpose.
237
+ retry: (error: unknown, metadata: QueueDeliveryMetadata) =>
238
+ error instanceof DrainUnsettled
239
+ ? {
240
+ afterSeconds: unsettledBackoffSeconds(metadata.deliveryCount),
241
+ }
242
+ : undefined,
243
+ }
244
+ const deploymentHandler = deploymentClient.handleCallback(
245
+ handler,
246
+ callbackOptions,
247
+ ) as (req: Request) => Promise<Response>
248
+ const deploymentlessHandler = deploymentlessClient.handleCallback(
249
+ handler,
250
+ callbackOptions,
251
+ ) as (req: Request) => Promise<Response>
252
+ return async (req: Request): Promise<Response> => {
253
+ let delivered: unknown
254
+ try {
255
+ delivered = await req.clone().json()
256
+ } catch {
257
+ return deploymentlessHandler(req)
258
+ }
259
+ const metadata = recordOf(recordOf(delivered)?.['_a2Vercel'])
260
+ const routing =
261
+ metadata?.['version'] === 1 ? metadata['deployment'] : undefined
262
+ return routing === 'deployment'
263
+ ? deploymentHandler(req)
264
+ : deploymentlessHandler(req)
265
+ }
266
+ },
267
+ isDuplicate: (error) => error instanceof queue.DuplicateMessageError,
268
+ }
269
+ }
270
+
271
+ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
272
+ const topic = options.topic ?? 'a2'
273
+ if (typeof topic !== 'string' || !TOPIC_PATTERN.test(topic)) {
274
+ throw new TypeError('vercelQueues() topic must match [A-Za-z0-9_-]+')
275
+ }
276
+ const scheduledAppends = options.scheduledAppends ?? 'deployment'
277
+ if (
278
+ scheduledAppends !== 'deployment' &&
279
+ scheduledAppends !== 'deploymentless'
280
+ ) {
281
+ throw new TypeError(
282
+ "vercelQueues() scheduledAppends must be 'deployment' or 'deploymentless'",
283
+ )
284
+ }
285
+ const clock = options.clock ?? SYSTEM_CLOCK
286
+
287
+ // Lazy: constructing the scheduler value does no I/O and works without
288
+ // the peer dep until the first task or delivery.
289
+ const transport = retryableLazy(() =>
290
+ options.transport
291
+ ? Promise.resolve(options.transport)
292
+ : vercelQueueTransport(),
293
+ ).get
294
+
295
+ const sendTask = async (task: SchedulerTask): Promise<void> => {
296
+ const nowMs = clock.now().getTime()
297
+ const requestedDueAt = task.dueAt
298
+ if (!Number.isFinite(requestedDueAt)) {
299
+ throw classifySchedulerSendFailure(
300
+ new TypeError('vercel scheduler dueAt must be a finite number'),
301
+ 'terminal',
302
+ )
303
+ }
304
+ const dueAt =
305
+ requestedDueAt <= nowMs
306
+ ? Math.floor(nowMs / 1_000) * 1_000
307
+ : Math.ceil(requestedDueAt / 1_000) * 1_000
308
+ const deliveryTargetAt = task.kind === 'append' ? requestedDueAt : dueAt
309
+ const deliveryDelaySeconds = Math.max(
310
+ 0,
311
+ Math.ceil((deliveryTargetAt - nowMs) / 1_000),
312
+ )
313
+ if (deliveryDelaySeconds > MAX_DELAY_SECONDS) {
314
+ throw classifySchedulerSendFailure(
315
+ new RangeError(
316
+ 'vercelQueues() tasks cannot be delayed more than 6 days',
317
+ ),
318
+ 'terminal',
319
+ )
320
+ }
321
+ const deployment = task.kind === 'append' ? scheduledAppends : 'deployment'
322
+ const message: SchedulerTask = { ...task, dueAt }
323
+ const idempotencyKey =
324
+ task.kind === 'drain'
325
+ ? digestId([
326
+ 'a2:scheduler-vercel:drain:v1',
327
+ topic,
328
+ task.contract,
329
+ task.sessionId,
330
+ String(dueAt),
331
+ ])
332
+ : digestId(['a2:scheduler-vercel:append:v1', topic, task.id])
333
+ const t = await transport()
334
+ try {
335
+ await t.send(
336
+ topic,
337
+ message,
338
+ {
339
+ delaySeconds: deliveryDelaySeconds,
340
+ idempotencyKey,
341
+ retentionSeconds: MAX_RETENTION_SECONDS,
342
+ },
343
+ { deployment },
344
+ )
345
+ } catch (err) {
346
+ if (t.isDuplicate(err)) return
347
+ throw classifyVercelSendFailure(err)
348
+ }
349
+ }
350
+
351
+ const successorDueAt = (message: SchedulerDrainTask): number => {
352
+ const minimumDueAt =
353
+ clock.now().getTime() +
354
+ DRAIN_TIMINGS.claimTtlMs +
355
+ DRAIN_TIMINGS.watchdogGraceMs
356
+ const beats = Math.max(
357
+ 1,
358
+ Math.ceil(
359
+ (minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs,
360
+ ),
361
+ )
362
+ return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs
363
+ }
364
+
365
+ return {
366
+ async schedule(task) {
367
+ await sendTask(task)
368
+ },
369
+
370
+ handler(...servers: DrainableServer[]) {
371
+ assertSchedulerTargets(servers, { allowStructural: true })
372
+ const byContract = new Map<string, DrainableServer>()
373
+ for (const server of servers) {
374
+ byContract.set(server.contract.name, server)
375
+ }
376
+ return async (req: Request): Promise<Response> => {
377
+ const t = await transport()
378
+ return t.handleCallback(async (delivered) => {
379
+ const task = normalizeSchedulerTask(delivered)
380
+ if (!task) {
381
+ throw new TypeError('a2 scheduler received an invalid task')
382
+ }
383
+ const server = byContract.get(task.contract)
384
+ if (!server) {
385
+ // A message for a contract this route doesn't serve is a
386
+ // wiring bug — throw so it redelivers and stays visible in
387
+ // queue observability instead of vanishing on an ack.
388
+ throw new Error(
389
+ `a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
390
+ )
391
+ }
392
+ const internals = serverInternals.get(server)
393
+ if (task.kind === 'append') {
394
+ await deliverSchedulerAppend(server, task)
395
+ return
396
+ }
397
+
398
+ const nextDueAt = successorDueAt(task)
399
+ const result = internals
400
+ ? await internals.schedulerDrain(task.sessionId, {
401
+ watchdogDueAt: nextDueAt,
402
+ })
403
+ : {
404
+ ...(await server.drain(task.sessionId)),
405
+ outcome: 'unknown' as const,
406
+ }
407
+ if (result.settled) return
408
+
409
+ if (result.outcome === 'busy') {
410
+ // The live owner normally arms its own claim windows.
411
+ // Ensure a later slot here as well before relinquishing this
412
+ // durable delivery. If the queue is down, the throw leaves the
413
+ // current message unacked for visibility-timeout redelivery.
414
+ if (result.schedulerArm) await result.schedulerArm
415
+ else {
416
+ await sendTask({
417
+ version: 1,
418
+ kind: 'drain',
419
+ contract: task.contract,
420
+ sessionId: task.sessionId,
421
+ dueAt: nextDueAt,
422
+ })
423
+ }
424
+ return
425
+ }
426
+
427
+ // Handler failure keeps the current durable message and uses the
428
+ // transport retry path. Dead-lettered sessions count as settled
429
+ // above, so retries stop there.
430
+ throw new DrainUnsettled(
431
+ `a2 scheduler: session '${task.sessionId}' of '${task.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`,
432
+ )
433
+ })(req)
434
+ }
435
+ },
436
+ }
437
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The browser build of experimental-a2/server. There isn't one — on purpose.
3
+ * Contracts and reducers (from `experimental-a2`) and the session client (from
4
+ * `experimental-a2/client` / `experimental-a2/react`) are the isomorphic surface; the server is
5
+ * where handlers and storage live. This module existing in a client
6
+ * bundle means a `'use client'` file (or something it imports)
7
+ * value-imported your server module.
8
+ */
9
+ throw new Error(
10
+ 'experimental-a2/server is server-only — a client bundle imported it. Import the contract (a2) or the session client (experimental-a2/client, experimental-a2/react) in browser code instead.',
11
+ )
12
+ export {}