@evolu/common 8.15.0 → 8.16.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.
Files changed (39) hide show
  1. package/dist/src/Console.d.ts +24 -46
  2. package/dist/src/Console.d.ts.map +1 -1
  3. package/dist/src/Console.js +34 -29
  4. package/dist/src/Error.d.ts +10 -6
  5. package/dist/src/Error.d.ts.map +1 -1
  6. package/dist/src/Error.js +52 -20
  7. package/dist/src/Sqlite.d.ts.map +1 -1
  8. package/dist/src/Sqlite.js +16 -12
  9. package/dist/src/Task.d.ts +3 -1
  10. package/dist/src/Task.d.ts.map +1 -1
  11. package/dist/src/Task.js +15 -13
  12. package/dist/src/Worker.d.ts +3 -0
  13. package/dist/src/Worker.d.ts.map +1 -1
  14. package/dist/src/Worker.js +6 -1
  15. package/dist/src/local-first/Db.d.ts.map +1 -1
  16. package/dist/src/local-first/Db.js +7 -7
  17. package/dist/src/local-first/Evolu.d.ts +4 -0
  18. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  19. package/dist/src/local-first/Schema.d.ts +3 -0
  20. package/dist/src/local-first/Schema.d.ts.map +1 -1
  21. package/dist/src/local-first/Schema.js +9 -5
  22. package/dist/src/local-first/Shared.d.ts +55 -39
  23. package/dist/src/local-first/Shared.d.ts.map +1 -1
  24. package/dist/src/local-first/Shared.js +264 -320
  25. package/package.json +1 -1
  26. package/src/Console.test.ts +75 -247
  27. package/src/Console.ts +68 -110
  28. package/src/Error.test.ts +85 -0
  29. package/src/Error.ts +48 -22
  30. package/src/Sqlite.ts +19 -11
  31. package/src/Task.test.ts +46 -0
  32. package/src/Task.ts +17 -13
  33. package/src/Worker.ts +6 -1
  34. package/src/local-first/Db.ts +12 -8
  35. package/src/local-first/Evolu.ts +4 -0
  36. package/src/local-first/Schema.test.ts +80 -1
  37. package/src/local-first/Schema.ts +15 -5
  38. package/src/local-first/Shared.test.ts +414 -39
  39. package/src/local-first/Shared.ts +386 -440
package/src/Console.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { objectFrom } from "./Object.ts";
8
7
  import type { ReadonlyStore } from "./Store.ts";
9
8
  import { createStore } from "./Store.ts";
10
9
  import type { Task } from "./Task.ts";
@@ -54,9 +53,7 @@ import {
54
53
  * createConsole,
55
54
  * createConsoleArrayOutput,
56
55
  * Data,
57
- * type Console,
58
56
  * type ConsoleEntry,
59
- * type ConsoleLevel,
60
57
  * } from "@evolu/common";
61
58
  *
62
59
  * const entries: Array<ConsoleEntry> = [];
