@evolu/common 6.0.1-preview.28 → 6.0.1-preview.29

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 (59) hide show
  1. package/dist/src/Array.d.ts +58 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +53 -5
  4. package/dist/src/Evolu/Evolu.d.ts +3 -3
  5. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  6. package/dist/src/Evolu/Evolu.js +3 -3
  7. package/dist/src/Evolu/Owner.d.ts +48 -19
  8. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  9. package/dist/src/Evolu/Owner.js +11 -2
  10. package/dist/src/Evolu/Protocol.d.ts +31 -31
  11. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  12. package/dist/src/Evolu/Protocol.js +51 -28
  13. package/dist/src/Evolu/Relay.d.ts +40 -25
  14. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  15. package/dist/src/Evolu/Relay.js +106 -49
  16. package/dist/src/Evolu/Storage.d.ts +59 -12
  17. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  18. package/dist/src/Evolu/Storage.js +77 -50
  19. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  20. package/dist/src/Evolu/Sync.js +14 -5
  21. package/dist/src/Evolu/Timestamp.d.ts +25 -0
  22. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  23. package/dist/src/Evolu/Timestamp.js +25 -0
  24. package/dist/src/Instances.d.ts +34 -0
  25. package/dist/src/Instances.d.ts.map +1 -0
  26. package/dist/src/{Multiton.js → Instances.js} +20 -9
  27. package/dist/src/Sqlite.d.ts +6 -0
  28. package/dist/src/Sqlite.d.ts.map +1 -1
  29. package/dist/src/Sqlite.js +6 -0
  30. package/dist/src/Task.d.ts +75 -0
  31. package/dist/src/Task.d.ts.map +1 -1
  32. package/dist/src/Task.js +29 -6
  33. package/dist/src/Time.d.ts +7 -1
  34. package/dist/src/Time.d.ts.map +1 -1
  35. package/dist/src/Time.js +13 -2
  36. package/dist/src/Type.d.ts +56 -9
  37. package/dist/src/Type.d.ts.map +1 -1
  38. package/dist/src/Type.js +40 -8
  39. package/dist/src/index.d.ts +1 -1
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +1 -1
  42. package/package.json +1 -1
  43. package/src/Array.ts +76 -11
  44. package/src/Evolu/Evolu.ts +4 -5
  45. package/src/Evolu/Owner.ts +75 -26
  46. package/src/Evolu/Protocol.ts +90 -61
  47. package/src/Evolu/Relay.ts +182 -77
  48. package/src/Evolu/Storage.ts +157 -67
  49. package/src/Evolu/Sync.ts +18 -6
  50. package/src/Evolu/Timestamp.ts +25 -0
  51. package/src/Instances.ts +90 -0
  52. package/src/Sqlite.ts +6 -0
  53. package/src/Task.ts +88 -7
  54. package/src/Time.ts +13 -2
  55. package/src/Type.ts +56 -9
  56. package/src/index.ts +1 -1
  57. package/dist/src/Multiton.d.ts +0 -50
  58. package/dist/src/Multiton.d.ts.map +0 -1
  59. package/src/Multiton.ts +0 -98
@@ -2,10 +2,12 @@ import { sha256 } from "@noble/hashes/sha2.js";
2
2
  import { NonEmptyReadonlyArray } from "../Array.js";
3
3
  import { assert } from "../Assert.js";
4
4
  import { Brand } from "../Brand.js";
5
+ import { concatBytes } from "../Buffer.js";
5
6
  import { decrement } from "../Number.js";
6
7
  import { RandomDep } from "../Random.js";
7
8
  import { ok, Result } from "../Result.js";
8
9
  import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
