@richardmcquiston01/error-log-adapter 0.10.0 → 0.11.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/README.md +2 -2
- package/dist/index.cjs +88 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -8
- package/dist/index.d.ts +10 -8
- package/dist/index.mjs +88 -60
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ Every adapter takes an optional `prefix` (default `[error-log-adapter]`) that is
|
|
|
84
84
|
| `FirestoreErrorAdapter(collection)` | A Firestore collection | Node, browser | `timeoutMs`, `expireAfterMs` |
|
|
85
85
|
| `RealtimeDatabaseErrorAdapter(ref)` | A Realtime Database reference | Node, browser | `timeoutMs` |
|
|
86
86
|
| `FlatFileErrorAdapter(path)` | One text line per entry | Node, Bun | `formatLine`, `maxBytes`, `maxFiles` |
|
|
87
|
-
| `JsonFileErrorAdapter(path)` | NDJSON lines or one JSON array | Node, Bun | `mode`, `maxEntries`, `indent`
|
|
87
|
+
| `JsonFileErrorAdapter(path)` | NDJSON lines or one JSON array | Node, Bun | `mode`, `maxBytes`, `maxFiles`, `maxEntries`, `indent` |
|
|
88
88
|
| `IndexedDbErrorAdapter(database)` | An IndexedDB object store | Browser | `storeName`, `timeoutMs` |
|
|
89
89
|
| `WebStorageErrorAdapter(storage)` | `sessionStorage` or `localStorage`, as one capped array | Browser | `key`, `maxEntries`, `locks` |
|
|
90
90
|
| `EventEmitterErrorAdapter(emitter)` | An event on an emitter you own (in-process hooks) | Anywhere | `eventName`, `requireListener` |
|
|
@@ -100,7 +100,7 @@ What changes durability or size, by adapter:
|
|
|
100
100
|
- **SQL adapters** (`createTable`): the table is created on first use by default. Set `createTable: false` and run the statements from `buildErrorLogTableDdl('postgres' | 'mysql')` (or `buildCreateErrorLogTableSql()` for SQLite) yourself if the application's database user should not change the schema. Table names are checked to be plain identifiers. A MySQL table made by 0.4.0 or earlier has `VARCHAR(255)` columns for `code` and `source`; run the statement from `buildMySqlWidenColumnsSql()` once to widen them.
|
|
101
101
|
- **Supabase**: create the table once with `buildSupabaseSetupSql()`, which also turns on row level security. Log from a server with the service role key, which bypasses RLS and must never reach a browser. A browser-held key needs an insert policy: pass `insertRoles: ['authenticated']` to the same function. The policy limits the size of each field but cannot tie a row to a user, so a holder of that key can still add entries of their own. Listing `anon` or `public` throws unless you also pass `allowPublicInsert: true`, because the anon key is public.
|
|
102
102
|
- **S3** (`batch`): with `batch: { batchSize, maxWaitMs }`, entries are buffered and uploaded together as one NDJSON object. `log()` then returns `ok` as soon as the entry is buffered, so call `flush()` before the process exits.
|
|
103
|
-
- **Files** (`maxBytes` / `maxFiles`, `maxEntries`): the flat file adapter
|
|
103
|
+
- **Files** (`maxBytes` / `maxFiles`, `maxEntries`): the flat file adapter and the JSON file adapter's `ndjson` mode rotate by size: before a write that would push the file past `maxBytes`, `errors.log` becomes `errors.log.1` (older files shift up, and the one past `maxFiles`, default 3, is deleted). Every file holds whole lines, an entry is never split across files, and a failed rotation is reported to the console but the entry is still written. Rotation assumes one process writes the file. The JSON file adapter's `array` mode instead keeps the newest `maxEntries`, and rejects `maxBytes` / `maxFiles`.
|
|
104
104
|
- **Redis and Web Storage** (`maxEntries`): the oldest entries are dropped past the cap.
|
|
105
105
|
- **Browser storage**: `sessionStorage` belongs to one tab. `localStorage` is shared by every tab and has no atomic update, so two tabs logging at the same instant can overwrite each other's entry. `WebStorageErrorAdapter` takes a `navigator.locks` lock around each write, which reduces the loss but cannot remove it, since tabs in separate processes can still read a stale copy. Use `IndexedDbErrorAdapter` when every entry matters. Create its object store from your `onupgradeneeded` handler with `createErrorLogStore`. A stored value the adapter cannot read is copied to `<key>.corrupt` before it is replaced.
|
|
106
106
|
- **Firestore expiry**: pass `expireAfterMs` (for example `30 * 24 * 60 * 60 * 1000`) and every document gets a Firestore timestamp field, `expire_at` (change the name with `expireField`), set to the entry's `timestamp` plus that time. This only writes the field: Firestore deletes nothing until you enable a TTL policy on it, in the console or with `gcloud firestore fields ttls update expire_at --collection-group=error_log --enable-ttl`. Documents are then removed some time after they expire (typically within a day), and the deletes are billed like any other. The Realtime Database has no TTL feature, so the option is Firestore only.
|
package/dist/index.cjs
CHANGED
|
@@ -973,8 +973,55 @@ function isMissingFileError(error) {
|
|
|
973
973
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
974
974
|
}
|
|
975
975
|
|
|
976
|
-
// src/
|
|
976
|
+
// src/file-rotation.ts
|
|
977
977
|
var DEFAULT_MAX_FILES = 3;
|
|
978
|
+
function assertPositiveInteger(adapterName, value, name) {
|
|
979
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
980
|
+
throw new RangeError(
|
|
981
|
+
`${adapterName}: ${name} must be a positive integer, received ${String(value)}.`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async function renameIfPresent(fs, from, to) {
|
|
986
|
+
try {
|
|
987
|
+
await fs.rename(from, to);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
if (!isMissingFileError(error)) {
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async function rotateIfNeeded(fs, filePath, writeBytes, settings, report) {
|
|
995
|
+
try {
|
|
996
|
+
let currentSize;
|
|
997
|
+
try {
|
|
998
|
+
currentSize = (await fs.stat(filePath)).size;
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (isMissingFileError(error)) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
if (currentSize === 0 || currentSize + writeBytes <= settings.maxBytes) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
await fs.rm(`${filePath}.${settings.maxFiles}`, { force: true });
|
|
1009
|
+
for (let index = settings.maxFiles - 1; index >= 1; index -= 1) {
|
|
1010
|
+
await renameIfPresent(
|
|
1011
|
+
fs,
|
|
1012
|
+
`${filePath}.${index}`,
|
|
1013
|
+
`${filePath}.${index + 1}`
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
await fs.rename(filePath, `${filePath}.1`);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
console.error(
|
|
1019
|
+
`${report.prefix} ${report.adapterName}: could not rotate "${filePath}", so it will keep growing: ${describeError(error)}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/adapters/flat-file-error-adapter.ts
|
|
978
1025
|
function escapeLineBreaks(text) {
|
|
979
1026
|
return text.replace(/\r?\n/g, "\\n");
|
|
980
1027
|
}
|
|
@@ -1001,13 +1048,6 @@ function formatErrorLogLine(entry) {
|
|
|
1001
1048
|
`${entry.timestamp} [${entry.code}] ${source}${entry.message}${cause}`
|
|
1002
1049
|
);
|
|
1003
1050
|
}
|
|
1004
|
-
function assertPositiveInteger(value, name) {
|
|
1005
|
-
if (!Number.isInteger(value) || value < 1) {
|
|
1006
|
-
throw new RangeError(
|
|
1007
|
-
`FlatFileErrorAdapter: ${name} must be a positive integer, received ${String(value)}.`
|
|
1008
|
-
);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
1051
|
var FlatFileErrorAdapter = class {
|
|
1012
1052
|
/** @throws {RangeError} if `maxBytes` or `maxFiles` is not a positive integer. */
|
|
1013
1053
|
constructor(filePath, options = {}) {
|
|
@@ -1021,9 +1061,9 @@ var FlatFileErrorAdapter = class {
|
|
|
1021
1061
|
this.createDirectory = options.createDirectory ?? true;
|
|
1022
1062
|
this.prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
1023
1063
|
if (this.maxBytes !== void 0) {
|
|
1024
|
-
assertPositiveInteger(this.maxBytes, "maxBytes");
|
|
1064
|
+
assertPositiveInteger("FlatFileErrorAdapter", this.maxBytes, "maxBytes");
|
|
1025
1065
|
}
|
|
1026
|
-
assertPositiveInteger(this.maxFiles, "maxFiles");
|
|
1066
|
+
assertPositiveInteger("FlatFileErrorAdapter", this.maxFiles, "maxFiles");
|
|
1027
1067
|
}
|
|
1028
1068
|
log(entry) {
|
|
1029
1069
|
return this.queue.run(() => this.write(entry));
|
|
@@ -1035,7 +1075,15 @@ var FlatFileErrorAdapter = class {
|
|
|
1035
1075
|
const line = formatted.endsWith("\n") ? formatted : `${formatted}
|
|
1036
1076
|
`;
|
|
1037
1077
|
await this.prepareDirectory(fs);
|
|
1038
|
-
|
|
1078
|
+
if (this.maxBytes !== void 0) {
|
|
1079
|
+
await rotateIfNeeded(
|
|
1080
|
+
fs,
|
|
1081
|
+
this.filePath,
|
|
1082
|
+
new TextEncoder().encode(line).length,
|
|
1083
|
+
{ maxBytes: this.maxBytes, maxFiles: this.maxFiles },
|
|
1084
|
+
{ adapterName: "FlatFileErrorAdapter", prefix: this.prefix }
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1039
1087
|
await fs.appendFile(this.filePath, line);
|
|
1040
1088
|
return { ok: true };
|
|
1041
1089
|
} catch (error) {
|
|
@@ -1064,64 +1112,20 @@ var FlatFileErrorAdapter = class {
|
|
|
1064
1112
|
}
|
|
1065
1113
|
this.directoryReady = true;
|
|
1066
1114
|
}
|
|
1067
|
-
/**
|
|
1068
|
-
* Moves `app.log` to `app.log.1` (shifting older files up, dropping the
|
|
1069
|
-
* oldest) when the next line would push it past `maxBytes`. A failed rotation
|
|
1070
|
-
* is reported, but the entry is still written so it is not lost.
|
|
1071
|
-
*/
|
|
1072
|
-
async rotateIfNeeded(fs, lineBytes) {
|
|
1073
|
-
if (this.maxBytes === void 0) {
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
try {
|
|
1077
|
-
let currentSize;
|
|
1078
|
-
try {
|
|
1079
|
-
currentSize = (await fs.stat(this.filePath)).size;
|
|
1080
|
-
} catch (error) {
|
|
1081
|
-
if (isMissingFileError(error)) {
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
throw error;
|
|
1085
|
-
}
|
|
1086
|
-
if (currentSize === 0 || currentSize + lineBytes <= this.maxBytes) {
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
await fs.rm(`${this.filePath}.${this.maxFiles}`, { force: true });
|
|
1090
|
-
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
|
1091
|
-
await this.renameIfPresent(
|
|
1092
|
-
fs,
|
|
1093
|
-
`${this.filePath}.${index}`,
|
|
1094
|
-
`${this.filePath}.${index + 1}`
|
|
1095
|
-
);
|
|
1096
|
-
}
|
|
1097
|
-
await fs.rename(this.filePath, `${this.filePath}.1`);
|
|
1098
|
-
} catch (error) {
|
|
1099
|
-
console.error(
|
|
1100
|
-
`${this.prefix} FlatFileErrorAdapter: could not rotate "${this.filePath}", so it will keep growing: ${describeError(error)}`
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
async renameIfPresent(fs, from, to) {
|
|
1105
|
-
try {
|
|
1106
|
-
await fs.rename(from, to);
|
|
1107
|
-
} catch (error) {
|
|
1108
|
-
if (!isMissingFileError(error)) {
|
|
1109
|
-
throw error;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
1115
|
};
|
|
1114
1116
|
|
|
1115
1117
|
// src/adapters/json-file-error-adapter.ts
|
|
1116
1118
|
var DEFAULT_INDENT = 2;
|
|
1117
1119
|
var JsonFileErrorAdapter = class {
|
|
1118
|
-
/** @throws {RangeError} if `maxEntries` or `indent` is invalid, or
|
|
1120
|
+
/** @throws {RangeError} if `maxEntries`, `maxBytes`, `maxFiles` or `indent` is invalid, or an option is used in the wrong mode. */
|
|
1119
1121
|
constructor(filePath, options = {}) {
|
|
1120
1122
|
this.filePath = filePath;
|
|
1121
1123
|
this.directoryReady = false;
|
|
1122
1124
|
this.queue = new SerialQueue();
|
|
1123
1125
|
this.mode = options.mode ?? "ndjson";
|
|
1124
1126
|
this.maxEntries = options.maxEntries;
|
|
1127
|
+
this.maxBytes = options.maxBytes;
|
|
1128
|
+
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
1125
1129
|
this.indent = options.indent ?? DEFAULT_INDENT;
|
|
1126
1130
|
this.fs = options.fs;
|
|
1127
1131
|
this.createDirectory = options.createDirectory ?? true;
|
|
@@ -1138,6 +1142,20 @@ var JsonFileErrorAdapter = class {
|
|
|
1138
1142
|
);
|
|
1139
1143
|
}
|
|
1140
1144
|
}
|
|
1145
|
+
if (this.maxBytes !== void 0 || options.maxFiles !== void 0) {
|
|
1146
|
+
if (this.mode !== "ndjson") {
|
|
1147
|
+
throw new RangeError(
|
|
1148
|
+
`JsonFileErrorAdapter: maxBytes and maxFiles only apply to mode "ndjson", but mode is "${this.mode}". Use mode "ndjson", or use maxEntries to limit array mode.`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
if (this.maxBytes === void 0) {
|
|
1152
|
+
throw new RangeError(
|
|
1153
|
+
"JsonFileErrorAdapter: maxFiles needs maxBytes; set maxBytes to turn rotation on."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
assertPositiveInteger("JsonFileErrorAdapter", this.maxBytes, "maxBytes");
|
|
1157
|
+
assertPositiveInteger("JsonFileErrorAdapter", this.maxFiles, "maxFiles");
|
|
1158
|
+
}
|
|
1141
1159
|
if (!Number.isInteger(this.indent) || this.indent < 0) {
|
|
1142
1160
|
throw new RangeError(
|
|
1143
1161
|
`JsonFileErrorAdapter: indent must be a non-negative integer, received ${String(this.indent)}.`
|
|
@@ -1156,8 +1174,18 @@ var JsonFileErrorAdapter = class {
|
|
|
1156
1174
|
const fs = await this.getFileSystem();
|
|
1157
1175
|
await this.prepareDirectory(fs);
|
|
1158
1176
|
if (this.mode === "ndjson") {
|
|
1159
|
-
|
|
1160
|
-
|
|
1177
|
+
const line = `${JSON.stringify(record)}
|
|
1178
|
+
`;
|
|
1179
|
+
if (this.maxBytes !== void 0) {
|
|
1180
|
+
await rotateIfNeeded(
|
|
1181
|
+
fs,
|
|
1182
|
+
this.filePath,
|
|
1183
|
+
new TextEncoder().encode(line).length,
|
|
1184
|
+
{ maxBytes: this.maxBytes, maxFiles: this.maxFiles },
|
|
1185
|
+
{ adapterName: "JsonFileErrorAdapter", prefix: this.prefix }
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
await fs.appendFile(this.filePath, line);
|
|
1161
1189
|
} else {
|
|
1162
1190
|
await this.rewriteArray(fs, record);
|
|
1163
1191
|
}
|