@richardmcquiston01/error-log-adapter 0.3.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
@@ -22,19 +22,24 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CompositeErrorAdapter: () => CompositeErrorAdapter,
24
24
  ConsoleErrorAdapter: () => ConsoleErrorAdapter,
25
+ FirestoreErrorAdapter: () => FirestoreErrorAdapter,
25
26
  FlatFileErrorAdapter: () => FlatFileErrorAdapter,
26
27
  IndexedDbErrorAdapter: () => IndexedDbErrorAdapter,
27
28
  JsonFileErrorAdapter: () => JsonFileErrorAdapter,
28
29
  MongoDbErrorAdapter: () => MongoDbErrorAdapter,
29
30
  MySqlErrorAdapter: () => MySqlErrorAdapter,
30
31
  PostgresErrorAdapter: () => PostgresErrorAdapter,
32
+ RealtimeDatabaseErrorAdapter: () => RealtimeDatabaseErrorAdapter,
31
33
  RedisErrorAdapter: () => RedisErrorAdapter,
32
34
  S3ErrorAdapter: () => S3ErrorAdapter,
33
35
  SqliteErrorAdapter: () => SqliteErrorAdapter,
36
+ SupabaseErrorAdapter: () => SupabaseErrorAdapter,
34
37
  WebStorageErrorAdapter: () => WebStorageErrorAdapter,
35
38
  WebhookErrorAdapter: () => WebhookErrorAdapter,
36
39
  buildCreateErrorLogTableSql: () => buildCreateErrorLogTableSql,
37
40
  buildErrorLogTableDdl: () => buildErrorLogTableDdl,
41
+ buildMySqlWidenColumnsSql: () => buildMySqlWidenColumnsSql,
42
+ buildSupabaseSetupSql: () => buildSupabaseSetupSql,
38
43
  createErrorLogStore: () => createErrorLogStore,
39
44
  formatErrorLogLine: () => formatErrorLogLine,
40
45
  serializeCause: () => serializeCause,
@@ -43,11 +48,100 @@ __export(index_exports, {
43
48
  module.exports = __toCommonJS(index_exports);
44
49
 
45
50
  // src/serialize.ts
46
- function serializeCause(cause) {
47
- if (cause instanceof Error) {
48
- 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;
49
55
  }
50
- return cause;
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);
141
+ }
142
+ }
143
+ function serializeCause(cause) {
144
+ return toJsonSafe(cause, 0, /* @__PURE__ */ new WeakSet());
51
145
  }
52
146
  function serializeEntry(entry) {
53
147
  const serialized = {
@@ -67,10 +161,20 @@ function describeError(error) {
67
161
  return error instanceof Error ? error.message : String(error);
68
162
  }
69
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
+
70
174
  // src/adapters/console-error-adapter.ts
71
175
  var ConsoleErrorAdapter = class {
72
176
  constructor(options = {}) {
73
- this.prefix = options.prefix ?? "[error-log-adapter]";
177
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
74
178
  }
75
179
  async log(entry) {
76
180
  try {
@@ -85,14 +189,135 @@ var ConsoleErrorAdapter = class {
85
189
  }
86
190
  };
87
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
+
88
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
+ }
89
305
  var WebhookErrorAdapter = class {
306
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
90
307
  constructor(url, options = {}) {
91
308
  this.url = url;
92
309
  this.options = options;
93
- 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;
94
313
  }
95
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);
96
321
  try {
97
322
  const response = await fetch(this.url, {
98
323
  method: "POST",
@@ -100,58 +325,112 @@ var WebhookErrorAdapter = class {
100
325
  "Content-Type": "application/json",
101
326
  ...this.options.headers
102
327
  },
103
- 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
104
336
  });
337
+ if (response.type === "opaqueredirect") {
338
+ return this.fail(this.redirectMessage("a redirect"));
339
+ }
105
340
  if (!response.ok) {
341
+ if (response.status >= 300 && response.status < 400) {
342
+ return this.fail(this.redirectMessage(`HTTP ${response.status}`));
343
+ }
106
344
  return this.fail(
107
- `webhook ${this.url} responded with HTTP ${response.status}`
345
+ `webhook ${this.target} responded with HTTP ${response.status}`
108
346
  );
109
347
  }
110
348
  return { ok: true };
111
349
  } catch (error) {
112
350
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
351
+ if (timedOut) {
352
+ return this.fail(
353
+ timeoutMessage(`webhook ${this.target}`, this.timeoutMs)
354
+ );
355
+ }
113
356
  return this.fail(
114
- `could not reach webhook ${this.url}: ${describeError(error)}`
357
+ `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`
115
358
  );
359
+ } finally {
360
+ clearTimeout(timer);
116
361
  }
117
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
+ }
118
370
  fail(reason) {
119
371
  return { ok: false, error: `WebhookErrorAdapter failed: ${reason}` };
120
372
  }
121
373
  };
122
374
 
123
375
  // src/adapters/mongodb-error-adapter.ts
376
+ function isDuplicateKeyError(error) {
377
+ return typeof error === "object" && error !== null && error.code === 11e3;
378
+ }
124
379
  var MongoDbErrorAdapter = class {
380
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
125
381
  constructor(collection, options = {}) {
126
382
  this.collection = collection;
127
- this.prefix = options.prefix ?? "[error-log-adapter]";
383
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
384
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "MongoDbErrorAdapter");
128
385
  }
129
386
  async log(entry) {
130
387
  try {
131
- 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
+ );
132
394
  return { ok: true };
133
395
  } catch (error) {
134
- console.error(`${this.prefix} MongoDbErrorAdapter:`, error);
135
- return {
136
- ok: false,
137
- error: `MongoDbErrorAdapter failed to insert entry: ${describeError(error)}`
138
- };
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
+ });
139
405
  }
