nedb-engine 2.8.4 → 2.8.6

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/README.md CHANGED
@@ -20,6 +20,83 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
20
20
 
21
21
  ---
22
22
 
23
+ ## ⚠️ New in 2.8.6 — Durability & Recovery (read this if you store anything you care about)
24
+
25
+ Three defects found by killing a real engine at every persistence boundary and by filling a real
26
+ filesystem to zero free blocks. All three are fixed. **If you are on 2.8.5 or earlier, upgrade.**
27
+
28
+ ### 1. A failed flush silently discarded acknowledged writes
29
+
30
+ `IdIndex::flush_write_buf` cleared every buffered entry regardless of whether its disk write
31
+ succeeded. So a flush that hit `ENOSPC` threw the entry away, and no later flush retried it.
32
+
33
+ Reproduced on a full 22 MiB filesystem: **30 rows acknowledged by `put() -> Ok`, then `list()`
34
+ returned 0 after reopen — while `verify()` reported all 30 objects healthy.** The content-addressed
35
+ objects were durable; the id-index entries that make them findable were gone.
36
+
37
+ ```
38
+ before: try_flush_all() -> (no return value) reopen -> 0 rows, verify() = 30 ok
39
+ after: try_flush_all() -> Err("id-index leaf rows/buf_25: No space left on device (os error 28)")
40
+ ...free space, retry -> Ok reopen -> 30 rows
41
+ ```
42
+
43
+ **Fixed:** an entry leaves the WAL only when its write actually landed. Failures stay buffered and
44
+ retry on the next flush.
45
+
46
+ ### 2. Flush errors were unobservable — new `try_flush_all()`
47
+
48
+ `flush_all()` returns `()` and logged fsync failures to stderr, so a caller could not tell a durable
49
+ flush from a failed one. Anything that takes a destructive or externally-visible action on the
50
+ strength of a persisted record needs to know.
51
+
52
+ ```rust
53
+ // Use this when the outcome matters:
54
+ db.try_flush_all()?; // Result<()> — id-index WAL + segment sync + MANIFEST
55
+
56
+ // Still available, still logs, nowhere to propagate (ticker / Drop):
57
+ db.flush_all();
58
+ ```
59
+
60
+ Also new: `Db::try_flush_manifest()` and `IdIndex::try_flush_write_buf()`.
61
+
62
+ ### 3. `repair` could not repair, and `since()` claimed "caught up" while behind
63
+
64
+ The cold scan rebuilt `seq_index`, per-collection tips, the Merkle head and `MANIFEST` — but **never
65
+ the id index**. A database whose WAL never reached disk came back with every object verifying and
66
+ `list()` empty, and `nedb-cli repair` printed success without fixing it, because
67
+ `start_cold_scan()` is a deliberate no-op on a warm store.
68
+
69
+ ```bash
70
+ nedb-cli repair ./data
71
+ # repaired: 203 id-index entr(ies) rebuilt, 203 node(s) verified, flushed
72
+ ```
73
+
74
+ ```rust
75
+ let restored = db.repair()?; // rebuild id index from objects; highest seq wins
76
+ ```
77
+
78
+ Every object carries its own `coll`, `id` and `seq`, so the id index is fully derivable — a lost WAL
79
+ is recoverable and nothing is invented. `repair()` also recomputes head and tips, so a repaired
80
+ database reopens **warm** instead of coming back up cold with an empty head.
81
+
82
+ Separately, `since()` set `has_more = hit_limit` alone. On a warm boot the seq index is empty **by
83
+ design** (that is why warm start is O(1)), so every lookup missed and `since()` returned zero nodes
84
+ with `has_more = false` — indistinguishable from genuinely up to date. A consumer following the
85
+ documented drain loop stopped one call in, with every record unread.
86
+
87
+ **Fixed:** `has_more` is true whenever the cursor is behind the log head. `ScanStatus` gains
88
+ **`seq_index_ready`** — replication consumers should gate on that, not on `scan_complete`, which is
89
+ true on a warm boot precisely because the scan was skipped.
90
+
91
+ ### Known sharp edge (documented, not changed)
92
+
93
+ `since()`'s cursor is **exclusive** and seqs start at 0, so `since(0, _)` returns `(0, head]` and the
94
+ very first write in a database (seq 0) is unreachable through any cursor value. Ten writes drain as
95
+ nine records. Changing the convention would break existing consumers; a replica seeded from
96
+ `since()` alone starts one record short.
97
+
98
+ ---
99
+
23
100
  ## NEDB v2.8.0 — Production Stable
24
101
 
