@hasna/events 0.1.14 → 0.1.16

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/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 join2 } from "path";
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
@@ -433,21 +433,27 @@ function now() {
433
433
  function truncate(value, max = 4096) {
434
434
  return value.length > max ? `${value.slice(0, max)}...` : value;
435
435
  }
436
- function buildWebhookRequest(event, channel) {
436
+ function buildWebhookRequest(event, channel, options = {}) {
437
437
  if (!channel.webhook)
438
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
+ }
439
444
  const body = JSON.stringify(event);
440
- const timestamp = event.time;
445
+ const timestamp = options.timestamp ?? new Date().toISOString();
441
446
  const headers = {
442
447
  "Content-Type": "application/json",
443
448
  "User-Agent": "@hasna/events",
444
449
  "X-Hasna-Event-Id": event.id,
445
450
  "X-Hasna-Event-Type": event.type,
446
- "X-Hasna-Timestamp": timestamp,
447
- ...channel.webhook.headers
451
+ ...channel.webhook.headers,
452
+ "X-Hasna-Timestamp": timestamp
448
453
  };
449
- if (channel.webhook.secret) {
450
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
454
+ const secret = options.secret ?? channel.webhook.secret;
455
+ if (secret) {
456
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
451
457
  }
452
458
  return { body, headers };
453
459
  }
@@ -455,7 +461,21 @@ async function dispatchWebhook(event, channel, options = {}) {
455
461
  if (!channel.webhook)
456
462
  throw new Error(`Channel ${channel.id} has no webhook config`);
457
463
  const startedAt = now();
458
- const { body, headers } = buildWebhookRequest(event, channel);
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 });
459
479
  const controller = new AbortController;
460
480
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
461
481
  try {
@@ -487,6 +507,15 @@ async function dispatchWebhook(event, channel, options = {}) {
487
507
  clearTimeout(timeout);
488
508
  }
489
509
  }
510
+ function failedAttempt(startedAt, error) {
511
+ return {
512
+ attempt: 1,
513
+ status: "failed",
514
+ startedAt,
515
+ completedAt: now(),
516
+ error
517
+ };
518
+ }
490
519
  async function dispatchCommand(event, channel) {
491
520
  if (!channel.command)
492
521
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -622,6 +651,48 @@ class EventTypeCatalog {
622
651
  }
623
652
  var defaultEventTypeCatalog = new EventTypeCatalog;
624
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
+
625
696
  // src/index.ts
626
697
  function createEvent(input) {
627
698
  return {
@@ -648,7 +719,11 @@ class EventsClient {
648
719
  constructor(options = {}) {
649
720
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
650
721
  this.redactors = options.redactors ?? [];
651
- this.transportOptions = { fetchImpl: options.fetchImpl };
722
+ this.transportOptions = {
723
+ fetchImpl: options.fetchImpl,
724
+ secretResolver: options.secretResolver,
725
+ now: options.now
726
+ };
652
727
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
653
728
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
654
729
  }
@@ -826,15 +901,6 @@ class EventsClient {
826
901
  return createDeliveryResult(event, channel, attempts);
827
902
  }
828
903
  }
829
- function redactPaths(event, paths, replacement = "[REDACTED]") {
830
- if (paths.length === 0)
831
- return event;
832
- const copy = structuredClone(event);
833
- for (const path of paths) {
834
- setPath(copy, path, replacement);
835
- }
836
- return copy;
837
- }
838
904
  function sanitizeChannelForOutput(channel) {
839
905
  const copy = structuredClone(channel);
840
906
  if (copy.webhook?.secret)
@@ -847,35 +913,6 @@ function sanitizeChannelForOutput(channel) {
847
913
  function sanitizeChannelsForOutput(channels) {
848
914
  return channels.map(sanitizeChannelForOutput);
849
915
  }
850
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
851
- return redactValue(event, replacement);
852
- }
853
- function shouldRedactKey(key) {
854
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
855
- }
856
- function redactValue(value, replacement) {
857
- if (Array.isArray(value))
858
- return value.map((item) => redactValue(item, replacement));
859
- if (!value || typeof value !== "object")
860
- return value;
861
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
862
- key,
863
- shouldRedactKey(key) ? replacement : redactValue(item, replacement)
864
- ]));
865
- }
866
- function setPath(input, path, replacement) {
867
- const parts = path.split(".");
868
- let cursor = input;
869
- for (const part of parts.slice(0, -1)) {
870
- const next = cursor[part];
871
- if (!next || typeof next !== "object")
872
- return;
873
- cursor = next;
874
- }
875
- const last = parts.at(-1);
876
- if (last && last in cursor)
877
- cursor[last] = replacement;
878
- }
879
916
  function queryClientEvents(events, options) {
880
917
  let rows = events;
881
918
  if (options.eventId)
@@ -903,6 +940,1119 @@ function normalizeRetryPolicy(policy) {
903
940
  };
904
941
  }
905
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
+
906
2056
  // src/filter-options.ts
907
2057
  function parseFieldMatchers(values, label, typed = false) {
908
2058
  if (!values?.length)
@@ -978,8 +2128,8 @@ function parseMatcherExpression(value, label) {
978
2128
  // src/cli/index.ts
979
2129
  function version() {
980
2130
  try {
981
- const packagePath = join2(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
982
- return JSON.parse(readFileSync(packagePath, "utf-8")).version ?? "0.0.0";
2131
+ const packagePath = join5(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
2132
+ return JSON.parse(readFileSync2(packagePath, "utf-8")).version ?? "0.0.0";
983
2133
  } catch {
984
2134
  return "0.0.0";
985
2135
  }
@@ -1102,9 +2252,24 @@ Usage:
1102
2252
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
1103
2253
  ${name} [--dir <path>] [--json] events list [--limit <n>]
1104
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
1105
2268
 
1106
2269
  Environment:
1107
- HASNA_EVENTS_DIR or HASNA_EVENTS_HOME overrides the default ${getEventsDataDir()}`);
2270
+ HASNA_EVENTS_DIR Primary data-directory override
2271
+ HASNA_EVENTS_HOME Legacy data-directory fallback
2272
+ Default directory ${getEventsDataDir()}`);
1108
2273
  }
1109
2274
  function printChannelsHelp(options = {}) {
1110
2275
  const name = commandName(options);
@@ -1118,22 +2283,24 @@ Usage:
1118
2283
  ${name} [--dir <path>] [--json] channels match <id>
1119
2284
  ${name} [--dir <path>] [--json] channels status
1120
2285
 
1121
- Options:
1122
- --id <id> Channel id for add
1123
- --type <pattern> Event type filter, supports wildcards
1124
- --source <source> Event source filter
1125
- --subject <subject> Event subject filter
1126
- --severity <severity> Event severity filter
1127
- --data <path=value|path!=value> Event data field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
1128
- --metadata <path=value|path!=value> Event metadata field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
1129
- --data-json <path=json|path!=json> Event data field filter with typed JSON value
1130
- --metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
1131
- --honor-filters On channels test, skip delivery when the sample event does not match filters
1132
- --transport <kind> webhook or command
1133
- --secret <secret> Webhook signing secret
1134
- --header <name=value> Webhook header, repeatable
1135
- --redact <path> Redaction path, repeatable
1136
- --no-deliver Available on events emit`);
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`);
1137
2304
  }
1138
2305
  function printChannelAddHelp(options = {}) {
1139
2306
  const name = commandName(options);
@@ -1144,23 +2311,24 @@ Usage:
1144
2311
  ${name} [--dir <path>] [--json] channels add <command> --transport command [options] -- [command-args...]
1145
2312
 
1146
2313
  Options:
1147
- --id <id> Channel id
2314
+ --id <id> Channel id (default: generated UUID)
1148
2315
  --name <name> Display name
1149
- --transport <kind> webhook or command
2316
+ --transport <kind> webhook or command (default: webhook)
1150
2317
  --type <pattern> Event type filter, supports wildcards
2318
+ --event-type <pattern> Alias for --type
1151
2319
  --source <source> Event source filter
1152
2320
  --subject <subject> Event subject filter
1153
2321
  --severity <severity> Event severity filter
1154
- --data <path=value|path!=value> Event data field filter, repeatable
1155
- --metadata <path=value|path!=value> Event metadata field filter, repeatable
1156
- --data-json <path=json|path!=json> Event data field filter with typed JSON value
1157
- --metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
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
1158
2326
  --secret <secret> Webhook signing secret
1159
2327
  --header <name=value> Webhook header, repeatable
1160
2328
  --arg <arg> Command argument, repeatable; values may begin with dashes
1161
- --timeout-ms <ms> Transport timeout in milliseconds
1162
- --retry-attempts <n> Maximum delivery attempts
1163
- --retry-backoff-ms <ms> Initial retry backoff in milliseconds
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)
1164
2332
  --redact <path> Redaction path, repeatable
1165
2333
  --disabled Create channel disabled
1166
2334
 
@@ -1179,15 +2347,25 @@ Usage:
1179
2347
  ${name} [--dir <path>] [--json] events list [--limit <n>]
1180
2348
  ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--cursor <cursor>] [--limit <n>] [--dry-run]
1181
2349
 
1182
- Options:
2350
+ Emit options:
1183
2351
  --source <source> Event source${options.source ? ` (default: ${options.source})` : ""}
1184
2352
  --subject <subject> Event subject
1185
- --severity <severity> Event severity
2353
+ --severity <severity> debug|info|notice|warning|error|critical (default: info)
1186
2354
  --message <message> Human-readable event message
1187
2355
  --dedupe-key <key> Deduplicate repeated events
1188
2356
  --data <json> JSON object payload
1189
2357
  --metadata <json> JSON object metadata
1190
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
1191
2369
  --cursor <cursor> Opaque cursor returned by a previous replay page
1192
2370
  --limit <n> Maximum events to replay
1193
2371
  --dry-run Preview replay matches without delivery`);
@@ -1211,6 +2389,19 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
1211
2389
  });
1212
2390
  return;
1213
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
+ }
1214
2405
  const store = new JsonEventsStore(parsed.dir);
1215
2406
  const client = new EventsClient({ store });
1216
2407
  if (group === "channels") {
@@ -1243,6 +2434,150 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
1243
2434
  }
1244
2435
  throw new Error(`Unknown command group: ${group}`);
1245
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
+ }
1246
2581
  async function handleChannels(client, command, tail, parsed, options) {
1247
2582
  if (command === "add") {
1248
2583
  const { args, delimiterArgs } = splitDelimiter(tail);
@@ -1252,7 +2587,7 @@ async function handleChannels(client, command, tail, parsed, options) {
1252
2587
  const secret = takeOption(args, "--secret");
1253
2588
  const timeoutMs = numberOption(takeOption(args, "--timeout-ms"));
1254
2589
  const retryAttempts = numberOption(takeOption(args, "--retry-attempts"));
1255
- const retryBackoffMs = numberOption(takeOption(args, "--retry-backoff-ms"));
2590
+ const retryBackoffMs2 = numberOption(takeOption(args, "--retry-backoff-ms"));
1256
2591
  const disabled = takeFlag(args, "--disabled");
1257
2592
  const headerValues = takeMany(args, "--header");
1258
2593
  const commandArgs = takeMany(args, "--arg");
@@ -1268,7 +2603,7 @@ async function handleChannels(client, command, tail, parsed, options) {
1268
2603
  enabled: !disabled,
1269
2604
  transport,
1270
2605
  filters,
1271
- retry: retryAttempts || retryBackoffMs ? { maxAttempts: retryAttempts, backoffMs: retryBackoffMs } : undefined,
2606
+ retry: retryAttempts || retryBackoffMs2 ? { maxAttempts: retryAttempts, backoffMs: retryBackoffMs2 } : undefined,
1272
2607
  redact: redactions.length > 0 ? { paths: redactions } : undefined,
1273
2608
  createdAt: now2,
1274
2609
  updatedAt: now2