@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.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,35 +140,324 @@ 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 DEFAULT_RETRY_DELAY_MS = 500;
233
+ var DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
234
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
235
+ var SigningUnavailableError = class extends Error {
236
+ };
237
+ async function hmacSha256Hex(secret, message) {
238
+ const subtle = globalThis.crypto?.subtle;
239
+ if (subtle === void 0) {
240
+ throw new SigningUnavailableError(
241
+ "signingSecret needs Web Crypto (crypto.subtle), which this runtime does not provide, so the entry was not sent unsigned"
242
+ );
243
+ }
244
+ const encoder = new TextEncoder();
245
+ const key = await subtle.importKey(
246
+ "raw",
247
+ encoder.encode(secret),
248
+ { name: "HMAC", hash: "SHA-256" },
249
+ false,
250
+ ["sign"]
251
+ );
252
+ const signature = await subtle.sign(
253
+ "HMAC",
254
+ key,
255
+ encoder.encode(message)
256
+ );
257
+ return Array.from(
258
+ new Uint8Array(signature),
259
+ (byte) => byte.toString(16).padStart(2, "0")
260
+ ).join("");
261
+ }
262
+ function parseRetryAfterMs(value) {
263
+ if (value === null || value === void 0 || value.trim() === "") {
264
+ return void 0;
265
+ }
266
+ const seconds = Number(value);
267
+ if (Number.isFinite(seconds) && seconds >= 0) {
268
+ return seconds * 1e3;
269
+ }
270
+ const date = Date.parse(value);
271
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
272
+ }
273
+ function validateRetryOptions(options) {
274
+ const retries = options.retries ?? 0;
275
+ if (!Number.isInteger(retries) || retries < 0) {
276
+ throw new RangeError(
277
+ `WebhookErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
278
+ );
279
+ }
280
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
281
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs <= 0) {
282
+ throw new RangeError(
283
+ `WebhookErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
284
+ );
285
+ }
286
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
287
+ if (!Number.isFinite(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
288
+ throw new RangeError(
289
+ `WebhookErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
290
+ );
291
+ }
292
+ if (options.signingSecret === "") {
293
+ throw new RangeError(
294
+ "WebhookErrorAdapter: signingSecret must not be empty; leave it out to send unsigned."
295
+ );
296
+ }
297
+ return { retries, retryDelayMs, maxRetryDelayMs };
298
+ }
299
+ function sleep(ms) {
300
+ return new Promise((resolve) => {
301
+ setTimeout(resolve, ms);
302
+ });
303
+ }
304
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
305
+ "localhost",
306
+ "127.0.0.1",
307
+ "[::1]"
308
+ ]);
309
+ function parseWebhookUrl(url, allowInsecure) {
310
+ let parsed;
311
+ try {
312
+ parsed = new URL(url);
313
+ } catch {
314
+ throw new RangeError(
315
+ "WebhookErrorAdapter: the webhook URL is not a valid absolute URL (for example https://example.com/errors)."
316
+ );
317
+ }
318
+ if (parsed.protocol === "https:") {
319
+ return parsed;
320
+ }
321
+ if (parsed.protocol === "http:" && (allowInsecure || LOOPBACK_HOSTS.has(parsed.hostname))) {
322
+ return parsed;
323
+ }
324
+ throw new RangeError(
325
+ `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.`
326
+ );
327
+ }
45
328
  var WebhookErrorAdapter = class {
329
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
46
330
  constructor(url, options = {}) {
47
331
  this.url = url;
48
332
  this.options = options;
49
- this.prefix = options.prefix ?? "[error-log-adapter]";
333
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
334
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
335
+ ({
336
+ retries: this.retries,
337
+ retryDelayMs: this.retryDelayMs,
338
+ maxRetryDelayMs: this.maxRetryDelayMs
339
+ } = validateRetryOptions(options));
340
+ this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
50
341
  }
51
342
  async log(entry) {
343
+ let body;
344
+ try {
345
+ body = JSON.stringify(
346
+ hasCallerId(entry) || this.retries > 0 ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
347
+ );
348
+ } catch (error) {
349
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
350
+ return this.fail(
351
+ `could not prepare the entry: ${this.redact(describeError(error))}`
352
+ );
353
+ }
354
+ let attempt = await this.send(body);
355
+ let attempts = 1;
356
+ while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
357
+ await sleep(this.retryWaitMs(attempts, attempt.retryAfterMs));
358
+ attempt = await this.send(body);
359
+ attempts += 1;
360
+ }
361
+ if (attempt.ok) {
362
+ return { ok: true };
363
+ }
364
+ return this.fail(
365
+ attempts > 1 ? `${attempt.reason} (after ${attempts} attempts)` : attempt.reason
366
+ );
367
+ }
368
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
369
+ retryWaitMs(retryNumber, retryAfterMs) {
370
+ if (retryAfterMs !== void 0) {
371
+ return Math.min(retryAfterMs, this.maxRetryDelayMs);
372
+ }
373
+ const ceiling = Math.min(
374
+ this.maxRetryDelayMs,
375
+ this.retryDelayMs * 2 ** (retryNumber - 1)
376
+ );
377
+ return ceiling / 2 + Math.random() * ceiling / 2;
378
+ }
379
+ async send(body) {
380
+ const controller = new AbortController();
381
+ let timedOut = false;
382
+ const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
383
+ timedOut = true;
384
+ controller.abort();
385
+ }, this.timeoutMs);
52
386
  try {
53
387
  const response = await fetch(this.url, {
54
388
  method: "POST",
55
389
  headers: {
56
390
  "Content-Type": "application/json",
57
- ...this.options.headers
391
+ ...this.options.headers,
392
+ ...await this.signatureHeaders(body)
58
393
  },
59
- body: JSON.stringify(serializeEntry(entry))
394
+ body,
395
+ // A redirect would turn the POST into a GET without a body (302, 303)
396
+ // or re-send the entry and headers to another host (307, 308).
397
+ redirect: "manual",
398
+ signal: controller.signal
60
399
  });
400
+ if (response.type === "opaqueredirect") {
401
+ return this.rejected(this.redirectMessage("a redirect"));
402
+ }
61
403
  if (!response.ok) {
62
- return this.fail(
63
- `webhook ${this.url} responded with HTTP ${response.status}`
64
- );
404
+ if (response.status >= 300 && response.status < 400) {
405
+ return this.rejected(this.redirectMessage(`HTTP ${response.status}`));
406
+ }
407
+ return {
408
+ ok: false,
409
+ reason: `webhook ${this.target} responded with HTTP ${response.status}`,
410
+ retryable: response.status >= 500 || RETRYABLE_STATUSES.has(response.status),
411
+ retryAfterMs: parseRetryAfterMs(response.headers?.get("retry-after"))
412
+ };
65
413
  }
66
414
  return { ok: true };
67
415
  } catch (error) {
68
416
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
69
- return this.fail(
70
- `could not reach webhook ${this.url}: ${describeError(error)}`
71
- );
417
+ if (error instanceof SigningUnavailableError) {
418
+ return this.rejected(error.message);
419
+ }
420
+ if (timedOut) {
421
+ return {
422
+ ok: false,
423
+ reason: timeoutMessage(`webhook ${this.target}`, this.timeoutMs),
424
+ retryable: true
425
+ };
426
+ }
427
+ return {
428
+ ok: false,
429
+ reason: `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`,
430
+ retryable: true
431
+ };
432
+ } finally {
433
+ clearTimeout(timer);
434
+ }
435
+ }
436
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
437
+ async signatureHeaders(body) {
438
+ const secret = this.options.signingSecret;
439
+ if (secret === void 0) {
440
+ return {};
72
441
  }
442
+ const timestamp = String(Math.floor(Date.now() / 1e3));
443
+ const signature = await hmacSha256Hex(
444
+ secret,
445
+ `${timestamp}.${body}`
446
+ );
447
+ return {
448
+ "X-Signature-Timestamp": timestamp,
449
+ "X-Signature-256": `sha256=${signature}`
450
+ };
451
+ }
452
+ rejected(reason) {
453
+ return { ok: false, reason, retryable: false };
454
+ }
455
+ redirectMessage(what) {
456
+ return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
457
+ }
458
+ /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
459
+ redact(message) {
460
+ return message.split(this.url).join(this.target);
73
461
  }
74
462
  fail(reason) {
75
463
  return { ok: false, error: `WebhookErrorAdapter failed: ${reason}` };
@@ -77,37 +465,64 @@ var WebhookErrorAdapter = class {
77
465
  };
78
466
 
79
467
  // src/adapters/mongodb-error-adapter.ts
468
+ function isDuplicateKeyError(error) {
469
+ return typeof error === "object" && error !== null && error.code === 11e3;
470
+ }
80
471
  var MongoDbErrorAdapter = class {
472
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
81
473
  constructor(collection, options = {}) {
82
474
  this.collection = collection;
83
- this.prefix = options.prefix ?? "[error-log-adapter]";
475
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
476
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "MongoDbErrorAdapter");
84
477
  }
85
478
  async log(entry) {
86
479
  try {
87
- await this.collection.insertOne(serializeEntry(entry));
480
+ const document = hasCallerId(entry) ? { _id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry);
481
+ await withTimeout(
482
+ () => this.collection.insertOne(document),
483
+ this.timeoutMs,
484
+ timeoutMessage("MongoDB", this.timeoutMs)
485
+ );
88
486
  return { ok: true };
89
487
  } catch (error) {
90
- console.error(`${this.prefix} MongoDbErrorAdapter:`, error);
91
- return {
92
- ok: false,
93
- error: `MongoDbErrorAdapter failed to insert entry: ${describeError(error)}`
94
- };
488
+ if (hasCallerId(entry) && isDuplicateKeyError(error)) {
489
+ return { ok: true };
490
+ }
491
+ return reportFailure({
492
+ adapterName: "MongoDbErrorAdapter",
493
+ prefix: this.prefix,
494
+ action: "to insert entry",
495
+ error
496
+ });
95
497
  }
96
498
  }
97
499
  };
98
500
 
99
501
  // src/adapters/composite-error-adapter.ts
502
+ var DEFAULT_COMPOSITE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 3;
100
503
  var CompositeErrorAdapter = class {
101
- constructor(adapters) {
504
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
505
+ constructor(adapters, options = {}) {
102
506
  this.adapters = adapters;
507
+ this.timeoutMs = resolveTimeoutMs(
508
+ options.timeoutMs ?? DEFAULT_COMPOSITE_TIMEOUT_MS,
509
+ "CompositeErrorAdapter"
510
+ );
103
511
  }
104
512
  async log(entry) {
105
513
  const results = await Promise.all(
106
514
  this.adapters.map(
107
- async (adapter) => {
515
+ async (adapter, index) => {
108
516
  try {
109
- return await adapter.log(entry);
517
+ return await withTimeout(
518
+ () => adapter.log(entry),
519
+ this.timeoutMs,
520
+ `adapter ${index} (${adapter.constructor.name}) did not return within ${this.timeoutMs} ms, so the entry may or may not have been stored there`
521
+ );
110
522
  } catch (error) {
523
+ if (error instanceof SinkTimeoutError) {
524
+ return { ok: false, error: error.message };
525
+ }
111
526
  return {
112
527
  ok: false,
113
528
  error: `adapter threw instead of returning a result: ${String(error)}`
@@ -138,58 +553,101 @@ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
138
553
  database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
139
554
  }
140
555
  var IndexedDbErrorAdapter = class {
556
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
141
557
  constructor(database, options = {}) {
142
558
  this.database = database;
143
559
  this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
144
- this.prefix = options.prefix ?? "[error-log-adapter]";
560
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
561
+ this.timeoutMs = resolveTimeoutMs(
562
+ options.timeoutMs,
563
+ "IndexedDbErrorAdapter"
564
+ );
145
565
  }
146
566
  async log(entry) {
147
567
  try {
148
568
  const record = {
149
- id: crypto.randomUUID(),
569
+ id: resolveEntryId(entry),
150
570
  ...serializeEntry(entry)
151
571
  };
152
- await this.write(record);
572
+ await this.write(record, hasCallerId(entry));
153
573
  return { ok: true };
154
574
  } 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
- };
575
+ return reportFailure({
576
+ adapterName: "IndexedDbErrorAdapter",
577
+ prefix: this.prefix,
578
+ action: `to write to object store "${this.storeName}"`,
579
+ error
580
+ });
160
581
  }
161
582
  }
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
- }
583
+ /**
584
+ * Resolves once the transaction commits; rejects if the request fails, the
585
+ * transaction aborts, or it does not finish within `timeoutMs`.
586
+ *
587
+ * Error events are handled here (`preventDefault` and `stopPropagation`), so a
588
+ * failed log write does not reach the `onerror` handler an app may have set on
589
+ * its own database object.
590
+ */
591
+ write(record, replaceExisting) {
592
+ return withTimeout(
593
+ () => new Promise(
594
+ (resolve, reject) => {
595
+ const transaction = this.database.transaction(
596
+ this.storeName,
597
+ "readwrite"
598
+ );
599
+ const fail = (what, source) => (event) => {
600
+ event?.preventDefault?.();
601
+ event?.stopPropagation?.();
602
+ reject(new Error(`${what}: ${describeDomError(source.error)}`));
603
+ };
604
+ transaction.oncomplete = () => resolve();
605
+ transaction.onerror = fail("transaction failed", transaction);
606
+ transaction.onabort = fail("transaction aborted", transaction);
607
+ const store = transaction.objectStore(
608
+ this.storeName
609
+ );
610
+ const request = replaceExisting && typeof store.put === "function" ? store.put(record) : store.add(record);
611
+ if (typeof request === "object" && request !== null) {
612
+ request.onerror = fail(
613
+ "request failed",
614
+ request
615
+ );
616
+ }
617
+ }
618
+ ),
619
+ this.timeoutMs,
620
+ timeoutMessage("IndexedDB", this.timeoutMs, "finish the transaction")
180
621
  );
181
622
  }
182
623
  };
624
+ function describeDomError(error) {
625
+ if (error === null || error === void 0) {
626
+ return "no details provided";
627
+ }
628
+ const text = [error.name, error.message].filter((part) => typeof part === "string" && part !== "").join(": ");
629
+ return text === "" ? "no details provided" : text;
630
+ }
183
631
 
184
- // src/adapters/sqlite-error-adapter.ts
632
+ // src/sql-ident.ts
185
633
  var DEFAULT_TABLE_NAME = "error_log";
186
634
  var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
187
- function assertValidTableName(tableName) {
188
- if (!IDENTIFIER_PATTERN.test(tableName)) {
635
+ function assertIdentifier(value, label, owner, maxLength) {
636
+ if (!IDENTIFIER_PATTERN.test(value)) {
189
637
  throw new RangeError(
190
- `SqliteErrorAdapter: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
638
+ `${owner}: invalid ${label} "${value}". Use only letters, digits and underscores, and do not start with a digit.`
191
639
  );
192
640
  }
641
+ if (maxLength !== void 0 && value.length > maxLength) {
642
+ throw new RangeError(
643
+ `${owner}: ${label} "${value}" is ${value.length} characters long, but the limit is ${maxLength}.`
644
+ );
645
+ }
646
+ }
647
+
648
+ // src/adapters/sqlite-error-adapter.ts
649
+ function assertValidTableName(tableName) {
650
+ assertIdentifier(tableName, "table name", "SqliteErrorAdapter");
193
651
  }
194
652
  function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
195
653
  assertValidTableName(tableName);
@@ -199,23 +657,24 @@ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
199
657
  ];
