@richardmcquiston01/error-log-adapter 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,8 +22,21 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CompositeErrorAdapter: () => CompositeErrorAdapter,
24
24
  ConsoleErrorAdapter: () => ConsoleErrorAdapter,
25
+ FlatFileErrorAdapter: () => FlatFileErrorAdapter,
26
+ IndexedDbErrorAdapter: () => IndexedDbErrorAdapter,
27
+ JsonFileErrorAdapter: () => JsonFileErrorAdapter,
25
28
  MongoDbErrorAdapter: () => MongoDbErrorAdapter,
29
+ MySqlErrorAdapter: () => MySqlErrorAdapter,
30
+ PostgresErrorAdapter: () => PostgresErrorAdapter,
31
+ RedisErrorAdapter: () => RedisErrorAdapter,
32
+ S3ErrorAdapter: () => S3ErrorAdapter,
33
+ SqliteErrorAdapter: () => SqliteErrorAdapter,
34
+ WebStorageErrorAdapter: () => WebStorageErrorAdapter,
26
35
  WebhookErrorAdapter: () => WebhookErrorAdapter,
36
+ buildCreateErrorLogTableSql: () => buildCreateErrorLogTableSql,
37
+ buildErrorLogTableDdl: () => buildErrorLogTableDdl,
38
+ createErrorLogStore: () => createErrorLogStore,
39
+ formatErrorLogLine: () => formatErrorLogLine,
27
40
  serializeCause: () => serializeCause,
28
41
  serializeEntry: () => serializeEntry
29
42
  });
@@ -159,12 +172,882 @@ var CompositeErrorAdapter = class {
159
172
  };
160
173
  }
161
174
  };
