@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.cjs CHANGED
@@ -26,10 +26,15 @@ __export(index_exports, {
26
26
  IndexedDbErrorAdapter: () => IndexedDbErrorAdapter,
27
27
  JsonFileErrorAdapter: () => JsonFileErrorAdapter,
28
28
  MongoDbErrorAdapter: () => MongoDbErrorAdapter,
29
+ MySqlErrorAdapter: () => MySqlErrorAdapter,
30
+ PostgresErrorAdapter: () => PostgresErrorAdapter,
31
+ RedisErrorAdapter: () => RedisErrorAdapter,
32
+ S3ErrorAdapter: () => S3ErrorAdapter,
29
33
  SqliteErrorAdapter: () => SqliteErrorAdapter,
30
34
  WebStorageErrorAdapter: () => WebStorageErrorAdapter,
31
35
  WebhookErrorAdapter: () => WebhookErrorAdapter,
32
36
  buildCreateErrorLogTableSql: () => buildCreateErrorLogTableSql,
37
+ buildErrorLogTableDdl: () => buildErrorLogTableDdl,
33
38
  createErrorLogStore: () => createErrorLogStore,
34
39
  formatErrorLogLine: () => formatErrorLogLine,
35
40
  serializeCause: () => serializeCause,
@@ -667,6 +672,363 @@ var JsonFileErrorAdapter = class {
667
672
  return [];
668
673
  }
669
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
+ };
670
1032
  // Annotate the CommonJS export names for ESM import in node:
671
1033
  0 && (module.exports = {
672
1034
  CompositeErrorAdapter,
@@ -675,10 +1037,15 @@ var JsonFileErrorAdapter = class {
675
1037
  IndexedDbErrorAdapter,
676
1038
  JsonFileErrorAdapter,
677
1039
  MongoDbErrorAdapter,
1040
+ MySqlErrorAdapter,
1041
+ PostgresErrorAdapter,
1042
+ RedisErrorAdapter,
1043
+ S3ErrorAdapter,
678
1044
  SqliteErrorAdapter,
679
1045
  WebStorageErrorAdapter,
680
1046
  WebhookErrorAdapter,
681
1047
  buildCreateErrorLogTableSql,
1048
+ buildErrorLogTableDdl,
682
1049
  createErrorLogStore,
683
1050
  formatErrorLogLine,
684
1051
  serializeCause,