@richardmcquiston01/error-log-adapter 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  WebhookErrorAdapter: () => WebhookErrorAdapter,
39
39
  buildCreateErrorLogTableSql: () => buildCreateErrorLogTableSql,
40
40
  buildErrorLogTableDdl: () => buildErrorLogTableDdl,
41
+ buildMySqlWidenColumnsSql: () => buildMySqlWidenColumnsSql,
41
42
  buildSupabaseSetupSql: () => buildSupabaseSetupSql,
42
43
  createErrorLogStore: () => createErrorLogStore,
43
44
  formatErrorLogLine: () => formatErrorLogLine,
@@ -47,11 +48,100 @@ __export(index_exports, {
47
48
  module.exports = __toCommonJS(index_exports);
48
49
 
49
50
  // src/serialize.ts
50
- function serializeCause(cause) {
51
- if (cause instanceof Error) {
52
- return { name: cause.name, message: cause.message, stack: cause.stack };
51
+ var MAX_CAUSE_DEPTH = 6;
52
+ function isErrorLike(value) {
53
+ if (value instanceof Error) {
54
+ return true;
55
+ }
56
+ const candidate = value;
57
+ return typeof candidate.name === "string" && typeof candidate.message === "string" && typeof value.stack === "string";
58
+ }
59
+ function readProperty(source, key) {
60
+ try {
61
+ return source[key];
62
+ } catch (error) {
63
+ return `[Unreadable: ${error instanceof Error ? error.message : String(error)}]`;
64
+ }
65
+ }
66
+ function toJsonSafe(value, depth, ancestors) {
67
+ switch (typeof value) {
68
+ case "string":
69
+ case "boolean":
70
+ case "undefined":
71
+ return value;
72
+ case "number":
73
+ return Number.isFinite(value) ? value : String(value);
74
+ case "bigint":
75
+ return `${value}n`;
76
+ case "symbol":
77
+ return value.toString();
78
+ case "function":
79
+ return `[Function ${value.name || "anonymous"}]`;
80
+ default:
81
+ break;
82
+ }
83
+ if (value === null) {
84
+ return null;
85
+ }
86
+ const objectValue = value;
87
+ if (ancestors.has(objectValue)) {
88
+ return "[Circular]";
89
+ }
90
+ if (depth >= MAX_CAUSE_DEPTH) {
91
+ return "[Truncated]";
92
+ }
93
+ ancestors.add(objectValue);
94
+ try {
95
+ if (isErrorLike(objectValue)) {
96
+ const result2 = {
97
+ name: objectValue.name,
98
+ message: objectValue.message,
99
+ stack: objectValue.stack
100
+ };
101
+ const nested = readProperty(objectValue, "cause");
102
+ if (nested !== void 0) {
103
+ result2["cause"] = toJsonSafe(nested, depth + 1, ancestors);
104
+ }
105
+ const errors = readProperty(objectValue, "errors");
106
+ if (Array.isArray(errors)) {
107
+ result2["errors"] = errors.map(
108
+ (item) => toJsonSafe(item, depth + 1, ancestors)
109
+ );
110
+ }
111
+ return result2;
112
+ }
113
+ if (Array.isArray(value)) {
114
+ return value.map(
115
+ (item) => toJsonSafe(item, depth + 1, ancestors)
116
+ );
117
+ }
118
+ const toJson = readProperty(objectValue, "toJSON");
119
+ if (typeof toJson === "function") {
120
+ try {
121
+ return toJsonSafe(
122
+ toJson.call(objectValue),
123
+ depth + 1,
124
+ ancestors
125
+ );
126
+ } catch (error) {
127
+ return `[toJSON failed: ${error instanceof Error ? error.message : String(error)}]`;
128
+ }
129
+ }
130
+ const result = {};
131
+ for (const key of Object.keys(objectValue)) {
132
+ result[key] = toJsonSafe(
133
+ readProperty(objectValue, key),
134
+ depth + 1,
135
+ ancestors
136
+ );
137
+ }
138
+ return result;
139
+ } finally {
140
+ ancestors.delete(objectValue);
53
141
  }
54
- return cause;
142
+ }
143
+ function serializeCause(cause) {
144
+ return toJsonSafe(cause, 0, /* @__PURE__ */ new WeakSet());
55
145
  }
56
146
  function serializeEntry(entry) {
57
147
  const serialized = {
@@ -71,10 +161,20 @@ function describeError(error) {
71
161
  return error instanceof Error ? error.message : String(error);
72
162
  }
73
163
 
164
+ // src/fail.ts
165
+ var DEFAULT_PREFIX = "[error-log-adapter]";
166
+ function reportFailure(report) {
167
+ console.error(`${report.prefix} ${report.adapterName}:`, report.error);
168
+ return {
169
+ ok: false,
170
+ error: `${report.adapterName} failed ${report.action}: ${report.reason ?? describeError(report.error)}`
171
+ };
172
+ }
173
+
74
174
  // src/adapters/console-error-adapter.ts
75
175
  var ConsoleErrorAdapter = class {
76
176
  constructor(options = {}) {
77
- this.prefix = options.prefix ?? "[error-log-adapter]";
177
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
78
178
  }
79
179
  async log(entry) {
80
180
  try {
@@ -89,14 +189,135 @@ var ConsoleErrorAdapter = class {
89
189
  }
90
190
  };
91
191
 
192
+ // src/uuid.ts
193
+ function generateId() {
194
+ const cryptoApi = globalThis.crypto;
195
+ if (cryptoApi !== void 0 && typeof cryptoApi.randomUUID === "function") {
196
+ return cryptoApi.randomUUID();
197
+ }
198
+ const bytes = new Uint8Array(16);
199
+ if (cryptoApi !== void 0 && typeof cryptoApi.getRandomValues === "function") {
200
+ cryptoApi.getRandomValues(bytes);
201
+ } else {
202
+ for (let index = 0; index < bytes.length; index += 1) {
203
+ bytes[index] = Math.floor(Math.random() * 256);
204
+ }
205
+ }
206
+ bytes[6] = bytes[6] & 15 | 64;
207
+ bytes[8] = bytes[8] & 63 | 128;
208
+ const hex = Array.from(
209
+ bytes,
210
+ (byte) => byte.toString(16).padStart(2, "0")
211
+ ).join("");
212
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
213
+ }
214
+
215
+ // src/entry-id.ts
216
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
217
+ function hasCallerId(entry) {
218
+ return entry.id !== void 0;
219
+ }
220
+ function resolveEntryId(entry) {
221
+ if (entry.id === void 0) {
222
+ return generateId();
223
+ }
224
+ if (typeof entry.id !== "string" || !UUID_PATTERN.test(entry.id)) {
225
+ throw new RangeError(
226
+ `entry.id must be a UUID such as one from crypto.randomUUID(), received ${JSON.stringify(entry.id)}`
227
+ );
228
+ }
229
+ return entry.id.toLowerCase();
230
+ }
231
+
232
+ // src/timeout.ts
233
+ var DEFAULT_TIMEOUT_MS = 1e4;
234
+ var SinkTimeoutError = class extends Error {
235
+ constructor(message) {
236
+ super(message);
237
+ this.name = "SinkTimeoutError";
238
+ }
239
+ };
240
+ function resolveTimeoutMs(value, adapterName) {
241
+ const timeoutMs = value ?? DEFAULT_TIMEOUT_MS;
242
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
243
+ throw new RangeError(
244
+ `${adapterName}: timeoutMs must be a positive number or Infinity, received ${String(value)}.`
245
+ );
246
+ }
247
+ return timeoutMs;
248
+ }
249
+ function timeoutMessage(sink, timeoutMs, action = "respond") {
250
+ 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)`;
251
+ }
252
+ function withTimeout(start, timeoutMs, message) {
253
+ if (timeoutMs === Infinity) {
254
+ return new Promise((resolve, reject) => {
255
+ try {
256
+ resolve(start());
257
+ } catch (error) {
258
+ reject(error);
259
+ }
260
+ });
261
+ }
262
+ return new Promise(
263
+ (resolve, reject) => {
264
+ const timer = setTimeout(() => {
265
+ reject(new SinkTimeoutError(message));
266
+ }, timeoutMs);
267
+ const settle = (done) => (...args) => {
268
+ clearTimeout(timer);
269
+ done(...args);
270
+ };
271
+ try {
272
+ Promise.resolve(start()).then(settle(resolve), settle(reject));
273
+ } catch (error) {
274
+ settle(reject)(error);
275
+ }
276
+ }
277
+ );
278
+ }
279
+
92
280
  // src/adapters/webhook-error-adapter.ts
281
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
282
+ "localhost",
283
+ "127.0.0.1",
284
+ "[::1]"
285
+ ]);
286
+ function parseWebhookUrl(url, allowInsecure) {
287
+ let parsed;
288
+ try {
289
+ parsed = new URL(url);
290
+ } catch {
291
+ throw new RangeError(
292
+ "WebhookErrorAdapter: the webhook URL is not a valid absolute URL (for example https://example.com/errors)."
293
+ );
294
+ }
295
+ if (parsed.protocol === "https:") {
296
+ return parsed;
297
+ }
298
+ if (parsed.protocol === "http:" && (allowInsecure || LOOPBACK_HOSTS.has(parsed.hostname))) {
299
+ return parsed;
300
+ }
301
+ throw new RangeError(
302
+ `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.`
303
+ );
304
+ }
93
305
  var WebhookErrorAdapter = class {
306
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
94
307
  constructor(url, options = {}) {
95
308
  this.url = url;
96
309
  this.options = options;
97
- this.prefix = options.prefix ?? "[error-log-adapter]";
310
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
311
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
312
+ this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
98
313
  }
99
314
  async log(entry) {
315
+ const controller = new AbortController();
316
+ let timedOut = false;
317
+ const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
318
+ timedOut = true;
319
+ controller.abort();
320
+ }, this.timeoutMs);
100
321
  try {
101
322
  const response = await fetch(this.url, {
102
323
  method: "POST",
@@ -104,58 +325,112 @@ var WebhookErrorAdapter = class {
104
325
  "Content-Type": "application/json",
105
326
  ...this.options.headers
106
327
  },
107
- body: JSON.stringify(serializeEntry(entry))
328
+ // A caller-supplied id goes along so the receiver can drop a repeat.
329
+ body: JSON.stringify(
330
+ hasCallerId(entry) ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
331
+ ),
332
+ // A redirect would turn the POST into a GET without a body (302, 303)
333
+ // or re-send the entry and headers to another host (307, 308).
334
+ redirect: "manual",
335
+ signal: controller.signal
108
336
  });
337
+ if (response.type === "opaqueredirect") {
338
+ return this.fail(this.redirectMessage("a redirect"));
339
+ }
109
340
  if (!response.ok) {
341
+ if (response.status >= 300 && response.status < 400) {
342
+ return this.fail(this.redirectMessage(`HTTP ${response.status}`));
343
+ }
110
344
  return this.fail(
111
- `webhook ${this.url} responded with HTTP ${response.status}`
345
+ `webhook ${this.target} responded with HTTP ${response.status}`
112
346
  );
113
347
  }
114
348
  return { ok: true };
115
349
  } catch (error) {
116
350
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
351
+ if (timedOut) {
352
+ return this.fail(
353
+ timeoutMessage(`webhook ${this.target}`, this.timeoutMs)
354
+ );
355
+ }
117
356
  return this.fail(
118
- `could not reach webhook ${this.url}: ${describeError(error)}`
357
+ `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`
119
358
  );
359
+ } finally {
360
+ clearTimeout(timer);
120
361
  }
121
362
  }
363
+ redirectMessage(what) {
364
+ return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
365
+ }
366
+ /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
367
+ redact(message) {
368
+ return message.split(this.url).join(this.target);
369
+ }
122
370
  fail(reason) {
123
371
  return { ok: false, error: `WebhookErrorAdapter failed: ${reason}` };
124
372
  }
125
373
  };
126
374
 
127
375
  // src/adapters/mongodb-error-adapter.ts
376
+ function isDuplicateKeyError(error) {
377
+ return typeof error === "object" && error !== null && error.code === 11e3;
378
+ }
128
379
  var MongoDbErrorAdapter = class {
380
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
129
381
  constructor(collection, options = {}) {
130
382
  this.collection = collection;
131
- this.prefix = options.prefix ?? "[error-log-adapter]";
383
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
384
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "MongoDbErrorAdapter");
132
385
  }
133
386
  async log(entry) {
134
387
  try {
135
- await this.collection.insertOne(serializeEntry(entry));
388
+ const document = hasCallerId(entry) ? { _id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry);
389
+ await withTimeout(
390
+ () => this.collection.insertOne(document),
391
+ this.timeoutMs,
392
+ timeoutMessage("MongoDB", this.timeoutMs)
393
+ );
136
394
  return { ok: true };
137
395
  } catch (error) {
138
- console.error(`${this.prefix} MongoDbErrorAdapter:`, error);
139
- return {
140
- ok: false,
141
- error: `MongoDbErrorAdapter failed to insert entry: ${describeError(error)}`
142
- };
396
+ if (hasCallerId(entry) && isDuplicateKeyError(error)) {
397
+ return { ok: true };
398
+ }
399
+ return reportFailure({
400
+ adapterName: "MongoDbErrorAdapter",
401
+ prefix: this.prefix,
402
+ action: "to insert entry",
403
+ error
404
+ });
143
405
  }
144
406
  }
145
407
  };
146
408
 
147
409
  // src/adapters/composite-error-adapter.ts
410
+ var DEFAULT_COMPOSITE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 3;
148
411
  var CompositeErrorAdapter = class {
149
- constructor(adapters) {
412
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
413
+ constructor(adapters, options = {}) {
150
414
  this.adapters = adapters;
415
+ this.timeoutMs = resolveTimeoutMs(
416
+ options.timeoutMs ?? DEFAULT_COMPOSITE_TIMEOUT_MS,
417
+ "CompositeErrorAdapter"
418
+ );
151
419
  }
152
420
  async log(entry) {
153
421
  const results = await Promise.all(
154
422
  this.adapters.map(
155
- async (adapter) => {
423
+ async (adapter, index) => {
156
424
  try {
157
- return await adapter.log(entry);
425
+ return await withTimeout(
426
+ () => adapter.log(entry),
427
+ this.timeoutMs,
428
+ `adapter ${index} (${adapter.constructor.name}) did not return within ${this.timeoutMs} ms, so the entry may or may not have been stored there`
429
+ );
158
430
  } catch (error) {
431
+ if (error instanceof SinkTimeoutError) {
432
+ return { ok: false, error: error.message };
433
+ }
159
434
  return {
160
435
  ok: false,
161
436
  error: `adapter threw instead of returning a result: ${String(error)}`
@@ -186,58 +461,101 @@ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
186
461
  database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
187
462
  }
188
463
  var IndexedDbErrorAdapter = class {
464
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
189
465
  constructor(database, options = {}) {
190
466
  this.database = database;
191
467
  this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
192
- this.prefix = options.prefix ?? "[error-log-adapter]";
468
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
469
+ this.timeoutMs = resolveTimeoutMs(
470
+ options.timeoutMs,
471
+ "IndexedDbErrorAdapter"
472
+ );
193
473
  }
194
474
  async log(entry) {
195
475
  try {
196
476
  const record = {
197
- id: crypto.randomUUID(),
477
+ id: resolveEntryId(entry),
198
478
  ...serializeEntry(entry)
199
479
  };
200
- await this.write(record);
480
+ await this.write(record, hasCallerId(entry));
201
481
  return { ok: true };
202
482
  } 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
- };
483
+ return reportFailure({
484
+ adapterName: "IndexedDbErrorAdapter",
485
+ prefix: this.prefix,
486
+ action: `to write to object store "${this.storeName}"`,
487
+ error
488
+ });
208
489
  }
209
490
  }
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
- }
491
+ /**
492
+ * Resolves once the transaction commits; rejects if the request fails, the
493
+ * transaction aborts, or it does not finish within `timeoutMs`.
494
+ *
495
+ * Error events are handled here (`preventDefault` and `stopPropagation`), so a
496
+ * failed log write does not reach the `onerror` handler an app may have set on
497
+ * its own database object.
498
+ */
499
+ write(record, replaceExisting) {
500
+ return withTimeout(
501
+ () => new Promise(
502
+ (resolve, reject) => {
503
+ const transaction = this.database.transaction(
504
+ this.storeName,
505
+ "readwrite"
506
+ );
507
+ const fail = (what, source) => (event) => {
508
+ event?.preventDefault?.();
509
+ event?.stopPropagation?.();
510
+ reject(new Error(`${what}: ${describeDomError(source.error)}`));
511
+ };
512
+ transaction.oncomplete = () => resolve();
513
+ transaction.onerror = fail("transaction failed", transaction);
514
+ transaction.onabort = fail("transaction aborted", transaction);
515
+ const store = transaction.objectStore(
516
+ this.storeName
517
+ );
518
+ const request = replaceExisting && typeof store.put === "function" ? store.put(record) : store.add(record);
519
+ if (typeof request === "object" && request !== null) {
520
+ request.onerror = fail(
521
+ "request failed",
522
+ request
523
+ );
524
+ }
525
+ }
526
+ ),
527
+ this.timeoutMs,
528
+ timeoutMessage("IndexedDB", this.timeoutMs, "finish the transaction")
228
529
  );
229
530
  }
230
531
  };
532
+ function describeDomError(error) {
533
+ if (error === null || error === void 0) {
534
+ return "no details provided";
535
+ }
536
+ const text = [error.name, error.message].filter((part) => typeof part === "string" && part !== "").join(": ");
537
+ return text === "" ? "no details provided" : text;
538
+ }
231
539
 
232
- // src/adapters/sqlite-error-adapter.ts
540
+ // src/sql-ident.ts
233
541
  var DEFAULT_TABLE_NAME = "error_log";
234
542
  var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
235
- function assertValidTableName(tableName) {
236
- if (!IDENTIFIER_PATTERN.test(tableName)) {
543
+ function assertIdentifier(value, label, owner, maxLength) {
544
+ if (!IDENTIFIER_PATTERN.test(value)) {
237
545
  throw new RangeError(
238
- `SqliteErrorAdapter: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
546
+ `${owner}: invalid ${label} "${value}". Use only letters, digits and underscores, and do not start with a digit.`
239
547
  );
240
548
  }
549
+ if (maxLength !== void 0 && value.length > maxLength) {
550
+ throw new RangeError(
551
+ `${owner}: ${label} "${value}" is ${value.length} characters long, but the limit is ${maxLength}.`
552
+ );
553
+ }
554
+ }
555
+
556
+ // src/adapters/sqlite-error-adapter.ts
557
+ function assertValidTableName(tableName) {
558
+ assertIdentifier(tableName, "table name", "SqliteErrorAdapter");
241
559
  }
242
560
  function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
243
561
  assertValidTableName(tableName);
@@ -247,23 +565,24 @@ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
247
565
  ];
248
566
  }
249
567
  var SqliteErrorAdapter = class {
250
- /** @throws {RangeError} if `options.tableName` is not a plain identifier. */
568
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
251
569
  constructor(client, options = {}) {
252
570
  this.client = client;
253
571
  this.tableReady = null;
254
572
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
255
573
  assertValidTableName(this.tableName);
256
574
  this.createTable = options.createTable ?? true;
257
- this.prefix = options.prefix ?? "[error-log-adapter]";
575
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
576
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "SqliteErrorAdapter");
258
577
  }
259
578
  async log(entry) {
260
579
  try {
261
580
  await this.ensureTable();
262
581
  const serialized = serializeEntry(entry);
263
- await this.client.run(
264
- `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?)`,
582
+ await this.run(
583
+ `INSERT INTO ${this.tableName} (id, code, message, cause, source, occurred_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`,
265
584
  [
266
- crypto.randomUUID(),
585
+ resolveEntryId(entry),
267
586
  serialized.code,
268
587
  serialized.message,
269
588
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -273,11 +592,12 @@ var SqliteErrorAdapter = class {
273
592
  );
274
593
  return { ok: true };
275
594
  } 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
- };
595
+ return reportFailure({
596
+ adapterName: "SqliteErrorAdapter",
597
+ prefix: this.prefix,
598
+ action: `to write to table "${this.tableName}"`,
599
+ error
600
+ });
281
601
  }
282
602
  }
283
603
  /** Creates the table once. A failed attempt is retried on the next log call. */
@@ -297,9 +617,17 @@ var SqliteErrorAdapter = class {
297
617
  }
298
618
  async runCreateStatements() {
299
619
  for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
300
- await this.client.run(sql, []);
620
+ await this.run(sql, []);
301
621
  }
302
622
  }
623
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
624
+ run(sql, params) {
625
+ return withTimeout(
626
+ () => this.client.run(sql, params),
627
+ this.timeoutMs,
628
+ timeoutMessage("SQLite", this.timeoutMs)
629
+ );
630
+ }
303
631
  };
304
632
 
305
633
  // src/adapters/web-storage-error-adapter.ts
@@ -318,7 +646,8 @@ var WebStorageErrorAdapter = class {
318
646
  this.storage = storage;
319
647
  this.key = options.key ?? DEFAULT_STORAGE_KEY;
320
648
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
321
- this.prefix = options.prefix ?? "[error-log-adapter]";
649
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
650
+ this.locks = options.locks === void 0 ? defaultLockManager() : options.locks;
322
651
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
323
652
  throw new RangeError(
324
653
  `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
@@ -328,23 +657,49 @@ var WebStorageErrorAdapter = class {
328
657
  async log(entry) {
329
658
  try {
330
659
  const record = {
331
- id: crypto.randomUUID(),
660
+ id: resolveEntryId(entry),
332
661
  ...serializeEntry(entry)
333
662
  };
334
- this.append(record);
663
+ await this.appendLocked(record);
335
664
  return { ok: true };
336
665
  } 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
- };
666
+ return reportFailure({
667
+ adapterName: "WebStorageErrorAdapter",
668
+ prefix: this.prefix,
669
+ action: `to write to storage key "${this.key}"`,
670
+ error
671
+ });
672
+ }
673
+ }
674
+ /**
675
+ * Appends under a cross-tab lock when one is available. If the lock itself
676
+ * cannot be taken (for example a sandboxed frame), the entry is still written
677
+ * without it rather than lost.
678
+ */
679
+ async appendLocked(record) {
680
+ if (this.locks === null) {
681
+ this.append(record);
682
+ return;
683
+ }
684
+ let appended = false;
685
+ try {
686
+ await this.locks.request(`error-log-adapter:${this.key}`, () => {
687
+ appended = true;
688
+ this.append(record);
689
+ });
690
+ } catch (error) {
691
+ if (appended) {
692
+ throw error;
693
+ }
694
+ this.append(record);
342
695
  }
343
696
  }
344
- /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
697
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls in one tab never interleave. */
345
698
  append(record) {
346
699
  const records = [
347
- ...this.readRecords(),
700
+ ...this.readRecords().filter(
701
+ (existing) => existing?.id !== record.id
702
+ ),
348
703
  record
349
704
  ].slice(-this.maxEntries);
350
705
  for (; ; ) {
@@ -370,16 +725,51 @@ var WebStorageErrorAdapter = class {
370
725
  if (Array.isArray(parsed)) {
371
726
  return parsed;
372
727
  }
373
- console.error(
374
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
375
- );
728
+ this.setAside(stored, "did not hold an array");
376
729
  } catch (error) {
377
- console.error(
378
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
379
- );
730
+ this.setAside(stored, `held invalid JSON (${describeError(error)})`);
380
731
  }
381
732
  return [];
382
733
  }
734
+ /**
735
+ * Copies an unusable value to `<key>.corrupt` before it is replaced, so data
736
+ * the host app stored under the same key is not silently destroyed. Only the
737
+ * first one is kept; a later unusable value would not overwrite it.
738
+ */
739
+ setAside(stored, reason) {
740
+ const backupKey = `${this.key}${CORRUPT_KEY_SUFFIX}`;
741
+ let savedTo = "but it could not be copied aside";
742
+ try {
743
+ if (this.storage.getItem(backupKey) === null) {
744
+ this.storage.setItem(backupKey, stored);
745
+ savedTo = `and a copy was kept under "${backupKey}"`;
746
+ } else {
747
+ savedTo = `and "${backupKey}" already holds an earlier copy`;
748
+ }
749
+ } catch {
750
+ }
751
+ console.error(
752
+ `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" ${reason}, so it was replaced, ${savedTo}.`
753
+ );
754
+ }
755
+ };
756
+ var CORRUPT_KEY_SUFFIX = ".corrupt";
757
+ function defaultLockManager() {
758
+ const locks = globalThis.navigator?.locks;
759
+ return typeof locks?.request === "function" ? locks : null;
760
+ }
761
+
762
+ // src/queue.ts
763
+ var SerialQueue = class {
764
+ constructor() {
765
+ this.tail = Promise.resolve();
766
+ }
767
+ /** Starts `task` once every earlier task has finished, and resolves with its result. */
768
+ run(task) {
769
+ const result = this.tail.then(task);
770
+ this.tail = result.catch(() => void 0);
771
+ return result;
772
+ }
383
773
  };
