@sixb/ducklake 0.1.5 → 0.1.7

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 (34) hide show
  1. package/README.md +24 -26
  2. package/dist/ducklake-storage.d.ts +3 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1100 -661
  5. package/dist/index.js.map +22 -19
  6. package/dist/internal/duckdb-reader.d.ts +28 -0
  7. package/dist/internal/duckdb-reader.d.ts.map +1 -0
  8. package/dist/internal/duckdb-runtime.d.ts +5 -3
  9. package/dist/internal/duckdb-runtime.d.ts.map +1 -1
  10. package/dist/internal/ducklake-change-reader.d.ts +13 -0
  11. package/dist/internal/ducklake-change-reader.d.ts.map +1 -0
  12. package/dist/internal/ducklake-connection-manager.d.ts +19 -3
  13. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
  14. package/dist/internal/ducklake-read-gate.d.ts +11 -0
  15. package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
  16. package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  18. package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
  19. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  20. package/dist/types.d.ts +2 -0
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +4 -4
  23. package/src/ducklake-storage.ts +13 -0
  24. package/src/internal/duckdb-reader.ts +129 -0
  25. package/src/internal/duckdb-runtime.ts +75 -51
  26. package/src/internal/ducklake-change-reader.ts +157 -0
  27. package/src/internal/ducklake-connection-manager.ts +117 -33
  28. package/src/internal/ducklake-maintenance.ts +1 -1
  29. package/src/internal/ducklake-read-gate.ts +58 -0
  30. package/src/internal/ducklake-row-reader.ts +84 -63
  31. package/src/internal/ducklake-snapshot-reader.ts +7 -2
  32. package/src/internal/ducklake-write-coordinator.ts +56 -15
  33. package/src/internal/ducklake-write-session.ts +1 -0
  34. package/src/types.ts +2 -0
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
3
+ import {
4
+ type DatasetChanges,
5
+ type DatasetRowChange,
6
+ LakeStorageError,
7
+ type ReadDatasetChangesInput,
8
+ resolveDatasetChangeColumns,
9
+ } from "@sixb/core/lake-storage"
10
+ import type { DuckLakeStorageOptions } from "../types"
11
+ import { getBigIntLike } from "./duckdb-row"
12
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
13
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
14
+ import { resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
15
+ import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader"
16
+ import { normalizeReadValue } from "./schema"
17
+ import { qualifiedTableName, quoteIdentifier } from "./sql"
18
+ import { parseVersionId } from "./versions"
19
+
20
+ const PAGE_ROWS = 5_000
21
+
22
+ /** Compare once in DuckDB, then page only the delta without holding the connection between yields. */
23
+ export class DuckLakeChangeReader {
24
+ constructor(
25
+ private readonly options: DuckLakeStorageOptions,
26
+ private readonly connections: DuckLakeConnectionManager,
27
+ private readonly snapshots: DuckLakeSnapshotReader
28
+ ) {}
29
+
30
+ async readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null> {
31
+ input.signal?.throwIfAborted()
32
+ const before = await this.snapshots.getVersion(input.datasetId, input.fromVersionId)
33
+ const after = await this.snapshots.getVersion(input.datasetId, input.toVersionId)
34
+ const columns = resolveDatasetChangeColumns(input, before, after)
35
+ if (!columns) return null
36
+ const lease = await this.connections.acquireAttachedRuntime()
37
+ const runtime = lease.runtime
38
+ const temporary = quoteIdentifier(`sixb_delta_${randomUUID().replaceAll("-", "")}`)
39
+ let retained = false
40
+ let created = false
41
+ try {
42
+ const ref = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
43
+ if (!ref) return null
44
+ const table = qualifiedTableName(this.options, ref.tableName)
45
+ const oldRelation = `${table} AT (VERSION => ${parseVersionId(input.fromVersionId)})`
46
+ const newRelation = `${table} AT (VERSION => ${parseVersionId(input.toVersionId)})`
47
+ const previousCount = await uniqueRowCount(runtime, oldRelation, input.keyColumns)
48
+ const nextCount = await uniqueRowCount(runtime, newRelation, input.keyColumns)
49
+ if (previousCount === null || nextCount === null) return null
50
+ input.signal?.throwIfAborted()
51
+ const key = quoteIdentifier(input.keyColumns[0]!)
52
+ const values = columns.flatMap((column, index) => [
53
+ `b.${quoteIdentifier(column.name)} AS b${index}`,
54
+ `a.${quoteIdentifier(column.name)} AS a${index}`,
55
+ ])
56
+ const join = input.keyColumns
57
+ .map((name) => `b.${quoteIdentifier(name)} = a.${quoteIdentifier(name)}`)
58
+ .join(" AND ")
59
+ const changed = columns
60
+ .map(
61
+ (column) =>
62
+ `b.${quoteIdentifier(column.name)} IS DISTINCT FROM a.${quoteIdentifier(column.name)}`
63
+ )
64
+ .join(" OR ")
65
+ // The native change feed reflects DELETE/INSERT writes: snapshot ingestion can produce 2N
66
+ // physical changes for one logical edit. Compare the pinned states instead of replaying it.
67
+ await runtime.run(`CREATE TEMP TABLE ${temporary} AS
68
+ SELECT b.${key} IS NOT NULL AS before_present, a.${key} IS NOT NULL AS after_present,
69
+ ${values.join(", ")}
70
+ FROM (SELECT * FROM ${oldRelation}) b
71
+ FULL OUTER JOIN (SELECT * FROM ${newRelation}) a ON ${join}
72
+ WHERE b.${key} IS NULL OR a.${key} IS NULL OR ${changed}`)
73
+ created = true
74
+ const [count] = await runtime.query(`SELECT count(*) AS count FROM ${temporary}`)
75
+ const changeCount = safeCount(count!, "count")
76
+ input.signal?.throwIfAborted()
77
+ let closePromise: Promise<void> | undefined
78
+ const close = () => {
79
+ closePromise ??= runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
80
+ return closePromise
81
+ }
82
+ const changes = (async function* (): AsyncIterable<DatasetRowChange> {
83
+ try {
84
+ let cursor = -1n
85
+ let delivered = 0
86
+ while (delivered < changeCount) {
87
+ input.signal?.throwIfAborted()
88
+ if (closePromise) throw new LakeStorageError("[SixbDuckLake] Change reader is closed.")
89
+ const page = await runtime.query(`SELECT rowid AS position, * FROM ${temporary}
90
+ WHERE rowid > ${cursor} ORDER BY rowid LIMIT ${PAGE_ROWS}`)
91
+ if (page.length === 0)
92
+ throw new LakeStorageError(
93
+ "[SixbDuckLake] Change read ended before its declared count."
94
+ )
95
+ for (const row of page) {
96
+ input.signal?.throwIfAborted()
97
+ delivered++
98
+ yield {
99
+ before: row.before_present === true ? rowImage(row, "b", columns) : null,
100
+ after: row.after_present === true ? rowImage(row, "a", columns) : null,
101
+ }
102
+ }
103
+ cursor = getBigIntLike(page[page.length - 1]!, "position")
104
+ }
105
+ } finally {
106
+ await close()
107
+ }
108
+ })()
109
+ retained = true
110
+ return { fromRowCount: previousCount, toRowCount: nextCount, changeCount, changes, close }
111
+ } finally {
112
+ // The delta is a DuckDB temp table, independent of the attached DuckLake catalog. Release
113
+ // the local attachment lock before returning it, otherwise consumer lake operations deadlock.
114
+ try {
115
+ if (!retained && created) await runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
116
+ } finally {
117
+ await lease.release()
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ async function uniqueRowCount(
124
+ runtime: DuckDbQueryRuntime,
125
+ relation: string,
126
+ keys: readonly string[]
127
+ ): Promise<number | null> {
128
+ const names = keys.map(quoteIdentifier)
129
+ const invalid = names.map((name) => `${name} IS NULL OR trim(${name}) = ''`).join(" OR ")
130
+ const [row] = await runtime.query(`SELECT count(*) AS total,
131
+ count(DISTINCT (${names.join(", ")})) AS unique_keys,
132
+ count(*) FILTER (WHERE ${invalid}) AS invalid_keys FROM ${relation}`)
133
+ const count = safeCount(row!, "total")
134
+ return safeCount(row!, "unique_keys") === count && safeCount(row!, "invalid_keys") === 0
135
+ ? count
136
+ : null
137
+ }
138
+
139
+ function safeCount(row: Readonly<Record<string, unknown>>, name: string): number {
140
+ const count = Number(getBigIntLike(row, name))
141
+ if (!Number.isSafeInteger(count) || count < 0)
142
+ throw new LakeStorageError("[SixbDuckLake] Change count exceeds the supported integer range.")
143
+ return count
144
+ }
145
+
146
+ function rowImage(
147
+ row: Readonly<Record<string, unknown>>,
148
+ prefix: "a" | "b",
149
+ columns: readonly DatasetColumnDefinition[]
150
+ ): DatasetRow {
151
+ return Object.fromEntries(
152
+ columns.map((column, index) => [
153
+ column.name,
154
+ normalizeReadValue(row[`${prefix}${index}`], column),
155
+ ])
156
+ )
157
+ }
@@ -1,6 +1,7 @@
1
1
  import { LakeStorageError } from "@sixb/core/lake-storage"
2
2
  import type { DuckLakeStorageOptions } from "../types"
3
3
  import { localCatalogCoordinationKey } from "./catalog-key"
4
+ import type { DuckDbReader } from "./duckdb-reader"
4
5
  import {
5
6
  createDuckDbRuntime,
6
7
  type DuckDbExclusiveRuntime,
@@ -9,6 +10,7 @@ import {
9
10
  installDuckLakeExtensions,
10
11
  setupDuckLake,
11
12
  } from "./duckdb-runtime"
13
+ import { DuckLakeReadGate } from "./ducklake-read-gate"
12
14
  import {
13
15
  buildAttachSql,
14
16
  buildConfigurePostgresMetadataPoolSql,
@@ -37,14 +39,89 @@ export class DuckLakeConnectionManager {
37
39
  // Local catalogs need DETACH/ATTACH to refresh metadata visibility. Keep
38
40
  // those attachment changes outside whole read operations so a read cannot
39
41
  // observe the DuckLake alias disappearing between its metadata queries.
40
- private localAttachmentLock: Promise<void> = Promise.resolve()
42
+ private attachmentLock: Promise<void> = Promise.resolve()
41
43
  private observedLocalCatalogGeneration: number
42
44
  private attached = false
43
45
  private closed = false
46
+ private closePromise: Promise<void> | undefined
44
47
  private runtimePoisoned = false
48
+ private pinnedReaders = 0
49
+ private refreshRequested = false
50
+ private readonly shutdown = new AbortController()
51
+ private readonly reads = new DuckLakeReadGate()
52
+ private readonly maxStreamingReads: number
45
53
 
46
54
  constructor(private readonly options: DuckLakeStorageOptions) {
47
55
  this.observedLocalCatalogGeneration = currentLocalCatalogGeneration(options)
56
+ this.maxStreamingReads = options.maxStreamingReads ?? 4
57
+ if (!Number.isSafeInteger(this.maxStreamingReads) || this.maxStreamingReads < 1) {
58
+ throw new LakeStorageError("[SixbDuckLake] maxStreamingReads must be a positive integer.")
59
+ }
60
+ }
61
+
62
+ async acquireRead(signal?: AbortSignal): Promise<{
63
+ readonly signal: AbortSignal
64
+ readonly release: () => void
65
+ }> {
66
+ this.assertOpen()
67
+ const combined = signal ? AbortSignal.any([signal, this.shutdown.signal]) : this.shutdown.signal
68
+ return { signal: combined, release: await this.reads.acquire(combined) }
69
+ }
70
+
71
+ private async nativeReaderLimit(runtime: DuckDbRuntime): Promise<number> {
72
+ if (this.options.catalog.type === "duckdb") return this.maxStreamingReads
73
+ // SQLite metadata transactions keep a read lock for the native stream's lifetime.
74
+ // Unknown/custom catalogs use bounded queries too, without assuming their locking behavior.
75
+ if (this.options.catalog.type !== "postgres") return 0
76
+ const [setting] = await runtime.query(
77
+ "SELECT current_setting('pg_pool_max_connections') AS value"
78
+ )
79
+ const budget = Number(setting?.value)
80
+ // Reserve metadata/write headroom, including transient leases from bounded scans. A
81
+ // four-connection pool must stay paged: one paused native cursor can still starve an append.
82
+ // The extension can exceed its cache limit; this is not a server-wide connection guarantee.
83
+ return Number.isFinite(budget) ? Math.min(this.maxStreamingReads, Math.max(0, budget - 4)) : 0
84
+ }
85
+
86
+ /** Caller holds an attachment lease until this query has started. */
87
+ async openReader(
88
+ runtime: DuckDbRuntime,
89
+ sql: string,
90
+ signal: AbortSignal,
91
+ releaseRead: () => void
92
+ ): Promise<DuckDbReader | null> {
93
+ const limit = await this.nativeReaderLimit(runtime)
94
+ signal.throwIfAborted()
95
+ if (this.pinnedReaders >= limit) return null
96
+ this.pinnedReaders++
97
+ let starting = true
98
+ let released = false
99
+ const onClose = async () => {
100
+ if (released) return
101
+ released = true
102
+ this.pinnedReaders--
103
+ if (signal.aborted || this.closed || this.runtimePoisoned) releaseRead()
104
+ // On startup failure the caller still holds its lease and will detach on release.
105
+ if (starting || this.closed || this.runtimePoisoned) return
106
+ await this.withAttachmentLock(async () => {
107
+ if (this.pinnedReaders === 0 && this.refreshRequested) await this.resetRuntimeUnlocked()
108
+ await this.detachDuckDbCatalogAfterLease()
109
+ })
110
+ }
111
+ try {
112
+ return await runtime.openReader(sql, { signal, onClose })
113
+ } catch (error) {
114
+ await onClose()
115
+ throw error
116
+ } finally {
117
+ starting = false
118
+ }
119
+ }
120
+
121
+ async withMaintenanceRuntime<T>(
122
+ run: (runtime: DuckDbExclusiveRuntime) => Promise<T>
123
+ ): Promise<T> {
124
+ return this.reads.withMaintenance(this.shutdown.signal, () => this.withExclusiveAttached(run))
48
125
  }
49
126
 
50
127
  assertOpen(): void {
@@ -87,10 +164,9 @@ export class DuckLakeConnectionManager {
87
164
  async acquireAttachedRuntime(): Promise<DuckLakeAttachedRuntimeLease> {
88
165
  this.assertOpen()
89
166
 
90
- // The lease is intentionally wider than one DuckDB queue operation. It
91
- // covers the full Sixb read/commit boundary for local catalogs while
92
- // remaining a no-op for PostgreSQL catalogs.
93
- const releaseLock = await this.acquireLocalAttachmentLock()
167
+ // Keep attachment changes and native-query preparation outside each other's critical section.
168
+ // The lease ends before rows are yielded; an active reader only retains the attachment.
169
+ const releaseLock = await this.acquireAttachmentLock()
94
170
  let released = false
95
171
  const release = async () => {
96
172
  if (released) {
@@ -100,8 +176,8 @@ export class DuckLakeConnectionManager {
100
176
  released = true
101
177
  try {
102
178
  if (this.runtimePoisoned) {
103
- this.runtimePoisoned = false
104
179
  await this.discardRuntimeUnlocked()
180
+ this.runtimePoisoned = false
105
181
  } else {
106
182
  await this.detachDuckDbCatalogAfterLease()
107
183
  }
@@ -168,7 +244,7 @@ export class DuckLakeConnectionManager {
168
244
  }
169
245
 
170
246
  async resetRuntime(): Promise<void> {
171
- const releaseLock = await this.acquireLocalAttachmentLock()
247
+ const releaseLock = await this.acquireAttachmentLock()
172
248
  try {
173
249
  await this.resetRuntimeUnlocked()
174
250
  } finally {
@@ -177,6 +253,11 @@ export class DuckLakeConnectionManager {
177
253
  }
178
254
 
179
255
  private async resetRuntimeUnlocked(): Promise<void> {
256
+ if (this.pinnedReaders > 0) {
257
+ this.refreshRequested = true
258
+ return
259
+ }
260
+ this.refreshRequested = false
180
261
  const runtimePromise = this.runtimePromise
181
262
  if (runtimePromise === undefined) {
182
263
  return
@@ -226,7 +307,7 @@ export class DuckLakeConnectionManager {
226
307
 
227
308
  if (this.refreshPromise === undefined) {
228
309
  let refreshPromise: Promise<void>
229
- refreshPromise = this.withLocalAttachmentLock(() =>
310
+ refreshPromise = this.withAttachmentLock(() =>
230
311
  this.refreshForExternalChangesUnlocked()
231
312
  ).finally(() => {
232
313
  if (this.refreshPromise === refreshPromise) {
@@ -253,8 +334,10 @@ export class DuckLakeConnectionManager {
253
334
  }
254
335
 
255
336
  try {
256
- await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
257
- this.attached = false
337
+ if (this.pinnedReaders === 0) {
338
+ await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
339
+ this.attached = false
340
+ }
258
341
  } catch {
259
342
  // The commit already succeeded. Keep the original write result and let a
260
343
  // later refresh/close retry detaching this local catalog if needed.
@@ -278,12 +361,14 @@ export class DuckLakeConnectionManager {
278
361
  return this.observedLocalCatalogGeneration !== currentLocalCatalogGeneration(this.options)
279
362
  }
280
363
 
281
- async close(): Promise<void> {
282
- if (this.closed) {
283
- return
284
- }
364
+ close(): Promise<void> {
365
+ this.closePromise ??= this.finishClose()
366
+ return this.closePromise
367
+ }
285
368
 
369
+ private async finishClose(): Promise<void> {
286
370
  this.closed = true
371
+ this.shutdown.abort(new LakeStorageError("[SixbDuckLake] DuckLakeStorage is closed."))
287
372
 
288
373
  const runtimePromise = this.runtimePromise
289
374
  this.runtimePromise = undefined
@@ -364,7 +449,7 @@ export class DuckLakeConnectionManager {
364
449
  }
365
450
 
366
451
  private async detachRuntime(): Promise<void> {
367
- await this.withLocalAttachmentLock(() => this.detachRuntimeUnlocked())
452
+ await this.withAttachmentLock(() => this.detachRuntimeUnlocked())
368
453
  }
369
454
 
370
455
  /**
@@ -377,7 +462,7 @@ export class DuckLakeConnectionManager {
377
462
  }
378
463
 
379
464
  private async detachDuckDbCatalogAfterLease(): Promise<void> {
380
- if (this.options.catalog.type !== "duckdb" || !this.attached) {
465
+ if (this.options.catalog.type !== "duckdb" || !this.attached || this.pinnedReaders > 0) {
381
466
  return
382
467
  }
383
468
 
@@ -403,6 +488,7 @@ export class DuckLakeConnectionManager {
403
488
  }
404
489
 
405
490
  private async detachRuntimeUnlocked(): Promise<void> {
491
+ if (this.pinnedReaders > 0) return
406
492
  if (this.attachPromise !== undefined) {
407
493
  await this.attachPromise
408
494
  }
@@ -416,8 +502,8 @@ export class DuckLakeConnectionManager {
416
502
  this.attached = false
417
503
  }
418
504
 
419
- private async withLocalAttachmentLock<T>(run: () => Promise<T>): Promise<T> {
420
- const releaseLock = await this.acquireLocalAttachmentLock()
505
+ private async withAttachmentLock<T>(run: () => Promise<T>): Promise<T> {
506
+ const releaseLock = await this.acquireAttachmentLock()
421
507
  try {
422
508
  return await run()
423
509
  } finally {
@@ -425,17 +511,13 @@ export class DuckLakeConnectionManager {
425
511
  }
426
512
  }
427
513
 
428
- private async acquireLocalAttachmentLock(): Promise<() => void> {
429
- if (this.options.catalog.type === "postgres") {
430
- return () => {}
431
- }
432
-
433
- const previous = this.localAttachmentLock.catch(() => {})
514
+ private async acquireAttachmentLock(): Promise<() => void> {
515
+ const previous = this.attachmentLock.catch(() => {})
434
516
  let release!: () => void
435
517
  const current = new Promise<void>((resolve) => {
436
518
  release = resolve
437
519
  })
438
- this.localAttachmentLock = previous.then(() => current)
520
+ this.attachmentLock = previous.then(() => current)
439
521
 
440
522
  await previous
441
523
 
@@ -475,16 +557,18 @@ export class DuckLakeConnectionManager {
475
557
  runtime: DuckDbRuntime,
476
558
  options: { readonly detach: boolean }
477
559
  ): Promise<void> {
478
- if (options.detach) {
479
- try {
480
- await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
481
- } catch {
482
- // The runtime may already be detached, partially initialized, or in an
483
- // error state. Closing the native connection is still required.
560
+ try {
561
+ await runtime.stopReaders()
562
+ if (options.detach) {
563
+ try {
564
+ await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
565
+ } catch {
566
+ // Detaching a partially initialized or failed catalog can fail; still close the engine.
567
+ }
484
568
  }
569
+ } finally {
570
+ await runtime.close()
485
571
  }
486
-
487
- await runtime.close()
488
572
  }
489
573
  }
490
574
 
@@ -37,7 +37,7 @@ export class DuckLakeMaintenance {
37
37
  )
38
38
  }
39
39
 
40
- return this.connections.withExclusiveAttached(async (runtime) => {
40
+ return this.connections.withMaintenanceRuntime(async (runtime) => {
41
41
  const counts = await runMaintenanceProcedures(
42
42
  runtime,
43
43
  this.options,
@@ -0,0 +1,58 @@
1
+ /** Keeps destructive maintenance outside reads, including paged overflow readers. */
2
+ export class DuckLakeReadGate {
3
+ private active = 0
4
+ private maintenance = false
5
+ private readonly waiters = new Set<() => void>()
6
+
7
+ async acquire(signal: AbortSignal): Promise<() => void> {
8
+ while (true) {
9
+ signal.throwIfAborted()
10
+ if (!this.maintenance) {
11
+ this.active++
12
+ let released = false
13
+ return () => {
14
+ if (released) return
15
+ released = true
16
+ this.active--
17
+ this.wake()
18
+ }
19
+ }
20
+ await this.wait(signal)
21
+ }
22
+ }
23
+
24
+ async withMaintenance<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> {
25
+ // Waiting maintenance must let an active pipeline open the rest of its inputs. Only block
26
+ // new reads once maintenance can actually start; continuous reads may defer it indefinitely.
27
+ while (this.active > 0 || this.maintenance) await this.wait(signal)
28
+ signal.throwIfAborted()
29
+ this.maintenance = true
30
+ try {
31
+ return await run()
32
+ } finally {
33
+ this.maintenance = false
34
+ this.wake()
35
+ }
36
+ }
37
+
38
+ private wait(signal: AbortSignal): Promise<void> {
39
+ return new Promise((resolve, reject) => {
40
+ const wake = () => {
41
+ signal.removeEventListener("abort", abort)
42
+ this.waiters.delete(wake)
43
+ resolve()
44
+ }
45
+ const abort = () => {
46
+ this.waiters.delete(wake)
47
+ reject(signal.reason)
48
+ }
49
+ signal.throwIfAborted()
50
+ this.waiters.add(wake)
51
+ signal.addEventListener("abort", abort, { once: true })
52
+ })
53
+ }
54
+
55
+ private wake(): void {
56
+ for (const wake of this.waiters) wake()
57
+ }
58
+ }