200
658
  }
201
659
  var SqliteErrorAdapter = class {
202
- /** @throws {RangeError} if `options.tableName` is not a plain identifier. */
660
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
203
661
  constructor(client, options = {}) {
204
662
  this.client = client;
205
663
  this.tableReady = null;
206
664
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
207
665
  assertValidTableName(this.tableName);
208
666
  this.createTable = options.createTable ?? true;
209
- this.prefix = options.prefix ?? "[error-log-adapter]";
667
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
668
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "SqliteErrorAdapter");
210
669
  }
211
670
  async log(entry) {
212
671
  try {
213
672
  await this.ensureTable();
214
673
  const serialized = serializeEntry(entry);
215
- await this.client.run(
216
- `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?)`,
674
+ await this.run(
675
+ `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`,
217
676
  [
218
- crypto.randomUUID(),
677
+ resolveEntryId(entry),
219
678
  serialized.code,
220
679
  serialized.message,
221
680
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -225,11 +684,12 @@ var SqliteErrorAdapter = class {
225
684
  );
226
685
  return { ok: true };
227
686
  } 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
- };
687
+ return reportFailure({
688
+ adapterName: "SqliteErrorAdapter",
689
+ prefix: this.prefix,
690
+ action: `to write to table "${this.tableName}"`,
691
+ error
692
+ });
233
693
  }
234
694
  }
235
695
  /** Creates the table once. A failed attempt is retried on the next log call. */
@@ -249,9 +709,17 @@ var SqliteErrorAdapter = class {
249
709
  }
250
710
  async runCreateStatements() {
251
711
  for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
252
- await this.client.run(sql, []);
712
+ await this.run(sql, []);
253
713
  }
254
714
  }
