@headless-lms/adapter-defaults 0.4.0 → 0.5.1

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.
@@ -0,0 +1,8 @@
1
+ import { BodyParser, ParsedBody } from "@headless-lms/core/shared/ports";
2
+ //#region src/body/index.d.ts
3
+ declare class PlainBodyParser implements BodyParser {
4
+ parse(body: string): ParsedBody;
5
+ }
6
+ //#endregion
7
+ export { PlainBodyParser };
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { mentionTokens } from "@headless-lms/core/discussion";
2
+ //#region src/body/index.ts
3
+ var PlainBodyParser = class {
4
+ parse(body) {
5
+ return {
6
+ text: body,
7
+ mentions: mentionTokens(body),
8
+ assetIds: []
9
+ };
10
+ }
11
+ };
12
+ //#endregion
13
+ export { PlainBodyParser };
14
+
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/body/index.ts"],"sourcesContent":["import { mentionTokens } from \"@headless-lms/core/discussion\";\nimport type { BodyParser, ParsedBody } from \"@headless-lms/core/shared/ports\";\n\nexport class PlainBodyParser implements BodyParser {\n parse(body: string): ParsedBody {\n return { text: body, mentions: mentionTokens(body), assetIds: [] };\n }\n}\n"],"mappings":";;AAGA,IAAa,kBAAb,MAAmD;CACjD,MAAM,MAA0B;EAC9B,OAAO;GAAE,MAAM;GAAM,UAAU,cAAc,IAAI;GAAG,UAAU,CAAC;EAAE;CACnE;AACF"}
@@ -10,6 +10,13 @@ declare class StubTemplateRenderer implements TemplateRenderer {
10
10
  constructor(logger?: Logger);
11
11
  render(id: EmailTemplateId): Promise<never>;
12
12
  }
13
+ declare class FileSinkEmailAdapter implements EmailSender {
14
+ private readonly dir;
15
+ private readonly logger;
16
+ private sequence;
17
+ constructor(dir: string, logger?: Logger);
18
+ send(message: EmailMessage): Promise<void>;
19
+ }
13
20
  //#endregion
14
- export { EmailAdapter, StubTemplateRenderer };
21
+ export { EmailAdapter, FileSinkEmailAdapter, StubTemplateRenderer };
15
22
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,6 @@
1
1
  import { noopLogger } from "@headless-lms/core/shared/logger";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
2
4
  //#region src/email/index.ts
3
5
  var EmailAdapter = class {
4
6
  logger;
@@ -20,7 +22,27 @@ var StubTemplateRenderer = class {
20
22
  throw new Error("no template renderer configured");
21
23
  }
22
24
  };