25
102
  **Current stable: 2.8.0** — NEDB ships as **three version-aligned distributions** on one tag — `nedb-engine` (flagship), `crypto-database` (verifiable v2/v3 DAG), and `aof-db` (fast append-only) — across npm / PyPI / crates.io with full mac + linux + windows native addons (see [**Releasing**](#releasing) below). All native wheels (Linux + Windows on GitHub Actions; macOS arm64 + x86_64 on Codemagic M2 Mac Minis) **plus** the universal pure-Python wheel ship from a single `v*` tag, with the `nedbd-v2` binary bundled inside `pip install nedb-engine`.
@@ -47,6 +124,7 @@ Off by default — feature-gated at compile time, flag-gated at runtime, and `ex
47
124
  **New in 2.5.x:**
48
125
 
49
126
  - **Durable-mode auto-flush-on-exit** — a durable store flushes buffered writes on `Ctrl+C` / `SIGTERM`, not just on a clean `Drop`. Automatic in the Node and Python bindings; `Db::install_exit_flush(Arc<Db>)` for standalone Rust binaries. See [**docs/DURABILITY.md**](docs/DURABILITY.md).
127
+ - **2.8.5 — embedded bindings flush on a cadence.** `NedbCore.open()` (Node + Python) now runs the 1 s manifest ticker exactly as `nedbd` does, so a `SIGKILL` / OOM / power cut loses at most one tick of acknowledged writes instead of everything since open. `NEDB_FLUSH_MS` tunes or disables it. See [docs/DURABILITY.md](docs/DURABILITY.md).
50
128
  - **`nedb-cli`** — operate on a store directory offline (`head` · `status` · `verify` · `get` · `scan` · `flush` · `repair` · `export`), and **`nedb-inspector`** — a deterministic (no-regex, no-LLM) checker that warns when a durable open lacks flush-on-exit wiring. See [**docs/CLI.md**](docs/CLI.md).
51
129
  - **Replication contract** — `tip()` (the latest write), a bounded `since()` changefeed, and a `scan_status()` readiness gate. See [**docs/REPLICATION.md**](docs/REPLICATION.md).
52
130
 
package/index.d.ts CHANGED
@@ -1,64 +1,5 @@
1
- /* tslint:disable */
2
- /* eslint-disable */
3
-
4
- /* auto-generated by NAPI-RS */
5
-
6
- export declare class NedbCore {
7
- /** Create an in-memory v2 DAG database — zero disk I/O. */
8
- constructor()
9
- /**
10
- * Open a durable v2 DAG database at `path`.
11
- * Automatically migrates v1 AOF → v2 DAG on first open.
12
- *
13
- * Durable-mode auto-flush-on-exit is wired in the JS wrapper via
14
- * `process.on('SIGTERM'|'SIGINT'|'beforeExit', () => db.flush())` — the
15
- * libuv-cooperative hook — NOT a C-level signal handler here, which would
16
- * clobber libuv's own signal machinery.
17
- */
18
- static open(path: string): NedbCore
19
- createIndex(coll: string, field: string, kind: string): void
20
- /** Put a document. Returns the stored doc as a JSON string. */
21
- put(coll: string, id: string, docJson: string): string
22
- /** Full put with optional client / nonce — API compat, v2 ignores these. */
23
- putEx(coll: string, id: string, docJson: string, client?: string | undefined | null, nonce?: bigint | undefined | null, idem?: string | undefined | null): string
24
- delete(coll: string, id: string): void
25
- deleteEx(coll: string, id: string, client?: string | undefined | null, nonce?: bigint | undefined | null, idem?: string | undefined | null): void
26
- /** Link: stored as a doc in __links__ collection for NQL traversal. */
27
- link(frm: string, rel: string, to: string): void
28
- unlink(frm: string, rel: string, to: string): void
29
- get(coll: string, id: string): string | null
30
- getAsOf(coll: string, id: string, asOf: bigint): string | null
31
- query(nqlStr: string): Array<string>
32
- neighbors(frm: string, rel: string): Array<string>
33
- neighborsAsOf(frm: string, rel: string, asOf: bigint): Array<string>
34
- inbound(to: string, rel: string): Array<string>
35
- inboundAsOf(to: string, rel: string, asOf: bigint): Array<string>
36
- verify(): boolean
37
- head(): string
38
- seq(): bigint
39
- /** Flush WAL and MANIFEST — v2 equivalent of v1 flush(). */
40
- flush(): void
41
- /**
42
- * The tip — the most recent write (latest node) as a JSON string, or null if
43
- * the database is empty. The cheap "give me the latest write" primitive.
44
- */
45
- tip(): string | null
46
- /**
47
- * Collection-local tip — the most recent write into `coll` as a JSON string,
48
- * or null if the collection has no writes. Resume one chain without filtering.
49
- */
50
- tipCollection(coll: string): string | null
51
- /**
52
- * Changefeed page after `after_seq` (exclusive), up to `limit` nodes (0 = the
53
- * engine default cap), as a JSON envelope string:
54
- * `{nodes, from_seq, to_seq, head_seq, has_more}`. Page while `has_more`,
55
- * advancing your cursor to `to_seq`, then attach to the live subscribe edge.
56
- */
57
- since(afterSeq: bigint, limit: number): string
58
- /**
59
- * Replication readiness as a JSON string: `{scan_complete, tip_seq,
60
- * indexed_seq_min, indexed_seq_max, indexed_count}`. Wait for
61
- * `scan_complete == true` before trusting historical `since()` catch-up.
62
- */
63
- scanStatus(): string
64
- }
1
+ // nedb-engine — public type surface.
2
+ //
3
+ // Runtime behavior (durable-mode auto-flush-on-exit) is added by the wrapper in
4
+ // index.js; the type surface is exactly the generated native binding's.
5
+ export * from './native';
package/index.js CHANGED
@@ -1,315 +1,81 @@
1
- /* tslint:disable */
2
- /* eslint-disable */
3
- /* prettier-ignore */
1
+ 'use strict';
2
+ // nedb-engine — durable-mode auto-flush-on-exit wrapper.
3
+ //
4
+ // The native addon (generated napi binding in ./native.js) exposes `NedbCore`.
5
+ // A durable `NedbCore.open(path)` buffers writes in the engine's id-index WAL and
6
+ // only makes them durable on `flush()`; a hard exit (Ctrl+C, `SIGTERM` from an
7
+ // orchestrator, `pm2 stop`) that never runs an explicit flush would lose writes
8
+ // staged since the last flush.
9
+ //
10
+ // We close that gap the libuv-cooperative way — `process.on('SIGINT'|'SIGTERM'
11
+ // |'exit', () => db.flush())` — NOT a C-level signal handler inside the addon,
12
+ // which would clobber libuv's own signal machinery. In-memory databases
13
+ // (`new NedbCore()`) are never armed; there is nothing to flush.
14
+ //
15
+ // Escape hatch: set NEDB_NO_EXIT_FLUSH=1 to leave signal handling entirely to
16
+ // the host app (it can still call `db.flush()` itself).
17
+ //
18
+ // © INTERCHAINED LLC × Claude Opus 4.8
4
19
 
5
- /* auto-generated by NAPI-RS */
20
+ const native = require('./native.js');
6
21
 
7
- const { existsSync, readFileSync } = require('fs')
8
- const { join } = require('path')
22
+ const Native = native.NedbCore;
9
23
 
10
- const { platform, arch } = process
24
+ // The napi class defines `open` as a NON-writable, NON-configurable static, so the
25
+ // 2.5.x wrapper's `NedbCore.open = …` threw ("Cannot assign to read only property")
26
+ // — and CI's `napi build` was overwriting this file with the generated loader
27
+ // anyway, so the published package never carried the wrapper at all. Both fixed
28
+ // in 2.8.5: wrap by SUBCLASS (an own static on the subclass shadows the parent's),
29
+ // build with `--js native.js` so this file survives, and gate the publish on
30
+ // `NedbCore.__exitFlushWrapped` (see test/durability.test.mjs).
31
+ let NedbCore = Native;
32
+ if (Native && typeof Native.open === 'function' && !Native.__exitFlushWrapped) {
33
+ // Durable handles opened in this process. Strong refs: a durable DB is meant to
34
+ // live for the process, and we must be able to flush it on the way out.
35
+ const live = new Set();
36
+ let armed = false;
11
37
 
12
- let nativeBinding = null
13
- let localFileExisted = false
14
- let loadError = null
15
-
16
- function isMusl() {
17
- // For Node 10
18
- if (!process.report || typeof process.report.getReport !== 'function') {
19
- try {
20
- const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
- return readFileSync(lddPath, 'utf8').includes('musl')
22
- } catch (e) {
23
- return true
24
- }
25
- } else {
26
- const { glibcVersionRuntime } = process.report.getReport().header
27
- return !glibcVersionRuntime
28
- }
29
- }
30
-
31
- switch (platform) {
32
- case 'android':
33
- switch (arch) {
34
- case 'arm64':
35
- localFileExisted = existsSync(join(__dirname, 'nedb.android-arm64.node'))
36
- try {
37
- if (localFileExisted) {
38
- nativeBinding = require('./nedb.android-arm64.node')
39
- } else {
40
- nativeBinding = require('nedb-engine-android-arm64')
41
- }
42
- } catch (e) {
43
- loadError = e
44
- }
45
- break
46
- case 'arm':
47
- localFileExisted = existsSync(join(__dirname, 'nedb.android-arm-eabi.node'))
48
- try {
49
- if (localFileExisted) {
50
- nativeBinding = require('./nedb.android-arm-eabi.node')
51
- } else {
52
- nativeBinding = require('nedb-engine-android-arm-eabi')
53
- }
54
- } catch (e) {
55
- loadError = e
56
- }
57
- break
58
- default:
59
- throw new Error(`Unsupported architecture on Android ${arch}`)
60
- }
61
- break
62
- case 'win32':
63
- switch (arch) {
64
- case 'x64':
65
- localFileExisted = existsSync(
66
- join(__dirname, 'nedb.win32-x64-msvc.node')
67
- )
68
- try {
69
- if (localFileExisted) {
70
- nativeBinding = require('./nedb.win32-x64-msvc.node')
71
- } else {
72
- nativeBinding = require('nedb-engine-win32-x64-msvc')
73
- }
74
- } catch (e) {
75
- loadError = e
76
- }
77
- break
78
- case 'ia32':
79
- localFileExisted = existsSync(
80
- join(__dirname, 'nedb.win32-ia32-msvc.node')
81
- )
82
- try {
83
- if (localFileExisted) {
84
- nativeBinding = require('./nedb.win32-ia32-msvc.node')
85
- } else {
86
- nativeBinding = require('nedb-engine-win32-ia32-msvc')
87
- }
88
- } catch (e) {
89
- loadError = e
90
- }
91
- break
92
- case 'arm64':
93
- localFileExisted = existsSync(
94
- join(__dirname, 'nedb.win32-arm64-msvc.node')
95
- )
96
- try {
97
- if (localFileExisted) {
98
- nativeBinding = require('./nedb.win32-arm64-msvc.node')
99
- } else {
100
- nativeBinding = require('nedb-engine-win32-arm64-msvc')
101
- }
102
- } catch (e) {
103
- loadError = e
104
- }
105
- break
106
- default:
107
- throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
- }
109
- break
110
- case 'darwin':
111
- localFileExisted = existsSync(join(__dirname, 'nedb.darwin-universal.node'))
112
- try {
113
- if (localFileExisted) {
114
- nativeBinding = require('./nedb.darwin-universal.node')
115
- } else {
116
- nativeBinding = require('nedb-engine-darwin-universal')
117
- }
118
- break
119
- } catch {}
120
- switch (arch) {
121
- case 'x64':
122
- localFileExisted = existsSync(join(__dirname, 'nedb.darwin-x64.node'))
123
- try {
124
- if (localFileExisted) {
125
- nativeBinding = require('./nedb.darwin-x64.node')
126
- } else {
127
- nativeBinding = require('nedb-engine-darwin-x64')
128
- }
129
- } catch (e) {
130
- loadError = e
131
- }
132
- break
133
- case 'arm64':
134
- localFileExisted = existsSync(
135
- join(__dirname, 'nedb.darwin-arm64.node')
136
- )
137
- try {
138
- if (localFileExisted) {
139
- nativeBinding = require('./nedb.darwin-arm64.node')
140
- } else {
141
- nativeBinding = require('nedb-engine-darwin-arm64')
142
- }
143
- } catch (e) {
144
- loadError = e
145
- }
146
- break
147
- default:
148
- throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
- }
150
- break
151
- case 'freebsd':
152
- if (arch !== 'x64') {
153
- throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
- }
155
- localFileExisted = existsSync(join(__dirname, 'nedb.freebsd-x64.node'))
156
- try {
157
- if (localFileExisted) {
158
- nativeBinding = require('./nedb.freebsd-x64.node')
159
- } else {
160
- nativeBinding = require('nedb-engine-freebsd-x64')
38
+ const flushAll = () => {
39
+ for (const db of live) {
40
+ try {
41
+ db.flush();
42
+ } catch (_) {
43
+ // Best-effort on shutdown — never throw out of an exit handler.
161
44
  }
162
- } catch (e) {
163
- loadError = e
164
45
  }
165
- break
166
- case 'linux':
167
- switch (arch) {
168
- case 'x64':
169
- if (isMusl()) {
170
- localFileExisted = existsSync(
171
- join(__dirname, 'nedb.linux-x64-musl.node')
172
- )
173
- try {
174
- if (localFileExisted) {
175
- nativeBinding = require('./nedb.linux-x64-musl.node')
176
- } else {
177
- nativeBinding = require('nedb-engine-linux-x64-musl')
178
- }
179
- } catch (e) {
180
- loadError = e
181
- }
182
- } else {
183
- localFileExisted = existsSync(
184
- join(__dirname, 'nedb.linux-x64-gnu.node')
185
- )
186
- try {
187
- if (localFileExisted) {
188
- nativeBinding = require('./nedb.linux-x64-gnu.node')
189
- } else {
190
- nativeBinding = require('nedb-engine-linux-x64-gnu')
191
- }
192
- } catch (e) {
193
- loadError = e
194
- }
195
- }
196
- break
197
- case 'arm64':
198
- if (isMusl()) {
199
- localFileExisted = existsSync(
200
- join(__dirname, 'nedb.linux-arm64-musl.node')
201
- )
202
- try {
203
- if (localFileExisted) {
204
- nativeBinding = require('./nedb.linux-arm64-musl.node')
205
- } else {
206
- nativeBinding = require('nedb-engine-linux-arm64-musl')
207
- }
208
- } catch (e) {
209
- loadError = e
210
- }
211
- } else {
212
- localFileExisted = existsSync(
213
- join(__dirname, 'nedb.linux-arm64-gnu.node')
214
- )
215
- try {
216
- if (localFileExisted) {
217
- nativeBinding = require('./nedb.linux-arm64-gnu.node')
218
- } else {
219
- nativeBinding = require('nedb-engine-linux-arm64-gnu')
220
- }
221
- } catch (e) {
222
- loadError = e
223
- }
224
- }
225
- break
226
- case 'arm':
227
- if (isMusl()) {
228
- localFileExisted = existsSync(
229
- join(__dirname, 'nedb.linux-arm-musleabihf.node')
230
- )
231
- try {
232
- if (localFileExisted) {
233
- nativeBinding = require('./nedb.linux-arm-musleabihf.node')
234
- } else {
235
- nativeBinding = require('nedb-engine-linux-arm-musleabihf')
236
- }
237
- } catch (e) {
238
- loadError = e
239
- }
240
- } else {
241
- localFileExisted = existsSync(
242
- join(__dirname, 'nedb.linux-arm-gnueabihf.node')
243
- )
244
- try {
245
- if (localFileExisted) {
246
- nativeBinding = require('./nedb.linux-arm-gnueabihf.node')
247
- } else {
248
- nativeBinding = require('nedb-engine-linux-arm-gnueabihf')
249
- }
250
- } catch (e) {
251
- loadError = e
252
- }
253
- }
254
- break
255
- case 'riscv64':
256
- if (isMusl()) {
257
- localFileExisted = existsSync(
258
- join(__dirname, 'nedb.linux-riscv64-musl.node')
259
- )
260
- try {
261
- if (localFileExisted) {
262
- nativeBinding = require('./nedb.linux-riscv64-musl.node')
263
- } else {
264
- nativeBinding = require('nedb-engine-linux-riscv64-musl')
265
- }
266
- } catch (e) {
267
- loadError = e
268
- }
269
- } else {
270
- localFileExisted = existsSync(
271
- join(__dirname, 'nedb.linux-riscv64-gnu.node')
272
- )
273
- try {
274
- if (localFileExisted) {
275
- nativeBinding = require('./nedb.linux-riscv64-gnu.node')
276
- } else {
277
- nativeBinding = require('nedb-engine-linux-riscv64-gnu')
278
- }
279
- } catch (e) {
280
- loadError = e
281
- }
282
- }
283
- break
284
- case 's390x':
285
- localFileExisted = existsSync(
286
- join(__dirname, 'nedb.linux-s390x-gnu.node')
287
- )
288
- try {
289
- if (localFileExisted) {
290
- nativeBinding = require('./nedb.linux-s390x-gnu.node')
291
- } else {
292
- nativeBinding = require('nedb-engine-linux-s390x-gnu')
293
- }
294
- } catch (e) {
295
- loadError = e
296
- }
297
- break
298
- default:
299
- throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
- }
301
- break
302
- default:
303
- throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
- }
46
+ };
305
47
 
306
- if (!nativeBinding) {
307
- if (loadError) {
308
- throw loadError
309
- }
310
- throw new Error(`Failed to load native binding`)
311
- }
48
+ const arm = () => {
49
+ if (armed || process.env.NEDB_NO_EXIT_FLUSH) return;
50
+ armed = true;
51
+ // 'exit' fires on normal termination; handlers must be synchronous, and
52
+ // db.flush() is a synchronous native call — so this is safe and sufficient
53
+ // for clean exits and uncaught-exception exits.
54
+ process.on('exit', flushAll);
55
+ // Registering a SIGINT/SIGTERM listener SUPPRESSES Node's default
56
+ // termination, so once we listen we own the exit: flush, then terminate with
57
+ // the conventional 128+signum status.
58
+ const onSignal = (signum) => () => {
59
+ flushAll();
60
+ process.exit(128 + signum);
61
+ };
62
+ process.on('SIGINT', onSignal(2));
63
+ process.on('SIGTERM', onSignal(15));
64
+ };
312
65
 
313
- const { NedbCore } = nativeBinding
66
+ NedbCore = class NedbCore extends Native {
67
+ static open(path) {
68
+ const db = Native.open(path);
69
+ live.add(db);
70
+ arm();
71
+ return db;
72
+ }
73
+ };
74
+ // Mark so a re-require (or a wrapped re-export) never double-wraps.
75
+ Object.defineProperty(NedbCore, '__exitFlushWrapped', { value: true, enumerable: false });
76
+ }
314
77
 
315
- module.exports.NedbCore = NedbCore
78
+ module.exports = { ...native, NedbCore };
79
+ // Explicit named re-export so ESM `import { NedbCore } from 'nedb-engine'` (used
80
+ // by the test suite) resolves the class through cjs-module-lexer.
81
+ module.exports.NedbCore = NedbCore;
package/native.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ export declare class NedbCore {
7
+ /** Create an in-memory v2 DAG database — zero disk I/O. */
8
+ constructor()
9
+ /**
10
+ * Open a durable v2 DAG database at `path`.
11
+ * Automatically migrates v1 AOF → v2 DAG on first open.
12
+ *
13
+ * Durable-mode auto-flush-on-exit is wired in the JS wrapper via
14
+ * `process.on('SIGTERM'|'SIGINT'|'beforeExit', () => db.flush())` — the
15
+ * libuv-cooperative hook — NOT a C-level signal handler here, which would
16
+ * clobber libuv's own signal machinery.
17
+ */
18
+ static open(path: string): NedbCore
19
+ createIndex(coll: string, field: string, kind: string): void
20
+ /** Put a document. Returns the stored doc as a JSON string. */
21
+ put(coll: string, id: string, docJson: string): string
22
+ /** Full put with optional client / nonce — API compat, v2 ignores these. */
23
+ putEx(coll: string, id: string, docJson: string, client?: string | undefined | null, nonce?: bigint | undefined | null, idem?: string | undefined | null): string
24
+ delete(coll: string, id: string): void
25
+ deleteEx(coll: string, id: string, client?: string | undefined | null, nonce?: bigint | undefined | null, idem?: string | undefined | null): void
26
+ /** Link: stored as a doc in __links__ collection for NQL traversal. */
27
+ link(frm: string, rel: string, to: string): void
28
+ unlink(frm: string, rel: string, to: string): void
29
+ get(coll: string, id: string): string | null
30
+ getAsOf(coll: string, id: string, asOf: bigint): string | null
31
+ query(nqlStr: string): Array<string>
32
+ neighbors(frm: string, rel: string): Array<string>
33
+ neighborsAsOf(frm: string, rel: string, asOf: bigint): Array<string>
34
+ inbound(to: string, rel: string): Array<string>
35
+ inboundAsOf(to: string, rel: string, asOf: bigint): Array<string>
36
+ verify(): boolean
37
+ head(): string
38
+ seq(): bigint
39
+ /** Flush WAL and MANIFEST — v2 equivalent of v1 flush(). */
40
+ flush(): void
41
+ /**
42
+ * The tip — the most recent write (latest node) as a JSON string, or null if
43
+ * the database is empty. The cheap "give me the latest write" primitive.
44
+ */
45
+ tip(): string | null
46
+ /**
47
+ * Collection-local tip — the most recent write into `coll` as a JSON string,
48
+ * or null if the collection has no writes. Resume one chain without filtering.
49
+ */
50
+ tipCollection(coll: string): string | null
51
+ /**
52
+ * Changefeed page after `after_seq` (exclusive), up to `limit` nodes (0 = the
53
+ * engine default cap), as a JSON envelope string:
54
+ * `{nodes, from_seq, to_seq, head_seq, has_more}`. Page while `has_more`,
55
+ * advancing your cursor to `to_seq`, then attach to the live subscribe edge.
56
+ */
57
+ since(afterSeq: bigint, limit: number): string
58
+ /**
59
+ * Replication readiness as a JSON string: `{scan_complete, tip_seq,
60
+ * indexed_seq_min, indexed_seq_max, indexed_count}`. Wait for
61
+ * `scan_complete == true` before trusting historical `since()` catch-up.
62
+ */
63
+ scanStatus(): string
64
+ }
package/native.js ADDED
@@ -0,0 +1,315 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* auto-generated by NAPI-RS */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ // For Node 10
18
+ if (!process.report || typeof process.report.getReport !== 'function') {
19
+ try {
20
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
+ return readFileSync(lddPath, 'utf8').includes('musl')
22
+ } catch (e) {
23
+ return true
24
+ }
25
+ } else {
26
+ const { glibcVersionRuntime } = process.report.getReport().header
27
+ return !glibcVersionRuntime
28
+ }
29
+ }
30
+
31
+ switch (platform) {
32
+ case 'android':
33
+ switch (arch) {
34
+ case 'arm64':
35
+ localFileExisted = existsSync(join(__dirname, 'nedb.android-arm64.node'))
36
+ try {
37
+ if (localFileExisted) {
38
+ nativeBinding = require('./nedb.android-arm64.node')
39
+ } else {
40
+ nativeBinding = require('nedb-engine-android-arm64')
41
+ }
42
+ } catch (e) {
43
+ loadError = e
44
+ }
45
+ break
46
+ case 'arm':
47
+ localFileExisted = existsSync(join(__dirname, 'nedb.android-arm-eabi.node'))
48
+ try {
49
+ if (localFileExisted) {
50
+ nativeBinding = require('./nedb.android-arm-eabi.node')
51
+ } else {
52
+ nativeBinding = require('nedb-engine-android-arm-eabi')
53
+ }
54
+ } catch (e) {
55
+ loadError = e
56
+ }
57
+ break
58
+ default:
59
+ throw new Error(`Unsupported architecture on Android ${arch}`)
60
+ }
61
+ break
62
+ case 'win32':
63
+ switch (arch) {
64
+ case 'x64':
65
+ localFileExisted = existsSync(
66
+ join(__dirname, 'nedb.win32-x64-msvc.node')
67
+ )
68
+ try {
69
+ if (localFileExisted) {
70
+ nativeBinding = require('./nedb.win32-x64-msvc.node')
71
+ } else {
72
+ nativeBinding = require('nedb-engine-win32-x64-msvc')
73
+ }
74
+ } catch (e) {
75
+ loadError = e
76
+ }
77
+ break
78
+ case 'ia32':
79
+ localFileExisted = existsSync(
80
+ join(__dirname, 'nedb.win32-ia32-msvc.node')
81
+ )
82
+ try {
83
+ if (localFileExisted) {
84
+ nativeBinding = require('./nedb.win32-ia32-msvc.node')
85
+ } else {
86
+ nativeBinding = require('nedb-engine-win32-ia32-msvc')
87
+ }
88
+ } catch (e) {
89
+ loadError = e
90
+ }
91
+ break
92
+ case 'arm64':
93
+ localFileExisted = existsSync(
94
+ join(__dirname, 'nedb.win32-arm64-msvc.node')
95
+ )
96
+ try {
97
+ if (localFileExisted) {
98
+ nativeBinding = require('./nedb.win32-arm64-msvc.node')
99
+ } else {
100
+ nativeBinding = require('nedb-engine-win32-arm64-msvc')
101
+ }
102
+ } catch (e) {
103
+ loadError = e
104
+ }
105
+ break
106
+ default:
107
+ throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
+ }
109
+ break
110
+ case 'darwin':
111
+ localFileExisted = existsSync(join(__dirname, 'nedb.darwin-universal.node'))
112
+ try {
113
+ if (localFileExisted) {
114
+ nativeBinding = require('./nedb.darwin-universal.node')
115
+ } else {
116
+ nativeBinding = require('nedb-engine-darwin-universal')
117
+ }
118
+ break
119
+ } catch {}
120
+ switch (arch) {
121
+ case 'x64':
122
+ localFileExisted = existsSync(join(__dirname, 'nedb.darwin-x64.node'))
123
+ try {
124
+ if (localFileExisted) {
125
+ nativeBinding = require('./nedb.darwin-x64.node')
126
+ } else {
127
+ nativeBinding = require('nedb-engine-darwin-x64')
128
+ }
129
+ } catch (e) {
130
+ loadError = e
131
+ }
132
+ break
133
+ case 'arm64':
134
+ localFileExisted = existsSync(
135
+ join(__dirname, 'nedb.darwin-arm64.node')
136
+ )
137
+ try {
138
+ if (localFileExisted) {
139
+ nativeBinding = require('./nedb.darwin-arm64.node')
140
+ } else {
141
+ nativeBinding = require('nedb-engine-darwin-arm64')
142
+ }
143
+ } catch (e) {
144
+ loadError = e
145
+ }
146
+ break
147
+ default:
148
+ throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
+ }
150
+ break
151
+ case 'freebsd':
152
+ if (arch !== 'x64') {
153
+ throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
+ }
155
+ localFileExisted = existsSync(join(__dirname, 'nedb.freebsd-x64.node'))
156
+ try {
157
+ if (localFileExisted) {
158
+ nativeBinding = require('./nedb.freebsd-x64.node')
159
+ } else {
160
+ nativeBinding = require('nedb-engine-freebsd-x64')
161
+ }
162
+ } catch (e) {
163
+ loadError = e
164
+ }
165
+ break
166
+ case 'linux':
167
+ switch (arch) {
168
+ case 'x64':
169
+ if (isMusl()) {
170
+ localFileExisted = existsSync(
171
+ join(__dirname, 'nedb.linux-x64-musl.node')
172
+ )
173
+ try {
174
+ if (localFileExisted) {
175
+ nativeBinding = require('./nedb.linux-x64-musl.node')
176
+ } else {
177
+ nativeBinding = require('nedb-engine-linux-x64-musl')
178
+ }
179
+ } catch (e) {
180
+ loadError = e
181
+ }
182
+ } else {
183
+ localFileExisted = existsSync(
184
+ join(__dirname, 'nedb.linux-x64-gnu.node')
185
+ )
186
+ try {
187
+ if (localFileExisted) {
188
+ nativeBinding = require('./nedb.linux-x64-gnu.node')
189
+ } else {
190
+ nativeBinding = require('nedb-engine-linux-x64-gnu')
191
+ }
192
+ } catch (e) {
193
+ loadError = e
194
+ }
195
+ }
196
+ break
197
+ case 'arm64':
198
+ if (isMusl()) {
199
+ localFileExisted = existsSync(
200
+ join(__dirname, 'nedb.linux-arm64-musl.node')
201
+ )
202
+ try {
203
+ if (localFileExisted) {
204
+ nativeBinding = require('./nedb.linux-arm64-musl.node')
205
+ } else {
206
+ nativeBinding = require('nedb-engine-linux-arm64-musl')
207
+ }
208
+ } catch (e) {
209
+ loadError = e
210
+ }
211
+ } else {
212
+ localFileExisted = existsSync(
213
+ join(__dirname, 'nedb.linux-arm64-gnu.node')
214
+ )
215
+ try {
216
+ if (localFileExisted) {
217
+ nativeBinding = require('./nedb.linux-arm64-gnu.node')
218
+ } else {
219
+ nativeBinding = require('nedb-engine-linux-arm64-gnu')
220
+ }
221
+ } catch (e) {
222
+ loadError = e
223
+ }
224
+ }
225
+ break
226
+ case 'arm':
227
+ if (isMusl()) {
228
+ localFileExisted = existsSync(
229
+ join(__dirname, 'nedb.linux-arm-musleabihf.node')
230
+ )
231
+ try {
232
+ if (localFileExisted) {
233
+ nativeBinding = require('./nedb.linux-arm-musleabihf.node')
234
+ } else {
235
+ nativeBinding = require('nedb-engine-linux-arm-musleabihf')
236
+ }
237
+ } catch (e) {
238
+ loadError = e
239
+ }
240
+ } else {
241
+ localFileExisted = existsSync(
242
+ join(__dirname, 'nedb.linux-arm-gnueabihf.node')
243
+ )
244
+ try {
245
+ if (localFileExisted) {
246
+ nativeBinding = require('./nedb.linux-arm-gnueabihf.node')
247
+ } else {
248
+ nativeBinding = require('nedb-engine-linux-arm-gnueabihf')
249
+ }
250
+ } catch (e) {
251
+ loadError = e
252
+ }
253
+ }
254
+ break
255
+ case 'riscv64':
256
+ if (isMusl()) {
257
+ localFileExisted = existsSync(
258
+ join(__dirname, 'nedb.linux-riscv64-musl.node')
259
+ )
260
+ try {
261
+ if (localFileExisted) {
262
+ nativeBinding = require('./nedb.linux-riscv64-musl.node')
263
+ } else {
264
+ nativeBinding = require('nedb-engine-linux-riscv64-musl')
265
+ }
266
+ } catch (e) {
267
+ loadError = e
268
+ }
269
+ } else {
270
+ localFileExisted = existsSync(
271
+ join(__dirname, 'nedb.linux-riscv64-gnu.node')
272
+ )
273
+ try {
274
+ if (localFileExisted) {
275
+ nativeBinding = require('./nedb.linux-riscv64-gnu.node')
276
+ } else {
277
+ nativeBinding = require('nedb-engine-linux-riscv64-gnu')
278
+ }
279
+ } catch (e) {
280
+ loadError = e
281
+ }
282
+ }
283
+ break
284
+ case 's390x':
285
+ localFileExisted = existsSync(
286
+ join(__dirname, 'nedb.linux-s390x-gnu.node')
287
+ )
288
+ try {
289
+ if (localFileExisted) {
290
+ nativeBinding = require('./nedb.linux-s390x-gnu.node')
291
+ } else {
292
+ nativeBinding = require('nedb-engine-linux-s390x-gnu')
293
+ }
294
+ } catch (e) {
295
+ loadError = e
296
+ }
297
+ break
298
+ default:
299
+ throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
+ }
301
+ break
302
+ default:
303
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
+ }
305
+
306
+ if (!nativeBinding) {
307
+ if (loadError) {
308
+ throw loadError
309
+ }
310
+ throw new Error(`Failed to load native binding`)
311
+ }
312
+
313
+ const { NedbCore } = nativeBinding
314
+
315
+ module.exports.NedbCore = NedbCore
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nedb-engine",
3
- "version": "2.8.4",
3
+ "version": "2.8.6",
4
4
  "description": "NEDB — hash-chained, time-traveling, bi-temporal embedded database with Rust native core. SQL, Redis, MongoDB adapters. Causal Write Provenance. RESP2 wire protocol.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -19,6 +19,7 @@
19
19
  "*.node",
20
20
  "nedbd-v2*",
21
21
  "test/smoke.mjs",
22
+ "test/durability.test.mjs",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -59,7 +60,7 @@
59
60
  "scripts": {
60
61
  "build": "napi build --release --platform --cargo-cwd rust/crates/nedb-node --js native.js --dts native.d.ts",
61
62
  "build:debug": "napi build --platform --cargo-cwd rust/crates/nedb-node --js native.js --dts native.d.ts",
62
- "test": "node test/smoke.mjs"
63
+ "test": "node test/smoke.mjs && node test/durability.test.mjs"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@napi-rs/cli": "^2.18.0"
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // nedb-engine — embedded durability under SIGKILL (2.8.5)
3
+ // ---------------------------------------------------------------------------
4
+ // The contract: an acknowledged write is on disk within one manifest tick (default 1 s) or at
5
+ // exit, whichever comes first. This test proves the tick half the only way that means anything:
6
+ // a child process opens a durable db, puts a document, gets the hash back, waits past one tick,
7
+ // and is killed with SIGKILL — no exit hook can run. The parent then opens the same directory
8
+ // and must find the document.
9
+ //
10
+ // Negative control: the same sequence with NEDB_FLUSH_MS=0 (ticker disabled) must LOSE the
11
+ // document — otherwise this test would pass for reasons unrelated to the ticker.
12
+ // Wrapper case: ticker off + graceful SIGTERM must KEEP the document — the exit-flush wrapper's job.
13
+ //
14
+ // © INTERCHAINED LLC
15
+ import os from 'node:os';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawn } from 'node:child_process';
19
+
20
+ let NedbCore, addonSpecifier;
21
+ try { ({ NedbCore } = await import('nedb-engine')); addonSpecifier = 'nedb-engine'; }
22
+ catch { addonSpecifier = new URL('../index.js', import.meta.url).href; ({ NedbCore } = await import(addonSpecifier)); }
23
+
24
+ const CHILD = `
25
+ const { NedbCore } = await import(process.argv[1]); // node -e: argv[1] is the first user arg
26
+ const db = NedbCore.open(process.argv[2]);
27
+ const out = db.put('durability', 'doc-1', JSON.stringify({ written_at: Date.now(), note: 'must survive SIGKILL' }));
28
+ process.stdout.write('PUT ' + JSON.parse(out)._hash + '\\n');
29
+ setInterval(() => {}, 1 << 30); // stay alive until killed
30
+ `;
31
+
32
+ function runChild(dir, env) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn(process.execPath, ['--input-type=module', '-e', CHILD, addonSpecifier, dir], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'inherit'] });
35
+ let buf = '';
36
+ child.stdout.on('data', (d) => { buf += d; const m = buf.match(/PUT ([0-9a-f]+)/); if (m) resolve({ child, hash: m[1] }); });
37
+ child.on('exit', (code, sig) => reject(new Error(`child exited early (${code ?? sig}) — ${buf}`)));
38
+ setTimeout(() => reject(new Error('child never acknowledged the put')), 15000).unref();
39
+ });
40
+ }
41
+
42
+ const tmp = (l) => fs.mkdtempSync(path.join(os.tmpdir(), `nedb-dur-${l}-`));
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ const readBack = (dir) => { const db = NedbCore.open(dir); const raw = db.get('durability', 'doc-1'); return raw ? JSON.parse(raw) : null; };
45
+ let failed = 0;
46
+ const check = (ok, msg) => { console.log(` ${ok ? '✓' : '✗'} ${msg}`); if (!ok) failed++; };
47
+
48
+ console.log('\n N E D B · embedded durability under SIGKILL');
49
+
50
+ // ── positive: default ticker (1000 ms) ─────────────────────────────────────
51
+ {
52
+ const dir = tmp('tick');
53
+ const { child, hash } = await runChild(dir, { NEDB_FLUSH_MS: '1000' });
54
+ console.log(` → child acknowledged put ${hash.slice(0, 12)}…; waiting 2.5 s (two ticks) then SIGKILL`);
55
+ await sleep(2500);
56
+ child.kill('SIGKILL'); await new Promise((r) => child.on('exit', r));
57
+ const doc = readBack(dir);
58
+ check(doc !== null && doc._hash === hash, `after SIGKILL + reopen the document is present with the acknowledged hash (${doc ? doc._hash.slice(0, 12) + '…' : 'GONE'})`);
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ }
61
+
62
+ // ── negative control: ticker disabled → the write must be lost ─────────────
63
+ {
64
+ const dir = tmp('notick');
65
+ const { child, hash } = await runChild(dir, { NEDB_FLUSH_MS: '0' });
66
+ console.log(` → control: NEDB_FLUSH_MS=0, put ${hash.slice(0, 12)}…, 2.5 s, SIGKILL`);
67
+ await sleep(2500);
68
+ child.kill('SIGKILL'); await new Promise((r) => child.on('exit', r));
69
+ const doc = readBack(dir);
70
+ check(doc === null, `with the ticker disabled the same sequence loses the write (${doc ? 'unexpectedly present' : 'gone, as the control requires'})`);
71
+ fs.rmSync(dir, { recursive: true, force: true });
72
+ }
73
+
74
+ // ── wrapper: graceful SIGTERM with the ticker OFF → the exit flush must save it ──
75
+ {
76
+ check(NedbCore.__exitFlushWrapped === true, 'index.js is the durable-mode wrapper (not the generated loader)');
77
+ const dir = tmp('term');
78
+ const { child, hash } = await runChild(dir, { NEDB_FLUSH_MS: '0' });
79
+ console.log(` → wrapper: NEDB_FLUSH_MS=0, put ${hash.slice(0, 12)}…, SIGTERM (graceful)`);
80
+ child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r));
81
+ const doc = readBack(dir);
82
+ check(doc !== null && doc._hash === hash, `after SIGTERM the exit-flush wrapper made the write durable (${doc ? 'present' : 'GONE'})`);
83
+ fs.rmSync(dir, { recursive: true, force: true });
84
+ }
85
+
86
+ if (failed) { console.log(`\n ${failed} check(s) failed`); process.exit(1); }
87
+ console.log('\n durability contract holds: on disk within one tick, or at exit.\n');