715
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
716
+ run(sql, params) {
717
+ return withTimeout(
718
+ () => this.client.run(sql, params),
719
+ this.timeoutMs,
720
+ timeoutMessage("SQLite", this.timeoutMs)
721
+ );
722
+ }
255
723
  };
256
724
 
257
725
  // src/adapters/web-storage-error-adapter.ts
@@ -270,7 +738,8 @@ var WebStorageErrorAdapter = class {
270
738
  this.storage = storage;
271
739
  this.key = options.key ?? DEFAULT_STORAGE_KEY;
272
740
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
273
- this.prefix = options.prefix ?? "[error-log-adapter]";
741
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
742
+ this.locks = options.locks === void 0 ? defaultLockManager() : options.locks;
274
743
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
275
744
  throw new RangeError(
276
745
  `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
@@ -280,23 +749,49 @@ var WebStorageErrorAdapter = class {
280
749
  async log(entry) {
281
750
  try {
282
751
  const record = {
283
- id: crypto.randomUUID(),
752
+ id: resolveEntryId(entry),
284
753
  ...serializeEntry(entry)
285
754
  };
286
- this.append(record);
755
+ await this.appendLocked(record);
287
756
  return { ok: true };
288
757
  } 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
- };
758
+ return reportFailure({
759
+ adapterName: "WebStorageErrorAdapter",
760
+ prefix: this.prefix,
761
+ action: `to write to storage key "${this.key}"`,
762
+ error
763
+ });
764
+ }
765
+ }
766
+ /**
767
+ * Appends under a cross-tab lock when one is available. If the lock itself
768
+ * cannot be taken (for example a sandboxed frame), the entry is still written
769
+ * without it rather than lost.
770
+ */
771
+ async appendLocked(record) {
772
+ if (this.locks === null) {
773
+ this.append(record);
774
+ return;
775
+ }
776
+ let appended = false;
777
+ try {
778
+ await this.locks.request(`error-log-adapter:${this.key}`, () => {
779
+ appended = true;
780
+ this.append(record);
781
+ });
782
+ } catch (error) {
783
+ if (appended) {
784
+ throw error;
785
+ }
786
+ this.append(record);
294
787
  }
295
788
  }
296
- /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
789
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls in one tab never interleave. */
297
790
  append(record) {
298
791
  const records = [
299
- ...this.readRecords(),
792
+ ...this.readRecords().filter(
793
+ (existing) => existing?.id !== record.id
794
+ ),
300
795
  record
301
796
  ].slice(-this.maxEntries);
302
797
  for (; ; ) {
@@ -322,16 +817,51 @@ var WebStorageErrorAdapter = class {
322
817
  if (Array.isArray(parsed)) {
323
818
  return parsed;
324
819
  }
325
- console.error(
326
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
327
- );
820
+ this.setAside(stored, "did not hold an array");
328
821
  } catch (error) {
329
- console.error(
330
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
331
- );
822
+ this.setAside(stored, `held invalid JSON (${describeError(error)})`);
332
823
  }
333
824
  return [];
334
825
  }
826
+ /**
827
+ * Copies an unusable value to `<key>.corrupt` before it is replaced, so data
828
+ * the host app stored under the same key is not silently destroyed. Only the
829
+ * first one is kept; a later unusable value would not overwrite it.
830
+ */
831
+ setAside(stored, reason) {
832
+ const backupKey = `${this.key}${CORRUPT_KEY_SUFFIX}`;
833
+ let savedTo = "but it could not be copied aside";
834
+ try {
835
+ if (this.storage.getItem(backupKey) === null) {
836
+ this.storage.setItem(backupKey, stored);
837
+ savedTo = `and a copy was kept under "${backupKey}"`;
838
+ } else {
839
+ savedTo = `and "${backupKey}" already holds an earlier copy`;
840
+ }
841
+ } catch {
842
+ }
843
+ console.error(
844
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" ${reason}, so it was replaced, ${savedTo}.`
845
+ );
846
+ }
847
+ };
848
+ var CORRUPT_KEY_SUFFIX = ".corrupt";
849
+ function defaultLockManager() {
850
+ const locks = globalThis.navigator?.locks;
851
+ return typeof locks?.request === "function" ? locks : null;
852
+ }
853
+
854
+ // src/queue.ts
855
+ var SerialQueue = class {
856
+ constructor() {
857
+ this.tail = Promise.resolve();
858
+ }
859
+ /** Starts `task` once every earlier task has finished, and resolves with its result. */
860
+ run(task) {
861
+ const result = this.tail.then(task);
862
+ this.tail = result.catch(() => void 0);
863
+ return result;
864
+ }
335
865
  };
