nedb-engine 2.4.1 → 2.4.2

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,13 +20,13 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
20
20
 
21
21
  ---
22
22
 
23
- ## NEDB v2.4.1 — Production Stable
23
+ ## NEDB v2.4.2 — Production Stable
24
24
 
25
- **Current stable: 2.4.1** — the first **complete cross-platform** release since the CI publish fix: 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`.
25
+ **Current stable: 2.4.2** — a polish release on the complete cross-platform line. The `nedbd-v2` daemon now does **real CLI parsing** — `--dag-v3`, `--data`, `--fast-fsync`, `--help`, `--version` are recognized flags instead of being silently swallowed as the positional data dir and `npm test` ships a **cinematic native smoke test** that tours v1→v2 migration, the v2 DAG, the v3 segment store, and a causal-provenance audit. 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`.
26
26
 
27
- **The v3 storage line — consolidated, spec'd, and (as of 2.4.1) cleanly published across every platform.** It makes the NEDB **v3 segment/pack object store** a first-class, fully-documented feature:
27
+ **The v3 storage line — consolidated, spec'd, and (as of 2.4.2) cleanly published across every platform.** It makes the NEDB **v3 segment/pack object store** a first-class, fully-documented feature:
28
28
 
29
- - **`--dag-v3`** (opt-in) — append-only segment store: one `fsync` per group-commit, `.idx` sidecars, compaction, non-destructive dual-read. Took a real itcd chainstate flush from *minutes* to **~1.3 s**. (See the v3 section below.)
29
+ - **`--dag-v3`** (opt-in) — append-only segment store: one `fsync` per group-commit, `.idx` sidecars, compaction, non-destructive dual-read. Took a real itcd chainstate flush from *minutes* to **~1.3 s**. Parsed as a real flag by `nedbd-v2` as of v2.4.2 (or set `NEDB_DAG_V3=1`). (See the v3 section below.)
30
30
  - **`NEDB_FAST_FSYNC`** — macOS fast-fsync: a plain `fsync(2)` instead of `F_FULLFSYNC` (default off; no-op on Linux/Windows).
31
31
  - Durable **flush-on-close**, a **Windows-safe id-index** (percent-encodes filesystem-unsafe ids), and idempotent object re-writes — shipped across the 2.3.3xxx line.
32
32
  - **`docs/SPEC.md` §3** now formally specifies the v2 object store, the v3 substrate, and the durability model.
@@ -360,8 +360,8 @@ v3 batches objects into append-only **segment packs** — `objects/segments/seg-
360
360
  ### How to enable
361
361
 
362
362
  ```bash
363
- # Engine / nedbd
364
- nedbd --dag-v3 --data /var/lib/nedb # or set NEDB_DAG_V3=1
363
+ # Engine / nedbd-v2 (the native daemon from npm / the native wheel)
364
+ nedbd-v2 --dag-v3 --data /var/lib/nedb # real flag as of v2.4.2 — or set NEDB_DAG_V3=1
365
365
 
366
366
  # itcd — Bitcoin-fork node embedding NEDB via nedb-ffi
367
367
  interchainedd -dagv3 # puts chainstate AND block index on segments
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.4.1",
3
+ "version": "2.4.2",
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",
@@ -14,6 +14,7 @@
14
14
  "nedbd-v2.js",
15
15
  "*.node",
16
16
  "nedbd-v2*",
17
+ "test/smoke.mjs",
17
18
  "README.md",
18
19
  "LICENSE"
19
20
  ],