175
+
176
+ // src/adapters/indexeddb-error-adapter.ts
177
+ var DEFAULT_STORE_NAME = "error_log";
178
+ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
179
+ if (database.objectStoreNames.contains(storeName)) {
180
+ return;
181
+ }
182
+ database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
183
+ }
184
+ var IndexedDbErrorAdapter = class {
185
+ constructor(database, options = {}) {
186
+ this.database = database;
187
+ this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
188
+ this.prefix = options.prefix ?? "[error-log-adapter]";
189
+ }
190
+ async log(entry) {
191
+ try {
192
+ const record = {
193
+ id: crypto.randomUUID(),
194
+ ...serializeEntry(entry)
195
+ };
196
+ await this.write(record);
197
+ return { ok: true };
198
+ } catch (error) {
199
+ console.error(`${this.prefix} IndexedDbErrorAdapter:`, error);
200
+ return {
201
+ ok: false,
202
+ error: `IndexedDbErrorAdapter failed to write to object store "${this.storeName}": ${describeError(error)}`
203
+ };
204
+ }
205
+ }
206
+ /** Resolves once the transaction commits; rejects if it errors or aborts. */
207
+ write(record) {
208
+ return new Promise(
209
+ (resolve, reject) => {
210
+ const transaction = this.database.transaction(
211
+ this.storeName,
212
+ "readwrite"
213
+ );
214
+ const fail = (action) => () => reject(
215
+ new Error(
216
+ `transaction ${action}: ${transaction.error?.message ?? "no details provided"}`
217
+ )
218
+ );
219
+ transaction.oncomplete = () => resolve();
220
+ transaction.onerror = fail("failed");
221
+ transaction.onabort = fail("aborted");
222
+ transaction.objectStore(this.storeName).add(record);
223
+ }
224
+ );
225
+ }
226
+ };
227
+
228
+ // src/adapters/sqlite-error-adapter.ts
229
+ var DEFAULT_TABLE_NAME = "error_log";
230
+ var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
231
+ function assertValidTableName(tableName) {
232
+ if (!IDENTIFIER_PATTERN.test(tableName)) {
233
+ throw new RangeError(
234
+ `SqliteErrorAdapter: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
235
+ );
236
+ }
237
+ }
238
+ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
239
+ assertValidTableName(tableName);
240
+ return [
241
+ `CREATE TABLE IF NOT EXISTS ${tableName} (id TEXT PRIMARY KEY NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, cause TEXT, source TEXT, occurred_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))`,
242
+ `CREATE INDEX IF NOT EXISTS ${tableName}_occurred_at_idx ON ${tableName} (occurred_at)`
243
+ ];
244
+ }
245
+ var SqliteErrorAdapter = class {
246
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier. */
247
+ constructor(client, options = {}) {
248
+ this.client = client;
249
+ this.tableReady = null;
250
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
251
+ assertValidTableName(this.tableName);
252
+ this.createTable = options.createTable ?? true;
253
+ this.prefix = options.prefix ?? "[error-log-adapter]";
254
+ }
255
+ async log(entry) {
256
+ try {
257
+ await this.ensureTable();
258
+ const serialized = serializeEntry(entry);
259
+ await this.client.run(
260
+ `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?)`,
261
+ [
262
+ crypto.randomUUID(),
263
+ serialized.code,
264
+ serialized.message,
265
+ serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
266
+ serialized.source ?? null,
267
+ serialized.timestamp
268
+ ]
269
+ );
270
+ return { ok: true };
271
+ } catch (error) {
272
+ console.error(`${this.prefix} SqliteErrorAdapter:`, error);
273
+ return {
274
+ ok: false,
275
+ error: `SqliteErrorAdapter failed to write to table "${this.tableName}": ${describeError(error)}`
276
+ };
277
+ }
278
+ }
279
+ /** Creates the table once. A failed attempt is retried on the next log call. */
280
+ ensureTable() {
281
+ if (!this.createTable) {
282
+ return Promise.resolve();
283
+ }
284
+ if (this.tableReady === null) {
285
+ this.tableReady = this.runCreateStatements().catch(
286
+ (error) => {
287
+ this.tableReady = null;
288
+ throw error;
289
+ }
290
+ );
291
+ }
292
+ return this.tableReady;
293
+ }
294
+ async runCreateStatements() {
295
+ for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
296
+ await this.client.run(sql, []);
297
+ }
298
+ }
299
+ };
300
+
301
+ // src/adapters/web-storage-error-adapter.ts
302
+ var DEFAULT_STORAGE_KEY = "error_log";
303
+ var DEFAULT_MAX_ENTRIES = 100;
304
+ function isQuotaError(error) {
305
+ if (typeof error !== "object" || error === null) {
306
+ return false;
307
+ }
308
+ const { name, code } = error;
309
+ return typeof name === "string" && name.toLowerCase().includes("quota") || code === 22 || code === 1014;
310
+ }
311
+ var WebStorageErrorAdapter = class {
312
+ /** @throws {RangeError} if `options.maxEntries` is not a positive integer. */
313
+ constructor(storage, options = {}) {
314
+ this.storage = storage;
315
+ this.key = options.key ?? DEFAULT_STORAGE_KEY;
316
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
317
+ this.prefix = options.prefix ?? "[error-log-adapter]";
318
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
319
+ throw new RangeError(
320
+ `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
321
+ );
322
+ }
323
+ }
324
+ async log(entry) {
325
+ try {
326
+ const record = {
327
+ id: crypto.randomUUID(),
328
+ ...serializeEntry(entry)
329
+ };
330
+ this.append(record);
331
+ return { ok: true };
332
+ } catch (error) {
333
+ console.error(`${this.prefix} WebStorageErrorAdapter:`, error);
334
+ return {
335
+ ok: false,
336
+ error: `WebStorageErrorAdapter failed to write to storage key "${this.key}": ${describeError(error)}`
337
+ };
338
+ }
339
+ }
340
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
341
+ append(record) {
342
+ const records = [
343
+ ...this.readRecords(),
344
+ record
345
+ ].slice(-this.maxEntries);
346
+ for (; ; ) {
347
+ try {
348
+ this.storage.setItem(this.key, JSON.stringify(records));
349
+ return;
350
+ } catch (error) {
351
+ if (!isQuotaError(error) || records.length <= 1) {
352
+ throw error;
353
+ }
354
+ records.shift();
355
+ }
356
+ }
357
+ }
358
+ /** Returns the stored records, or none if the value is missing or unreadable. */
359
+ readRecords() {
360
+ const stored = this.storage.getItem(this.key);
361
+ if (stored === null) {
362
+ return [];
363
+ }
364
+ try {
365
+ const parsed = JSON.parse(stored);
366
+ if (Array.isArray(parsed)) {
367
+ return parsed;
368
+ }
369
+ console.error(
370
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
371
+ );
372
+ } catch (error) {
373
+ console.error(
374
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
375
+ );
376
+ }
377
+ return [];
378
+ }
379
+ };
380
+
381
+ // src/file-system.ts
382
+ async function loadNodeFileSystem() {
383
+ const moduleName = "node:fs/promises";
384
+ return await import(moduleName);
385
+ }
386
+ function parentDirectory(filePath) {
387
+ const lastSeparator = Math.max(
388
+ filePath.lastIndexOf("/"),
389
+ filePath.lastIndexOf("\\")
390
+ );
391
+ if (lastSeparator < 0) {
392
+ return null;
393
+ }
394
+ return lastSeparator === 0 ? "/" : filePath.slice(0, lastSeparator);
395
+ }
396
+ function isMissingFileError(error) {
397
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
398
+ }
399
+
400
+ // src/adapters/flat-file-error-adapter.ts
401
+ var DEFAULT_MAX_FILES = 3;
402
+ function escapeLineBreaks(text) {
403
+ return text.replace(/\r?\n/g, "\\n");
404
+ }
405
+ function describeCause(cause) {
406
+ if (typeof cause === "string") {
407
+ return cause;
408
+ }
409
+ if (typeof cause === "object" && cause !== null) {
410
+ const { name, message } = cause;
411
+ if (typeof name === "string" && typeof message === "string") {
412
+ return `${name}: ${message}`;
413
+ }
414
+ }
415
+ try {
416
+ return JSON.stringify(cause) ?? String(cause);
417
+ } catch {
418
+ return String(cause);
419
+ }
420
+ }
421
+ function formatErrorLogLine(entry) {
422
+ const source = entry.source === void 0 ? "" : `${entry.source}: `;
423
+ const cause = entry.cause === void 0 ? "" : ` (cause: ${describeCause(entry.cause)})`;
424
+ return escapeLineBreaks(
425
+ `${entry.timestamp} [${entry.code}] ${source}${entry.message}${cause}`
426
+ );
427
+ }
428
+ function assertPositiveInteger(value, name) {
429
+ if (!Number.isInteger(value) || value < 1) {
430
+ throw new RangeError(
431
+ `FlatFileErrorAdapter: ${name} must be a positive integer, received ${String(value)}.`
432
+ );
433
+ }
434
+ }
435
+ var FlatFileErrorAdapter = class {
436
+ /** @throws {RangeError} if `maxBytes` or `maxFiles` is not a positive integer. */
437
+ constructor(filePath, options = {}) {
438
+ this.filePath = filePath;
439
+ this.directoryReady = false;
440
+ this.queue = Promise.resolve();
441
+ this.fs = options.fs;
442
+ this.formatLine = options.formatLine ?? formatErrorLogLine;
443
+ this.maxBytes = options.maxBytes;
444
+ this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
445
+ this.createDirectory = options.createDirectory ?? true;
446
+ this.prefix = options.prefix ?? "[error-log-adapter]";
447
+ if (this.maxBytes !== void 0) {
448
+ assertPositiveInteger(this.maxBytes, "maxBytes");
449
+ }
450
+ assertPositiveInteger(this.maxFiles, "maxFiles");
451
+ }
452
+ log(entry) {
453
+ const result = this.queue.then(
454
+ () => this.write(entry)
455
+ );
456
+ this.queue = result.catch(() => void 0);
457
+ return result;
458
+ }
459
+ async write(entry) {
460
+ try {
461
+ const fs = await this.getFileSystem();
462
+ const formatted = this.formatLine(serializeEntry(entry));
463
+ const line = formatted.endsWith("\n") ? formatted : `${formatted}
464
+ `;
465
+ await this.prepareDirectory(fs);
466
+ await this.rotateIfNeeded(fs, new TextEncoder().encode(line).length);
467
+ await fs.appendFile(this.filePath, line);
468
+ return { ok: true };
469
+ } catch (error) {
470
+ console.error(`${this.prefix} FlatFileErrorAdapter:`, error);
471
+ return {
472
+ ok: false,
473
+ error: `FlatFileErrorAdapter failed to append to "${this.filePath}": ${describeError(error)}`
474
+ };
475
+ }
476
+ }
477
+ /** Uses the injected file system, or loads `node:fs/promises` on first use. */
478
+ async getFileSystem() {
479
+ if (this.fs === void 0) {
480
+ this.fs = await loadNodeFileSystem();
481
+ }
482
+ return this.fs;
483
+ }
484
+ async prepareDirectory(fs) {
485
+ if (!this.createDirectory || this.directoryReady) {
486
+ return;
487
+ }
488
+ const directory = parentDirectory(this.filePath);
489
+ if (directory !== null) {
490
+ await fs.mkdir(directory, { recursive: true });
491
+ }
492
+ this.directoryReady = true;
493
+ }
494
+ /**
495
+ * Moves `app.log` to `app.log.1` (shifting older files up, dropping the
496
+ * oldest) when the next line would push it past `maxBytes`. A failed rotation
497
+ * is reported, but the entry is still written so it is not lost.
498
+ */
499
+ async rotateIfNeeded(fs, lineBytes) {
500
+ if (this.maxBytes === void 0) {
501
+ return;
502
+ }
503
+ try {
504
+ let currentSize;
505
+ try {
506
+ currentSize = (await fs.stat(this.filePath)).size;
507
+ } catch (error) {
508
+ if (isMissingFileError(error)) {
509
+ return;
510
+ }
511
+ throw error;
512
+ }
513
+ if (currentSize === 0 || currentSize + lineBytes <= this.maxBytes) {
514
+ return;
515
+ }
516
+ await fs.rm(`${this.filePath}.${this.maxFiles}`, { force: true });
517
+ for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
518
+ await this.renameIfPresent(
519
+ fs,
520
+ `${this.filePath}.${index}`,
521
+ `${this.filePath}.${index + 1}`
522
+ );
523
+ }
524
+ await fs.rename(this.filePath, `${this.filePath}.1`);
525
+ } catch (error) {
526
+ console.error(
527
+ `${this.prefix} FlatFileErrorAdapter: could not rotate "${this.filePath}", so it will keep growing: ${describeError(error)}`
528
+ );
529
+ }
530
+ }
531
+ async renameIfPresent(fs, from, to) {
532
+ try {
533
+ await fs.rename(from, to);
534
+ } catch (error) {
535
+ if (!isMissingFileError(error)) {
536
+ throw error;
537
+ }
538
+ }
539
+ }
540
+ };
541
+
542
+ // src/adapters/json-file-error-adapter.ts
543
+ var DEFAULT_INDENT = 2;
544
+ var JsonFileErrorAdapter = class {
545
+ /** @throws {RangeError} if `maxEntries` or `indent` is invalid, or `maxEntries` is used outside array mode. */
546
+ constructor(filePath, options = {}) {
547
+ this.filePath = filePath;
548
+ this.directoryReady = false;
549
+ this.queue = Promise.resolve();
550
+ this.mode = options.mode ?? "ndjson";
551
+ this.maxEntries = options.maxEntries;
552
+ this.indent = options.indent ?? DEFAULT_INDENT;
553
+ this.fs = options.fs;
554
+ this.createDirectory = options.createDirectory ?? true;
555
+ this.prefix = options.prefix ?? "[error-log-adapter]";
556
+ if (this.maxEntries !== void 0) {
557
+ if (this.mode !== "array") {
558
+ throw new RangeError(
559
+ `JsonFileErrorAdapter: maxEntries only applies to mode "array", but mode is "${this.mode}". Use mode "array" or remove maxEntries.`
560
+ );
561
+ }
562
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
563
+ throw new RangeError(
564
+ `JsonFileErrorAdapter: maxEntries must be a positive integer, received ${String(this.maxEntries)}.`
565
+ );
566
+ }
567
+ }
568
+ if (!Number.isInteger(this.indent) || this.indent < 0) {
569
+ throw new RangeError(
570
+ `JsonFileErrorAdapter: indent must be a non-negative integer, received ${String(this.indent)}.`
571
+ );
572
+ }
573
+ }
574
+ log(entry) {
575
+ const result = this.queue.then(
576
+ () => this.write(entry)
577
+ );
578
+ this.queue = result.catch(() => void 0);
579
+ return result;
580
+ }
581
+ async write(entry) {
582
+ try {
583
+ const record = {
584
+ id: crypto.randomUUID(),
585
+ ...serializeEntry(entry)
586
+ };
587
+ const fs = await this.getFileSystem();
588
+ await this.prepareDirectory(fs);
589
+ if (this.mode === "ndjson") {
590
+ await fs.appendFile(this.filePath, `${JSON.stringify(record)}
591
+ `);
592
+ } else {
593
+ await this.rewriteArray(fs, record);
594
+ }
595
+ return { ok: true };
596
+ } catch (error) {
597
+ console.error(`${this.prefix} JsonFileErrorAdapter:`, error);
598
+ return {
599
+ ok: false,
600
+ error: `JsonFileErrorAdapter failed to write "${this.filePath}" in ${this.mode} mode: ${describeError(error)}`
601
+ };
602
+ }
603
+ }
604
+ async getFileSystem() {
605
+ if (this.fs === void 0) {
606
+ this.fs = await loadNodeFileSystem();
607
+ }
608
+ return this.fs;
609
+ }
610
+ async prepareDirectory(fs) {
611
+ if (!this.createDirectory || this.directoryReady) {
612
+ return;
613
+ }
614
+ const directory = parentDirectory(this.filePath);
615
+ if (directory !== null) {
616
+ await fs.mkdir(directory, { recursive: true });
617
+ }
618
+ this.directoryReady = true;
619
+ }
620
+ /**
621
+ * Adds the record to the array and replaces the file. The new content is
622
+ * written to a temporary file first and renamed into place, so a crash
623
+ * mid-write cannot leave a half-written file.
624
+ */
625
+ async rewriteArray(fs, record) {
626
+ const records = [
627
+ ...await this.readRecords(fs),
628
+ record
629
+ ];
630
+ const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
631
+ const temporaryPath = `${this.filePath}.tmp`;
632
+ await fs.writeFile(
633
+ temporaryPath,
634
+ `${JSON.stringify(kept, null, this.indent)}
635
+ `
636
+ );
637
+ await fs.rename(temporaryPath, this.filePath);
638
+ }
639
+ /**
640
+ * Returns the records already in the file. A missing or empty file has none.
641
+ * A file that is not a JSON array is moved to `<path>.corrupt` (so nothing is
642
+ * lost) and logging continues with a fresh file.
643
+ */
644
+ async readRecords(fs) {
645
+ let content;
646
+ try {
647
+ content = await fs.readFile(this.filePath, "utf8");
648
+ } catch (error) {
649
+ if (isMissingFileError(error)) {
650
+ return [];
651
+ }
652
+ throw error;
653
+ }
654
+ if (content.trim() === "") {
655
+ return [];
656
+ }
657
+ let problem;
658
+ try {
659
+ const parsed = JSON.parse(content);
660
+ if (Array.isArray(parsed)) {
661
+ return parsed;
662
+ }
663
+ problem = "did not hold a JSON array";
664
+ } catch (error) {
665
+ problem = `held invalid JSON (${describeError(error)})`;
666
+ }
667
+ const corruptPath = `${this.filePath}.corrupt`;
668
+ await fs.rename(this.filePath, corruptPath);
669
+ console.error(
670
+ `${this.prefix} JsonFileErrorAdapter: "${this.filePath}" ${problem}, so it was moved to "${corruptPath}" and a new file was started.`
671
+ );
672
+ return [];
673
+ }
674
+ };
675
+
676
+ // src/adapters/sql-error-adapter.ts
677
+ var DEFAULT_TABLE_NAME2 = "error_log";
678
+ var MAX_TABLE_NAME_LENGTH = 47;
679
+ var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
680
+ function assertValidTableName2(tableName, adapterName) {
681
+ if (!IDENTIFIER_PATTERN2.test(tableName)) {
682
+ throw new RangeError(
683
+ `${adapterName}: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
684
+ );
685
+ }
686
+ if (tableName.length > MAX_TABLE_NAME_LENGTH) {
687
+ throw new RangeError(
688
+ `${adapterName}: table name "${tableName}" is ${tableName.length} characters long, but the limit is ${MAX_TABLE_NAME_LENGTH}.`
689
+ );
690
+ }
691
+ }
692
+ function quoteIdentifier(dialect, identifier) {
693
+ return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
694
+ }
695
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
696
+ assertValidTableName2(tableName, "buildErrorLogTableDdl");
697
+ const table = quoteIdentifier(dialect, tableName);
698
+ const index = quoteIdentifier(
699
+ dialect,
700
+ `${tableName}_occurred_at_idx`
701
+ );
702
+ if (dialect === "postgres") {
703
+ return [
704
+ `CREATE TABLE IF NOT EXISTS ${table} (id UUID PRIMARY KEY, code TEXT NOT NULL, message TEXT NOT NULL, cause JSONB, source TEXT, occurred_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
705
+ `CREATE INDEX IF NOT EXISTS ${index} ON ${table} (occurred_at)`
706
+ ];
707
+ }
708
+ return [
709
+ `CREATE TABLE IF NOT EXISTS ${table} (id CHAR(36) NOT NULL PRIMARY KEY, code VARCHAR(255) NOT NULL, message TEXT NOT NULL, cause JSON NULL, source VARCHAR(255) NULL, occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX ${index} (occurred_at))`
710
+ ];
711
+ }
712
+ function toMysqlDateTime(date) {
713
+ return date.toISOString().replace("T", " ").replace("Z", "");
714
+ }
715
+ var SqlErrorAdapter = class {
716
+ constructor(client, dialect, adapterName, options) {
717
+ this.client = client;
718
+ this.dialect = dialect;
719
+ this.adapterName = adapterName;
720
+ this.tableReady = null;
721
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
722
+ assertValidTableName2(this.tableName, adapterName);
723
+ this.createTable = options.createTable ?? true;
724
+ this.prefix = options.prefix ?? "[error-log-adapter]";
725
+ }
726
+ async log(entry) {
727
+ try {
728
+ const serialized = serializeEntry(entry);
729
+ const occurredAt = new Date(serialized.timestamp);
730
+ if (Number.isNaN(occurredAt.getTime())) {
731
+ throw new RangeError(
732
+ `timestamp "${serialized.timestamp}" is not a valid ISO 8601 date`
733
+ );
734
+ }
735
+ const params = [
736
+ crypto.randomUUID(),
737
+ serialized.code,
738
+ serialized.message,
739
+ serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
740
+ serialized.source ?? null,
741
+ this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
742
+ ];
743
+ await this.ensureTable();
744
+ await this.client.query(this.insertSql(), params);
745
+ return { ok: true };
746
+ } catch (error) {
747
+ console.error(`${this.prefix} ${this.adapterName}:`, error);
748
+ return {
749
+ ok: false,
750
+ error: `${this.adapterName} failed to write to table "${this.tableName}": ${describeError(error)}`
751
+ };
752
+ }
753
+ }
754
+ insertSql() {
755
+ const placeholders = this.dialect === "postgres" ? "$1, $2, $3, $4, $5, $6" : "?, ?, ?, ?, ?, ?";
756
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders})`;
757
+ }
758
+ /** Creates the table once. A failed attempt is retried on the next log call. */
759
+ ensureTable() {
760
+ if (!this.createTable) {
761
+ return Promise.resolve();
762
+ }
763
+ if (this.tableReady === null) {
764
+ this.tableReady = this.runCreateStatements().catch(
765
+ (error) => {
766
+ this.tableReady = null;
767
+ throw error;
768
+ }
769
+ );
770
+ }
771
+ return this.tableReady;
772
+ }
773
+ async runCreateStatements() {
774
+ for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
775
+ await this.client.query(sql, []);
776
+ }
777
+ }
778
+ };
779
+ var PostgresErrorAdapter = class extends SqlErrorAdapter {
780
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
781
+ constructor(client, options = {}) {
782
+ super(client, "postgres", "PostgresErrorAdapter", options);
783
+ }
784
+ };
785
+ var MySqlErrorAdapter = class extends SqlErrorAdapter {
786
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
787
+ constructor(client, options = {}) {
788
+ super(client, "mysql", "MySqlErrorAdapter", options);
789
+ }
790
+ };
791
+
792
+ // src/adapters/redis-error-adapter.ts
793
+ var DEFAULT_REDIS_KEY = "error_log";
794
+ var RedisErrorAdapter = class {
795
+ /** @throws {RangeError} if `key` is empty, `maxEntries` is not a positive integer, or `mode` / `side` is unknown. */
796
+ constructor(client, options = {}) {
797
+ this.client = client;
798
+ this.mode = options.mode ?? "list";
799
+ this.key = options.key ?? DEFAULT_REDIS_KEY;
800
+ this.maxEntries = options.maxEntries;
801
+ this.side = options.side ?? "right";
802
+ this.prefix = options.prefix ?? "[error-log-adapter]";
803
+ if (this.mode !== "list" && this.mode !== "stream") {
804
+ throw new RangeError(
805
+ `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
806
+ );
807
+ }
808
+ if (this.side !== "left" && this.side !== "right") {
809
+ throw new RangeError(
810
+ `RedisErrorAdapter: side must be "left" or "right", received "${String(options.side)}".`
811
+ );
812
+ }
813
+ if (this.key === "") {
814
+ throw new RangeError("RedisErrorAdapter: key must not be empty.");
815
+ }
816
+ if (this.maxEntries !== void 0 && (!Number.isInteger(this.maxEntries) || this.maxEntries < 1)) {
817
+ throw new RangeError(
818
+ `RedisErrorAdapter: maxEntries must be a positive integer, received ${String(this.maxEntries)}.`
819
+ );
820
+ }
821
+ }
822
+ async log(entry) {
823
+ try {
824
+ const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
825
+ if (this.mode === "stream") {
826
+ await this.client.sendCommand(this.buildStreamCommand(record));
827
+ } else {
828
+ await this.client.sendCommand([
829
+ this.side === "left" ? "LPUSH" : "RPUSH",
830
+ this.key,
831
+ JSON.stringify(record)
832
+ ]);
833
+ await this.trimList();
834
+ }
835
+ return { ok: true };
836
+ } catch (error) {
837
+ console.error(`${this.prefix} RedisErrorAdapter:`, error);
838
+ return {
839
+ ok: false,
840
+ error: `RedisErrorAdapter failed to write to ${this.mode} "${this.key}": ${describeError(error)}`
841
+ };
842
+ }
843
+ }
844
+ buildStreamCommand(record) {
845
+ const command = ["XADD", this.key];
846
+ if (this.maxEntries !== void 0) {
847
+ command.push("MAXLEN", "=", String(this.maxEntries));
848
+ }
849
+ command.push(
850
+ "*",
851
+ "id",
852
+ record.id,
853
+ "code",
854
+ record.code,
855
+ "message",
856
+ record.message,
857
+ "timestamp",
858
+ record.timestamp
859
+ );
860
+ if (record.cause !== void 0) {
861
+ command.push("cause", JSON.stringify(record.cause));
862
+ }
863
+ if (record.source !== void 0) {
864
+ command.push("source", record.source);
865
+ }
866
+ return command;
867
+ }
868
+ /**
869
+ * Drops the oldest list entries beyond `maxEntries`. The entry is already
870
+ * stored by now, so a trim failure is reported but does not fail the call;
871
+ * the next successful write trims the list again.
872
+ */
873
+ async trimList() {
874
+ if (this.maxEntries === void 0) {
875
+ return;
876
+ }
877
+ const keep = this.maxEntries;
878
+ const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
879
+ try {
880
+ await this.client.sendCommand(["LTRIM", this.key, start, stop]);
881
+ } catch (error) {
882
+ console.error(
883
+ `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
884
+ );
885
+ }
886
+ }
887
+ };
888
+
889
+ // src/adapters/s3-error-adapter.ts
890
+ var DEFAULT_S3_PREFIX = "error_log/";
891
+ var BUFFER_LIMIT_MULTIPLIER = 10;
892
+ function datePath(date) {
893
+ const compact = date.toISOString().replace(/[-:.]/g, "");
894
+ return `${compact.slice(0, 4)}/${compact.slice(4, 6)}/${compact.slice(6, 8)}/${compact}`;
895
+ }
896
+ function normalizePrefix(prefix) {
897
+ const trimmed = prefix.replace(/^\/+/, "");
898
+ return trimmed === "" || trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
899
+ }
900
+ var S3ErrorAdapter = class {
901
+ /** @throws {RangeError} if `bucket` is empty or the batch options are invalid. */
902
+ constructor(client, bucket, options = {}) {
903
+ this.client = client;
904
+ this.bucket = bucket;
905
+ this.buffer = [];
906
+ this.timer = null;
907
+ this.flushQueue = Promise.resolve();
908
+ this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
909
+ this.batch = options.batch;
910
+ this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
911
+ this.logPrefix = options.prefix ?? "[error-log-adapter]";
912
+ if (bucket === "") {
913
+ throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
914
+ }
915
+ if (this.batch !== void 0) {
916
+ if (!Number.isInteger(this.batch.batchSize) || this.batch.batchSize < 1) {
917
+ throw new RangeError(
918
+ `S3ErrorAdapter: batch.batchSize must be a positive integer, received ${String(this.batch.batchSize)}.`
919
+ );
920
+ }
921
+ if (this.batch.maxWaitMs !== void 0 && (!Number.isFinite(this.batch.maxWaitMs) || this.batch.maxWaitMs <= 0)) {
922
+ throw new RangeError(
923
+ `S3ErrorAdapter: batch.maxWaitMs must be a positive number, received ${String(this.batch.maxWaitMs)}.`
924
+ );
925
+ }
926
+ }
927
+ }
928
+ async log(entry) {
929
+ try {
930
+ const record = {
931
+ id: crypto.randomUUID(),
932
+ ...serializeEntry(entry)
933
+ };
934
+ const occurredAt = new Date(record.timestamp);
935
+ if (Number.isNaN(occurredAt.getTime())) {
936
+ throw new RangeError(
937
+ `timestamp "${record.timestamp}" is not a valid ISO 8601 date`
938
+ );
939
+ }
940
+ if (this.batch === void 0) {
941
+ const key = `${this.keyPrefix}${datePath(occurredAt)}-${record.id}.json`;
942
+ await this.upload(key, `${JSON.stringify(record)}
943
+ `);
944
+ return { ok: true };
945
+ }
946
+ return await this.addToBatch(record, this.batch);
947
+ } catch (error) {
948
+ return this.fail(error);
949
+ }
950
+ }
951
+ /**
952
+ * Uploads any buffered entries now. Resolves ok when nothing is buffered.
953
+ * Entries from a failed upload stay buffered and are retried by the next flush.
954
+ */
955
+ flush() {
956
+ const result = this.flushQueue.then(
957
+ () => this.uploadBuffered()
958
+ );
959
+ this.flushQueue = result.catch(() => void 0);
960
+ return result;
961
+ }
962
+ async addToBatch(record, batch) {
963
+ this.buffer.push(record);
964
+ const limit = batch.batchSize * BUFFER_LIMIT_MULTIPLIER;
965
+ if (this.buffer.length > limit) {
966
+ const dropped = this.buffer.length - limit;
967
+ this.buffer = this.buffer.slice(dropped);
968
+ console.error(
969
+ `${this.logPrefix} S3ErrorAdapter: dropped the ${dropped} oldest buffered entries because uploads to bucket "${this.bucket}" keep failing and the buffer is full (${limit} entries).`
970
+ );
971
+ }
972
+ if (this.buffer.length >= batch.batchSize) {
973
+ return this.flush();
974
+ }
975
+ this.scheduleFlush(batch);
976
+ return { ok: true };
977
+ }
978
+ scheduleFlush(batch) {
979
+ if (batch.maxWaitMs === void 0 || this.timer !== null) {
980
+ return;
981
+ }
982
+ const timer = setTimeout(() => {
983
+ this.timer = null;
984
+ void this.flush();
985
+ }, batch.maxWaitMs);
986
+ timer.unref?.();
987
+ this.timer = timer;
988
+ }
989
+ async uploadBuffered() {
990
+ if (this.timer !== null) {
991
+ clearTimeout(this.timer);
992
+ this.timer = null;
993
+ }
994
+ const pending = this.buffer.slice();
995
+ if (pending.length === 0) {
996
+ return { ok: true };
997
+ }
998
+ try {
999
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1000
+ const body = pending.map((record) => JSON.stringify(record)).join("\n");
1001
+ await this.upload(key, `${body}
1002
+ `);
1003
+ const uploaded = new Set(pending);
1004
+ this.buffer = this.buffer.filter(
1005
+ (record) => !uploaded.has(record)
1006
+ );
1007
+ return { ok: true };
1008
+ } catch (error) {
1009
+ if (this.batch !== void 0) {
1010
+ this.scheduleFlush(this.batch);
1011
+ }
1012
+ return this.fail(error, pending.length);
1013
+ }
1014
+ }
1015
+ upload(key, body) {
1016
+ return this.client.putObject({
1017
+ Bucket: this.bucket,
1018
+ Key: key,
1019
+ Body: body,
1020
+ ContentType: this.contentType
1021
+ });
1022
+ }
1023
+ fail(error, bufferedCount) {
1024
+ console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
1025
+ const retry = bufferedCount === void 0 ? "" : ` (${bufferedCount} buffered ${bufferedCount === 1 ? "entry was" : "entries were"} kept for the next flush)`;
1026
+ return {
1027
+ ok: false,
1028
+ error: `S3ErrorAdapter failed to upload to bucket "${this.bucket}": ${describeError(error)}${retry}`
1029
+ };
1030
+ }
1031
+ };
162
1032
  // Annotate the CommonJS export names for ESM import in node:
163
1033
  0 && (module.exports = {
164
1034
  CompositeErrorAdapter,
165
1035
  ConsoleErrorAdapter,
1036
+ FlatFileErrorAdapter,
1037
+ IndexedDbErrorAdapter,
1038
+ JsonFileErrorAdapter,
166
1039
  MongoDbErrorAdapter,
1040
+ MySqlErrorAdapter,
1041
+ PostgresErrorAdapter,
1042
+ RedisErrorAdapter,
1043
+ S3ErrorAdapter,
1044
+ SqliteErrorAdapter,
1045
+ WebStorageErrorAdapter,
167
1046
  WebhookErrorAdapter,
1047
+ buildCreateErrorLogTableSql,
1048
+ buildErrorLogTableDdl,
1049
+ createErrorLogStore,
1050
+ formatErrorLogLine,
168
1051
  serializeCause,
169
1052
  serializeEntry
170
1053
  });