@richardmcquiston01/error-log-adapter 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CompositeErrorAdapter: () => CompositeErrorAdapter,
24
24
  ConsoleErrorAdapter: () => ConsoleErrorAdapter,
25
+ EventEmitterErrorAdapter: () => EventEmitterErrorAdapter,
25
26
  FirestoreErrorAdapter: () => FirestoreErrorAdapter,
26
27
  FlatFileErrorAdapter: () => FlatFileErrorAdapter,
27
28
  IndexedDbErrorAdapter: () => IndexedDbErrorAdapter,
@@ -38,6 +39,7 @@ __export(index_exports, {
38
39
  WebhookErrorAdapter: () => WebhookErrorAdapter,
39
40
  buildCreateErrorLogTableSql: () => buildCreateErrorLogTableSql,
40
41
  buildErrorLogTableDdl: () => buildErrorLogTableDdl,
42
+ buildMySqlWidenColumnsSql: () => buildMySqlWidenColumnsSql,
41
43
  buildSupabaseSetupSql: () => buildSupabaseSetupSql,
42
44
  createErrorLogStore: () => createErrorLogStore,
43
45
  formatErrorLogLine: () => formatErrorLogLine,
@@ -47,11 +49,100 @@ __export(index_exports, {
47
49
  module.exports = __toCommonJS(index_exports);
48
50
 
49
51
  // src/serialize.ts
50
- function serializeCause(cause) {
51
- if (cause instanceof Error) {
52
- return { name: cause.name, message: cause.message, stack: cause.stack };
52
+ var MAX_CAUSE_DEPTH = 6;
53
+ function isErrorLike(value) {
54
+ if (value instanceof Error) {
55
+ return true;
56
+ }
57
+ const candidate = value;
58
+ return typeof candidate.name === "string" && typeof candidate.message === "string" && typeof value.stack === "string";
59
+ }
60
+ function readProperty(source, key) {
61
+ try {
62
+ return source[key];
63
+ } catch (error) {
64
+ return `[Unreadable: ${error instanceof Error ? error.message : String(error)}]`;
65
+ }
66
+ }
67
+ function toJsonSafe(value, depth, ancestors) {
68
+ switch (typeof value) {
69
+ case "string":
70
+ case "boolean":
71
+ case "undefined":
72
+ return value;
73
+ case "number":
74
+ return Number.isFinite(value) ? value : String(value);
75
+ case "bigint":
76
+ return `${value}n`;
77
+ case "symbol":
78
+ return value.toString();
79
+ case "function":
80
+ return `[Function ${value.name || "anonymous"}]`;
81
+ default:
82
+ break;
83
+ }
84
+ if (value === null) {
85
+ return null;
86
+ }
87
+ const objectValue = value;
88
+ if (ancestors.has(objectValue)) {
89
+ return "[Circular]";
90
+ }
91
+ if (depth >= MAX_CAUSE_DEPTH) {
92
+ return "[Truncated]";
93
+ }
94
+ ancestors.add(objectValue);
95
+ try {
96
+ if (isErrorLike(objectValue)) {
97
+ const result2 = {
98
+ name: objectValue.name,
99
+ message: objectValue.message,
100
+ stack: objectValue.stack
101
+ };
102
+ const nested = readProperty(objectValue, "cause");
103
+ if (nested !== void 0) {
104
+ result2["cause"] = toJsonSafe(nested, depth + 1, ancestors);
105
+ }
106
+ const errors = readProperty(objectValue, "errors");
107
+ if (Array.isArray(errors)) {
108
+ result2["errors"] = errors.map(
109
+ (item) => toJsonSafe(item, depth + 1, ancestors)
110
+ );
111
+ }
112
+ return result2;
113
+ }
114
+ if (Array.isArray(value)) {
115
+ return value.map(
116
+ (item) => toJsonSafe(item, depth + 1, ancestors)
117
+ );
118
+ }
119
+ const toJson = readProperty(objectValue, "toJSON");
120
+ if (typeof toJson === "function") {
121
+ try {
122
+ return toJsonSafe(
123
+ toJson.call(objectValue),
124
+ depth + 1,
125
+ ancestors
126
+ );
127
+ } catch (error) {
128
+ return `[toJSON failed: ${error instanceof Error ? error.message : String(error)}]`;
129
+ }
130
+ }
131
+ const result = {};
132
+ for (const key of Object.keys(objectValue)) {
133
+ result[key] = toJsonSafe(
134
+ readProperty(objectValue, key),
135
+ depth + 1,
136
+ ancestors
137
+ );
138
+ }
139
+ return result;
140
+ } finally {
141
+ ancestors.delete(objectValue);
53
142
  }
54
- return cause;
143
+ }
144
+ function serializeCause(cause) {
145
+ return toJsonSafe(cause, 0, /* @__PURE__ */ new WeakSet());
55
146
  }
56
147
  function serializeEntry(entry) {
57
148
  const serialized = {
@@ -71,10 +162,20 @@ function describeError(error) {
71
162
  return error instanceof Error ? error.message : String(error);
72
163
  }
73
164
 
165
+ // src/fail.ts
166
+ var DEFAULT_PREFIX = "[error-log-adapter]";
167
+ function reportFailure(report) {
168
+ console.error(`${report.prefix} ${report.adapterName}:`, report.error);
169
+ return {
170
+ ok: false,
171
+ error: `${report.adapterName} failed ${report.action}: ${report.reason ?? describeError(report.error)}`
172
+ };
173
+ }
174
+
74
175
  // src/adapters/console-error-adapter.ts
75
176
  var ConsoleErrorAdapter = class {
76
177
  constructor(options = {}) {
77
- this.prefix = options.prefix ?? "[error-log-adapter]";
178
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
78
179
  }
79
180
  async log(entry) {
80
181
  try {
@@ -89,35 +190,324 @@ var ConsoleErrorAdapter = class {
89
190
  }
90
191
  };
91
192
 
193
+ // src/uuid.ts
194
+ function generateId() {
195
+ const cryptoApi = globalThis.crypto;
196
+ if (cryptoApi !== void 0 && typeof cryptoApi.randomUUID === "function") {
197
+ return cryptoApi.randomUUID();
198
+ }
199
+ const bytes = new Uint8Array(16);
200
+ if (cryptoApi !== void 0 && typeof cryptoApi.getRandomValues === "function") {
201
+ cryptoApi.getRandomValues(bytes);
202
+ } else {
203
+ for (let index = 0; index < bytes.length; index += 1) {
204
+ bytes[index] = Math.floor(Math.random() * 256);
205
+ }
206
+ }
207
+ bytes[6] = bytes[6] & 15 | 64;
208
+ bytes[8] = bytes[8] & 63 | 128;
209
+ const hex = Array.from(
210
+ bytes,
211
+ (byte) => byte.toString(16).padStart(2, "0")
212
+ ).join("");
213
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
214
+ }
215
+
216
+ // src/entry-id.ts
217
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
218
+ function hasCallerId(entry) {
219
+ return entry.id !== void 0;
220
+ }
221
+ function resolveEntryId(entry) {
222
+ if (entry.id === void 0) {
223
+ return generateId();
224
+ }
225
+ if (typeof entry.id !== "string" || !UUID_PATTERN.test(entry.id)) {
226
+ throw new RangeError(
227
+ `entry.id must be a UUID such as one from crypto.randomUUID(), received ${JSON.stringify(entry.id)}`
228
+ );
229
+ }
230
+ return entry.id.toLowerCase();
231
+ }
232
+
233
+ // src/timeout.ts
234
+ var DEFAULT_TIMEOUT_MS = 1e4;
235
+ var SinkTimeoutError = class extends Error {
236
+ constructor(message) {
237
+ super(message);
238
+ this.name = "SinkTimeoutError";
239
+ }
240
+ };
241
+ function resolveTimeoutMs(value, adapterName) {
242
+ const timeoutMs = value ?? DEFAULT_TIMEOUT_MS;
243
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
244
+ throw new RangeError(
245
+ `${adapterName}: timeoutMs must be a positive number or Infinity, received ${String(value)}.`
246
+ );
247
+ }
248
+ return timeoutMs;
249
+ }
250
+ function timeoutMessage(sink, timeoutMs, action = "respond") {
251
+ 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)`;
252
+ }
253
+ function withTimeout(start, timeoutMs, message) {
254
+ if (timeoutMs === Infinity) {
255
+ return new Promise((resolve, reject) => {
256
+ try {
257
+ resolve(start());
258
+ } catch (error) {
259
+ reject(error);
260
+ }
261
+ });
262
+ }
263
+ return new Promise(
264
+ (resolve, reject) => {
265
+ const timer = setTimeout(() => {
266
+ reject(new SinkTimeoutError(message));
267
+ }, timeoutMs);
268
+ const settle = (done) => (...args) => {
269
+ clearTimeout(timer);
270
+ done(...args);
271
+ };
272
+ try {
273
+ Promise.resolve(start()).then(settle(resolve), settle(reject));
274
+ } catch (error) {
275
+ settle(reject)(error);
276
+ }
277
+ }
278
+ );
279
+ }
280
+
92
281
  // src/adapters/webhook-error-adapter.ts
282
+ var DEFAULT_RETRY_DELAY_MS = 500;
283
+ var DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
284
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
285
+ var SigningUnavailableError = class extends Error {
286
+ };
287
+ async function hmacSha256Hex(secret, message) {
288
+ const subtle = globalThis.crypto?.subtle;
289
+ if (subtle === void 0) {
290
+ throw new SigningUnavailableError(
291
+ "signingSecret needs Web Crypto (crypto.subtle), which this runtime does not provide, so the entry was not sent unsigned"
292
+ );
293
+ }
294
+ const encoder = new TextEncoder();
295
+ const key = await subtle.importKey(
296
+ "raw",
297
+ encoder.encode(secret),
298
+ { name: "HMAC", hash: "SHA-256" },
299
+ false,
300
+ ["sign"]
301
+ );
302
+ const signature = await subtle.sign(
303
+ "HMAC",
304
+ key,
305
+ encoder.encode(message)
306
+ );
307
+ return Array.from(
308
+ new Uint8Array(signature),
309
+ (byte) => byte.toString(16).padStart(2, "0")
310
+ ).join("");
311
+ }
312
+ function parseRetryAfterMs(value) {
313
+ if (value === null || value === void 0 || value.trim() === "") {
314
+ return void 0;
315
+ }
316
+ const seconds = Number(value);
317
+ if (Number.isFinite(seconds) && seconds >= 0) {
318
+ return seconds * 1e3;
319
+ }
320
+ const date = Date.parse(value);
321
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
322
+ }
323
+ function validateRetryOptions(options) {
324
+ const retries = options.retries ?? 0;
325
+ if (!Number.isInteger(retries) || retries < 0) {
326
+ throw new RangeError(
327
+ `WebhookErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
328
+ );
329
+ }
330
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
331
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs <= 0) {
332
+ throw new RangeError(
333
+ `WebhookErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
334
+ );
335
+ }
336
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
337
+ if (!Number.isFinite(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
338
+ throw new RangeError(
339
+ `WebhookErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
340
+ );
341
+ }
342
+ if (options.signingSecret === "") {
343
+ throw new RangeError(
344
+ "WebhookErrorAdapter: signingSecret must not be empty; leave it out to send unsigned."
345
+ );
346
+ }
347
+ return { retries, retryDelayMs, maxRetryDelayMs };
348
+ }
349
+ function sleep(ms) {
350
+ return new Promise((resolve) => {
351
+ setTimeout(resolve, ms);
352
+ });
353
+ }
354
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
355
+ "localhost",
356
+ "127.0.0.1",
357
+ "[::1]"
358
+ ]);
359
+ function parseWebhookUrl(url, allowInsecure) {
360
+ let parsed;
361
+ try {
362
+ parsed = new URL(url);
363
+ } catch {
364
+ throw new RangeError(
365
+ "WebhookErrorAdapter: the webhook URL is not a valid absolute URL (for example https://example.com/errors)."
366
+ );
367
+ }
368
+ if (parsed.protocol === "https:") {
369
+ return parsed;
370
+ }
371
+ if (parsed.protocol === "http:" && (allowInsecure || LOOPBACK_HOSTS.has(parsed.hostname))) {
372
+ return parsed;
373
+ }
374
+ throw new RangeError(
375
+ `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.`
376
+ );
377
+ }
93
378
  var WebhookErrorAdapter = class {
379
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
94
380
  constructor(url, options = {}) {
95
381
  this.url = url;
96
382
  this.options = options;
97
- this.prefix = options.prefix ?? "[error-log-adapter]";
383
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
384
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
385
+ ({
386
+ retries: this.retries,
387
+ retryDelayMs: this.retryDelayMs,
388
+ maxRetryDelayMs: this.maxRetryDelayMs
389
+ } = validateRetryOptions(options));
390
+ this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
98
391
  }
99
392
  async log(entry) {
393
+ let body;
394
+ try {
395
+ body = JSON.stringify(
396
+ hasCallerId(entry) || this.retries > 0 ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
397
+ );
398
+ } catch (error) {
399
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
400
+ return this.fail(
401
+ `could not prepare the entry: ${this.redact(describeError(error))}`
402
+ );
403
+ }
404
+ let attempt = await this.send(body);
405
+ let attempts = 1;
406
+ while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
407
+ await sleep(this.retryWaitMs(attempts, attempt.retryAfterMs));
408
+ attempt = await this.send(body);
409
+ attempts += 1;
410
+ }
411
+ if (attempt.ok) {
412
+ return { ok: true };
413
+ }
414
+ return this.fail(
415
+ attempts > 1 ? `${attempt.reason} (after ${attempts} attempts)` : attempt.reason
416
+ );
417
+ }
418
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
419
+ retryWaitMs(retryNumber, retryAfterMs) {
420
+ if (retryAfterMs !== void 0) {
421
+ return Math.min(retryAfterMs, this.maxRetryDelayMs);
422
+ }
423
+ const ceiling = Math.min(
424
+ this.maxRetryDelayMs,
425
+ this.retryDelayMs * 2 ** (retryNumber - 1)
426
+ );
427
+ return ceiling / 2 + Math.random() * ceiling / 2;
428
+ }
429
+ async send(body) {
430
+ const controller = new AbortController();
431
+ let timedOut = false;
432
+ const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
433
+ timedOut = true;
434
+ controller.abort();
435
+ }, this.timeoutMs);
100
436
  try {
101
437
  const response = await fetch(this.url, {
102
438
  method: "POST",
103
439
  headers: {
104
440
  "Content-Type": "application/json",
105
- ...this.options.headers
441
+ ...this.options.headers,
442
+ ...await this.signatureHeaders(body)
106
443
  },
107
- body: JSON.stringify(serializeEntry(entry))
444
+ body,
445
+ // A redirect would turn the POST into a GET without a body (302, 303)
446
+ // or re-send the entry and headers to another host (307, 308).
447
+ redirect: "manual",
448
+ signal: controller.signal
108
449
  });
450
+ if (response.type === "opaqueredirect") {
451
+ return this.rejected(this.redirectMessage("a redirect"));
452
+ }
109
453
  if (!response.ok) {
110
- return this.fail(
111
- `webhook ${this.url} responded with HTTP ${response.status}`
112
- );
454
+ if (response.status >= 300 && response.status < 400) {
455
+ return this.rejected(this.redirectMessage(`HTTP ${response.status}`));
456
+ }
457
+ return {
458
+ ok: false,
459
+ reason: `webhook ${this.target} responded with HTTP ${response.status}`,
460
+ retryable: response.status >= 500 || RETRYABLE_STATUSES.has(response.status),
461
+ retryAfterMs: parseRetryAfterMs(response.headers?.get("retry-after"))
462
+ };
113
463
  }
114
464
  return { ok: true };
115
465
  } catch (error) {
116
466
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
117
- return this.fail(
118
- `could not reach webhook ${this.url}: ${describeError(error)}`
119
- );
467
+ if (error instanceof SigningUnavailableError) {
468
+ return this.rejected(error.message);
469
+ }
470
+ if (timedOut) {
471
+ return {
472
+ ok: false,
473
+ reason: timeoutMessage(`webhook ${this.target}`, this.timeoutMs),
474
+ retryable: true
475
+ };
476
+ }
477
+ return {
478
+ ok: false,
479
+ reason: `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`,
480
+ retryable: true
481
+ };
482
+ } finally {
483
+ clearTimeout(timer);
484
+ }
485
+ }
486
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
487
+ async signatureHeaders(body) {
488
+ const secret = this.options.signingSecret;
489
+ if (secret === void 0) {
490
+ return {};
120
491
  }
492
+ const timestamp = String(Math.floor(Date.now() / 1e3));
493
+ const signature = await hmacSha256Hex(
494
+ secret,
495
+ `${timestamp}.${body}`
496
+ );
497
+ return {
498
+ "X-Signature-Timestamp": timestamp,
499
+ "X-Signature-256": `sha256=${signature}`
500
+ };
501
+ }
502
+ rejected(reason) {
503
+ return { ok: false, reason, retryable: false };
504
+ }
505
+ redirectMessage(what) {
506
+ return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
507
+ }
508
+ /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
509
+ redact(message) {
510
+ return message.split(this.url).join(this.target);
121
511
  }
122
512
  fail(reason) {
123
513
  return { ok: false, error: `WebhookErrorAdapter failed: ${reason}` };
@@ -125,37 +515,64 @@ var WebhookErrorAdapter = class {
125
515
  };
126
516
 
127
517
  // src/adapters/mongodb-error-adapter.ts
518
+ function isDuplicateKeyError(error) {
519
+ return typeof error === "object" && error !== null && error.code === 11e3;
520
+ }
128
521
  var MongoDbErrorAdapter = class {
522
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
129
523
  constructor(collection, options = {}) {
130
524
  this.collection = collection;
131
- this.prefix = options.prefix ?? "[error-log-adapter]";
525
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
526
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "MongoDbErrorAdapter");
132
527
  }
133
528
  async log(entry) {
134
529
  try {
135
- await this.collection.insertOne(serializeEntry(entry));
530
+ const document = hasCallerId(entry) ? { _id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry);
531
+ await withTimeout(
532
+ () => this.collection.insertOne(document),
533
+ this.timeoutMs,
534
+ timeoutMessage("MongoDB", this.timeoutMs)
535
+ );
136
536
  return { ok: true };
137
537
  } catch (error) {
138
- console.error(`${this.prefix} MongoDbErrorAdapter:`, error);
139
- return {
140
- ok: false,
141
- error: `MongoDbErrorAdapter failed to insert entry: ${describeError(error)}`
142
- };
538
+ if (hasCallerId(entry) && isDuplicateKeyError(error)) {
539
+ return { ok: true };
540
+ }
541
+ return reportFailure({
542
+ adapterName: "MongoDbErrorAdapter",
543
+ prefix: this.prefix,
544
+ action: "to insert entry",
545
+ error
546
+ });
143
547
  }
144
548
  }
145
549
  };
146
550
 
147
551
  // src/adapters/composite-error-adapter.ts
552
+ var DEFAULT_COMPOSITE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 3;
148
553
  var CompositeErrorAdapter = class {
149
- constructor(adapters) {
554
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
555
+ constructor(adapters, options = {}) {
150
556
  this.adapters = adapters;
557
+ this.timeoutMs = resolveTimeoutMs(
558
+ options.timeoutMs ?? DEFAULT_COMPOSITE_TIMEOUT_MS,
559
+ "CompositeErrorAdapter"
560
+ );
151
561
  }
152
562
  async log(entry) {
153
563
  const results = await Promise.all(
154
564
  this.adapters.map(
155
- async (adapter) => {
565
+ async (adapter, index) => {
156
566
  try {
157
- return await adapter.log(entry);
567
+ return await withTimeout(
568
+ () => adapter.log(entry),
569
+ this.timeoutMs,
570
+ `adapter ${index} (${adapter.constructor.name}) did not return within ${this.timeoutMs} ms, so the entry may or may not have been stored there`
571
+ );
158
572
  } catch (error) {
573
+ if (error instanceof SinkTimeoutError) {
574
+ return { ok: false, error: error.message };
575
+ }
159
576
  return {
160
577
  ok: false,
161
578
  error: `adapter threw instead of returning a result: ${String(error)}`
@@ -186,58 +603,101 @@ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
186
603
  database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
187
604
  }
188
605
  var IndexedDbErrorAdapter = class {
606
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
189
607
  constructor(database, options = {}) {
190
608
  this.database = database;
191
609
  this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
192
- this.prefix = options.prefix ?? "[error-log-adapter]";
610
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
611
+ this.timeoutMs = resolveTimeoutMs(
612
+ options.timeoutMs,
613
+ "IndexedDbErrorAdapter"
614
+ );
193
615
  }
194
616
  async log(entry) {
195
617
  try {
196
618
  const record = {
197
- id: crypto.randomUUID(),
619
+ id: resolveEntryId(entry),
198
620
  ...serializeEntry(entry)
199
621
  };
200
- await this.write(record);
622
+ await this.write(record, hasCallerId(entry));
201
623
  return { ok: true };
202
624
  } catch (error) {
203
- console.error(`${this.prefix} IndexedDbErrorAdapter:`, error);
204
- return {
205
- ok: false,
206
- error: `IndexedDbErrorAdapter failed to write to object store "${this.storeName}": ${describeError(error)}`
207
- };
625
+ return reportFailure({
626
+ adapterName: "IndexedDbErrorAdapter",
627
+ prefix: this.prefix,
628
+ action: `to write to object store "${this.storeName}"`,
629
+ error
630
+ });
208
631
  }
209
632
  }
210
- /** Resolves once the transaction commits; rejects if it errors or aborts. */
211
- write(record) {
212
- return new Promise(
213
- (resolve, reject) => {
214
- const transaction = this.database.transaction(
215
- this.storeName,
216
- "readwrite"
217
- );
218
- const fail = (action) => () => reject(
219
- new Error(
220
- `transaction ${action}: ${transaction.error?.message ?? "no details provided"}`
221
- )
222
- );
223
- transaction.oncomplete = () => resolve();
224
- transaction.onerror = fail("failed");
225
- transaction.onabort = fail("aborted");
226
- transaction.objectStore(this.storeName).add(record);
227
- }
633
+ /**
634
+ * Resolves once the transaction commits; rejects if the request fails, the
635
+ * transaction aborts, or it does not finish within `timeoutMs`.
636
+ *
637
+ * Error events are handled here (`preventDefault` and `stopPropagation`), so a
638
+ * failed log write does not reach the `onerror` handler an app may have set on
639
+ * its own database object.
640
+ */
641
+ write(record, replaceExisting) {
642
+ return withTimeout(
643
+ () => new Promise(
644
+ (resolve, reject) => {
645
+ const transaction = this.database.transaction(
646
+ this.storeName,
647
+ "readwrite"
648
+ );
649
+ const fail = (what, source) => (event) => {
650
+ event?.preventDefault?.();
651
+ event?.stopPropagation?.();
652
+ reject(new Error(`${what}: ${describeDomError(source.error)}`));
653
+ };
654
+ transaction.oncomplete = () => resolve();
655
+ transaction.onerror = fail("transaction failed", transaction);
656
+ transaction.onabort = fail("transaction aborted", transaction);
657
+ const store = transaction.objectStore(
658
+ this.storeName
659
+ );
660
+ const request = replaceExisting && typeof store.put === "function" ? store.put(record) : store.add(record);
661
+ if (typeof request === "object" && request !== null) {
662
+ request.onerror = fail(
663
+ "request failed",
664
+ request
665
+ );
666
+ }
667
+ }
668
+ ),
669
+ this.timeoutMs,
670
+ timeoutMessage("IndexedDB", this.timeoutMs, "finish the transaction")
228
671
  );
229
672
  }
230
673
  };
674
+ function describeDomError(error) {
675
+ if (error === null || error === void 0) {
676
+ return "no details provided";
677
+ }
678
+ const text = [error.name, error.message].filter((part) => typeof part === "string" && part !== "").join(": ");
679
+ return text === "" ? "no details provided" : text;
680
+ }
231
681
 
232
- // src/adapters/sqlite-error-adapter.ts
682
+ // src/sql-ident.ts
233
683
  var DEFAULT_TABLE_NAME = "error_log";
234
684
  var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
235
- function assertValidTableName(tableName) {
236
- if (!IDENTIFIER_PATTERN.test(tableName)) {
685
+ function assertIdentifier(value, label, owner, maxLength) {
686
+ if (!IDENTIFIER_PATTERN.test(value)) {
237
687
  throw new RangeError(
238
- `SqliteErrorAdapter: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
688
+ `${owner}: invalid ${label} "${value}". Use only letters, digits and underscores, and do not start with a digit.`
239
689
  );
240
690
  }
691
+ if (maxLength !== void 0 && value.length > maxLength) {
692
+ throw new RangeError(
693
+ `${owner}: ${label} "${value}" is ${value.length} characters long, but the limit is ${maxLength}.`
694
+ );
695
+ }
696
+ }
697
+
698
+ // src/adapters/sqlite-error-adapter.ts
699
+ function assertValidTableName(tableName) {
700
+ assertIdentifier(tableName, "table name", "SqliteErrorAdapter");
241
701
  }
242
702
  function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
243
703
  assertValidTableName(tableName);
@@ -247,23 +707,24 @@ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
247
707
  ];
248
708
  }
249
709
  var SqliteErrorAdapter = class {
250
- /** @throws {RangeError} if `options.tableName` is not a plain identifier. */
710
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
251
711
  constructor(client, options = {}) {
252
712
  this.client = client;
253
713
  this.tableReady = null;
254
714
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
255
715
  assertValidTableName(this.tableName);
256
716
  this.createTable = options.createTable ?? true;
257
- this.prefix = options.prefix ?? "[error-log-adapter]";
717
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
718
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "SqliteErrorAdapter");
258
719
  }
259
720
  async log(entry) {
260
721
  try {
261
722
  await this.ensureTable();
262
723
  const serialized = serializeEntry(entry);
263
- await this.client.run(
264
- `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?)`,
724
+ await this.run(
725
+ `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`,
265
726
  [
266
- crypto.randomUUID(),
727
+ resolveEntryId(entry),
267
728
  serialized.code,
268
729
  serialized.message,
269
730
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -273,11 +734,12 @@ var SqliteErrorAdapter = class {
273
734
  );
274
735
  return { ok: true };
275
736
  } catch (error) {
276
- console.error(`${this.prefix} SqliteErrorAdapter:`, error);
277
- return {
278
- ok: false,
279
- error: `SqliteErrorAdapter failed to write to table "${this.tableName}": ${describeError(error)}`
280
- };
737
+ return reportFailure({
738
+ adapterName: "SqliteErrorAdapter",
739
+ prefix: this.prefix,
740
+ action: `to write to table "${this.tableName}"`,
741
+ error
742
+ });
281
743
  }
282
744
  }
283
745
  /** Creates the table once. A failed attempt is retried on the next log call. */
@@ -297,9 +759,17 @@ var SqliteErrorAdapter = class {
297
759
  }
298
760
  async runCreateStatements() {
299
761
  for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
300
- await this.client.run(sql, []);
762
+ await this.run(sql, []);
301
763
  }
302
764
  }
765
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
766
+ run(sql, params) {
767
+ return withTimeout(
768
+ () => this.client.run(sql, params),
769
+ this.timeoutMs,
770
+ timeoutMessage("SQLite", this.timeoutMs)
771
+ );
772
+ }
303
773
  };
304
774
 
305
775
  // src/adapters/web-storage-error-adapter.ts
@@ -318,7 +788,8 @@ var WebStorageErrorAdapter = class {
318
788
  this.storage = storage;
319
789
  this.key = options.key ?? DEFAULT_STORAGE_KEY;
320
790
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
321
- this.prefix = options.prefix ?? "[error-log-adapter]";
791
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
792
+ this.locks = options.locks === void 0 ? defaultLockManager() : options.locks;
322
793
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
323
794
  throw new RangeError(
324
795
  `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
@@ -328,23 +799,49 @@ var WebStorageErrorAdapter = class {
328
799
  async log(entry) {
329
800
  try {
330
801
  const record = {
331
- id: crypto.randomUUID(),
802
+ id: resolveEntryId(entry),
332
803
  ...serializeEntry(entry)
333
804
  };
334
- this.append(record);
805
+ await this.appendLocked(record);
335
806
  return { ok: true };
336
807
  } catch (error) {
337
- console.error(`${this.prefix} WebStorageErrorAdapter:`, error);
338
- return {
339
- ok: false,
340
- error: `WebStorageErrorAdapter failed to write to storage key "${this.key}": ${describeError(error)}`
341
- };
808
+ return reportFailure({
809
+ adapterName: "WebStorageErrorAdapter",
810
+ prefix: this.prefix,
811
+ action: `to write to storage key "${this.key}"`,
812
+ error
813
+ });
814
+ }
815
+ }
816
+ /**
817
+ * Appends under a cross-tab lock when one is available. If the lock itself
818
+ * cannot be taken (for example a sandboxed frame), the entry is still written
819
+ * without it rather than lost.
820
+ */
821
+ async appendLocked(record) {
822
+ if (this.locks === null) {
823
+ this.append(record);
824
+ return;
825
+ }
826
+ let appended = false;
827
+ try {
828
+ await this.locks.request(`error-log-adapter:${this.key}`, () => {
829
+ appended = true;
830
+ this.append(record);
831
+ });
832
+ } catch (error) {
833
+ if (appended) {
834
+ throw error;
835
+ }
836
+ this.append(record);
342
837
  }
343
838
  }
344
- /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
839
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls in one tab never interleave. */
345
840
  append(record) {
346
841
  const records = [
347
- ...this.readRecords(),
842
+ ...this.readRecords().filter(
843
+ (existing) => existing?.id !== record.id
844
+ ),
348
845
  record
349
846
  ].slice(-this.maxEntries);
350
847
  for (; ; ) {
@@ -370,16 +867,51 @@ var WebStorageErrorAdapter = class {
370
867
  if (Array.isArray(parsed)) {
371
868
  return parsed;
372
869
  }
373
- console.error(
374
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
375
- );
870
+ this.setAside(stored, "did not hold an array");
376
871
  } catch (error) {
377
- console.error(
378
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
379
- );
872
+ this.setAside(stored, `held invalid JSON (${describeError(error)})`);
380
873
  }
381
874
  return [];
382
875
  }
876
+ /**
877
+ * Copies an unusable value to `<key>.corrupt` before it is replaced, so data
878
+ * the host app stored under the same key is not silently destroyed. Only the
879
+ * first one is kept; a later unusable value would not overwrite it.
880
+ */
881
+ setAside(stored, reason) {
882
+ const backupKey = `${this.key}${CORRUPT_KEY_SUFFIX}`;
883
+ let savedTo = "but it could not be copied aside";
884
+ try {
885
+ if (this.storage.getItem(backupKey) === null) {
886
+ this.storage.setItem(backupKey, stored);
887
+ savedTo = `and a copy was kept under "${backupKey}"`;
888
+ } else {
889
+ savedTo = `and "${backupKey}" already holds an earlier copy`;
890
+ }
891
+ } catch {
892
+ }
893
+ console.error(
894
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" ${reason}, so it was replaced, ${savedTo}.`
895
+ );
896
+ }
897
+ };
898
+ var CORRUPT_KEY_SUFFIX = ".corrupt";
899
+ function defaultLockManager() {
900
+ const locks = globalThis.navigator?.locks;
901
+ return typeof locks?.request === "function" ? locks : null;
902
+ }
903
+
904
+ // src/queue.ts
905
+ var SerialQueue = class {
906
+ constructor() {
907
+ this.tail = Promise.resolve();
908
+ }
909
+ /** Starts `task` once every earlier task has finished, and resolves with its result. */
910
+ run(task) {
911
+ const result = this.tail.then(task);
912
+ this.tail = result.catch(() => void 0);
913
+ return result;
914
+ }
383
915
  };
384
916
 
385
917
  // src/file-system.ts
@@ -387,6 +919,7 @@ async function loadNodeFileSystem() {
387
919
  const moduleName = "node:fs/promises";
388
920
  return await import(moduleName);
389
921
  }
922
+ var ROOT_PATTERN = /^(?:[A-Za-z]:)?[\\/]+$/;
390
923
  function parentDirectory(filePath) {
391
924
  const lastSeparator = Math.max(
392
925
  filePath.lastIndexOf("/"),
@@ -395,7 +928,11 @@ function parentDirectory(filePath) {
395
928
  if (lastSeparator < 0) {
396
929
  return null;
397
930
  }
398
- return lastSeparator === 0 ? "/" : filePath.slice(0, lastSeparator);
931
+ const directory = filePath.slice(0, lastSeparator + 1);
932
+ if (ROOT_PATTERN.test(directory)) {
933
+ return null;
934
+ }
935
+ return filePath.slice(0, lastSeparator);
399
936
  }
400
937
  function isMissingFileError(error) {
401
938
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -441,24 +978,20 @@ var FlatFileErrorAdapter = class {
441
978
  constructor(filePath, options = {}) {
442
979
  this.filePath = filePath;
443
980
  this.directoryReady = false;
444
- this.queue = Promise.resolve();
981
+ this.queue = new SerialQueue();
445
982
  this.fs = options.fs;
446
983
  this.formatLine = options.formatLine ?? formatErrorLogLine;
447
984
  this.maxBytes = options.maxBytes;
448
985
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
449
986
  this.createDirectory = options.createDirectory ?? true;
450
- this.prefix = options.prefix ?? "[error-log-adapter]";
987
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
451
988
  if (this.maxBytes !== void 0) {
452
989
  assertPositiveInteger(this.maxBytes, "maxBytes");
453
990
  }
454
991
  assertPositiveInteger(this.maxFiles, "maxFiles");
455
992
  }
456
993
  log(entry) {
457
- const result = this.queue.then(
458
- () => this.write(entry)
459
- );
460
- this.queue = result.catch(() => void 0);
461
- return result;
994
+ return this.queue.run(() => this.write(entry));
462
995
  }
463
996
  async write(entry) {
464
997
  try {
@@ -471,11 +1004,12 @@ var FlatFileErrorAdapter = class {
471
1004
  await fs.appendFile(this.filePath, line);
472
1005
  return { ok: true };
473
1006
  } catch (error) {
474
- console.error(`${this.prefix} FlatFileErrorAdapter:`, error);
475
- return {
476
- ok: false,
477
- error: `FlatFileErrorAdapter failed to append to "${this.filePath}": ${describeError(error)}`
478
- };
1007
+ return reportFailure({
1008
+ adapterName: "FlatFileErrorAdapter",
1009
+ prefix: this.prefix,
1010
+ action: `to append to "${this.filePath}"`,
1011
+ error
1012
+ });
479
1013
  }
480
1014
  }
481
1015
  /** Uses the injected file system, or loads `node:fs/promises` on first use. */
@@ -550,13 +1084,13 @@ var JsonFileErrorAdapter = class {
550
1084
  constructor(filePath, options = {}) {
551
1085
  this.filePath = filePath;
552
1086
  this.directoryReady = false;
553
- this.queue = Promise.resolve();
1087
+ this.queue = new SerialQueue();
554
1088
  this.mode = options.mode ?? "ndjson";
555
1089
  this.maxEntries = options.maxEntries;
556
1090
  this.indent = options.indent ?? DEFAULT_INDENT;
557
1091
  this.fs = options.fs;
558
1092
  this.createDirectory = options.createDirectory ?? true;
559
- this.prefix = options.prefix ?? "[error-log-adapter]";
1093
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
560
1094
  if (this.maxEntries !== void 0) {
561
1095
  if (this.mode !== "array") {
562
1096
  throw new RangeError(
@@ -576,16 +1110,12 @@ var JsonFileErrorAdapter = class {
576
1110
  }
577
1111
  }
578
1112
  log(entry) {
579
- const result = this.queue.then(
580
- () => this.write(entry)
581
- );
582
- this.queue = result.catch(() => void 0);
583
- return result;
1113
+ return this.queue.run(() => this.write(entry));
584
1114
  }
585
1115
  async write(entry) {
586
1116
  try {
587
1117
  const record = {
588
- id: crypto.randomUUID(),
1118
+ id: resolveEntryId(entry),
589
1119
  ...serializeEntry(entry)
590
1120
  };
591
1121
  const fs = await this.getFileSystem();
@@ -598,11 +1128,12 @@ var JsonFileErrorAdapter = class {
598
1128
  }
599
1129
  return { ok: true };
600
1130
  } catch (error) {
601
- console.error(`${this.prefix} JsonFileErrorAdapter:`, error);
602
- return {
603
- ok: false,
604
- error: `JsonFileErrorAdapter failed to write "${this.filePath}" in ${this.mode} mode: ${describeError(error)}`
605
- };
1131
+ return reportFailure({
1132
+ adapterName: "JsonFileErrorAdapter",
1133
+ prefix: this.prefix,
1134
+ action: `to write "${this.filePath}" in ${this.mode} mode`,
1135
+ error
1136
+ });
606
1137
  }
607
1138
  }
608
1139
  async getFileSystem() {
@@ -628,7 +1159,9 @@ var JsonFileErrorAdapter = class {
628
1159
  */
629
1160
  async rewriteArray(fs, record) {
630
1161
  const records = [
631
- ...await this.readRecords(fs),
1162
+ ...(await this.readRecords(fs)).filter(
1163
+ (existing) => existing?.id !== record.id
1164
+ ),
632
1165
  record
633
1166
  ];
634
1167
  const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
@@ -678,25 +1211,14 @@ var JsonFileErrorAdapter = class {
678
1211
  };
679
1212
 
680
1213
  // src/adapters/sql-error-adapter.ts
681
- var DEFAULT_TABLE_NAME2 = "error_log";
682
1214
  var MAX_TABLE_NAME_LENGTH = 47;
683
- var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
684
1215
  function assertValidTableName2(tableName, adapterName) {
685
- if (!IDENTIFIER_PATTERN2.test(tableName)) {
686
- throw new RangeError(
687
- `${adapterName}: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
688
- );
689
- }
690
- if (tableName.length > MAX_TABLE_NAME_LENGTH) {
691
- throw new RangeError(
692
- `${adapterName}: table name "${tableName}" is ${tableName.length} characters long, but the limit is ${MAX_TABLE_NAME_LENGTH}.`
693
- );
694
- }
1216
+ assertIdentifier(tableName, "table name", adapterName, MAX_TABLE_NAME_LENGTH);
695
1217
  }
696
1218
  function quoteIdentifier(dialect, identifier) {
697
1219
  return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
698
1220
  }
699
- function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
1221
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME) {
700
1222
  assertValidTableName2(tableName, "buildErrorLogTableDdl");
701
1223
  const table = quoteIdentifier(dialect, tableName);
702
1224
  const index = quoteIdentifier(
@@ -710,7 +1232,13 @@ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
710
1232
  ];
711
1233
  }
712
1234
  return [
713
- `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))`
1235
+ `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))`
1236
+ ];
1237
+ }
1238
+ function buildMySqlWidenColumnsSql(tableName = DEFAULT_TABLE_NAME) {
1239
+ assertValidTableName2(tableName, "buildMySqlWidenColumnsSql");
1240
+ return [
1241
+ `ALTER TABLE ${quoteIdentifier("mysql", tableName)} MODIFY code TEXT NOT NULL, MODIFY source TEXT NULL`
714
1242
  ];
715
1243
  }
716
1244
  function toMysqlDateTime(date) {
@@ -722,10 +1250,11 @@ var SqlErrorAdapter = class {
722
1250
  this.dialect = dialect;
723
1251
  this.adapterName = adapterName;
724
1252
  this.tableReady = null;
725
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1253
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
726
1254
  assertValidTableName2(this.tableName, adapterName);
727
1255
  this.createTable = options.createTable ?? true;
728
- this.prefix = options.prefix ?? "[error-log-adapter]";
1256
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1257
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, adapterName);
729
1258
  }
730
1259
  async log(entry) {
731
1260
  try {
@@ -737,7 +1266,7 @@ var SqlErrorAdapter = class {
737
1266
  );
738
1267
  }
739
1268
  const params = [
740
- crypto.randomUUID(),
1269
+ resolveEntryId(entry),
741
1270
  serialized.code,
742
1271
  serialized.message,
743
1272
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -745,19 +1274,21 @@ var SqlErrorAdapter = class {
745
1274
  this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
746
1275
  ];
747
1276
  await this.ensureTable();
748
- await this.client.query(this.insertSql(), params);
1277
+ await this.query(this.insertSql(), params);
749
1278
  return { ok: true };
750
1279
  } catch (error) {
751
- console.error(`${this.prefix} ${this.adapterName}:`, error);
752
- return {
753
- ok: false,
754
- error: `${this.adapterName} failed to write to table "${this.tableName}": ${describeError(error)}`
755
- };
1280
+ return reportFailure({
1281
+ adapterName: this.adapterName,
1282
+ prefix: this.prefix,
1283
+ action: `to write to table "${this.tableName}"`,
1284
+ error
1285
+ });
756
1286
  }
757
1287
  }
758
1288
  insertSql() {
759
1289
  const placeholders = this.dialect === "postgres" ? "$1, $2, $3, $4, $5, $6" : "?, ?, ?, ?, ?, ?";
760
- return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders})`;
1290
+ const onRepeat = this.dialect === "postgres" ? "ON CONFLICT (id) DO NOTHING" : "ON DUPLICATE KEY UPDATE id = id";
1291
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders}) ${onRepeat}`;
761
1292
  }
762
1293
  /** Creates the table once. A failed attempt is retried on the next log call. */
763
1294
  ensureTable() {
@@ -776,9 +1307,20 @@ var SqlErrorAdapter = class {
776
1307
  }
777
1308
  async runCreateStatements() {
778
1309
  for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
779
- await this.client.query(sql, []);
1310
+ await this.query(sql, []);
780
1311
  }
781
1312
  }
1313
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
1314
+ query(sql, params) {
1315
+ return withTimeout(
1316
+ () => this.client.query(sql, params),
1317
+ this.timeoutMs,
1318
+ timeoutMessage(
1319
+ this.dialect === "postgres" ? "Postgres" : "MySQL",
1320
+ this.timeoutMs
1321
+ )
1322
+ );
1323
+ }
782
1324
  };
783
1325
  var PostgresErrorAdapter = class extends SqlErrorAdapter {
784
1326
  /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
@@ -803,7 +1345,8 @@ var RedisErrorAdapter = class {
803
1345
  this.key = options.key ?? DEFAULT_REDIS_KEY;
804
1346
  this.maxEntries = options.maxEntries;
805
1347
  this.side = options.side ?? "right";
806
- this.prefix = options.prefix ?? "[error-log-adapter]";
1348
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1349
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "RedisErrorAdapter");
807
1350
  if (this.mode !== "list" && this.mode !== "stream") {
808
1351
  throw new RangeError(
809
1352
  `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
@@ -825,11 +1368,11 @@ var RedisErrorAdapter = class {
825
1368
  }
826
1369
  async log(entry) {
827
1370
  try {
828
- const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
1371
+ const record = { id: resolveEntryId(entry), ...serializeEntry(entry) };
829
1372
  if (this.mode === "stream") {
830
- await this.client.sendCommand(this.buildStreamCommand(record));
1373
+ await this.send(this.buildStreamCommand(record));
831
1374
  } else {
832
- await this.client.sendCommand([
1375
+ await this.send([
833
1376
  this.side === "left" ? "LPUSH" : "RPUSH",
834
1377
  this.key,
835
1378
  JSON.stringify(record)
@@ -838,11 +1381,12 @@ var RedisErrorAdapter = class {
838
1381
  }
839
1382
  return { ok: true };
840
1383
  } catch (error) {
841
- console.error(`${this.prefix} RedisErrorAdapter:`, error);
842
- return {
843
- ok: false,
844
- error: `RedisErrorAdapter failed to write to ${this.mode} "${this.key}": ${describeError(error)}`
845
- };
1384
+ return reportFailure({
1385
+ adapterName: "RedisErrorAdapter",
1386
+ prefix: this.prefix,
1387
+ action: `to write to ${this.mode} "${this.key}"`,
1388
+ error
1389
+ });
846
1390
  }
847
1391
  }
848
1392
  buildStreamCommand(record) {
@@ -874,6 +1418,14 @@ var RedisErrorAdapter = class {
874
1418
  * stored by now, so a trim failure is reported but does not fail the call;
875
1419
  * the next successful write trims the list again.
876
1420
  */
1421
+ /** Sends one command, giving up waiting after `timeoutMs`. */
1422
+ send(args) {
1423
+ return withTimeout(
1424
+ () => this.client.sendCommand(args),
1425
+ this.timeoutMs,
1426
+ timeoutMessage("Redis", this.timeoutMs)
1427
+ );
1428
+ }
877
1429
  async trimList() {
878
1430
  if (this.maxEntries === void 0) {
879
1431
  return;
@@ -881,7 +1433,7 @@ var RedisErrorAdapter = class {
881
1433
  const keep = this.maxEntries;
882
1434
  const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
883
1435
  try {
884
- await this.client.sendCommand(["LTRIM", this.key, start, stop]);
1436
+ await this.send(["LTRIM", this.key, start, stop]);
885
1437
  } catch (error) {
886
1438
  console.error(
887
1439
  `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
@@ -908,11 +1460,12 @@ var S3ErrorAdapter = class {
908
1460
  this.bucket = bucket;
909
1461
  this.buffer = [];
910
1462
  this.timer = null;
911
- this.flushQueue = Promise.resolve();
1463
+ this.flushQueue = new SerialQueue();
912
1464
  this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
913
1465
  this.batch = options.batch;
914
1466
  this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
915
- this.logPrefix = options.prefix ?? "[error-log-adapter]";
1467
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
1468
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "S3ErrorAdapter");
916
1469
  if (bucket === "") {
917
1470
  throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
918
1471
  }
@@ -932,7 +1485,7 @@ var S3ErrorAdapter = class {
932
1485
  async log(entry) {
933
1486
  try {
934
1487
  const record = {
935
- id: crypto.randomUUID(),
1488
+ id: resolveEntryId(entry),
936
1489
  ...serializeEntry(entry)
937
1490
  };
938
1491
  const occurredAt = new Date(record.timestamp);
@@ -957,11 +1510,9 @@ var S3ErrorAdapter = class {
957
1510
  * Entries from a failed upload stay buffered and are retried by the next flush.
958
1511
  */
959
1512
  flush() {
960
- const result = this.flushQueue.then(
1513
+ return this.flushQueue.run(
961
1514
  () => this.uploadBuffered()
962
1515
  );
963
- this.flushQueue = result.catch(() => void 0);
964
- return result;
965
1516
  }
966
1517
  async addToBatch(record, batch) {
967
1518
  this.buffer.push(record);
@@ -1000,7 +1551,7 @@ var S3ErrorAdapter = class {
1000
1551
  return { ok: true };
1001
1552
  }
1002
1553
  try {
1003
- const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1554
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${generateId()}.ndjson`;
1004
1555
  const body = pending.map((record) => JSON.stringify(record)).join("\n");
1005
1556
  await this.upload(key, `${body}
1006
1557
  `);
@@ -1017,64 +1568,41 @@ var S3ErrorAdapter = class {
1017
1568
  }
1018
1569
  }
1019
1570
  upload(key, body) {
1020
- return this.client.putObject({
1021
- Bucket: this.bucket,
1022
- Key: key,
1023
- Body: body,
1024
- ContentType: this.contentType
1025
- });
1571
+ return withTimeout(
1572
+ () => this.client.putObject({
1573
+ Bucket: this.bucket,
1574
+ Key: key,
1575
+ Body: body,
1576
+ ContentType: this.contentType
1577
+ }),
1578
+ this.timeoutMs,
1579
+ timeoutMessage("S3", this.timeoutMs)
1580
+ );
1026
1581
  }
1027
1582
  fail(error, bufferedCount) {
1028
- console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
1029
1583
  const retry = bufferedCount === void 0 ? "" : ` (${bufferedCount} buffered ${bufferedCount === 1 ? "entry was" : "entries were"} kept for the next flush)`;
1030
- return {
1031
- ok: false,
1032
- error: `S3ErrorAdapter failed to upload to bucket "${this.bucket}": ${describeError(error)}${retry}`
1033
- };
1584
+ return reportFailure({
1585
+ adapterName: "S3ErrorAdapter",
1586
+ prefix: this.logPrefix,
1587
+ action: `to upload to bucket "${this.bucket}"`,
1588
+ error,
1589
+ reason: `${describeError(error)}${retry}`
1590
+ });
1034
1591
  }
1035
1592
  };
1036
1593
 
1037
1594
  // src/adapters/firebase-error-adapter.ts
1038
- var DEFAULT_WRITE_TIMEOUT_MS = 1e4;
1039
- function resolveTimeout(options, adapter) {
1040
- const timeoutMs = options.timeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
1041
- if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
1042
- throw new RangeError(
1043
- `${adapter}: timeoutMs must be a positive number or Infinity, received ${String(options.timeoutMs)}.`
1044
- );
1045
- }
1046
- return timeoutMs;
1047
- }
1048
- function waitForWrite(write, timeoutMs) {
1049
- if (timeoutMs === Infinity) {
1050
- return Promise.resolve(write);
1051
- }
1052
- return new Promise(
1053
- (resolve, reject) => {
1054
- const timer = setTimeout(() => {
1055
- reject(
1056
- new Error(
1057
- `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)`
1058
- )
1059
- );
1060
- }, timeoutMs);
1061
- write.then(
1062
- (value) => {
1063
- clearTimeout(timer);
1064
- resolve(value);
1065
- },
1066
- (reason) => {
1067
- clearTimeout(timer);
1068
- reject(reason);
1069
- }
1070
- );
1071
- }
1595
+ function waitForWrite(start, timeoutMs) {
1596
+ return withTimeout(
1597
+ start,
1598
+ timeoutMs,
1599
+ `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)`
1072
1600
  );
1073
1601
  }
1074
1602
  function buildRecord(entry) {
1075
1603
  const serialized = serializeEntry(entry);
1076
1604
  const record = {
1077
- id: crypto.randomUUID(),
1605
+ id: resolveEntryId(entry),
1078
1606
  code: serialized.code,
1079
1607
  message: serialized.message,
1080
1608
  timestamp: serialized.timestamp
@@ -1091,23 +1619,27 @@ var FirestoreErrorAdapter = class {
1091
1619
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1092
1620
  constructor(collection, options = {}) {
1093
1621
  this.collection = collection;
1094
- this.prefix = options.prefix ?? "[error-log-adapter]";
1095
- this.timeoutMs = resolveTimeout(options, "FirestoreErrorAdapter");
1622
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1623
+ this.timeoutMs = resolveTimeoutMs(
1624
+ options.timeoutMs,
1625
+ "FirestoreErrorAdapter"
1626
+ );
1096
1627
  }
1097
1628
  async log(entry) {
1098
1629
  try {
1099
1630
  const record = buildRecord(entry);
1100
1631
  await waitForWrite(
1101
- this.collection.doc(record.id).set(record),
1632
+ () => this.collection.doc(record.id).set(record),
1102
1633
  this.timeoutMs
1103
1634
  );
1104
1635
  return { ok: true };
1105
1636
  } catch (error) {
1106
- console.error(`${this.prefix} FirestoreErrorAdapter:`, error);
1107
- return {
1108
- ok: false,
1109
- error: `FirestoreErrorAdapter failed to write the document: ${describeError(error)}`
1110
- };
1637
+ return reportFailure({
1638
+ adapterName: "FirestoreErrorAdapter",
1639
+ prefix: this.prefix,
1640
+ action: `to write the document`,
1641
+ error
1642
+ });
1111
1643
  }
1112
1644
  }
1113
1645
  };
@@ -1115,34 +1647,51 @@ var RealtimeDatabaseErrorAdapter = class {
1115
1647
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1116
1648
  constructor(reference, options = {}) {
1117
1649
  this.reference = reference;
1118
- this.prefix = options.prefix ?? "[error-log-adapter]";
1119
- this.timeoutMs = resolveTimeout(options, "RealtimeDatabaseErrorAdapter");
1650
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1651
+ this.timeoutMs = resolveTimeoutMs(
1652
+ options.timeoutMs,
1653
+ "RealtimeDatabaseErrorAdapter"
1654
+ );
1120
1655
  }
1121
1656
  async log(entry) {
1122
1657
  try {
1123
- await waitForWrite(
1124
- this.reference.push(buildRecord(entry)),
1125
- this.timeoutMs
1126
- );
1658
+ const record = buildRecord(entry);
1659
+ let write;
1660
+ if (hasCallerId(entry)) {
1661
+ if (this.reference.child === void 0) {
1662
+ throw new TypeError(
1663
+ "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"
1664
+ );
1665
+ }
1666
+ write = this.reference.child(record.id).set(record);
1667
+ } else {
1668
+ write = this.reference.push(record);
1669
+ }
1670
+ await waitForWrite(() => write, this.timeoutMs);
1127
1671
  return { ok: true };
1128
1672
  } catch (error) {
1129
- console.error(`${this.prefix} RealtimeDatabaseErrorAdapter:`, error);
1130
- return {
1131
- ok: false,
1132
- error: `RealtimeDatabaseErrorAdapter failed to write the entry: ${describeError(error)}`
1133
- };
1673
+ return reportFailure({
1674
+ adapterName: "RealtimeDatabaseErrorAdapter",
1675
+ prefix: this.prefix,
1676
+ action: `to write the entry`,
1677
+ error
1678
+ });
1134
1679
  }
1135
1680
  }
1136
1681
  };
1137
1682
 
1138
1683
  // src/adapters/supabase-error-adapter.ts
1139
- var IDENTIFIER_PATTERN3 = /^[A-Za-z_][A-Za-z0-9_]*$/;
1140
- function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1684
+ var POLICY_MAX_CODE_LENGTH = 255;
1685
+ var POLICY_MAX_MESSAGE_LENGTH = 4096;
1686
+ var POLICY_MAX_CAUSE_BYTES = 8192;
1687
+ 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})`;
1688
+ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME, options = {}) {
1141
1689
  const insertRoles = options.insertRoles ?? [];
1142
1690
  for (const role of insertRoles) {
1143
- if (!IDENTIFIER_PATTERN3.test(role)) {
1691
+ assertIdentifier(role, "role name", "buildSupabaseSetupSql");
1692
+ if ((role.toLowerCase() === "anon" || role.toLowerCase() === "public") && options.allowPublicInsert !== true) {
1144
1693
  throw new RangeError(
1145
- `buildSupabaseSetupSql: invalid role name "${role}". Use only letters, digits and underscores, and do not start with a digit.`
1694
+ `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.`
1146
1695
  );
1147
1696
  }
1148
1697
  }
@@ -1154,7 +1703,7 @@ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1154
1703
  const policy = `${tableName}_insert_only`;
1155
1704
  statements.push(
1156
1705
  `DROP POLICY IF EXISTS "${policy}" ON "${tableName}"`,
1157
- `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (true)`
1706
+ `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (${INSERT_POLICY_CHECK})`
1158
1707
  );
1159
1708
  }
1160
1709
  return statements;
@@ -1184,50 +1733,98 @@ function describeSupabaseError(result) {
1184
1733
  return parts.join("; ");
1185
1734
  }
1186
1735
  var SupabaseErrorAdapter = class {
1187
- /** @throws {RangeError} if `options.tableName` is empty. */
1736
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
1188
1737
  constructor(client, options = {}) {
1189
1738
  this.client = client;
1190
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1191
- if (this.tableName.trim() === "") {
1192
- throw new RangeError(
1193
- "SupabaseErrorAdapter: tableName must not be empty."
1194
- );
1195
- }
1196
- this.prefix = options.prefix ?? "[error-log-adapter]";
1739
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
1740
+ assertIdentifier(this.tableName, "table name", "SupabaseErrorAdapter");
1741
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1742
+ this.timeoutMs = resolveTimeoutMs(
1743
+ options.timeoutMs,
1744
+ "SupabaseErrorAdapter"
1745
+ );
1197
1746
  }
1198
1747
  async log(entry) {
1199
1748
  try {
1200
1749
  const serialized = serializeEntry(entry);
1201
1750
  const row = {
1202
- id: crypto.randomUUID(),
1751
+ id: resolveEntryId(entry),
1203
1752
  code: serialized.code,
1204
1753
  message: serialized.message,
1205
1754
  cause: serialized.cause === void 0 ? null : serialized.cause,
1206
1755
  source: serialized.source ?? null,
1207
1756
  occurred_at: serialized.timestamp
1208
1757
  };
1209
- const result = await this.client.from(this.tableName).insert(row);
1758
+ const result = await withTimeout(
1759
+ () => this.client.from(this.tableName).insert(row),
1760
+ this.timeoutMs,
1761
+ timeoutMessage("Supabase", this.timeoutMs)
1762
+ );
1210
1763
  if (result.error) {
1211
- console.error(`${this.prefix} SupabaseErrorAdapter:`, result);
1212
- return this.fail(describeSupabaseError(result));
1764
+ if (hasCallerId(entry) && result.error.code === "23505") {
1765
+ return { ok: true };
1766
+ }
1767
+ return this.fail(result, describeSupabaseError(result));
1213
1768
  }
1214
1769
  return { ok: true };
1215
1770
  } catch (error) {
1216
- console.error(`${this.prefix} SupabaseErrorAdapter:`, error);
1217
- return this.fail(describeError(error));
1771
+ return this.fail(error, describeError(error));
1218
1772
  }
1219
1773
  }
1220
- fail(reason) {
1221
- return {
1222
- ok: false,
1223
- error: `SupabaseErrorAdapter failed to insert into table "${this.tableName}": ${reason}`
1224
- };
1774
+ fail(logged, reason) {
1775
+ return reportFailure({
1776
+ adapterName: "SupabaseErrorAdapter",
1777
+ prefix: this.prefix,
1778
+ action: `to insert into table "${this.tableName}"`,
1779
+ error: logged,
1780
+ reason
1781
+ });
1782
+ }
1783
+ };
1784
+
1785
+ // src/adapters/event-emitter-error-adapter.ts
1786
+ var DEFAULT_EVENT_NAME = "error-log";
1787
+ var EventEmitterErrorAdapter = class {
1788
+ /** @throws {RangeError} if `options.eventName` is empty. */
1789
+ constructor(emitter, options = {}) {
1790
+ this.emitter = emitter;
1791
+ this.eventName = options.eventName ?? DEFAULT_EVENT_NAME;
1792
+ if (this.eventName === "") {
1793
+ throw new RangeError(
1794
+ "EventEmitterErrorAdapter: eventName must not be empty."
1795
+ );
1796
+ }
1797
+ this.requireListener = options.requireListener ?? false;
1798
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1799
+ }
1800
+ async log(entry) {
1801
+ try {
1802
+ const record = {
1803
+ id: resolveEntryId(entry),
1804
+ ...serializeEntry(entry)
1805
+ };
1806
+ const delivered = this.emitter.emit(this.eventName, record);
1807
+ if (this.requireListener && delivered === false) {
1808
+ throw new Error(
1809
+ `no listener is attached to "${this.eventName}", so nothing received the entry`
1810
+ );
1811
+ }
1812
+ return { ok: true };
1813
+ } catch (error) {
1814
+ return reportFailure({
1815
+ adapterName: "EventEmitterErrorAdapter",
1816
+ prefix: this.prefix,
1817
+ action: `to emit "${this.eventName}"`,
1818
+ error
1819
+ });
1820
+ }
1225
1821
  }
1226
1822
  };
1227
1823
  // Annotate the CommonJS export names for ESM import in node:
1228
1824
  0 && (module.exports = {
1229
1825
  CompositeErrorAdapter,
1230
1826
  ConsoleErrorAdapter,
1827
+ EventEmitterErrorAdapter,
1231
1828
  FirestoreErrorAdapter,
1232
1829
  FlatFileErrorAdapter,
1233
1830
  IndexedDbErrorAdapter,
@@ -1244,6 +1841,7 @@ var SupabaseErrorAdapter = class {
1244
1841
  WebhookErrorAdapter,
1245
1842
  buildCreateErrorLogTableSql,
1246
1843
  buildErrorLogTableDdl,
1844
+ buildMySqlWidenColumnsSql,
1247
1845
  buildSupabaseSetupSql,
1248
1846
  createErrorLogStore,
1249
1847
  formatErrorLogLine,