@hasna/events 0.1.13 → 0.1.15
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/LICENSE +198 -13
- package/README.md +274 -26
- package/dist/app-event.js +382 -0
- package/dist/catalog.js +191 -0
- package/dist/cli/index.js +1680 -107
- package/dist/commander.js +882 -92
- package/dist/durable-spool.js +184 -0
- package/dist/durable-worker.js +378 -0
- package/dist/durable.js +2232 -0
- package/dist/index.js +868 -71
- package/dist/storage.js +140 -4
- package/dist/transports.js +36 -7
- package/fixtures/hasna.app_event.v1.json +108 -0
- package/hasna.contract.json +70 -0
- package/package.json +46 -12
- package/schemas/hasna.app_event.v1.json +186 -0
- package/types/app-event.d.ts +130 -0
- package/types/catalog.d.ts +136 -0
- package/{dist → types}/commander.d.ts +14 -0
- package/types/durable-spool.d.ts +30 -0
- package/types/durable-worker.d.ts +27 -0
- package/types/durable.d.ts +112 -0
- package/{dist → types}/index.d.ts +23 -8
- package/types/redaction.d.ts +4 -0
- package/{dist → types}/storage.d.ts +16 -3
- package/{dist → types}/transports.d.ts +8 -1
- package/{dist → types}/types.d.ts +64 -2
- /package/{dist → types}/cli/index.d.ts +0 -0
- /package/{dist → types}/filter-options.d.ts +0 -0
- /package/{dist → types}/filter.d.ts +0 -0
- /package/{dist → types}/signing.d.ts +0 -0
package/dist/cli/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli/index.ts
|
|
5
|
-
import { readFileSync } from "fs";
|
|
6
|
-
import { dirname, join as
|
|
5
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
import { dirname, join as join5 } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
|
|
9
9
|
// src/index.ts
|
|
@@ -108,11 +108,15 @@ function channelMatchesEvent(channel, event) {
|
|
|
108
108
|
|
|
109
109
|
// src/storage.ts
|
|
110
110
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
111
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
111
112
|
import { existsSync } from "fs";
|
|
112
113
|
import { homedir } from "os";
|
|
113
114
|
import { join } from "path";
|
|
114
115
|
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
115
116
|
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
117
|
+
var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
118
|
+
var DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
119
|
+
var MAX_EVENT_PAGE_LIMIT = 1000;
|
|
116
120
|
function getEventsDataDir(override) {
|
|
117
121
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
118
122
|
}
|
|
@@ -126,11 +130,13 @@ function getActiveEventsDirEnv() {
|
|
|
126
130
|
|
|
127
131
|
class JsonEventsStore {
|
|
128
132
|
dataDir;
|
|
133
|
+
runtime;
|
|
129
134
|
channelsPath;
|
|
130
135
|
eventsPath;
|
|
131
136
|
deliveriesPath;
|
|
132
137
|
constructor(dataDir = getEventsDataDir()) {
|
|
133
138
|
this.dataDir = dataDir;
|
|
139
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
134
140
|
this.channelsPath = join(dataDir, "channels.json");
|
|
135
141
|
this.eventsPath = join(dataDir, "events.json");
|
|
136
142
|
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
@@ -178,13 +184,58 @@ class JsonEventsStore {
|
|
|
178
184
|
await this.writeJson(this.eventsPath, events);
|
|
179
185
|
return event;
|
|
180
186
|
}
|
|
181
|
-
async
|
|
187
|
+
async appendEventOnce(event, options = {}) {
|
|
182
188
|
await this.init();
|
|
183
|
-
|
|
189
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
190
|
+
const dedupe = options.dedupe !== false;
|
|
191
|
+
if (dedupe) {
|
|
192
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
193
|
+
if (existing) {
|
|
194
|
+
return {
|
|
195
|
+
event: existing,
|
|
196
|
+
stored: false,
|
|
197
|
+
deduped: true,
|
|
198
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
events.push(event);
|
|
203
|
+
await this.writeJson(this.eventsPath, events);
|
|
204
|
+
return {
|
|
205
|
+
event,
|
|
206
|
+
stored: true,
|
|
207
|
+
deduped: false,
|
|
208
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async listEvents(options = {}) {
|
|
212
|
+
await this.init();
|
|
213
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
214
|
+
return queryEvents(events, options);
|
|
215
|
+
}
|
|
216
|
+
async listEventsPage(options = {}) {
|
|
217
|
+
await this.init();
|
|
218
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
219
|
+
const queried = queryEvents(events, {
|
|
220
|
+
eventId: options.eventId,
|
|
221
|
+
source: options.source,
|
|
222
|
+
type: options.type
|
|
223
|
+
});
|
|
224
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
225
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
226
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
227
|
+
const nextOffset = offset + pageEvents.length;
|
|
228
|
+
const hasMore = nextOffset < queried.length;
|
|
229
|
+
return {
|
|
230
|
+
events: pageEvents,
|
|
231
|
+
cursor: options.cursor,
|
|
232
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
233
|
+
hasMore
|
|
234
|
+
};
|
|
184
235
|
}
|
|
185
236
|
async findEventByIdentity(identity) {
|
|
186
237
|
const events = await this.listEvents();
|
|
187
|
-
return events
|
|
238
|
+
return findEventByIdentity(events, identity);
|
|
188
239
|
}
|
|
189
240
|
async appendDelivery(result) {
|
|
190
241
|
await this.init();
|
|
@@ -235,6 +286,83 @@ class JsonEventsStore {
|
|
|
235
286
|
});
|
|
236
287
|
}
|
|
237
288
|
}
|
|
289
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
290
|
+
return {
|
|
291
|
+
mode: "local-files",
|
|
292
|
+
name: "json-events-store",
|
|
293
|
+
remote: false,
|
|
294
|
+
localFiles: true,
|
|
295
|
+
localSqlite: false,
|
|
296
|
+
postgres: false,
|
|
297
|
+
s3: false,
|
|
298
|
+
aws: false,
|
|
299
|
+
durable: true,
|
|
300
|
+
idempotency: "best-effort-local",
|
|
301
|
+
replayCursors: true,
|
|
302
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
306
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
307
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
308
|
+
const payload = {
|
|
309
|
+
offset,
|
|
310
|
+
eventId: options.eventId,
|
|
311
|
+
source: options.source,
|
|
312
|
+
type: options.type
|
|
313
|
+
};
|
|
314
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
315
|
+
}
|
|
316
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
317
|
+
if (!cursor)
|
|
318
|
+
return 0;
|
|
319
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
320
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
321
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
322
|
+
let payload;
|
|
323
|
+
try {
|
|
324
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
325
|
+
} catch {
|
|
326
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
327
|
+
}
|
|
328
|
+
const offset = payload.offset;
|
|
329
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
330
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
331
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
332
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
333
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
334
|
+
return offset;
|
|
335
|
+
}
|
|
336
|
+
function normalizeEventPageLimit(limit) {
|
|
337
|
+
if (limit === undefined)
|
|
338
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
339
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
340
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
341
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
342
|
+
}
|
|
343
|
+
function queryEvents(events, options) {
|
|
344
|
+
let rows = events;
|
|
345
|
+
if (options.eventId)
|
|
346
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
347
|
+
if (options.source)
|
|
348
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
349
|
+
if (options.type)
|
|
350
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
351
|
+
if (options.cursor) {
|
|
352
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
353
|
+
rows = rows.slice(offset);
|
|
354
|
+
}
|
|
355
|
+
if (options.limit !== undefined)
|
|
356
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
357
|
+
return rows;
|
|
358
|
+
}
|
|
359
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
360
|
+
if (cursorValue !== optionValue)
|
|
361
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
362
|
+
}
|
|
363
|
+
function findEventByIdentity(events, identity) {
|
|
364
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
365
|
+
}
|
|
238
366
|
async function getEventsStatus(dataDir) {
|
|
239
367
|
const store = new JsonEventsStore(dataDir);
|
|
240
368
|
await store.init();
|
|
@@ -251,6 +379,7 @@ async function getEventsStatus(dataDir) {
|
|
|
251
379
|
service: "events",
|
|
252
380
|
schemaVersion: "1.0",
|
|
253
381
|
dataDir: store.dataDir,
|
|
382
|
+
storage: store.runtime,
|
|
254
383
|
env: {
|
|
255
384
|
primary: HASNA_EVENTS_DIR_ENV,
|
|
256
385
|
fallback: HASNA_EVENTS_HOME_ENV,
|
|
@@ -304,21 +433,27 @@ function now() {
|
|
|
304
433
|
function truncate(value, max = 4096) {
|
|
305
434
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
306
435
|
}
|
|
307
|
-
function buildWebhookRequest(event, channel) {
|
|
436
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
308
437
|
if (!channel.webhook)
|
|
309
438
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
439
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
440
|
+
if (/^x-hasna-/i.test(name)) {
|
|
441
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
310
444
|
const body = JSON.stringify(event);
|
|
311
|
-
const timestamp =
|
|
445
|
+
const timestamp = options.timestamp ?? new Date().toISOString();
|
|
312
446
|
const headers = {
|
|
313
447
|
"Content-Type": "application/json",
|
|
314
448
|
"User-Agent": "@hasna/events",
|
|
315
449
|
"X-Hasna-Event-Id": event.id,
|
|
316
450
|
"X-Hasna-Event-Type": event.type,
|
|
317
|
-
|
|
318
|
-
|
|
451
|
+
...channel.webhook.headers,
|
|
452
|
+
"X-Hasna-Timestamp": timestamp
|
|
319
453
|
};
|
|
320
|
-
|
|
321
|
-
|
|
454
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
455
|
+
if (secret) {
|
|
456
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
|
|
322
457
|
}
|
|
323
458
|
return { body, headers };
|
|
324
459
|
}
|
|
@@ -326,7 +461,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
326
461
|
if (!channel.webhook)
|
|
327
462
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
328
463
|
const startedAt = now();
|
|
329
|
-
|
|
464
|
+
let secret = channel.webhook.secret;
|
|
465
|
+
if (channel.webhook.secretRef) {
|
|
466
|
+
if (!options.secretResolver) {
|
|
467
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
471
|
+
} catch {
|
|
472
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
473
|
+
}
|
|
474
|
+
if (!secret)
|
|
475
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
476
|
+
}
|
|
477
|
+
const timestamp = (options.now?.() ?? new Date).toISOString();
|
|
478
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
|
|
330
479
|
const controller = new AbortController;
|
|
331
480
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
332
481
|
try {
|
|
@@ -358,6 +507,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
358
507
|
clearTimeout(timeout);
|
|
359
508
|
}
|
|
360
509
|
}
|
|
510
|
+
function failedAttempt(startedAt, error) {
|
|
511
|
+
return {
|
|
512
|
+
attempt: 1,
|
|
513
|
+
status: "failed",
|
|
514
|
+
startedAt,
|
|
515
|
+
completedAt: now(),
|
|
516
|
+
error
|
|
517
|
+
};
|
|
518
|
+
}
|
|
361
519
|
async function dispatchCommand(event, channel) {
|
|
362
520
|
if (!channel.command)
|
|
363
521
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -447,6 +605,94 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
447
605
|
};
|
|
448
606
|
}
|
|
449
607
|
|
|
608
|
+
// src/catalog.ts
|
|
609
|
+
class EventValidationError extends Error {
|
|
610
|
+
eventType;
|
|
611
|
+
issues;
|
|
612
|
+
constructor(eventType, issues) {
|
|
613
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
614
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
615
|
+
this.name = "EventValidationError";
|
|
616
|
+
this.eventType = eventType;
|
|
617
|
+
this.issues = issues;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
class EventTypeCatalog {
|
|
622
|
+
definitions = new Map;
|
|
623
|
+
register(definition) {
|
|
624
|
+
this.definitions.set(definition.type, definition);
|
|
625
|
+
return this;
|
|
626
|
+
}
|
|
627
|
+
unregister(type) {
|
|
628
|
+
return this.definitions.delete(type);
|
|
629
|
+
}
|
|
630
|
+
has(type) {
|
|
631
|
+
return this.definitions.has(type);
|
|
632
|
+
}
|
|
633
|
+
get(type) {
|
|
634
|
+
return this.definitions.get(type);
|
|
635
|
+
}
|
|
636
|
+
list() {
|
|
637
|
+
return [...this.definitions.values()];
|
|
638
|
+
}
|
|
639
|
+
validateEvent(event) {
|
|
640
|
+
const definition = this.definitions.get(event.type);
|
|
641
|
+
if (!definition)
|
|
642
|
+
return { ok: true };
|
|
643
|
+
return definition.validate(event.data, event);
|
|
644
|
+
}
|
|
645
|
+
assertEventValid(event) {
|
|
646
|
+
const result = this.validateEvent(event);
|
|
647
|
+
if (!result.ok) {
|
|
648
|
+
throw new EventValidationError(event.type, result.issues);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
var defaultEventTypeCatalog = new EventTypeCatalog;
|
|
653
|
+
|
|
654
|
+
// src/redaction.ts
|
|
655
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
656
|
+
if (paths.length === 0)
|
|
657
|
+
return event;
|
|
658
|
+
const copy = structuredClone(event);
|
|
659
|
+
for (const path of paths) {
|
|
660
|
+
setPath(copy, path, replacement);
|
|
661
|
+
}
|
|
662
|
+
return copy;
|
|
663
|
+
}
|
|
664
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
665
|
+
return redactValue(event, replacement);
|
|
666
|
+
}
|
|
667
|
+
function shouldRedactKey(key) {
|
|
668
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
669
|
+
}
|
|
670
|
+
function redactValue(value, replacement) {
|
|
671
|
+
if (Array.isArray(value))
|
|
672
|
+
return value.map((item) => redactValue(item, replacement));
|
|
673
|
+
if (!value || typeof value !== "object")
|
|
674
|
+
return value;
|
|
675
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
676
|
+
key,
|
|
677
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
678
|
+
]));
|
|
679
|
+
}
|
|
680
|
+
function setPath(input, path, replacement) {
|
|
681
|
+
const parts = path.split(".");
|
|
682
|
+
let cursor = input;
|
|
683
|
+
for (const part of parts.slice(0, -1)) {
|
|
684
|
+
const next = cursor[part];
|
|
685
|
+
if (!next || typeof next !== "object")
|
|
686
|
+
return;
|
|
687
|
+
cursor = next;
|
|
688
|
+
}
|
|
689
|
+
const last = parts.at(-1);
|
|
690
|
+
if (last && last in cursor)
|
|
691
|
+
cursor[last] = replacement;
|
|
692
|
+
}
|
|
693
|
+
// src/app-event.ts
|
|
694
|
+
var APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
695
|
+
|
|
450
696
|
// src/index.ts
|
|
451
697
|
function createEvent(input) {
|
|
452
698
|
return {
|
|
@@ -468,10 +714,18 @@ class EventsClient {
|
|
|
468
714
|
store;
|
|
469
715
|
redactors;
|
|
470
716
|
transportOptions;
|
|
717
|
+
catalog;
|
|
718
|
+
validateCatalogTypes;
|
|
471
719
|
constructor(options = {}) {
|
|
472
720
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
473
721
|
this.redactors = options.redactors ?? [];
|
|
474
|
-
this.transportOptions = {
|
|
722
|
+
this.transportOptions = {
|
|
723
|
+
fetchImpl: options.fetchImpl,
|
|
724
|
+
secretResolver: options.secretResolver,
|
|
725
|
+
now: options.now
|
|
726
|
+
};
|
|
727
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
728
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
475
729
|
}
|
|
476
730
|
async addChannel(input) {
|
|
477
731
|
const timestamp = new Date().toISOString();
|
|
@@ -489,18 +743,40 @@ class EventsClient {
|
|
|
489
743
|
}
|
|
490
744
|
async emit(input, options = {}) {
|
|
491
745
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
492
|
-
if (options.
|
|
493
|
-
|
|
494
|
-
if (existing) {
|
|
495
|
-
return { event: existing, deliveries: [], deduped: true };
|
|
496
|
-
}
|
|
746
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
747
|
+
this.catalog.assertEventValid(event);
|
|
497
748
|
}
|
|
498
|
-
await this.
|
|
499
|
-
|
|
500
|
-
|
|
749
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
750
|
+
if (append.deduped) {
|
|
751
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
752
|
+
}
|
|
753
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
754
|
+
return { event: append.event, deliveries, deduped: false };
|
|
501
755
|
}
|
|
502
|
-
async listEvents() {
|
|
503
|
-
|
|
756
|
+
async listEvents(options = {}) {
|
|
757
|
+
if (Object.keys(options).length === 0)
|
|
758
|
+
return this.store.listEvents();
|
|
759
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
760
|
+
}
|
|
761
|
+
async listEventsPage(options = {}) {
|
|
762
|
+
if (this.store.listEventsPage)
|
|
763
|
+
return this.store.listEventsPage(options);
|
|
764
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
765
|
+
eventId: options.eventId,
|
|
766
|
+
source: options.source,
|
|
767
|
+
type: options.type
|
|
768
|
+
});
|
|
769
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
770
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
771
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
772
|
+
const nextOffset = offset + pageEvents.length;
|
|
773
|
+
const hasMore = nextOffset < events.length;
|
|
774
|
+
return {
|
|
775
|
+
events: pageEvents,
|
|
776
|
+
cursor: options.cursor,
|
|
777
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
778
|
+
hasMore
|
|
779
|
+
};
|
|
504
780
|
}
|
|
505
781
|
async listDeliveries() {
|
|
506
782
|
return this.store.listDeliveries();
|
|
@@ -568,22 +844,37 @@ class EventsClient {
|
|
|
568
844
|
return result;
|
|
569
845
|
}
|
|
570
846
|
async replay(options = {}) {
|
|
571
|
-
const
|
|
572
|
-
if (options.eventId && event.id !== options.eventId)
|
|
573
|
-
return false;
|
|
574
|
-
if (options.source && event.source !== options.source)
|
|
575
|
-
return false;
|
|
576
|
-
if (options.type && event.type !== options.type)
|
|
577
|
-
return false;
|
|
578
|
-
return true;
|
|
579
|
-
});
|
|
847
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
580
848
|
if (options.dryRun)
|
|
581
|
-
return { events, deliveries: [] };
|
|
849
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
582
850
|
const deliveries = [];
|
|
583
|
-
for (const event of events) {
|
|
851
|
+
for (const event of page.events) {
|
|
584
852
|
deliveries.push(...await this.deliver(event));
|
|
585
853
|
}
|
|
586
|
-
return { events, deliveries };
|
|
854
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
855
|
+
}
|
|
856
|
+
async appendEvent(event, options) {
|
|
857
|
+
if (this.store.appendEventOnce) {
|
|
858
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
859
|
+
}
|
|
860
|
+
if (options.dedupe) {
|
|
861
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
862
|
+
if (existing) {
|
|
863
|
+
return {
|
|
864
|
+
event: existing,
|
|
865
|
+
stored: false,
|
|
866
|
+
deduped: true,
|
|
867
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const stored = await this.store.appendEvent(event);
|
|
872
|
+
return {
|
|
873
|
+
event: stored,
|
|
874
|
+
stored: true,
|
|
875
|
+
deduped: false,
|
|
876
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
877
|
+
};
|
|
587
878
|
}
|
|
588
879
|
async applyRedaction(event, channel) {
|
|
589
880
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -610,15 +901,6 @@ class EventsClient {
|
|
|
610
901
|
return createDeliveryResult(event, channel, attempts);
|
|
611
902
|
}
|
|
612
903
|
}
|
|
613
|
-
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
614
|
-
if (paths.length === 0)
|
|
615
|
-
return event;
|
|
616
|
-
const copy = structuredClone(event);
|
|
617
|
-
for (const path of paths) {
|
|
618
|
-
setPath(copy, path, replacement);
|
|
619
|
-
}
|
|
620
|
-
return copy;
|
|
621
|
-
}
|
|
622
904
|
function sanitizeChannelForOutput(channel) {
|
|
623
905
|
const copy = structuredClone(channel);
|
|
624
906
|
if (copy.webhook?.secret)
|
|
@@ -631,34 +913,19 @@ function sanitizeChannelForOutput(channel) {
|
|
|
631
913
|
function sanitizeChannelsForOutput(channels) {
|
|
632
914
|
return channels.map(sanitizeChannelForOutput);
|
|
633
915
|
}
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
648
|
-
]));
|
|
649
|
-
}
|
|
650
|
-
function setPath(input, path, replacement) {
|
|
651
|
-
const parts = path.split(".");
|
|
652
|
-
let cursor = input;
|
|
653
|
-
for (const part of parts.slice(0, -1)) {
|
|
654
|
-
const next = cursor[part];
|
|
655
|
-
if (!next || typeof next !== "object")
|
|
656
|
-
return;
|
|
657
|
-
cursor = next;
|
|
658
|
-
}
|
|
659
|
-
const last = parts.at(-1);
|
|
660
|
-
if (last && last in cursor)
|
|
661
|
-
cursor[last] = replacement;
|
|
916
|
+
function queryClientEvents(events, options) {
|
|
917
|
+
let rows = events;
|
|
918
|
+
if (options.eventId)
|
|
919
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
920
|
+
if (options.source)
|
|
921
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
922
|
+
if (options.type)
|
|
923
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
924
|
+
if (options.cursor)
|
|
925
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
926
|
+
if (options.limit !== undefined)
|
|
927
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
928
|
+
return rows;
|
|
662
929
|
}
|
|
663
930
|
function normalizeTime(value) {
|
|
664
931
|
if (!value)
|
|
@@ -673,6 +940,1119 @@ function normalizeRetryPolicy(policy) {
|
|
|
673
940
|
};
|
|
674
941
|
}
|
|
675
942
|
|
|
943
|
+
// src/durable.ts
|
|
944
|
+
import { Database } from "bun:sqlite";
|
|
945
|
+
import { createHash, randomUUID as randomUUID3 } from "crypto";
|
|
946
|
+
import {
|
|
947
|
+
chmodSync,
|
|
948
|
+
closeSync,
|
|
949
|
+
existsSync as existsSync2,
|
|
950
|
+
fsyncSync,
|
|
951
|
+
mkdirSync,
|
|
952
|
+
openSync,
|
|
953
|
+
readdirSync,
|
|
954
|
+
readFileSync,
|
|
955
|
+
unlinkSync
|
|
956
|
+
} from "fs";
|
|
957
|
+
import { join as join2 } from "path";
|
|
958
|
+
var DURABLE_SCHEMA_VERSION = 1;
|
|
959
|
+
var MAX_RETRY_ATTEMPTS = 1000;
|
|
960
|
+
var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
961
|
+
var MAX_RETRY_MULTIPLIER = 100;
|
|
962
|
+
var SCHEMA_V1_TABLE_SQL = {
|
|
963
|
+
channels: `CREATE TABLE channels (
|
|
964
|
+
id TEXT PRIMARY KEY,
|
|
965
|
+
enabled INTEGER NOT NULL,
|
|
966
|
+
config_json TEXT NOT NULL,
|
|
967
|
+
created_at TEXT NOT NULL,
|
|
968
|
+
updated_at TEXT NOT NULL
|
|
969
|
+
)`,
|
|
970
|
+
events: `CREATE TABLE events (
|
|
971
|
+
id TEXT PRIMARY KEY,
|
|
972
|
+
dedupe_key TEXT,
|
|
973
|
+
source TEXT NOT NULL,
|
|
974
|
+
type TEXT NOT NULL,
|
|
975
|
+
time TEXT NOT NULL,
|
|
976
|
+
envelope_json TEXT NOT NULL,
|
|
977
|
+
created_at TEXT NOT NULL
|
|
978
|
+
)`,
|
|
979
|
+
outbox: `CREATE TABLE outbox (
|
|
980
|
+
id TEXT PRIMARY KEY,
|
|
981
|
+
event_id TEXT NOT NULL REFERENCES events(id),
|
|
982
|
+
channel_id TEXT NOT NULL,
|
|
983
|
+
event_json TEXT NOT NULL,
|
|
984
|
+
channel_json TEXT NOT NULL,
|
|
985
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'leased', 'delivered', 'dead')),
|
|
986
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
987
|
+
available_at INTEGER NOT NULL,
|
|
988
|
+
lease_owner TEXT,
|
|
989
|
+
lease_expires_at INTEGER,
|
|
990
|
+
attempts_json TEXT NOT NULL DEFAULT '[]',
|
|
991
|
+
created_at TEXT NOT NULL,
|
|
992
|
+
updated_at TEXT NOT NULL,
|
|
993
|
+
UNIQUE(event_id, channel_id)
|
|
994
|
+
)`,
|
|
995
|
+
deliveries: `CREATE TABLE deliveries (
|
|
996
|
+
id TEXT PRIMARY KEY,
|
|
997
|
+
event_id TEXT NOT NULL REFERENCES events(id),
|
|
998
|
+
channel_id TEXT NOT NULL,
|
|
999
|
+
result_json TEXT NOT NULL,
|
|
1000
|
+
created_at TEXT NOT NULL
|
|
1001
|
+
)`
|
|
1002
|
+
};
|
|
1003
|
+
var SCHEMA_V1_INDEX_SQL = {
|
|
1004
|
+
events_dedupe_key_unique: `CREATE UNIQUE INDEX events_dedupe_key_unique
|
|
1005
|
+
ON events(dedupe_key) WHERE dedupe_key IS NOT NULL`,
|
|
1006
|
+
events_source_type_idx: "CREATE INDEX events_source_type_idx ON events(source, type)",
|
|
1007
|
+
outbox_due_idx: "CREATE INDEX outbox_due_idx ON outbox(status, available_at, lease_expires_at)"
|
|
1008
|
+
};
|
|
1009
|
+
var SCHEMA_V1_COLUMNS = {
|
|
1010
|
+
channels: [
|
|
1011
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1012
|
+
{ name: "enabled", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
1013
|
+
{ name: "config_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1014
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1015
|
+
{ name: "updated_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1016
|
+
],
|
|
1017
|
+
events: [
|
|
1018
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1019
|
+
{ name: "dedupe_key", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
1020
|
+
{ name: "source", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1021
|
+
{ name: "type", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1022
|
+
{ name: "time", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1023
|
+
{ name: "envelope_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1024
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1025
|
+
],
|
|
1026
|
+
outbox: [
|
|
1027
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1028
|
+
{ name: "event_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1029
|
+
{ name: "channel_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1030
|
+
{ name: "event_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1031
|
+
{ name: "channel_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1032
|
+
{ name: "status", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1033
|
+
{ name: "attempt_count", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
|
|
1034
|
+
{ name: "available_at", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
1035
|
+
{ name: "lease_owner", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
1036
|
+
{ name: "lease_expires_at", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
|
|
1037
|
+
{ name: "attempts_json", type: "TEXT", notnull: 1, defaultValue: "'[]'", pk: 0 },
|
|
1038
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1039
|
+
{ name: "updated_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1040
|
+
],
|
|
1041
|
+
deliveries: [
|
|
1042
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1043
|
+
{ name: "event_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1044
|
+
{ name: "channel_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1045
|
+
{ name: "result_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1046
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1047
|
+
]
|
|
1048
|
+
};
|
|
1049
|
+
var EVENT_FOREIGN_KEY = {
|
|
1050
|
+
table: "events",
|
|
1051
|
+
from: "event_id",
|
|
1052
|
+
to: "id",
|
|
1053
|
+
onUpdate: "NO ACTION",
|
|
1054
|
+
onDelete: "NO ACTION",
|
|
1055
|
+
match: "NONE"
|
|
1056
|
+
};
|
|
1057
|
+
var SCHEMA_V1_FOREIGN_KEYS = {
|
|
1058
|
+
channels: [],
|
|
1059
|
+
events: [],
|
|
1060
|
+
outbox: [EVENT_FOREIGN_KEY],
|
|
1061
|
+
deliveries: [EVENT_FOREIGN_KEY]
|
|
1062
|
+
};
|
|
1063
|
+
var SCHEMA_V1_INDEXES = {
|
|
1064
|
+
channels: [
|
|
1065
|
+
{ name: "sqlite_autoindex_channels_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1066
|
+
],
|
|
1067
|
+
events: [
|
|
1068
|
+
{ name: "events_dedupe_key_unique", unique: 1, origin: "c", partial: 1, columns: ["dedupe_key"] },
|
|
1069
|
+
{ name: "events_source_type_idx", unique: 0, origin: "c", partial: 0, columns: ["source", "type"] },
|
|
1070
|
+
{ name: "sqlite_autoindex_events_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1071
|
+
],
|
|
1072
|
+
outbox: [
|
|
1073
|
+
{ name: "outbox_due_idx", unique: 0, origin: "c", partial: 0, columns: ["status", "available_at", "lease_expires_at"] },
|
|
1074
|
+
{ name: "sqlite_autoindex_outbox_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] },
|
|
1075
|
+
{ name: "sqlite_autoindex_outbox_2", unique: 1, origin: "u", partial: 0, columns: ["event_id", "channel_id"] }
|
|
1076
|
+
],
|
|
1077
|
+
deliveries: [
|
|
1078
|
+
{ name: "sqlite_autoindex_deliveries_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1079
|
+
]
|
|
1080
|
+
};
|
|
1081
|
+
function defaultWebhookSecretResolver(reference) {
|
|
1082
|
+
if (!reference.startsWith("env:"))
|
|
1083
|
+
throw new Error("Unsupported webhook secret reference scheme");
|
|
1084
|
+
const name = reference.slice("env:".length);
|
|
1085
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
1086
|
+
throw new Error("Invalid webhook secret environment reference");
|
|
1087
|
+
return process.env[name];
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
class DurableEventsBroker {
|
|
1091
|
+
dataDir;
|
|
1092
|
+
databasePath;
|
|
1093
|
+
db;
|
|
1094
|
+
now;
|
|
1095
|
+
transportOptions;
|
|
1096
|
+
constructor(options) {
|
|
1097
|
+
if (!options.dataDir)
|
|
1098
|
+
throw new Error("DurableEventsBroker requires dataDir");
|
|
1099
|
+
this.dataDir = options.dataDir;
|
|
1100
|
+
this.databasePath = join2(options.dataDir, options.databaseName ?? "events.sqlite");
|
|
1101
|
+
this.now = options.now ?? (() => new Date);
|
|
1102
|
+
this.transportOptions = {
|
|
1103
|
+
fetchImpl: options.fetchImpl,
|
|
1104
|
+
secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
|
|
1105
|
+
now: this.now
|
|
1106
|
+
};
|
|
1107
|
+
mkdirSync(this.dataDir, { recursive: true, mode: 448 });
|
|
1108
|
+
chmodSync(this.dataDir, 448);
|
|
1109
|
+
this.db = new Database(this.databasePath, { create: true, strict: true });
|
|
1110
|
+
try {
|
|
1111
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1112
|
+
this.ensureSchema();
|
|
1113
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
1114
|
+
this.db.exec("PRAGMA synchronous = FULL;");
|
|
1115
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
1116
|
+
this.secureDatabaseFiles();
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
this.db.close();
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
close() {
|
|
1123
|
+
this.db.close();
|
|
1124
|
+
}
|
|
1125
|
+
addChannel(input) {
|
|
1126
|
+
if (input.transport !== "webhook") {
|
|
1127
|
+
throw new Error("Durable SQLite channels support only webhook transport");
|
|
1128
|
+
}
|
|
1129
|
+
if (input.webhook?.secret !== undefined) {
|
|
1130
|
+
throw new Error("Durable SQLite channels reject inline webhook secrets; use webhook.secretRef");
|
|
1131
|
+
}
|
|
1132
|
+
if (input.transport === "webhook" && !input.webhook?.secretRef) {
|
|
1133
|
+
throw new Error("Durable SQLite webhook channels require webhook.secretRef");
|
|
1134
|
+
}
|
|
1135
|
+
if (input.webhook?.secretRef && !/^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(input.webhook.secretRef)) {
|
|
1136
|
+
throw new Error("Durable SQLite webhook secretRef must be a runtime reference");
|
|
1137
|
+
}
|
|
1138
|
+
if (input.webhook)
|
|
1139
|
+
validateDurableWebhookConfig(input.webhook);
|
|
1140
|
+
if (input.retry !== undefined)
|
|
1141
|
+
validateRetryPolicy(input.retry);
|
|
1142
|
+
const timestamp = this.now().toISOString();
|
|
1143
|
+
const existing = this.db.query("SELECT config_json FROM channels WHERE id = ?").get(input.id);
|
|
1144
|
+
const existingChannel = existing ? parseJson(existing.config_json) : undefined;
|
|
1145
|
+
const channel = {
|
|
1146
|
+
...input,
|
|
1147
|
+
createdAt: existingChannel?.createdAt ?? input.createdAt ?? timestamp,
|
|
1148
|
+
updatedAt: timestamp
|
|
1149
|
+
};
|
|
1150
|
+
this.immediate(() => {
|
|
1151
|
+
this.db.query(`
|
|
1152
|
+
INSERT INTO channels (id, enabled, config_json, created_at, updated_at)
|
|
1153
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1154
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1155
|
+
enabled = excluded.enabled,
|
|
1156
|
+
config_json = excluded.config_json,
|
|
1157
|
+
updated_at = excluded.updated_at
|
|
1158
|
+
`).run(channel.id, channel.enabled ? 1 : 0, JSON.stringify(channel), channel.createdAt, channel.updatedAt);
|
|
1159
|
+
});
|
|
1160
|
+
this.secureDatabaseFiles();
|
|
1161
|
+
return channel;
|
|
1162
|
+
}
|
|
1163
|
+
listChannels() {
|
|
1164
|
+
const rows = this.db.query("SELECT config_json FROM channels ORDER BY id").all();
|
|
1165
|
+
return rows.map((row) => parseJson(row.config_json));
|
|
1166
|
+
}
|
|
1167
|
+
enqueue(input, options = {}) {
|
|
1168
|
+
const event = redactSensitiveKeys(createEvent({ ...input, time: input.time ?? this.now() }));
|
|
1169
|
+
const result = this.immediate(() => {
|
|
1170
|
+
if (options.dedupe !== false) {
|
|
1171
|
+
const existing = this.findEvent(event.id, event.dedupeKey);
|
|
1172
|
+
if (existing) {
|
|
1173
|
+
const storedEvent = parseJson(existing.envelope_json);
|
|
1174
|
+
return {
|
|
1175
|
+
event: storedEvent,
|
|
1176
|
+
deduped: true,
|
|
1177
|
+
queued: this.queueMatchingChannels(storedEvent)
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
this.db.query(`
|
|
1182
|
+
INSERT INTO events (id, dedupe_key, source, type, time, envelope_json, created_at)
|
|
1183
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1184
|
+
`).run(event.id, event.dedupeKey ?? null, event.source, event.type, event.time, JSON.stringify(event), this.now().toISOString());
|
|
1185
|
+
const queued = this.queueMatchingChannels(event);
|
|
1186
|
+
return { event, deduped: false, queued };
|
|
1187
|
+
});
|
|
1188
|
+
this.secureDatabaseFiles();
|
|
1189
|
+
return result;
|
|
1190
|
+
}
|
|
1191
|
+
async drain(options = {}) {
|
|
1192
|
+
const workerId = options.workerId ?? randomUUID3();
|
|
1193
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1194
|
+
const leaseMs = normalizePositiveInteger(options.leaseMs, 60000, "leaseMs");
|
|
1195
|
+
const attemptedIds = new Set;
|
|
1196
|
+
const summary = {
|
|
1197
|
+
workerId,
|
|
1198
|
+
claimed: 0,
|
|
1199
|
+
delivered: 0,
|
|
1200
|
+
retried: 0,
|
|
1201
|
+
dead: 0,
|
|
1202
|
+
lost: 0,
|
|
1203
|
+
deliveries: []
|
|
1204
|
+
};
|
|
1205
|
+
while (summary.claimed < limit) {
|
|
1206
|
+
const [job] = this.claim({ workerId, limit: 1, leaseMs, excludeIds: [...attemptedIds] });
|
|
1207
|
+
if (!job)
|
|
1208
|
+
break;
|
|
1209
|
+
attemptedIds.add(job.id);
|
|
1210
|
+
summary.claimed += 1;
|
|
1211
|
+
let attempt;
|
|
1212
|
+
try {
|
|
1213
|
+
attempt = await dispatchChannel(job.event, job.channel, this.transportOptions);
|
|
1214
|
+
} catch {
|
|
1215
|
+
const timestamp = this.now().toISOString();
|
|
1216
|
+
attempt = {
|
|
1217
|
+
attempt: job.attempt,
|
|
1218
|
+
status: "failed",
|
|
1219
|
+
startedAt: timestamp,
|
|
1220
|
+
completedAt: timestamp,
|
|
1221
|
+
error: "Webhook delivery failed"
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
attempt.attempt = job.attempt;
|
|
1225
|
+
attempt = sanitizeDurableAttempt(attempt);
|
|
1226
|
+
const settled = this.settle(job, attempt);
|
|
1227
|
+
if (settled.status === "delivered")
|
|
1228
|
+
summary.delivered += 1;
|
|
1229
|
+
if (settled.status === "retry")
|
|
1230
|
+
summary.retried += 1;
|
|
1231
|
+
if (settled.status === "dead")
|
|
1232
|
+
summary.dead += 1;
|
|
1233
|
+
if (settled.status === "lost")
|
|
1234
|
+
summary.lost += 1;
|
|
1235
|
+
if (settled.delivery)
|
|
1236
|
+
summary.deliveries.push(settled.delivery);
|
|
1237
|
+
}
|
|
1238
|
+
this.secureDatabaseFiles();
|
|
1239
|
+
return summary;
|
|
1240
|
+
}
|
|
1241
|
+
importSpool(options = {}) {
|
|
1242
|
+
const inboxDir = join2(this.dataDir, "spool", "inbox");
|
|
1243
|
+
if (!existsSync2(inboxDir))
|
|
1244
|
+
return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
|
|
1245
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1246
|
+
const names = readdirSync(inboxDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, limit);
|
|
1247
|
+
const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0 };
|
|
1248
|
+
for (const name of names) {
|
|
1249
|
+
const path = join2(inboxDir, name);
|
|
1250
|
+
let event;
|
|
1251
|
+
try {
|
|
1252
|
+
event = parseSpoolEnvelope(readFileSync(path, "utf8"));
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
if (isNodeError(error, "ENOENT"))
|
|
1255
|
+
continue;
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
if (spoolFileName(event) !== name)
|
|
1259
|
+
throw new Error("Durable event spool filename does not match its identity");
|
|
1260
|
+
const enqueued = this.enqueue(event);
|
|
1261
|
+
if (enqueued.deduped)
|
|
1262
|
+
result.deduped += 1;
|
|
1263
|
+
else
|
|
1264
|
+
result.imported += 1;
|
|
1265
|
+
result.queued += enqueued.queued;
|
|
1266
|
+
try {
|
|
1267
|
+
unlinkSync(path);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
if (!isNodeError(error, "ENOENT"))
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (names.length > 0)
|
|
1274
|
+
syncDirectory(inboxDir);
|
|
1275
|
+
this.secureDatabaseFiles();
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
1278
|
+
retryDead(options = {}) {
|
|
1279
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1280
|
+
return this.immediate(() => {
|
|
1281
|
+
const conditions = ["status = 'dead'"];
|
|
1282
|
+
const bindings = [];
|
|
1283
|
+
if (options.eventId) {
|
|
1284
|
+
conditions.push("event_id = ?");
|
|
1285
|
+
bindings.push(options.eventId);
|
|
1286
|
+
}
|
|
1287
|
+
if (options.channelId) {
|
|
1288
|
+
conditions.push("channel_id = ?");
|
|
1289
|
+
bindings.push(options.channelId);
|
|
1290
|
+
}
|
|
1291
|
+
const rows = this.db.query(`
|
|
1292
|
+
SELECT id FROM outbox
|
|
1293
|
+
WHERE ${conditions.join(" AND ")}
|
|
1294
|
+
ORDER BY updated_at, id
|
|
1295
|
+
LIMIT ?
|
|
1296
|
+
`).all(...bindings, limit);
|
|
1297
|
+
let requeued = 0;
|
|
1298
|
+
for (const row of rows) {
|
|
1299
|
+
const updated = this.db.query(`
|
|
1300
|
+
UPDATE outbox
|
|
1301
|
+
SET status = 'pending', attempt_count = 0, attempts_json = '[]',
|
|
1302
|
+
available_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
1303
|
+
updated_at = ?
|
|
1304
|
+
WHERE id = ? AND status = 'dead'
|
|
1305
|
+
`).run(this.now().getTime(), this.now().toISOString(), row.id);
|
|
1306
|
+
requeued += Number(updated.changes);
|
|
1307
|
+
}
|
|
1308
|
+
return { matched: rows.length, requeued };
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
status() {
|
|
1312
|
+
const channels = this.count("SELECT COUNT(*) AS count FROM channels");
|
|
1313
|
+
const enabledChannels = this.count("SELECT COUNT(*) AS count FROM channels WHERE enabled = 1");
|
|
1314
|
+
const events = this.count("SELECT COUNT(*) AS count FROM events");
|
|
1315
|
+
const statusRows = this.db.query("SELECT status, COUNT(*) AS count FROM outbox GROUP BY status").all();
|
|
1316
|
+
const statuses = Object.fromEntries(statusRows.map((row) => [row.status, Number(row.count)]));
|
|
1317
|
+
return {
|
|
1318
|
+
service: "events",
|
|
1319
|
+
storage: "local-sqlite",
|
|
1320
|
+
schemaVersion: DURABLE_SCHEMA_VERSION,
|
|
1321
|
+
databasePath: this.databasePath,
|
|
1322
|
+
counts: {
|
|
1323
|
+
channels,
|
|
1324
|
+
enabledChannels,
|
|
1325
|
+
events,
|
|
1326
|
+
pending: statuses.pending ?? 0,
|
|
1327
|
+
leased: statuses.leased ?? 0,
|
|
1328
|
+
delivered: statuses.delivered ?? 0,
|
|
1329
|
+
dead: statuses.dead ?? 0
|
|
1330
|
+
},
|
|
1331
|
+
safety: {
|
|
1332
|
+
statusOmitsEventPayloads: true,
|
|
1333
|
+
databasePersistsEventEnvelopes: true,
|
|
1334
|
+
includesResolvedSecrets: false,
|
|
1335
|
+
inlineWebhookSecretsAllowed: false
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
nextWakeAt() {
|
|
1340
|
+
const row = this.db.query(`
|
|
1341
|
+
SELECT MIN(
|
|
1342
|
+
CASE WHEN o.status = 'leased' THEN o.lease_expires_at ELSE o.available_at END
|
|
1343
|
+
) AS next_at
|
|
1344
|
+
FROM outbox o
|
|
1345
|
+
JOIN channels c ON c.id = o.channel_id AND c.enabled = 1
|
|
1346
|
+
WHERE o.status IN ('pending', 'leased')
|
|
1347
|
+
`).get();
|
|
1348
|
+
return row?.next_at === null || row?.next_at === undefined ? undefined : Number(row.next_at);
|
|
1349
|
+
}
|
|
1350
|
+
claim(options) {
|
|
1351
|
+
return this.immediate(() => {
|
|
1352
|
+
const nowMs = this.now().getTime();
|
|
1353
|
+
const excludeIds = options.excludeIds ?? [];
|
|
1354
|
+
const exclusion = excludeIds.length > 0 ? ` AND o.id NOT IN (${excludeIds.map(() => "?").join(", ")})` : "";
|
|
1355
|
+
const rows = this.db.query(`
|
|
1356
|
+
SELECT o.id, o.event_json, c.config_json AS channel_json,
|
|
1357
|
+
o.attempt_count, o.attempts_json
|
|
1358
|
+
FROM outbox o
|
|
1359
|
+
JOIN channels c ON c.id = o.channel_id AND c.enabled = 1
|
|
1360
|
+
WHERE ((o.status = 'pending' AND o.available_at <= ?)
|
|
1361
|
+
OR (o.status = 'leased' AND o.lease_expires_at <= ?))
|
|
1362
|
+
${exclusion}
|
|
1363
|
+
ORDER BY o.available_at, o.created_at, o.id
|
|
1364
|
+
LIMIT ?
|
|
1365
|
+
`).all(nowMs, nowMs, ...excludeIds, options.limit);
|
|
1366
|
+
const jobs = [];
|
|
1367
|
+
for (const row of rows) {
|
|
1368
|
+
const nextAttempt = Number(row.attempt_count) + 1;
|
|
1369
|
+
const channel = parseJson(row.channel_json);
|
|
1370
|
+
const transportTimeoutMs = channel.webhook?.timeoutMs ?? channel.command?.timeoutMs ?? 15000;
|
|
1371
|
+
const leaseMs = Math.max(options.leaseMs, transportTimeoutMs + 5000);
|
|
1372
|
+
const update = this.db.query(`
|
|
1373
|
+
UPDATE outbox
|
|
1374
|
+
SET status = 'leased', attempt_count = ?, lease_owner = ?,
|
|
1375
|
+
lease_expires_at = ?, updated_at = ?
|
|
1376
|
+
WHERE id = ?
|
|
1377
|
+
AND ((status = 'pending' AND available_at <= ?)
|
|
1378
|
+
OR (status = 'leased' AND lease_expires_at <= ?))
|
|
1379
|
+
`).run(nextAttempt, options.workerId, nowMs + leaseMs, this.now().toISOString(), row.id, nowMs, nowMs);
|
|
1380
|
+
if (Number(update.changes) !== 1)
|
|
1381
|
+
continue;
|
|
1382
|
+
jobs.push({
|
|
1383
|
+
id: row.id,
|
|
1384
|
+
event: parseJson(row.event_json),
|
|
1385
|
+
channel,
|
|
1386
|
+
attempt: nextAttempt,
|
|
1387
|
+
workerId: options.workerId
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
return jobs;
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
settle(job, attempt) {
|
|
1394
|
+
return this.immediate(() => {
|
|
1395
|
+
const row = this.db.query(`
|
|
1396
|
+
SELECT attempts_json FROM outbox
|
|
1397
|
+
WHERE id = ? AND status = 'leased' AND lease_owner = ?
|
|
1398
|
+
`).get(job.id, job.workerId);
|
|
1399
|
+
if (!row)
|
|
1400
|
+
return { status: "lost" };
|
|
1401
|
+
const attempts = parseJson(row.attempts_json);
|
|
1402
|
+
attempts.push(attempt);
|
|
1403
|
+
if (attempt.status === "success") {
|
|
1404
|
+
const delivery2 = createDeliveryResult(job.event, job.channel, attempts);
|
|
1405
|
+
this.completeOutbox(job, "delivered", attempts, delivery2);
|
|
1406
|
+
return { status: "delivered", delivery: delivery2 };
|
|
1407
|
+
}
|
|
1408
|
+
const retry = normalizeRetryPolicy2(job.channel.retry);
|
|
1409
|
+
if (job.attempt < retry.maxAttempts) {
|
|
1410
|
+
const backoffMs = retryBackoffMs(retry, job.attempt);
|
|
1411
|
+
attempt.nextBackoffMs = backoffMs;
|
|
1412
|
+
this.db.query(`
|
|
1413
|
+
UPDATE outbox
|
|
1414
|
+
SET status = 'pending', available_at = ?, attempts_json = ?,
|
|
1415
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
1416
|
+
WHERE id = ? AND lease_owner = ?
|
|
1417
|
+
`).run(this.now().getTime() + backoffMs, JSON.stringify(attempts), this.now().toISOString(), job.id, job.workerId);
|
|
1418
|
+
return { status: "retry" };
|
|
1419
|
+
}
|
|
1420
|
+
const delivery = createDeliveryResult(job.event, job.channel, attempts);
|
|
1421
|
+
this.completeOutbox(job, "dead", attempts, delivery);
|
|
1422
|
+
return { status: "dead", delivery };
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
completeOutbox(job, status, attempts, delivery) {
|
|
1426
|
+
const timestamp = this.now().toISOString();
|
|
1427
|
+
this.db.query(`
|
|
1428
|
+
UPDATE outbox
|
|
1429
|
+
SET status = ?, attempts_json = ?, lease_owner = NULL,
|
|
1430
|
+
lease_expires_at = NULL, updated_at = ?
|
|
1431
|
+
WHERE id = ? AND lease_owner = ?
|
|
1432
|
+
`).run(status, JSON.stringify(attempts), timestamp, job.id, job.workerId);
|
|
1433
|
+
this.db.query(`
|
|
1434
|
+
INSERT INTO deliveries (id, event_id, channel_id, result_json, created_at)
|
|
1435
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1436
|
+
`).run(delivery.id, job.event.id, job.channel.id, JSON.stringify(delivery), timestamp);
|
|
1437
|
+
}
|
|
1438
|
+
findEvent(id, dedupeKey) {
|
|
1439
|
+
if (dedupeKey === undefined) {
|
|
1440
|
+
return this.db.query("SELECT envelope_json FROM events WHERE id = ? LIMIT 1").get(id);
|
|
1441
|
+
}
|
|
1442
|
+
return this.db.query(`
|
|
1443
|
+
SELECT envelope_json FROM events
|
|
1444
|
+
WHERE id = ? OR dedupe_key = ?
|
|
1445
|
+
LIMIT 1
|
|
1446
|
+
`).get(id, dedupeKey);
|
|
1447
|
+
}
|
|
1448
|
+
queueMatchingChannels(event) {
|
|
1449
|
+
const channels = this.db.query("SELECT config_json FROM channels WHERE enabled = 1 ORDER BY id").all();
|
|
1450
|
+
let queued = 0;
|
|
1451
|
+
for (const row of channels) {
|
|
1452
|
+
const channel = parseJson(row.config_json);
|
|
1453
|
+
if (!channelMatchesEvent(channel, event))
|
|
1454
|
+
continue;
|
|
1455
|
+
const channelEvent = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
1456
|
+
const timestamp = this.now().toISOString();
|
|
1457
|
+
const inserted = this.db.query(`
|
|
1458
|
+
INSERT OR IGNORE INTO outbox (
|
|
1459
|
+
id, event_id, channel_id, event_json, channel_json, status,
|
|
1460
|
+
attempt_count, available_at, attempts_json, created_at, updated_at
|
|
1461
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, '[]', ?, ?)
|
|
1462
|
+
`).run(randomUUID3(), event.id, channel.id, JSON.stringify(channelEvent), JSON.stringify(channel), this.now().getTime(), timestamp, timestamp);
|
|
1463
|
+
queued += Number(inserted.changes);
|
|
1464
|
+
}
|
|
1465
|
+
return queued;
|
|
1466
|
+
}
|
|
1467
|
+
count(sql) {
|
|
1468
|
+
const row = this.db.query(sql).get();
|
|
1469
|
+
return Number(row?.count ?? 0);
|
|
1470
|
+
}
|
|
1471
|
+
immediate(operation) {
|
|
1472
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
1473
|
+
try {
|
|
1474
|
+
const result = operation();
|
|
1475
|
+
this.db.exec("COMMIT;");
|
|
1476
|
+
return result;
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
this.db.exec("ROLLBACK;");
|
|
1479
|
+
throw error;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
ensureSchema() {
|
|
1483
|
+
const version = this.readSchemaVersion();
|
|
1484
|
+
if (!Number.isInteger(version) || version < 0) {
|
|
1485
|
+
throw new Error("Durable SQLite schema version is invalid");
|
|
1486
|
+
}
|
|
1487
|
+
if (version > DURABLE_SCHEMA_VERSION) {
|
|
1488
|
+
throw new Error(`Durable SQLite schema version ${version} is newer than supported version ${DURABLE_SCHEMA_VERSION}`);
|
|
1489
|
+
}
|
|
1490
|
+
if (version === 0) {
|
|
1491
|
+
this.immediate(() => {
|
|
1492
|
+
if (this.readSchemaVersion() !== 0) {
|
|
1493
|
+
throw new Error("Durable SQLite schema version changed during initialization");
|
|
1494
|
+
}
|
|
1495
|
+
this.assertEmptyApplicationSchema();
|
|
1496
|
+
this.createSchemaV1();
|
|
1497
|
+
this.assertSchemaV1();
|
|
1498
|
+
this.db.exec(`PRAGMA user_version = ${DURABLE_SCHEMA_VERSION};`);
|
|
1499
|
+
if (this.readSchemaVersion() !== DURABLE_SCHEMA_VERSION) {
|
|
1500
|
+
throw new Error("Durable SQLite schema version could not be recorded");
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
this.assertSchemaV1();
|
|
1506
|
+
}
|
|
1507
|
+
createSchemaV1() {
|
|
1508
|
+
for (const sql of Object.values(SCHEMA_V1_TABLE_SQL))
|
|
1509
|
+
this.db.exec(`${sql};`);
|
|
1510
|
+
for (const sql of Object.values(SCHEMA_V1_INDEX_SQL))
|
|
1511
|
+
this.db.exec(`${sql};`);
|
|
1512
|
+
}
|
|
1513
|
+
assertSchemaV1() {
|
|
1514
|
+
const objects = this.applicationSchemaObjects();
|
|
1515
|
+
const expectedObjects = [
|
|
1516
|
+
...Object.entries(SCHEMA_V1_TABLE_SQL).map(([name, sql]) => ({ type: "table", name, table: name, sql })),
|
|
1517
|
+
...Object.entries(SCHEMA_V1_INDEX_SQL).map(([name, sql]) => ({
|
|
1518
|
+
type: "index",
|
|
1519
|
+
name,
|
|
1520
|
+
table: schemaIndexTable(name),
|
|
1521
|
+
sql
|
|
1522
|
+
}))
|
|
1523
|
+
].sort(compareSchemaObjects);
|
|
1524
|
+
assertSchemaShape("application objects", objects.map(({ type, name, table }) => ({ type, name, table })), expectedObjects.map(({ type, name, table }) => ({ type, name, table })));
|
|
1525
|
+
for (const table of Object.keys(SCHEMA_V1_TABLE_SQL)) {
|
|
1526
|
+
const columns = this.db.query(`PRAGMA table_info(${schemaIdentifier(table)})`).all().map((column) => ({
|
|
1527
|
+
name: column.name,
|
|
1528
|
+
type: column.type,
|
|
1529
|
+
notnull: Number(column.notnull),
|
|
1530
|
+
defaultValue: column.dflt_value,
|
|
1531
|
+
pk: Number(column.pk)
|
|
1532
|
+
}));
|
|
1533
|
+
assertSchemaShape(`${table} columns`, columns, SCHEMA_V1_COLUMNS[table]);
|
|
1534
|
+
const foreignKeys = this.db.query(`PRAGMA foreign_key_list(${schemaIdentifier(table)})`).all().map((foreignKey) => ({
|
|
1535
|
+
table: foreignKey.table,
|
|
1536
|
+
from: foreignKey.from,
|
|
1537
|
+
to: foreignKey.to,
|
|
1538
|
+
onUpdate: foreignKey.on_update,
|
|
1539
|
+
onDelete: foreignKey.on_delete,
|
|
1540
|
+
match: foreignKey.match
|
|
1541
|
+
})).sort((left, right) => `${left.from}:${left.table}`.localeCompare(`${right.from}:${right.table}`));
|
|
1542
|
+
assertSchemaShape(`${table} foreign keys`, foreignKeys, SCHEMA_V1_FOREIGN_KEYS[table]);
|
|
1543
|
+
const indexes = this.db.query(`PRAGMA index_list(${schemaIdentifier(table)})`).all().map((index) => ({
|
|
1544
|
+
name: index.name,
|
|
1545
|
+
unique: Number(index.unique),
|
|
1546
|
+
origin: index.origin,
|
|
1547
|
+
partial: Number(index.partial),
|
|
1548
|
+
columns: this.db.query(`PRAGMA index_info(${schemaIdentifier(index.name)})`).all().sort((left, right) => Number(left.seqno) - Number(right.seqno)).map((column) => column.name)
|
|
1549
|
+
})).sort((left, right) => left.name.localeCompare(right.name));
|
|
1550
|
+
const expectedIndexes = [...SCHEMA_V1_INDEXES[table]].sort((left, right) => left.name.localeCompare(right.name));
|
|
1551
|
+
assertSchemaShape(`${table} indexes`, indexes, expectedIndexes);
|
|
1552
|
+
}
|
|
1553
|
+
for (const expected of expectedObjects) {
|
|
1554
|
+
const actual = objects.find((object) => object.type === expected.type && object.name === expected.name);
|
|
1555
|
+
if (!actual?.sql || normalizeSchemaSql(actual.sql) !== normalizeSchemaSql(expected.sql)) {
|
|
1556
|
+
throw incompatibleSchema(`${expected.type} ${expected.name} SQL`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
readSchemaVersion() {
|
|
1561
|
+
const row = this.db.query("PRAGMA user_version").get();
|
|
1562
|
+
return Number(row?.user_version);
|
|
1563
|
+
}
|
|
1564
|
+
applicationSchemaObjects() {
|
|
1565
|
+
return this.db.query(`
|
|
1566
|
+
SELECT type, name, tbl_name, sql
|
|
1567
|
+
FROM sqlite_master
|
|
1568
|
+
WHERE substr(name, 1, 7) <> 'sqlite_'
|
|
1569
|
+
ORDER BY type, name
|
|
1570
|
+
`).all().map((row) => ({
|
|
1571
|
+
type: row.type,
|
|
1572
|
+
name: row.name,
|
|
1573
|
+
table: row.tbl_name,
|
|
1574
|
+
sql: row.sql
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
assertEmptyApplicationSchema() {
|
|
1578
|
+
if (this.applicationSchemaObjects().length !== 0) {
|
|
1579
|
+
throw new Error("Durable SQLite schema version 0 requires an empty application schema");
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
secureDatabaseFiles() {
|
|
1583
|
+
for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
|
|
1584
|
+
if (!existsSync2(path))
|
|
1585
|
+
continue;
|
|
1586
|
+
chmodSync(path, 384);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function schemaIndexTable(name) {
|
|
1591
|
+
if (name === "events_dedupe_key_unique" || name === "events_source_type_idx")
|
|
1592
|
+
return "events";
|
|
1593
|
+
if (name === "outbox_due_idx")
|
|
1594
|
+
return "outbox";
|
|
1595
|
+
throw new Error(`Unknown durable schema index: ${name}`);
|
|
1596
|
+
}
|
|
1597
|
+
function schemaIdentifier(value) {
|
|
1598
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
1599
|
+
throw new Error("Invalid durable schema identifier");
|
|
1600
|
+
return value;
|
|
1601
|
+
}
|
|
1602
|
+
function compareSchemaObjects(left, right) {
|
|
1603
|
+
return `${left.type}:${left.name}`.localeCompare(`${right.type}:${right.name}`);
|
|
1604
|
+
}
|
|
1605
|
+
function normalizeSchemaSql(sql) {
|
|
1606
|
+
return sql.trim().replace(/;$/, "").replace(/\s+/g, " ").replace(/\s*([(),])\s*/g, "$1").toLowerCase();
|
|
1607
|
+
}
|
|
1608
|
+
function assertSchemaShape(label, actual, expected) {
|
|
1609
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
1610
|
+
throw incompatibleSchema(label);
|
|
1611
|
+
}
|
|
1612
|
+
function incompatibleSchema(detail) {
|
|
1613
|
+
return new Error(`Durable SQLite schema version 1 is incompatible: ${detail}`);
|
|
1614
|
+
}
|
|
1615
|
+
function normalizePositiveInteger(value, fallback, name) {
|
|
1616
|
+
const resolved = value ?? fallback;
|
|
1617
|
+
if (!Number.isInteger(resolved) || resolved < 1)
|
|
1618
|
+
throw new Error(`${name} must be a positive integer`);
|
|
1619
|
+
return resolved;
|
|
1620
|
+
}
|
|
1621
|
+
function normalizeRetryPolicy2(policy) {
|
|
1622
|
+
const normalized = {
|
|
1623
|
+
maxAttempts: policy?.maxAttempts ?? 1,
|
|
1624
|
+
backoffMs: policy?.backoffMs ?? 250,
|
|
1625
|
+
multiplier: policy?.multiplier ?? 2
|
|
1626
|
+
};
|
|
1627
|
+
validateRetryPolicy(normalized);
|
|
1628
|
+
return normalized;
|
|
1629
|
+
}
|
|
1630
|
+
function validateRetryPolicy(policy) {
|
|
1631
|
+
const maxAttempts = policy.maxAttempts ?? 1;
|
|
1632
|
+
const backoffMs = policy.backoffMs ?? 250;
|
|
1633
|
+
const multiplier = policy.multiplier ?? 2;
|
|
1634
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
1635
|
+
throw new Error(`retry.maxAttempts must be an integer from 1 to ${MAX_RETRY_ATTEMPTS}`);
|
|
1636
|
+
}
|
|
1637
|
+
if (!Number.isInteger(backoffMs) || backoffMs < 0 || backoffMs > MAX_RETRY_DELAY_MS) {
|
|
1638
|
+
throw new Error(`retry.backoffMs must be an integer from 0 to ${MAX_RETRY_DELAY_MS}`);
|
|
1639
|
+
}
|
|
1640
|
+
if (!Number.isFinite(multiplier) || multiplier < 1 || multiplier > MAX_RETRY_MULTIPLIER) {
|
|
1641
|
+
throw new Error(`retry.multiplier must be finite and from 1 to ${MAX_RETRY_MULTIPLIER}`);
|
|
1642
|
+
}
|
|
1643
|
+
if (maxAttempts > 1)
|
|
1644
|
+
retryBackoffMs({ maxAttempts, backoffMs, multiplier }, maxAttempts - 1);
|
|
1645
|
+
}
|
|
1646
|
+
function retryBackoffMs(policy, attempt) {
|
|
1647
|
+
const delay = Math.round(policy.backoffMs * policy.multiplier ** (attempt - 1));
|
|
1648
|
+
if (!Number.isSafeInteger(delay) || delay < 0 || delay > MAX_RETRY_DELAY_MS) {
|
|
1649
|
+
throw new Error(`retry policy must not produce a delay above ${MAX_RETRY_DELAY_MS}ms`);
|
|
1650
|
+
}
|
|
1651
|
+
return delay;
|
|
1652
|
+
}
|
|
1653
|
+
function parseJson(value) {
|
|
1654
|
+
return JSON.parse(value);
|
|
1655
|
+
}
|
|
1656
|
+
function validateDurableWebhookConfig(webhook) {
|
|
1657
|
+
let url;
|
|
1658
|
+
try {
|
|
1659
|
+
url = new URL(webhook.url);
|
|
1660
|
+
} catch {
|
|
1661
|
+
throw new Error("Durable webhook URL must be a valid URL");
|
|
1662
|
+
}
|
|
1663
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1664
|
+
throw new Error("Durable webhook URL must use http or https");
|
|
1665
|
+
}
|
|
1666
|
+
if (url.username || url.password) {
|
|
1667
|
+
throw new Error("Durable webhook URL must not contain credentials");
|
|
1668
|
+
}
|
|
1669
|
+
for (const name of url.searchParams.keys()) {
|
|
1670
|
+
if (/authorization|cookie|api[-_]?key|token|secret|credential|signature/i.test(name)) {
|
|
1671
|
+
throw new Error("Durable webhook URL must not contain credential query parameters");
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
for (const name of Object.keys(webhook.headers ?? {})) {
|
|
1675
|
+
if (/^x-hasna-/i.test(name)) {
|
|
1676
|
+
throw new Error("Durable webhook X-Hasna headers are reserved for signed delivery metadata");
|
|
1677
|
+
}
|
|
1678
|
+
if (/authorization|cookie|api[-_]?key|token|secret|credential/i.test(name)) {
|
|
1679
|
+
throw new Error("Durable webhook credential headers are not persisted; use webhook.secretRef");
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
function sanitizeDurableAttempt(attempt) {
|
|
1684
|
+
const { responseBody: _responseBody, stdout: _stdout, stderr: _stderr, ...metadata } = attempt;
|
|
1685
|
+
if (metadata.status === "failed") {
|
|
1686
|
+
metadata.error = metadata.responseStatus === undefined ? "Webhook delivery failed" : `Webhook returned HTTP ${metadata.responseStatus}`;
|
|
1687
|
+
}
|
|
1688
|
+
return metadata;
|
|
1689
|
+
}
|
|
1690
|
+
function parseSpoolEnvelope(raw) {
|
|
1691
|
+
const value = parseJson(raw);
|
|
1692
|
+
if (!value || typeof value !== "object")
|
|
1693
|
+
throw new Error("Invalid durable event spool record");
|
|
1694
|
+
for (const field of ["id", "source", "type", "time", "schemaVersion"]) {
|
|
1695
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
1696
|
+
throw new Error("Invalid durable event spool record");
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
1700
|
+
throw new Error("Invalid durable event spool record");
|
|
1701
|
+
}
|
|
1702
|
+
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) {
|
|
1703
|
+
throw new Error("Invalid durable event spool record");
|
|
1704
|
+
}
|
|
1705
|
+
return value;
|
|
1706
|
+
}
|
|
1707
|
+
function syncDirectory(path) {
|
|
1708
|
+
const descriptor = openSync(path, "r");
|
|
1709
|
+
try {
|
|
1710
|
+
fsyncSync(descriptor);
|
|
1711
|
+
} finally {
|
|
1712
|
+
closeSync(descriptor);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
function isNodeError(error, code) {
|
|
1716
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
1717
|
+
}
|
|
1718
|
+
function spoolFileName(event) {
|
|
1719
|
+
const identity = event.dedupeKey ?? event.id;
|
|
1720
|
+
return `${createHash("sha256").update(identity, "utf8").digest("hex")}.json`;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// src/durable-worker.ts
|
|
1724
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, watch } from "fs";
|
|
1725
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1726
|
+
import { join as join4 } from "path";
|
|
1727
|
+
|
|
1728
|
+
// src/durable-spool.ts
|
|
1729
|
+
import { createHash as createHash2, randomUUID as randomUUID4 } from "crypto";
|
|
1730
|
+
import {
|
|
1731
|
+
chmod as chmod2,
|
|
1732
|
+
link,
|
|
1733
|
+
mkdir as mkdir2,
|
|
1734
|
+
open,
|
|
1735
|
+
readdir,
|
|
1736
|
+
readFile as readFile2,
|
|
1737
|
+
stat,
|
|
1738
|
+
unlink
|
|
1739
|
+
} from "fs/promises";
|
|
1740
|
+
import { join as join3 } from "path";
|
|
1741
|
+
class DurableEventSpool {
|
|
1742
|
+
dataDir;
|
|
1743
|
+
inboxDir;
|
|
1744
|
+
constructor(options) {
|
|
1745
|
+
if (!options.dataDir)
|
|
1746
|
+
throw new Error("DurableEventSpool requires dataDir");
|
|
1747
|
+
this.dataDir = options.dataDir;
|
|
1748
|
+
this.inboxDir = join3(options.dataDir, "spool", "inbox");
|
|
1749
|
+
}
|
|
1750
|
+
async enqueue(input) {
|
|
1751
|
+
const event = redactSensitiveKeys(createSpoolEvent(input));
|
|
1752
|
+
await this.ensureInbox();
|
|
1753
|
+
const finalPath = this.pathFor(event);
|
|
1754
|
+
const tempPath = join3(this.inboxDir, `.tmp-${process.pid}-${randomUUID4()}`);
|
|
1755
|
+
const handle = await open(tempPath, "wx", 384);
|
|
1756
|
+
try {
|
|
1757
|
+
await handle.writeFile(`${JSON.stringify(event)}
|
|
1758
|
+
`, "utf8");
|
|
1759
|
+
await handle.sync();
|
|
1760
|
+
} finally {
|
|
1761
|
+
await handle.close();
|
|
1762
|
+
}
|
|
1763
|
+
let stored = false;
|
|
1764
|
+
try {
|
|
1765
|
+
await link(tempPath, finalPath);
|
|
1766
|
+
stored = true;
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
if (!isNodeError2(error, "EEXIST")) {
|
|
1769
|
+
await unlink(tempPath).catch(() => {
|
|
1770
|
+
return;
|
|
1771
|
+
});
|
|
1772
|
+
throw error;
|
|
1773
|
+
}
|
|
1774
|
+
await this.assertSameIdentity(finalPath, event);
|
|
1775
|
+
}
|
|
1776
|
+
await unlink(tempPath);
|
|
1777
|
+
await this.syncInbox();
|
|
1778
|
+
return { event, stored, deduped: !stored };
|
|
1779
|
+
}
|
|
1780
|
+
async recover(options = {}) {
|
|
1781
|
+
await this.ensureInbox();
|
|
1782
|
+
const olderThanMs = Math.max(0, options.olderThanMs ?? 60000);
|
|
1783
|
+
const threshold = Date.now() - olderThanMs;
|
|
1784
|
+
const result = { recovered: 0, deduped: 0, cleaned: 0 };
|
|
1785
|
+
const names = (await readdir(this.inboxDir)).filter((name) => name.startsWith(".tmp-")).sort();
|
|
1786
|
+
for (const name of names) {
|
|
1787
|
+
const tempPath = join3(this.inboxDir, name);
|
|
1788
|
+
const details = await stat(tempPath).catch(() => {
|
|
1789
|
+
return;
|
|
1790
|
+
});
|
|
1791
|
+
if (!details || details.mtimeMs > threshold)
|
|
1792
|
+
continue;
|
|
1793
|
+
let event;
|
|
1794
|
+
try {
|
|
1795
|
+
event = parseEnvelope(await readFile2(tempPath, "utf8"));
|
|
1796
|
+
} catch {
|
|
1797
|
+
await unlink(tempPath).catch(() => {
|
|
1798
|
+
return;
|
|
1799
|
+
});
|
|
1800
|
+
result.cleaned += 1;
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
const finalPath = this.pathFor(event);
|
|
1804
|
+
try {
|
|
1805
|
+
await link(tempPath, finalPath);
|
|
1806
|
+
result.recovered += 1;
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
if (!isNodeError2(error, "EEXIST"))
|
|
1809
|
+
throw error;
|
|
1810
|
+
await this.assertSameIdentity(finalPath, event);
|
|
1811
|
+
result.deduped += 1;
|
|
1812
|
+
}
|
|
1813
|
+
await unlink(tempPath).catch(() => {
|
|
1814
|
+
return;
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
if (result.recovered || result.deduped || result.cleaned)
|
|
1818
|
+
await this.syncInbox();
|
|
1819
|
+
return result;
|
|
1820
|
+
}
|
|
1821
|
+
async close() {}
|
|
1822
|
+
pathFor(event) {
|
|
1823
|
+
const identity = event.dedupeKey ?? event.id;
|
|
1824
|
+
const digest = createHash2("sha256").update(identity, "utf8").digest("hex");
|
|
1825
|
+
return join3(this.inboxDir, `${digest}.json`);
|
|
1826
|
+
}
|
|
1827
|
+
async assertSameIdentity(path, event) {
|
|
1828
|
+
const existing = parseEnvelope(await readFile2(path, "utf8"));
|
|
1829
|
+
const matches = existing.id === event.id || event.dedupeKey !== undefined && existing.dedupeKey === event.dedupeKey;
|
|
1830
|
+
if (!matches)
|
|
1831
|
+
throw new Error("Durable spool identity collision");
|
|
1832
|
+
}
|
|
1833
|
+
async ensureInbox() {
|
|
1834
|
+
const spoolDir = join3(this.dataDir, "spool");
|
|
1835
|
+
await mkdir2(this.inboxDir, { recursive: true, mode: 448 });
|
|
1836
|
+
await chmod2(this.dataDir, 448);
|
|
1837
|
+
await chmod2(spoolDir, 448);
|
|
1838
|
+
await chmod2(this.inboxDir, 448);
|
|
1839
|
+
await this.syncDirectory(this.dataDir);
|
|
1840
|
+
await this.syncDirectory(spoolDir);
|
|
1841
|
+
}
|
|
1842
|
+
async syncInbox() {
|
|
1843
|
+
await this.syncDirectory(this.inboxDir);
|
|
1844
|
+
}
|
|
1845
|
+
async syncDirectory(path) {
|
|
1846
|
+
const directory = await open(path, "r");
|
|
1847
|
+
try {
|
|
1848
|
+
await directory.sync();
|
|
1849
|
+
} finally {
|
|
1850
|
+
await directory.close();
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
function createSpoolEvent(input) {
|
|
1855
|
+
return {
|
|
1856
|
+
id: input.id ?? randomUUID4(),
|
|
1857
|
+
source: input.source,
|
|
1858
|
+
type: input.type,
|
|
1859
|
+
time: input.time instanceof Date ? input.time.toISOString() : input.time ?? new Date().toISOString(),
|
|
1860
|
+
subject: input.subject,
|
|
1861
|
+
severity: input.severity ?? "info",
|
|
1862
|
+
data: input.data ?? {},
|
|
1863
|
+
message: input.message,
|
|
1864
|
+
dedupeKey: input.dedupeKey,
|
|
1865
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
1866
|
+
metadata: input.metadata ?? {}
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
function parseEnvelope(raw) {
|
|
1870
|
+
const value = JSON.parse(raw);
|
|
1871
|
+
if (!value || typeof value !== "object")
|
|
1872
|
+
throw new Error("Invalid durable event spool record");
|
|
1873
|
+
for (const field of ["id", "source", "type", "time", "schemaVersion"]) {
|
|
1874
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
1875
|
+
throw new Error("Invalid durable event spool record");
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
1879
|
+
throw new Error("Invalid durable event spool record");
|
|
1880
|
+
}
|
|
1881
|
+
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) {
|
|
1882
|
+
throw new Error("Invalid durable event spool record");
|
|
1883
|
+
}
|
|
1884
|
+
return value;
|
|
1885
|
+
}
|
|
1886
|
+
function isNodeError2(error, code) {
|
|
1887
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// src/durable-worker.ts
|
|
1891
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
1892
|
+
async function runDurableWorker(options) {
|
|
1893
|
+
const workerId = options.workerId ?? randomUUID5();
|
|
1894
|
+
const limit = positiveInteger(options.limit, 100, "limit");
|
|
1895
|
+
const leaseMs = positiveInteger(options.leaseMs, 60000, "leaseMs");
|
|
1896
|
+
const debounceMs = nonNegativeInteger(options.debounceMs, 50, "debounceMs");
|
|
1897
|
+
const reconcileMs = positiveInteger(options.reconcileMs, 30000, "reconcileMs");
|
|
1898
|
+
const watchRestartMs = positiveInteger(options.watchRestartMs, 1000, "watchRestartMs");
|
|
1899
|
+
const spool = new DurableEventSpool({ dataDir: options.broker.dataDir });
|
|
1900
|
+
const inboxDir = spool.inboxDir;
|
|
1901
|
+
mkdirSync2(inboxDir, { recursive: true, mode: 448 });
|
|
1902
|
+
chmodSync2(join4(options.broker.dataDir, "spool"), 448);
|
|
1903
|
+
chmodSync2(inboxDir, 448);
|
|
1904
|
+
const totals = {
|
|
1905
|
+
workerId,
|
|
1906
|
+
cycles: 0,
|
|
1907
|
+
imported: 0,
|
|
1908
|
+
deduped: 0,
|
|
1909
|
+
delivered: 0,
|
|
1910
|
+
retried: 0,
|
|
1911
|
+
dead: 0,
|
|
1912
|
+
lost: 0
|
|
1913
|
+
};
|
|
1914
|
+
return new Promise((resolve, reject) => {
|
|
1915
|
+
let watcher;
|
|
1916
|
+
let debounceTimer;
|
|
1917
|
+
let retryTimer;
|
|
1918
|
+
let reconcileTimer;
|
|
1919
|
+
let restartTimer;
|
|
1920
|
+
let running = false;
|
|
1921
|
+
let rerun = false;
|
|
1922
|
+
let stopped = false;
|
|
1923
|
+
const clearRetryTimer = () => {
|
|
1924
|
+
if (retryTimer)
|
|
1925
|
+
clearTimeout(retryTimer);
|
|
1926
|
+
retryTimer = undefined;
|
|
1927
|
+
};
|
|
1928
|
+
const stop = () => {
|
|
1929
|
+
if (stopped)
|
|
1930
|
+
return;
|
|
1931
|
+
stopped = true;
|
|
1932
|
+
watcher?.close();
|
|
1933
|
+
if (debounceTimer)
|
|
1934
|
+
clearTimeout(debounceTimer);
|
|
1935
|
+
clearRetryTimer();
|
|
1936
|
+
if (reconcileTimer)
|
|
1937
|
+
clearInterval(reconcileTimer);
|
|
1938
|
+
if (restartTimer)
|
|
1939
|
+
clearTimeout(restartTimer);
|
|
1940
|
+
options.signal.removeEventListener("abort", stop);
|
|
1941
|
+
if (!running)
|
|
1942
|
+
resolve(totals);
|
|
1943
|
+
};
|
|
1944
|
+
const scheduleRetryWake = () => {
|
|
1945
|
+
clearRetryTimer();
|
|
1946
|
+
if (stopped)
|
|
1947
|
+
return;
|
|
1948
|
+
const nextWakeAt = options.broker.nextWakeAt();
|
|
1949
|
+
if (nextWakeAt === undefined)
|
|
1950
|
+
return;
|
|
1951
|
+
const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextWakeAt - Date.now()));
|
|
1952
|
+
retryTimer = setTimeout(() => {
|
|
1953
|
+
retryTimer = undefined;
|
|
1954
|
+
runCycle();
|
|
1955
|
+
}, delay);
|
|
1956
|
+
};
|
|
1957
|
+
const runCycle = async () => {
|
|
1958
|
+
if (stopped)
|
|
1959
|
+
return;
|
|
1960
|
+
if (running) {
|
|
1961
|
+
rerun = true;
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
running = true;
|
|
1965
|
+
clearRetryTimer();
|
|
1966
|
+
try {
|
|
1967
|
+
await spool.recover();
|
|
1968
|
+
const imported = options.broker.importSpool({ limit });
|
|
1969
|
+
const drained = await options.broker.drain({ workerId, limit, leaseMs });
|
|
1970
|
+
const cycle = { imported, drained };
|
|
1971
|
+
totals.cycles += 1;
|
|
1972
|
+
totals.imported += imported.imported;
|
|
1973
|
+
totals.deduped += imported.deduped;
|
|
1974
|
+
totals.delivered += drained.delivered;
|
|
1975
|
+
totals.retried += drained.retried;
|
|
1976
|
+
totals.dead += drained.dead;
|
|
1977
|
+
totals.lost += drained.lost;
|
|
1978
|
+
await options.onCycle?.(cycle);
|
|
1979
|
+
if (imported.scanned >= limit || drained.claimed >= limit)
|
|
1980
|
+
rerun = true;
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
reject(error);
|
|
1983
|
+
stop();
|
|
1984
|
+
return;
|
|
1985
|
+
} finally {
|
|
1986
|
+
running = false;
|
|
1987
|
+
}
|
|
1988
|
+
if (stopped) {
|
|
1989
|
+
resolve(totals);
|
|
1990
|
+
} else if (rerun) {
|
|
1991
|
+
rerun = false;
|
|
1992
|
+
queueMicrotask(() => {
|
|
1993
|
+
runCycle();
|
|
1994
|
+
});
|
|
1995
|
+
} else {
|
|
1996
|
+
scheduleRetryWake();
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
const scheduleDebouncedCycle = () => {
|
|
2000
|
+
if (stopped)
|
|
2001
|
+
return;
|
|
2002
|
+
if (debounceTimer)
|
|
2003
|
+
clearTimeout(debounceTimer);
|
|
2004
|
+
debounceTimer = setTimeout(() => {
|
|
2005
|
+
debounceTimer = undefined;
|
|
2006
|
+
runCycle();
|
|
2007
|
+
}, debounceMs);
|
|
2008
|
+
};
|
|
2009
|
+
const startWatcher = () => {
|
|
2010
|
+
if (stopped)
|
|
2011
|
+
return;
|
|
2012
|
+
watcher?.close();
|
|
2013
|
+
try {
|
|
2014
|
+
watcher = watch(inboxDir, (_eventType, filename) => {
|
|
2015
|
+
if (!filename || filename.toString().endsWith(".json"))
|
|
2016
|
+
scheduleDebouncedCycle();
|
|
2017
|
+
});
|
|
2018
|
+
watcher.on("error", () => {
|
|
2019
|
+
watcher?.close();
|
|
2020
|
+
watcher = undefined;
|
|
2021
|
+
scheduleDebouncedCycle();
|
|
2022
|
+
if (!stopped)
|
|
2023
|
+
restartTimer = setTimeout(startWatcher, watchRestartMs);
|
|
2024
|
+
});
|
|
2025
|
+
} catch {
|
|
2026
|
+
scheduleDebouncedCycle();
|
|
2027
|
+
if (!stopped)
|
|
2028
|
+
restartTimer = setTimeout(startWatcher, watchRestartMs);
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
2031
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
2032
|
+
if (options.signal.aborted) {
|
|
2033
|
+
stop();
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
startWatcher();
|
|
2037
|
+
reconcileTimer = setInterval(() => {
|
|
2038
|
+
runCycle();
|
|
2039
|
+
}, reconcileMs);
|
|
2040
|
+
runCycle();
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
function positiveInteger(value, fallback, name) {
|
|
2044
|
+
const resolved = value ?? fallback;
|
|
2045
|
+
if (!Number.isInteger(resolved) || resolved < 1)
|
|
2046
|
+
throw new Error(`${name} must be a positive integer`);
|
|
2047
|
+
return resolved;
|
|
2048
|
+
}
|
|
2049
|
+
function nonNegativeInteger(value, fallback, name) {
|
|
2050
|
+
const resolved = value ?? fallback;
|
|
2051
|
+
if (!Number.isInteger(resolved) || resolved < 0)
|
|
2052
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
2053
|
+
return resolved;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
676
2056
|
// src/filter-options.ts
|
|
677
2057
|
function parseFieldMatchers(values, label, typed = false) {
|
|
678
2058
|
if (!values?.length)
|
|
@@ -748,8 +2128,8 @@ function parseMatcherExpression(value, label) {
|
|
|
748
2128
|
// src/cli/index.ts
|
|
749
2129
|
function version() {
|
|
750
2130
|
try {
|
|
751
|
-
const packagePath =
|
|
752
|
-
return JSON.parse(
|
|
2131
|
+
const packagePath = join5(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
2132
|
+
return JSON.parse(readFileSync2(packagePath, "utf-8")).version ?? "0.0.0";
|
|
753
2133
|
} catch {
|
|
754
2134
|
return "0.0.0";
|
|
755
2135
|
}
|
|
@@ -871,10 +2251,25 @@ Usage:
|
|
|
871
2251
|
${name} [--dir <path>] [--json] status
|
|
872
2252
|
${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
|
|
873
2253
|
${name} [--dir <path>] [--json] events list [--limit <n>]
|
|
874
|
-
${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
|
|
2254
|
+
${name} [--dir <path>] [--json] events replay [--id <event-id>] [--cursor <cursor>] [--limit <n>] [--dry-run]
|
|
2255
|
+
${name} [--dir <path>] [--json] durable channel <url> [options]
|
|
2256
|
+
${name} [--dir <path>] [--json] durable enqueue <type> --source <source> [options]
|
|
2257
|
+
${name} [--dir <path>] [--json] durable import [--limit <n>]
|
|
2258
|
+
${name} [--dir <path>] [--json] durable drain [--limit <n>] [--lease-ms <ms>]
|
|
2259
|
+
${name} [--dir <path>] [--json] durable work [--limit <n>] [--lease-ms <ms>] [--reconcile-ms <ms>]
|
|
2260
|
+
${name} [--dir <path>] [--json] durable retry-dead [--event-id <id>] [--channel-id <id>] [--limit <n>]
|
|
2261
|
+
${name} [--dir <path>] [--json] durable status
|
|
2262
|
+
|
|
2263
|
+
Global options (must precede the command group):
|
|
2264
|
+
--dir <path> Data directory
|
|
2265
|
+
-j, --json Print JSON output
|
|
2266
|
+
-h, --help Show help
|
|
2267
|
+
-v, --version Show version
|
|
875
2268
|
|
|
876
2269
|
Environment:
|
|
877
|
-
HASNA_EVENTS_DIR
|
|
2270
|
+
HASNA_EVENTS_DIR Primary data-directory override
|
|
2271
|
+
HASNA_EVENTS_HOME Legacy data-directory fallback
|
|
2272
|
+
Default directory ${getEventsDataDir()}`);
|
|
878
2273
|
}
|
|
879
2274
|
function printChannelsHelp(options = {}) {
|
|
880
2275
|
const name = commandName(options);
|
|
@@ -888,22 +2283,24 @@ Usage:
|
|
|
888
2283
|
${name} [--dir <path>] [--json] channels match <id>
|
|
889
2284
|
${name} [--dir <path>] [--json] channels status
|
|
890
2285
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
--
|
|
903
|
-
--
|
|
904
|
-
--
|
|
905
|
-
--
|
|
906
|
-
--
|
|
2286
|
+
Commands:
|
|
2287
|
+
add Add or replace a channel
|
|
2288
|
+
list List configured channels
|
|
2289
|
+
remove Remove a channel
|
|
2290
|
+
test Send a sample event to one channel
|
|
2291
|
+
match Preview a sample event without delivery
|
|
2292
|
+
status Show channel storage status
|
|
2293
|
+
|
|
2294
|
+
Run '${name} channels add --help' for add options.
|
|
2295
|
+
|
|
2296
|
+
Test and match options:
|
|
2297
|
+
--source <source> Event source (default: ${options.source ?? "hasna.events"})
|
|
2298
|
+
--type <type> Event type (default: events.test)
|
|
2299
|
+
--subject <subject> Event subject (default: channel id)
|
|
2300
|
+
--message <message> Event message
|
|
2301
|
+
--data <json> Event data object
|
|
2302
|
+
--metadata <json> Event metadata object
|
|
2303
|
+
--honor-filters Test only: skip delivery on a filter mismatch`);
|
|
907
2304
|
}
|
|
908
2305
|
function printChannelAddHelp(options = {}) {
|
|
909
2306
|
const name = commandName(options);
|
|
@@ -914,23 +2311,24 @@ Usage:
|
|
|
914
2311
|
${name} [--dir <path>] [--json] channels add <command> --transport command [options] -- [command-args...]
|
|
915
2312
|
|
|
916
2313
|
Options:
|
|
917
|
-
--id <id> Channel id
|
|
2314
|
+
--id <id> Channel id (default: generated UUID)
|
|
918
2315
|
--name <name> Display name
|
|
919
|
-
--transport <kind> webhook or command
|
|
2316
|
+
--transport <kind> webhook or command (default: webhook)
|
|
920
2317
|
--type <pattern> Event type filter, supports wildcards
|
|
2318
|
+
--event-type <pattern> Alias for --type
|
|
921
2319
|
--source <source> Event source filter
|
|
922
2320
|
--subject <subject> Event subject filter
|
|
923
2321
|
--severity <severity> Event severity filter
|
|
924
|
-
--data <path=value
|
|
925
|
-
--metadata <path=value
|
|
926
|
-
--data-json <path=json
|
|
927
|
-
--metadata-json <path=json
|
|
2322
|
+
--data <path=value> String data filter; repeatable; != negates
|
|
2323
|
+
--metadata <path=value> String metadata filter; repeatable; != negates
|
|
2324
|
+
--data-json <path=json> Typed JSON data filter; repeatable; != negates
|
|
2325
|
+
--metadata-json <path=json> Typed JSON metadata filter; repeatable; != negates
|
|
928
2326
|
--secret <secret> Webhook signing secret
|
|
929
2327
|
--header <name=value> Webhook header, repeatable
|
|
930
2328
|
--arg <arg> Command argument, repeatable; values may begin with dashes
|
|
931
|
-
--timeout-ms <ms> Transport timeout
|
|
932
|
-
--retry-attempts <n> Maximum delivery attempts
|
|
933
|
-
--retry-backoff-ms <ms> Initial retry backoff
|
|
2329
|
+
--timeout-ms <ms> Transport timeout (default: 15000)
|
|
2330
|
+
--retry-attempts <n> Maximum delivery attempts (default: 1)
|
|
2331
|
+
--retry-backoff-ms <ms> Initial retry backoff (default: 250)
|
|
934
2332
|
--redact <path> Redaction path, repeatable
|
|
935
2333
|
--disabled Create channel disabled
|
|
936
2334
|
|
|
@@ -947,17 +2345,29 @@ function printEventsHelp(options = {}) {
|
|
|
947
2345
|
Usage:
|
|
948
2346
|
${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
|
|
949
2347
|
${name} [--dir <path>] [--json] events list [--limit <n>]
|
|
950
|
-
${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
|
|
2348
|
+
${name} [--dir <path>] [--json] events replay [--id <event-id>] [--cursor <cursor>] [--limit <n>] [--dry-run]
|
|
951
2349
|
|
|
952
|
-
|
|
2350
|
+
Emit options:
|
|
953
2351
|
--source <source> Event source${options.source ? ` (default: ${options.source})` : ""}
|
|
954
2352
|
--subject <subject> Event subject
|
|
955
|
-
--severity <severity>
|
|
2353
|
+
--severity <severity> debug|info|notice|warning|error|critical (default: info)
|
|
956
2354
|
--message <message> Human-readable event message
|
|
957
2355
|
--dedupe-key <key> Deduplicate repeated events
|
|
958
2356
|
--data <json> JSON object payload
|
|
959
2357
|
--metadata <json> JSON object metadata
|
|
960
2358
|
--no-deliver Record without delivering channels
|
|
2359
|
+
|
|
2360
|
+
List options:
|
|
2361
|
+
--source <source> Filter by exact source
|
|
2362
|
+
--type <type> Filter by exact type
|
|
2363
|
+
--limit <n> Most recent events; 0 or omitted lists all
|
|
2364
|
+
|
|
2365
|
+
Replay options:
|
|
2366
|
+
--id <event-id> Filter by exact event id
|
|
2367
|
+
--source <source> Filter by exact source
|
|
2368
|
+
--type <type> Filter by exact type
|
|
2369
|
+
--cursor <cursor> Opaque cursor returned by a previous replay page
|
|
2370
|
+
--limit <n> Maximum events to replay
|
|
961
2371
|
--dry-run Preview replay matches without delivery`);
|
|
962
2372
|
}
|
|
963
2373
|
async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
@@ -979,6 +2389,19 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
|
979
2389
|
});
|
|
980
2390
|
return;
|
|
981
2391
|
}
|
|
2392
|
+
if (group === "durable") {
|
|
2393
|
+
if (!command || command === "--help" || command === "-h" || tail.includes("--help") || tail.includes("-h")) {
|
|
2394
|
+
printDurableHelp(options);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
const broker = new DurableEventsBroker({ dataDir: parsed.dir ?? getEventsDataDir() });
|
|
2398
|
+
try {
|
|
2399
|
+
await handleDurable(broker, command, tail, parsed);
|
|
2400
|
+
} finally {
|
|
2401
|
+
broker.close();
|
|
2402
|
+
}
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
982
2405
|
const store = new JsonEventsStore(parsed.dir);
|
|
983
2406
|
const client = new EventsClient({ store });
|
|
984
2407
|
if (group === "channels") {
|
|
@@ -1011,6 +2434,150 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1011
2434
|
}
|
|
1012
2435
|
throw new Error(`Unknown command group: ${group}`);
|
|
1013
2436
|
}
|
|
2437
|
+
function printDurableHelp(options = {}) {
|
|
2438
|
+
const name = commandName(options);
|
|
2439
|
+
console.log(`${name} durable
|
|
2440
|
+
|
|
2441
|
+
Usage:
|
|
2442
|
+
${name} [--dir <path>] [--json] durable channel <url> --id <id> --source <source> --type <type> --secret-ref <ref> [options]
|
|
2443
|
+
${name} [--dir <path>] [--json] durable enqueue <type> --source <source> [options]
|
|
2444
|
+
${name} [--dir <path>] [--json] durable import [--limit <n>]
|
|
2445
|
+
${name} [--dir <path>] [--json] durable drain [--limit <n>] [--lease-ms <ms>]
|
|
2446
|
+
${name} [--dir <path>] [--json] durable work [--limit <n>] [--lease-ms <ms>] [--reconcile-ms <ms>]
|
|
2447
|
+
${name} [--dir <path>] [--json] durable retry-dead [--event-id <id>] [--channel-id <id>] [--limit <n>]
|
|
2448
|
+
${name} [--dir <path>] [--json] durable status
|
|
2449
|
+
|
|
2450
|
+
Channel options:
|
|
2451
|
+
--id <id> Required stable channel id
|
|
2452
|
+
--source <source> Required exact source filter
|
|
2453
|
+
--type <type> Required exact event type filter
|
|
2454
|
+
--secret-ref <ref> Runtime secret reference, e.g. env:HASNA_WEBHOOK_SECRET
|
|
2455
|
+
--timeout-ms <ms> Webhook timeout (default: 15000)
|
|
2456
|
+
--retry-attempts <n> Maximum durable attempts (default: 1)
|
|
2457
|
+
--retry-backoff-ms <ms> Initial persisted backoff (default: 250)
|
|
2458
|
+
--disabled Persist the route disabled
|
|
2459
|
+
|
|
2460
|
+
Enqueue options:
|
|
2461
|
+
--id <id> Stable event id
|
|
2462
|
+
--subject <subject> Stable event subject
|
|
2463
|
+
--time <iso-time> Event occurrence time
|
|
2464
|
+
--schema-version <value> Envelope schema version
|
|
2465
|
+
--dedupe-key <key> Stable business idempotency key
|
|
2466
|
+
--data <json> Event data object
|
|
2467
|
+
--metadata <json> Event metadata object`);
|
|
2468
|
+
}
|
|
2469
|
+
async function handleDurable(broker, command, tail, parsed) {
|
|
2470
|
+
if (command === "channel") {
|
|
2471
|
+
const args = [...tail];
|
|
2472
|
+
const target = args.shift();
|
|
2473
|
+
if (!target)
|
|
2474
|
+
throw new Error("durable channel requires a webhook URL");
|
|
2475
|
+
const id = takeOption(args, "--id");
|
|
2476
|
+
const source = takeOption(args, "--source");
|
|
2477
|
+
const type = takeOption(args, "--type");
|
|
2478
|
+
if (!id || !source || !type)
|
|
2479
|
+
throw new Error("durable channel requires --id, --source, and --type");
|
|
2480
|
+
if (source.includes("*") || type.includes("*"))
|
|
2481
|
+
throw new Error("durable channel source/type filters must be exact");
|
|
2482
|
+
const secretRef = takeOption(args, "--secret-ref");
|
|
2483
|
+
if (!secretRef)
|
|
2484
|
+
throw new Error("durable channel requires --secret-ref");
|
|
2485
|
+
const timeoutMs = numberOption(takeOption(args, "--timeout-ms"));
|
|
2486
|
+
const retryAttempts = numberOption(takeOption(args, "--retry-attempts"));
|
|
2487
|
+
const retryBackoffMs2 = numberOption(takeOption(args, "--retry-backoff-ms"));
|
|
2488
|
+
const channel = broker.addChannel({
|
|
2489
|
+
id,
|
|
2490
|
+
enabled: !takeFlag(args, "--disabled"),
|
|
2491
|
+
transport: "webhook",
|
|
2492
|
+
filters: [{ source, type }],
|
|
2493
|
+
webhook: { url: target, secretRef, timeoutMs },
|
|
2494
|
+
retry: retryAttempts || retryBackoffMs2 ? { maxAttempts: retryAttempts, backoffMs: retryBackoffMs2 } : undefined
|
|
2495
|
+
});
|
|
2496
|
+
output(parsed, sanitizeChannelForOutput(channel), () => console.log(`Added durable webhook channel ${channel.id}`));
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
if (command === "enqueue") {
|
|
2500
|
+
const args = [...tail];
|
|
2501
|
+
const type = args.shift();
|
|
2502
|
+
if (!type)
|
|
2503
|
+
throw new Error("durable enqueue requires an event type");
|
|
2504
|
+
const source = takeOption(args, "--source");
|
|
2505
|
+
if (!source)
|
|
2506
|
+
throw new Error("durable enqueue requires --source");
|
|
2507
|
+
const result = broker.enqueue({
|
|
2508
|
+
id: takeOption(args, "--id"),
|
|
2509
|
+
source,
|
|
2510
|
+
type,
|
|
2511
|
+
time: takeOption(args, "--time"),
|
|
2512
|
+
subject: takeOption(args, "--subject"),
|
|
2513
|
+
dedupeKey: takeOption(args, "--dedupe-key"),
|
|
2514
|
+
schemaVersion: takeOption(args, "--schema-version"),
|
|
2515
|
+
data: parseJsonOption(takeOption(args, "--data"), {}),
|
|
2516
|
+
metadata: parseJsonOption(takeOption(args, "--metadata"), {})
|
|
2517
|
+
});
|
|
2518
|
+
output(parsed, result, () => console.log(`${result.deduped ? "Deduped" : "Enqueued"} ${result.event.id} to ${result.queued} channel(s)`));
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
if (command === "import") {
|
|
2522
|
+
const args = [...tail];
|
|
2523
|
+
const result = broker.importSpool({ limit: numberOption(takeOption(args, "--limit")) });
|
|
2524
|
+
output(parsed, result, () => console.log(`Imported ${result.imported}, deduped ${result.deduped}, queued ${result.queued}`));
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
if (command === "drain") {
|
|
2528
|
+
const args = [...tail];
|
|
2529
|
+
const limit = numberOption(takeOption(args, "--limit"));
|
|
2530
|
+
const imported = broker.importSpool({ limit });
|
|
2531
|
+
const drained = await broker.drain({
|
|
2532
|
+
limit,
|
|
2533
|
+
leaseMs: numberOption(takeOption(args, "--lease-ms")),
|
|
2534
|
+
workerId: takeOption(args, "--worker-id")
|
|
2535
|
+
});
|
|
2536
|
+
const result = { imported, drained };
|
|
2537
|
+
output(parsed, result, () => console.log(`Claimed ${drained.claimed}, delivered ${drained.delivered}, retried ${drained.retried}, dead ${drained.dead}`));
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
if (command === "work") {
|
|
2541
|
+
const args = [...tail];
|
|
2542
|
+
const controller = new AbortController;
|
|
2543
|
+
const stop = () => controller.abort();
|
|
2544
|
+
process.once("SIGTERM", stop);
|
|
2545
|
+
process.once("SIGINT", stop);
|
|
2546
|
+
try {
|
|
2547
|
+
const result = await runDurableWorker({
|
|
2548
|
+
broker,
|
|
2549
|
+
signal: controller.signal,
|
|
2550
|
+
limit: numberOption(takeOption(args, "--limit")),
|
|
2551
|
+
leaseMs: numberOption(takeOption(args, "--lease-ms")),
|
|
2552
|
+
workerId: takeOption(args, "--worker-id"),
|
|
2553
|
+
debounceMs: numberOption(takeOption(args, "--debounce-ms")),
|
|
2554
|
+
reconcileMs: numberOption(takeOption(args, "--reconcile-ms")),
|
|
2555
|
+
watchRestartMs: numberOption(takeOption(args, "--watch-restart-ms"))
|
|
2556
|
+
});
|
|
2557
|
+
output(parsed, result, () => console.log(`Worker stopped after ${result.cycles} cycle(s), delivered ${result.delivered}`));
|
|
2558
|
+
} finally {
|
|
2559
|
+
process.removeListener("SIGTERM", stop);
|
|
2560
|
+
process.removeListener("SIGINT", stop);
|
|
2561
|
+
}
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
if (command === "status") {
|
|
2565
|
+
const result = broker.status();
|
|
2566
|
+
output(parsed, result, () => console.log(`events durable: ${result.counts.pending} pending, ${result.counts.leased} leased, ${result.counts.dead} dead`));
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
if (command === "retry-dead") {
|
|
2570
|
+
const args = [...tail];
|
|
2571
|
+
const result = broker.retryDead({
|
|
2572
|
+
eventId: takeOption(args, "--event-id"),
|
|
2573
|
+
channelId: takeOption(args, "--channel-id"),
|
|
2574
|
+
limit: numberOption(takeOption(args, "--limit"))
|
|
2575
|
+
});
|
|
2576
|
+
output(parsed, result, () => console.log(`Requeued ${result.requeued} dead delivery job(s)`));
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
throw new Error(`Unknown durable command: ${command}`);
|
|
2580
|
+
}
|
|
1014
2581
|
async function handleChannels(client, command, tail, parsed, options) {
|
|
1015
2582
|
if (command === "add") {
|
|
1016
2583
|
const { args, delimiterArgs } = splitDelimiter(tail);
|
|
@@ -1020,7 +2587,7 @@ async function handleChannels(client, command, tail, parsed, options) {
|
|
|
1020
2587
|
const secret = takeOption(args, "--secret");
|
|
1021
2588
|
const timeoutMs = numberOption(takeOption(args, "--timeout-ms"));
|
|
1022
2589
|
const retryAttempts = numberOption(takeOption(args, "--retry-attempts"));
|
|
1023
|
-
const
|
|
2590
|
+
const retryBackoffMs2 = numberOption(takeOption(args, "--retry-backoff-ms"));
|
|
1024
2591
|
const disabled = takeFlag(args, "--disabled");
|
|
1025
2592
|
const headerValues = takeMany(args, "--header");
|
|
1026
2593
|
const commandArgs = takeMany(args, "--arg");
|
|
@@ -1036,7 +2603,7 @@ async function handleChannels(client, command, tail, parsed, options) {
|
|
|
1036
2603
|
enabled: !disabled,
|
|
1037
2604
|
transport,
|
|
1038
2605
|
filters,
|
|
1039
|
-
retry: retryAttempts ||
|
|
2606
|
+
retry: retryAttempts || retryBackoffMs2 ? { maxAttempts: retryAttempts, backoffMs: retryBackoffMs2 } : undefined,
|
|
1040
2607
|
redact: redactions.length > 0 ? { paths: redactions } : undefined,
|
|
1041
2608
|
createdAt: now2,
|
|
1042
2609
|
updatedAt: now2
|
|
@@ -1178,9 +2745,11 @@ async function handleEvents(client, command, tail, parsed, options) {
|
|
|
1178
2745
|
eventId: takeOption(args, "--id"),
|
|
1179
2746
|
source: takeOption(args, "--source"),
|
|
1180
2747
|
type: takeOption(args, "--type"),
|
|
2748
|
+
cursor: takeOption(args, "--cursor"),
|
|
2749
|
+
limit: numberOption(takeOption(args, "--limit")),
|
|
1181
2750
|
dryRun: takeFlag(args, "--dry-run")
|
|
1182
2751
|
});
|
|
1183
|
-
output(parsed, result, () => console.log(
|
|
2752
|
+
output(parsed, result, () => console.log(replaySummary(result.events.length, result.deliveries.length, result.nextCursor)));
|
|
1184
2753
|
return;
|
|
1185
2754
|
}
|
|
1186
2755
|
throw new Error(`Unknown events command: ${command ?? ""}`);
|
|
@@ -1201,6 +2770,10 @@ function severityOption(value) {
|
|
|
1201
2770
|
throw new Error(`Invalid severity: ${value}`);
|
|
1202
2771
|
return value;
|
|
1203
2772
|
}
|
|
2773
|
+
function replaySummary(events, deliveries, nextCursor) {
|
|
2774
|
+
const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
|
|
2775
|
+
return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
|
|
2776
|
+
}
|
|
1204
2777
|
if (import.meta.main) {
|
|
1205
2778
|
runEventsCli().catch((error) => {
|
|
1206
2779
|
const parsed = parseGlobalArgs(process.argv.slice(2));
|