@richardmcquiston01/error-log-adapter 0.2.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.d.ts CHANGED
@@ -355,4 +355,208 @@ declare class JsonFileErrorAdapter<TCode extends string = string> implements Err
355
355
  private readRecords;
356
356
  }
357
357
 
358
- export { type CollectionLike, CompositeErrorAdapter, ConsoleErrorAdapter, type ConsoleErrorOptions, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, type FileSystemLike, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, MongoDbErrorAdapter, type MongoDbErrorOptions, type ReadWriteFileSystemLike, type SerializedErrorLogEntry, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
358
+ type SqlDialect = 'postgres' | 'mysql';
359
+ /**
360
+ * The only thing these adapters need from a database driver: run one
361
+ * parameterized statement. `pg` (`Pool` / `Client`) and `mysql2/promise`
362
+ * (`Pool` / `Connection`) both fit as they are.
363
+ */
364
+ interface SqlClientLike {
365
+ query(sql: string, params: unknown[]): Promise<unknown>;
366
+ }
367
+ interface SqlErrorOptions {
368
+ /** Table to write to. Defaults to `error_log`. Letters, digits and `_` only, up to 47 characters. */
369
+ tableName?: string;
370
+ /** Create the table (and index) on first use. Defaults to `true`. */
371
+ createTable?: boolean;
372
+ /** Text printed before failure messages. Defaults to `[error-log-adapter]`. */
373
+ prefix?: string;
374
+ }
375
+ /**
376
+ * Returns the statements that create the error log table and its index for a
377
+ * dialect. Use this to run your own migration when `createTable` is disabled.
378
+ *
379
+ * Postgres gets a native `UUID` key, `JSONB` for the cause, and `TIMESTAMPTZ`
380
+ * columns. MySQL gets a `CHAR(36)` UUID key (it has no UUID type), a `JSON`
381
+ * cause, and `DATETIME(3)` columns holding UTC times.
382
+ */
383
+ declare function buildErrorLogTableDdl(dialect: SqlDialect, tableName?: string): string[];
384
+ /** Shared by the Postgres and MySQL adapters: only the SQL text and time format differ. */
385
+ declare abstract class SqlErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
386
+ private readonly client;
387
+ private readonly dialect;
388
+ private readonly adapterName;
389
+ private readonly tableName;
390
+ private readonly createTable;
391
+ private readonly prefix;
392
+ private tableReady;
393
+ protected constructor(client: SqlClientLike, dialect: SqlDialect, adapterName: string, options: SqlErrorOptions);
394
+ log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
395
+ private insertSql;
396
+ /** Creates the table once. A failed attempt is retried on the next log call. */
397
+ private ensureTable;
398
+ private runCreateStatements;
399
+ }
400
+ /**
401
+ * Writes entries to Postgres. Pass a `pg` `Pool` or `Client`.
402
+ *
403
+ * ```ts
404
+ * const adapter = new PostgresErrorAdapter(new Pool({ connectionString }));
405
+ * ```
406
+ */
407
+ declare class PostgresErrorAdapter<TCode extends string = string> extends SqlErrorAdapter<TCode> {
408
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
409
+ constructor(client: SqlClientLike, options?: SqlErrorOptions);
410
+ }
411
+ /**
412
+ * Writes entries to MySQL or MariaDB. Pass a `mysql2/promise` `Pool` or `Connection`.
413
+ *
414
+ * ```ts
415
+ * const adapter = new MySqlErrorAdapter(mysql.createPool(connectionUri));
416
+ * ```
417
+ */
418
+ declare class MySqlErrorAdapter<TCode extends string = string> extends SqlErrorAdapter<TCode> {
419
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
420
+ constructor(client: SqlClientLike, options?: SqlErrorOptions);
421
+ }
422
+
423
+ /**
424
+ * The only thing this adapter needs from a Redis client: send one raw command
425
+ * as an array of strings. node-redis (`client.sendCommand`) already matches.
426
+ * ioredis needs a one-line wrapper:
427
+ *
428
+ * ```ts
429
+ * const client: RedisClientLike = {
430
+ * sendCommand: (args) => redis.call(args[0] as string, ...args.slice(1)),
431
+ * };
432
+ * ```
433
+ */
434
+ interface RedisClientLike {
435
+ sendCommand(args: string[]): Promise<unknown>;
436
+ }
437
+ interface RedisErrorOptions {
438
+ /**
439
+ * `list` (default) pushes each entry onto a Redis list as one JSON string.
440
+ * `stream` appends each entry to a Redis stream (`XADD`) as separate fields.
441
+ */
442
+ mode?: 'list' | 'stream';
443
+ /** Key to write to. Defaults to `error_log`. */
444
+ key?: string;
445
+ /**
446
+ * Most entries to keep. The oldest are dropped first. Unlimited by default.
447
+ * Lists are trimmed with `LTRIM` after the push; streams use an exact `MAXLEN`.
448
+ */
449
+ maxEntries?: number;
450
+ /**
451
+ * List mode only: which end to push onto. `right` (default, `RPUSH`) keeps
452
+ * the newest entry last; `left` (`LPUSH`) keeps the newest entry first.
453
+ */
454
+ side?: 'left' | 'right';
455
+ /** Text printed before failure messages. Defaults to `[error-log-adapter]`. */
456
+ prefix?: string;
457
+ }
458
+ /**
459
+ * Writes entries to Redis, either as a capped list of JSON strings or as a
460
+ * stream. Each record holds a UUID `id` plus the serialized entry. In stream
461
+ * mode `cause` and `source` fields are left out when the entry has none.
462
+ */
463
+ declare class RedisErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
464
+ private readonly client;
465
+ private readonly mode;
466
+ private readonly key;
467
+ private readonly maxEntries;
468
+ private readonly side;
469
+ private readonly prefix;
470
+ /** @throws {RangeError} if `key` is empty, `maxEntries` is not a positive integer, or `mode` / `side` is unknown. */
471
+ constructor(client: RedisClientLike, options?: RedisErrorOptions);
472
+ log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
473
+ private buildStreamCommand;
474
+ /**
475
+ * Drops the oldest list entries beyond `maxEntries`. The entry is already
476
+ * stored by now, so a trim failure is reported but does not fail the call;
477
+ * the next successful write trims the list again.
478
+ */
479
+ private trimList;
480
+ }
481
+
482
+ /** The parameters this adapter passes to `putObject`. */
483
+ interface S3PutObjectInput {
484
+ Bucket: string;
485
+ Key: string;
486
+ Body: string;
487
+ ContentType: string;
488
+ }
489
+ /**
490
+ * The only thing this adapter needs from an S3 client: `putObject`. The AWS
491
+ * SDK v3 aggregated client (`new S3({ ... })` from `@aws-sdk/client-s3`) fits as
492
+ * it is, and so does anything else that follows the S3 API, including
493
+ * DigitalOcean Spaces, Cloudflare R2 and MinIO when you point the client at
494
+ * their endpoint.
495
+ */
496
+ interface S3ClientLike {
497
+ putObject(input: S3PutObjectInput): Promise<unknown>;
498
+ }
499
+ interface S3BatchOptions {
500
+ /** Upload once this many entries are buffered. */
501
+ batchSize: number;
502
+ /**
503
+ * Also upload when the oldest buffered entry has waited this long (ms). The
504
+ * timer does not keep the process alive, so call `flush()` before exiting.
505
+ */
506
+ maxWaitMs?: number;
507
+ }
508
+ interface S3ErrorOptions {
509
+ /**
510
+ * Key prefix, for example `logs/app/`. A trailing slash is added if missing.
511
+ * Defaults to `error_log/`. Use `''` for no prefix.
512
+ */
513
+ keyPrefix?: string;
514
+ /**
515
+ * Content type of the uploaded objects. Defaults to `application/json`, or
516
+ * `application/x-ndjson` when batching.
517
+ */
518
+ contentType?: string;
519
+ /**
520
+ * Buffer entries and upload them together as one newline-delimited JSON
521
+ * object. Off by default: one object per entry.
522
+ */
523
+ batch?: S3BatchOptions;
524
+ /** Text printed before failure messages. Defaults to `[error-log-adapter]`. */
525
+ prefix?: string;
526
+ }
527
+ /**
528
+ * Stores entries as objects in an S3-compatible bucket, keyed by date and UUID:
529
+ * `error_log/2026/01/02/20260102T030405678Z-<uuid>.json`.
530
+ *
531
+ * By default each entry becomes its own JSON object and `log` resolves once the
532
+ * upload succeeds. With `batch`, entries are buffered and uploaded together as
533
+ * one `.ndjson` object; `log` then resolves ok as soon as the entry is
534
+ * buffered (it is not stored yet), except for the call that fills the batch,
535
+ * which resolves with the upload result. Call `flush()` before shutting down.
536
+ */
537
+ declare class S3ErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
538
+ private readonly client;
539
+ private readonly bucket;
540
+ private readonly keyPrefix;
541
+ private readonly contentType;
542
+ private readonly batch;
543
+ private readonly logPrefix;
544
+ private buffer;
545
+ private timer;
546
+ private flushQueue;
547
+ /** @throws {RangeError} if `bucket` is empty or the batch options are invalid. */
548
+ constructor(client: S3ClientLike, bucket: string, options?: S3ErrorOptions);
549
+ log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
550
+ /**
551
+ * Uploads any buffered entries now. Resolves ok when nothing is buffered.
552
+ * Entries from a failed upload stay buffered and are retried by the next flush.
553
+ */
554
+ flush(): Promise<ErrorLogResult>;
555
+ private addToBatch;
556
+ private scheduleFlush;
557
+ private uploadBuffered;
558
+ private upload;
559
+ private fail;
560
+ }
561
+
562
+ export { type CollectionLike, CompositeErrorAdapter, ConsoleErrorAdapter, type ConsoleErrorOptions, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, type FileSystemLike, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, MongoDbErrorAdapter, type MongoDbErrorOptions, MySqlErrorAdapter, PostgresErrorAdapter, type ReadWriteFileSystemLike, type RedisClientLike, RedisErrorAdapter, type RedisErrorOptions, type S3BatchOptions, type S3ClientLike, S3ErrorAdapter, type S3ErrorOptions, type S3PutObjectInput, type SerializedErrorLogEntry, type SqlClientLike, type SqlDialect, type SqlErrorOptions, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, buildErrorLogTableDdl, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
package/dist/index.mjs CHANGED
@@ -628,6 +628,363 @@ var JsonFileErrorAdapter = class {
628
628
  return [];
629
629
  }
630
630
  };