140
406
  }
141
407
  };
142
408
 
143
409
  // src/adapters/composite-error-adapter.ts
410
+ var DEFAULT_COMPOSITE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 3;
144
411
  var CompositeErrorAdapter = class {
145
- constructor(adapters) {
412
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
413
+ constructor(adapters, options = {}) {
146
414
  this.adapters = adapters;
415
+ this.timeoutMs = resolveTimeoutMs(
416
+ options.timeoutMs ?? DEFAULT_COMPOSITE_TIMEOUT_MS,
417
+ "CompositeErrorAdapter"
418
+ );
147
419
  }
148
420
  async log(entry) {
149
421
  const results = await Promise.all(
150
422
  this.adapters.map(
151
- async (adapter) => {
423
+ async (adapter, index) => {
152
424
  try {
153
- 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
+ );
154
430
  } catch (error) {
431
+ if (error instanceof SinkTimeoutError) {
432
+ return { ok: false, error: error.message };
433
+ }
155
434
  return {
156
435
  ok: false,
157
436
  error: `adapter threw instead of returning a result: ${String(error)}`
@@ -182,59 +461,102 @@ function createErrorLogStore(database, storeName = DEFAULT_STORE_NAME) {
182
461
  database.createObjectStore(storeName, { keyPath: "id" }).createIndex("timestamp", "timestamp");
183
462
  }
184
463
  var IndexedDbErrorAdapter = class {
464
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or Infinity. */
185
465
  constructor(database, options = {}) {
186
466
  this.database = database;
187
467
  this.storeName = options.storeName ?? DEFAULT_STORE_NAME;
188
- this.prefix = options.prefix ?? "[error-log-adapter]";
468
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
469
+ this.timeoutMs = resolveTimeoutMs(
470
+ options.timeoutMs,
471
+ "IndexedDbErrorAdapter"
472
+ );
189
473
  }
190
474
  async log(entry) {
191
475
  try {
192
476
  const record = {
193
- id: crypto.randomUUID(),
477
+ id: resolveEntryId(entry),
194
478
  ...serializeEntry(entry)
195
479
  };
196
- await this.write(record);
480
+ await this.write(record, hasCallerId(entry));
197
481
  return { ok: true };
198
482
  } catch (error) {
199
- console.error(`${this.prefix} IndexedDbErrorAdapter:`, error);
200
- return {
201
- ok: false,
202
- error: `IndexedDbErrorAdapter failed to write to object store "${this.storeName}": ${describeError(error)}`
203
- };
483
+ return reportFailure({
484
+ adapterName: "IndexedDbErrorAdapter",
485
+ prefix: this.prefix,
486
+ action: `to write to object store "${this.storeName}"`,
487
+ error
488
+ });
204
489
  }
205
490
  }
206
- /** Resolves once the transaction commits; rejects if it errors or aborts. */
207
- write(record) {
208
- return new Promise(
209
- (resolve, reject) => {
210
- const transaction = this.database.transaction(
211
- this.storeName,
212
- "readwrite"
213
- );
214
- const fail = (action) => () => reject(
215
- new Error(
216
- `transaction ${action}: ${transaction.error?.message ?? "no details provided"}`
217
- )
218
- );
219
- transaction.oncomplete = () => resolve();
220
- transaction.onerror = fail("failed");
221
- transaction.onabort = fail("aborted");
222
- transaction.objectStore(this.storeName).add(record);
223
- }
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")
224
529
  );
225
530
  }
226
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
+ }
227
539
 
228
- // src/adapters/sqlite-error-adapter.ts
540
+ // src/sql-ident.ts
229
541
  var DEFAULT_TABLE_NAME = "error_log";
230
542
  var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
231
- function assertValidTableName(tableName) {
232
- if (!IDENTIFIER_PATTERN.test(tableName)) {
543
+ function assertIdentifier(value, label, owner, maxLength) {
544
+ if (!IDENTIFIER_PATTERN.test(value)) {
233
545
  throw new RangeError(
234
- `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.`
547
+ );
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}.`
235
552
  );
236
553
  }
237
554
  }
555
+
556
+ // src/adapters/sqlite-error-adapter.ts
557
+ function assertValidTableName(tableName) {
558
+ assertIdentifier(tableName, "table name", "SqliteErrorAdapter");
559
+ }
238
560
  function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
239
561
  assertValidTableName(tableName);
240
562
  return [
@@ -243,23 +565,24 @@ function buildCreateErrorLogTableSql(tableName = DEFAULT_TABLE_NAME) {
243
565
  ];
244
566
  }
245
567
  var SqliteErrorAdapter = class {
246
- /** @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. */
247
569
  constructor(client, options = {}) {
248
570
  this.client = client;
249
571
  this.tableReady = null;
250
572
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
251
573
  assertValidTableName(this.tableName);
252
574
  this.createTable = options.createTable ?? true;
253
- this.prefix = options.prefix ?? "[error-log-adapter]";
575
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
576
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "SqliteErrorAdapter");
254
577
  }
255
578
  async log(entry) {
256
579
  try {
257
580
  await this.ensureTable();
258
581
  const serialized = serializeEntry(entry);
259
- await this.client.run(
260
- `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`,
261
584
  [
262
- crypto.randomUUID(),
585
+ resolveEntryId(entry),
263
586
  serialized.code,
264
587
  serialized.message,
265
588
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -269,11 +592,12 @@ var SqliteErrorAdapter = class {
269
592
  );
270
593
  return { ok: true };
271
594
  } catch (error) {
272
- console.error(`${this.prefix} SqliteErrorAdapter:`, error);
273
- return {
274
- ok: false,
275
- error: `SqliteErrorAdapter failed to write to table "${this.tableName}": ${describeError(error)}`
276
- };
595
+ return reportFailure({
596
+ adapterName: "SqliteErrorAdapter",
597
+ prefix: this.prefix,
598
+ action: `to write to table "${this.tableName}"`,
599
+ error
600
+ });
277
601
  }
278
602
  }
279
603
  /** Creates the table once. A failed attempt is retried on the next log call. */
@@ -293,9 +617,17 @@ var SqliteErrorAdapter = class {
293
617
  }
294
618
  async runCreateStatements() {
295
619
  for (const sql of buildCreateErrorLogTableSql(this.tableName)) {
296
- await this.client.run(sql, []);
620
+ await this.run(sql, []);
297
621
  }
298
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
+ }
299
631
  };
300
632
 
301
633
  // src/adapters/web-storage-error-adapter.ts
@@ -314,7 +646,8 @@ var WebStorageErrorAdapter = class {
314
646
  this.storage = storage;
315
647
  this.key = options.key ?? DEFAULT_STORAGE_KEY;
316
648
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
317
- this.prefix = options.prefix ?? "[error-log-adapter]";
649
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
650
+ this.locks = options.locks === void 0 ? defaultLockManager() : options.locks;
318
651
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
319
652
  throw new RangeError(
320
653
  `WebStorageErrorAdapter: maxEntries must be a positive integer, received ${String(options.maxEntries)}.`
@@ -324,23 +657,49 @@ var WebStorageErrorAdapter = class {
324
657
  async log(entry) {
325
658
  try {
326
659
  const record = {
327
- id: crypto.randomUUID(),
660
+ id: resolveEntryId(entry),
328
661
  ...serializeEntry(entry)
329
662
  };
330
- this.append(record);
663
+ await this.appendLocked(record);
331
664
  return { ok: true };
332
665
  } catch (error) {
333
- console.error(`${this.prefix} WebStorageErrorAdapter:`, error);
334
- return {
335
- ok: false,
336
- error: `WebStorageErrorAdapter failed to write to storage key "${this.key}": ${describeError(error)}`
337
- };
666
+ return reportFailure({
667
+ adapterName: "WebStorageErrorAdapter",
668
+ prefix: this.prefix,
669
+ action: `to write to storage key "${this.key}"`,
670
+ error
671
+ });
338
672
  }
339
673
  }
340
- /** Reads, appends, trims, and writes back. Runs without awaiting, so calls never interleave. */
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);
695
+ }
696
+ }
697
+ /** Reads, appends, trims, and writes back. Runs without awaiting, so calls in one tab never interleave. */
341
698
  append(record) {
342
699
  const records = [
343
- ...this.readRecords(),
700
+ ...this.readRecords().filter(
701
+ (existing) => existing?.id !== record.id
702
+ ),
344
703
  record
345
704
  ].slice(-this.maxEntries);
346
705
  for (; ; ) {
@@ -366,16 +725,51 @@ var WebStorageErrorAdapter = class {
366
725
  if (Array.isArray(parsed)) {
367
726
  return parsed;
368
727
  }
369
- console.error(
370
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" did not hold an array, so it was replaced.`
371
- );
728
+ this.setAside(stored, "did not hold an array");
372
729
  } catch (error) {
373
- console.error(
374
- `${this.prefix} WebStorageErrorAdapter: storage key "${this.key}" held invalid JSON (${describeError(error)}), so it was replaced.`
375
- );
730
+ this.setAside(stored, `held invalid JSON (${describeError(error)})`);
376
731
  }
377
732
  return [];
378
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
+ }
379
773
  };
380
774
 
381
775
  // src/file-system.ts
@@ -383,6 +777,7 @@ async function loadNodeFileSystem() {
383
777
  const moduleName = "node:fs/promises";
384
778
  return await import(moduleName);
385
779
  }
780
+ var ROOT_PATTERN = /^(?:[A-Za-z]:)?[\\/]+$/;
386
781
  function parentDirectory(filePath) {
387
782
  const lastSeparator = Math.max(
388
783
  filePath.lastIndexOf("/"),
@@ -391,7 +786,11 @@ function parentDirectory(filePath) {
391
786
  if (lastSeparator < 0) {
392
787
  return null;
393
788
  }
394
- 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);
395
794
  }
396
795
  function isMissingFileError(error) {
397
796
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -437,24 +836,20 @@ var FlatFileErrorAdapter = class {
437
836
  constructor(filePath, options = {}) {
438
837
  this.filePath = filePath;
439
838
  this.directoryReady = false;
440
- this.queue = Promise.resolve();
839
+ this.queue = new SerialQueue();
441
840
  this.fs = options.fs;
442
841
  this.formatLine = options.formatLine ?? formatErrorLogLine;
443
842
  this.maxBytes = options.maxBytes;
444
843
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
445
844
  this.createDirectory = options.createDirectory ?? true;
446
- this.prefix = options.prefix ?? "[error-log-adapter]";
845
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
447
846
  if (this.maxBytes !== void 0) {
448
847
  assertPositiveInteger(this.maxBytes, "maxBytes");
449
848
  }
450
849
  assertPositiveInteger(this.maxFiles, "maxFiles");
451
850
  }
452
851
  log(entry) {
453
- const result = this.queue.then(
454
- () => this.write(entry)
455
- );
456
- this.queue = result.catch(() => void 0);
457
- return result;
852
+ return this.queue.run(() => this.write(entry));
458
853
  }
459
854
  async write(entry) {
460
855
  try {
@@ -467,11 +862,12 @@ var FlatFileErrorAdapter = class {
467
862
  await fs.appendFile(this.filePath, line);
468
863
  return { ok: true };
469
864
  } catch (error) {
470
- console.error(`${this.prefix} FlatFileErrorAdapter:`, error);
471
- return {
472
- ok: false,
473
- error: `FlatFileErrorAdapter failed to append to "${this.filePath}": ${describeError(error)}`
474
- };
865
+ return reportFailure({
866
+ adapterName: "FlatFileErrorAdapter",
867
+ prefix: this.prefix,
868
+ action: `to append to "${this.filePath}"`,
869
+ error
870
+ });
475
871
  }
476
872
  }
477
873
  /** Uses the injected file system, or loads `node:fs/promises` on first use. */
@@ -546,13 +942,13 @@ var JsonFileErrorAdapter = class {
546
942
  constructor(filePath, options = {}) {
547
943
  this.filePath = filePath;
548
944
  this.directoryReady = false;
549
- this.queue = Promise.resolve();
945
+ this.queue = new SerialQueue();
550
946
  this.mode = options.mode ?? "ndjson";
551
947
  this.maxEntries = options.maxEntries;
552
948
  this.indent = options.indent ?? DEFAULT_INDENT;
553
949
  this.fs = options.fs;
554
950
  this.createDirectory = options.createDirectory ?? true;
555
- this.prefix = options.prefix ?? "[error-log-adapter]";
951
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
556
952
  if (this.maxEntries !== void 0) {
557
953
  if (this.mode !== "array") {
558
954
  throw new RangeError(
@@ -572,16 +968,12 @@ var JsonFileErrorAdapter = class {
572
968
  }
573
969
  }
574
970
  log(entry) {
575
- const result = this.queue.then(
576
- () => this.write(entry)
577
- );
578
- this.queue = result.catch(() => void 0);
579
- return result;
971
+ return this.queue.run(() => this.write(entry));
580
972
  }
581
973
  async write(entry) {
582
974
  try {
583
975
  const record = {
584
- id: crypto.randomUUID(),
976
+ id: resolveEntryId(entry),
585
977
  ...serializeEntry(entry)
586
978
  };
587
979
  const fs = await this.getFileSystem();
@@ -594,11 +986,12 @@ var JsonFileErrorAdapter = class {
594
986
  }
595
987
  return { ok: true };
596
988
  } catch (error) {
597
- console.error(`${this.prefix} JsonFileErrorAdapter:`, error);
598
- return {
599
- ok: false,
600
- error: `JsonFileErrorAdapter failed to write "${this.filePath}" in ${this.mode} mode: ${describeError(error)}`
601
- };
989
+ return reportFailure({
990
+ adapterName: "JsonFileErrorAdapter",
991
+ prefix: this.prefix,
992
+ action: `to write "${this.filePath}" in ${this.mode} mode`,
993
+ error
994
+ });
602
995
  }
603
996
  }
604
997
  async getFileSystem() {
@@ -624,7 +1017,9 @@ var JsonFileErrorAdapter = class {
624
1017
  */
625
1018
  async rewriteArray(fs, record) {
626
1019
  const records = [
627
- ...await this.readRecords(fs),
1020
+ ...(await this.readRecords(fs)).filter(
1021
+ (existing) => existing?.id !== record.id
1022
+ ),
628
1023
  record
629
1024
  ];
630
1025
  const kept = this.maxEntries === void 0 ? records : records.slice(-this.maxEntries);
@@ -674,25 +1069,14 @@ var JsonFileErrorAdapter = class {
674
1069
  };
675
1070
 
676
1071
  // src/adapters/sql-error-adapter.ts
677
- var DEFAULT_TABLE_NAME2 = "error_log";
678
1072
  var MAX_TABLE_NAME_LENGTH = 47;
679
- var IDENTIFIER_PATTERN2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
680
1073
  function assertValidTableName2(tableName, adapterName) {
681
- if (!IDENTIFIER_PATTERN2.test(tableName)) {
682
- throw new RangeError(
683
- `${adapterName}: invalid table name "${tableName}". Use only letters, digits and underscores, and do not start with a digit.`
684
- );
685
- }
686
- if (tableName.length > MAX_TABLE_NAME_LENGTH) {
687
- throw new RangeError(
688
- `${adapterName}: table name "${tableName}" is ${tableName.length} characters long, but the limit is ${MAX_TABLE_NAME_LENGTH}.`
689
- );
690
- }
1074
+ assertIdentifier(tableName, "table name", adapterName, MAX_TABLE_NAME_LENGTH);
691
1075
  }
692
1076
  function quoteIdentifier(dialect, identifier) {
693
1077
  return dialect === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
694
1078
  }
695
- function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
1079
+ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME) {
696
1080
  assertValidTableName2(tableName, "buildErrorLogTableDdl");
697
1081
  const table = quoteIdentifier(dialect, tableName);
698
1082
  const index = quoteIdentifier(
@@ -706,7 +1090,13 @@ function buildErrorLogTableDdl(dialect, tableName = DEFAULT_TABLE_NAME2) {
706
1090
  ];
707
1091
  }
708
1092
  return [
709
- `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`
710
1100
  ];
711
1101
  }
712
1102
  function toMysqlDateTime(date) {
@@ -718,10 +1108,11 @@ var SqlErrorAdapter = class {
718
1108
  this.dialect = dialect;
719
1109
  this.adapterName = adapterName;
720
1110
  this.tableReady = null;
721
- this.tableName = options.tableName ?? DEFAULT_TABLE_NAME2;
1111
+ this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
722
1112
  assertValidTableName2(this.tableName, adapterName);
723
1113
  this.createTable = options.createTable ?? true;
724
- this.prefix = options.prefix ?? "[error-log-adapter]";
1114
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1115
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, adapterName);
725
1116
  }
726
1117
  async log(entry) {
727
1118
  try {
@@ -733,7 +1124,7 @@ var SqlErrorAdapter = class {
733
1124
  );
734
1125
  }
735
1126
  const params = [
736
- crypto.randomUUID(),
1127
+ resolveEntryId(entry),
737
1128
  serialized.code,
738
1129
  serialized.message,
739
1130
  serialized.cause === void 0 ? null : JSON.stringify(serialized.cause),
@@ -741,19 +1132,21 @@ var SqlErrorAdapter = class {
741
1132
  this.dialect === "postgres" ? occurredAt.toISOString() : toMysqlDateTime(occurredAt)
742
1133
  ];
743
1134
  await this.ensureTable();
744
- await this.client.query(this.insertSql(), params);
1135
+ await this.query(this.insertSql(), params);
745
1136
  return { ok: true };
746
1137
  } catch (error) {
747
- console.error(`${this.prefix} ${this.adapterName}:`, error);
748
- return {
749
- ok: false,
750
- error: `${this.adapterName} failed to write to table "${this.tableName}": ${describeError(error)}`
751
- };
1138
+ return reportFailure({
1139
+ adapterName: this.adapterName,
1140
+ prefix: this.prefix,
1141
+ action: `to write to table "${this.tableName}"`,
1142
+ error
1143
+ });
752
1144
  }
753
1145
  }
754
1146
  insertSql() {
755
1147
  const placeholders = this.dialect === "postgres" ? "$1, $2, $3, $4, $5, $6" : "?, ?, ?, ?, ?, ?";
756
- 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}`;
757
1150
  }
758
1151
  /** Creates the table once. A failed attempt is retried on the next log call. */
759
1152
  ensureTable() {
@@ -772,9 +1165,20 @@ var SqlErrorAdapter = class {
772
1165
  }
773
1166
  async runCreateStatements() {
774
1167
  for (const sql of buildErrorLogTableDdl(this.dialect, this.tableName)) {
775
- await this.client.query(sql, []);
1168
+ await this.query(sql, []);
776
1169
  }
777
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
+ }
778
1182
  };
779
1183
  var PostgresErrorAdapter = class extends SqlErrorAdapter {
780
1184
  /** @throws {RangeError} if `options.tableName` is not a plain identifier of 47 characters or fewer. */
@@ -799,7 +1203,8 @@ var RedisErrorAdapter = class {
799
1203
  this.key = options.key ?? DEFAULT_REDIS_KEY;
800
1204
  this.maxEntries = options.maxEntries;
801
1205
  this.side = options.side ?? "right";
802
- this.prefix = options.prefix ?? "[error-log-adapter]";
1206
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1207
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "RedisErrorAdapter");
803
1208
  if (this.mode !== "list" && this.mode !== "stream") {
804
1209
  throw new RangeError(
805
1210
  `RedisErrorAdapter: mode must be "list" or "stream", received "${String(options.mode)}".`
@@ -821,11 +1226,11 @@ var RedisErrorAdapter = class {
821
1226
  }
822
1227
  async log(entry) {
823
1228
  try {
824
- const record = { id: crypto.randomUUID(), ...serializeEntry(entry) };
1229
+ const record = { id: resolveEntryId(entry), ...serializeEntry(entry) };
825
1230
  if (this.mode === "stream") {
826
- await this.client.sendCommand(this.buildStreamCommand(record));
1231
+ await this.send(this.buildStreamCommand(record));
827
1232
  } else {
828
- await this.client.sendCommand([
1233
+ await this.send([
829
1234
  this.side === "left" ? "LPUSH" : "RPUSH",
830
1235
  this.key,
831
1236
  JSON.stringify(record)
@@ -834,11 +1239,12 @@ var RedisErrorAdapter = class {
834
1239
  }
835
1240
  return { ok: true };
836
1241
  } catch (error) {
837
- console.error(`${this.prefix} RedisErrorAdapter:`, error);
838
- return {
839
- ok: false,
840
- error: `RedisErrorAdapter failed to write to ${this.mode} "${this.key}": ${describeError(error)}`
841
- };
1242
+ return reportFailure({
1243
+ adapterName: "RedisErrorAdapter",
1244
+ prefix: this.prefix,
1245
+ action: `to write to ${this.mode} "${this.key}"`,
1246
+ error
1247
+ });
842
1248
  }
843
1249
  }
844
1250
  buildStreamCommand(record) {
@@ -870,6 +1276,14 @@ var RedisErrorAdapter = class {
870
1276
  * stored by now, so a trim failure is reported but does not fail the call;
871
1277
  * the next successful write trims the list again.
872
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
+ }
873
1287
  async trimList() {
874
1288
  if (this.maxEntries === void 0) {
875
1289
  return;
@@ -877,7 +1291,7 @@ var RedisErrorAdapter = class {
877
1291
  const keep = this.maxEntries;
878
1292
  const [start, stop] = this.side === "right" ? [String(-keep), "-1"] : ["0", String(keep - 1)];
879
1293
  try {
880
- await this.client.sendCommand(["LTRIM", this.key, start, stop]);
1294
+ await this.send(["LTRIM", this.key, start, stop]);
881
1295
  } catch (error) {
882
1296
  console.error(
883
1297
  `${this.prefix} RedisErrorAdapter: stored the entry but could not trim list "${this.key}" to ${keep} entries: ${describeError(error)}`
@@ -904,11 +1318,12 @@ var S3ErrorAdapter = class {
904
1318
  this.bucket = bucket;
905
1319
  this.buffer = [];
906
1320
  this.timer = null;
907
- this.flushQueue = Promise.resolve();
1321
+ this.flushQueue = new SerialQueue();
908
1322
  this.keyPrefix = normalizePrefix(options.keyPrefix ?? DEFAULT_S3_PREFIX);
909
1323
  this.batch = options.batch;
910
1324
  this.contentType = options.contentType ?? (this.batch === void 0 ? "application/json" : "application/x-ndjson");
911
- this.logPrefix = options.prefix ?? "[error-log-adapter]";
1325
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
1326
+ this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "S3ErrorAdapter");
912
1327
  if (bucket === "") {
913
1328
  throw new RangeError("S3ErrorAdapter: bucket must not be empty.");
914
1329
  }
@@ -928,7 +1343,7 @@ var S3ErrorAdapter = class {
928
1343
  async log(entry) {
929
1344
  try {
930
1345
  const record = {
931
- id: crypto.randomUUID(),
1346
+ id: resolveEntryId(entry),
932
1347
  ...serializeEntry(entry)
933
1348
  };
934
1349
  const occurredAt = new Date(record.timestamp);
@@ -953,11 +1368,9 @@ var S3ErrorAdapter = class {
953
1368
  * Entries from a failed upload stay buffered and are retried by the next flush.
954
1369
  */
955
1370
  flush() {
956
- const result = this.flushQueue.then(
1371
+ return this.flushQueue.run(
957
1372
  () => this.uploadBuffered()
958
1373
  );
959
- this.flushQueue = result.catch(() => void 0);
960
- return result;
961
1374
  }
962
1375
  async addToBatch(record, batch) {
963
1376
  this.buffer.push(record);
@@ -996,7 +1409,7 @@ var S3ErrorAdapter = class {
996
1409
  return { ok: true };
997
1410
  }
998
1411
  try {
999
- const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${crypto.randomUUID()}.ndjson`;
1412
+ const key = `${this.keyPrefix}${datePath(/* @__PURE__ */ new Date())}-${generateId()}.ndjson`;
1000
1413
  const body = pending.map((record) => JSON.stringify(record)).join("\n");
1001
1414
  await this.upload(key, `${body}
1002
1415
  `);
@@ -1013,39 +1426,241 @@ var S3ErrorAdapter = class {
1013
1426
  }
1014
1427
  }
1015
1428
  upload(key, body) {
1016
- return this.client.putObject({
1017
- Bucket: this.bucket,
1018
- Key: key,
1019
- Body: body,
1020
- ContentType: this.contentType
1021
- });
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
+ );
1022
1439
  }
1023
1440
  fail(error, bufferedCount) {
1024
- console.error(`${this.logPrefix} S3ErrorAdapter:`, error);
1025
1441
  const retry = bufferedCount === void 0 ? "" : ` (${bufferedCount} buffered ${bufferedCount === 1 ? "entry was" : "entries were"} kept for the next flush)`;
1026
- return {
1027
- ok: false,
1028
- error: `S3ErrorAdapter failed to upload to bucket "${this.bucket}": ${describeError(error)}${retry}`
1029
- };
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
+ });
1449
+ }
1450
+ };
1451
+
1452
+ // src/adapters/firebase-error-adapter.ts
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)`
1458
+ );
1459
+ }
1460
+ function buildRecord(entry) {
1461
+ const serialized = serializeEntry(entry);
1462
+ const record = {
1463
+ id: resolveEntryId(entry),
1464
+ code: serialized.code,
1465
+ message: serialized.message,
1466
+ timestamp: serialized.timestamp
1467
+ };
1468
+ if (serialized.source !== void 0) {
1469
+ record.source = serialized.source;
1470
+ }
1471
+ if (serialized.cause !== void 0) {
1472
+ record.cause = JSON.stringify(serialized.cause);
1473
+ }
1474
+ return record;
1475
+ }
1476
+ var FirestoreErrorAdapter = class {
1477
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1478
+ constructor(collection, options = {}) {
1479
+ this.collection = collection;
1480
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1481
+ this.timeoutMs = resolveTimeoutMs(
1482
+ options.timeoutMs,
1483
+ "FirestoreErrorAdapter"
1484
+ );
1485
+ }
1486
+ async log(entry) {
1487
+ try {
1488
+ const record = buildRecord(entry);
1489
+ await waitForWrite(
1490
+ () => this.collection.doc(record.id).set(record),
1491
+ this.timeoutMs
1492
+ );
1493
+ return { ok: true };
1494
+ } catch (error) {
1495
+ return reportFailure({
1496
+ adapterName: "FirestoreErrorAdapter",
1497
+ prefix: this.prefix,
1498
+ action: `to write the document`,
1499
+ error
1500
+ });
1501
+ }
1502
+ }
1503
+ };
1504
+ var RealtimeDatabaseErrorAdapter = class {
1505
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1506
+ constructor(reference, options = {}) {
1507
+ this.reference = reference;
1508
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1509
+ this.timeoutMs = resolveTimeoutMs(
1510
+ options.timeoutMs,
1511
+ "RealtimeDatabaseErrorAdapter"
1512
+ );
1513
+ }
1514
+ async log(entry) {
1515
+ try {
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);
1529
+ return { ok: true };
1530
+ } catch (error) {
1531
+ return reportFailure({
1532
+ adapterName: "RealtimeDatabaseErrorAdapter",
1533
+ prefix: this.prefix,
1534
+ action: `to write the entry`,
1535
+ error
1536
+ });
1537
+ }
1538
+ }
1539
+ };
1540
+
1541
+ // src/adapters/supabase-error-adapter.ts
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 = {}) {
1547
+ const insertRoles = options.insertRoles ?? [];
1548
+ for (const role of insertRoles) {
1549
+ assertIdentifier(role, "role name", "buildSupabaseSetupSql");
1550
+ if ((role.toLowerCase() === "anon" || role.toLowerCase() === "public") && options.allowPublicInsert !== true) {
1551
+ throw new RangeError(
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.`
1553
+ );
1554
+ }
1555
+ }
1556
+ const statements = [
1557
+ ...buildErrorLogTableDdl("postgres", tableName),
1558
+ `ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY`
1559
+ ];
1560
+ if (insertRoles.length > 0) {
1561
+ const policy = `${tableName}_insert_only`;
1562
+ statements.push(
1563
+ `DROP POLICY IF EXISTS "${policy}" ON "${tableName}"`,
1564
+ `CREATE POLICY "${policy}" ON "${tableName}" FOR INSERT TO ${insertRoles.map((role) => `"${role}"`).join(", ")} WITH CHECK (${INSERT_POLICY_CHECK})`
1565
+ );
1566
+ }
1567
+ return statements;
1568
+ }
1569
+ function describeSupabaseError(result) {
1570
+ const error = result.error ?? { message: "" };
1571
+ const parts = [];
1572
+ if (error.message) {
1573
+ parts.push(error.message);
1574
+ } else if (result.status) {
1575
+ parts.push(
1576
+ `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""} with no error message (a 404 usually means the table does not exist or is not exposed by the API)`
1577
+ );
1578
+ } else {
1579
+ parts.push("unknown error with no message");
1580
+ }
1581
+ if (error.code) {
1582
+ parts.push(`code ${error.code}`);
1583
+ }
1584
+ const detailsLine = (error.details ?? "").split("\n")[0].trim();
1585
+ if (detailsLine && detailsLine !== error.message) {
1586
+ parts.push(`details: ${detailsLine}`);
1587
+ }
1588
+ if (error.hint) {
1589
+ parts.push(`hint: ${error.hint}`);
1590
+ }
1591
+ return parts.join("; ");
1592
+ }
1593
+ var SupabaseErrorAdapter = class {
1594
+ /** @throws {RangeError} if `options.tableName` is not a plain identifier or `options.timeoutMs` is invalid. */
1595
+ constructor(client, options = {}) {
1596
+ this.client = client;
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
+ );
1604
+ }
1605
+ async log(entry) {
1606
+ try {
1607
+ const serialized = serializeEntry(entry);
1608
+ const row = {
1609
+ id: resolveEntryId(entry),
1610
+ code: serialized.code,
1611
+ message: serialized.message,
1612
+ cause: serialized.cause === void 0 ? null : serialized.cause,
1613
+ source: serialized.source ?? null,
1614
+ occurred_at: serialized.timestamp
1615
+ };
1616
+ const result = await withTimeout(
1617
+ () => this.client.from(this.tableName).insert(row),
1618
+ this.timeoutMs,
1619
+ timeoutMessage("Supabase", this.timeoutMs)
1620
+ );
1621
+ if (result.error) {
1622
+ if (hasCallerId(entry) && result.error.code === "23505") {
1623
+ return { ok: true };
1624
+ }
1625
+ return this.fail(result, describeSupabaseError(result));
1626
+ }
1627
+ return { ok: true };
1628
+ } catch (error) {
1629
+ return this.fail(error, describeError(error));
1630
+ }
1631
+ }
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
+ });
1030
1640
  }
1031
1641
  };
1032
1642
  // Annotate the CommonJS export names for ESM import in node:
1033
1643
  0 && (module.exports = {
1034
1644
  CompositeErrorAdapter,
1035
1645
  ConsoleErrorAdapter,
1646
+ FirestoreErrorAdapter,
1036
1647
  FlatFileErrorAdapter,
1037
1648
  IndexedDbErrorAdapter,
1038
1649
  JsonFileErrorAdapter,
1039
1650
  MongoDbErrorAdapter,
1040
1651
  MySqlErrorAdapter,
1041
1652
  PostgresErrorAdapter,
1653
+ RealtimeDatabaseErrorAdapter,
1042
1654
  RedisErrorAdapter,
1043
1655
  S3ErrorAdapter,
1044
1656
  SqliteErrorAdapter,
1657
+ SupabaseErrorAdapter,
1045
1658
  WebStorageErrorAdapter,
1046
1659
  WebhookErrorAdapter,
1047
1660
  buildCreateErrorLogTableSql,
1048
1661
  buildErrorLogTableDdl,
1662
+ buildMySqlWidenColumnsSql,
1663
+ buildSupabaseSetupSql,
1049
1664
  createErrorLogStore,
1050
1665
  formatErrorLogLine,
1051
1666
  serializeCause,