@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.
- package/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- 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
|
+
}
|