336
866
 
337
867
  // src/file-system.ts
@@ -339,6 +869,7 @@ async function loadNodeFileSystem() {
339
869
  const moduleName = "node:fs/promises";
340
870
  return await import(moduleName);
341
871
  }
872
+ var ROOT_PATTERN = /^(?:[A-Za-z]:)?[\\/]+$/;
342
873
  function parentDirectory(filePath) {
343
874
  const lastSeparator = Math.max(
344
875
  filePath.lastIndexOf("/"),
@@ -347,7 +878,11 @@ function parentDirectory(filePath) {
347
878
  if (lastSeparator < 0) {
348
879
  return null;
349
880
  }
350
- return lastSeparator === 0 ? "/" : filePath.slice(0, lastSeparator);
881
+ const directory = filePath.slice(0, lastSeparator + 1);
882
+ if (ROOT_PATTERN.test(directory)) {
883
+ return null;
884
+ }
885
+ return filePath.slice(0, lastSeparator);
351
886
  }
352
887
  function isMissingFileError(error) {
353
888
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -393,24 +928,20 @@ var FlatFileErrorAdapter = class {
393
928
  constructor(filePath, options = {}) {
394
929
  this.filePath = filePath;
395
930
  this.directoryReady = false;
396
- this.queue = Promise.resolve();
931
+ this.queue = new SerialQueue();
397
932
  this.fs = options.fs;
398
933
  this.formatLine = options.formatLine ?? formatErrorLogLine;
399
934
  this.maxBytes = options.maxBytes;
400
935
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
401
936
  this.createDirectory = options.createDirectory ?? true;
402
- this.prefix = options.prefix ?? "[error-log-adapter]";
937
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
403
938
  if (this.maxBytes !== void 0) {
404
939
  assertPositiveInteger(this.maxBytes, "maxBytes");
405
940
  }
406
941
  assertPositiveInteger(this.maxFiles, "maxFiles");
407
942
  }
408
943
  log(entry) {
409
- const result = this.queue.then(
410
- () => this.write(entry)
411
- );
412
- this.queue = result.catch(() => void 0);
413
- return result;
944
+ return this.queue.run(() => this.write(entry));
414
945
  }
415
946
  async write(entry) {
416
947
  try {
@@ -423,11 +954,12 @@ var FlatFileErrorAdapter = class {
423
954
  await fs.appendFile(this.filePath, line);
424
955
  return { ok: true };
425
956
  } 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
- };
957
+ return reportFailure({
958
+ adapterName: "FlatFileErrorAdapter",
959
+ prefix: this.prefix,
960
+ action: `to append to "${this.filePath}"`,
961
+ error
962
+ });
431
963
  }
432
964
  }
433
965
  /** Uses the injected file system, or loads `node:fs/promises` on first use. */
@@ -502,13 +1034,13 @@ var JsonFileErrorAdapter = class {
502
1034
  constructor(filePath, options = {}) {
503
1035
  this.filePath = filePath;
504
1036
  this.directoryReady = false;
505
- this.queue = Promise.resolve();
1037
+ this.queue = new SerialQueue();
506
1038
  this.mode = options.mode ?? "ndjson";
507
1039
  this.maxEntries = options.maxEntries;
508
1040
  this.indent = options.indent ?? DEFAULT_INDENT;
509
1041
  this.fs = options.fs;
510
1042
  this.createDirectory = options.createDirectory ?? true;
511
- this.prefix = options.prefix ?? "[error-log-adapter]";
1043
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
512
1044
  if (this.maxEntries !== void 0) {
513
1045
  if (this.mode !== "array") {
514
1046
  throw new RangeError(
@@ -528,16 +1060,12 @@ var JsonFileErrorAdapter = class {
528
1060
  }
529
1061
  }
530
1062
  log(entry) {
531
- const result = this.queue.then(
532
- () => this.write(entry)
533
- );
534
- this.queue = result.catch(() => void 0);
535
- return result;
1063
+ return this.queue.run(() => this.write(entry));
536
1064
  }
537
1065
  async write(entry) {
538
1066
  try {
539
1067
  const record = {
540
- id: crypto.randomUUID(),
1068
+ id: resolveEntryId(entry),
541
1069
  ...serializeEntry(entry)
542
1070
  };
543
1071
  const fs = await this.getFileSystem();
@@ -550,11 +1078,12 @@ var JsonFileErrorAdapter = class {
550
1078
  }
551
1079
  return { ok: true };
552
1080
  } 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
- };
1081
+ return reportFailure({
1082
+ adapterName: "JsonFileErrorAdapter",
1083
+ prefix: this.prefix,
1084
+ action: `to write "${this.filePath}" in ${this.mode} mode`,
1085
+ error
1086
+ });
558
1087
  }
559
1088
  }
560
1089
  async getFileSystem() {
@@ -580,7 +1109,9 @@ var JsonFileErrorAdapter = class {
580
1109
  */
581
1110
  async rewriteArray(fs, record) {
582
1111
  const records = [
583
- ...await this.readRecords(fs),
1112
+ ...(await this.readRecords(fs)).filter(
1113
+ (existing) => existing?.id !== record.id
1114
+ ),
584
1115
  record
585
1116
  ];
586
1117
  const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
@@ -630,25 +1161,14 @@ var JsonFileErrorAdapter = class {
630
1161
  };
631
1162
 
632
1163
  // src/adapters/sql-error-adapter.ts
633
- var DEFAULT_TABLE_NAME2 = "error_log";
634
1164
  var MAX_TABLE_NAME_LENGTH = 47;
635
- var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
636
1165
  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
- }
1166
+ assertIdentifier(tableName, "table name", adapterName, MAX_TABLE_NAME_LENGTH);
647
1167
  }
648
1168
  function quoteIdentifier(dialect, identifier) {
649
1169
  return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
650
1170
  }
651
- function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
1171
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME) {
652
1172
  assertValidTableName2(tableName, "buildErrorLogTableDdl");
653
1173
  const table = quoteIdentifier(dialect, tableName);
654
1174
  const index = quoteIdentifier(
@@ -662,7 +1182,13 @@ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
662
1182
  ];
663
1183
  }
664
1184
  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))`
1185
+ `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))`
1186
+ ];
1187
+ }
1188
+ function buildMySqlWidenColumnsSql(tableName = DEFAULT_TABLE_NAME) {
1189
+ assertValidTableName2(tableName, "buildMySqlWidenColumnsSql");
1190
+ return [
1191
+ `ALTER TABLE ${quoteIdentifier("mysql", tableName)} MODIFY code TEXT NOT NULL, MODIFY source TEXT NULL`
666
1192
  ];
667
1193
  }
668
1194
  function toMysqlDateTime(date) {
@@ -674,10 +1200,11 @@ var SqlErrorAdapter = class {
674
1200
  this.dialect = dialect;
675
1201
  this.adapterName = adapterName;
676
1202
  this.tableReady = null;
677
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1203
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
678
1204
  assertValidTableName2(this.tableName, adapterName);
679
1205
  this.createTable = options.createTable ?? true;
680
- this.prefix = options.prefix ?? "[error-log-adapter]";
1206
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1207
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, adapterName);
681
1208
  }
682
1209
  async log(entry) {
683
1210
  try {
@@ -689,7 +1216,7 @@ var SqlErrorAdapter = class {
689
1216
  );
690
1217
  }
691
1218
  const params = [
692
- crypto.randomUUID(),
1219
+ resolveEntryId(entry),
693
1220
  serialized.code,
694
1221
  serialized.message,
695
1222
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -697,19 +1224,21 @@ var SqlErrorAdapter = class {
697
1224
  this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
698
1225
  ];
699
1226
  await this.ensureTable();
700
- await this.client.query(this.insertSql(), params);
1227
+ await this.query(this.insertSql(), params);
701
1228
  return { ok: true };
702
1229
  } 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
- };
1230
+ return reportFailure({
1231
+ adapterName: this.adapterName,
1232
+ prefix: this.prefix,
1233
+ action: `to write to table "${this.tableName}"`,
1234
+ error
1235
+ });
708
1236
  }
709
1237
  }
710
1238
  insertSql() {
711
1239
  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})`;
1240
+ const onRepeat = this.dialect === "postgres" ? "ON CONFLICT (id) DO NOTHING" : "ON DUPLICATE KEY UPDATE id = id";
1241
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders}) ${onRepeat}`;
713
1242
  }
714
1243
  /** Creates the table once. A failed attempt is retried on the next log call. */
715
1244
  ensureTable() {
@@ -728,9 +1257,20 @@ var SqlErrorAdapter = class {
728
1257
  }
729
1258
  async runCreateStatements() {
730
1259
  for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
731
- await this.client.query(sql, []);
1260
+ await this.query(sql, []);
732
1261
  }
733
1262
  }
1263
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
1264
+ query(sql, params) {
1265
+ return withTimeout(
1266
+ () => this.client.query(sql, params),
1267
+ this.timeoutMs,
1268
+ timeoutMessage(
1269
+ this.dialect === "postgres" ? "Postgres" : "MySQL",
1270
+ this.timeoutMs
1271
+ )
1272
+ );
1273
+ }
734
1274
  };
735
1275
  var PostgresErrorAdapter = class extends SqlErrorAdapter {
736
1276
  /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
@@ -755,7 +1295,8 @@ var RedisErrorAdapter = class {
755
1295
  this.key = options.key ?? DEFAULT_REDIS_KEY;
756
1296
  this.maxEntries = options.maxEntries;
757
1297
  this.side = options.side ?? "right";
758
- this.prefix = options.prefix ?? "[error-log-adapter]";
1298
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1299
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "RedisErrorAdapter");
759
1300
  if (this.mode !== "list" && this.mode !== "stream") {
760
1301
  throw new RangeError(
761
1302
  `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
