Skip to content

Commit e3fba20

Browse files
committed
Add opt-in test for signal option (#59)
And require error `name` to be `AbortError`, as follow-up for #55 (comment).
1 parent 9816423 commit e3fba20

File tree

4 files changed

+80
-26
lines changed

4 files changed

+80
-26
lines changed

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ db.foo = async function (key) {
568568

569569
The optional `options` object may contain:
570570

571-
- `signal`: an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to abort the deferred operation. When aborted (now or later) the `fn` function will not be called, and the promise returned by `deferAsync()` will be rejected with a [`LEVEL_ABORTED`](#errors) error.
571+
- `signal`: an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to abort the deferred operation. When aborted (now or later) the `fn` function will not be called, and the promise returned by `deferAsync()` will be rejected with a [`LEVEL_ABORTED`](#level_aborted) error.
572572

573573
### `chainedBatch`
574574

@@ -734,7 +734,7 @@ const remaining = iterator.limit - iterator.count
734734

735735
#### Aborting Iterators
736736

737-
Iterators take an experimental `signal` option that, once signaled, aborts an in-progress read operation (if any) and rejects subsequent reads. The relevant promise will be rejected with a [`LEVEL_ABORTED`](#errors) error. Aborting does not close the iterator, because closing is asynchronous and may result in an error that needs a place to go. This means signals should be used together with a pattern that automatically closes the iterator:
737+
Iterators take an experimental `signal` option that, once signaled, aborts an in-progress read operation (if any) and rejects subsequent reads. The relevant promise will be rejected with a [`LEVEL_ABORTED`](#level_aborted) error. Aborting does not close the iterator, because closing is asynchronous and may result in an error that needs a place to go. This means signals should be used together with a pattern that automatically closes the iterator:
738738

739739
```js
740740
const abortController = new AbortController()
@@ -770,6 +770,8 @@ try {
770770
}
771771
```
772772

773+
Support of signals is indicated via [`db.supports.signals.iterators`](https://github.com/Level/supports#signals-object).
774+
773775
### `keyIterator`
774776

775777
A key iterator has the same interface as `iterator` except that its methods yield keys instead of entries. Usage is otherwise the same.
@@ -1212,7 +1214,13 @@ When an operation was made on a chained batch while it was closing or closed, wh
12121214

12131215
#### `LEVEL_ABORTED`
12141216

1215-
When an operation was aborted by the user.
1217+
When an operation was aborted by the user. For [web compatibility](https://dom.spec.whatwg.org/#aborting-ongoing-activities) this error can also be identified by its `name` which is `'AbortError'`:
1218+
1219+
```js
1220+
if (err.name === 'AbortError') {
1221+
// Operation was aborted
1222+
}
1223+
```
12161224

12171225
#### `LEVEL_ENCODING_NOT_FOUND`
12181226

@@ -1617,13 +1625,13 @@ class ExampleSublevel extends AbstractSublevel {
16171625

16181626
The first argument to this constructor must be an instance of the relevant `AbstractLevel` implementation. The constructor will set `iterator.db` which is used (among other things) to access encodings and ensures that `db` will not be garbage collected in case there are no other references to it. The `options` argument must be the original `options` object that was passed to `db._iterator()` and it is therefore not (publicly) possible to create an iterator via constructors alone.
16191627

1620-
The `signal` option, if any and once signaled, should abort an in-progress `_next()`, `_nextv()` or `_all()` call and reject the promise returned by that call with a [`LEVEL_ABORTED`](#errors) error. Doing so is optional until a future semver-major release. Responsibilities are divided as follows:
1628+
The `signal` option, if any and once signaled, should abort an in-progress `_next()`, `_nextv()` or `_all()` call and reject the promise returned by that call with a [`LEVEL_ABORTED`](#level_aborted) error. Doing so is optional until a future semver-major release. Responsibilities are divided as follows:
16211629

16221630
1. Before a database has finished opening, `abstract-level` handles the signal
16231631
2. While a call is in progress, the implementation handles the signal
16241632
3. Once the signal is aborted, `abstract-level` rejects further calls.
16251633

1626-
A method like `_next()` therefore doesn't have to check the signal _before_ it start its asynchronous work, only _during_ that work. Whether to respect the signal and on which (potentially long-running) methods, is up to the implementation.
1634+
A method like `_next()` therefore doesn't have to check the signal _before_ it start its asynchronous work, only _during_ that work. If supported, set `db.supports.signals.iterators` to `true` (via the manifest passed to the database constructor) which also enables relevant tests in the [test suite](#test-suite).
16271635

16281636
#### `iterator._next()`
16291637

lib/errors.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@ class AbortError extends ModuleError {
1010
})
1111
}
1212

13-
// TODO: we should set name to AbortError for web compatibility. See:
13+
// Set name to AbortError for web compatibility. See:
1414
// https://dom.spec.whatwg.org/#aborting-ongoing-activities
1515
// https://github.com/nodejs/node/pull/35911#discussion_r515779306
16-
//
17-
// But I'm not sure we can do the same for errors created by a Node-API addon (like
18-
// classic-level) so for now this behavior is undocumented and folks should use the
19-
// LEVEL_ABORTED code instead.
2016
get name () {
2117
return 'AbortError'
2218
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"dependencies": {
2828
"buffer": "^6.0.3",
2929
"is-buffer": "^2.0.5",
30-
"level-supports": "^5.0.0",
30+
"level-supports": "^6.0.0",
3131
"level-transcoder": "^1.0.1",
3232
"maybe-combine-errors": "^1.0.0",
3333
"module-error": "^1.0.1"

test/iterator-test.js

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -133,26 +133,76 @@ exports.sequence = function (test, testCommon) {
133133
})
134134
}
135135

136-
// At the moment, we can only be sure that signals are supported if the iterator is deferred
137-
globalThis.AbortController && test(`${mode}().${method}() with aborted signal yields error`, async function (t) {
138-
t.plan(2)
136+
// 1) At the moment, we can only be sure that signals are supported if the iterator is deferred
137+
if (globalThis.AbortController) {
138+
test(`${mode}().${method}() with aborted signal yields error (deferred)`, async function (t) {
139+
t.plan(3)
139140

140-
const db = testCommon.factory()
141-
const ac = new globalThis.AbortController()
142-
const it = db[mode]({ signal: ac.signal })
141+
const db = testCommon.factory()
142+
const ac = new globalThis.AbortController()
143+
const it = db[mode]({ signal: ac.signal })
143144

144-
t.is(db.status, 'opening', 'is deferred')
145-
ac.abort()
145+
t.is(db.status, 'opening', 'is deferred')
146+
ac.abort()
146147

147-
try {
148+
try {
149+
await it[method](...requiredArgs)
150+
} catch (err) {
151+
t.is(err.code, 'LEVEL_ABORTED')
152+
t.is(err.name, 'AbortError')
153+
}
154+
155+
await it.close()
156+
return db.close()
157+
})
158+
}
159+
160+
// 2) Unless the implementation opts-in
161+
if (globalThis.AbortController && testCommon.supports.signals && testCommon.supports.signals.iterators) {
162+
test(`${mode}().${method}() with signal yields error when aborted`, async function (t) {
163+
t.plan(2)
164+
165+
const db = testCommon.factory()
166+
167+
await db.open()
168+
await db.batch().put('a', 'a').put('b', 'b').write()
169+
170+
const ac = new globalThis.AbortController()
171+
const it = db[mode]({ signal: ac.signal })
172+
const promise = it[method](...requiredArgs)
173+
174+
ac.abort()
175+
176+
try {
177+
await promise
178+
} catch (err) {
179+
t.is(err.code, 'LEVEL_ABORTED')
180+
t.is(err.name, 'AbortError')
181+
}
182+
183+
await it.close()
184+
return db.close()
185+
})
186+
187+
test(`${mode}().${method}() with non-aborted signal`, async function (t) {
188+
const db = testCommon.factory()
189+
190+
await db.open()
191+
await db.batch().put('a', 'a').put('b', 'b').write()
192+
193+
const ac = new globalThis.AbortController()
194+
const it = db[mode]({ signal: ac.signal })
195+
196+
// We're merely testing that this does not throw. And implicitly testing (through
197+
// coverage) that abort listeners are removed. An implementation might choose to
198+
// periodically check signal.aborted instead of using an abort listener, so we
199+
// can't directly assert that cleanup indeed happens.
148200
await it[method](...requiredArgs)
149-
} catch (err) {
150-
t.is(err.code, 'LEVEL_ABORTED')
151-
}
201+
await it.close()
152202

153-
await it.close()
154-
return db.close()
155-
})
203+
return db.close()
204+
})
205+
}
156206
}
157207
}
158208
}

0 commit comments

Comments
 (0)