384
774
 
385
775
  // src/file-system.ts
@@ -387,6 +777,7 @@ async function loadNodeFileSystem() {
387
777
  const moduleName = "node:fs/promises";
388
778
  return await import(moduleName);
389
779
  }
780
+ var ROOT_PATTERN = /^(?:[A-Za-z]:)?[\\/]+$/;
390
781
  function parentDirectory(filePath) {
391
782
  const lastSeparator = Math.max(
392
783
  filePath.lastIndexOf("/"),
@@ -395,7 +786,11 @@ function parentDirectory(filePath) {
395
786
  if (lastSeparator < 0) {
396
787
  return null;
397
788
  }
398
- return lastSeparator === 0 ? "/" : filePath.slice(0, lastSeparator);
789
+ const directory = filePath.slice(0, lastSeparator + 1);
790
+ if (ROOT_PATTERN.test(directory)) {
791
+ return null;
792
+ }
793
+ return filePath.slice(0, lastSeparator);
399
794
  }
400
795
  function isMissingFileError(error) {
401
796
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -441,24 +836,20 @@ var FlatFileErrorAdapter = class {
441
836
  constructor(filePath, options = {}) {
442
837
  this.filePath = filePath;
443
838
  this.directoryReady = false;
444
- this.queue = Promise.resolve();
839
+ this.queue = new SerialQueue();
445
840
  this.fs = options.fs;
446
841
  this.formatLine = options.formatLine ?? formatErrorLogLine;
447
842
  this.maxBytes = options.maxBytes;
448
843
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
449
844
  this.createDirectory = options.createDirectory ?? true;
450
- this.prefix = options.prefix ?? "[error-log-adapter]";
845
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
451
846
  if (this.maxBytes !== void 0) {
452
847
  assertPositiveInteger(this.maxBytes, "maxBytes");
453
848
  }
454
849
  assertPositiveInteger(this.maxFiles, "maxFiles");
455
850
  }
456
851
  log(entry) {
457
- const result = this.queue.then(
458
- () => this.write(entry)
459
- );
460
- this.queue = result.catch(() => void 0);
461
- return result;
852
+ return this.queue.run(() => this.write(entry));
462
853
  }
463
854
  async write(entry) {
464
855
  try {
@@ -471,11 +862,12 @@ var FlatFileErrorAdapter = class {
471
862
  await fs.appendFile(this.filePath, line);
472
863
  return { ok: true };
473
864
  } 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
- };
865
+ return reportFailure({
866
+ adapterName: "FlatFileErrorAdapter",
867
+ prefix: this.prefix,
868
+ action: `to append to "${this.filePath}"`,
869
+ error
870
+ });
479
871
  }
480
872
  }
481
873
  /** Uses the injected file system, or loads `node:fs/promises` on first use. */
@@ -550,13 +942,13 @@ var JsonFileErrorAdapter = class {
550
942
  constructor(filePath, options = {}) {
551
943
  this.filePath = filePath;
552
944
  this.directoryReady = false;
553
- this.queue = Promise.resolve();
945
+ this.queue = new SerialQueue();
554
946
  this.mode = options.mode ?? "ndjson";
555
947
  this.maxEntries = options.maxEntries;
556
948
  this.indent = options.indent ?? DEFAULT_INDENT;
557
949
  this.fs = options.fs;
558
950
  this.createDirectory = options.createDirectory ?? true;
559
- this.prefix = options.prefix ?? "[error-log-adapter]";
951
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
560
952
  if (this.maxEntries !== void 0) {
561
953
  if (this.mode !== "array") {
562
954
  throw new RangeError(
@@ -576,16 +968,12 @@ var JsonFileErrorAdapter = class {
576
968
  }
577
969
  }
578
970
  log(entry) {
579
- const result = this.queue.then(
580
- () => this.write(entry)
581
- );
582
- this.queue = result.catch(() => void 0);
583
- return result;
971
+ return this.queue.run(() => this.write(entry));
584
972
  }
585
973
  async write(entry) {
586
974
  try {
587
975
  const record = {
588
- id: crypto.randomUUID(),
976
+ id: resolveEntryId(entry),
589
977
  ...serializeEntry(entry)
590
978
  };
591
979
  const fs = await this.getFileSystem();
@@ -598,11 +986,12 @@ var JsonFileErrorAdapter = class {
598
986
  }
599
987
  return { ok: true };
600
988
  } 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
- };
989
+ return reportFailure({
990
+ adapterName: "JsonFileErrorAdapter",
991
+ prefix: this.prefix,
992
+ action: `to write "${this.filePath}" in ${this.mode} mode`,
993
+ error
994
+ });
606
995
  }
607
996
  }
608
997
  async getFileSystem() {
@@ -628,7 +1017,9 @@ var JsonFileErrorAdapter = class {
628
1017
  */
629
1018
  async rewriteArray(fs, record) {
630
1019
  const records = [
631
- ...await this.readRecords(fs),
1020
+ ...(await this.readRecords(fs)).filter(
1021
+ (existing) => existing?.id !== record.id
1022
+ ),
632
1023
  record
633
1024
  ];
634
1025
  const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
@@ -678,25 +1069,14 @@ var JsonFileErrorAdapter = class {
678
1069
  };
679
1070
 
680
1071
  // src/adapters/sql-error-adapter.ts
681
- var DEFAULT_TABLE_NAME2 = "error_log";
682
1072
  var MAX_TABLE_NAME_LENGTH = 47;
683
- var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
684
1073
  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
- }
1074
+ assertIdentifier(tableName, "table name", adapterName, MAX_TABLE_NAME_LENGTH);
695
1075
  }
696
1076
  function quoteIdentifier(dialect, identifier) {
697
1077
  return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
698
1078
  }
699
- function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
1079
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME) {
700
1080
  assertValidTableName2(tableName, "buildErrorLogTableDdl");
701
1081
  const table = quoteIdentifier(dialect, tableName);
702
1082
  const index = quoteIdentifier(
@@ -710,7 +1090,13 @@ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
710
1090
  ];
711
1091
  }
712
1092
  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))`
1093
+ `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))`
1094
+ ];
1095
+ }
1096
+ function buildMySqlWidenColumnsSql(tableName = DEFAULT_TABLE_NAME) {
1097
+ assertValidTableName2(tableName, "buildMySqlWidenColumnsSql");
1098
+ return [
1099
+ `ALTER TABLE ${quoteIdentifier("mysql", tableName)} MODIFY code TEXT NOT NULL, MODIFY source TEXT NULL`
714
1100
  ];
715
1101
  }
716
1102
  function toMysqlDateTime(date) {
@@ -722,10 +1108,11 @@ var SqlErrorAdapter = class {
722
1108
  this.dialect = dialect;
723
1109
  this.adapterName = adapterName;
724
1110
  this.tableReady = null;
725
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1111
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
726
1112
  assertValidTableName2(this.tableName, adapterName);
727
1113
  this.createTable = options.createTable ?? true;
728
- this.prefix = options.prefix ?? "[error-log-adapter]";
1114
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1115
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, adapterName);
729
1116
  }
730
1117
  async log(entry) {
731
1118
  try {
@@ -737,7 +1124,7 @@ var SqlErrorAdapter = class {
737
1124
  );
738
1125
  }
739
1126
  const params = [
740
- crypto.randomUUID(),
1127
+ resolveEntryId(entry),
741
1128
  serialized.code,
742
1129
  serialized.message,
743
1130
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -745,19 +1132,21 @@ var SqlErrorAdapter = class {
745
1132
  this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
746
1133
  ];
747
1134
  await this.ensureTable();
748
- await this.client.query(this.insertSql(), params);
1135
+ await this.query(this.insertSql(), params);
749
1136
  return { ok: true };
750
1137
  } 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
- };
1138
+ return reportFailure({
1139
+ adapterName: this.adapterName,
1140
+ prefix: this.prefix,
1141
+ action: `to write to table "${this.tableName}"`,
1142
+ error
1143
+ });
756
1144
  }
757
1145
  }
758
1146
  insertSql() {
759
1147
  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})`;
1148
+ const onRepeat = this.dialect === "postgres" ? "ON CONFLICT (id) DO NOTHING" : "ON DUPLICATE KEY UPDATE id = id";
1149
+ return `INSERT INTO ${quoteIdentifier(this.dialect, this.tableName)} (id, code, message, cause, source, occurred_at) VALUES (${placeholders}) ${onRepeat}`;
761
1150
  }
