@abloatai/ablo 0.3.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 (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * HTTP headers used on signed source requests. Conforms to the
3
+ * Standard Webhooks specification (https://www.standardwebhooks.com/)
4
+ * so customer code can verify our signatures with any of the official
5
+ * libraries (svix, standardwebhooks, hookdeck, etc.) — no Ablo-
6
+ * specific verifier required.
7
+ */
8
+ export const ABLO_SOURCE_HEADERS = {
9
+ signature: 'webhook-signature',
10
+ timestamp: 'webhook-timestamp',
11
+ id: 'webhook-id',
12
+ idempotencyKey: 'Idempotency-Key',
13
+ };
14
+ export class SourceSignatureError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = 'SourceSignatureError';
19
+ this.code = code;
20
+ }
21
+ }
22
+ const DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
23
+ function json(data, status = 200) {
24
+ return new Response(JSON.stringify(data), {
25
+ status,
26
+ headers: { 'Content-Type': 'application/json' },
27
+ });
28
+ }
29
+ async function readBody(request) {
30
+ if (typeof request.text === 'function') {
31
+ const rawBody = await request.text();
32
+ return { rawBody, body: JSON.parse(rawBody) };
33
+ }
34
+ const body = (await request.json());
35
+ return { rawBody: JSON.stringify(body), body };
36
+ }
37
+ function getHeader(request, name) {
38
+ const headers = request.headers;
39
+ if (!headers)
40
+ return null;
41
+ if (typeof headers.get === 'function') {
42
+ return headers.get(name);
43
+ }
44
+ const record = headers;
45
+ return record[name] ?? record[name.toLowerCase()] ?? null;
46
+ }
47
+ /**
48
+ * Parse a `webhook-signature` header per the Standard Webhooks spec.
49
+ * Values are space-delimited `<scheme>,<base64>` pairs (e.g.
50
+ * `v1,abc== v1,def==` during a key rotation window). Returns the set
51
+ * of `v1` signatures so the verifier can accept any of them.
52
+ */
53
+ function parseSignatureHeader(raw) {
54
+ if (!raw)
55
+ return [];
56
+ const out = [];
57
+ for (const part of raw.split(/\s+/)) {
58
+ const trimmed = part.trim();
59
+ if (!trimmed)
60
+ continue;
61
+ const commaAt = trimmed.indexOf(',');
62
+ if (commaAt === -1)
63
+ continue;
64
+ const scheme = trimmed.slice(0, commaAt);
65
+ const value = trimmed.slice(commaAt + 1);
66
+ if (scheme === 'v1' && value.length > 0) {
67
+ out.push(value);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ function bufferToBase64(buffer) {
73
+ // Node + browsers both expose `btoa` on the global; we feed it
74
+ // a binary string built from the byte view.
75
+ let binary = '';
76
+ const bytes = new Uint8Array(buffer);
77
+ for (let i = 0; i < bytes.length; i++) {
78
+ binary += String.fromCharCode(bytes[i]);
79
+ }
80
+ return btoa(binary);
81
+ }
82
+ async function hmacSha256Base64(secret, payload) {
83
+ const crypto = globalThis.crypto?.subtle;
84
+ if (!crypto) {
85
+ throw new SourceSignatureError('source_signature_invalid', 'WebCrypto HMAC support is unavailable in this runtime');
86
+ }
87
+ const encoder = new TextEncoder();
88
+ const key = await crypto.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
89
+ return bufferToBase64(await crypto.sign('HMAC', key, encoder.encode(payload)));
90
+ }
91
+ /**
92
+ * Constant-time string equality. Used over `===` so a malicious
93
+ * signature can't be probed byte-by-byte via timing differences.
94
+ */
95
+ function timingSafeEqual(expected, actual) {
96
+ const max = Math.max(expected.length, actual.length);
97
+ let diff = expected.length ^ actual.length;
98
+ for (let i = 0; i < max; i++) {
99
+ diff |= (expected.charCodeAt(i) || 0) ^ (actual.charCodeAt(i) || 0);
100
+ }
101
+ return diff === 0;
102
+ }
103
+ export async function signAbloSourceRequest(options) {
104
+ const signedAt = options.timestamp ?? Date.now();
105
+ // Standard Webhooks signing input: `${msg_id}.${timestamp}.${payload}`
106
+ // Timestamps are seconds-since-epoch in the spec; we keep millis on
107
+ // the wire for backwards compatibility with our existing tolerance
108
+ // window — the receiver compares them millis-to-millis.
109
+ const signature = await hmacSha256Base64(options.secret, `${options.messageId}.${signedAt}.${options.body}`);
110
+ return {
111
+ signedAt,
112
+ signature,
113
+ headers: {
114
+ [ABLO_SOURCE_HEADERS.id]: options.messageId,
115
+ [ABLO_SOURCE_HEADERS.timestamp]: String(signedAt),
116
+ [ABLO_SOURCE_HEADERS.signature]: `v1,${signature}`,
117
+ },
118
+ };
119
+ }
120
+ export async function verifyAbloSourceRequest(options) {
121
+ const messageId = getHeader(options.request, ABLO_SOURCE_HEADERS.id);
122
+ if (!messageId) {
123
+ throw new SourceSignatureError('source_id_missing', 'Missing webhook-id header');
124
+ }
125
+ const rawTimestamp = getHeader(options.request, ABLO_SOURCE_HEADERS.timestamp);
126
+ if (!rawTimestamp) {
127
+ throw new SourceSignatureError('source_timestamp_missing', 'Missing webhook-timestamp header');
128
+ }
129
+ const signedAt = Number(rawTimestamp);
130
+ if (!Number.isFinite(signedAt)) {
131
+ throw new SourceSignatureError('source_timestamp_invalid', 'Invalid webhook-timestamp header');
132
+ }
133
+ const toleranceMs = options.toleranceMs ?? DEFAULT_SIGNATURE_TOLERANCE_MS;
134
+ if (Math.abs(Date.now() - signedAt) > toleranceMs) {
135
+ throw new SourceSignatureError('source_timestamp_expired', 'webhook-timestamp is outside the allowed clock-skew window');
136
+ }
137
+ const presented = parseSignatureHeader(getHeader(options.request, ABLO_SOURCE_HEADERS.signature));
138
+ if (presented.length === 0) {
139
+ throw new SourceSignatureError('source_signature_missing', 'Missing webhook-signature header');
140
+ }
141
+ const expected = await hmacSha256Base64(options.secret, `${messageId}.${signedAt}.${options.body}`);
142
+ // Accept any presented signature that matches — supports key
143
+ // rotation per the Standard Webhooks spec.
144
+ const ok = presented.some((sig) => timingSafeEqual(expected, sig));
145
+ if (!ok) {
146
+ throw new SourceSignatureError('source_signature_invalid', 'Invalid webhook-signature');
147
+ }
148
+ return { messageId, signedAt };
149
+ }
150
+ async function resolveSecret(secret, context) {
151
+ if (!secret)
152
+ return null;
153
+ return typeof secret === 'function' ? secret(context) : secret;
154
+ }
155
+ /**
156
+ * Map a wire request to its scope tag. Each request type corresponds
157
+ * to one scope, so the function is total and exhaustive — adding a
158
+ * new request type forces a new scope tag, which is the right design
159
+ * pressure for keeping the scope vocabulary in sync with the wire.
160
+ */
161
+ function scopeFor(body) {
162
+ switch (body.type) {
163
+ case 'load':
164
+ return 'load';
165
+ case 'list':
166
+ return 'list';
167
+ case 'commit':
168
+ return 'commit';
169
+ case 'events':
170
+ return 'events';
171
+ }
172
+ }
173
+ function normalizeListResult(result) {
174
+ if (Array.isArray(result)) {
175
+ return { rows: result };
176
+ }
177
+ const page = result;
178
+ return page.nextCursor !== undefined
179
+ ? { rows: page.rows, nextCursor: page.nextCursor }
180
+ : { rows: page.rows };
181
+ }
182
+ function getModelHandlers(options, model) {
183
+ const grouped = options.models?.[model];
184
+ if (grouped)
185
+ return grouped;
186
+ const direct = options[model];
187
+ return direct;
188
+ }
189
+ function sameModel(operations) {
190
+ const first = operations[0]?.model;
191
+ if (!first)
192
+ return null;
193
+ return operations.every((op) => op.model === first) ? first : null;
194
+ }
195
+ export function abloSource(options) {
196
+ return async function handleAbloSource(request) {
197
+ if (request.method !== 'POST') {
198
+ return json({ error: 'method_not_allowed' }, 405);
199
+ }
200
+ let body;
201
+ let rawBody;
202
+ try {
203
+ const parsed = await readBody(request);
204
+ body = parsed.body;
205
+ rawBody = parsed.rawBody;
206
+ }
207
+ catch {
208
+ return json({ error: 'invalid_json' }, 400);
209
+ }
210
+ let signature = null;
211
+ try {
212
+ const secret = await resolveSecret(options.signingSecret ?? options.secret, {
213
+ request,
214
+ body,
215
+ rawBody,
216
+ });
217
+ if (secret) {
218
+ signature = await verifyAbloSourceRequest({
219
+ request,
220
+ body: rawBody,
221
+ secret,
222
+ toleranceMs: options.signatureToleranceMs,
223
+ });
224
+ }
225
+ }
226
+ catch (err) {
227
+ if (err instanceof SourceSignatureError) {
228
+ return json({ error: err.code, message: err.message }, 401);
229
+ }
230
+ throw err;
231
+ }
232
+ const auth = options.authorize
233
+ ? await options.authorize({ request, body, rawBody })
234
+ : undefined;
235
+ // Per-key permission scope check. When `resolveScopes` is set,
236
+ // the customer returns the operation set this key is allowed to
237
+ // invoke; we enforce before any model handler runs.
238
+ if (options.resolveScopes) {
239
+ const required = scopeFor(body);
240
+ const granted = await options.resolveScopes({ auth, request, body });
241
+ const grantedSet = granted instanceof Set ? granted : new Set(granted);
242
+ if (!grantedSet.has(required)) {
243
+ return json({
244
+ error: 'source_forbidden',
245
+ required,
246
+ granted: Array.from(grantedSet),
247
+ }, 403);
248
+ }
249
+ }
250
+ const context = {
251
+ auth,
252
+ request,
253
+ messageId: signature?.messageId,
254
+ signedAt: signature?.signedAt,
255
+ ...(body.scope ? { scope: body.scope } : {}),
256
+ };
257
+ if (body.type === 'load') {
258
+ const handlers = getModelHandlers(options, body.model);
259
+ if (!handlers?.load) {
260
+ return json({ error: 'source_load_not_configured', model: body.model }, 404);
261
+ }
262
+ const row = await handlers.load({ id: body.id, context });
263
+ return json({ row });
264
+ }
265
+ if (body.type === 'list') {
266
+ const handlers = getModelHandlers(options, body.model);
267
+ if (!handlers?.list) {
268
+ return json({ error: 'source_list_not_configured', model: body.model }, 404);
269
+ }
270
+ const result = await handlers.list({ query: body.query ?? {}, context });
271
+ const normalized = normalizeListResult(result);
272
+ return json(normalized);
273
+ }
274
+ if (body.type === 'commit') {
275
+ if (options.commit) {
276
+ const result = await options.commit({
277
+ operations: body.operations,
278
+ clientTxId: body.clientTxId,
279
+ context,
280
+ });
281
+ return json(result);
282
+ }
283
+ const model = body.model ?? sameModel(body.operations);
284
+ if (!model) {
285
+ return json({ error: 'source_commit_requires_single_model' }, 400);
286
+ }
287
+ const handlers = getModelHandlers(options, model);
288
+ if (!handlers?.commit) {
289
+ return json({ error: 'source_commit_not_configured', model }, 404);
290
+ }
291
+ const result = await handlers.commit({
292
+ operations: body.operations,
293
+ clientTxId: body.clientTxId,
294
+ context,
295
+ });
296
+ return json(result);
297
+ }
298
+ if (body.type === 'events') {
299
+ if (!options.events) {
300
+ return json({ error: 'source_events_not_configured' }, 404);
301
+ }
302
+ const result = await options.events({
303
+ cursor: body.cursor,
304
+ limit: body.limit,
305
+ context,
306
+ });
307
+ return json({
308
+ events: result.events,
309
+ ...(result.nextCursor !== undefined
310
+ ? { nextCursor: result.nextCursor }
311
+ : {}),
312
+ });
313
+ }
314
+ return json({ error: 'unknown_source_request' }, 400);
315
+ };
316
+ }
317
+ export function dataSource(options) {
318
+ return abloSource(options);
319
+ }
320
+ export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, } from './pushQueue.js';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Customer-side push retry queue.
3
+ *
4
+ * The push path (`POST /api/source/events` on Ablo Cloud) acks
5
+ * synchronously. If the customer's app crashes mid-call, the network
6
+ * drops, or Ablo returns 5xx, those events would otherwise be lost
7
+ * — the poll path is the durability backstop, but it's higher
8
+ * latency.
9
+ *
10
+ * `PushQueue` lives in the customer's process and gives them a
11
+ * queue+worker pattern matching Stripe / Svix semantics:
12
+ *
13
+ * - `enqueue(events)` returns immediately after persisting
14
+ * - background worker delivers and retries per the Standard
15
+ * Webhooks schedule (0, 5s, 5m, 30m, 2h, 5h, 10h, 14h, 20h, 24h
16
+ * — ~3 days total)
17
+ * - exhausted items move to DLQ for customer-owned monitoring
18
+ *
19
+ * Persistence is pluggable — `InMemoryPushQueueStorage` for single-
20
+ * process customers, a SQL implementation against the customer's own
21
+ * outbox table for production.
22
+ */
23
+ import { type SourceEvent } from './index.js';
24
+ export interface PushQueueItem {
25
+ readonly id: string;
26
+ readonly events: readonly SourceEvent[];
27
+ readonly attempts: number;
28
+ /** Timestamp (ms) of the next attempt. Workers skip earlier items. */
29
+ readonly nextAttemptAt: number;
30
+ /** Most recent error message, when any attempt has failed. */
31
+ readonly lastError?: string;
32
+ /** `dlq` once retries exhausted. */
33
+ readonly status: 'pending' | 'delivered' | 'dlq';
34
+ }
35
+ export interface PushQueueStorage {
36
+ /**
37
+ * Append a new item; returns the persisted record. Implementations
38
+ * generate a stable id (used as the `webhook-id`) and set
39
+ * `nextAttemptAt = now`.
40
+ */
41
+ enqueue(events: readonly SourceEvent[]): Promise<PushQueueItem>;
42
+ /** Items whose `nextAttemptAt <= now` and `status === 'pending'`. */
43
+ due(now: number, limit: number): Promise<readonly PushQueueItem[]>;
44
+ /** Bump attempt count + reschedule. */
45
+ reschedule(id: string, nextAttemptAt: number, lastError: string): Promise<void>;
46
+ /** Mark the item delivered (no further attempts). */
47
+ markDelivered(id: string): Promise<void>;
48
+ /** Mark the item DLQ (retries exhausted). */
49
+ markDlq(id: string, lastError: string): Promise<void>;
50
+ /** Read DLQ contents — customer monitors this. */
51
+ listDlq(): Promise<readonly PushQueueItem[]>;
52
+ }
53
+ /**
54
+ * Standard Webhooks retry schedule. Index = attempt number; value =
55
+ * delay-ms after the previous attempt. After the last entry, items
56
+ * move to DLQ.
57
+ *
58
+ * Source: https://www.standardwebhooks.com/
59
+ */
60
+ export declare const STANDARD_WEBHOOKS_RETRY_SCHEDULE: readonly number[];
61
+ export interface PushQueueOptions {
62
+ readonly endpoint: string;
63
+ readonly secret: string;
64
+ readonly storage: PushQueueStorage;
65
+ /**
66
+ * Override the retry delays. Default: Standard Webhooks schedule.
67
+ * The number of attempts equals the array length; the i-th entry
68
+ * is the delay after attempt `i` failed.
69
+ */
70
+ readonly retrySchedule?: readonly number[];
71
+ /** Worker poll interval. Default 1000ms. */
72
+ readonly tickIntervalMs?: number;
73
+ /** Max items pulled per tick. Default 50. */
74
+ readonly batchSize?: number;
75
+ /** Pluggable for tests / non-Node fetch impls. */
76
+ readonly fetch?: typeof fetch;
77
+ /** Pluggable for tests. */
78
+ readonly now?: () => number;
79
+ /** Random jitter on retry delays. Default ±10%. Set to 0 to disable. */
80
+ readonly jitter?: number;
81
+ readonly onError?: (item: PushQueueItem, err: unknown) => void;
82
+ }
83
+ export interface PushQueue {
84
+ enqueue(events: readonly SourceEvent[]): Promise<PushQueueItem>;
85
+ /** Run the worker loop until `signal` aborts. */
86
+ run(signal: AbortSignal): Promise<void>;
87
+ /** Drain the DLQ by re-enqueueing — customer-triggered redrive. */
88
+ redriveDlq(): Promise<number>;
89
+ }
90
+ export declare function createPushQueue(options: PushQueueOptions): PushQueue;
91
+ export declare class InMemoryPushQueueStorage implements PushQueueStorage {
92
+ /**
93
+ * Real implementation, not a mock. Suitable for low-volume single-
94
+ * process customers; not durable across restarts (in-flight items
95
+ * are lost). Production customers should swap in a SQL-backed
96
+ * storage that writes to their existing outbox table.
97
+ */
98
+ private items;
99
+ private nextId;
100
+ private readonly now;
101
+ constructor(options?: {
102
+ now?: () => number;
103
+ });
104
+ enqueue(events: readonly SourceEvent[]): Promise<PushQueueItem>;
105
+ due(now: number, limit: number): Promise<readonly PushQueueItem[]>;
106
+ reschedule(id: string, nextAttemptAt: number, lastError: string): Promise<void>;
107
+ markDelivered(id: string): Promise<void>;
108
+ markDlq(id: string, lastError: string): Promise<void>;
109
+ listDlq(): Promise<readonly PushQueueItem[]>;
110
+ /** Test helper — read all items regardless of status. */
111
+ snapshot(): readonly PushQueueItem[];
112
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Customer-side push retry queue.
3
+ *
4
+ * The push path (`POST /api/source/events` on Ablo Cloud) acks
5
+ * synchronously. If the customer's app crashes mid-call, the network
6
+ * drops, or Ablo returns 5xx, those events would otherwise be lost
7
+ * — the poll path is the durability backstop, but it's higher
8
+ * latency.
9
+ *
10
+ * `PushQueue` lives in the customer's process and gives them a
11
+ * queue+worker pattern matching Stripe / Svix semantics:
12
+ *
13
+ * - `enqueue(events)` returns immediately after persisting
14
+ * - background worker delivers and retries per the Standard
15
+ * Webhooks schedule (0, 5s, 5m, 30m, 2h, 5h, 10h, 14h, 20h, 24h
16
+ * — ~3 days total)
17
+ * - exhausted items move to DLQ for customer-owned monitoring
18
+ *
19
+ * Persistence is pluggable — `InMemoryPushQueueStorage` for single-
20
+ * process customers, a SQL implementation against the customer's own
21
+ * outbox table for production.
22
+ */
23
+ import { ABLO_SOURCE_HEADERS, signAbloSourceRequest, } from './index.js';
24
+ /**
25
+ * Standard Webhooks retry schedule. Index = attempt number; value =
26
+ * delay-ms after the previous attempt. After the last entry, items
27
+ * move to DLQ.
28
+ *
29
+ * Source: https://www.standardwebhooks.com/
30
+ */
31
+ export const STANDARD_WEBHOOKS_RETRY_SCHEDULE = [
32
+ 0, // immediate
33
+ 5_000, // 5s
34
+ 5 * 60_000, // 5m
35
+ 30 * 60_000, // 30m
36
+ 2 * 60 * 60_000, // 2h
37
+ 5 * 60 * 60_000, // 5h
38
+ 10 * 60 * 60_000, // 10h
39
+ 14 * 60 * 60_000, // 14h
40
+ 20 * 60 * 60_000, // 20h
41
+ 24 * 60 * 60_000, // 24h
42
+ ];
43
+ export function createPushQueue(options) {
44
+ const tickIntervalMs = options.tickIntervalMs ?? 1000;
45
+ const batchSize = options.batchSize ?? 50;
46
+ const fetchImpl = options.fetch ?? fetch;
47
+ const now = options.now ?? Date.now;
48
+ const schedule = options.retrySchedule ?? STANDARD_WEBHOOKS_RETRY_SCHEDULE;
49
+ const jitter = options.jitter ?? 0.1;
50
+ return {
51
+ async enqueue(events) {
52
+ return options.storage.enqueue(events);
53
+ },
54
+ async run(signal) {
55
+ while (!signal.aborted) {
56
+ try {
57
+ const due = await options.storage.due(now(), batchSize);
58
+ for (const item of due) {
59
+ if (signal.aborted)
60
+ return;
61
+ await deliver(item);
62
+ }
63
+ }
64
+ catch (err) {
65
+ // Storage failures shouldn't kill the loop; surface and
66
+ // back off the tick interval.
67
+ options.onError?.({ id: 'storage', events: [], attempts: 0, nextAttemptAt: 0, status: 'pending' }, err);
68
+ }
69
+ await sleep(tickIntervalMs, signal);
70
+ }
71
+ },
72
+ async redriveDlq() {
73
+ const items = await options.storage.listDlq();
74
+ let redriven = 0;
75
+ for (const item of items) {
76
+ await options.storage.enqueue(item.events);
77
+ redriven++;
78
+ }
79
+ return redriven;
80
+ },
81
+ };
82
+ async function deliver(item) {
83
+ const rawBody = JSON.stringify({ events: item.events });
84
+ let signed;
85
+ try {
86
+ signed = await signAbloSourceRequest({
87
+ secret: options.secret,
88
+ body: rawBody,
89
+ timestamp: now(),
90
+ // Reuse the queue id as the webhook-id across all retry
91
+ // attempts so the receiver can dedupe replays per spec.
92
+ messageId: item.id,
93
+ });
94
+ }
95
+ catch (err) {
96
+ // Signing should not fail in practice (no network, just HMAC).
97
+ // If it does, treat as a permanent failure.
98
+ await options.storage.markDlq(item.id, formatError(err));
99
+ options.onError?.(item, err);
100
+ return;
101
+ }
102
+ let response;
103
+ try {
104
+ response = await fetchImpl(options.endpoint, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ ...signed.headers,
109
+ [ABLO_SOURCE_HEADERS.idempotencyKey]: item.id,
110
+ },
111
+ body: rawBody,
112
+ });
113
+ }
114
+ catch (err) {
115
+ await reschedule(item, formatError(err));
116
+ return;
117
+ }
118
+ if (response.ok) {
119
+ await options.storage.markDelivered(item.id);
120
+ return;
121
+ }
122
+ // 4xx other than 408/429 are unrecoverable — don't retry. Move
123
+ // straight to DLQ so the customer's monitoring catches the bad
124
+ // request shape early instead of waiting 3 days for retries.
125
+ if (response.status >= 400 &&
126
+ response.status < 500 &&
127
+ response.status !== 408 &&
128
+ response.status !== 429) {
129
+ await options.storage.markDlq(item.id, `HTTP ${response.status}`);
130
+ return;
131
+ }
132
+ await reschedule(item, `HTTP ${response.status}`);
133
+ }
134
+ async function reschedule(item, error) {
135
+ const nextAttempt = item.attempts + 1;
136
+ if (nextAttempt >= schedule.length) {
137
+ await options.storage.markDlq(item.id, error);
138
+ options.onError?.(item, new Error(error));
139
+ return;
140
+ }
141
+ const delay = applyJitter(schedule[nextAttempt], jitter);
142
+ await options.storage.reschedule(item.id, now() + delay, error);
143
+ }
144
+ }
145
+ export class InMemoryPushQueueStorage {
146
+ /**
147
+ * Real implementation, not a mock. Suitable for low-volume single-
148
+ * process customers; not durable across restarts (in-flight items
149
+ * are lost). Production customers should swap in a SQL-backed
150
+ * storage that writes to their existing outbox table.
151
+ */
152
+ items = new Map();
153
+ nextId = 0;
154
+ now;
155
+ constructor(options = {}) {
156
+ this.now = options.now ?? Date.now;
157
+ }
158
+ async enqueue(events) {
159
+ const id = `q_${(++this.nextId).toString(36)}_${Math.random()
160
+ .toString(36)
161
+ .slice(2, 8)}`;
162
+ const item = {
163
+ id,
164
+ events,
165
+ attempts: 0,
166
+ nextAttemptAt: this.now(),
167
+ status: 'pending',
168
+ };
169
+ this.items.set(id, item);
170
+ return item;
171
+ }
172
+ async due(now, limit) {
173
+ const out = [];
174
+ for (const item of this.items.values()) {
175
+ if (out.length >= limit)
176
+ break;
177
+ if (item.status !== 'pending')
178
+ continue;
179
+ if (item.nextAttemptAt > now)
180
+ continue;
181
+ out.push(item);
182
+ }
183
+ return out;
184
+ }
185
+ async reschedule(id, nextAttemptAt, lastError) {
186
+ const item = this.items.get(id);
187
+ if (!item)
188
+ return;
189
+ this.items.set(id, {
190
+ ...item,
191
+ attempts: item.attempts + 1,
192
+ nextAttemptAt,
193
+ lastError,
194
+ });
195
+ }
196
+ async markDelivered(id) {
197
+ const item = this.items.get(id);
198
+ if (!item)
199
+ return;
200
+ this.items.set(id, { ...item, status: 'delivered' });
201
+ }
202
+ async markDlq(id, lastError) {
203
+ const item = this.items.get(id);
204
+ if (!item)
205
+ return;
206
+ this.items.set(id, {
207
+ ...item,
208
+ attempts: item.attempts + 1,
209
+ status: 'dlq',
210
+ lastError,
211
+ });
212
+ }
213
+ async listDlq() {
214
+ return Array.from(this.items.values()).filter((i) => i.status === 'dlq');
215
+ }
216
+ /** Test helper — read all items regardless of status. */
217
+ snapshot() {
218
+ return Array.from(this.items.values());
219
+ }
220
+ }
221
+ function applyJitter(delayMs, factor) {
222
+ if (factor <= 0 || delayMs === 0)
223
+ return delayMs;
224
+ const swing = delayMs * factor;
225
+ return Math.max(0, delayMs + (Math.random() * 2 - 1) * swing);
226
+ }
227
+ function formatError(err) {
228
+ if (err instanceof Error)
229
+ return err.message;
230
+ return String(err);
231
+ }
232
+ function sleep(ms, signal) {
233
+ return new Promise((resolve) => {
234
+ if (signal.aborted) {
235
+ resolve();
236
+ return;
237
+ }
238
+ const timer = setTimeout(() => {
239
+ signal.removeEventListener('abort', onAbort);
240
+ resolve();
241
+ }, ms);
242
+ const onAbort = () => {
243
+ clearTimeout(timer);
244
+ signal.removeEventListener('abort', onAbort);
245
+ resolve();
246
+ };
247
+ signal.addEventListener('abort', onAbort, { once: true });
248
+ });
249
+ }