10
+ import { MaybeAsync } from "../Task.js";
9
11
  import {
10
12
  Id,
11
13
  Int64String,
@@ -16,6 +18,7 @@ import {
16
18
  String,
17
19
  } from "../Type.js";
18
20
  import {
21
+ BaseOwnerError,
19
22
  Owner,
20
23
  OwnerId,
21
24
  OwnerIdBytes,
@@ -24,13 +27,50 @@ import {
24
27
  } from "./Owner.js";
25
28
  import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
26
29
 
30
+ export interface StorageConfig {
31
+ /**
32
+ * Optional callback to check if an {@link OwnerId} is within their quota for
33
+ * the requested write. If this callback is not provided, all writes are
34
+ * allowed regardless of size.
35
+ *
36
+ * If provided, the callback receives the OwnerId and the number of bytes
37
+ * required for the write, and should return a {@link MaybeAsync} boolean:
38
+ * `true` to allow the write, or `false` to deny it due to quota limits.
39
+ *
40
+ * The callback can be synchronous (for SQLite or in-memory checks) or
41
+ * asynchronous (for calling remote APIs).
42
+ *
43
+ * The callback returns a boolean rather than an error type because error
44
+ * handling and logging are the responsibility of the callback
45
+ * implementation.
46
+ *
47
+ * ### Example
48
+ *
49
+ * ```ts
50
+ * // Client
51
+ * // evolu.subscribeError
52
+ *
53
+ * // Relay
54
+ * isOwnerWithinQuota: (ownerId, requiredBytes) => {
55
+ * console.log(ownerId, requiredBytes);
56
+ * // Check error via evolu.subscribeError
57
+ * return true;
58
+ * };
59
+ * ```
60
+ */
61
+ readonly isOwnerWithinQuota?: (
62
+ ownerId: OwnerId,
63
+ requiredBytes: PositiveInt,
64
+ ) => MaybeAsync<boolean>;
65
+ }
66
+
27
67
  /**
28
68
  * Evolu Storage
29
69
  *
30
- * The protocol using Storage is agnostic to storage implementation details—any
31
- * storage can be plugged in, as long as it implements this interface.
32
- * Implementations must handle their own errors; return values only indicate
33
- * overall success or failure.
70
+ * Evolu protocol using Storage is agnostic to storage implementation
71
+ * details—any storage can be plugged in, as long as it implements this
72
+ * interface. Implementations must handle their own errors; return values only
73
+ * indicate overall success or failure.
34
74
  *
35
75
  * The Storage API is synchronous because SQLite's synchronous API is the
36
76
  * fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
@@ -96,15 +136,15 @@ export interface Storage {
96
136
  /**
97
137
  * Write encrypted {@link CrdtMessage}s to storage.
98
138
  *
99
- * Must use a mutex (per ownerId on Relay) to ensure sequential processing and
100
- * proper protocol logic handling during sync operations.
139
+ * Must use a mutex per ownerId to ensure sequential processing and proper
140
+ * protocol logic handling during sync operations.
101
141
  *
102
- * Returns `true` on success, `false` on failure.
142
+ * TODO: Use MaybeAsync
103
143
  */
104
144
  readonly writeMessages: (
105
- ownerId: OwnerIdBytes,
145
+ ownerIdBytes: OwnerIdBytes,
106
146
  messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
107
- ) => Promise<boolean>;
147
+ ) => MaybeAsync<Result<void, StorageWriteError | StorageQuotaError>>;
108
148
 
109
149
  /** Read encrypted {@link DbChange}s from storage. */
110
150
  readonly readDbChange: (
@@ -124,6 +164,16 @@ export interface StorageDep {
124
164
  readonly storage: Storage;
125
165
  }
126
166
 
167
+ /** Error indicating a serious write failure. */
168
+ export interface StorageWriteError extends BaseOwnerError {
169
+ readonly type: "StorageWriteError";
170
+ }
171
+
172
+ /** Error when storage or billing quota is exceeded. */
173
+ export interface StorageQuotaError extends BaseOwnerError {
174
+ readonly type: "StorageQuotaError";
175
+ }
176
+
127
177
  /**
128
178
  * A cryptographic hash used for efficiently comparing collections of
129
179
  * {@link TimestampBytes}s.
@@ -261,6 +311,15 @@ export interface BaseSqliteStorage
261
311
  ownerId: OwnerIdBytes,
262
312
  timestamp: TimestampBytes,
263
313
  ) => Result<void, SqliteError>;
314
+
315
+ /**
316
+ * Efficiently checks which timestamps already exist in the database using a
317
+ * single CTE query instead of N individual queries.
318
+ */
319
+ readonly getExistingTimestamps: (
320
+ ownerIdBytes: OwnerIdBytes,
321
+ timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
322
+ ) => Result<ReadonlyArray<TimestampBytes>, SqliteError>;
264
323
  }
265
324
 
266
325
  export interface BaseSqliteStorageDep {
@@ -269,14 +328,14 @@ export interface BaseSqliteStorageDep {
269
328
 
270
329
  export type SqliteStorageDeps = RandomDep & SqliteDep;
271
330
 
272
- export interface CreateBaseSqliteStorageOptions {
331
+ export interface CreateBaseSqliteStorageConfig extends StorageConfig {
273
332
  onStorageError: (error: SqliteError) => void;
274
333
  }
275
334
 
276
335
  export const createBaseSqliteStorage =
277
336
  (deps: SqliteStorageDeps) =>
278
- (options: CreateBaseSqliteStorageOptions): BaseSqliteStorage => {
279
- // TODO: Use OwnerUsage table.
337
+ (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
338
+ // TODO: Use evolu_usage table.
280
339
  const ownerStats = new Map<
281
340
  OwnerId,
282
341
  {
@@ -286,49 +345,10 @@ export const createBaseSqliteStorage =
286
345
  >();
287
346
 
288
347
  return {
289
- insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
290
- const ownerIdString = ownerIdBytesToOwnerId(ownerId);
291
- const level = randomSkiplistLevel(deps);
292
-
293
- let stats = ownerStats.get(ownerIdString);
294
-
295
- if (!stats) {
296
- const result = deps.sqlite.exec<{
297
- maxT: TimestampBytes | null;
298
- minT: TimestampBytes | null;
299
- }>(sql.prepared`
300
- select min(t) as minT, max(t) as maxT
301
- from evolu_timestamp
302
- where ownerId = ${ownerId};
303
- `);
304
- if (!result.ok) return result;
305
-
306
- stats = {
307
- minT: result.value.rows[0].minT ?? timestamp,
308
- maxT: result.value.rows[0].maxT ?? timestamp,
309
- };
310
- ownerStats.set(ownerIdString, stats);
311
- }
312
-
313
- let strategy: InsertTimestampStrategy;
314
-
315
- if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
316
- strategy = "append";
317
- stats.maxT = timestamp;
318
- } else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
319
- strategy = "prepend";
320
- stats.minT = timestamp;
321
- } else {
322
- strategy = "insert";
323
- }
324
-
325
- return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
326
- },
327
-
328
348
  getSize: (ownerId) => {
329
349
  const size = getSize(deps)(ownerId);
330
350
  if (!size.ok) {
331
- options.onStorageError(size.error);
351
+ config.onStorageError(size.error);
332
352
  return null;
333
353
  }
334
354
  return size.value;
@@ -338,7 +358,7 @@ export const createBaseSqliteStorage =
338
358
  assertBeginEnd(begin, end);
339
359
  const result = fingerprint(deps)(ownerId, begin, end);
340
360
  if (!result.ok) {
341
- options.onStorageError(result.error);
361
+ config.onStorageError(result.error);
342
362
  return null;
343
363
  }
344
364
  return result.value;
@@ -347,7 +367,7 @@ export const createBaseSqliteStorage =
347
367
  fingerprintRanges: (ownerId, buckets, upperBound) => {
348
368
  const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound);
349
369
  if (!ranges.ok) {
350
- options.onStorageError(ranges.error);
370
+ config.onStorageError(ranges.error);
351
371
  return null;
352
372
  }
353
373
  return ranges.value;
@@ -361,7 +381,7 @@ export const createBaseSqliteStorage =
361
381
  upperBound,
362
382
  );
363
383
  if (!lowerBound.ok) {
364
- options.onStorageError(lowerBound.error);
384
+ config.onStorageError(lowerBound.error);
365
385
  return null;
366
386
  }
367
387
  return lowerBound.value;
@@ -375,7 +395,7 @@ export const createBaseSqliteStorage =
375
395
  // This is much faster than SQL limit with offset.
376
396
  const first = getTimestampByIndex(deps)(ownerId, begin);
377
397
  if (!first.ok) {
378
- options.onStorageError(first.error);
398
+ config.onStorageError(first.error);
379
399
  return;
380
400
  }
381
401
 
@@ -400,7 +420,7 @@ export const createBaseSqliteStorage =
400
420
  limit ${length - 1};
401
421
  `);
402
422
  if (!result.ok) {
403
- options.onStorageError(result.error);
423
+ config.onStorageError(result.error);
404
424
  return;
405
425
  }
406
426
 
@@ -415,11 +435,80 @@ export const createBaseSqliteStorage =
415
435
  delete from evolu_timestamp where ownerId = ${ownerId};
416
436
  `);
417
437
  if (!result.ok) {
418
- options.onStorageError(result.error);
438
+ config.onStorageError(result.error);
419
439
  return false;
420
440
  }
421
441
  return true;
422
442
  },
443
+
444
+ insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
445
+ const ownerIdString = ownerIdBytesToOwnerId(ownerId);
446
+ const level = randomSkiplistLevel(deps);
447
+
448
+ let stats = ownerStats.get(ownerIdString);
449
+
450
+ if (!stats) {
451
+ const result = deps.sqlite.exec<{
452
+ maxT: TimestampBytes | null;
453
+ minT: TimestampBytes | null;
454
+ }>(sql.prepared`
455
+ select min(t) as minT, max(t) as maxT
456
+ from evolu_timestamp
457
+ where ownerId = ${ownerId};
458
+ `);
459
+ if (!result.ok) return result;
460
+
461
+ stats = {
462
+ minT: result.value.rows[0].minT ?? timestamp,
463
+ maxT: result.value.rows[0].maxT ?? timestamp,
464
+ };
465
+ ownerStats.set(ownerIdString, stats);
466
+ }
467
+
468
+ let strategy: InsertTimestampStrategy;
469
+
470
+ if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
471
+ strategy = "append";
472
+ stats.maxT = timestamp;
473
+ } else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
474
+ strategy = "prepend";
475
+ stats.minT = timestamp;
476
+ } else {
477
+ strategy = "insert";
478
+ }
479
+
480
+ return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
481
+ },
482
+
483
+ getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
484
+ const concatenatedTimestamps = concatBytes(...timestampsBytes);
485
+
486
+ const result = deps.sqlite.exec<{
487
+ timestampBytes: TimestampBytes;
488
+ }>(sql`
489
+ with recursive
490
+ split_timestamps(timestampBytes, pos) as (
491
+ select
492
+ substr(${concatenatedTimestamps}, 1, 16),
493
+ 17 as pos
494
+ union all
495
+ select
496
+ substr(${concatenatedTimestamps}, pos, 16),
497
+ pos + 16
498
+ from split_timestamps
499
+ where pos <= length(${concatenatedTimestamps})
500
+ )
501
+ select s.timestampBytes
502
+ from
503
+ split_timestamps s
504
+ join evolu_timestamp t
505
+ on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
506
+ `);
507
+
508
+ if (!result.ok) return result;
509
+
510
+ return ok(result.value.rows.map((row) => row.timestampBytes));
511
+ },
423
512
  };