631
+
632
+ // src/adapters/sql-error-adapter.ts
633
+ var DEFAULT_TABLE_NAME2 = "error_log";
634
+ var MAX_TABLE_NAME_LENGTH = 47;
635
+ var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
636
+ function assertValidTableName2(tableName, adapterName) {
637
+ if (!IDENTIFIER_PATTERN2.test(tableName)) {
638
+ throw new RangeError(
639
+ `${adapterName}: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
640
+ );
641
+ }
642
+ if (tableName.length > MAX_TABLE_NAME_LENGTH) {
643
+ throw new RangeError(
644
+ `${adapterName}: table name "${tableName}" is ${tableName.length} characters long, but the limit is ${MAX_TABLE_NAME_LENGTH}.`
645
+ );
646
+ }
647
+ }
648
+ function quoteIdentifier(dialect, identifier) {
649
+ return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
650
+ }
651
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
652
+ assertValidTableName2(tableName, "buildErrorLogTableDdl");
653
+ const table = quoteIdentifier(dialect, tableName);
654
+ const index = quoteIdentifier(
655
+ dialect,
656
+ `${tableName}_occurred_at_idx`
657
+ );
658
+ if (dialect === "postgres") {
659
+ return [
660
+ `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())`,
661
+ `CREATE INDEX IF NOT EXISTS ${index} ON ${table} (occurred_at)`
662
+ ];
663
+ }
664
+ return [
665
+ `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))`
666
+ ];
667
+ }
668
+ function toMysqlDateTime(date) {
669
+ return date.toISOString().replace("T", " ").replace("Z", "");
670
+ }
671
+ var SqlErrorAdapter = class {
672
+ constructor(client, dialect, adapterName, options) {
673
+ this.client = client;
674
+ this.dialect = dialect;
675
+ this.adapterName = adapterName;
676
+ this.tableReady = null;
677
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
678
+ assertValidTableName2(this.tableName, adapterName);
679
+ this.createTable = options.createTable ?? true;
680
+ this.prefix = options.prefix ?? "[error-log-adapter]";
681
+ }
682
+ async log(entry) {
683
+ try {
684
+ const serialized = serializeEntry(entry);
685
+ const occurredAt = new Date(serialized.timestamp);
686
+ if (Number.isNaN(occurredAt.getTime())) {
687
+ throw new RangeError(
688
+ `timestamp "${serialized.timestamp}" is not a valid ISO 8601 date`
689
+ );
690
+ }
691
+ const params = [
692
+ crypto.randomUUID(),
693
+ serialized.code,
694
+ serialized.message,
695
+ serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
696
+ serialized.source ?? null,
697
+ this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
698
+ ];
699
+ await this.ensureTable();
700
+ await this.client.query(this.insertSql(), params);
701
+ return { ok: true };
702
+ } catch (error) {
703
+ console.error(`${this.prefix} ${this.adapterName}:`, error);
704
+ return {
705
+ ok: false,
706
+ error: `${this.adapterName} failed to write to table "${this.tableName}": ${describeError(error)}`
707
+ };
708
+ }
709
+ }
710
+ insertSql() {
711
+ const placeholders = this.dialect === "postgres" ? "$1, $2, $3, $4, $5, $6" : "?, ?, ?, ?, ?, ?";
712
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders})`;
713
+ }
714
+ /** Creates the table once. A failed attempt is retried on the next log call. */
715
+ ensureTable() {
716
+ if (!this.createTable) {
717
+ return Promise.resolve();
718
+ }
719
+ if (this.tableReady === null) {
720
+ this.tableReady = this.runCreateStatements().catch(
721
+ (error) => {
722
+ this.tableReady = null;
723
+ throw error;
724
+ }
725
+ );
726
+ }
727
+ return this.tableReady;
728
+ }
729
+ async runCreateStatements() {
730
+ for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
731
+ await this.client.query(sql, []);
732
+ }
733
+ }
734
+ };
735
+ var PostgresErrorAdapter = class extends SqlErrorAdapter {
736
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
737
+ constructor(client, options = {}) {
738
+ super(client, "postgres", "PostgresErrorAdapter", options);
739
+ }
740
+ };
741
+ var MySqlErrorAdapter = class extends SqlErrorAdapter {
742
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
743
+ constructor(client, options = {}) {
744
+ super(client, "mysql", "MySqlErrorAdapter", options);
745
+ }
746
+ };
747
+
748
+ // src/adapters/redis-error-adapter.ts
749
+ var DEFAULT_REDIS_KEY = "error_log";
750
+ var RedisErrorAdapter = class {
751
+ /** @throws {RangeError} if `key` is empty, `maxEntries` is not a positive integer, or `mode` / `side` is unknown. */
752
+ constructor(client, options = {}) {
753
+ this.client = client;
754
+ this.mode = options.mode ?? "list";
755
+ this.key = options.key ?? DEFAULT_REDIS_KEY;
756
+ this.maxEntries = options.maxEntries;
757
+ this.side = options.side ?? "right";
758
+ this.prefix = options.prefix ?? "[error-log-adapter]";
759
+ if (this.mode !== "list" && this.mode !== "stream") {
760
+ throw new RangeError(
761
+ `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
762
+ );
763
+ }
764
+ if (this.side !== "left" && this.side !== "right") {
765
+ throw new RangeError(
766
+ `RedisErrorAdapter: side must be "left" or "right", received "${String(options.side)}".`
767
+ );
768
+ }
769
+ if (this.key === "") {
770
+ throw new RangeError("RedisErrorAdapter: key must not be empty.");
771
+ }
772
+ if (this.maxEntries !== void 0 && (!Number.isInteger(this.maxEntries) || this.maxEntries < 1)) {
773
+ throw new RangeError(
774
+ `RedisErrorAdapter: maxEntries must be a positive integer, received ${String(this.maxEntries)}.`
775
+ );
776
+ }
777
+ }
778
+ async log(entry) {
779
+ try {
780
+ const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
781
+ if (this.mode === "stream") {
782
+ await this.client.sendCommand(this.buildStreamCommand(record));
783
+ } else {
784
+ await this.client.sendCommand([
785
+ this.side === "left" ? "LPUSH" : "RPUSH",
786
+ this.key,
787
+ JSON.stringify(record)
788
+ ]);
789
+ await this.trimList();
790
+ }
791
+ return { ok: true };
792
+ } catch (error) {
793
+ console.error(`${this.prefix} RedisErrorAdapter:`, error);
794
+ return {
795
+ ok: false,
796
+ error: `RedisErrorAdapter failed to write to ${this.mode} "${this.key}": ${describeError(error)}`
797
+ };
798
+ }
799
+ }
800
+ buildStreamCommand(record) {
801
+ const command = ["XADD", this.key];
802
+ if (this.maxEntries !== void 0) {
803
+ command.push("MAXLEN", "=", String(this.maxEntries));
804
+ }
805
+ command.push(
806
+ "*",
807
+ "id",
808
+ record.id,
809
+ "code",
810
+ record.code,
811
+ "message",
812
+ record.message,
813
+ "timestamp",
814
+ record.timestamp
815
+ );
816
+ if (record.cause !== void 0) {
817
+ command.push("cause", JSON.stringify(record.cause));
818
+ }
819
+ if (record.source !== void 0) {
820
+ command.push("source", record.source);
821
+ }
822
+ return command;
823
+ }
824
+ /**
825
+ * Drops the oldest list entries beyond `maxEntries`. The entry is already
826
+ * stored by now, so a trim failure is reported but does not fail the call;
827
+ * the next successful write trims the list again.
828
+ */
829
+ async trimList() {
830
+ if (this.maxEntries === void 0) {
831
+ return;
832
+ }
833
+ const keep = this.maxEntries;
834
+ const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
835
+ try {
836
+ await this.client.sendCommand(["LTRIM", this.key, start, stop]);
837
+ } catch (error) {
838
+ console.error(
839
+ `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
840
+ );
841
+ }
842
+ }
843
+ };
844
+
845
+ // src/adapters/s3-error-adapter.ts
846
+ var DEFAULT_S3_PREFIX = "error_log/";
847
+ var BUFFER_LIMIT_MULTIPLIER = 10;
848
+ function datePath(date) {
849
+ const compact = date.toISOString().replace(/[-:.]/g, "");
850
+ return `${compact.slice(0, 4)}/${compact.slice(4, 6)}/${compact.slice(6, 8)}/${compact}`;
851
+ }
852
+ function normalizePrefix(prefix) {
853
+ const trimmed = prefix.replace(/^\/+/, "");
854
+ return trimmed === "" || trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
855
+ }
856
+ var S3ErrorAdapter = class {
857
+ /** @throws {RangeError} if `bucket` is empty or the batch options are invalid. */
858
+ constructor(client, bucket, options = {}) {
859
+ this.client = client;
860
+ this.bucket = bucket;
861
+ this.buffer = [];
862
+ this.timer = null;
863
+ this.flushQueue = Promise.resolve();
864
+ this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
865
+ this.batch = options.batch;
866
+ this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
867
+ this.logPrefix = options.prefix ?? "[error-log-adapter]";
868
+ if (bucket === "") {
869
+ throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
870
+ }
871
+ if (this.batch !== void 0) {
872
+ if (!Number.isInteger(this.batch.batchSize) || this.batch.batchSize < 1) {
873
+ throw new RangeError(
874
+ `S3ErrorAdapter: batch.batchSize must be a positive integer, received ${String(this.batch.batchSize)}.`
875
+ );
876
+ }
877
+ if (this.batch.maxWaitMs !== void 0 && (!Number.isFinite(this.batch.maxWaitMs) || this.batch.maxWaitMs <= 0)) {
878
+ throw new RangeError(
879
+ `S3ErrorAdapter: batch.maxWaitMs must be a positive number, received ${String(this.batch.maxWaitMs)}.`
880
+ );
881
+ }
882
+ }
883
+ }
884
+ async log(entry) {
885
+ try {
886
+ const record = {
887
+ id: crypto.randomUUID(),
888
+ ...serializeEntry(entry)
889
+ };
890
+ const occurredAt = new Date(record.timestamp);
891
+ if (Number.isNaN(occurredAt.getTime())) {
892
+ throw new RangeError(
893
+ `timestamp "${record.timestamp}" is not a valid ISO 8601 date`
894
+ );
895
+ }
896
+ if (this.batch === void 0) {
897
+ const key = `${this.keyPrefix}${datePath(occurredAt)}-${record.id}.json`;
898
+ await this.upload(key, `${JSON.stringify(record)}
899
+ `);
900
+ return { ok: true };
901
+ }
902
+ return await this.addToBatch(record, this.batch);
903
+ } catch (error) {
904
+ return this.fail(error);
905
+ }
906
+ }
907
+ /**
908
+ * Uploads any buffered entries now. Resolves ok when nothing is buffered.
909
+ * Entries from a failed upload stay buffered and are retried by the next flush.
910
+ */
911
+ flush() {
912
+ const result = this.flushQueue.then(
913
+ () => this.uploadBuffered()
914
+ );
915
+ this.flushQueue = result.catch(() => void 0);
916
+ return result;
917
+ }
918
+ async addToBatch(record, batch) {
919
+ this.buffer.push(record);
920
+ const limit = batch.batchSize * BUFFER_LIMIT_MULTIPLIER;
921
+ if (this.buffer.length > limit) {
922
+ const dropped = this.buffer.length - limit;
923
+ this.buffer = this.buffer.slice(dropped);
924
+ console.error(
925
+ `${this.logPrefix} S3ErrorAdapter: dropped the ${dropped} oldest buffered entries because uploads to bucket "${this.bucket}" keep failing and the buffer is full (${limit} entries).`
926
+ );
927
+ }
928
+ if (this.buffer.length >= batch.batchSize) {
929
+ return this.flush();
930
+ }
931
+ this.scheduleFlush(batch);
932
+ return { ok: true };
933
+ }
934
+ scheduleFlush(batch) {
935
+ if (batch.maxWaitMs === void 0 || this.timer !== null) {
936
+ return;
937
+ }
938
+ const timer = setTimeout(() => {
939
+ this.timer = null;
940
+ void this.flush();
941
+ }, batch.maxWaitMs);
942
+ timer.unref?.();
943
+ this.timer = timer;
944
+ }
945
+ async uploadBuffered() {
946
+ if (this.timer !== null) {
947
+ clearTimeout(this.timer);
948
+ this.timer = null;
949
+ }
950
+ const pending = this.buffer.slice();
951
+ if (pending.length === 0) {
952
+ return { ok: true };
953
+ }
954
+ try {
955
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
956
+ const body = pending.map((record) => JSON.stringify(record)).join("\n");
957
+ await this.upload(key, `${body}
958
+ `);
959
+ const uploaded = new Set(pending);
960
+ this.buffer = this.buffer.filter(
961
+ (record) => !uploaded.has(record)
962
+ );
963
+ return { ok: true };
964
+ } catch (error) {
965
+ if (this.batch !== void 0) {
966
+ this.scheduleFlush(this.batch);
967
+ }
968
+ return this.fail(error, pending.length);
969
+ }
970
+ }
971
+ upload(key, body) {
972
+ return this.client.putObject({
973
+ Bucket: this.bucket,
974
+ Key: key,
975
+ Body: body,
976
+ ContentType: this.contentType
977
+ });
978
+ }
979
+ fail(error, bufferedCount) {
980
+ console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
981
+ const retry = bufferedCount === void 0 ? "" : ` (${bufferedCount} buffered ${bufferedCount === 1 ? "entry was" : "entries were"} kept for the next flush)`;
982
+ return {
983
+ ok: false,
984
+ error: `S3ErrorAdapter failed to upload to bucket "${this.bucket}": ${describeError(error)}${retry}`
985
+ };
986
+ }
987
+ };
631
988
  export {
632
989
  CompositeErrorAdapter,
633
990
  ConsoleErrorAdapter,
@@ -635,10 +992,15 @@ export {
635
992
  IndexedDbErrorAdapter,
636
993
  JsonFileErrorAdapter,
637
994
  MongoDbErrorAdapter,
995
+ MySqlErrorAdapter,
996
+ PostgresErrorAdapter,
997
+ RedisErrorAdapter,
998
+ S3ErrorAdapter,
638
999
  SqliteErrorAdapter,
639
1000
  WebStorageErrorAdapter,
640
1001
  WebhookErrorAdapter,
641
1002
  buildCreateErrorLogTableSql,
1003
+ buildErrorLogTableDdl,
642
1004
  createErrorLogStore,
643
1005
  formatErrorLogLine,
644
1006
  serializeCause,