@@ -75,16 +72,11 @@ import {
75
72
  * assertType(Data, args);
76
73
  * assertEqual(args, ["Creating instance", { config: { port: 4000 } }]);
77
74
  *
78
- * const setLevelRecursive = (
79
- * console: Console,
80
- * level: ConsoleLevel,
81
- * ): void => {
82
- * console.setLevel(level);
83
- * for (const child of console.children) setLevelRecursive(child, level);
84
- * };
85
- * setLevelRecursive(root, "warn");
86
- * assertEqual(root.getLevel(), "warn");
87
- * assertEqual(relay.getLevel(), "warn");
75
+ * // Children without their own level follow their parent.
76
+ * const db = root.child("db");
77
+ * root.setLevel("warn");
78
+ * assertEqual(db.getLevel(), "warn");
79
+ * assertEqual(relay.getLevel(), "silent");
88
80
  * ```
89
81
  *
90
82
  * Console intentionally does not use {@link Task}. Logging must be as fast as
@@ -97,17 +89,12 @@ import {
97
89
  * @see {@link createConsole}
98
90
  */
99
91
  export interface Console {
100
- /** Name of this console. Empty for root. */
101
- readonly name: string;
102
-
103
- /** Child consoles created via {@link Console.child}. */
104
- readonly children: ReadonlySet<Console>;
105
-
106
92
  /**
107
93
  * Returns the effective log level.
108
94
  *
109
95
  * If this console has its own level set via {@link Console.setLevel}, returns
110
- * that. Otherwise returns the inherited level from creation time.
96
+ * that. Otherwise returns its parent's current level, or
97
+ * {@link ConsoleConfig.level} for a root console.
111
98
  */
112
99
  readonly getLevel: () => ConsoleLevel;
113
100
 
@@ -125,12 +112,12 @@ export interface Console {
125
112
  /**
126
113
  * Creates a child console with the given name added to the path.
127
114
  *
128
- * Child inherits the parent's configured level (not any runtime override).
129
- * Use {@link Console.children} to access all children for batch operations.
115
+ * A child without its own level follows its parent's level, including later
116
+ * changes.
130
117
  */
131
118
  readonly child: (name: string) => Console;
132
119
 
133
- /** Outputs a stack trace. */
120
+ /** Most detailed execution flow, such as every SQL query. */
134
121
  readonly trace: (...args: ReadonlyArray<unknown>) => void;
135
122
 
136
123
  /** Development diagnostics. */
@@ -148,31 +135,10 @@ export interface Console {
148
135
  /** Failures requiring immediate attention. */
149
136
  readonly error: (...args: ReadonlyArray<unknown>) => void;
150
137
 
151
- /** Displays an object with expandable properties. Level: debug. */
152
- readonly dir: (item: unknown) => void;
153
-
154
- /** Displays tabular data. Level: debug. */
155
- readonly table: (data: unknown) => void;
156
-
157
- /** Starts a timer with the given label. Level: debug. */
158
- readonly time: (label: string) => void;
159
-
160
- /** Logs elapsed time for a timer. Level: debug. */
161
- readonly timeLog: (label: string, ...args: ReadonlyArray<unknown>) => void;
162
-
163
- /** Ends a timer and logs elapsed time. Level: debug. */
164
- readonly timeEnd: (label: string) => void;
165
-
166
- /** Increments and logs a counter. Level: debug. */
167
- readonly count: (label?: string) => void;
168
-
169
- /** Resets a counter. Level: debug. */
170
- readonly countReset: (label?: string) => void;
171
-
172
138
  /**
173
- * Writes a pre-built {@link ConsoleEntry} directly to the output, bypassing
174
- * level filtering. Used to replay entries from another context (e.g., a
175
- * SharedWorker) where filtering was already applied.
139
+ * Writes a pre-built {@link ConsoleEntry} to the output if this console's
140
+ * level allows its method. Used to replay entries from another context, such
141
+ * as a worker, that filtered them by its own level.
176
142
  */
177
143
  readonly write: (entry: ConsoleEntry) => void;
178
144
  }
@@ -192,8 +158,8 @@ export interface ConsoleDep {
192
158
  * Setting a level enables all logs at that level and above (ordered by
193
159
  * severity):
194
160
  *
195
- * - `"trace"` — Stack traces and detailed execution flow
196
- * - `"debug"` — Development diagnostics, timers, counters
161
+ * - `"trace"` — Most detailed execution flow, such as every SQL query
162
+ * - `"debug"` — Development diagnostics
197
163
  * - `"log"` — General-purpose messages
198
164
  * - `"info"` — Operational milestones (startup, shutdown)
199
165
  * - `"warn"` — Recoverable issues that may need attention
@@ -233,19 +199,7 @@ export interface ConsoleEntry {
233
199
  * @group Core
234
200
  */
235
201
  export type ConsoleMethod =
236
- | "trace"
237
- | "debug"
238
- | "log"
239
- | "info"
240
- | "warn"
241
- | "error"
242
- | "dir"
243
- | "table"
244
- | "time"
245
- | "timeLog"
246
- | "timeEnd"
247
- | "count"
248
- | "countReset";
202
+ "trace" | "debug" | "log" | "info" | "warn" | "error";
249
203
 
250
204
  /**
251
205
  * Output destination for {@link Console}.
@@ -278,9 +232,6 @@ export type ConsoleFormatter = (entry: ConsoleEntry) => ReadonlyArray<unknown>;
278
232
  * @group Core
279
233
  */
280
234
  export interface ConsoleConfig {
281
- /** Name of this console. Defaults to empty string. */
282
- readonly name?: string;
283
-
284
235
  /** Initial log level. Defaults to `"log"`. */
285
236
  readonly level?: ConsoleLevel;
286
237
 
@@ -320,7 +271,8 @@ export interface ConsoleFormatterConfig {
320
271
  readonly timestampFormat?: ConsoleEntryTimestampFormat;
321
272
 
322
273
  /**
323
- * Start time for relative timestamps. Defaults to first entry timestamp.
274
+ * Start time for relative timestamps. Defaults to the time the first entry is
275
+ * formatted.
324
276
  *
325
277
  * Pass a {@link Millis} value to use a custom start time, useful when multiple
326
278
  * consoles should share the same relative timeline.
@@ -427,65 +379,66 @@ const levelOrder: Record<ConsoleLevel, number> = {
427
379
  * @group Core
428
380
  */
429
381
  export const createConsole = ({
430
- name = "",
431
382
  level = "log",
432
383
  output = createNativeConsoleOutput(),
433
384
  path = [],
434
385
  formatter,
435
- }: ConsoleConfig = {}): Console => {
436
- const childrenSet = new Set<Console>();
386
+ }: ConsoleConfig = {}): Console =>
387
+ createConsoleWithInheritedLevel({
388
+ getInheritedLevel: () => level,
389
+ output,
390
+ path,
391
+ formatter,
392
+ });
393
+
394
+ const createConsoleWithInheritedLevel = ({
395
+ getInheritedLevel,
396
+ output,
397
+ path,
398
+ formatter,
399
+ }: {
400
+ getInheritedLevel: () => ConsoleLevel;
401
+ output: ConsoleOutput;
402
+ path: ReadonlyArray<string>;
403
+ formatter: ConsoleFormatter | undefined;
404
+ }): Console => {
437
405
  let ownLevel: ConsoleLevel | null = null;
438
406
 
439
- const getLevel = (): ConsoleLevel => ownLevel ?? level;
407
+ const getLevel = (): ConsoleLevel => ownLevel ?? getInheritedLevel();
408
+
409
+ const isEnabled = (method: ConsoleMethod): boolean =>
410
+ levelOrder[method] >= levelOrder[getLevel()];
440
411
 
441
412
  const createMethod =
442
- (
443
- method: ConsoleMethod,
444
- methodLevel: ConsoleLevel,
445
- formatter?: ConsoleFormatter,
446
- ) =>
413
+ (method: ConsoleMethod) =>
447
414
  (...args: ReadonlyArray<unknown>): void => {
448
- if (levelOrder[methodLevel] >= levelOrder[getLevel()])
449
- output.write({ method, path, args }, formatter);
415
+ if (isEnabled(method)) output.write({ method, path, args }, formatter);
450
416
  };
451
417
 
452
- const levelMethod = (method: ConsoleLevel & ConsoleMethod) =>
453
- createMethod(method, method, formatter);
454
-
455
- const debugMethod = (method: ConsoleMethod) => createMethod(method, "debug");
456
-
457
418
  return {
458
- name,
459
- children: childrenSet,
460
419
  getLevel,
461
420
  setLevel: (level) => {
462
421
  ownLevel = level;
463
422
  },
464
423
  hasOwnLevel: () => ownLevel !== null,
465
424
 
466
- child: (name) => {
467
- const childConsole = createConsole({
468
- name,
469
- level,
425
+ child: (name) =>
426
+ createConsoleWithInheritedLevel({
427
+ getInheritedLevel: getLevel,
470
428
  output,
471
429
  path: [...path, name],
472
- ...(formatter && { formatter }),
473
- });
474
- childrenSet.add(childConsole);
475
- return childConsole;
476
- },
430
+ formatter,
431
+ }),
477
432
 
478
- ...objectFrom(
479
- ["trace", "debug", "log", "info", "warn", "error"],
480
- levelMethod,
481
- ),
482
- ...objectFrom(
483
- ["dir", "table", "time", "timeLog", "timeEnd", "count", "countReset"],
484
- debugMethod,
485
- ),
433
+ trace: createMethod("trace"),
434
+ debug: createMethod("debug"),
435
+ log: createMethod("log"),
436
+ info: createMethod("info"),
437
+ warn: createMethod("warn"),
438
+ error: createMethod("error"),
486
439
 
487
440
  write: (entry) => {
488
- output.write(entry, formatter);
441
+ if (isEnabled(entry.method)) output.write(entry, formatter);
489
442
  },
490
443
  };
491
444
  };
@@ -497,6 +450,9 @@ export const createConsole = ({
497
450
  * Use {@link createConsoleFormatter} with {@link ConsoleConfig.formatter} for
498
451
  * timestamps and path prefixes.
499
452
  *
453
+ * Trace entries are written with native `console.debug`, because native
454
+ * `console.trace` prints a stack trace with every call.
455
+ *
500
456
  * ### Example
501
457
  *
502
458
  * ```ts
@@ -517,11 +473,9 @@ export const createNativeConsoleOutput = (): ConsoleOutput => ({
517
473
  write: (entry, formatter) => {
518
474
  const args = formatter ? formatter(entry) : entry.args;
519
475
  // oxlint-disable-next-line evolu/no-unnecessary-global-this -- Write to the global object console even if a realm lexical binding shadows it.
520
- const nativeConsole = globalThis.console as unknown as Record<
521
- ConsoleMethod,
522
- (...args: Array<unknown>) => void
523
- >;
524
- nativeConsole[entry.method](...args);
476
+ globalThis.console[entry.method === "trace" ? "debug" : entry.method](
477
+ ...args,
478
+ );
525
479
  },
526
480
  });
527
481
 
@@ -592,7 +546,11 @@ export const createConsoleFormatter =
592
546
  timestamp = "";
593
547
  break;
594
548
  case "relative":
595
- timestamp = `+${formatMillisAsDuration((now - startTime) as Millis)}`;
549
+ // A clock set back, or a startTime ahead of now, must not print a
550
+ // negative duration.
551
+ timestamp = `+${formatMillisAsDuration(
552
+ Math.max(0, now - startTime) as Millis,
553
+ )}`;
596
554
  break;
597
555
  case "absolute":
598
556
  timestamp = formatMillisAsClockTime(now);
@@ -709,8 +667,8 @@ export const createMultiOutput = (
709
667
  /**
710
668
  * Creates a {@link TestConsole} that captures all output for testing.
711
669
  *
712
- * Unlike {@link createConsole}, this doesn't require dependencies and captures
713
- * entries in memory.
670
+ * Captures entries in memory and defaults to the `"trace"` level, so tests see
671
+ * every entry.
714
672
  *
715
673
  * ### Example
716
674
  *
package/src/Error.test.ts CHANGED
@@ -75,6 +75,91 @@ describe("createUnknownError", () => {
75
75
  assertFalse("nonClonable" in result.error);
76
76
  });
77
77
 
78
+ it("describes error properties that cannot be cloned", () => {
79
+ const error = new Error("Test error", {
80
+ cause: { attempt: 2, retry: () => undefined },
81
+ });
82
+ const result = createUnknownError(error);
83
+
84
+ assertType(Object, result.error);
85
+ assertEqual(result.error.cause, '{"attempt":2}');
86
+ assertEqual(structuredClone(result), result);
87
+ });
88
+
89
+ it("converts errors in an array that cannot be cloned", () => {
90
+ const error = new AggregateError(
91
+ [
92
+ new Error("first", { cause: { retry: () => undefined } }),
93
+ new Error("second"),
94
+ ],
95
+ "both",
96
+ );
97
+ const result = createUnknownError(error);
98
+
99
+ assertType(Object, result.error);
100
+ const errors = result.error.errors as ReadonlyArray<{
101
+ readonly message: string;
102
+ }>;
103
+ assertEqual(
104
+ errors.map(({ message }) => message),
105
+ ["first", "second"],
106
+ );
107
+ assertEqual(structuredClone(result), result);
108
+ });
109
+
110
+ it("keeps reference cycles through errors and arrays", () => {
111
+ const error = new Error("Test error") as Error & {
112
+ originalError?: unknown;
113
+ items?: ReadonlyArray<unknown>;
114
+ };
115
+ const items: Array<unknown> = [() => undefined];
116
+ items.push(items);
117
+ error.cause = error;
118
+ error.originalError = error;
119
+ error.items = items;
120
+
121
+ const result = structuredClone(createUnknownError(error));
122
+
123
+ assertType(Object, result.error);
124
+ assertSame(result.error.cause, result.error);
125
+ assertSame(result.error.originalError, result.error);
126
+ const convertedItems = result.error.items as ReadonlyArray<unknown>;
127
+ assertSame(convertedItems[0], "() => undefined");
128
+ assertSame(convertedItems[1], convertedItems);
129
+ });
130
+
131
+ it("converts the errors of an AggregateError that can be cloned", () => {
132
+ const inner = globalThis.Object.assign(new Error("connect failed"), {
133
+ code: "ECONNREFUSED",
134
+ });
135
+ const result = structuredClone(
136
+ createUnknownError(new AggregateError([inner], "all failed")),
137
+ );
138
+
139
+ assertType(Object, result.error);
140
+ assertEqual(result.error.errors, [
141
+ {
142
+ stack: inner.stack,
143
+ message: "connect failed",
144
+ code: "ECONNREFUSED",
145
+ name: "Error",
146
+ },
147
+ ]);
148
+ });
149
+
150
+ it("includes the inherited name and message of a DOMException", () => {
151
+ const result = createUnknownError(
152
+ new Error("write failed", {
153
+ cause: new DOMException("Quota exceeded", "QuotaExceededError"),
154
+ }),
155
+ );
156
+
157
+ assertType(Object, result.error);
158
+ assertType(Object, result.error.cause);
159
+ assertEqual(result.error.cause.name, "QuotaExceededError");
160
+ assertEqual(result.error.cause.message, "Quota exceeded");
161
+ });
162
+
78
163
  it("handles structured cloneable objects", () => {
79
164
  const error = { key: "value" };
80
165
  const result = createUnknownError(error);
package/src/Error.ts CHANGED
@@ -34,31 +34,57 @@ export interface UnknownError extends InferType<typeof UnknownError> {}
34
34
  * Creates an {@link UnknownError} from an unknown error.
35
35
  *
36
36
  * An `Error` becomes a plain object of its own properties, such as `message`
37
- * and `stack`. A `cause` that is an `Error` is converted the same way, a
38
- * function is dropped, and any other property is kept as it is. An `Error`'s
39
- * properties are not enumerable, so JSON and comparisons by content miss them,
40
- * and a structured clone creates a new `Error`. Any other value is
41
- * structured-cloned, or described by `String` when it cannot be, or as
42
- * `"[Unserializable Object]"` when even that throws.
37
+ * and `stack`, plus its `name`, `message`, and `stack` when they are inherited,
38
+ * as in a `DOMException`. A property that is an `Error`, such as `cause`, is
39
+ * converted the same way, an array, such as the `errors` of an
40
+ * `AggregateError`, is converted element by element, a function is dropped, and
41
+ * any other property is kept when it can be structured-cloned, or described as
42
+ * a string when it cannot. Reference cycles through errors and arrays stay
43
+ * cycles in the result. An `Error`'s properties are not enumerable, so JSON and
44
+ * comparisons by content miss them, and a structured clone creates a new
45
+ * `Error`. Any other value is structured-cloned, or described by `String` when
46
+ * it cannot be, or as `"[Unserializable Object]"` when even that throws.
43
47
  */
44
48
  export const createUnknownError = (error: unknown): UnknownError => {
45
- const convertError = (err: Error): Record<string, unknown> => {
46
- const result: Record<string, unknown> = Object.getOwnPropertyNames(
47
- err,
48
- ).reduce<Record<string, unknown>>((acc, key) => {
49
+ // An UnknownError is posted between workers, so its values must clone.
50
+ // Each error and array is converted once and registered before its values,
51
+ // so a reference cycle becomes a cycle in the result instead of endless
52
+ // recursion, and structured cloning preserves it.
53
+ const converted = new Map<object, unknown>();
54
+
55
+ const convertValue = (value: unknown): unknown => {
56
+ if (value instanceof Error) return convertError(value);
57
+ if (Array.isArray(value)) {
58
+ const known = converted.get(value);
59
+ if (known !== undefined) return known;
60
+ const result: Array<unknown> = [];
61
+ converted.set(value, result);
62
+ for (const element of value) result.push(convertValue(element));
63
+ return result;
64
+ }
65
+ try {
66
+ structuredClone(value);
67
+ return value;
68
+ } catch {
69
+ return safelyStringifyUnknownValue(value);
70
+ }
71
+ };
72
+
73
+ const convertError = (err: Error): unknown => {
74
+ const known = converted.get(err);
75
+ if (known !== undefined) return known;
76
+ const result: Record<string, unknown> = {};
77
+ converted.set(err, result);
78
+ for (const key of Object.getOwnPropertyNames(err)) {
49
79
  const value = (err as never)[key] as unknown;
50
- if (key === "cause" && value instanceof Error) {
51
- // Recursively process the `cause` property
52
- acc[key] = convertError(value);
53
- } else if (typeof value !== "function") {
54
- acc[key] = value;
55
- }
56
- return acc;
57
- }, {});
58
- // Firefox defines `stack` as a getter on Error.prototype, not as an own
59
- // property, so getOwnPropertyNames misses it. Explicitly include it.
60
- if (err.stack !== undefined && !("stack" in result)) {
61
- result.stack = err.stack;
80
+ if (typeof value !== "function") result[key] = convertValue(value);
81
+ }
82
+ // A DOMException keeps `name` and `message` on its prototype, and Firefox
83
+ // defines `stack` as a getter on Error.prototype, so getOwnPropertyNames
84
+ // misses them. Explicitly include them.
85
+ for (const key of ["name", "message", "stack"] as const) {
86
+ const value = err[key];
87
+ if (value !== undefined && !(key in result)) result[key] = value;
62
88
  }
63
89
  return result;
64
90
  };
package/src/Sqlite.ts CHANGED
@@ -14,6 +14,7 @@ import { createMutableRecord, objectToEntries } from "./Object.ts";
14
14
  import type { Result } from "./Result.ts";
15
15
  import { ok } from "./Result.ts";
16
16
  import { type Task, testCreateRun, type TestRunDep } from "./Task.ts";
17
+ import { performanceDurationBetween } from "./Time.ts";
17
18
  import {
18
19
  array,
19
20
  type ArrayType,
@@ -330,7 +331,7 @@ export const createSqlite =
330
331
  options?: SqliteDriverOptions,
331
332
  ): Task<Sqlite, never, CreateSqliteDriverDep> =>
332
333
  async (run) => {
333
- const { createSqliteDriver } = run.deps;
334
+ const { createSqliteDriver, time } = run.deps;
334
335
  const console = run.deps.console.child("sql");
335
336
  await using disposer = new AsyncDisposableStack();
336
337
 
@@ -343,16 +344,23 @@ export const createSqlite =
343
344
  disposable<Sqlite>(
344
345
  {
345
346
  exec: <R extends SqliteRow = SqliteRow>(query: SqliteQuery) => {
346
- console.debug({ query });
347
+ console.trace({ query });
347
348
 
348
- const label =
349
+ const start =
349
350
  query.options?.logQueryExecutionTime === true
350
- ? `SqliteQueryExecutionTime ${query.sql}`
351
+ ? time.performance.now()
351
352
  : null;
352
-
353
- if (label !== null) console.time(label);
354
353
  const result = driver.exec(query);
355
- if (label !== null) console.timeEnd(label);
354
+ if (start !== null) {
355
+ const duration = performanceDurationBetween(
356
+ start,
357
+ time.performance.now(),
358
+ );
359
+ console.log("[logQueryExecutionTime]", {
360
+ query,
361
+ duration: `${duration.toFixed(3)}ms`,
362
+ });
363
+ }
356
364
 
357
365
  if (query.options?.logExplainQueryPlan) {
358
366
  const result = driver.exec({
@@ -367,12 +375,12 @@ export const createSqlite =
367
375
  );
368
376
  }
369
377
 
370
- console.debug({ result });
378
+ console.trace({ result });
371
379
  return result as SqliteExecResult<R>;
372
380
  },
373
381
 
374
382
  transaction: ((callback: () => Result<unknown, unknown> | void) => {
375
- console.debug("begin");
383
+ console.trace("begin");
376
384
  driver.exec(sql`begin;`);
377
385
 
378
386
  // After errors such as SQLITE_FULL or SQLITE_IOERR, SQLite may roll
@@ -386,14 +394,14 @@ export const createSqlite =
386
394
  let shouldRollback = true;
387
395
  rollback.defer(() => {
388
396
  if (!shouldRollback) return;
389
- console.debug("rollback");
397
+ console.trace("rollback");
390
398
  driver.exec(sql`rollback;`);
391
399
  });
392
400
 
393
401
  const result = callback();
394
402
  if (result != null && !result.ok) return result;
395
403
 
396
- console.debug("commit");
404
+ console.trace("commit");
397
405
  driver.exec(sql`commit;`);
398
406
  shouldRollback = false;
399
407
 
package/src/Task.test.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./Assert.ts";
20
20
 
21
21
  import { emptyArray, type NonEmptyReadonlyArray } from "./Array.ts";
22
+ import { testCreateConsole } from "./Console.ts";
22
23
  import { emptyRecord } from "./Object.ts";
23
24
  import type { Int1To100OrPositiveInt } from "./Number.ts";
24
25
  import { none, some, type Option } from "./Option.ts";
@@ -1640,6 +1641,51 @@ describe("Run", () => {
1640
1641
  assertSame(secondRun.deps.console.getLevel(), secondRunLevel);
1641
1642
  });
1642
1643
 
1644
+ it("reports leaks to the console passed to createRun", async () => {
1645
+ const console = testCreateConsole();
1646
+ let report: ((leak: unknown) => void) | undefined;
1647
+ // Captures the callback that garbage collection would call.
1648
+ using _registry = testStubGlobal(
1649
+ "FinalizationRegistry",
1650
+ function (callback: (leak: unknown) => void) {
1651
+ report = callback;
1652
+ return { register: () => undefined, unregister: () => undefined };
1653
+ },
1654
+ );
1655
+ await using _run = createRun({ console });
1656
+
1657
+ assertNotUndefined(report);
1658
+ report({ name: "Lease", isLeaked: () => true, stack: "stack" });
1659
+
1660
+ assertEqual(console.getEntriesSnapshot(), [
1661
+ {
1662
+ method: "warn",
1663
+ path: [],
1664
+ args: [
1665
+ "Lease was garbage-collected without cleanup. Tracked at:",
1666
+ "stack",
1667
+ ],
1668
+ },
1669
+ ]);
1670
+ });
1671
+
1672
+ it("reports leaks to the console passed to testCreateRun", async () => {
1673
+ const console = testCreateConsole();
1674
+ await using run = testCreateRun({ console });
1675
+
1676
+ run.deps.leakDetector.track(
1677
+ {},
1678
+ { name: "Lease", isLeaked: () => true },
1679
+ {},
1680
+ );
1681
+
1682
+ assertEqual(run.deps.leakDetector.collect(), 1);
1683
+ assertEqual(
1684
+ console.getEntriesSnapshot().map(({ args }) => args[0]),
1685
+ ["Lease was garbage-collected without cleanup. Tracked at:"],
1686
+ );
1687
+ });
1688
+
1643
1689
  it("lets custom deps override defaults in createRun", async () => {
1644
1690
  await using run = createRun({ random });
1645
1691
 
package/src/Task.ts CHANGED
@@ -2136,20 +2136,21 @@ export type RunDefaultDeps = ConsoleDep &
2136
2136
  /**
2137
2137
  * Creates {@link RunDefaultDeps}.
2138
2138
  *
2139
+ * The leak detector reports to the provided console, or to a default one.
2140
+ *
2139
2141
  * @group Run
2140
2142
  */
2141
- export const createRunDefaultDeps = (): RunDefaultDeps => {
2142
- const console = createConsole();
2143
- return {
2144
- console,
2145
- leakDetector: isDev ? createLeakDetector({ console }) : noopLeakDetector,
2146
- nativeFetch: globalThis.fetch.bind(globalThis),
2147
- randomBytes: createRandomBytes(),
2148
- random: createRandom(),
2149
- reportDefect: reportDefectAfterMicrotask,
2150
- time: createTime(),
2151
- };
2152
- };
2143
+ export const createRunDefaultDeps = ({
2144
+ console = createConsole(),
2145
+ }: Partial<ConsoleDep> = {}): RunDefaultDeps => ({
2146
+ console,
2147
+ leakDetector: isDev ? createLeakDetector({ console }) : noopLeakDetector,
2148
+ nativeFetch: globalThis.fetch.bind(globalThis),
2149
+ randomBytes: createRandomBytes(),
2150
+ random: createRandom(),
2151
+ reportDefect: reportDefectAfterMicrotask,
2152
+ time: createTime(),
2153
+ });
2153
2154
 
2154
2155
  /**
2155
2156
  * Factory type for creating root {@link DisposableRun} instances.
@@ -2198,7 +2199,7 @@ export const createRun: CreateRun = <D extends object>(
2198
2199
  deps?: RunCustomDeps<D>,
2199
2200
  ): DisposableRun<D> =>
2200
2201
  createRunInternal<D>({
2201
- ...createRunDefaultDeps(),
2202
+ ...createRunDefaultDeps(deps),
2202
2203
  ...deps,
2203
2204
  } as RunDefaultDeps & D);
2204
2205
 
@@ -2406,8 +2407,11 @@ export function testCreateRun<D extends object>(
2406
2407
  export function testCreateRun<D extends object>(
2407
2408
  deps?: TestRunDefaultDeps | RunCustomDeps<D>,
2408
2409
  ): DisposableRun<TestRunDefaultDeps & D> {
2410
+ // As in createRun, the default leak detector reports to a passed console.
2411
+ const { console }: Partial<ConsoleDep> = deps ?? {};
2409
2412
  return createRunInternal<TestRunDefaultDeps & D>({
2410
2413
  ...testCreateDeps(),
2414
+ ...(console && { leakDetector: testCreateLeakDetector({ console }) }),
2411
2415
  ...deps,
2412
2416
  } as TestRunDefaultDeps & D);
2413
2417
  }