762
1151
  /** Creates the table once. A failed attempt is retried on the next log call. */
763
1152
  ensureTable() {
@@ -776,9 +1165,20 @@ var SqlErrorAdapter = class {
776
1165
  }
777
1166
  async runCreateStatements() {
778
1167
  for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
779
- await this.client.query(sql, []);
1168
+ await this.query(sql, []);
780
1169
  }
781
1170
  }
1171
+ /** Runs one statement, giving up waiting after `timeoutMs`. */
1172
+ query(sql, params) {
1173
+ return withTimeout(
1174
+ () => this.client.query(sql, params),
1175
+ this.timeoutMs,
1176
+ timeoutMessage(
1177
+ this.dialect === "postgres" ? "Postgres" : "MySQL",
1178
+ this.timeoutMs
1179
+ )
1180
+ );
1181
+ }
782
1182
  };
783
1183
  var PostgresErrorAdapter = class extends SqlErrorAdapter {
784
1184
  /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
@@ -803,7 +1203,8 @@ var RedisErrorAdapter = class {
803
1203
  this.key = options.key ?? DEFAULT_REDIS_KEY;
804
1204
  this.maxEntries = options.maxEntries;
805
1205
  this.side = options.side ?? "right";
806
- this.prefix = options.prefix ?? "[error-log-adapter]";
1206
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1207
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "RedisErrorAdapter");
807
1208
  if (this.mode !== "list" && this.mode !== "stream") {
808
1209
  throw new RangeError(
809
1210
  `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
@@ -825,11 +1226,11 @@ var RedisErrorAdapter = class {
825
1226
  }
826
1227
  async log(entry) {
827
1228
  try {
828
- const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
1229
+ const record = { id: resolveEntryId(entry), ...serializeEntry(entry) };
829
1230
  if (this.mode === "stream") {
830
- await this.client.sendCommand(this.buildStreamCommand(record));
1231
+ await this.send(this.buildStreamCommand(record));
831
1232
  } else {
832
- await this.client.sendCommand([
1233
+ await this.send([
833
1234
  this.side === "left" ? "LPUSH" : "RPUSH",
834
1235
  this.key,
835
1236
  JSON.stringify(record)
@@ -838,11 +1239,12 @@ var RedisErrorAdapter = class {
838
1239
  }
839
1240
  return { ok: true };
840
1241
  } 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
- };
1242
+ return reportFailure({
1243
+ adapterName: "RedisErrorAdapter",
1244
+ prefix: this.prefix,
1245
+ action: `to write to ${this.mode} "${this.key}"`,
1246
+ error
1247
+ });
846
1248
  }
847
1249
  }
848
1250
  buildStreamCommand(record) {
@@ -874,6 +1276,14 @@ var RedisErrorAdapter = class {
874
1276
  * stored by now, so a trim failure is reported but does not fail the call;
875
1277
  * the next successful write trims the list again.
876
1278
  */
1279
+ /** Sends one command, giving up waiting after `timeoutMs`. */
1280
+ send(args) {
1281
+ return withTimeout(
1282
+ () => this.client.sendCommand(args),
1283
+ this.timeoutMs,
1284
+ timeoutMessage("Redis", this.timeoutMs)
1285
+ );
1286
+ }
877
1287
  async trimList() {
878
1288
  if (this.maxEntries === void 0) {
879
1289
  return;
@@ -881,7 +1291,7 @@ var RedisErrorAdapter = class {
881
1291
  const keep = this.maxEntries;
882
1292
  const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
883
1293
  try {
884
- await this.client.sendCommand(["LTRIM", this.key, start, stop]);
1294
+ await this.send(["LTRIM", this.key, start, stop]);
885
1295
  } catch (error) {
886
1296
  console.error(
887
1297
  `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
@@ -908,11 +1318,12 @@ var S3ErrorAdapter = class {
908
1318
  this.bucket = bucket;
909
1319
  this.buffer = [];
910
1320
  this.timer = null;
911
- this.flushQueue = Promise.resolve();
1321
+ this.flushQueue = new SerialQueue();
912
1322
  this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
913
1323
  this.batch = options.batch;
914
1324
  this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
915
- this.logPrefix = options.prefix ?? "[error-log-adapter]";
1325
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
1326
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "S3ErrorAdapter");
916
1327
  if (bucket === "") {
917
1328
  throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
918
1329
  }
@@ -932,7 +1343,7 @@ var S3ErrorAdapter = class {
932
1343
  async log(entry) {
933
1344
  try {
934
1345
  const record = {
935
- id: crypto.randomUUID(),
1346
+ id: resolveEntryId(entry),
936
1347
  ...serializeEntry(entry)
937
1348
  };
938
1349
  const occurredAt = new Date(record.timestamp);
@@ -957,11 +1368,9 @@ var S3ErrorAdapter = class {
957
1368
  * Entries from a failed upload stay buffered and are retried by the next flush.
958
1369
  */
959
1370
  flush() {
960
- const result = this.flushQueue.then(
1371
+ return this.flushQueue.run(
961
1372
  () => this.uploadBuffered()
962
1373
  );
963
- this.flushQueue = result.catch(() => void 0);
964
- return result;
965
1374
  }
966
1375
  async addToBatch(record, batch) {
967
1376
  this.buffer.push(record);
@@ -1000,7 +1409,7 @@ var S3ErrorAdapter = class {
1000
1409
  return { ok: true };
1001
1410
  }
1002
1411
  try {
1003
- const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1412
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${generateId()}.ndjson`;
1004
1413
  const body = pending.map((record) => JSON.stringify(record)).join("\n");
1005
1414
  await this.upload(key, `${body}
1006
1415
  `);
@@ -1017,64 +1426,41 @@ var S3ErrorAdapter = class {
1017
1426
  }
1018
1427
  }
1019
1428
  upload(key, body) {
1020
- return this.client.putObject({
1021
- Bucket: this.bucket,
1022
- Key: key,
1023
- Body: body,
1024
- ContentType: this.contentType
1025
- });
1429
+ return withTimeout(
1430
+ () => this.client.putObject({
1431
+ Bucket: this.bucket,
1432
+ Key: key,
1433
+ Body: body,
1434
+ ContentType: this.contentType
1435
+ }),
1436
+ this.timeoutMs,
1437
+ timeoutMessage("S3", this.timeoutMs)
1438
+ );
1026
1439
  }
1027
1440
  fail(error, bufferedCount) {
1028
- console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
1029
1441
  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
- };
1442
+ return reportFailure({
1443
+ adapterName: "S3ErrorAdapter",
1444
+ prefix: this.logPrefix,
1445
+ action: `to upload to bucket "${this.bucket}"`,
1446
+ error,
1447
+ reason: `${describeError(error)}${retry}`
1448
+ });
1034
1449
  }
1035
1450
  };
1036
1451
 
1037
1452
  // 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
- }
1453
+ function waitForWrite(start, timeoutMs) {
1454
+ return withTimeout(
1455
+ start,
1456
+ timeoutMs,
1457
+ `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
1458
  );
1073
1459
  }
1074
1460
  function buildRecord(entry) {
1075
1461
  const serialized = serializeEntry(entry);
1076
1462
  const record = {
1077
- id: crypto.randomUUID(),
1463
+ id: resolveEntryId(entry),
1078
1464
  code: serialized.code,
1079
1465
  message: serialized.message,
1080
1466
  timestamp: serialized.timestamp
@@ -1091,23 +1477,27 @@ var FirestoreErrorAdapter = class {
1091
1477
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1092
1478
  constructor(collection, options = {}) {
1093
1479
  this.collection = collection;
1094
- this.prefix = options.prefix ?? "[error-log-adapter]";
1095
- this.timeoutMs = resolveTimeout(options, "FirestoreErrorAdapter");
1480
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1481
+ this.timeoutMs = resolveTimeoutMs(
1482
+ options.timeoutMs,
1483
+ "FirestoreErrorAdapter"
1484
+ );
1096
1485
  }
1097
1486
  async log(entry) {
1098
1487
  try {
1099
1488
  const record = buildRecord(entry);
1100
1489
  await waitForWrite(
1101
- this.collection.doc(record.id).set(record),
1490
+ () => this.collection.doc(record.id).set(record),
1102
1491
  this.timeoutMs
1103
1492
  );
1104
1493
  return { ok: true };
1105
1494
  } 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
- };
1495
+ return reportFailure({
1496
+ adapterName: "FirestoreErrorAdapter",
1497
+ prefix: this.prefix,
1498
+ action: `to write the document`,
1499
+ error
1500
+ });
1111
1501
  }
1112
1502
  }
1113
1503
  };
@@ -1115,34 +1505,51 @@ var RealtimeDatabaseErrorAdapter = class {
1115
1505
  /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1116
1506
  constructor(reference, options = {}) {
1117
1507
  this.reference = reference;
1118
- this.prefix = options.prefix ?? "[error-log-adapter]";
1119
- this.timeoutMs = resolveTimeout(options, "RealtimeDatabaseErrorAdapter");
1508
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1509
+ this.timeoutMs = resolveTimeoutMs(
1510
+ options.timeoutMs,
1511
+ "RealtimeDatabaseErrorAdapter"
1512
+ );
1120
1513
  }
1121
1514
  async log(entry) {
1122
1515
  try {
1123
- await waitForWrite(
1124
- this.reference.push(buildRecord(entry)),
1125
- this.timeoutMs
1126
- );
1516
+ const record = buildRecord(entry);
1517
+ let write;
1518
+ if (hasCallerId(entry)) {
1519
+ if (this.reference.child === void 0) {
1520
+ throw new TypeError(
1521
+ "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"
1522
+ );
1523
+ }
1524
+ write = this.reference.child(record.id).set(record);
1525
+ } else {
1526
+ write = this.reference.push(record);
1527
+ }
1528
+ await waitForWrite(() => write, this.timeoutMs);
1127
1529
  return { ok: true };
1128
1530
  } 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
- };
1531
+ return reportFailure({
1532
+ adapterName: "RealtimeDatabaseErrorAdapter",
1533
+ prefix: this.prefix,
1534
+ action: `to write the entry`,
1535
+ error
1536
+ });
1134
1537
  }
1135
1538
  }
1136
1539
  };
1137
1540
 
1138
1541
  // 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 = {}) {
1542
+ var POLICY_MAX_CODE_LENGTH = 255;
1543
+ var POLICY_MAX_MESSAGE_LENGTH = 4096;
1544
+ var POLICY_MAX_CAUSE_BYTES = 8192;
1545
+ 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})`;
1546
+ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME, options = {}) {
1141
1547
  const insertRoles = options.insertRoles ?? [];
1142
1548
  for (const role of insertRoles) {
1143
- if (!IDENTIFIER_PATTERN3.test(role)) {
1549
+ assertIdentifier(role, "role name", "buildSupabaseSetupSql");
1550
+ if ((role.toLowerCase() === "anon" || role.toLowerCase() === "public") && options.allowPublicInsert !== true) {
1144
1551
  throw new RangeError(
1145
- `buildSupabaseSetupSql: invalid role name "${role}". Use only letters, digits and underscores, and do not start with a digit.`
1552
+ `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
1553
  );
1147
1554
  }
1148
1555
  }
@@ -1154,7 +1561,7 @@ function buildSupabaseSetupSql(tableName = DEFAULT_TABLE_NAME2, options = {}) {
1154
1561
  const policy = `${tableName}_insert_only`;
1155
1562
  statements.push(
1156
1563
  `DROP POLICY IF EXISTS "${policy}" ON "${tableName}"`,
1157
- `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (true)`
1564
+ `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (${INSERT_POLICY_CHECK})`
1158
1565
  );
1159
1566
  }
1160
1567
  return statements;
@@ -1184,44 +1591,52 @@ function describeSupabaseError(result) {
1184
1591
  return parts.join("; ");
1185
1592
  }
1186
1593
  var SupabaseErrorAdapter = class {
1187
- /** @throws {RangeError} if `options.tableName` is empty. */
1594
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
1188
1595
  constructor(client, options = {}) {
1189
1596
  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]";
1597
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
1598
+ assertIdentifier(this.tableName, "table name", "SupabaseErrorAdapter");
1599
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1600
+ this.timeoutMs = resolveTimeoutMs(
1601
+ options.timeoutMs,
1602
+ "SupabaseErrorAdapter"
1603
+ );
1197
1604
  }
1198
1605
  async log(entry) {
1199
1606
  try {
1200
1607
  const serialized = serializeEntry(entry);
1201
1608
  const row = {
1202
- id: crypto.randomUUID(),
1609
+ id: resolveEntryId(entry),
1203
1610
  code: serialized.code,
1204
1611
  message: serialized.message,
1205
1612
  cause: serialized.cause === void 0 ? null : serialized.cause,
1206
1613
  source: serialized.source ?? null,
1207
1614
  occurred_at: serialized.timestamp
1208
1615
  };
1209
- const result = await this.client.from(this.tableName).insert(row);
1616
+ const result = await withTimeout(
1617
+ () => this.client.from(this.tableName).insert(row),
1618
+ this.timeoutMs,
1619
+ timeoutMessage("Supabase", this.timeoutMs)
1620
+ );
1210
1621
  if (result.error) {
1211
- console.error(`${this.prefix} SupabaseErrorAdapter:`, result);
1212
- return this.fail(describeSupabaseError(result));
1622
+ if (hasCallerId(entry) && result.error.code === "23505") {
1623
+ return { ok: true };
1624
+ }
1625
+ return this.fail(result, describeSupabaseError(result));
1213
1626
  }
1214
1627
  return { ok: true };
1215
1628
  } catch (error) {
1216
- console.error(`${this.prefix} SupabaseErrorAdapter:`, error);
1217
- return this.fail(describeError(error));
1629
+ return this.fail(error, describeError(error));
1218
1630
  }
1219
1631
  }
1220
- fail(reason) {
1221
- return {
1222
- ok: false,
1223
- error: `SupabaseErrorAdapter failed to insert into table "${this.tableName}": ${reason}`
1224
- };
1632
+ fail(logged, reason) {
1633
+ return reportFailure({
1634
+ adapterName: "SupabaseErrorAdapter",
1635
+ prefix: this.prefix,
1636
+ action: `to insert into table "${this.tableName}"`,
1637
+ error: logged,
1638
+ reason
1639
+ });
1225
1640
  }
1226
1641
  };
1227
1642
  // Annotate the CommonJS export names for ESM import in node:
@@ -1244,6 +1659,7 @@ var SupabaseErrorAdapter = class {
1244
1659
  WebhookErrorAdapter,
1245
1660
  buildCreateErrorLogTableSql,
1246
1661
  buildErrorLogTableDdl,
1662
+ buildMySqlWidenColumnsSql,
1247
1663
  buildSupabaseSetupSql,
1248
1664
  createErrorLogStore,
1249
1665
  formatErrorLogLine,