25
+ var FileSinkEmailAdapter = class {
26
+ dir;
27
+ logger;
28
+ sequence = 0;
29
+ constructor(dir, logger = noopLogger) {
30
+ this.dir = dir;
31
+ this.logger = logger;
32
+ }
33
+ async send(message) {
34
+ await mkdir(this.dir, { recursive: true });
35
+ const name = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${String(++this.sequence).padStart(4, "0")}`;
36
+ await writeFile(join(this.dir, `${name}.json`), JSON.stringify(message, null, 2));
37
+ if (message.html) await writeFile(join(this.dir, `${name}.html`), message.html);
38
+ this.logger.info("email written to sink", {
39
+ to: message.to,
40
+ subject: message.subject,
41
+ file: name
42
+ });
43
+ }
44
+ };
23
45
  //#endregion
24
- export { EmailAdapter, StubTemplateRenderer };
46
+ export { EmailAdapter, FileSinkEmailAdapter, StubTemplateRenderer };
25
47
 
26
48
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/email/index.ts"],"sourcesContent":["// Default when no EmailSender is injected — fails loudly on use.\nimport type {\n EmailSender,\n EmailMessage,\n EmailTemplateId,\n Logger,\n TemplateRenderer,\n} from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\n\nexport class EmailAdapter implements EmailSender {\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async send(_message: EmailMessage): Promise<void> {\n this.logger.error(\"email send failed: no transport configured\");\n throw new Error(\"not implemented\");\n }\n}\n\nexport class StubTemplateRenderer implements TemplateRenderer {\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async render(id: EmailTemplateId): Promise<never> {\n this.logger.error(\"template render failed: no renderer configured\", { id });\n throw new Error(\"no template renderer configured\");\n }\n}\n"],"mappings":";;AAUA,IAAa,eAAb,MAAiD;CAClB;CAA7B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,KAAK,UAAuC;EAChD,KAAK,OAAO,MAAM,4CAA4C;EAC9D,MAAM,IAAI,MAAM,iBAAiB;CACnC;AACF;AAEA,IAAa,uBAAb,MAA8D;CAC/B;CAA7B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,OAAO,IAAqC;EAChD,KAAK,OAAO,MAAM,kDAAkD,EAAE,GAAG,CAAC;EAC1E,MAAM,IAAI,MAAM,iCAAiC;CACnD;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/email/index.ts"],"sourcesContent":["// Default when no EmailSender is injected — fails loudly on use.\nimport type {\n EmailSender,\n EmailMessage,\n EmailTemplateId,\n Logger,\n TemplateRenderer,\n} from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nexport class EmailAdapter implements EmailSender {\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async send(_message: EmailMessage): Promise<void> {\n this.logger.error(\"email send failed: no transport configured\");\n throw new Error(\"not implemented\");\n }\n}\n\nexport class StubTemplateRenderer implements TemplateRenderer {\n constructor(private readonly logger: Logger = noopLogger) {}\n\n async render(id: EmailTemplateId): Promise<never> {\n this.logger.error(\"template render failed: no renderer configured\", { id });\n throw new Error(\"no template renderer configured\");\n }\n}\n\nexport class FileSinkEmailAdapter implements EmailSender {\n private sequence = 0;\n\n constructor(\n private readonly dir: string,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async send(message: EmailMessage): Promise<void> {\n await mkdir(this.dir, { recursive: true });\n const stamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const name = `${stamp}-${String(++this.sequence).padStart(4, \"0\")}`;\n await writeFile(join(this.dir, `${name}.json`), JSON.stringify(message, null, 2));\n if (message.html) {\n await writeFile(join(this.dir, `${name}.html`), message.html);\n }\n this.logger.info(\"email written to sink\", {\n to: message.to,\n subject: message.subject,\n file: name,\n });\n }\n}\n"],"mappings":";;;;AAYA,IAAa,eAAb,MAAiD;CAClB;CAA7B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,KAAK,UAAuC;EAChD,KAAK,OAAO,MAAM,4CAA4C;EAC9D,MAAM,IAAI,MAAM,iBAAiB;CACnC;AACF;AAEA,IAAa,uBAAb,MAA8D;CAC/B;CAA7B,YAAY,SAAkC,YAAY;EAA7B,KAAA,SAAA;CAA8B;CAE3D,MAAM,OAAO,IAAqC;EAChD,KAAK,OAAO,MAAM,kDAAkD,EAAE,GAAG,CAAC;EAC1E,MAAM,IAAI,MAAM,iCAAiC;CACnD;AACF;AAEA,IAAa,uBAAb,MAAyD;CAIpC;CACA;CAJnB,WAAmB;CAEnB,YACE,KACA,SAAkC,YAClC;EAFiB,KAAA,MAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,KAAK,SAAsC;EAC/C,MAAM,MAAM,KAAK,KAAK,EAAE,WAAW,KAAK,CAAC;EAEzC,MAAM,OAAO,oBADC,IAAI,KAAK,EAAA,CAAE,YAAY,CAAC,CAAC,QAAQ,SAAS,GACpC,EAAE,GAAG,OAAO,EAAE,KAAK,QAAQ,CAAC,CAAC,SAAS,GAAG,GAAG;EAChE,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,KAAK,MAAM,GAAG,KAAK,UAAU,SAAS,MAAM,CAAC,CAAC;EAChF,IAAI,QAAQ,MACV,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,KAAK,MAAM,GAAG,QAAQ,IAAI;EAE9D,KAAK,OAAO,KAAK,yBAAyB;GACxC,IAAI,QAAQ;GACZ,SAAS,QAAQ;GACjB,MAAM;EACR,CAAC;CACH;AACF"}
@@ -1,3 +1,4 @@
1
+ import { describeError } from "@headless-lms/core/shared/logger";
1
2
  import { OUTBOX_MAX_ATTEMPTS } from "@headless-lms/adapter-db";
2
3
  //#region src/events/outbox-relay.ts
3
4
  /** Backoff base: first retry 5s after the first failure, doubling per attempt. */
@@ -73,7 +74,7 @@ var PollingOutboxRelay = class {
73
74
  if (fetched > 0) this.logger.debug("outbox batch fetched", { count: fetched });
74
75
  await this.dispatchBatch(batch);
75
76
  } catch (err) {
76
- this.logger.error("outbox poll failed", { error: String(err) });
77
+ this.logger.error("outbox poll failed", { error: describeError(err) });
77
78
  }
78
79
  this.schedule(fetched >= this.config.batchSize ? 0 : this.config.pollIntervalMs);
79
80
  }
@@ -100,7 +101,7 @@ var PollingOutboxRelay = class {
100
101
  type: message.payload.type
101
102
  });