424
513
  };
425
514
 
@@ -484,19 +573,20 @@ export const createBaseSqliteStorageTables = (
484
573
  *
485
574
  * - `ownerId` – OwnerIdBytes (primary key)
486
575
  * - `storedBytes` – total bytes stored in database
487
- * - `receivedBytes` – total bytes received from clients
488
- * - `sentBytes` – total bytes sent to clients
489
- * - `firstTimestamp` – minimum timestamp (nullable)
490
- * - `lastTimestamp` – maximum timestamp (nullable)
576
+ * - `receivedBytes` – TODO: Decide how to use
577
+ * - `sentBytes` – TODO: Decide how to use
578
+ * - `firstTimestamp` – TODO: Decide how to use (nullable)
579
+ * - `lastTimestamp` – TODO: Decide how to use (nullable)
491
580
  */
492
581
  sql`
493
582
  create table evolu_usage (
494
583
  "ownerId" blob primary key,
495
- "storedBytes" integer not null,
496
- "receivedBytes" integer not null,
497
- "sentBytes" integer not null,
498
- "firstTimestamp" blob,
499
- "lastTimestamp" blob
584
+ "storedBytes" integer not null
585
+ -- TODO: Decide how to use receivedBytes, sentBytes, firstTimestamp, lastTimestamp
586
+ -- "receivedBytes" integer not null,
587
+ -- "sentBytes" integer not null,
588
+ -- "firstTimestamp" blob,
589
+ -- "lastTimestamp" blob
500
590
  )
501
591
  strict;
502
592
  `,
package/src/Evolu/Sync.ts CHANGED
@@ -13,7 +13,7 @@ import { constFalse } from "../Function.js";
13
13
  import { objectToEntries } from "../Object.js";
14
14
  import { RandomDep } from "../Random.js";
15
15
  import { createRefCountedResourceManager } from "../RefCountedResourceManager.js";
16
- import { ok, Result } from "../Result.js";
16
+ import { err, ok, Result } from "../Result.js";
17
17
  import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
18
18
  import { AbortError, createMutex } from "../Task.js";
19
19
  import { TimeDep } from "../Time.js";
@@ -48,10 +48,11 @@ import {
48
48
  import { MutationChange } from "./Schema.js";
49
49
  import {
50
50
  BaseSqliteStorage,
51
- createBaseSqliteStorage,
52
51
  CrdtMessage,
52
+ createBaseSqliteStorage,
53
53
  DbChange,
54
54
  Storage,
55
+ StorageWriteError,
55
56
  } from "./Storage.js";
56
57
  import {
57
58
  createInitialTimestamp,
@@ -452,15 +453,20 @@ const createClientStorage =
452
453
  onStorageError: config.onError,
453
454
  });
454
455
 
456
+ // TODO: Mutex per OwnerId
455
457
  const mutex = createMutex();
456
458
 
457
459
  const storage: ClientStorage = {
458
460
  ...sqliteStorageBase,
459
461
 
462
+ // Not implemented yet.
460
463
  validateWriteKey: constFalse,
461
464
  setWriteKey: constFalse,
462
465
 
463
466
  writeMessages: async (ownerIdBytes, encryptedMessages) => {
467
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
468
+
469
+ // Everything is sync now, but we will need async crypto in the future.
464
470
  const writeResult = await mutex.withLock<
465
471
  boolean,
466
472
  | AbortError
@@ -473,10 +479,16 @@ const createClientStorage =
473
479
  | TimestampTimeOutOfRangeError
474
480
  // eslint-disable-next-line @typescript-eslint/require-await
475
481
  >(async () => {
476
- const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
477
482
  const owner = deps.getSyncOwner(ownerId);
478
483
  // Owner can be removed during syncing.
479
- if (!owner) return ok(false);
484
+ // `ok(true)` means success, we just skipped the write.
485
+ if (!owner) return ok(true);
486
+
487
+ // TODO: Add quota checking for collaborative scenarios.
488
+ // When receiving messages from other owners via relay broadcast,
489
+ // check if this owner is within quota before accepting the data.
490
+ // This prevents an owner from exceeding storage limits when receiving
491
+ // data shared by other collaborators.
480
492
 
481
493
  const messages: Array<CrdtMessage> = [];
482
494
 
@@ -530,12 +542,12 @@ const createClientStorage =
530
542
  if (writeResult.error.type !== "AbortError") {
531
543
  config.onError(writeResult.error);
532
544
  }
533
- return false;
545
+ return err<StorageWriteError>({ type: "StorageWriteError", ownerId });
534
546
  }
535
547
 
536
548
  config.onReceive();
537
549
 
538
- return true;
550
+ return ok();
539
551
  },
540
552
 
541
553
  readDbChange: (ownerId, timestamp) => {
@@ -117,6 +117,31 @@ export const maxNodeId = "ffffffffffffffff" as NodeId;
117
117
  * Timestamps serve as globally unique, causally ordered identifiers for CRDT
118
118
  * messages in Evolu's sync protocol.
119
119
  *
120
+ * ### Why Hybrid Logical Clocks
121
+ *
122
+ * Evolu uses Hybrid Logical Clocks (HLC), which combine physical time (millis)
123
+ * with a logical counter. This hybrid approach preserves causality like logical
124
+ * clocks while staying close to physical time for better human
125
+ * interpretability.
126
+ *
127
+ * The counter component ensures causality is maintained even when physical
128
+ * clocks are imperfect. When clocks drift or operations occur concurrently, the
129
+ * counter increments to establish a total order. This means Evolu achieves
130
+ * well-defined, eventually-consistent behavior regardless of physical clock
131
+ * accuracy.
132
+ *
133
+ * Vector clocks can accurately track causality and detect concurrent
134
+ * operations, but they require unbounded space in peer-to-peer systems and
135
+ * crucially, still don't solve our fundamental problem: when they detect
136
+ * operations as concurrent, we still need a deterministic way to choose a
137
+ * winner. Additionally, any deterministic conflict resolution can be gamed by
138
+ * malicious actors.
139
+ *
140
+ * HLC timestamps work well in practice because modern device clocks accurately
141
+ * reflect the order of sequential edits in the common case. Evolu's `maxDrift`
142
+ * configuration protects against buggy clocks and prevents problematic
143
+ * future-dated entries from propagating through the network.
144
+ *
120
145
  * ### References
121
146
  *
122
147
  * - https://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Manages disposable instances by key, ensuring exactly one instance per key.
3
+ *
4
+ * Use cases:
5
+ *
6
+ * - One mutex per key to prevent concurrent writes
7
+ * - Preserving state during hot module reloading
8
+ *
9
+ * **Important:** Do not use this as global shared state. Use it locally or pass
10
+ * it as a dependency instead. The only exception is for hot reloading support,
11
+ * where Evolu uses it to ensure only one instance exists across module reloads
12
+ * (having two Evolu instances with the same name would mean two SQLite
13
+ * connections to the same file, which could corrupt data).
14
+ */
15
+ export interface Instances<K extends string, T extends Disposable>
16
+ extends Disposable {
17
+ /**
18
+ * Ensures an instance exists for the given key, creating it if necessary. If
19
+ * the instance already exists, the optional `onCacheHit` callback is invoked
20
+ * to update the existing instance.
21
+ */
22
+ readonly ensure: (
23
+ key: K,
24
+ create: () => T,
25
+ onCacheHit?: (instance: T) => void,
26
+ ) => T;
27
+
28
+ /** Gets an instance by key, or returns `null` if it doesn't exist. */
29
+ readonly get: (key: K) => T | null;
30
+
31
+ /** Checks if an instance exists for the given key. */
32
+ readonly has: (key: K) => boolean;
33
+
34
+ /**
35
+ * Deletes and disposes an instance by key. Returns `true` if the instance
36
+ * existed and was deleted, `false` otherwise.
37
+ */
38
+ readonly delete: (key: K) => boolean;
39
+ }
40
+
41
+ /** Creates an {@link Instances} manager. */
42
+ export const createInstances = <
43
+ K extends string,
44
+ T extends Disposable,
45
+ >(): Instances<K, T> => {
46
+ const instances = new Map<K, T>();
47
+
48
+ return {
49
+ ensure: (key, create, onCacheHit) => {
50
+ let instance = instances.get(key);
51
+
52
+ if (instance == null) {
53
+ instance = create();
54
+ instances.set(key, instance);
55
+ } else if (onCacheHit) {
56
+ onCacheHit(instance);
57
+ }
58
+
59
+ return instance;
60
+ },
61
+
62
+ get: (key) => instances.get(key) ?? null,
63
+
64
+ has: (key) => instances.has(key),
65
+
66
+ delete: (key) => {
67
+ const instance = instances.get(key);
68
+ if (instance == null) return false;
69
+ instances.delete(key);
70
+ instance[Symbol.dispose]();
71
+ return true;
72
+ },
73
+
74
+ [Symbol.dispose]: () => {
75
+ const errors: Array<unknown> = [];
76
+ for (const instance of instances.values()) {
77
+ try {
78
+ instance[Symbol.dispose]();
79
+ } catch (error) {
80
+ errors.push(error);
81
+ }
82
+ }
83
+ instances.clear();
84
+ if (errors.length === 1) throw errors[0];
85
+ if (errors.length > 1) {
86
+ throw new AggregateError(errors, "Multiple disposal errors occurred");
87
+ }
88
+ },
89
+ };
90
+ };
package/src/Sqlite.ts CHANGED
@@ -334,6 +334,12 @@ export type SqlTemplateParam = SqliteValue | SqlIdentifier | RawSql;
334
334
  * const orderBy = "created_at desc";
335
335
  * sqlite.exec(sql`select * from users order by ${sql.raw(orderBy)};`);
336
336
  * ```
337
+ *
338
+ * ### TIP
339
+ *
340
+ * Use `prettier-plugin-sql-cst` for SQL formatting. Like Prettier for
341
+ * JavaScript, this plugin formats SQL expressions differently depending on
342
+ * their length.
337
343
  */
338
344
  export const sql = (
339
345
  strings: TemplateStringsArray,
package/src/Task.ts CHANGED
@@ -778,13 +778,6 @@ export const createMutex = (): Mutex => {
778
778
  };
779
779
  };
780
780
 
781
- // TODO: Add tracing support
782
- // - Extend TaskContext with optional tracing field
783
- // - Add traced(name, task) helper that wraps Task execution
784
- // - Collect span data (name, timing, parent-child relationships, status)
785
- // - Support OpenTelemetry export format with proper traceId/spanId generation
786
- // - Automatic parent-child span relationships through context propagation
787
-
788
781
  /**
789
782
  * Schedule a task to run after all interactions (animations, gestures,
790
783
  * navigation) have completed.
@@ -818,3 +811,91 @@ const idleCallback: (callback: () => void) => void =
818
811
  typeof globalThis.requestIdleCallback === "function"
819
812
  ? globalThis.requestIdleCallback
820
813
  : (callback) => setTimeout(callback, 0);
814
+
815
+ /**
816
+ * Represents a value that can be either synchronous or asynchronous.
817
+ *
818
+ * This type is useful for functions that may complete synchronously or
819
+ * asynchronously depending on runtime conditions (e.g., cache hit vs network
820
+ * fetch).
821
+ *
822
+ * ### Why MaybeAsync?
823
+ *
824
+ * When a function can be sync or async, the typical approaches are:
825
+ *
826
+ * 1. **Always return Promise** - Simple but forces microtask overhead even for
827
+ * sync values (see "await always adds microtask" test in Task.test.ts)
828
+ * 2. **Use callbacks** - Can avoid microtask, but calling code must still `await`
829
+ * for sane composition, which adds microtask anyway
830
+ * 3. **Return `T | PromiseLike<T>`** - Calling code can check the value and only
831
+ * `await` when needed, avoiding microtask overhead for sync cases
832
+ *
833
+ * The third approach (MaybeAsync) provides:
834
+ *
835
+ * - **Performance**: No microtask overhead for synchronous operations
836
+ * - **Reliability**: No interleaving via microtask queue when operations are
837
+ * _synchronous_, reducing need for mutexes to protect shared state
838
+ *
839
+ * ### Example
840
+ *
841
+ * ```ts
842
+ * // Function that may be sync or async
843
+ * const getData = (id: string): MaybeAsync<Data> => {
844
+ * const cached = cache.get(id);
845
+ * if (cached) return cached; // Sync path
846
+ * return fetchData(id); // Async path
847
+ * };
848
+ *
849
+ * // Caller can optimize based on actual behavior
850
+ * const result = getData(id);
851
+ * const data = isAsync(result) ? await result : result;
852
+ * ```
853
+ *
854
+ * ### Alternative Approaches
855
+ *
856
+ * It's possible to eliminate the sync/async distinction using complex
857
+ * frameworks with custom schedulers. However, such frameworks require depending
858
+ * on other people's code that controls how your code executes, resulting in
859
+ * more complex stack traces and debugging experiences. With MaybeAsync, we
860
+ * don't need that machinery - it works directly with JavaScript's native
861
+ * primitives and TypeScript's type system.
862
+ *
863
+ * ### TODO: Consider
864
+ *
865
+ * Use MaybeAsync in Task and Task helpers to preserve synchronous execution
866
+ * when possible (e.g., mutex with available permit, retry on first success).
867
+ */
868
+ export type MaybeAsync<T> = T | PromiseLike<T>;
869
+
870
+ /**
871
+ * Type guard to check if a {@link MaybeAsync} value is async (a promise).
872
+ *
873
+ * This function narrows the type of a {@link MaybeAsync} value, allowing you to
874
+ * conditionally `await` only when necessary.
875
+ *
876
+ * ### Example
877
+ *
878
+ * ```ts
879
+ * const getData = (id: string): MaybeAsync<Data> => {
880
+ * const cached = cache.get(id);
881
+ * if (cached) return cached; // Sync path
882
+ * return fetchData(id); // Async path
883
+ * };
884
+ *
885
+ * const result = getData(id);
886
+ * const data = isAsync(result) ? await result : result;
887
+ * // No microtask overhead when cached!
888
+ * ```
889
+ */
890
+ export const isAsync = <T>(
891
+ value: MaybeAsync<T>,
892
+ ): value is T extends PromiseLike<unknown> ? never : PromiseLike<T> =>
893
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
894
+ typeof (value as any)?.then === "function";
895
+
896
+ // TODO: Add tracing support
897
+ // - Extend TaskContext with optional tracing field
898
+ // - Add traced(name, task) helper that wraps Task execution
899
+ // - Collect span data (name, timing, parent-child relationships, status)
900
+ // - Support OpenTelemetry export format with proper traceId/spanId generation
901
+ // - Automatic parent-child span relationships through context propagation