package/test/smoke.mjs ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ // nedb-engine — cinematic smoke test (`npm test`)
3
+ // ---------------------------------------------------------------------------
4
+ // A five-act tour of the engine, driven entirely by the real native addon
5
+ // (NedbCore — the same prebuilt .node binary the npm package ships). No external
6
+ // deps; Node built-ins only. Exits 0 on success.
7
+ //
8
+ // Act I — v1: the legacy append-only op-log (log.aof)
9
+ // Act II — automatic v1 -> v2 DAG migration (zero user action, lossless)
10
+ // Act III — v2: the content-addressed, hash-chained, time-traveling DAG
11
+ // Act IV — v3: the segment/pack object store (one fsync per group-commit)
12
+ // Act V — The Honest Dispatch: a causal rideshare decision you can audit
13
+ //
14
+ // © INTERCHAINED LLC × Claude Opus 4.8
15
+ import os from 'node:os';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ // ── tiny presentation toolkit ───────────────────────────────────────────────
20
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const sgr = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s));
22
+ const dim = sgr('2'), bold = sgr('1'), ul = sgr('4');
23
+ const cyan = sgr('36'), green = sgr('32'), yellow = sgr('33');
24
+ const magenta = sgr('35'), blue = sgr('34'), red = sgr('31');
25
+
26
+ const log = (...a) => console.log(...a);
27
+ const rule = (ch = '─') => log(dim(ch.repeat(74)));
28
+ function act(n, title, subtitle) {
29
+ log('');
30
+ rule('═');
31
+ log(`${bold(magenta(` ACT ${n}`))} ${bold(title)}`);
32
+ if (subtitle) log(` ${dim(subtitle)}`);
33
+ rule('═');
34
+ }
35
+ const step = (s) => log(` ${cyan('→')} ${s}`);
36
+ const tick = (s) => log(` ${green('✓')} ${s}`);
37
+ const note = (s) => log(` ${dim(s)}`);
38
+ const kv = (k, v) => log(` ${dim(k.padEnd(16))} ${v}`);
39
+
40
+ // Positive sanity guard — confirms an invariant; only speaks up if reality
41
+ // disagrees (which, on an intact build, it won't).
42
+ function expect(cond, msg) {
43
+ if (!cond) {
44
+ log(` ${red('✗')} ${bold('unexpected:')} ${msg}`);
45
+ process.exitCode = 1;
46
+ throw new Error(msg);
47
+ }
48
+ }
49
+
50
+ const short = (h) => (h ? `${h.slice(0, 12)}…` : '∅');
51
+ const tmp = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `nedb-smoke-${label}-`));
52
+ const rm = (d) => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} };
53
+
54
+ // ── resolve the native addon (installed package, or in-repo after a build) ───
55
+ let NedbCore;
56
+ try {
57
+ ({ NedbCore } = await import('nedb-engine'));
58
+ } catch {
59
+ try {
60
+ ({ NedbCore } = await import(new URL('../index.js', import.meta.url)));
61
+ } catch (err) {
62
+ log(red(bold('\n nedb-engine native addon not found.')));
63
+ note('This smoke test drives the prebuilt NedbCore binding.');
64
+ note('From a source checkout, build it first: npm run build');
65
+ note(`(${err && err.message ? err.message : err})`);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ // ── banner ───────────────────────────────────────────────────────────────────
71
+ log('');
72
+ log(bold(cyan(' N E D B · native smoke test')));
73
+ log(dim(' hash-chained · bi-temporal · causal-provenance · v1→v2→v3'));
74
+
75
+ const cleanup = [];
76
+ const t0 = Date.now();
77
+ try {
78
+
79
+ // ════════════════════════════════════════════════════════════════════════
80
+ act('I', 'v1 — the legacy append-only op-log', 'where NEDB began: one JSON op per line, in log.aof');
81
+
82
+ const v1dir = tmp('v1'); cleanup.push(v1dir);
83
+ // The v1 wire format the migrator understands: {seq, op, ts, payload:{coll,id,doc}}.
84
+ // A tiny legacy "rides ledger" left behind by an older NEDB.
85
+ const v1ops = [
86
+ { seq: 0, op: 'put', ts: 1719400000.0, payload: { coll: 'trips', id: 't-1001', doc: { rider: 'maya', driver: 'sam', fare: 18.5, city: 'metropolis' } } },
87
+ { seq: 1, op: 'put', ts: 1719400100.0, payload: { coll: 'trips', id: 't-1002', doc: { rider: 'omar', driver: 'ana', fare: 24.0, city: 'metropolis' } } },
88
+ { seq: 2, op: 'put', ts: 1719400200.0, payload: { coll: 'drivers', id: 'sam', doc: { name: 'Sam', rating: 4.97, joined: 2024 } } },
89
+ ];
90
+ fs.writeFileSync(path.join(v1dir, 'log.aof'), v1ops.map((o) => JSON.stringify(o)).join('\n') + '\n');
91
+ step(`wrote a legacy ${bold('log.aof')} — ${v1ops.length} ops, append-only, plain JSON`);
92
+ for (const o of v1ops) note(`${o.op.toUpperCase()} ${o.payload.coll}/${o.payload.id} ${JSON.stringify(o.payload.doc)}`);
93
+ note('No hashes. No chain. No time-travel. Just an op log — that\'s v1.');
94
+
95
+ // ════════════════════════════════════════════════════════════════════════
96
+ act('II', 'v1 → v2 — automatic migration', 'open() detects log.aof and rebuilds it as a content-addressed DAG');
97
+
98
+ step(`${bold('NedbCore.open(dir)')} — the engine speaks for itself:`);
99
+ const migrated = NedbCore.open(v1dir); // <- triggers migrate_if_needed()
100
+ const t1001 = JSON.parse(migrated.get('trips', 't-1001'));
101
+ tick(`legacy data is live in v2: trips/t-1001 → fare ${bold(t1001.fare)}, rider ${bold(t1001.rider)}`);
102
+ kv('now content-addressed', `_hash ${cyan(short(t1001._hash))} (BLAKE2b-256, ${t1001._hash.length} hex chars)`);
103
+ expect(t1001._hash && t1001._hash.length === 64, 'migrated node is content-addressed');
104
+
105
+ const bakKept = fs.existsSync(path.join(v1dir, 'log.aof.v1.bak'));
106
+ const aofGone = !fs.existsSync(path.join(v1dir, 'log.aof'));
107
+ tick(`non-destructive: original preserved as ${bold('log.aof.v1.bak')} (${bakKept ? 'kept' : 'MISSING'})`);
108
+ expect(bakKept && aofGone, 'log.aof renamed to .v1.bak after migration');
109
+ tick(`integrity after migration: verify() = ${green(String(migrated.verify()))}`);
110
+ expect(migrated.verify() === true, 'migrated store verifies clean');
111
+ note('Zero user action. Lossless. Reversible. The op-log became a DAG.');
112
+
113
+ // ════════════════════════════════════════════════════════════════════════
114
+ act('III', 'v2 — the content-addressed DAG', 'hash chain · MVCC time-travel · causal graph · tamper-evident');
115
+
116
+ const v2 = new NedbCore(); // pure in-memory v2 — zero disk I/O
117
+ step('a fresh in-memory v2 DAG (no disk) — watch the Merkle head advance');
118
+ const h0 = v2.head(), s0 = v2.seq();
119
+ for (let i = 0; i < 5; i++) v2.put('blocks', String(i), JSON.stringify({ height: i, note: `block ${i}` }));
120
+ kv('seq', `${dim(String(s0))} → ${bold(String(v2.seq()))} (every write extends the chain)`);
121
+ kv('head', `${dim(short(h0))} → ${bold(short(v2.head()))}`);
122
+ expect(v2.seq() > s0 && v2.head() !== h0, 'chain advanced');
123
+
124
+ step('NQL — a real query language over the DAG');
125
+ kv('FROM blocks', `${v2.query('FROM blocks').length} rows`);
126
+ const q = v2.query('FROM blocks WHERE height = 3').map(JSON.parse);
127
+ kv('… WHERE height = 3', `${q.length} row → ${JSON.stringify({ height: q[0].height, note: q[0].note })}`);
128
+
129
+ step('MVCC time-travel — AS OF a past sequence');
130
+ const v1n = JSON.parse(v2.put('account', 'alice', JSON.stringify({ balance: 100 })));
131
+ const asOf = BigInt(v1n._seq);
132
+ v2.put('account', 'alice', JSON.stringify({ balance: 250 })); // new version, same id
133
+ const nowBal = JSON.parse(v2.get('account', 'alice')).balance;
134
+ const thenBal = JSON.parse(v2.getAsOf('account', 'alice', asOf)).balance;
135
+ kv('alice now', bold(`$${nowBal}`));
136
+ kv(`alice AS OF #${asOf}`, bold(`$${thenBal}`) + dim(' ← the past is still there, exactly'));
137
+ expect(nowBal === 250 && thenBal === 100, 'AS OF returns the historical version');
138
+
139
+ step('causal graph — typed edges you can traverse');
140
+ v2.link('blocks:4', 'prev', 'blocks:3');
141
+ v2.link('blocks:3', 'prev', 'blocks:2');
142
+ kv('neighbors(4,prev)', JSON.stringify(v2.neighbors('blocks:4', 'prev')));
143
+ kv('inbound(3,prev)', JSON.stringify(v2.inbound('blocks:3', 'prev')) + dim(' ← who points at me?'));
144
+
145
+ step('tamper-evidence — the whole store self-verifies');
146
+ tick(`verify() = ${green(String(v2.verify()))} ${dim('— every node\'s hash checked against its content')}`);
147
+ expect(v2.verify() === true, 'intact store verifies clean');
148
+
149
+ // ════════════════════════════════════════════════════════════════════════
150
+ act('IV', 'v3 — the segment/pack object store', 'same API, denser substrate: one fsync per group-commit, not per object');
151
+
152
+ const docs = 64;
153
+ const sample = (i) => JSON.stringify({ i, payload: `coin-${i}`, ts: 1719400000 + i });
154
+
155
+ // v2 substrate (env unset): loose objects — one file per write.
156
+ delete process.env.NEDB_DAG_V3;
157
+ const looseDir = tmp('v2loose'); cleanup.push(looseDir);
158
+ const looseDb = NedbCore.open(looseDir);
159
+ for (let i = 0; i < docs; i++) looseDb.put('utxo', String(i), sample(i));
160
+ looseDb.flush();
161
+ const looseObjs = countFiles(path.join(looseDir, 'objects'));
162
+ step(`v2 default substrate — ${bold(docs)} writes`);
163
+ kv('objects/ layout', `${bold(looseObjs)} loose object files ${dim('(content-addressed, one per object)')}`);
164
+
165
+ // v3 substrate (env set BEFORE open — the engine reads it per-open).
166
+ process.env.NEDB_DAG_V3 = '1';
167
+ const segDir = tmp('v3seg'); cleanup.push(segDir);
168
+ const segDb = NedbCore.open(segDir);
169
+ for (let i = 0; i < docs; i++) segDb.put('utxo', String(i), sample(i));
170
+ segDb.flush();
171
+ const segPath = path.join(segDir, 'objects', 'segments');
172
+ const segFiles = fs.existsSync(segPath) ? fs.readdirSync(segPath).filter((f) => f.endsWith('.dat')) : [];
173
+ step(`v3 segment substrate — ${bold(docs)} writes ${dim('(NEDB_DAG_V3=1)')}`);
174
+ if (segFiles.length) {
175
+ const seg0 = path.join(segPath, segFiles[0]);
176
+ const sz = fs.statSync(seg0).size;
177
+ kv('objects/segments/', `${bold(segFiles.length)} segment file: ${cyan(segFiles[0])} (${sz} bytes)`);
178
+ note(`${docs} objects packed into ${segFiles.length} append-only segment — the metadata-write ceiling is gone.`);
179
+ } else {
180
+ note('segment file not present yet (objects buffered) — engine still serving from memory+WAL.');
181
+ }
182
+ step('v3 round-trips and verifies after reopen');
183
+ const segReopen = NedbCore.open(segDir); // env still set → reopen as v3
184
+ const rt = JSON.parse(segReopen.get('utxo', '7'));
185
+ kv('reopen → utxo/7', JSON.stringify({ i: rt.i, payload: rt.payload }));
186
+ tick(`verify() = ${green(String(segReopen.verify()))} ${dim('— segment store + dual-read of any v2 loose objects')}`);
187
+ expect(rt.i === 7 && segReopen.verify() === true, 'v3 persists and verifies across reopen');
188
+ delete process.env.NEDB_DAG_V3; // leave the environment as we found it
189
+
190
+ // ════════════════════════════════════════════════════════════════════════
191
+ act('V', 'The Honest Dispatch', 'a rideshare match that ends in a good choice — because the data says so');
192
+
193
+ const rs = new NedbCore();
194
+ step('the world at request time — a rider and three real candidates');
195
+ // Facts. Each put() returns the stored node; we keep its _hash as an immutable
196
+ // citation we can later prove the decision was built from.
197
+ const req = JSON.parse(rs.put('request', 'trip-9001', JSON.stringify({
198
+ rider: 'maya', from: 'zone:downtown', to: 'airport', when: '18:10', wants: 'fast pickup',
199
+ })));
200
+ const surge = JSON.parse(rs.put('surge', 'zone:downtown', JSON.stringify({ multiplier: 1.2, at: '18:10' })));
201
+
202
+ const drivers = {
203
+ sam: { name: 'Sam', rating: 4.97, etaMin: 4, distMi: 0.7, recentCancels: 0, acceptsPool: true },
204
+ lee: { name: 'Lee', rating: 4.61, etaMin: 2, distMi: 0.3, recentCancels: 2, acceptsPool: false },
205
+ ana: { name: 'Ana', rating: 4.99, etaMin: 9, distMi: 1.2, recentCancels: 0, acceptsPool: true },
206
+ };
207
+ const driverHash = {};
208
+ for (const [id, d] of Object.entries(drivers)) {
209
+ driverHash[id] = JSON.parse(rs.put('driver', id, JSON.stringify({ ...d, available: true })))._hash;
210
+ }
211
+ for (const [id, d] of Object.entries(drivers)) {
212
+ kv(`driver:${id}`, `${d.name} ★${d.rating} eta ${d.etaMin}m ${d.distMi}mi cancels ${d.recentCancels} ${d.acceptsPool ? 'pool' : 'solo'}`);
213
+ }
214
+ note(`surge in downtown right now: ${bold(surge.multiplier + '×')}`);
215
+
216
+ step('score every candidate from the stored facts — not a hunch, a calculation');
217
+ // Transparent scoring: reward rating + ETA + pool; penalize recent cancels.
218
+ const score = (d) => +(
219
+ d.rating * 2
220
+ - d.etaMin * 0.25
221
+ - d.recentCancels * 1.5
222
+ + (d.acceptsPool ? 0.4 : 0)
223
+ ).toFixed(3);
224
+ const ranked = Object.entries(drivers)
225
+ .map(([id, d]) => ({ id, d, s: score(d) }))
226
+ .sort((a, b) => b.s - a.s);
227
+ for (const r of ranked) {
228
+ const why = r.id === 'lee' ? dim('(closest — but 2 recent cancels drag it down)')
229
+ : r.id === 'ana' ? dim('(top-rated — but a 9-min ETA for a "fast pickup")')
230
+ : dim('(4.97★, 4-min ETA, no cancels, takes pool)');
231
+ kv(`score driver:${r.id}`, `${bold(r.s.toFixed(2))} ${why}`);
232
+ }
233
+ const winner = ranked[0];
234
+ note(`naive "closest" would pick ${bold('Lee')} (0.3mi). The data picks ${bold(drivers[winner.id].name)}.`);
235
+
236
+ step('record the decision — with its causes wired in, permanently');
237
+ const decision = JSON.parse(rs.put('decision', 'trip-9001', JSON.stringify({
238
+ chosen: `driver:${winner.id}`,
239
+ score: winner.s,
240
+ policy: 'rating+eta+reliability+pool',
241
+ // caused_by: the exact immutable facts this choice was built from.
242
+ caused_by: [req._hash, surge._hash, driverHash[winner.id]],
243
+ })));
244
+ const decisionSeq = BigInt(decision._seq);
245
+ rs.link(`decision:trip-9001`, 'chose', `driver:${winner.id}`);
246
+ for (const r of ranked.slice(1)) rs.link(`decision:trip-9001`, 'considered', `driver:${r.id}`);
247
+ tick(`chose ${bold('driver:' + winner.id)} (${drivers[winner.id].name}) · decision is now a node in the DAG`);
248
+
249
+ step('AUDIT — "why did dispatch pick this driver?" Follow the causal trail.');
250
+ kv('decision.caused_by', `[ ${decision.caused_by.map(short).join(', ')} ]`);
251
+ const causeLabel = { [req._hash]: 'the rider\'s request', [surge._hash]: 'the surge snapshot', [driverHash[winner.id]]: `${drivers[winner.id].name}'s live state` };
252
+ for (const h of decision.caused_by) note(`${cyan(short(h))} → ${causeLabel[h] || 'a fact'}`);
253
+ kv('chose', JSON.stringify(rs.neighbors('decision:trip-9001', 'chose')));
254
+ kv('considered', JSON.stringify(rs.neighbors('decision:trip-9001', 'considered')) + dim(' ← the alternatives, on the record'));
255
+
256
+ step('REPRODUCE — surge spikes after the fact; the decision is unmoved');
257
+ rs.put('surge', 'zone:downtown', JSON.stringify({ multiplier: 2.5, at: '18:25' })); // later reality
258
+ const surgeNow = JSON.parse(rs.get('surge', 'zone:downtown')).multiplier;
259
+ const surgeThen = JSON.parse(rs.getAsOf('surge', 'zone:downtown', decisionSeq)).multiplier;
260
+ kv('surge now', bold(surgeNow + '×'));
261
+ kv('surge AS OF decision', bold(surgeThen + '×') + dim(' ← what the dispatcher actually saw; the audit is reproducible'));
262
+ expect(surgeThen === 1.2, 'AS OF reconstructs the world at decision time');
263
+
264
+ step('the good ending');
265
+ rs.put('trip', 'trip-9001', JSON.stringify({ status: 'completed', driver: `driver:${winner.id}`, riderRating: 5 }));
266
+ tick(`${bold('Maya')} matched with ${bold(drivers[winner.id].name)} → trip completed → ${yellow('★★★★★')}`);
267
+ tick(`fully auditable, content-addressed, reproducible — ${bold('a good choice, grounded in causal data')}`);
268
+ expect(rs.verify() === true, 'rideshare store verifies clean');
269
+
270
+ // ── curtain ────────────────────────────────────────────────────────────────
271
+ log('');
272
+ rule('═');
273
+ log(` ${green(bold('✓ all five acts passed'))} ${dim(`in ${Date.now() - t0} ms`)}`);
274
+ log(` ${dim('v1 → v2 migration · v2 DAG · v3 segments · causal audit — all on the native engine')}`);
275
+ rule('═');
276
+ log('');
277
+ } catch (err) {
278
+ log('');
279
+ log(red(bold(' smoke test failed:')) + ' ' + (err && err.message ? err.message : String(err)));
280
+ if (err && err.stack) log(dim(err.stack.split('\n').slice(1, 4).join('\n')));
281
+ process.exitCode = 1;
282
+ } finally {
283
+ for (const d of cleanup) rm(d);
284
+ }
285
+
286
+ // ── helpers ────────────────────────────────────────────────────────────────
287
+ function countFiles(root) {
288
+ let n = 0;
289
+ const walk = (d) => {
290
+ let ents;
291
+ try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
292
+ for (const e of ents) {
293
+ const p = path.join(d, e.name);
294
+ if (e.isDirectory()) walk(p); else n++;
295
+ }
296
+ };
297
+ walk(root);
298
+ return n;
299
+ }