102
103
  } catch (err) {
103
- const error = String(err);
104
+ const error = describeError(err);
104
105
  const nextAttemptAt = new Date(Date.now() + outboxBackoffMs(message.attempts));
105
106
  try {
106
107
  await this.store.markFailed(message.id, error, nextAttemptAt);
@@ -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, 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":";;;AAoBA,MAAa,yBAAyB;;AAEtC,MAAa,wBAAwB,KAAK;;;AAI1C,SAAgB,gBAAgB,UAA0B;CACxD,OAAO,KAAK,IAAI,yBAAyB,KAAK,UAAU,qBAAqB;AAC/E;AAYA,MAAM,kBAAkB;CAAC;CAAU;CAAU;CAAc;CAAa;AAAI;AAE5E,SAAgB,YAAY,OAA4B;CACtD,MAAM,OAAO,MAAM;CACnB,IAAI,SAAS,QAAQ,OAAO,SAAS,UACnC,KAAK,MAAM,SAAS,iBAAiB;EACnC,MAAM,QAAS,KAAiC;EAChD,IAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAC9C,OAAO,GAAG,MAAM,MAAM,GAAG;CAE7B;CAEF,OAAO,GAAG,MAAM,MAAM,GAAG,MAAM;AACjC;AAEA,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;GACvB,aAAa,KAAK,OAAO;EAC3B,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,MAAM,KAAK,cAAc,KAAK;EAChC,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,cAAc,OAAuC;EACjE,MAAM,wBAAQ,IAAI,IAA6B;EAC/C,KAAK,MAAM,WAAW,OAAO;GAC3B,MAAM,MAAM,YAAY,QAAQ,OAAO;GACvC,MAAM,OAAO,MAAM,IAAI,GAAG;GAC1B,IAAI,MACF,KAAK,KAAK,OAAO;QAEjB,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC;EAE5B;EACA,MAAM,QAAQ,CAAC,GAAG,MAAM,OAAO,CAAC;EAChC,MAAM,UAAU,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,OAAO,WAAW,GAAG,MAAM,MAAM;EAC3E,MAAM,QAAQ,IACZ,MAAM,KAAK,EAAE,QAAQ,QAAQ,GAAG,YAAY;GAC1C,KAAK,IAAI,OAAO,MAAM,MAAM,GAAG,MAAM,OAAO,MAAM,MAAM,GACtD,KAAK,MAAM,WAAW,MACpB,MAAM,KAAK,SAAS,OAAO;EAGjC,CAAC,CACH;CACF;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,IAAI;IACF,MAAM,KAAK,MAAM,WAAW,QAAQ,IAAI,OAAO,aAAa;GAC9D,SAAS,SAAS;IAChB,KAAK,OAAO,MAAM,+BAA+B;KAC/C,IAAI,QAAQ;KACZ,MAAM,QAAQ,QAAQ;KACtB;KACA,WAAW,OAAO,OAAO;IAC3B,CAAC;IACD;GACF;GACA,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 { describeError } from \"@headless-lms/core/shared/logger\";\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: describeError(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 = describeError(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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"}
@@ -16,6 +16,11 @@ declare class StorageAdapter implements ObjectStorage {
16
16
  from: string;
17
17
  to: string;
18
18
  }): Promise<void>;
19
+ publish(_input: {
20
+ from: string;
21
+ to: string;
22
+ }): Promise<void>;
23
+ publicUrl(_key: string): string;
19
24
  remove(_key: string): Promise<void>;
20
25
  private fail;
21
26
  }
@@ -21,6 +21,12 @@ var StorageAdapter = class {
21
21
  async copy(_input) {
22
22
  return this.fail("copy");
23
23
  }
24
+ async publish(_input) {
25
+ return this.fail("publish");
26
+ }
27
+ publicUrl(_key) {
28
+ return this.fail("publicUrl");
29
+ }
24
30
  async remove(_key) {
25
31
  return this.fail("remove");
26
32
  }
@@ -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 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
+ {"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 publish(_input: { from: string; to: string }): Promise<void> {\n return this.fail(\"publish\");\n }\n\n publicUrl(_key: string): string {\n return this.fail(\"publicUrl\");\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,QAAQ,QAAqD;EACjE,OAAO,KAAK,KAAK,SAAS;CAC5B;CAEA,UAAU,MAAsB;EAC9B,OAAO,KAAK,KAAK,WAAW;CAC9B;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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headless-lms/adapter-defaults",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./email": {
@@ -34,6 +34,10 @@
34
34
  "./webhooks": {
35
35
  "types": "./dist/webhooks/index.d.ts",
36
36
  "default": "./dist/webhooks/index.js"
37
+ },
38
+ "./body": {
39
+ "types": "./dist/body/index.d.ts",
40
+ "default": "./dist/body/index.js"
37
41
  }
38
42
  },
39
43
  "files": [
@@ -41,8 +45,8 @@
41
45
  ],
42
46
  "dependencies": {
43
47
  "pino": "^10.3.1",
44
- "@headless-lms/adapter-db": "0.4.0",
45
- "@headless-lms/core": "0.4.0"
48
+ "@headless-lms/adapter-db": "0.6.0",
49
+ "@headless-lms/core": "0.6.0"
46
50
  },
47
51
  "devDependencies": {
48
52
  "@types/node": "^22.10.2",