@@ -777,11 +1318,11 @@ var RedisErrorAdapter = class {
777
1318
  }
778
1319
  async log(entry) {
779
1320
  try {
780
- const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
1321
+ const record = { id: resolveEntryId(entry), ...serializeEntry(entry) };
781
1322
  if (this.mode === "stream") {
782
- await this.client.sendCommand(this.buildStreamCommand(record));
1323
+ await this.send(this.buildStreamCommand(record));
783
1324
  } else {
784
- await this.client.sendCommand([
1325
+ await this.send([
785
1326
  this.side === "left" ? "LPUSH" : "RPUSH",
786
1327
  this.key,
787
1328
  JSON.stringify(record)
@@ -790,11 +1331,12 @@ var RedisErrorAdapter = class {
790
1331
  }
791
1332
  return { ok: true };
792
1333
  } 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
- };
1334
+ return reportFailure({
1335
+ adapterName: "RedisErrorAdapter",
1336
+ prefix: this.prefix,
1337
+ action: `to write to ${this.mode} "${this.key}"`,
1338
+ error
1339
+ });
798
1340
  }
799
1341
  }
800
1342
  buildStreamCommand(record) {
@@ -826,6 +1368,14 @@ var RedisErrorAdapter = class {
826
1368
  * stored by now, so a trim failure is reported but does not fail the call;
827
1369
  * the next successful write trims the list again.
828
1370
  */
1371
+ /** Sends one command, giving up waiting after `timeoutMs`. */
1372
+ send(args) {
1373
+ return withTimeout(
1374
+ () => this.client.sendCommand(args),
1375
+ this.timeoutMs,
1376
+ timeoutMessage("Redis", this.timeoutMs)
1377
+ );
1378
+ }
829
1379
  async trimList() {
830
1380
  if (this.maxEntries === void 0) {
831
1381
  return;
@@ -833,7 +1383,7 @@ var RedisErrorAdapter = class {
833
1383
  const keep = this.maxEntries;
834
1384
  const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
835
1385
  try {
836
- await this.client.sendCommand(["LTRIM", this.key, start, stop]);
1386
+ await this.send(["LTRIM", this.key, start, stop]);
837
1387
  } catch (error) {
838
1388
  console.error(
839
1389
  `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
@@ -860,11 +1410,12 @@ var S3ErrorAdapter = class {
860
1410
  this.bucket = bucket;
861
1411
  this.buffer = [];
862
1412
  this.timer = null;
863
- this.flushQueue = Promise.resolve();
1413
+ this.flushQueue = new SerialQueue();
864
1414
  this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
865
1415
  this.batch = options.batch;
866
1416
  this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
867
- this.logPrefix = options.prefix ?? "[error-log-adapter]";
1417
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
1418
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "S3ErrorAdapter");
868
1419
  if (bucket === "") {
869
1420
  throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
870
1421
  }
@@ -884,7 +1435,7 @@ var S3ErrorAdapter = class {
884
1435
  async log(entry) {
885
1436
  try {
886
1437
  const record = {
887
- id: crypto.randomUUID(),
1438
+ id: resolveEntryId(entry),
888
1439
  ...serializeEntry(entry)
889
1440
  };
890
1441
  const occurredAt = new Date(record.timestamp);
@@ -909,11 +1460,9 @@ var S3ErrorAdapter = class {
909
1460
  * Entries from a failed upload stay buffered and are retried by the next flush.
910
1461
  */
911
1462
  flush() {
912
- const result = this.flushQueue.then(
1463
+ return this.flushQueue.run(
913
1464
  () => this.uploadBuffered()
914
1465
  );
915
- this.flushQueue = result.catch(() => void 0);
916
- return result;
917
1466
  }
918
1467
  async addToBatch(record, batch) {
919
1468
  this.buffer.push(record);
@@ -952,7 +1501,7 @@ var S3ErrorAdapter = class {
952
1501
  return { ok: true };
953
1502
  }
954
1503
  try {
955
- const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1504
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${generateId()}.ndjson`;
956
1505
  const body = pending.map((record) => JSON.stringify(record)).join("\n");
957
1506
  await this.upload(key, `${body}
958
1507
  `);
@@ -969,64 +1518,41 @@ var S3ErrorAdapter = class {
969
1518
  }
970
1519
  }
971
1520
  upload(key, body) {
972
- return this.client.putObject({
973
- Bucket: this.bucket,
974
- Key: key,
975
- Body: body,
976
- ContentType: this.contentType
977
- });
1521
+ return withTimeout(
1522
+ () => this.client.putObject({
1523
+ Bucket: this.bucket,
1524
+ Key: key,
1525
+ Body: body,
1526
+ ContentType: this.contentType
1527
+ }),
1528
+ this.timeoutMs,
1529
+ timeoutMessage("S3", this.timeoutMs)
1530
+ );
978
1531
  }
979
1532
  fail(error, bufferedCount) {
980
- console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
981
1533
  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
- };
1534
+ return reportFailure({
1535
+ adapterName: "S3ErrorAdapter",
1536
+ prefix: this.logPrefix,
1537
+ action: `to upload to bucket "${this.bucket}"`,
1538
+ error,
1539
+ reason: `${describeError(error)}${retry}`
1540
+ });
986
1541
  }
987
1542
  };
988
1543
 
989
1544
  // 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
- }
1545
+ function waitForWrite(start, timeoutMs) {
1546
+ return withTimeout(
1547
+ start,
1548
+ timeoutMs,
1549
+ `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
1550
  );
1025
1551
  }
1026
1552
  function buildRecord(entry) {
1027
1553
  const serialized = serializeEntry(entry);
1028
1554
  const record = {
1029
- id: crypto.randomUUID(),
1555
+ id: resolveEntryId(entry),
1030
1556
  code: serialized.code,
1031
1557
  message: serialized.message,
1032
1558
  timestamp: serialized.timestamp
@@ -1043,23 +1569,27 @@ var FirestoreErrorAdapter = class {
1043
1569
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1044
1570
  constructor(collection, options = {}) {
1045
1571
  this.collection = collection;
1046
- this.prefix = options.prefix ?? "[error-log-adapter]";
1047
- this.timeoutMs = resolveTimeout(options, "FirestoreErrorAdapter");
1572
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1573
+ this.timeoutMs = resolveTimeoutMs(
1574
+ options.timeoutMs,
1575
+ "FirestoreErrorAdapter"
1576
+ );
1048
1577
  }
1049
1578
  async log(entry) {
1050
1579
  try {
1051
1580
  const record = buildRecord(entry);
1052
1581
  await waitForWrite(
1053
- this.collection.doc(record.id).set(record),
1582
+ () => this.collection.doc(record.id).set(record),
1054
1583
  this.timeoutMs
1055
1584
  );
1056
1585
  return { ok: true };
1057
1586
  } 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
- };
1587
+ return reportFailure({
1588
+ adapterName: "FirestoreErrorAdapter",
1589
+ prefix: this.prefix,
1590
+ action: `to write the document`,
1591
+ error
1592
+ });
1063
1593
  }
1064
1594
  }
1065
1595
  };
@@ -1067,34 +1597,51 @@ var RealtimeDatabaseErrorAdapter = class {
1067
1597
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1068
1598
  constructor(reference, options = {}) {
1069
1599
  this.reference = reference;
1070
- this.prefix = options.prefix ?? "[error-log-adapter]";
1071
- this.timeoutMs = resolveTimeout(options, "RealtimeDatabaseErrorAdapter");
1600
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1601
+ this.timeoutMs = resolveTimeoutMs(
1602
+ options.timeoutMs,
1603
+ "RealtimeDatabaseErrorAdapter"
1604
+ );
1072
1605
  }
1073
1606
  async log(entry) {
1074
1607
  try {
1075
- await waitForWrite(
1076
- this.reference.push(buildRecord(entry)),
1077
- this.timeoutMs
1078
- );
1608
+ const record = buildRecord(entry);
1609
+ let write;
1610
+ if (hasCallerId(entry)) {
1611
+ if (this.reference.child === void 0) {
1612
+ throw new TypeError(
1613
+ "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"
1614
+ );
1615
+ }
1616
+ write = this.reference.child(record.id).set(record);
1617
+ } else {
1618
+ write = this.reference.push(record);
1619
+ }
1620
+ await waitForWrite(() => write, this.timeoutMs);
1079
1621
  return { ok: true };
1080
1622
  } 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
- };
1623
+ return reportFailure({
1624
+ adapterName: "RealtimeDatabaseErrorAdapter",
1625
+ prefix: this.prefix,
1626
+ action: `to write the entry`,
1627
+ error
1628
+ });
1086
1629
  }
1087
1630
  }
1088
1631
  };
1089
1632
 
1090
1633
  // 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 = {}) {
1634
+ var POLICY_MAX_CODE_LENGTH = 255;
1635
+ var POLICY_MAX_MESSAGE_LENGTH = 4096;
1636
+ var POLICY_MAX_CAUSE_BYTES = 8192;
1637
+ 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})`;
1638
+ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME, options = {}) {
1093
1639
  const insertRoles = options.insertRoles ?? [];
1094
1640
  for (const role of insertRoles) {
1095
- if (!IDENTIFIER_PATTERN3.test(role)) {
1641
+ assertIdentifier(role, "role name", "buildSupabaseSetupSql");
1642
+ if ((role.toLowerCase() === "anon" || role.toLowerCase() === "public") && options.allowPublicInsert !== true) {
1096
1643
  throw new RangeError(
1097
- `buildSupabaseSetupSql: invalid role name "${role}". Use only letters, digits and underscores, and do not start with a digit.`
1644
+ `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
1645
  );
1099
1646
  }
1100
1647
  }
@@ -1106,7 +1653,7 @@ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1106
1653
  const policy = `${tableName}_insert_only`;
1107
1654
  statements.push(
1108
1655
  `DROP POLICY IF EXISTS "${policy}" ON "${tableName}"`,
1109
- `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (true)`
1656
+ `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (${INSERT_POLICY_CHECK})`
1110
1657
  );
1111
1658
  }
1112
1659
  return statements;
@@ -1136,49 +1683,97 @@ function describeSupabaseError(result) {
1136
1683
  return parts.join("; ");
1137
1684
  }
1138
1685
  var SupabaseErrorAdapter = class {
1139
- /** @throws {RangeError} if `options.tableName` is empty. */
1686
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
1140
1687
  constructor(client, options = {}) {
1141
1688
  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]";
1689
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
1690
+ assertIdentifier(this.tableName, "table name", "SupabaseErrorAdapter");
1691
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1692
+ this.timeoutMs = resolveTimeoutMs(
1693
+ options.timeoutMs,
1694
+ "SupabaseErrorAdapter"
1695
+ );
1149
1696
  }
1150
1697
  async log(entry) {
1151
1698
  try {
1152
1699
  const serialized = serializeEntry(entry);
1153
1700
  const row = {
1154
- id: crypto.randomUUID(),
1701
+ id: resolveEntryId(entry),
1155
1702
  code: serialized.code,
1156
1703
  message: serialized.message,
1157
1704
  cause: serialized.cause === void 0 ? null : serialized.cause,
1158
1705
  source: serialized.source ?? null,
1159
1706
  occurred_at: serialized.timestamp
1160
1707
  };
1161
- const result = await this.client.from(this.tableName).insert(row);
1708
+ const result = await withTimeout(
1709
+ () => this.client.from(this.tableName).insert(row),
1710
+ this.timeoutMs,
1711
+ timeoutMessage("Supabase", this.timeoutMs)
1712
+ );
1162
1713
  if (result.error) {
1163
- console.error(`${this.prefix} SupabaseErrorAdapter:`, result);
1164
- return this.fail(describeSupabaseError(result));
1714
+ if (hasCallerId(entry) && result.error.code === "23505") {
1715
+ return { ok: true };
1716
+ }
1717
+ return this.fail(result, describeSupabaseError(result));
1165
1718
  }
1166
1719
  return { ok: true };
1167
1720
  } catch (error) {
1168
- console.error(`${this.prefix} SupabaseErrorAdapter:`, error);
1169
- return this.fail(describeError(error));
1721
+ return this.fail(error, describeError(error));
1170
1722
  }
1171
1723
  }
1172
- fail(reason) {
1173
- return {
1174
- ok: false,
1175
- error: `SupabaseErrorAdapter failed to insert into table "${this.tableName}": ${reason}`
1176
- };
1724
+ fail(logged, reason) {
1725
+ return reportFailure({
1726
+ adapterName: "SupabaseErrorAdapter",
1727
+ prefix: this.prefix,
1728
+ action: `to insert into table "${this.tableName}"`,
1729
+ error: logged,
1730
+ reason
1731
+ });
1732
+ }
1733
+ };
1734
+
1735
+ // src/adapters/event-emitter-error-adapter.ts
1736
+ var DEFAULT_EVENT_NAME = "error-log";
1737
+ var EventEmitterErrorAdapter = class {
1738
+ /** @throws {RangeError} if `options.eventName` is empty. */
1739
+ constructor(emitter, options = {}) {
1740
+ this.emitter = emitter;
1741
+ this.eventName = options.eventName ?? DEFAULT_EVENT_NAME;
1742
+ if (this.eventName === "") {
1743
+ throw new RangeError(
1744
+ "EventEmitterErrorAdapter: eventName must not be empty."
1745
+ );
1746
+ }
1747
+ this.requireListener = options.requireListener ?? false;
1748
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1749
+ }
1750
+ async log(entry) {
1751
+ try {
1752
+ const record = {
1753
+ id: resolveEntryId(entry),
1754
+ ...serializeEntry(entry)
1755
+ };
1756
+ const delivered = this.emitter.emit(this.eventName, record);
1757
+ if (this.requireListener && delivered === false) {
1758
+ throw new Error(
1759
+ `no listener is attached to "${this.eventName}", so nothing received the entry`
1760
+ );
1761
+ }
1762
+ return { ok: true };
1763
+ } catch (error) {
1764
+ return reportFailure({
1765
+ adapterName: "EventEmitterErrorAdapter",
1766
+ prefix: this.prefix,
1767
+ action: `to emit "${this.eventName}"`,
1768
+ error
1769
+ });
1770
+ }
1177
1771
  }
1178
1772
  };
1179
1773
  export {
1180
1774
  CompositeErrorAdapter,
1181
1775
  ConsoleErrorAdapter,
1776
+ EventEmitterErrorAdapter,
1182
1777
  FirestoreErrorAdapter,
1183
1778
  FlatFileErrorAdapter,
1184
1779
  IndexedDbErrorAdapter,
@@ -1195,6 +1790,7 @@ export {
1195
1790
  WebhookErrorAdapter,
1196
1791
  buildCreateErrorLogTableSql,
1197
1792
  buildErrorLogTableDdl,
1793
+ buildMySqlWidenColumnsSql,
1198
1794
  buildSupabaseSetupSql,
1199
1795
  createErrorLogStore,
1200
1796
  formatErrorLogLine,