@richardmcquiston01/error-log-adapter 0.4.0 → 0.5.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.mjs CHANGED
@@ -1,9 +1,98 @@
1
1
  // src/serialize.ts
2
- function serializeCause(cause) {
3
- if (cause instanceof Error) {
4
- return { name: cause.name, message: cause.message, stack: cause.stack };
2
+ var MAX_CAUSE_DEPTH = 6;
3
+ function isErrorLike(value) {
4
+ if (value instanceof Error) {
5
+ return true;
6
+ }
7
+ const candidate = value;
8
+ return typeof candidate.name === "string" && typeof candidate.message === "string" && typeof value.stack === "string";
9
+ }
10
+ function readProperty(source, key) {
11
+ try {
12
+ return source[key];
13
+ } catch (error) {
14
+ return `[Unreadable: ${error instanceof Error ? error.message : String(error)}]`;
15
+ }
16
+ }
17
+ function toJsonSafe(value, depth, ancestors) {
18
+ switch (typeof value) {
19
+ case "string":
20
+ case "boolean":
21
+ case "undefined":
22
+ return value;
23
+ case "number":
24
+ return Number.isFinite(value) ? value : String(value);
25
+ case "bigint":
26
+ return `${value}n`;
27
+ case "symbol":
28
+ return value.toString();
29
+ case "function":
30
+ return `[Function ${value.name || "anonymous"}]`;
31
+ default:
32
+ break;
33
+ }
34
+ if (value === null) {
35
+ return null;
36
+ }
37
+ const objectValue = value;
38
+ if (ancestors.has(objectValue)) {
39
+ return "[Circular]";
40
+ }
41
+ if (depth >= MAX_CAUSE_DEPTH) {
42
+ return "[Truncated]";
43
+ }
44
+ ancestors.add(objectValue);
45
+ try {
46
+ if (isErrorLike(objectValue)) {
47
+ const result2 = {
48
+ name: objectValue.name,
49
+ message: objectValue.message,
50
+ stack: objectValue.stack
51
+ };
52
+ const nested = readProperty(objectValue, "cause");
53
+ if (nested !== void 0) {
54
+ result2["cause"] = toJsonSafe(nested, depth + 1, ancestors);
55
+ }
56
+ const errors = readProperty(objectValue, "errors");
57
+ if (Array.isArray(errors)) {
58
+ result2["errors"] = errors.map(
59
+ (item) => toJsonSafe(item, depth + 1, ancestors)
60
+ );
61
+ }
62
+ return result2;
63
+ }
64
+ if (Array.isArray(value)) {
65
+ return value.map(
66
+ (item) => toJsonSafe(item, depth + 1, ancestors)
67
+ );
68
+ }
69
+ const toJson = readProperty(objectValue, "toJSON");
70
+ if (typeof toJson === "function") {
71
+ try {
72
+ return toJsonSafe(
73
+ toJson.call(objectValue),
74
+ depth + 1,
75
+ ancestors
76
+ );
77
+ } catch (error) {
78
+ return `[toJSON failed: ${error instanceof Error ? error.message : String(error)}]`;
79
+ }
80
+ }
81
+ const result = {};
82
+ for (const key of Object.keys(objectValue)) {
83
+ result[key] = toJsonSafe(
84
+ readProperty(objectValue, key),
85
+ depth + 1,
86
+ ancestors
87
+ );
88
+ }
89
+ return result;
90
+ } finally {
91
+ ancestors.delete(objectValue);
5
92
  }
6
- return cause;
93
+ }
94
+ function serializeCause(cause) {
95
+ return toJsonSafe(cause, 0, /* @__PURE__ */ new WeakSet());
7
96
  }
8
97
  function serializeEntry(entry) {
9
98
  const serialized = {
@@ -23,10 +112,20 @@ function describeError(error) {
23
112
  return error instanceof Error ? error.message : String(error);
24
113
  }
25
114
 
115
+ // src/fail.ts
116
+ var DEFAULT_PREFIX = "[error-log-adapter]";
117
+ function reportFailure(report) {
118
+ console.error(`${report.prefix} ${report.adapterName}:`, report.error);
119
+ return {
120
+ ok: false,
121
+ error: `${report.adapterName} failed ${report.action}: ${report.reason ?? describeError(report.error)}`
122
+ };
123
+ }
124
+
26
125
  // src/adapters/console-error-adapter.ts
27
126
  var ConsoleErrorAdapter = class {
28
127
  constructor(options = {}) {
29
- this.prefix = options.prefix ?? "[error-log-adapter]";
128
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
30
129
  }
31
130
  async log(entry) {
32
131
  try {
@@ -41,14 +140,135 @@ var ConsoleErrorAdapter = class {
41
140
  }
42
141
  };
43
142
 
143
+ // src/uuid.ts
144
+ function generateId() {
145
+ const cryptoApi = globalThis.crypto;
146
+ if (cryptoApi !== void 0 && typeof cryptoApi.randomUUID === "function") {
147
+ return cryptoApi.randomUUID();
148
+ }
149
+ const bytes = new Uint8Array(16);
150
+ if (cryptoApi !== void 0 && typeof cryptoApi.getRandomValues === "function") {
151
+ cryptoApi.getRandomValues(bytes);
152
+ } else {
153
+ for (let index = 0; index < bytes.length; index += 1) {
154
+ bytes[index] = Math.floor(Math.random() * 256);
155
+ }
156
+ }
157
+ bytes[6] = bytes[6] & 15 | 64;
158
+ bytes[8] = bytes[8] & 63 | 128;
159
+ const hex = Array.from(
160
+ bytes,
161
+ (byte) => byte.toString(16).padStart(2, "0")
162
+ ).join("");
163
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
164
+ }
165
+
166
+ // src/entry-id.ts
167
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
168
+ function hasCallerId(entry) {
169
+ return entry.id !== void 0;
170
+ }
171
+ function resolveEntryId(entry) {
172
+ if (entry.id === void 0) {
173
+ return generateId();
174
+ }
175
+ if (typeof entry.id !== "string" || !UUID_PATTERN.test(entry.id)) {
176
+ throw new RangeError(
177
+ `entry.id must be a UUID such as one from crypto.randomUUID(), received ${JSON.stringify(entry.id)}`
178
+ );
179
+ }
180
+ return entry.id.toLowerCase();
181
+ }
182
+
183
+ // src/timeout.ts
184
+ var DEFAULT_TIMEOUT_MS = 1e4;
185
+ var SinkTimeoutError = class extends Error {
186
+ constructor(message) {
187
+ super(message);
188
+ this.name = "SinkTimeoutError";
189
+ }
190
+ };
191
+ function resolveTimeoutMs(value, adapterName) {
192
+ const timeoutMs = value ?? DEFAULT_TIMEOUT_MS;
193
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
194
+ throw new RangeError(
195
+ `${adapterName}: timeoutMs must be a positive number or Infinity, received ${String(value)}.`
196
+ );
197
+ }
198
+ return timeoutMs;
199
+ }
200
+ function timeoutMessage(sink, timeoutMs, action = "respond") {
201
+ return `${sink} did not ${action} within ${timeoutMs} ms, so the entry may or may not have been stored (the operation can still finish later, so retrying could store it twice)`;
202
+ }
203
+ function withTimeout(start, timeoutMs, message) {
204
+ if (timeoutMs === Infinity) {
205
+ return new Promise((resolve, reject) => {
206
+ try {
207
+ resolve(start());
208
+ } catch (error) {
209
+ reject(error);
210
+ }
211
+ });
212
+ }
213
+ return new Promise(
214
+ (resolve, reject) => {
215
+ const timer = setTimeout(() => {
216
+ reject(new SinkTimeoutError(message));
217
+ }, timeoutMs);
218
+ const settle = (done) => (...args) => {
219
+ clearTimeout(timer);
220
+ done(...args);
221
+ };
222
+ try {
223
+ Promise.resolve(start()).then(settle(resolve), settle(reject));
224
+ } catch (error) {
225
+ settle(reject)(error);
226
+ }
227
+ }
228
+ );
229
+ }
230
+
44
231
  // src/adapters/webhook-error-adapter.ts
232
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
233
+ "localhost",
234
+ "127.0.0.1",
235
+ "[::1]"
236
+ ]);
237
+ function parseWebhookUrl(url, allowInsecure) {
238
+ let parsed;
239
+ try {
240
+ parsed = new URL(url);
241
+ } catch {
242
+ throw new RangeError(
243
+ "WebhookErrorAdapter: the webhook URL is not a valid absolute URL (for example https://example.com/errors)."
244
+ );
245
+ }
246
+ if (parsed.protocol === "https:") {
247
+ return parsed;
248
+ }
249
+ if (parsed.protocol === "http:" && (allowInsecure || LOOPBACK_HOSTS.has(parsed.hostname))) {
250
+ return parsed;
251
+ }
252
+ throw new RangeError(
253
+ `WebhookErrorAdapter: the webhook URL must use https:// because entries contain error messages and stack traces (received ${parsed.protocol}//${parsed.host}). Pass allowInsecure: true to send over http.`
254
+ );
255
+ }
45
256
  var WebhookErrorAdapter = class {
257
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
46
258
  constructor(url, options = {}) {
47
259
  this.url = url;
48
260
  this.options = options;
49
- this.prefix = options.prefix ?? "[error-log-adapter]";
261
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
262
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
263
+ this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
50
264
  }
51
265
  async log(entry) {
266
+ const controller = new AbortController();
267
+ let timedOut = false;
268
+ const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
269
+ timedOut = true;
270
+ controller.abort();
271
+ }, this.timeoutMs);
52
272
  try {
53
273
  const response = await fetch(this.url, {
54
274
  method: "POST",
@@ -56,58 +276,112 @@ var WebhookErrorAdapter = class {
56
276
  "Content-Type": "application/json",
57
277
  ...this.options.headers
58
278
  },
59
- body: JSON.stringify(serializeEntry(entry))
279
+ // A caller-supplied id goes along so the receiver can drop a repeat.
280
+ body: JSON.stringify(
281
+ hasCallerId(entry) ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
282
+ ),
283
+ // A redirect would turn the POST into a GET without a body (302, 303)
284
+ // or re-send the entry and headers to another host (307, 308).
285
+ redirect: "manual",
286
+ signal: controller.signal
60
287
  });
288
+ if (response.type === "opaqueredirect") {
289
+ return this.fail(this.redirectMessage("a redirect"));
290
+ }
61
291
  if (!response.ok) {
292
+ if (response.status >= 300 && response.status < 400) {
293
+ return this.fail(this.redirectMessage(`HTTP ${response.status}`));
294
+ }
62
295
  return this.fail(
63
- `webhook ${this.url} responded with HTTP ${response.status}`
296
+ `webhook ${this.target} responded with HTTP ${response.status}`
64
297
  );
65
298
  }
66
299
  return { ok: true };
67
300
  } catch (error) {
68
301
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
302
+ if (timedOut) {
303
+ return this.fail(
304
+ timeoutMessage(`webhook ${this.target}`, this.timeoutMs)
305
+ );
306
+ }
69
307
  return this.fail(
70
- `could not reach webhook ${this.url}: ${describeError(error)}`
308
+ `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`
71
309
  );
310
+ } finally {
311
+ clearTimeout(timer);
72
312
  }
73
313
  }
314
+ redirectMessage(what) {
315
+ return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
316
+ }
317
+ /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
318
+ redact(message) {
319
+ return message.split(this.url).join(this.target);
320
+ }
74
321
  fail(reason) {
75
322
  return { ok: false, error: `WebhookErrorAdapter failed: ${reason}` };
76
323
  }
77
324
  };
78
325
 
79
326
  // src/adapters/mongodb-error-adapter.ts
327
+ function isDuplicateKeyError(error) {
328
+ return typeof error === "object" && error !== null && error.code === 11e3;
329
+ }
80
330
  var MongoDbErrorAdapter = class {
331
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
81
332
  constructor(collection, options = {}) {
82
333
  this.collection = collection;
83
- this.prefix = options.prefix ?? "[error-log-adapter]";
334
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
335
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "MongoDbErrorAdapter");
84
336
  }
85
337
  async log(entry) {
86
338
  try {
87
- await this.collection.insertOne(serializeEntry(entry));
339
+ const document = hasCallerId(entry) ? { _id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry);
340
+ await withTimeout(
341
+ () => this.collection.insertOne(document),
342
+ this.timeoutMs,
343
+ timeoutMessage("MongoDB", this.timeoutMs)
344
+ );
88
345
  return { ok: true };
89
346
  } catch (error) {
90
- console.error(`${this.prefix} MongoDbErrorAdapter:`, error);
91
- return {
92
- ok: false,
93
- error: `MongoDbErrorAdapter failed to insert entry: ${describeError(error)}`
94
- };
347
+ if (hasCallerId(entry) && isDuplicateKeyError(error)) {
348
+ return { ok: true };
349
+ }
350
+ return reportFailure({
351
+ adapterName: "MongoDbErrorAdapter",
352
+ prefix: this.prefix,
353
+ action: "to insert entry",
354
+ error
355
+ });
95
356
  }
96
357
  }
97
358
  };
