@headless-lms/adapter-defaults 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { EventBus, Logger, OutboxRelay, OutboxStore } from "@headless-lms/core/shared/ports";
1
+ import { DomainEvent, EventBus, Logger, OutboxRelay, OutboxStore } from "@headless-lms/core/shared/ports";
2
2
  //#region src/events/outbox-relay.d.ts
3
3
  /** Backoff base: first retry 5s after the first failure, doubling per attempt. */
4
4
  declare const OUTBOX_BACKOFF_BASE_MS = 5000;
@@ -14,7 +14,9 @@ interface PollingOutboxRelayConfig {
14
14
  pollIntervalMs: number;
15
15
  /** Max rows fetched/dispatched per tick; a full batch re-polls immediately. */
16
16
  batchSize: number;
17
+ concurrency: number;
17
18
  }
19
+ declare function orderingKey(event: DomainEvent): string;
18
20
  declare class PollingOutboxRelay implements OutboxRelay {
19
21
  private readonly store;
20
22
  private readonly bus;
@@ -27,12 +29,13 @@ declare class PollingOutboxRelay implements OutboxRelay {
27
29
  start(): void;
28
30
  stop(): Promise<void>;
29
31
  private schedule;
30
- /** One poll: fetch a batch, dispatch in order, reschedule — immediately when
32
+ /** One poll: fetch a batch, dispatch in order per ordering key, reschedule — immediately when
31
33
  * the batch was full (drain bursts), else after the idle interval. Never
32
34
  * throws: a poll failure is logged and retried next tick. */
33
35
  private tick;
36
+ private dispatchBatch;
34
37
  private dispatch;
35
38
  }
36
39
  //#endregion
37
- export { OUTBOX_BACKOFF_BASE_MS, OUTBOX_BACKOFF_MAX_MS, PollingOutboxRelay, PollingOutboxRelayConfig, outboxBackoffMs };
40
+ export { OUTBOX_BACKOFF_BASE_MS, OUTBOX_BACKOFF_MAX_MS, PollingOutboxRelay, PollingOutboxRelayConfig, orderingKey, outboxBackoffMs };
38
41
  //# sourceMappingURL=outbox-relay.d.ts.map
@@ -9,6 +9,21 @@ const OUTBOX_BACKOFF_MAX_MS = 15 * 6e4;
9
9
  function outboxBackoffMs(attempts) {
10
10
  return Math.min(OUTBOX_BACKOFF_BASE_MS * 2 ** attempts, OUTBOX_BACKOFF_MAX_MS);
11
11
  }
12
+ const ORDERING_FIELDS = [
13
+ "chatId",
14
+ "postId",
15
+ "activityId",
16
+ "commentId",
17
+ "id"
18
+ ];
19
+ function orderingKey(event) {
20
+ const data = event.data;
21
+ if (data !== null && typeof data === "object") for (const field of ORDERING_FIELDS) {
22
+ const value = data[field];
23
+ if (typeof value === "string" && value.length > 0) return `${event.orgId}:${value}`;
24
+ }
25
+ return `${event.orgId}:${event.id}`;
26
+ }
12
27
  var PollingOutboxRelay = class {
13
28
  store;
14
29
  bus;
@@ -29,7 +44,8 @@ var PollingOutboxRelay = class {
29
44
  this.schedule(this.config.pollIntervalMs);
30
45
  this.logger.info("outbox relay started", {
31
46
  pollIntervalMs: this.config.pollIntervalMs,
32
- batchSize: this.config.batchSize
47
+ batchSize: this.config.batchSize,
48
+ concurrency: this.config.concurrency
33
49
  });
34
50
  }
35
51
  async stop() {
@@ -46,7 +62,7 @@ var PollingOutboxRelay = class {
46
62
  this.inFlight = this.tick();
47
63
  }, delayMs);
48
64
  }
49
- /** One poll: fetch a batch, dispatch in order, reschedule — immediately when
65
+ /** One poll: fetch a batch, dispatch in order per ordering key, reschedule — immediately when
50
66
  * the batch was full (drain bursts), else after the idle interval. Never
51
67
  * throws: a poll failure is logged and retried next tick. */
52
68
  async tick() {
@@ -55,12 +71,26 @@ var PollingOutboxRelay = class {
55
71
  const batch = await this.store.fetchBatch(this.config.batchSize);
56
72
  fetched = batch.length;
57
73
  if (fetched > 0) this.logger.debug("outbox batch fetched", { count: fetched });
58
- for (const message of batch) await this.dispatch(message);
74
+ await this.dispatchBatch(batch);
59
75
  } catch (err) {
60
76
  this.logger.error("outbox poll failed", { error: String(err) });
61
77
  }
62
78
  this.schedule(fetched >= this.config.batchSize ? 0 : this.config.pollIntervalMs);
63
79
  }
80
+ async dispatchBatch(batch) {
81
+ const lanes = /* @__PURE__ */ new Map();
82
+ for (const message of batch) {
83
+ const key = orderingKey(message.payload);
84
+ const lane = lanes.get(key);
85
+ if (lane) lane.push(message);
86
+ else lanes.set(key, [message]);
87
+ }
88
+ const queue = [...lanes.values()];
89
+ const workers = Math.min(Math.max(1, this.config.concurrency), queue.length);
90
+ await Promise.all(Array.from({ length: workers }, async () => {
91
+ for (let lane = queue.shift(); lane; lane = queue.shift()) for (const message of lane) await this.dispatch(message);
92
+ }));
93
+ }
64
94
  async dispatch(message) {
65
95
  try {
66
96
  await this.bus.publish(message.payload);
@@ -72,7 +102,17 @@ var PollingOutboxRelay = class {
72
102
  } catch (err) {
73
103
  const error = String(err);
74
104
  const nextAttemptAt = new Date(Date.now() + outboxBackoffMs(message.attempts));
75
- await this.store.markFailed(message.id, error, nextAttemptAt);
105
+ try {
106
+ await this.store.markFailed(message.id, error, nextAttemptAt);
107
+ } catch (markErr) {
108
+ this.logger.error("outbox failure not recorded", {
109
+ id: message.id,
110
+ type: message.payload.type,
111
+ error,
112
+ markError: String(markErr)
113
+ });
114
+ return;
115
+ }
76
116
  const attempt = message.attempts + 1;
77
117
  const meta = {
78
118
  id: message.id,
@@ -87,6 +127,6 @@ var PollingOutboxRelay = class {
87
127
  }
88
128
  };
89
129
  //#endregion
90
- export { OUTBOX_BACKOFF_BASE_MS, OUTBOX_BACKOFF_MAX_MS, PollingOutboxRelay, outboxBackoffMs };
130
+ export { OUTBOX_BACKOFF_BASE_MS, OUTBOX_BACKOFF_MAX_MS, PollingOutboxRelay, orderingKey, outboxBackoffMs };
91
131
 
92
132
  //# sourceMappingURL=outbox-relay.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"outbox-relay.js","names":[],"sources":["../../src/events/outbox-relay.ts"],"sourcesContent":["// Same-process outbox relay (implements the core OutboxRelay port). A\n// setTimeout-CHAINED poller (never setInterval — a slow batch must not overlap\n// the next tick): each tick fetches unprocessed rows in id order and publishes\n// them to the EventBus.\n//\n// Delivery is at-least-once — handlers must be idempotent, keyed on event.id.\n// Ordering is skip-past-failures: a failing row is stamped with the error and\n// an exponential-backoff next_attempt_at (retried once due) while later rows\n// flow; the store stops fetching it after OUTBOX_MAX_ATTEMPTS failures.\nimport type {\n EventBus,\n Logger,\n OutboxMessage,\n OutboxRelay,\n OutboxStore,\n} from \"@headless-lms/core/shared/ports\";\nimport { OUTBOX_MAX_ATTEMPTS } from \"@headless-lms/adapter-db\";\n\n/** Backoff base: first retry 5s after the first failure, doubling per attempt. */\nexport const OUTBOX_BACKOFF_BASE_MS = 5_000;\n/** Backoff ceiling: retries never wait longer than 15 minutes. */\nexport const OUTBOX_BACKOFF_MAX_MS = 15 * 60_000;\n\n/** Exponential backoff delay after a failure: min(base × 2^attempts, max),\n * where `attempts` is the PRIOR failure count (0 on the first failure). */\nexport function outboxBackoffMs(attempts: number): number {\n return Math.min(OUTBOX_BACKOFF_BASE_MS * 2 ** attempts, OUTBOX_BACKOFF_MAX_MS);\n}\n\nexport interface PollingOutboxRelayConfig {\n /** Master switch: false → start() is a no-op. */\n enabled: boolean;\n /** Idle delay between polls. */\n pollIntervalMs: number;\n /** Max rows fetched/dispatched per tick; a full batch re-polls immediately. */\n batchSize: number;\n}\n\nexport class PollingOutboxRelay implements OutboxRelay {\n private pollTimer: NodeJS.Timeout | undefined;\n private running = false;\n private inFlight: Promise<void> = Promise.resolve();\n\n constructor(\n private readonly store: OutboxStore,\n private readonly bus: EventBus,\n private readonly config: PollingOutboxRelayConfig,\n private readonly logger: Logger,\n ) {}\n\n start(): void {\n if (!this.config.enabled || this.running) {\n return;\n }\n this.running = true;\n this.schedule(this.config.pollIntervalMs);\n this.logger.info(\"outbox relay started\", {\n pollIntervalMs: this.config.pollIntervalMs,\n batchSize: this.config.batchSize,\n });\n }\n\n async stop(): Promise<void> {\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n }\n this.pollTimer = undefined;\n const wasRunning = this.running;\n this.running = false;\n await this.inFlight;\n if (wasRunning) {\n this.logger.info(\"outbox relay stopped\");\n }\n }\n\n private schedule(delayMs: number): void {\n if (!this.running) {\n return;\n }\n this.pollTimer = setTimeout(() => {\n this.inFlight = this.tick();\n }, delayMs);\n }\n\n /** One poll: fetch a batch, dispatch in order, reschedule — immediately when\n * the batch was full (drain bursts), else after the idle interval. Never\n * throws: a poll failure is logged and retried next tick. */\n private async tick(): Promise<void> {\n let fetched = 0;\n try {\n const batch = await this.store.fetchBatch(this.config.batchSize);\n fetched = batch.length;\n if (fetched > 0) {\n this.logger.debug(\"outbox batch fetched\", { count: fetched });\n }\n for (const message of batch) {\n await this.dispatch(message);\n }\n } catch (err) {\n this.logger.error(\"outbox poll failed\", { error: String(err) });\n }\n this.schedule(fetched >= this.config.batchSize ? 0 : this.config.pollIntervalMs);\n }\n\n private async dispatch(message: OutboxMessage): Promise<void> {\n try {\n await this.bus.publish(message.payload);\n await this.store.markProcessed(message.id);\n this.logger.info(\"outbox event dispatched\", {\n id: message.id,\n type: message.payload.type,\n });\n } catch (err) {\n const error = String(err);\n const nextAttemptAt = new Date(Date.now() + outboxBackoffMs(message.attempts));\n await this.store.markFailed(message.id, error, nextAttemptAt);\n const attempt = message.attempts + 1;\n const meta = {\n id: message.id,\n type: message.payload.type,\n attempt,\n nextAttemptAt: nextAttemptAt.toISOString(),\n error,\n };\n // The store stops fetching after OUTBOX_MAX_ATTEMPTS — this failure parks the row.\n if (attempt >= OUTBOX_MAX_ATTEMPTS) {\n this.logger.warn(\"outbox event parked\", meta);\n } else {\n this.logger.error(\"outbox dispatch failed\", meta);\n }\n }\n }\n}\n"],"mappings":";;;AAmBA,MAAa,yBAAyB;;AAEtC,MAAa,wBAAwB,KAAK;;;AAI1C,SAAgB,gBAAgB,UAA0B;CACxD,OAAO,KAAK,IAAI,yBAAyB,KAAK,UAAU,qBAAqB;AAC/E;AAWA,IAAa,qBAAb,MAAuD;CAMlC;CACA;CACA;CACA;CARnB;CACA,UAAkB;CAClB,WAAkC,QAAQ,QAAQ;CAElD,YACE,OACA,KACA,QACA,QACA;EAJiB,KAAA,QAAA;EACA,KAAA,MAAA;EACA,KAAA,SAAA;EACA,KAAA,SAAA;CAChB;CAEH,QAAc;EACZ,IAAI,CAAC,KAAK,OAAO,WAAW,KAAK,SAC/B;EAEF,KAAK,UAAU;EACf,KAAK,SAAS,KAAK,OAAO,cAAc;EACxC,KAAK,OAAO,KAAK,wBAAwB;GACvC,gBAAgB,KAAK,OAAO;GAC5B,WAAW,KAAK,OAAO;EACzB,CAAC;CACH;CAEA,MAAM,OAAsB;EAC1B,IAAI,KAAK,WACP,aAAa,KAAK,SAAS;EAE7B,KAAK,YAAY,KAAA;EACjB,MAAM,aAAa,KAAK;EACxB,KAAK,UAAU;EACf,MAAM,KAAK;EACX,IAAI,YACF,KAAK,OAAO,KAAK,sBAAsB;CAE3C;CAEA,SAAiB,SAAuB;EACtC,IAAI,CAAC,KAAK,SACR;EAEF,KAAK,YAAY,iBAAiB;GAChC,KAAK,WAAW,KAAK,KAAK;EAC5B,GAAG,OAAO;CACZ;;;;CAKA,MAAc,OAAsB;EAClC,IAAI,UAAU;EACd,IAAI;GACF,MAAM,QAAQ,MAAM,KAAK,MAAM,WAAW,KAAK,OAAO,SAAS;GAC/D,UAAU,MAAM;GAChB,IAAI,UAAU,GACZ,KAAK,OAAO,MAAM,wBAAwB,EAAE,OAAO,QAAQ,CAAC;GAE9D,KAAK,MAAM,WAAW,OACpB,MAAM,KAAK,SAAS,OAAO;EAE/B,SAAS,KAAK;GACZ,KAAK,OAAO,MAAM,sBAAsB,EAAE,OAAO,OAAO,GAAG,EAAE,CAAC;EAChE;EACA,KAAK,SAAS,WAAW,KAAK,OAAO,YAAY,IAAI,KAAK,OAAO,cAAc;CACjF;CAEA,MAAc,SAAS,SAAuC;EAC5D,IAAI;GACF,MAAM,KAAK,IAAI,QAAQ,QAAQ,OAAO;GACtC,MAAM,KAAK,MAAM,cAAc,QAAQ,EAAE;GACzC,KAAK,OAAO,KAAK,2BAA2B;IAC1C,IAAI,QAAQ;IACZ,MAAM,QAAQ,QAAQ;GACxB,CAAC;EACH,SAAS,KAAK;GACZ,MAAM,QAAQ,OAAO,GAAG;GACxB,MAAM,gBAAgB,IAAI,KAAK,KAAK,IAAI,IAAI,gBAAgB,QAAQ,QAAQ,CAAC;GAC7E,MAAM,KAAK,MAAM,WAAW,QAAQ,IAAI,OAAO,aAAa;GAC5D,MAAM,UAAU,QAAQ,WAAW;GACnC,MAAM,OAAO;IACX,IAAI,QAAQ;IACZ,MAAM,QAAQ,QAAQ;IACtB;IACA,eAAe,cAAc,YAAY;IACzC;GACF;GAEA,IAAI,WAAW,qBACb,KAAK,OAAO,KAAK,uBAAuB,IAAI;QAE5C,KAAK,OAAO,MAAM,0BAA0B,IAAI;EAEpD;CACF;AACF"}
1
+ {"version":3,"file":"outbox-relay.js","names":[],"sources":["../../src/events/outbox-relay.ts"],"sourcesContent":["// Same-process outbox relay (implements the core OutboxRelay port). A\n// setTimeout-CHAINED poller (never setInterval — a slow batch must not overlap\n// the next tick): each tick fetches unprocessed rows in id order and publishes\n// them to the EventBus, up to `concurrency` at once and in id order per ordering key.\n//\n// Delivery is at-least-once — handlers must be idempotent, keyed on event.id.\n// Ordering is skip-past-failures: a failing row is stamped with the error and\n// an exponential-backoff next_attempt_at (retried once due) while later rows\n// flow; the store stops fetching it after OUTBOX_MAX_ATTEMPTS failures.\nimport type {\n DomainEvent,\n EventBus,\n Logger,\n OutboxMessage,\n OutboxRelay,\n OutboxStore,\n} from \"@headless-lms/core/shared/ports\";\nimport { OUTBOX_MAX_ATTEMPTS } from \"@headless-lms/adapter-db\";\n\n/** Backoff base: first retry 5s after the first failure, doubling per attempt. */\nexport const OUTBOX_BACKOFF_BASE_MS = 5_000;\n/** Backoff ceiling: retries never wait longer than 15 minutes. */\nexport const OUTBOX_BACKOFF_MAX_MS = 15 * 60_000;\n\n/** Exponential backoff delay after a failure: min(base × 2^attempts, max),\n * where `attempts` is the PRIOR failure count (0 on the first failure). */\nexport function outboxBackoffMs(attempts: number): number {\n return Math.min(OUTBOX_BACKOFF_BASE_MS * 2 ** attempts, OUTBOX_BACKOFF_MAX_MS);\n}\n\nexport interface PollingOutboxRelayConfig {\n /** Master switch: false → start() is a no-op. */\n enabled: boolean;\n /** Idle delay between polls. */\n pollIntervalMs: number;\n /** Max rows fetched/dispatched per tick; a full batch re-polls immediately. */\n batchSize: number;\n concurrency: number;\n}\n\nconst ORDERING_FIELDS = [\"chatId\", \"postId\", \"activityId\", \"commentId\", \"id\"] as const;\n\nexport function orderingKey(event: DomainEvent): string {\n const data = event.data;\n if (data !== null && typeof data === \"object\") {\n for (const field of ORDERING_FIELDS) {\n const value = (data as Record<string, unknown>)[field];\n if (typeof value === \"string\" && value.length > 0) {\n return `${event.orgId}:${value}`;\n }\n }\n }\n return `${event.orgId}:${event.id}`;\n}\n\nexport class PollingOutboxRelay implements OutboxRelay {\n private pollTimer: NodeJS.Timeout | undefined;\n private running = false;\n private inFlight: Promise<void> = Promise.resolve();\n\n constructor(\n private readonly store: OutboxStore,\n private readonly bus: EventBus,\n private readonly config: PollingOutboxRelayConfig,\n private readonly logger: Logger,\n ) {}\n\n start(): void {\n if (!this.config.enabled || this.running) {\n return;\n }\n this.running = true;\n this.schedule(this.config.pollIntervalMs);\n this.logger.info(\"outbox relay started\", {\n pollIntervalMs: this.config.pollIntervalMs,\n batchSize: this.config.batchSize,\n concurrency: this.config.concurrency,\n });\n }\n\n async stop(): Promise<void> {\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n }\n this.pollTimer = undefined;\n const wasRunning = this.running;\n this.running = false;\n await this.inFlight;\n if (wasRunning) {\n this.logger.info(\"outbox relay stopped\");\n }\n }\n\n private schedule(delayMs: number): void {\n if (!this.running) {\n return;\n }\n this.pollTimer = setTimeout(() => {\n this.inFlight = this.tick();\n }, delayMs);\n }\n\n /** One poll: fetch a batch, dispatch in order per ordering key, reschedule — immediately when\n * the batch was full (drain bursts), else after the idle interval. Never\n * throws: a poll failure is logged and retried next tick. */\n private async tick(): Promise<void> {\n let fetched = 0;\n try {\n const batch = await this.store.fetchBatch(this.config.batchSize);\n fetched = batch.length;\n if (fetched > 0) {\n this.logger.debug(\"outbox batch fetched\", { count: fetched });\n }\n await this.dispatchBatch(batch);\n } catch (err) {\n this.logger.error(\"outbox poll failed\", { error: String(err) });\n }\n this.schedule(fetched >= this.config.batchSize ? 0 : this.config.pollIntervalMs);\n }\n\n private async dispatchBatch(batch: OutboxMessage[]): Promise<void> {\n const lanes = new Map<string, OutboxMessage[]>();\n for (const message of batch) {\n const key = orderingKey(message.payload);\n const lane = lanes.get(key);\n if (lane) {\n lane.push(message);\n } else {\n lanes.set(key, [message]);\n }\n }\n const queue = [...lanes.values()];\n const workers = Math.min(Math.max(1, this.config.concurrency), queue.length);\n await Promise.all(\n Array.from({ length: workers }, async () => {\n for (let lane = queue.shift(); lane; lane = queue.shift()) {\n for (const message of lane) {\n await this.dispatch(message);\n }\n }\n }),\n );\n }\n\n private async dispatch(message: OutboxMessage): Promise<void> {\n try {\n await this.bus.publish(message.payload);\n await this.store.markProcessed(message.id);\n this.logger.info(\"outbox event dispatched\", {\n id: message.id,\n type: message.payload.type,\n });\n } catch (err) {\n const error = String(err);\n const nextAttemptAt = new Date(Date.now() + outboxBackoffMs(message.attempts));\n try {\n await this.store.markFailed(message.id, error, nextAttemptAt);\n } catch (markErr) {\n this.logger.error(\"outbox failure not recorded\", {\n id: message.id,\n type: message.payload.type,\n error,\n markError: String(markErr),\n });\n return;\n }\n const attempt = message.attempts + 1;\n const meta = {\n id: message.id,\n type: message.payload.type,\n attempt,\n nextAttemptAt: nextAttemptAt.toISOString(),\n error,\n };\n // The store stops fetching after OUTBOX_MAX_ATTEMPTS — this failure parks the row.\n if (attempt >= OUTBOX_MAX_ATTEMPTS) {\n this.logger.warn(\"outbox event parked\", meta);\n } else {\n this.logger.error(\"outbox dispatch failed\", meta);\n }\n }\n }\n}\n"],"mappings":"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"}
@@ -22,12 +22,23 @@ var PinoLogger = class PinoLogger {
22
22
  return new PinoLogger(this.instance.child(bindings));
23
23
  }
24
24
  };
25
+ /** The HTTP access-log shape: the route pattern, never the raw path or query (tokens live there). */
26
+ function requestSerializer(req) {
27
+ return {
28
+ method: req.method,
29
+ route: req.routeOptions?.url,
30
+ remoteAddress: req.ip
31
+ };
32
+ }
25
33
  /** The process-wide root: JSON lines, Error serialization under `err`.
26
34
  * `destination` exists for tests; omitted → stdout. */
27
35
  function createRootLogger(level, destination) {
28
36
  const instance = pino({
29
37
  level,
30
- serializers: { err: stdSerializers.err },
38
+ serializers: {
39
+ err: stdSerializers.err,
40
+ req: requestSerializer
41
+ },
31
42
  mixin: () => requestLogContext.current()
32
43
  }, destination);
33
44
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/logging/index.ts"],"sourcesContent":["// Logging adapter — implements the core Logger port over pino. One root\n// instance per process: the container creates it, Fastify reuses it via\n// `loggerInstance`, and every component gets a child bound with { name }.\nimport { pino, stdSerializers, type DestinationStream, type Logger as PinoInstance } from \"pino\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { requestLogContext } from \"./request-context.js\";\n\nexport { requestLogContext, type RequestLogContext } from \"./request-context.js\";\nexport type { PinoInstance };\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport class PinoLogger implements Logger {\n constructor(private readonly instance: PinoInstance) {}\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n this.instance.debug(meta ?? {}, msg);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n this.instance.info(meta ?? {}, msg);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n this.instance.warn(meta ?? {}, msg);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n this.instance.error(meta ?? {}, msg);\n }\n child(bindings: Record<string, unknown>): Logger {\n return new PinoLogger(this.instance.child(bindings));\n }\n}\n\n/** The process-wide root: JSON lines, Error serialization under `err`.\n * `destination` exists for tests; omitted → stdout. */\nexport function createRootLogger(\n level: LogLevel,\n destination?: DestinationStream,\n): { instance: PinoInstance; logger: Logger } {\n const instance = pino(\n {\n level,\n serializers: { err: stdSerializers.err },\n // Ambient request correlation (reqId/orgId); call-site meta wins on clash.\n mixin: () => requestLogContext.current(),\n },\n destination,\n );\n return { instance, logger: new PinoLogger(instance) };\n}\n"],"mappings":";;;AAYA,IAAa,aAAb,MAAa,WAA6B;CACX;CAA7B,YAAY,UAAyC;EAAxB,KAAA,WAAA;CAAyB;CAEtD,MAAM,KAAa,MAAsC;EACvD,KAAK,SAAS,MAAM,QAAQ,CAAC,GAAG,GAAG;CACrC;CACA,KAAK,KAAa,MAAsC;EACtD,KAAK,SAAS,KAAK,QAAQ,CAAC,GAAG,GAAG;CACpC;CACA,KAAK,KAAa,MAAsC;EACtD,KAAK,SAAS,KAAK,QAAQ,CAAC,GAAG,GAAG;CACpC;CACA,MAAM,KAAa,MAAsC;EACvD,KAAK,SAAS,MAAM,QAAQ,CAAC,GAAG,GAAG;CACrC;CACA,MAAM,UAA2C;EAC/C,OAAO,IAAI,WAAW,KAAK,SAAS,MAAM,QAAQ,CAAC;CACrD;AACF;;;AAIA,SAAgB,iBACd,OACA,aAC4C;CAC5C,MAAM,WAAW,KACf;EACE;EACA,aAAa,EAAE,KAAK,eAAe,IAAI;EAEvC,aAAa,kBAAkB,QAAQ;CACzC,GACA,WACF;CACA,OAAO;EAAE;EAAU,QAAQ,IAAI,WAAW,QAAQ;CAAE;AACtD"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/logging/index.ts"],"sourcesContent":["// Logging adapter — implements the core Logger port over pino. One root\n// instance per process: the container creates it, Fastify reuses it via\n// `loggerInstance`, and every component gets a child bound with { name }.\nimport { pino, stdSerializers, type DestinationStream, type Logger as PinoInstance } from \"pino\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { requestLogContext } from \"./request-context.js\";\n\nexport { requestLogContext, type RequestLogContext } from \"./request-context.js\";\nexport type { PinoInstance };\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport class PinoLogger implements Logger {\n constructor(private readonly instance: PinoInstance) {}\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n this.instance.debug(meta ?? {}, msg);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n this.instance.info(meta ?? {}, msg);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n this.instance.warn(meta ?? {}, msg);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n this.instance.error(meta ?? {}, msg);\n }\n child(bindings: Record<string, unknown>): Logger {\n return new PinoLogger(this.instance.child(bindings));\n }\n}\n\ninterface LoggedRequest {\n method?: string;\n ip?: string;\n routeOptions?: { url?: string };\n}\n\n/** The HTTP access-log shape: the route pattern, never the raw path or query (tokens live there). */\nfunction requestSerializer(req: LoggedRequest): Record<string, unknown> {\n return { method: req.method, route: req.routeOptions?.url, remoteAddress: req.ip };\n}\n\n/** The process-wide root: JSON lines, Error serialization under `err`.\n * `destination` exists for tests; omitted → stdout. */\nexport function createRootLogger(\n level: LogLevel,\n destination?: DestinationStream,\n): { instance: PinoInstance; logger: Logger } {\n const instance = pino(\n {\n level,\n serializers: { err: stdSerializers.err, req: requestSerializer },\n // Ambient request correlation (reqId/orgId); call-site meta wins on clash.\n mixin: () => requestLogContext.current(),\n },\n destination,\n );\n return { instance, logger: new PinoLogger(instance) };\n}\n"],"mappings":";;;AAYA,IAAa,aAAb,MAAa,WAA6B;CACX;CAA7B,YAAY,UAAyC;EAAxB,KAAA,WAAA;CAAyB;CAEtD,MAAM,KAAa,MAAsC;EACvD,KAAK,SAAS,MAAM,QAAQ,CAAC,GAAG,GAAG;CACrC;CACA,KAAK,KAAa,MAAsC;EACtD,KAAK,SAAS,KAAK,QAAQ,CAAC,GAAG,GAAG;CACpC;CACA,KAAK,KAAa,MAAsC;EACtD,KAAK,SAAS,KAAK,QAAQ,CAAC,GAAG,GAAG;CACpC;CACA,MAAM,KAAa,MAAsC;EACvD,KAAK,SAAS,MAAM,QAAQ,CAAC,GAAG,GAAG;CACrC;CACA,MAAM,UAA2C;EAC/C,OAAO,IAAI,WAAW,KAAK,SAAS,MAAM,QAAQ,CAAC;CACrD;AACF;;AASA,SAAS,kBAAkB,KAA6C;CACtE,OAAO;EAAE,QAAQ,IAAI;EAAQ,OAAO,IAAI,cAAc;EAAK,eAAe,IAAI;CAAG;AACnF;;;AAIA,SAAgB,iBACd,OACA,aAC4C;CAC5C,MAAM,WAAW,KACf;EACE;EACA,aAAa;GAAE,KAAK,eAAe;GAAK,KAAK;EAAkB;EAE/D,aAAa,kBAAkB,QAAQ;CACzC,GACA,WACF;CACA,OAAO;EAAE;EAAU,QAAQ,IAAI,WAAW,QAAQ;CAAE;AACtD"}
@@ -2,6 +2,7 @@ import { Logger, ObjectStorage, PresignDownloadInput, PresignedUpload, StoredObj
2
2
  //#region src/storage/index.d.ts
3
3
  declare class StorageAdapter implements ObjectStorage {
4
4
  private readonly logger;
5
+ readonly uploadExpirySeconds = 0;
5
6
  constructor(logger?: Logger);
6
7
  presignUpload(_input: {
7
8
  key: string;
@@ -10,6 +11,11 @@ declare class StorageAdapter implements ObjectStorage {
10
11
  }): Promise<PresignedUpload>;
11
12
  presignDownload(_input: PresignDownloadInput): Promise<string>;
12
13
  stat(_key: string): Promise<StoredObjectInfo | null>;
14
+ list(_prefix: string): Promise<StoredObjectInfo[]>;
15
+ copy(_input: {
16
+ from: string;
17
+ to: string;
18
+ }): Promise<void>;
13
19
  remove(_key: string): Promise<void>;
14
20
  private fail;
15
21
  }
@@ -2,6 +2,7 @@ import { noopLogger } from "@headless-lms/core/shared/logger";
2
2
  //#region src/storage/index.ts
3
3
  var StorageAdapter = class {
4
4
  logger;
5
+ uploadExpirySeconds = 0;
5
6
  constructor(logger = noopLogger) {
6
7
  this.logger = logger;
7
8
  }
@@ -14,6 +15,12 @@ var StorageAdapter = class {
14
15
  async stat(_key) {
15
16
  return this.fail("stat");
16
17
  }
18
+ async list(_prefix) {
19
+ return [];
20
+ }
21
+ async copy(_input) {
22
+ return this.fail("copy");
23
+ }
17
24
  async remove(_key) {
18
25
  return this.fail("remove");
19
26
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/storage/index.ts"],"sourcesContent":["// Default when no ObjectStorage is injected — boots, fails loudly on use.\nimport type {\n Logger,\n ObjectStorage,\n PresignDownloadInput,\n PresignedUpload,\n StoredObjectInfo,\n} from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\n\nexport class StorageAdapter implements ObjectStorage {\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async presignUpload(_input: {\n key: string;\n contentType?: string;\n expiresInSeconds?: number;\n }): Promise<PresignedUpload> {\n return this.fail(\"presignUpload\");\n }\n\n async presignDownload(_input: PresignDownloadInput): Promise<string> {\n return this.fail(\"presignDownload\");\n }\n\n async stat(_key: string): Promise<StoredObjectInfo | null> {\n return this.fail(\"stat\");\n }\n\n async remove(_key: string): Promise<void> {\n return this.fail(\"remove\");\n }\n\n private fail(op: string): never {\n this.logger.error(`storage ${op} failed: no storage adapter configured`);\n throw new Error(\"not implemented\");\n }\n}\n"],"mappings":";;AAUA,IAAa,iBAAb,MAAqD;CACtB;CAA7B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,cAAc,QAIS;EAC3B,OAAO,KAAK,KAAK,eAAe;CAClC;CAEA,MAAM,gBAAgB,QAA+C;EACnE,OAAO,KAAK,KAAK,iBAAiB;CACpC;CAEA,MAAM,KAAK,MAAgD;EACzD,OAAO,KAAK,KAAK,MAAM;CACzB;CAEA,MAAM,OAAO,MAA6B;EACxC,OAAO,KAAK,KAAK,QAAQ;CAC3B;CAEA,KAAa,IAAmB;EAC9B,KAAK,OAAO,MAAM,WAAW,GAAG,uCAAuC;EACvE,MAAM,IAAI,MAAM,iBAAiB;CACnC;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/storage/index.ts"],"sourcesContent":["// Default when no ObjectStorage is injected — boots, fails loudly on use.\nimport type {\n Logger,\n ObjectStorage,\n PresignDownloadInput,\n PresignedUpload,\n StoredObjectInfo,\n} from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\n\nexport class StorageAdapter implements ObjectStorage {\n readonly uploadExpirySeconds = 0;\n\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async presignUpload(_input: {\n key: string;\n contentType?: string;\n expiresInSeconds?: number;\n }): Promise<PresignedUpload> {\n return this.fail(\"presignUpload\");\n }\n\n async presignDownload(_input: PresignDownloadInput): Promise<string> {\n return this.fail(\"presignDownload\");\n }\n\n async stat(_key: string): Promise<StoredObjectInfo | null> {\n return this.fail(\"stat\");\n }\n\n async list(_prefix: string): Promise<StoredObjectInfo[]> {\n return [];\n }\n\n async copy(_input: { from: string; to: string }): Promise<void> {\n return this.fail(\"copy\");\n }\n\n async remove(_key: string): Promise<void> {\n return this.fail(\"remove\");\n }\n\n private fail(op: string): never {\n this.logger.error(`storage ${op} failed: no storage adapter configured`);\n throw new Error(\"not implemented\");\n }\n}\n"],"mappings":";;AAUA,IAAa,iBAAb,MAAqD;CAGtB;CAF7B,sBAA+B;CAE/B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,cAAc,QAIS;EAC3B,OAAO,KAAK,KAAK,eAAe;CAClC;CAEA,MAAM,gBAAgB,QAA+C;EACnE,OAAO,KAAK,KAAK,iBAAiB;CACpC;CAEA,MAAM,KAAK,MAAgD;EACzD,OAAO,KAAK,KAAK,MAAM;CACzB;CAEA,MAAM,KAAK,SAA8C;EACvD,OAAO,CAAC;CACV;CAEA,MAAM,KAAK,QAAqD;EAC9D,OAAO,KAAK,KAAK,MAAM;CACzB;CAEA,MAAM,OAAO,MAA6B;EACxC,OAAO,KAAK,KAAK,QAAQ;CAC3B;CAEA,KAAa,IAAmB;EAC9B,KAAK,OAAO,MAAM,WAAW,GAAG,uCAAuC;EACvE,MAAM,IAAI,MAAM,iBAAiB;CACnC;AACF"}
@@ -1,10 +1,23 @@
1
1
  import { WebhookRequest, WebhookResponse, WebhookSender } from "@headless-lms/core/types";
2
2
  //#region src/webhooks/index.d.ts
3
+ interface FetchWebhookSenderOptions {
4
+ timeoutMs?: number;
5
+ /** Deliver to loopback and private network addresses too. Local development and testing only. */
6
+ allowPrivateNetwork?: boolean;
7
+ }
8
+ declare class WebhookAddressRefusedError extends Error {
9
+ readonly host: string;
10
+ readonly address: string;
11
+ constructor(host: string, address: string);
12
+ }
13
+ /** POSTs each delivery to the address its host resolved to once, so DNS can't swap it after the check. */
3
14
  declare class FetchWebhookSender implements WebhookSender {
4
15
  private readonly timeoutMs;
5
- constructor(timeoutMs?: number);
16
+ private readonly allowPrivateNetwork;
17
+ constructor(options?: FetchWebhookSenderOptions);
6
18
  send(request: WebhookRequest): Promise<WebhookResponse>;
19
+ private resolve;
7
20
  }
8
21
  //#endregion
9
- export { FetchWebhookSender };
22
+ export { FetchWebhookSender, FetchWebhookSenderOptions, WebhookAddressRefusedError };
10
23
  //# sourceMappingURL=index.d.ts.map
@@ -1,23 +1,79 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { request } from "node:http";
3
+ import { request as request$1 } from "node:https";
4
+ import { isIP } from "node:net";
5
+ import { isPublicIp } from "@headless-lms/core/shared/network";
1
6
  //#region src/webhooks/index.ts
2
7
  const DEFAULT_TIMEOUT_MS = 1e4;
8
+ var WebhookAddressRefusedError = class extends Error {
9
+ host;
10
+ address;
11
+ constructor(host, address) {
12
+ super(`webhook host "${host}" resolves to ${address}, which is not a public address`);
13
+ this.host = host;
14
+ this.address = address;
15
+ this.name = "WebhookAddressRefusedError";
16
+ }
17
+ };
18
+ /** POSTs each delivery to the address its host resolved to once, so DNS can't swap it after the check. */
3
19
  var FetchWebhookSender = class {
4
20
  timeoutMs;
5
- constructor(timeoutMs = DEFAULT_TIMEOUT_MS) {
6
- this.timeoutMs = timeoutMs;
21
+ allowPrivateNetwork;
22
+ constructor(options = {}) {
23
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24
+ this.allowPrivateNetwork = options.allowPrivateNetwork ?? false;
7
25
  }
8
26
  async send(request) {
9
- const response = await fetch(request.url, {
10
- method: "POST",
11
- headers: request.headers,
12
- body: request.body,
13
- redirect: "manual",
14
- signal: AbortSignal.timeout(this.timeoutMs)
15
- });
16
- await response.body?.cancel();
17
- return { status: response.status };
27
+ const url = new URL(request.url);
28
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`webhook URL protocol "${url.protocol}" is not supported`);
29
+ const signal = AbortSignal.timeout(this.timeoutMs);
30
+ const addresses = await this.resolve(url.hostname.replace(/^\[(.*)\]$/, "$1"));
31
+ signal.throwIfAborted();
32
+ return post(url, request, addresses, signal);
33
+ }
34
+ async resolve(host) {
35
+ const family = isIP(host);
36
+ const addresses = family === 0 ? await lookup(host, {
37
+ all: true,
38
+ verbatim: true
39
+ }) : [{
40
+ address: host,
41
+ family
42
+ }];
43
+ if (addresses.length === 0) throw new Error(`webhook host "${host}" did not resolve`);
44
+ if (!this.allowPrivateNetwork) {
45
+ const refused = addresses.find(({ address }) => !isPublicIp(address));
46
+ if (refused) throw new WebhookAddressRefusedError(host, refused.address);
47
+ }
48
+ return addresses;
18
49
  }
19
50
  };
51
+ function post(url, request$2, addresses, signal) {
52
+ const pinned = (_hostname, options, callback) => {
53
+ const [first] = addresses;
54
+ if (options.all) callback(null, addresses);
55
+ else callback(null, first.address, first.family);
56
+ };
57
+ const options = {
58
+ method: "POST",
59
+ headers: {
60
+ ...request$2.headers,
61
+ "content-length": Buffer.byteLength(request$2.body)
62
+ },
63
+ lookup: pinned,
64
+ signal
65
+ };
66
+ const send = url.protocol === "https:" ? request$1 : request;
67
+ return new Promise((resolve, reject) => {
68
+ const req = send(url, options, (res) => {
69
+ resolve({ status: res.statusCode ?? 0 });
70
+ res.destroy();
71
+ });
72
+ req.on("error", reject);
73
+ req.end(request$2.body);
74
+ });
75
+ }
20
76
  //#endregion
21
- export { FetchWebhookSender };
77
+ export { FetchWebhookSender, WebhookAddressRefusedError };
22
78
 
23
79
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/webhooks/index.ts"],"sourcesContent":["import type { WebhookRequest, WebhookResponse, WebhookSender } from \"@headless-lms/core/types\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\nexport class FetchWebhookSender implements WebhookSender {\n constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}\n\n async send(request: WebhookRequest): Promise<WebhookResponse> {\n const response = await fetch(request.url, {\n method: \"POST\",\n headers: request.headers,\n body: request.body,\n redirect: \"manual\",\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n await response.body?.cancel();\n return { status: response.status };\n }\n}\n"],"mappings":";AAEA,MAAM,qBAAqB;AAE3B,IAAa,qBAAb,MAAyD;CAC1B;CAA7B,YAAY,YAAqC,oBAAoB;EAAxC,KAAA,YAAA;CAAyC;CAEtE,MAAM,KAAK,SAAmD;EAC5D,MAAM,WAAW,MAAM,MAAM,QAAQ,KAAK;GACxC,QAAQ;GACR,SAAS,QAAQ;GACjB,MAAM,QAAQ;GACd,UAAU;GACV,QAAQ,YAAY,QAAQ,KAAK,SAAS;EAC5C,CAAC;EACD,MAAM,SAAS,MAAM,OAAO;EAC5B,OAAO,EAAE,QAAQ,SAAS,OAAO;CACnC;AACF"}
1
+ {"version":3,"file":"index.js","names":["request","httpsRequest","httpRequest"],"sources":["../../src/webhooks/index.ts"],"sourcesContent":["import { lookup } from \"node:dns/promises\";\nimport { request as httpRequest, type RequestOptions } from \"node:http\";\nimport { request as httpsRequest } from \"node:https\";\nimport { isIP, type LookupFunction } from \"node:net\";\nimport { isPublicIp } from \"@headless-lms/core/shared/network\";\nimport type { WebhookRequest, WebhookResponse, WebhookSender } from \"@headless-lms/core/types\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\nexport interface FetchWebhookSenderOptions {\n timeoutMs?: number;\n /** Deliver to loopback and private network addresses too. Local development and testing only. */\n allowPrivateNetwork?: boolean;\n}\n\ninterface ResolvedAddress {\n address: string;\n family: number;\n}\n\nexport class WebhookAddressRefusedError extends Error {\n constructor(\n readonly host: string,\n readonly address: string,\n ) {\n super(`webhook host \"${host}\" resolves to ${address}, which is not a public address`);\n this.name = \"WebhookAddressRefusedError\";\n }\n}\n\n/** POSTs each delivery to the address its host resolved to once, so DNS can't swap it after the check. */\nexport class FetchWebhookSender implements WebhookSender {\n private readonly timeoutMs: number;\n private readonly allowPrivateNetwork: boolean;\n\n constructor(options: FetchWebhookSenderOptions = {}) {\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.allowPrivateNetwork = options.allowPrivateNetwork ?? false;\n }\n\n async send(request: WebhookRequest): Promise<WebhookResponse> {\n const url = new URL(request.url);\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new Error(`webhook URL protocol \"${url.protocol}\" is not supported`);\n }\n const signal = AbortSignal.timeout(this.timeoutMs);\n const addresses = await this.resolve(url.hostname.replace(/^\\[(.*)\\]$/, \"$1\"));\n signal.throwIfAborted();\n return post(url, request, addresses, signal);\n }\n\n private async resolve(host: string): Promise<ResolvedAddress[]> {\n const family = isIP(host);\n const addresses =\n family === 0\n ? await lookup(host, { all: true, verbatim: true })\n : [{ address: host, family }];\n if (addresses.length === 0) {\n throw new Error(`webhook host \"${host}\" did not resolve`);\n }\n if (!this.allowPrivateNetwork) {\n const refused = addresses.find(({ address }) => !isPublicIp(address));\n if (refused) {\n throw new WebhookAddressRefusedError(host, refused.address);\n }\n }\n return addresses;\n }\n}\n\nfunction post(\n url: URL,\n request: WebhookRequest,\n addresses: ResolvedAddress[],\n signal: AbortSignal,\n): Promise<WebhookResponse> {\n const pinned: LookupFunction = (_hostname, options, callback) => {\n const [first] = addresses;\n if (options.all) {\n callback(null, addresses);\n } else {\n callback(null, first!.address, first!.family);\n }\n };\n const options: RequestOptions = {\n method: \"POST\",\n headers: { ...request.headers, \"content-length\": Buffer.byteLength(request.body) },\n lookup: pinned,\n signal,\n };\n const send = url.protocol === \"https:\" ? httpsRequest : httpRequest;\n return new Promise((resolve, reject) => {\n const req = send(url, options, (res) => {\n resolve({ status: res.statusCode ?? 0 });\n res.destroy();\n });\n req.on(\"error\", reject);\n req.end(request.body);\n });\n}\n"],"mappings":";;;;;;AAOA,MAAM,qBAAqB;AAa3B,IAAa,6BAAb,cAAgD,MAAM;CAEzC;CACA;CAFX,YACE,MACA,SACA;EACA,MAAM,iBAAiB,KAAK,gBAAgB,QAAQ,gCAAgC;EAH3E,KAAA,OAAA;EACA,KAAA,UAAA;EAGT,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,qBAAb,MAAyD;CACvD;CACA;CAEA,YAAY,UAAqC,CAAC,GAAG;EACnD,KAAK,YAAY,QAAQ,aAAa;EACtC,KAAK,sBAAsB,QAAQ,uBAAuB;CAC5D;CAEA,MAAM,KAAK,SAAmD;EAC5D,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;EAC/B,IAAI,IAAI,aAAa,WAAW,IAAI,aAAa,UAC/C,MAAM,IAAI,MAAM,yBAAyB,IAAI,SAAS,mBAAmB;EAE3E,MAAM,SAAS,YAAY,QAAQ,KAAK,SAAS;EACjD,MAAM,YAAY,MAAM,KAAK,QAAQ,IAAI,SAAS,QAAQ,cAAc,IAAI,CAAC;EAC7E,OAAO,eAAe;EACtB,OAAO,KAAK,KAAK,SAAS,WAAW,MAAM;CAC7C;CAEA,MAAc,QAAQ,MAA0C;EAC9D,MAAM,SAAS,KAAK,IAAI;EACxB,MAAM,YACJ,WAAW,IACP,MAAM,OAAO,MAAM;GAAE,KAAK;GAAM,UAAU;EAAK,CAAC,IAChD,CAAC;GAAE,SAAS;GAAM;EAAO,CAAC;EAChC,IAAI,UAAU,WAAW,GACvB,MAAM,IAAI,MAAM,iBAAiB,KAAK,kBAAkB;EAE1D,IAAI,CAAC,KAAK,qBAAqB;GAC7B,MAAM,UAAU,UAAU,MAAM,EAAE,cAAc,CAAC,WAAW,OAAO,CAAC;GACpE,IAAI,SACF,MAAM,IAAI,2BAA2B,MAAM,QAAQ,OAAO;EAE9D;EACA,OAAO;CACT;AACF;AAEA,SAAS,KACP,KACA,WACA,WACA,QAC0B;CAC1B,MAAM,UAA0B,WAAW,SAAS,aAAa;EAC/D,MAAM,CAAC,SAAS;EAChB,IAAI,QAAQ,KACV,SAAS,MAAM,SAAS;OAExB,SAAS,MAAM,MAAO,SAAS,MAAO,MAAM;CAEhD;CACA,MAAM,UAA0B;EAC9B,QAAQ;EACR,SAAS;GAAE,GAAGA,UAAQ;GAAS,kBAAkB,OAAO,WAAWA,UAAQ,IAAI;EAAE;EACjF,QAAQ;EACR;CACF;CACA,MAAM,OAAO,IAAI,aAAa,WAAWC,YAAeC;CACxD,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,MAAM,KAAK,KAAK,UAAU,QAAQ;GACtC,QAAQ,EAAE,QAAQ,IAAI,cAAc,EAAE,CAAC;GACvC,IAAI,QAAQ;EACd,CAAC;EACD,IAAI,GAAG,SAAS,MAAM;EACtB,IAAI,IAAIF,UAAQ,IAAI;CACtB,CAAC;AACH"}
@@ -1,12 +1,22 @@
1
- import { AutomationDispatch, AutomationEngine, AutomationExecutor } from "@headless-lms/core/types";
1
+ import { startScheduleLoop } from "./schedule.js";
2
+ import { RecurringJob, ScheduledJob, Scheduler } from "@headless-lms/core/shared/ports";
3
+ import { AutomationDispatch, AutomationEngine, AutomationExecutor, DeliveryDispatch, JsonValue, Logger as Logger$1 } from "@headless-lms/core/types";
2
4
  //#region src/workflows/index.d.ts
3
- declare class InlineAutomationEngine implements AutomationEngine {
5
+ declare class InlineAutomationEngine implements AutomationEngine, Scheduler {
6
+ private readonly logger?;
4
7
  private executor;
8
+ private schedule;
9
+ private readonly jobs;
10
+ constructor(logger?: Logger$1 | undefined);
5
11
  register(executor: AutomationExecutor): void;
12
+ registerJobs(handlers: ScheduledJob[], recurring: RecurringJob[]): void;
13
+ at(job: string, runAt: Date, key: string, payload: JsonValue): Promise<void>;
14
+ cancel(job: string, key: string): Promise<void>;
6
15
  dispatch(d: AutomationDispatch): Promise<void>;
16
+ deliver(d: DeliveryDispatch): Promise<void>;
7
17
  start(): Promise<void>;
8
18
  stop(): Promise<void>;
9
19
  }
10
20
  //#endregion
11
- export { InlineAutomationEngine };
21
+ export { InlineAutomationEngine, startScheduleLoop };
12
22
  //# sourceMappingURL=index.d.ts.map
@@ -1,9 +1,28 @@
1
+ import { startScheduleLoop } from "./schedule.js";
2
+ import { InlineJobs } from "./jobs.js";
3
+ import { ActionFailedError, actionFailureMessage } from "@headless-lms/core/automations";
1
4
  //#region src/workflows/index.ts
2
5
  var InlineAutomationEngine = class {
6
+ logger;
3
7
  executor;
8
+ schedule;
9
+ jobs;
10
+ constructor(logger) {
11
+ this.logger = logger;
12
+ this.jobs = new InlineJobs(logger);
13
+ }
4
14
  register(executor) {
5
15
  this.executor = executor;
6
16
  }
17
+ registerJobs(handlers, recurring) {
18
+ this.jobs.register(handlers, recurring);
19
+ }
20
+ async at(job, runAt, key, payload) {
21
+ this.jobs.at(job, runAt, key, payload);
22
+ }
23
+ async cancel(job, key) {
24
+ this.jobs.cancel(job, key);
25
+ }
7
26
  async dispatch(d) {
8
27
  if (!this.executor) throw new Error("InlineAutomationEngine.dispatch called before register()");
9
28
  const executor = this.executor;
@@ -11,21 +30,39 @@ var InlineAutomationEngine = class {
11
30
  for (let index = 0; index < d.actions.length; index++) try {
12
31
  results.push(await executor.runAction(d, index));
13
32
  } catch (err) {
14
- const message = err?.message ?? err;
33
+ if (!(err instanceof ActionFailedError)) this.logger?.error("automation action failed", {
34
+ orgId: d.orgId,
35
+ automationId: d.automationId,
36
+ runId: d.runId,
37
+ index,
38
+ err
39
+ });
15
40
  results.push({
16
41
  index,
17
42
  type: d.actions[index].type,
18
43
  status: "failed",
19
- error: String(message)
44
+ error: actionFailureMessage(err)
20
45
  });
21
46
  break;
22
47
  }
23
48
  await executor.finalize(d, results);
24
49
  }
25
- async start() {}
26
- async stop() {}
50
+ async deliver(d) {
51
+ if (!this.executor) throw new Error("InlineAutomationEngine.deliver called before register()");
52
+ await this.executor.runDelivery(d, true);
53
+ }
54
+ async start() {
55
+ this.schedule?.stop();
56
+ this.schedule = startScheduleLoop(() => this.executor, this.logger);
57
+ this.jobs.start();
58
+ }
59
+ async stop() {
60
+ this.schedule?.stop();
61
+ this.schedule = void 0;
62
+ this.jobs.stop();
63
+ }
27
64
  };
28
65
  //#endregion
29
- export { InlineAutomationEngine };
66
+ export { InlineAutomationEngine, startScheduleLoop };
30
67
 
31
68
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/workflows/index.ts"],"sourcesContent":["// Default in-process engine — runs a dispatched automation immediately, one\n// attempt per action; durable engines (Hatchet, …) are installation adapters.\nimport type {\n AutomationActionResult,\n AutomationDispatch,\n AutomationEngine,\n AutomationExecutor,\n} from \"@headless-lms/core/types\";\n\nexport class InlineAutomationEngine implements AutomationEngine {\n private executor: AutomationExecutor | undefined;\n\n register(executor: AutomationExecutor): void {\n this.executor = executor;\n }\n\n async dispatch(d: AutomationDispatch): Promise<void> {\n if (!this.executor) {\n throw new Error(\"InlineAutomationEngine.dispatch called before register()\");\n }\n const executor = this.executor;\n\n const results: AutomationActionResult[] = [];\n for (let index = 0; index < d.actions.length; index++) {\n try {\n results.push(await executor.runAction(d, index));\n } catch (err) {\n const message = (err as { message?: unknown } | undefined)?.message ?? err;\n results.push({\n index,\n type: d.actions[index]!.type,\n status: \"failed\",\n error: String(message),\n });\n break;\n }\n }\n\n await executor.finalize(d, results);\n }\n\n async start(): Promise<void> {}\n\n async stop(): Promise<void> {}\n}\n"],"mappings":";AASA,IAAa,yBAAb,MAAgE;CAC9D;CAEA,SAAS,UAAoC;EAC3C,KAAK,WAAW;CAClB;CAEA,MAAM,SAAS,GAAsC;EACnD,IAAI,CAAC,KAAK,UACR,MAAM,IAAI,MAAM,0DAA0D;EAE5E,MAAM,WAAW,KAAK;EAEtB,MAAM,UAAoC,CAAC;EAC3C,KAAK,IAAI,QAAQ,GAAG,QAAQ,EAAE,QAAQ,QAAQ,SAC5C,IAAI;GACF,QAAQ,KAAK,MAAM,SAAS,UAAU,GAAG,KAAK,CAAC;EACjD,SAAS,KAAK;GACZ,MAAM,UAAW,KAA2C,WAAW;GACvE,QAAQ,KAAK;IACX;IACA,MAAM,EAAE,QAAQ,MAAM,CAAE;IACxB,QAAQ;IACR,OAAO,OAAO,OAAO;GACvB,CAAC;GACD;EACF;EAGF,MAAM,SAAS,SAAS,GAAG,OAAO;CACpC;CAEA,MAAM,QAAuB,CAAC;CAE9B,MAAM,OAAsB,CAAC;AAC/B"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/workflows/index.ts"],"sourcesContent":["// Default in-process engine — runs a dispatched automation immediately, one\n// attempt per action; durable engines (Hatchet, …) are installation adapters.\nimport type {\n AutomationActionResult,\n AutomationDispatch,\n AutomationEngine,\n AutomationExecutor,\n DeliveryDispatch,\n JsonValue,\n Logger,\n} from \"@headless-lms/core/types\";\nimport type { RecurringJob, ScheduledJob, Scheduler } from \"@headless-lms/core/shared/ports\";\nimport { ActionFailedError, actionFailureMessage } from \"@headless-lms/core/automations\";\nimport { startScheduleLoop } from \"./schedule.js\";\nimport { InlineJobs } from \"./jobs.js\";\n\nexport { startScheduleLoop };\n\nexport class InlineAutomationEngine implements AutomationEngine, Scheduler {\n private executor: AutomationExecutor | undefined;\n private schedule: { stop(): void } | undefined;\n private readonly jobs: InlineJobs;\n\n constructor(private readonly logger?: Logger) {\n this.jobs = new InlineJobs(logger);\n }\n\n register(executor: AutomationExecutor): void {\n this.executor = executor;\n }\n\n registerJobs(handlers: ScheduledJob[], recurring: RecurringJob[]): void {\n this.jobs.register(handlers, recurring);\n }\n\n async at(job: string, runAt: Date, key: string, payload: JsonValue): Promise<void> {\n this.jobs.at(job, runAt, key, payload);\n }\n\n async cancel(job: string, key: string): Promise<void> {\n this.jobs.cancel(job, key);\n }\n\n async dispatch(d: AutomationDispatch): Promise<void> {\n if (!this.executor) {\n throw new Error(\"InlineAutomationEngine.dispatch called before register()\");\n }\n const executor = this.executor;\n\n const results: AutomationActionResult[] = [];\n for (let index = 0; index < d.actions.length; index++) {\n try {\n results.push(await executor.runAction(d, index));\n } catch (err) {\n if (!(err instanceof ActionFailedError)) {\n this.logger?.error(\"automation action failed\", {\n orgId: d.orgId,\n automationId: d.automationId,\n runId: d.runId,\n index,\n err,\n });\n }\n results.push({\n index,\n type: d.actions[index]!.type,\n status: \"failed\",\n error: actionFailureMessage(err),\n });\n break;\n }\n }\n\n await executor.finalize(d, results);\n }\n\n async deliver(d: DeliveryDispatch): Promise<void> {\n if (!this.executor) {\n throw new Error(\"InlineAutomationEngine.deliver called before register()\");\n }\n await this.executor.runDelivery(d, true);\n }\n\n async start(): Promise<void> {\n this.schedule?.stop();\n this.schedule = startScheduleLoop(() => this.executor, this.logger);\n this.jobs.start();\n }\n\n async stop(): Promise<void> {\n this.schedule?.stop();\n this.schedule = undefined;\n this.jobs.stop();\n }\n}\n"],"mappings":";;;;AAkBA,IAAa,yBAAb,MAA2E;CAK5C;CAJ7B;CACA;CACA;CAEA,YAAY,QAAkC;EAAjB,KAAA,SAAA;EAC3B,KAAK,OAAO,IAAI,WAAW,MAAM;CACnC;CAEA,SAAS,UAAoC;EAC3C,KAAK,WAAW;CAClB;CAEA,aAAa,UAA0B,WAAiC;EACtE,KAAK,KAAK,SAAS,UAAU,SAAS;CACxC;CAEA,MAAM,GAAG,KAAa,OAAa,KAAa,SAAmC;EACjF,KAAK,KAAK,GAAG,KAAK,OAAO,KAAK,OAAO;CACvC;CAEA,MAAM,OAAO,KAAa,KAA4B;EACpD,KAAK,KAAK,OAAO,KAAK,GAAG;CAC3B;CAEA,MAAM,SAAS,GAAsC;EACnD,IAAI,CAAC,KAAK,UACR,MAAM,IAAI,MAAM,0DAA0D;EAE5E,MAAM,WAAW,KAAK;EAEtB,MAAM,UAAoC,CAAC;EAC3C,KAAK,IAAI,QAAQ,GAAG,QAAQ,EAAE,QAAQ,QAAQ,SAC5C,IAAI;GACF,QAAQ,KAAK,MAAM,SAAS,UAAU,GAAG,KAAK,CAAC;EACjD,SAAS,KAAK;GACZ,IAAI,EAAE,eAAe,oBACnB,KAAK,QAAQ,MAAM,4BAA4B;IAC7C,OAAO,EAAE;IACT,cAAc,EAAE;IAChB,OAAO,EAAE;IACT;IACA;GACF,CAAC;GAEH,QAAQ,KAAK;IACX;IACA,MAAM,EAAE,QAAQ,MAAM,CAAE;IACxB,QAAQ;IACR,OAAO,qBAAqB,GAAG;GACjC,CAAC;GACD;EACF;EAGF,MAAM,SAAS,SAAS,GAAG,OAAO;CACpC;CAEA,MAAM,QAAQ,GAAoC;EAChD,IAAI,CAAC,KAAK,UACR,MAAM,IAAI,MAAM,yDAAyD;EAE3E,MAAM,KAAK,SAAS,YAAY,GAAG,IAAI;CACzC;CAEA,MAAM,QAAuB;EAC3B,KAAK,UAAU,KAAK;EACpB,KAAK,WAAW,wBAAwB,KAAK,UAAU,KAAK,MAAM;EAClE,KAAK,KAAK,MAAM;CAClB;CAEA,MAAM,OAAsB;EAC1B,KAAK,UAAU,KAAK;EACpB,KAAK,WAAW,KAAA;EAChB,KAAK,KAAK,KAAK;CACjB;AACF"}
@@ -0,0 +1,27 @@
1
+ import { RecurringJob, ScheduledJob } from "@headless-lms/core/shared/ports";
2
+ import { JsonValue, Logger as Logger$1 } from "@headless-lms/core/types";
3
+ //#region src/workflows/jobs.d.ts
4
+ declare class InlineJobs {
5
+ private readonly logger;
6
+ private handlers;
7
+ private recurring;
8
+ private readonly timers;
9
+ private loop;
10
+ private lastMinute;
11
+ private ticking;
12
+ constructor(logger?: Logger$1);
13
+ register(handlers: ScheduledJob[], recurring: RecurringJob[]): void;
14
+ at(job: string, runAt: Date, key: string, payload: JsonValue): void;
15
+ cancel(job: string, key: string): void;
16
+ start(): void;
17
+ stop(): void;
18
+ private stopLoop;
19
+ private arm;
20
+ private clear;
21
+ private tick;
22
+ private fireDue;
23
+ private fire;
24
+ }
25
+ //#endregion
26
+ export { InlineJobs };
27
+ //# sourceMappingURL=jobs.d.ts.map
@@ -0,0 +1,113 @@
1
+ import { noopLogger } from "@headless-lms/core/shared/logger";
2
+ import { cronMatches } from "@headless-lms/core/automations";
3
+ import { randomUUID } from "node:crypto";
4
+ //#region src/workflows/jobs.ts
5
+ const MINUTE_MS = 6e4;
6
+ const MAX_TIMER_MS = 2147483647;
7
+ function floorUtcMinute(at) {
8
+ const copy = new Date(at);
9
+ copy.setUTCSeconds(0, 0);
10
+ return copy.getTime();
11
+ }
12
+ function timerKey(job, key) {
13
+ return `${job}\0${key}`;
14
+ }
15
+ var InlineJobs = class {
16
+ logger;
17
+ handlers = /* @__PURE__ */ new Map();
18
+ recurring = [];
19
+ timers = /* @__PURE__ */ new Map();
20
+ loop;
21
+ lastMinute;
22
+ ticking = false;
23
+ constructor(logger = noopLogger) {
24
+ this.logger = logger;
25
+ }
26
+ register(handlers, recurring) {
27
+ this.handlers = new Map(handlers.map((handler) => [handler.job, handler]));
28
+ this.recurring = [...recurring];
29
+ }
30
+ at(job, runAt, key, payload) {
31
+ const id = timerKey(job, key);
32
+ this.clear(id);
33
+ this.arm(id, job, key, runAt.getTime(), payload);
34
+ }
35
+ cancel(job, key) {
36
+ this.clear(timerKey(job, key));
37
+ }
38
+ start() {
39
+ this.stopLoop();
40
+ this.tick();
41
+ this.loop = setInterval(() => this.tick(), MINUTE_MS);
42
+ this.loop.unref?.();
43
+ }
44
+ stop() {
45
+ this.stopLoop();
46
+ for (const timer of this.timers.values()) clearTimeout(timer);
47
+ this.timers.clear();
48
+ }
49
+ stopLoop() {
50
+ if (this.loop) clearInterval(this.loop);
51
+ this.loop = void 0;
52
+ }
53
+ arm(id, job, key, runAtMs, payload) {
54
+ const delay = Math.min(Math.max(runAtMs - Date.now(), 0), MAX_TIMER_MS);
55
+ const timer = setTimeout(() => {
56
+ if (runAtMs > Date.now()) {
57
+ this.arm(id, job, key, runAtMs, payload);
58
+ return;
59
+ }
60
+ this.timers.delete(id);
61
+ this.fire(job, key, payload);
62
+ }, delay);
63
+ timer.unref?.();
64
+ this.timers.set(id, timer);
65
+ }
66
+ clear(id) {
67
+ const timer = this.timers.get(id);
68
+ if (timer) clearTimeout(timer);
69
+ this.timers.delete(id);
70
+ }
71
+ tick() {
72
+ if (this.ticking) return;
73
+ this.ticking = true;
74
+ this.fireDue(/* @__PURE__ */ new Date()).finally(() => {
75
+ this.ticking = false;
76
+ });
77
+ }
78
+ async fireDue(now) {
79
+ const nowMs = floorUtcMinute(now);
80
+ const from = this.lastMinute === void 0 ? nowMs : this.lastMinute + MINUTE_MS;
81
+ for (let minute = from; minute <= nowMs; minute += MINUTE_MS) {
82
+ this.lastMinute = minute;
83
+ const at = new Date(minute);
84
+ for (const recurring of this.recurring) if (cronMatches(recurring.cron, at)) await this.fire(recurring.job, recurring.job, null);
85
+ }
86
+ }
87
+ async fire(job, key, payload) {
88
+ const handler = this.handlers.get(job);
89
+ if (!handler) {
90
+ this.logger.error("scheduled job has no handler", {
91
+ job,
92
+ key
93
+ });
94
+ return;
95
+ }
96
+ try {
97
+ await handler.run(payload, {
98
+ id: randomUUID(),
99
+ startedAt: /* @__PURE__ */ new Date()
100
+ });
101
+ } catch (err) {
102
+ this.logger.error("scheduled job failed", {
103
+ job,
104
+ key,
105
+ err
106
+ });
107
+ }
108
+ }
109
+ };
110
+ //#endregion
111
+ export { InlineJobs };
112
+
113
+ //# sourceMappingURL=jobs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jobs.js","names":[],"sources":["../../src/workflows/jobs.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { JsonValue, Logger } from \"@headless-lms/core/types\";\nimport type { RecurringJob, ScheduledJob } from \"@headless-lms/core/shared/ports\";\nimport { cronMatches } from \"@headless-lms/core/automations\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\n\nconst MINUTE_MS = 60_000;\nconst MAX_TIMER_MS = 2_147_483_647;\n\nfunction floorUtcMinute(at: Date): number {\n const copy = new Date(at);\n copy.setUTCSeconds(0, 0);\n return copy.getTime();\n}\n\nfunction timerKey(job: string, key: string): string {\n return `${job}\\0${key}`;\n}\n\nexport class InlineJobs {\n private handlers = new Map<string, ScheduledJob>();\n private recurring: RecurringJob[] = [];\n private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();\n private loop: ReturnType<typeof setInterval> | undefined;\n private lastMinute: number | undefined;\n private ticking = false;\n\n constructor(private readonly logger: Logger = noopLogger) {}\n\n register(handlers: ScheduledJob[], recurring: RecurringJob[]): void {\n this.handlers = new Map(handlers.map((handler) => [handler.job, handler]));\n this.recurring = [...recurring];\n }\n\n at(job: string, runAt: Date, key: string, payload: JsonValue): void {\n const id = timerKey(job, key);\n this.clear(id);\n this.arm(id, job, key, runAt.getTime(), payload);\n }\n\n cancel(job: string, key: string): void {\n this.clear(timerKey(job, key));\n }\n\n start(): void {\n this.stopLoop();\n this.tick();\n this.loop = setInterval(() => this.tick(), MINUTE_MS);\n this.loop.unref?.();\n }\n\n stop(): void {\n this.stopLoop();\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n }\n\n private stopLoop(): void {\n if (this.loop) {\n clearInterval(this.loop);\n }\n this.loop = undefined;\n }\n\n private arm(id: string, job: string, key: string, runAtMs: number, payload: JsonValue): void {\n const delay = Math.min(Math.max(runAtMs - Date.now(), 0), MAX_TIMER_MS);\n const timer = setTimeout(() => {\n if (runAtMs > Date.now()) {\n this.arm(id, job, key, runAtMs, payload);\n return;\n }\n this.timers.delete(id);\n void this.fire(job, key, payload);\n }, delay);\n timer.unref?.();\n this.timers.set(id, timer);\n }\n\n private clear(id: string): void {\n const timer = this.timers.get(id);\n if (timer) {\n clearTimeout(timer);\n }\n this.timers.delete(id);\n }\n\n private tick(): void {\n if (this.ticking) {\n return;\n }\n this.ticking = true;\n void this.fireDue(new Date()).finally(() => {\n this.ticking = false;\n });\n }\n\n private async fireDue(now: Date): Promise<void> {\n const nowMs = floorUtcMinute(now);\n const from = this.lastMinute === undefined ? nowMs : this.lastMinute + MINUTE_MS;\n for (let minute = from; minute <= nowMs; minute += MINUTE_MS) {\n this.lastMinute = minute;\n const at = new Date(minute);\n for (const recurring of this.recurring) {\n if (cronMatches(recurring.cron, at)) {\n await this.fire(recurring.job, recurring.job, null);\n }\n }\n }\n }\n\n private async fire(job: string, key: string, payload: JsonValue): Promise<void> {\n const handler = this.handlers.get(job);\n if (!handler) {\n this.logger.error(\"scheduled job has no handler\", { job, key });\n return;\n }\n try {\n await handler.run(payload, { id: randomUUID(), startedAt: new Date() });\n } catch (err) {\n this.logger.error(\"scheduled job failed\", { job, key, err });\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import { AutomationExecutor, Logger } from "@headless-lms/core/types";
2
+ //#region src/workflows/schedule.d.ts
3
+ declare function fireDueSchedules(executor: AutomationExecutor, now: Date, lastFired: Map<string, number>, logger?: Logger): Promise<boolean>;
4
+ declare function startScheduleLoop(getExecutor: () => AutomationExecutor | undefined, logger?: Logger): {
5
+ stop(): void;
6
+ };
7
+ //#endregion
8
+ export { fireDueSchedules, startScheduleLoop };
9
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1,89 @@
1
+ import { noopLogger } from "@headless-lms/core/shared/logger";
2
+ import { cronMatches } from "@headless-lms/core/automations";
3
+ //#region src/workflows/schedule.ts
4
+ const MINUTE_MS = 6e4;
5
+ function floorUtcMinute(at) {
6
+ const copy = new Date(at);
7
+ copy.setUTCSeconds(0, 0);
8
+ return copy.getTime();
9
+ }
10
+ function scheduleKey(schedule) {
11
+ return `${schedule.orgId}\0${schedule.automationId}\0${schedule.cron}`;
12
+ }
13
+ async function fireDueSchedules(executor, now, lastFired, logger = noopLogger) {
14
+ if (!executor.listSchedules || !executor.fireSchedule) return false;
15
+ const nowMs = floorUtcMinute(now);
16
+ let schedules = [];
17
+ try {
18
+ schedules = await executor.listSchedules();
19
+ } catch (err) {
20
+ logger.error("automation schedules not listed", { err });
21
+ return false;
22
+ }
23
+ const seen = /* @__PURE__ */ new Set();
24
+ for (const schedule of schedules) {
25
+ const key = scheduleKey(schedule);
26
+ seen.add(key);
27
+ const previous = lastFired.get(key);
28
+ const start = previous == null ? nowMs : previous + MINUTE_MS;
29
+ for (let minute = start; minute <= nowMs; minute += MINUTE_MS) {
30
+ const at = new Date(minute);
31
+ const context = {
32
+ orgId: schedule.orgId,
33
+ automationId: schedule.automationId,
34
+ cron: schedule.cron,
35
+ at: at.toISOString()
36
+ };
37
+ let due;
38
+ try {
39
+ due = cronMatches(schedule.cron, at);
40
+ } catch (err) {
41
+ logger.error("automation schedule not checked", {
42
+ ...context,
43
+ err
44
+ });
45
+ break;
46
+ }
47
+ if (due) try {
48
+ await executor.fireSchedule(schedule.orgId, schedule.automationId, at);
49
+ } catch (err) {
50
+ logger.error("automation schedule not fired", {
51
+ ...context,
52
+ err
53
+ });
54
+ if (!lastFired.has(key)) lastFired.set(key, minute - MINUTE_MS);
55
+ break;
56
+ }
57
+ lastFired.set(key, minute);
58
+ }
59
+ }
60
+ for (const key of lastFired.keys()) if (!seen.has(key)) lastFired.delete(key);
61
+ return true;
62
+ }
63
+ function startScheduleLoop(getExecutor, logger = noopLogger) {
64
+ const lastFired = /* @__PURE__ */ new Map();
65
+ let timer;
66
+ let running = false;
67
+ const tick = () => {
68
+ if (running) return;
69
+ const executor = getExecutor();
70
+ if (!executor) return;
71
+ running = true;
72
+ fireDueSchedules(executor, /* @__PURE__ */ new Date(), lastFired, logger).catch((err) => {
73
+ logger.error("automation schedule tick failed", { err });
74
+ }).finally(() => {
75
+ running = false;
76
+ });
77
+ };
78
+ tick();
79
+ timer = setInterval(tick, MINUTE_MS);
80
+ timer.unref?.();
81
+ return { stop() {
82
+ if (timer) clearInterval(timer);
83
+ timer = void 0;
84
+ } };
85
+ }
86
+ //#endregion
87
+ export { fireDueSchedules, startScheduleLoop };
88
+
89
+ //# sourceMappingURL=schedule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.js","names":[],"sources":["../../src/workflows/schedule.ts"],"sourcesContent":["import type { AutomationExecutor, Logger, ScheduledAutomation } from \"@headless-lms/core/types\";\nimport { cronMatches } from \"@headless-lms/core/automations\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\n\nconst MINUTE_MS = 60_000;\n\nfunction floorUtcMinute(at: Date): number {\n const copy = new Date(at);\n copy.setUTCSeconds(0, 0);\n return copy.getTime();\n}\n\nfunction scheduleKey(schedule: ScheduledAutomation): string {\n return `${schedule.orgId}\\0${schedule.automationId}\\0${schedule.cron}`;\n}\n\nexport async function fireDueSchedules(\n executor: AutomationExecutor,\n now: Date,\n lastFired: Map<string, number>,\n logger: Logger = noopLogger,\n): Promise<boolean> {\n if (!executor.listSchedules || !executor.fireSchedule) {\n return false;\n }\n const nowMs = floorUtcMinute(now);\n let schedules: ScheduledAutomation[] = [];\n try {\n schedules = await executor.listSchedules();\n } catch (err) {\n logger.error(\"automation schedules not listed\", { err });\n return false;\n }\n const seen = new Set<string>();\n for (const schedule of schedules) {\n const key = scheduleKey(schedule);\n seen.add(key);\n const previous = lastFired.get(key);\n const start = previous == null ? nowMs : previous + MINUTE_MS;\n for (let minute = start; minute <= nowMs; minute += MINUTE_MS) {\n const at = new Date(minute);\n const context = {\n orgId: schedule.orgId,\n automationId: schedule.automationId,\n cron: schedule.cron,\n at: at.toISOString(),\n };\n let due: boolean;\n try {\n due = cronMatches(schedule.cron, at);\n } catch (err) {\n logger.error(\"automation schedule not checked\", { ...context, err });\n break;\n }\n if (due) {\n try {\n await executor.fireSchedule(schedule.orgId, schedule.automationId, at);\n } catch (err) {\n logger.error(\"automation schedule not fired\", { ...context, err });\n if (!lastFired.has(key)) {\n lastFired.set(key, minute - MINUTE_MS);\n }\n break;\n }\n }\n lastFired.set(key, minute);\n }\n }\n for (const key of lastFired.keys()) {\n if (!seen.has(key)) {\n lastFired.delete(key);\n }\n }\n return true;\n}\n\nexport function startScheduleLoop(\n getExecutor: () => AutomationExecutor | undefined,\n logger: Logger = noopLogger,\n): {\n stop(): void;\n} {\n const lastFired = new Map<string, number>();\n let timer: ReturnType<typeof setInterval> | undefined;\n let running = false;\n const tick = () => {\n if (running) {\n return;\n }\n const executor = getExecutor();\n if (!executor) {\n return;\n }\n running = true;\n void fireDueSchedules(executor, new Date(), lastFired, logger)\n .catch((err: unknown) => {\n logger.error(\"automation schedule tick failed\", { err });\n })\n .finally(() => {\n running = false;\n });\n };\n tick();\n timer = setInterval(tick, MINUTE_MS);\n timer.unref?.();\n return {\n stop() {\n if (timer) {\n clearInterval(timer);\n }\n timer = undefined;\n },\n };\n}\n"],"mappings":";;;AAIA,MAAM,YAAY;AAElB,SAAS,eAAe,IAAkB;CACxC,MAAM,OAAO,IAAI,KAAK,EAAE;CACxB,KAAK,cAAc,GAAG,CAAC;CACvB,OAAO,KAAK,QAAQ;AACtB;AAEA,SAAS,YAAY,UAAuC;CAC1D,OAAO,GAAG,SAAS,MAAM,IAAI,SAAS,aAAa,IAAI,SAAS;AAClE;AAEA,eAAsB,iBACpB,UACA,KACA,WACA,SAAiB,YACC;CAClB,IAAI,CAAC,SAAS,iBAAiB,CAAC,SAAS,cACvC,OAAO;CAET,MAAM,QAAQ,eAAe,GAAG;CAChC,IAAI,YAAmC,CAAC;CACxC,IAAI;EACF,YAAY,MAAM,SAAS,cAAc;CAC3C,SAAS,KAAK;EACZ,OAAO,MAAM,mCAAmC,EAAE,IAAI,CAAC;EACvD,OAAO;CACT;CACA,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,YAAY,WAAW;EAChC,MAAM,MAAM,YAAY,QAAQ;EAChC,KAAK,IAAI,GAAG;EACZ,MAAM,WAAW,UAAU,IAAI,GAAG;EAClC,MAAM,QAAQ,YAAY,OAAO,QAAQ,WAAW;EACpD,KAAK,IAAI,SAAS,OAAO,UAAU,OAAO,UAAU,WAAW;GAC7D,MAAM,KAAK,IAAI,KAAK,MAAM;GAC1B,MAAM,UAAU;IACd,OAAO,SAAS;IAChB,cAAc,SAAS;IACvB,MAAM,SAAS;IACf,IAAI,GAAG,YAAY;GACrB;GACA,IAAI;GACJ,IAAI;IACF,MAAM,YAAY,SAAS,MAAM,EAAE;GACrC,SAAS,KAAK;IACZ,OAAO,MAAM,mCAAmC;KAAE,GAAG;KAAS;IAAI,CAAC;IACnE;GACF;GACA,IAAI,KACF,IAAI;IACF,MAAM,SAAS,aAAa,SAAS,OAAO,SAAS,cAAc,EAAE;GACvE,SAAS,KAAK;IACZ,OAAO,MAAM,iCAAiC;KAAE,GAAG;KAAS;IAAI,CAAC;IACjE,IAAI,CAAC,UAAU,IAAI,GAAG,GACpB,UAAU,IAAI,KAAK,SAAS,SAAS;IAEvC;GACF;GAEF,UAAU,IAAI,KAAK,MAAM;EAC3B;CACF;CACA,KAAK,MAAM,OAAO,UAAU,KAAK,GAC/B,IAAI,CAAC,KAAK,IAAI,GAAG,GACf,UAAU,OAAO,GAAG;CAGxB,OAAO;AACT;AAEA,SAAgB,kBACd,aACA,SAAiB,YAGjB;CACA,MAAM,4BAAY,IAAI,IAAoB;CAC1C,IAAI;CACJ,IAAI,UAAU;CACd,MAAM,aAAa;EACjB,IAAI,SACF;EAEF,MAAM,WAAW,YAAY;EAC7B,IAAI,CAAC,UACH;EAEF,UAAU;EACV,iBAAsB,0BAAU,IAAI,KAAK,GAAG,WAAW,MAAM,CAAC,CAC3D,OAAO,QAAiB;GACvB,OAAO,MAAM,mCAAmC,EAAE,IAAI,CAAC;EACzD,CAAC,CAAC,CACD,cAAc;GACb,UAAU;EACZ,CAAC;CACL;CACA,KAAK;CACL,QAAQ,YAAY,MAAM,SAAS;CACnC,MAAM,QAAQ;CACd,OAAO,EACL,OAAO;EACL,IAAI,OACF,cAAc,KAAK;EAErB,QAAQ,KAAA;CACV,EACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headless-lms/adapter-defaults",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./email": {
@@ -41,8 +41,8 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "pino": "^10.3.1",
44
- "@headless-lms/adapter-db": "0.2.0",
45
- "@headless-lms/core": "0.2.0"
44
+ "@headless-lms/adapter-db": "0.4.0",
45
+ "@headless-lms/core": "0.4.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.2",