98
359
 
99
360
  // src/adapters/composite-error-adapter.ts
361
+ var DEFAULT_COMPOSITE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 3;
100
362
  var CompositeErrorAdapter = class {
101
- constructor(adapters) {
363
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
364
+ constructor(adapters, options = {}) {
102
365
  this.adapters = adapters;
366
+ this.timeoutMs = resolveTimeoutMs(
367
+ options.timeoutMs ?? DEFAULT_COMPOSITE_TIMEOUT_MS,
368
+ "CompositeErrorAdapter"
369
+ );
103
370
  }
104
371
  async log(entry) {
105
372
  const results = await Promise.all(
106
373
  this.adapters.map(
107
- async (adapter) => {
374
+ async (adapter, index) => {
108
375
  try {
109
- return await adapter.log(entry);
376
+ return await withTimeout(
377
+ () => adapter.log(entry),
378
+ this.timeoutMs,
379
+ `adapter ${index} (${adapter.constructor.name}) did not return within ${this.timeoutMs} ms, so the entry may or may not have been stored there`
380
+ );
110
381
  } catch (error) {
382
+ if (error instanceof SinkTimeoutError) {
383
+ return { ok: false, error: error.message };
384
+ }
111
385
  return {
112
386
  ok: false,
113
387
  error: `adapter threw instead of returning a result: ${String(error)}`
@@ -138,58 +412,101 @@ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
138
412
  database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
139
413
  }
140
414
  var IndexedDbErrorAdapter = class {
415
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
141
416
  constructor(database, options = {}) {
142
417
  this.database = database;
143
418
  this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
144
- this.prefix = options.prefix ?? "[error-log-adapter]";
419
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
420
+ this.timeoutMs = resolveTimeoutMs(
421
+ options.timeoutMs,
422
+ "IndexedDbErrorAdapter"
423
+ );
145
424
  }
146
425
  async log(entry) {
147
426
  try {
148
427
  const record = {
149
- id: crypto.randomUUID(),
428
+ id: resolveEntryId(entry),
150
429
  ...serializeEntry(entry)
151
430
  };
152
- await this.write(record);
431
+ await this.write(record, hasCallerId(entry));
153
432
  return { ok: true };
154
433
  } catch (error) {
155
- console.error(`${this.prefix} IndexedDbErrorAdapter:`, error);
156
- return {
157
- ok: false,
158
- error: `IndexedDbErrorAdapter failed to write to object store "${this.storeName}": ${describeError(error)}`
159
- };
434
+ return reportFailure({
435
+ adapterName: "IndexedDbErrorAdapter",
436
+ prefix: this.prefix,
437
+ action: `to write to object store "${this.storeName}"`,
438
+ error
439
+ });
160
440
  }
161
441
  }
162
- /** Resolves once the transaction commits; rejects if it errors or aborts. */
163
- write(record) {
164
- return new Promise(
165
- (resolve, reject) => {
166
- const transaction = this.database.transaction(
167
- this.storeName,
168
- "readwrite"
169
- );
170
- const fail = (action) => () => reject(
171
- new Error(
172
- `transaction ${action}: ${transaction.error?.message ?? "no details provided"}`
173
- )
174
- );
175
- transaction.oncomplete = () => resolve();
176
- transaction.onerror = fail("failed");
177
- transaction.onabort = fail("aborted");
178
- transaction.objectStore(this.storeName).add(record);
179
- }
442
+ /**
443
+ * Resolves once the transaction commits; rejects if the request fails, the
444
+ * transaction aborts, or it does not finish within `timeoutMs`.
445
+ *
446
+ * Error events are handled here (`preventDefault` and `stopPropagation`), so a
447
+ * failed log write does not reach the `onerror` handler an app may have set on
448
+ * its own database object.
449
+ */
450
+ write(record, replaceExisting) {
451
+ return withTimeout(
452
+ () => new Promise(
453
+ (resolve, reject) => {
454
+ const transaction = this.database.transaction(
455
+ this.storeName,
456
+ "readwrite"
457
+ );
458
+ const fail = (what, source) => (event) => {
459
+ event?.preventDefault?.();
460
+ event?.stopPropagation?.();
461
+ reject(new Error(`${what}: ${describeDomError(source.error)}`));
462
+ };
463
+ transaction.oncomplete = () => resolve();
464
+ transaction.onerror = fail("transaction failed", transaction);
465
+ transaction.onabort = fail("transaction aborted", transaction);
466
+ const store = transaction.objectStore(
467
+ this.storeName
468
+ );
469
+ const request = replaceExisting && typeof store.put === "function" ? store.put(record) : store.add(record);
470
+ if (typeof request === "object" && request !== null) {
471
+ request.onerror = fail(
472
+ "request failed",
473
+ request
474
+ );
475
+ }
476
+ }
477
+ ),
478
+ this.timeoutMs,
479
+ timeoutMessage("IndexedDB", this.timeoutMs, "finish the transaction")
180
480
  );
181
481
  }
182
482
  };
483
+ function describeDomError(error) {
484
+ if (error === null || error === void 0) {
485
+ return "no details provided";
486
+ }
487
+ const text = [error.name, error.message].filter((part) => typeof part === "string" && part !== "").join(": ");
488
+ return text === "" ? "no details provided" : text;
489
+ }
183
490
 
184
- // src/adapters/sqlite-error-adapter.ts
491
+ // src/sql-ident.ts
185
492
  var DEFAULT_TABLE_NAME = "error_log";
186
493
  var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
187
- function assertValidTableName(tableName) {
188
- if (!IDENTIFIER_PATTERN.test(tableName)) {
494
+ function assertIdentifier(value, label, owner, maxLength) {
495
+ if (!IDENTIFIER_PATTERN.test(value)) {
189
496
  throw new RangeError(
190
- `SqliteErrorAdapter: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
497
+ `${owner}: invalid ${label} "${value}". Use only letters, digits and underscores, and do not start with a digit.`
191
498
  );
192
499
  }
500
+ if (maxLength !== void 0 && value.length > maxLength) {
501
+ throw new RangeError(
502
+ `${owner}: ${label} "${value}" is ${value.length} characters long, but the limit is ${maxLength}.`
503
+ );
504
+ }
505
+ }
506
+
507
+ // src/adapters/sqlite-error-adapter.ts
508
+ function assertValidTableName(tableName) {
509
+ assertIdentifier(tableName, "table name", "SqliteErrorAdapter");
193
510
  }
194
511
  function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
195
512
  assertValidTableName(tableName);
@@ -199,23 +516,24 @@ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
199
516
  ];
200
517
  }
201
518
  var SqliteErrorAdapter = class {
202
- /** @throws {RangeError} if `options.tableName` is not a plain identifier. */
519
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
203
520
  constructor(client, options = {}) {
204
521
  this.client = client;
205
522
  this.tableReady = null;
206
523
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
207
524
  assertValidTableName(this.tableName);
208
525
  this.createTable = options.createTable ?? true;
209
- this.prefix = options.prefix ?? "[error-log-adapter]";
526
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
527
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "SqliteErrorAdapter");
210
528
  }
211
529
  async log(entry) {
212
530
  try {
213
531
  await this.ensureTable();
214
532
  const serialized = serializeEntry(entry);
215
- await this.client.run(
216
- `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?)`,
533
+ await this.run(
534
+ `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`,
217
535
  [
218
- crypto.randomUUID(),
536
+ resolveEntryId(entry),
219
537
  serialized.code,
220
538
  serialized.message,
221
539
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -225,11 +543,12 @@ var SqliteErrorAdapter = class {
225
543
  );
226
544
  return { ok: true };
227
545
  } catch (error) {
228
- console.error(`${this.prefix} SqliteErrorAdapter:`, error);
229
- return {
230
- ok: false,
231
- error: `SqliteErrorAdapter failed to write to table "${this.tableName}": ${describeError(error)}`
232
- };
546
+ return reportFailure({
547
+ adapterName: "SqliteErrorAdapter",
548
+ prefix: this.prefix,
549
+ action: `to write to table "${this.tableName}"`,
550
+ error
551
+ });
233
552
  }
234
553
  }
235
554
  /** Creates the table once. A failed attempt is retried on the next log call. */
@@ -249,9 +568,17 @@ var SqliteErrorAdapter = class {
249
568
  }
250
569
  async runCreateStatements() {
251
570
  for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
252
- await this.client.run(sql, []);
571
+ await this.run(sql, []);
253
572
  }
254
573
  }
574
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
575
+ run(sql, params) {
576
+ return withTimeout(
577
+ () => this.client.run(sql, params),
578
+ this.timeoutMs,
579
+ timeoutMessage("SQLite", this.timeoutMs)
580
+ );
581
+ }
255
582
  };
256
583
 
257
584
  // src/adapters/web-storage-error-adapter.ts
@@ -270,7 +597,8 @@ var WebStorageErrorAdapter = class {
270
597
  this.storage = storage;
271
598
  this.key = options.key ?? DEFAULT_STORAGE_KEY;
272
599
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
273
- this.prefix = options.prefix ?? "[error-log-adapter]";
600
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
601
+ this.locks = options.locks === void 0 ? defaultLockManager() : options.locks;
274
602
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
275
603
  throw new RangeError(
276
604
  `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
@@ -280,23 +608,49 @@ var WebStorageErrorAdapter = class {
280
608
  async log(entry) {
281
609
  try {
282
610
  const record = {
283
- id: crypto.randomUUID(),
611
+ id: resolveEntryId(entry),
284
612
  ...serializeEntry(entry)
285
613
  };
286
- this.append(record);
614
+ await this.appendLocked(record);
287
615
  return { ok: true };
288
616
  } catch (error) {
289
- console.error(`${this.prefix} WebStorageErrorAdapter:`, error);
290
- return {
291
- ok: false,
292
- error: `WebStorageErrorAdapter failed to write to storage key "${this.key}": ${describeError(error)}`
293
- };
617
+ return reportFailure({
618
+ adapterName: "WebStorageErrorAdapter",
619
+ prefix: this.prefix,
620
+ action: `to write to storage key "${this.key}"`,
621
+ error
622
+ });
623
+ }
624
+ }
625
+ /**
626
+ * Appends under a cross-tab lock when one is available. If the lock itself
627
+ * cannot be taken (for example a sandboxed frame), the entry is still written
628
+ * without it rather than lost.
629
+ */
630
+ async appendLocked(record) {
631
+ if (this.locks === null) {
632
+ this.append(record);
633
+ return;
634
+ }
635
+ let appended = false;
636
+ try {
637
+ await this.locks.request(`error-log-adapter:${this.key}`, () => {
638
+ appended = true;
639
+ this.append(record);
640
+ });
641
+ } catch (error) {
642
+ if (appended) {
643
+ throw error;
644
+ }
645
+ this.append(record);
294
646
  }
295
647
  }
296
- /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
648
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls in one tab never interleave. */
297
649
  append(record) {
298
650
  const records = [
299
- ...this.readRecords(),
651
+ ...this.readRecords().filter(
652
+ (existing) => existing?.id !== record.id
653
+ ),
300
654
  record
301
655
  ].slice(-this.maxEntries);
302
656
  for (; ; ) {
@@ -322,16 +676,51 @@ var WebStorageErrorAdapter = class {
322
676
  if (Array.isArray(parsed)) {
323
677
  return parsed;
324
678
  }
325
- console.error(
326
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
327
- );
679
+ this.setAside(stored, "did not hold an array");
328
680
  } catch (error) {
329
- console.error(
330
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
331
- );
681
+ this.setAside(stored, `held invalid JSON (${describeError(error)})`);
332
682
  }
333
683
  return [];
334
684
  }
685
+ /**
686
+ * Copies an unusable value to `<key>.corrupt` before it is replaced, so data
687
+ * the host app stored under the same key is not silently destroyed. Only the
688
+ * first one is kept; a later unusable value would not overwrite it.
689
+ */
690
+ setAside(stored, reason) {
691
+ const backupKey = `${this.key}${CORRUPT_KEY_SUFFIX}`;
692
+ let savedTo = "but it could not be copied aside";
693
+ try {
694
+ if (this.storage.getItem(backupKey) === null) {
695
+ this.storage.setItem(backupKey, stored);
696
+ savedTo = `and a copy was kept under "${backupKey}"`;
697
+ } else {
698
+ savedTo = `and "${backupKey}" already holds an earlier copy`;
699
+ }
700
+ } catch {
701
+ }
702
+ console.error(
703
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" ${reason}, so it was replaced, ${savedTo}.`
704
+ );
705
+ }
706
+ };
707
+ var CORRUPT_KEY_SUFFIX = ".corrupt";
708
+ function defaultLockManager() {
709
+ const locks = globalThis.navigator?.locks;
710
+ return typeof locks?.request === "function" ? locks : null;
711
+ }
712
+
713
+ // src/queue.ts
714
+ var SerialQueue = class {
715
+ constructor() {
716
+ this.tail = Promise.resolve();
717
+ }
718
+ /** Starts `task` once every earlier task has finished, and resolves with its result. */
719
+ run(task) {
720
+ const result = this.tail.then(task);
721
+ this.tail = result.catch(() => void 0);
722
+ return result;
723
+ }
335
724
  };
336
725
 
337
726
  // src/file-system.ts
@@ -339,6 +728,7 @@ async function loadNodeFileSystem() {
339
728
  const moduleName = "node:fs/promises";
340
729
  return await import(moduleName);
341
730
  }
731
+ var ROOT_PATTERN = /^(?:[A-Za-z]:)?[\\/]+$/;
342
732
  function parentDirectory(filePath) {
343
733
  const lastSeparator = Math.max(
344
734
  filePath.lastIndexOf("/"),
@@ -347,7 +737,11 @@ function parentDirectory(filePath) {
347
737
  if (lastSeparator < 0) {
348
738
  return null;
349
739
  }
350
- return lastSeparator === 0 ? "/" : filePath.slice(0, lastSeparator);
740
+ const directory = filePath.slice(0, lastSeparator + 1);
741
+ if (ROOT_PATTERN.test(directory)) {
742
+ return null;
743
+ }
744
+ return filePath.slice(0, lastSeparator);
351
745
  }
352
746
  function isMissingFileError(error) {
353
747
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -393,24 +787,20 @@ var FlatFileErrorAdapter = class {
393
787
  constructor(filePath, options = {}) {
394
788
  this.filePath = filePath;
395
789
  this.directoryReady = false;
396
- this.queue = Promise.resolve();
790
+ this.queue = new SerialQueue();
397
791
  this.fs = options.fs;
398
792
  this.formatLine = options.formatLine ?? formatErrorLogLine;
399
793
  this.maxBytes = options.maxBytes;
400
794
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
401
795
  this.createDirectory = options.createDirectory ?? true;
402
- this.prefix = options.prefix ?? "[error-log-adapter]";
796
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
403
797
  if (this.maxBytes !== void 0) {
404
798
  assertPositiveInteger(this.maxBytes, "maxBytes");
405
799
  }
406
800
  assertPositiveInteger(this.maxFiles, "maxFiles");
407
801
  }
408
802
  log(entry) {
409
- const result = this.queue.then(
410
- () => this.write(entry)
411
- );
412
- this.queue = result.catch(() => void 0);
413
- return result;
803
+ return this.queue.run(() => this.write(entry));
414
804
  }
415
805
  async write(entry) {
416
806
  try {
@@ -423,11 +813,12 @@ var FlatFileErrorAdapter = class {
423
813
  await fs.appendFile(this.filePath, line);
424
814
  return { ok: true };
425
815
  } catch (error) {
426
- console.error(`${this.prefix} FlatFileErrorAdapter:`, error);
427
- return {
428
- ok: false,
429
- error: `FlatFileErrorAdapter failed to append to "${this.filePath}": ${describeError(error)}`
430
- };
816
+ return reportFailure({
817
+ adapterName: "FlatFileErrorAdapter",
818
+ prefix: this.prefix,
819
+ action: `to append to "${this.filePath}"`,
820
+ error
821
+ });
431
822
  }
432
823
  }
433
824
  /** Uses the injected file system, or loads `node:fs/promises` on first use. */
@@ -502,13 +893,13 @@ var JsonFileErrorAdapter = class {
502
893
  constructor(filePath, options = {}) {
503
894
  this.filePath = filePath;
504
895
  this.directoryReady = false;
505
- this.queue = Promise.resolve();
896
+ this.queue = new SerialQueue();
506
897
  this.mode = options.mode ?? "ndjson";
507
898
  this.maxEntries = options.maxEntries;
508
899
  this.indent = options.indent ?? DEFAULT_INDENT;
509
900
  this.fs = options.fs;
510
901
  this.createDirectory = options.createDirectory ?? true;
511
- this.prefix = options.prefix ?? "[error-log-adapter]";
902
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
512
903
  if (this.maxEntries !== void 0) {
513
904
  if (this.mode !== "array") {
514
905
  throw new RangeError(
@@ -528,16 +919,12 @@ var JsonFileErrorAdapter = class {
528
919
  }
529
920
  }
530
921
  log(entry) {
531
- const result = this.queue.then(
532
- () => this.write(entry)
533
- );
534
- this.queue = result.catch(() => void 0);
535
- return result;
922
+ return this.queue.run(() => this.write(entry));
536
923
  }
537
924
  async write(entry) {
538
925
  try {
539
926
  const record = {
540
- id: crypto.randomUUID(),
927
+ id: resolveEntryId(entry),
541
928
  ...serializeEntry(entry)
542
929
  };
543
930
  const fs = await this.getFileSystem();
@@ -550,11 +937,12 @@ var JsonFileErrorAdapter = class {
550
937
  }
551
938
  return { ok: true };
552
939
  } catch (error) {
553
- console.error(`${this.prefix} JsonFileErrorAdapter:`, error);
554
- return {
555
- ok: false,
556
- error: `JsonFileErrorAdapter failed to write "${this.filePath}" in ${this.mode} mode: ${describeError(error)}`
557
- };
940
+ return reportFailure({
941
+ adapterName: "JsonFileErrorAdapter",
942
+ prefix: this.prefix,
943
+ action: `to write "${this.filePath}" in ${this.mode} mode`,
944
+ error
945
+ });
558
946
  }
559
947
  }
560
948
  async getFileSystem() {
@@ -580,7 +968,9 @@ var JsonFileErrorAdapter = class {
580
968
  */
581
969
  async rewriteArray(fs, record) {
582
970
  const records = [
583
- ...await this.readRecords(fs),
971
+ ...(await this.readRecords(fs)).filter(
972
+ (existing) => existing?.id !== record.id
973
+ ),
584
974
  record
585
975
  ];
586
976
  const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
@@ -630,25 +1020,14 @@ var JsonFileErrorAdapter = class {
630
1020
  };
631
1021
 
632
1022
  // src/adapters/sql-error-adapter.ts
633
- var DEFAULT_TABLE_NAME2 = "error_log";
634
1023
  var MAX_TABLE_NAME_LENGTH = 47;
635
- var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
636
1024
  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
- }
1025
+ assertIdentifier(tableName, "table name", adapterName, MAX_TABLE_NAME_LENGTH);
647
1026
  }
648
1027
  function quoteIdentifier(dialect, identifier) {
649
1028
  return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
650
1029
  }
651
- function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
1030
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME) {
652
1031
  assertValidTableName2(tableName, "buildErrorLogTableDdl");
653
1032
  const table = quoteIdentifier(dialect, tableName);
654
1033
  const index = quoteIdentifier(
@@ -662,7 +1041,13 @@ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
662
1041
  ];
663
1042
  }
664
1043
  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))`
1044
+ `CREATE TABLE IF NOT EXISTS ${table} (id CHAR(36) NOT NULL PRIMARY KEY, code TEXT NOT NULL, message TEXT NOT NULL, cause JSON NULL, source TEXT NULL, occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX ${index} (occurred_at))`
1045
+ ];
1046
+ }
1047
+ function buildMySqlWidenColumnsSql(tableName = DEFAULT_TABLE_NAME) {
1048
+ assertValidTableName2(tableName, "buildMySqlWidenColumnsSql");
1049
+ return [
1050
+ `ALTER TABLE ${quoteIdentifier("mysql", tableName)} MODIFY code TEXT NOT NULL, MODIFY source TEXT NULL`
666
1051
  ];
667
1052
  }
668
1053
  function toMysqlDateTime(date) {
@@ -674,10 +1059,11 @@ var SqlErrorAdapter = class {
674
1059
  this.dialect = dialect;
675
1060
  this.adapterName = adapterName;
676
1061
  this.tableReady = null;
677
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1062
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
678
1063
  assertValidTableName2(this.tableName, adapterName);
679
1064
  this.createTable = options.createTable ?? true;
680
- this.prefix = options.prefix ?? "[error-log-adapter]";
1065
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1066
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, adapterName);
681
1067
  }
682
1068
  async log(entry) {
683
1069
  try {
@@ -689,7 +1075,7 @@ var SqlErrorAdapter = class {
689
1075
  );
690
1076
  }
691
1077
  const params = [
692
- crypto.randomUUID(),
1078
+ resolveEntryId(entry),
693
1079
  serialized.code,
694
1080
  serialized.message,
695
1081
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -697,19 +1083,21 @@ var SqlErrorAdapter = class {
697
1083
  this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
698
1084
  ];
699
1085
  await this.ensureTable();
700
- await this.client.query(this.insertSql(), params);
1086
+ await this.query(this.insertSql(), params);
701
1087
  return { ok: true };
702
1088
  } 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
- };
1089
+ return reportFailure({
1090
+ adapterName: this.adapterName,
1091
+ prefix: this.prefix,
1092
+ action: `to write to table "${this.tableName}"`,
1093
+ error
1094
+ });
708
1095
  }
709
1096
  }
710
1097
  insertSql() {
711
1098
  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})`;
1099
+ const onRepeat = this.dialect === "postgres" ? "ON CONFLICT (id) DO NOTHING" : "ON DUPLICATE KEY UPDATE id = id";
1100
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders}) ${onRepeat}`;
713
1101
  }
714
1102
  /** Creates the table once. A failed attempt is retried on the next log call. */
715
1103
  ensureTable() {
@@ -728,9 +1116,20 @@ var SqlErrorAdapter = class {
728
1116
  }
729
1117
  async runCreateStatements() {
730
1118
  for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
731
- await this.client.query(sql, []);
1119
+ await this.query(sql, []);
732
1120
  }
733
1121
  }
1122
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
1123
+ query(sql, params) {
1124
+ return withTimeout(
1125
+ () => this.client.query(sql, params),
1126
+ this.timeoutMs,
1127
+ timeoutMessage(
1128
+ this.dialect === "postgres" ? "Postgres" : "MySQL",
1129
+ this.timeoutMs
1130
+ )
1131
+ );
1132
+ }
734
1133
  };
735
1134
  var PostgresErrorAdapter = class extends SqlErrorAdapter {
736
1135
  /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
@@ -755,7 +1154,8 @@ var RedisErrorAdapter = class {
755
1154
  this.key = options.key ?? DEFAULT_REDIS_KEY;
756
1155
  this.maxEntries = options.maxEntries;
757
1156
  this.side = options.side ?? "right";
758
- this.prefix = options.prefix ?? "[error-log-adapter]";
1157
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1158
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "RedisErrorAdapter");
759
1159
  if (this.mode !== "list" && this.mode !== "stream") {
760
1160
  throw new RangeError(
761
1161
  `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
@@ -777,11 +1177,11 @@ var RedisErrorAdapter = class {
777
1177
  }
778
1178
  async log(entry) {
779
1179
  try {
780
- const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
1180
+ const record = { id: resolveEntryId(entry), ...serializeEntry(entry) };
781
1181
  if (this.mode === "stream") {
782
- await this.client.sendCommand(this.buildStreamCommand(record));
1182
+ await this.send(this.buildStreamCommand(record));
783
1183
  } else {
784
- await this.client.sendCommand([
1184
+ await this.send([
785
1185
  this.side === "left" ? "LPUSH" : "RPUSH",
786
1186
  this.key,
787
1187
  JSON.stringify(record)
@@ -790,11 +1190,12 @@ var RedisErrorAdapter = class {
790
1190
  }
791
1191
  return { ok: true };
792
1192
  } 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
- };
1193
+ return reportFailure({
1194
+ adapterName: "RedisErrorAdapter",
1195
+ prefix: this.prefix,
1196
+ action: `to write to ${this.mode} "${this.key}"`,
1197
+ error
1198
+ });
798
1199
  }
799
1200
  }
800
1201
  buildStreamCommand(record) {
@@ -826,6 +1227,14 @@ var RedisErrorAdapter = class {
826
1227
  * stored by now, so a trim failure is reported but does not fail the call;
827
1228
  * the next successful write trims the list again.
828
1229
  */
1230
+ /** Sends one command, giving up waiting after `timeoutMs`. */
1231
+ send(args) {
1232
+ return withTimeout(
1233
+ () => this.client.sendCommand(args),
1234
+ this.timeoutMs,
1235
+ timeoutMessage("Redis", this.timeoutMs)
1236
+ );
1237
+ }
829
1238
  async trimList() {
830
1239
  if (this.maxEntries === void 0) {
831
1240
  return;
@@ -833,7 +1242,7 @@ var RedisErrorAdapter = class {
833
1242
  const keep = this.maxEntries;
834
1243
  const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
835
1244
  try {
836
- await this.client.sendCommand(["LTRIM", this.key, start, stop]);
1245
+ await this.send(["LTRIM", this.key, start, stop]);
837
1246
  } catch (error) {
838
1247
  console.error(
839
1248
  `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
@@ -860,11 +1269,12 @@ var S3ErrorAdapter = class {
860
1269
  this.bucket = bucket;
861
1270
  this.buffer = [];
862
1271
  this.timer = null;
863
- this.flushQueue = Promise.resolve();
1272
+ this.flushQueue = new SerialQueue();
864
1273
  this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
865
1274
  this.batch = options.batch;
866
1275
  this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
867
- this.logPrefix = options.prefix ?? "[error-log-adapter]";
1276
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
1277
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "S3ErrorAdapter");
868
1278
  if (bucket === "") {
869
1279
  throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
870
1280
  }
@@ -884,7 +1294,7 @@ var S3ErrorAdapter = class {
884
1294
  async log(entry) {
885
1295
  try {
886
1296
  const record = {
887
- id: crypto.randomUUID(),
1297
+ id: resolveEntryId(entry),
888
1298
  ...serializeEntry(entry)
889
1299
  };
890
1300
  const occurredAt = new Date(record.timestamp);
@@ -909,11 +1319,9 @@ var S3ErrorAdapter = class {
909
1319
  * Entries from a failed upload stay buffered and are retried by the next flush.
910
1320
  */
911
1321
  flush() {
912
- const result = this.flushQueue.then(
1322
+ return this.flushQueue.run(
913
1323
  () => this.uploadBuffered()
914
1324
  );
915
- this.flushQueue = result.catch(() => void 0);
916
- return result;
917
1325
  }
918
1326
  async addToBatch(record, batch) {
919
1327
  this.buffer.push(record);
@@ -952,7 +1360,7 @@ var S3ErrorAdapter = class {
952
1360
  return { ok: true };
953
1361
  }
954
1362
  try {
955
- const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1363
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${generateId()}.ndjson`;
956
1364
  const body = pending.map((record) => JSON.stringify(record)).join("\n");
957
1365
  await this.upload(key, `${body}
958
1366
  `);
@@ -969,64 +1377,41 @@ var S3ErrorAdapter = class {
969
1377
  }
970
1378
  }
971
1379
  upload(key, body) {
972
- return this.client.putObject({
973
- Bucket: this.bucket,
974
- Key: key,
975
- Body: body,
976
- ContentType: this.contentType
977
- });
1380
+ return withTimeout(
1381
+ () => this.client.putObject({
1382
+ Bucket: this.bucket,
1383
+ Key: key,
1384
+ Body: body,
1385
+ ContentType: this.contentType
1386
+ }),
1387
+ this.timeoutMs,
1388
+ timeoutMessage("S3", this.timeoutMs)
1389
+ );
978
1390
  }
979
1391
  fail(error, bufferedCount) {
980
- console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
981
1392
  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
- };
1393
+ return reportFailure({
1394
+ adapterName: "S3ErrorAdapter",
1395
+ prefix: this.logPrefix,
1396
+ action: `to upload to bucket "${this.bucket}"`,
1397
+ error,
1398
+ reason: `${describeError(error)}${retry}`
1399
+ });
986
1400
  }
987
1401
  };
988
1402
 
989
1403
  // src/adapters/firebase-error-adapter.ts
990
- var DEFAULT_WRITE_TIMEOUT_MS = 1e4;
991
- function resolveTimeout(options, adapter) {
992
- const timeoutMs = options.timeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
993
- if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
994
- throw new RangeError(
995
- `${adapter}: timeoutMs must be a positive number or Infinity, received ${String(options.timeoutMs)}.`
996
- );
997
- }
998
- return timeoutMs;
999
- }
1000
- function waitForWrite(write, timeoutMs) {
1001
- if (timeoutMs === Infinity) {
1002
- return Promise.resolve(write);
1003
- }
1004
- return new Promise(
1005
- (resolve, reject) => {
1006
- const timer = setTimeout(() => {
1007
- reject(
1008
- new Error(
1009
- `the write was not saved within ${timeoutMs} ms (the connection may be down; the SDK keeps the write queued and may still save it later)`
1010
- )
1011
- );
1012
- }, timeoutMs);
1013
- write.then(
1014
- (value) => {
1015
- clearTimeout(timer);
1016
- resolve(value);
1017
- },
1018
- (reason) => {
1019
- clearTimeout(timer);
1020
- reject(reason);
1021
- }
1022
- );
1023
- }
1404
+ function waitForWrite(start, timeoutMs) {
1405
+ return withTimeout(
1406
+ start,
1407
+ timeoutMs,
1408
+ `the write was not saved within ${timeoutMs} ms (the connection may be down; the SDK keeps the write queued and may still save it later, so logging the entry again could store it twice unless it carries an id)`
1024
1409
  );
1025
1410
  }
1026
1411
  function buildRecord(entry) {
1027
1412
  const serialized = serializeEntry(entry);
1028
1413
  const record = {
1029
- id: crypto.randomUUID(),
1414
+ id: resolveEntryId(entry),
1030
1415
  code: serialized.code,
1031
1416
  message: serialized.message,
1032
1417
  timestamp: serialized.timestamp
@@ -1043,23 +1428,27 @@ var FirestoreErrorAdapter = class {
1043
1428
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1044
1429
  constructor(collection, options = {}) {
1045
1430
  this.collection = collection;
1046
- this.prefix = options.prefix ?? "[error-log-adapter]";
1047
- this.timeoutMs = resolveTimeout(options, "FirestoreErrorAdapter");
1431
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1432
+ this.timeoutMs = resolveTimeoutMs(
1433
+ options.timeoutMs,
1434
+ "FirestoreErrorAdapter"
1435
+ );
1048
1436
  }
1049
1437
  async log(entry) {
1050
1438
  try {
1051
1439
  const record = buildRecord(entry);
1052
1440
  await waitForWrite(
1053
- this.collection.doc(record.id).set(record),
1441
+ () => this.collection.doc(record.id).set(record),
1054
1442
  this.timeoutMs
1055
1443
  );
1056
1444
  return { ok: true };
1057
1445
  } catch (error) {
1058
- console.error(`${this.prefix} FirestoreErrorAdapter:`, error);
1059
- return {
1060
- ok: false,
1061
- error: `FirestoreErrorAdapter failed to write the document: ${describeError(error)}`
1062
- };
1446
+ return reportFailure({
1447
+ adapterName: "FirestoreErrorAdapter",
1448
+ prefix: this.prefix,
1449
+ action: `to write the document`,
1450
+ error
1451
+ });
1063
1452
  }
1064
1453
  }
1065
1454
  };
@@ -1067,34 +1456,51 @@ var RealtimeDatabaseErrorAdapter = class {
1067
1456
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1068
1457
  constructor(reference, options = {}) {
1069
1458
  this.reference = reference;
1070
- this.prefix = options.prefix ?? "[error-log-adapter]";
1071
- this.timeoutMs = resolveTimeout(options, "RealtimeDatabaseErrorAdapter");
1459
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1460
+ this.timeoutMs = resolveTimeoutMs(
1461
+ options.timeoutMs,
1462
+ "RealtimeDatabaseErrorAdapter"
1463
+ );
1072
1464
  }
1073
1465
  async log(entry) {
1074
1466
  try {
1075
- await waitForWrite(
1076
- this.reference.push(buildRecord(entry)),
1077
- this.timeoutMs
1078
- );
1467
+ const record = buildRecord(entry);
1468
+ let write;
1469
+ if (hasCallerId(entry)) {
1470
+ if (this.reference.child === void 0) {
1471
+ throw new TypeError(
1472
+ "the entry has an id, which needs a reference with child(key).set(value) so a repeat overwrites the first write; add child to the reference or log without an id"
1473
+ );
1474
+ }
1475
+ write = this.reference.child(record.id).set(record);
1476
+ } else {
1477
+ write = this.reference.push(record);
1478
+ }
1479
+ await waitForWrite(() => write, this.timeoutMs);
1079
1480
  return { ok: true };
1080
1481
  } catch (error) {
1081
- console.error(`${this.prefix} RealtimeDatabaseErrorAdapter:`, error);
1082
- return {
1083
- ok: false,
1084
- error: `RealtimeDatabaseErrorAdapter failed to write the entry: ${describeError(error)}`
1085
- };
1482
+ return reportFailure({
1483
+ adapterName: "RealtimeDatabaseErrorAdapter",
1484
+ prefix: this.prefix,
1485
+ action: `to write the entry`,
1486
+ error
1487
+ });
1086
1488
  }
1087
1489
  }
1088
1490
  };
1089
1491
 
1090
1492
  // src/adapters/supabase-error-adapter.ts
1091
- var IDENTIFIER_PATTERN3 = /^[A-Za-z_][A-Za-z0-9_]*$/;
1092
- function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1493
+ var POLICY_MAX_CODE_LENGTH = 255;
1494
+ var POLICY_MAX_MESSAGE_LENGTH = 4096;
1495
+ var POLICY_MAX_CAUSE_BYTES = 8192;
1496
+ var INSERT_POLICY_CHECK = `char_length(code) <= ${POLICY_MAX_CODE_LENGTH} AND char_length(message) <= ${POLICY_MAX_MESSAGE_LENGTH} AND (source IS NULL OR char_length(source) <= ${POLICY_MAX_CODE_LENGTH}) AND (cause IS NULL OR pg_column_size(cause) <= ${POLICY_MAX_CAUSE_BYTES})`;
1497
+ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME, options = {}) {
1093
1498
  const insertRoles = options.insertRoles ?? [];
1094
1499
  for (const role of insertRoles) {
1095
- if (!IDENTIFIER_PATTERN3.test(role)) {
1500
+ assertIdentifier(role, "role name", "buildSupabaseSetupSql");
1501
+ if ((role.toLowerCase() === "anon" || role.toLowerCase() === "public") && options.allowPublicInsert !== true) {
1096
1502
  throw new RangeError(
1097
- `buildSupabaseSetupSql: invalid role name "${role}". Use only letters, digits and underscores, and do not start with a digit.`
1503
+ `buildSupabaseSetupSql: role "${role}" would let anyone who loads your page write to the log, because the anon key is public. Use "authenticated" instead, or pass allowPublicInsert: true to accept the risk of forged entries and quota use.`
1098
1504
  );
1099
1505
  }
1100
1506
  }
@@ -1106,7 +1512,7 @@ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1106
1512
  const policy = `${tableName}_insert_only`;
1107
1513
  statements.push(
1108
1514
  `DROP POLICY IF EXISTS "${policy}" ON "${tableName}"`,
1109
- `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (true)`
1515
+ `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (${INSERT_POLICY_CHECK})`
1110
1516
  );
1111
1517
  }
1112
1518
  return statements;
@@ -1136,44 +1542,52 @@ function describeSupabaseError(result) {
1136
1542
  return parts.join("; ");
1137
1543
  }
1138
1544
  var SupabaseErrorAdapter = class {
1139
- /** @throws {RangeError} if `options.tableName` is empty. */
1545
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
1140
1546
  constructor(client, options = {}) {
1141
1547
  this.client = client;
1142
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1143
- if (this.tableName.trim() === "") {
1144
- throw new RangeError(
1145
- "SupabaseErrorAdapter: tableName must not be empty."
1146
- );
1147
- }
1148
- this.prefix = options.prefix ?? "[error-log-adapter]";
1548
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
1549
+ assertIdentifier(this.tableName, "table name", "SupabaseErrorAdapter");
1550
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1551
+ this.timeoutMs = resolveTimeoutMs(
1552
+ options.timeoutMs,
1553
+ "SupabaseErrorAdapter"
1554
+ );
1149
1555
  }
1150
1556
  async log(entry) {
1151
1557
  try {
1152
1558
  const serialized = serializeEntry(entry);
1153
1559
  const row = {
1154
- id: crypto.randomUUID(),
1560
+ id: resolveEntryId(entry),
1155
1561
  code: serialized.code,
1156
1562
  message: serialized.message,
1157
1563
  cause: serialized.cause === void 0 ? null : serialized.cause,
1158
1564
  source: serialized.source ?? null,
1159
1565
  occurred_at: serialized.timestamp
1160
1566
  };
1161
- const result = await this.client.from(this.tableName).insert(row);
1567
+ const result = await withTimeout(
1568
+ () => this.client.from(this.tableName).insert(row),
1569
+ this.timeoutMs,
1570
+ timeoutMessage("Supabase", this.timeoutMs)
1571
+ );
1162
1572
  if (result.error) {
1163
- console.error(`${this.prefix} SupabaseErrorAdapter:`, result);
1164
- return this.fail(describeSupabaseError(result));
1573
+ if (hasCallerId(entry) && result.error.code === "23505") {
1574
+ return { ok: true };
1575
+ }
1576
+ return this.fail(result, describeSupabaseError(result));
1165
1577
  }
1166
1578
  return { ok: true };
1167
1579
  } catch (error) {
1168
- console.error(`${this.prefix} SupabaseErrorAdapter:`, error);
1169
- return this.fail(describeError(error));
1580
+ return this.fail(error, describeError(error));
1170
1581
  }
1171
1582
  }
1172
- fail(reason) {
1173
- return {
1174
- ok: false,
1175
- error: `SupabaseErrorAdapter failed to insert into table "${this.tableName}": ${reason}`
1176
- };
1583
+ fail(logged, reason) {
1584
+ return reportFailure({
1585
+ adapterName: "SupabaseErrorAdapter",
1586
+ prefix: this.prefix,
1587
+ action: `to insert into table "${this.tableName}"`,
1588
+ error: logged,
1589
+ reason
1590
+ });
1177
1591
  }
1178
1592
  };
1179
1593
  export {
@@ -1195,6 +1609,7 @@ export {
1195
1609
  WebhookErrorAdapter,
1196
1610
  buildCreateErrorLogTableSql,
1197
1611
  buildErrorLogTableDdl,
1612
+ buildMySqlWidenColumnsSql,
1198
1613
  buildSupabaseSetupSql,
1199
1614
  createErrorLogStore,
1200
1615
  formatErrorLogLine,