rida-format 0.0.0-stage → 0.3.3
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/LICENSE +51 -0
- package/README.md +83 -2
- package/package.json +22 -4
- package/src/builder.js +520 -0
- package/src/cli.js +100 -0
- package/src/index.js +360 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
RIDA PROPRIETARY LICENSE
|
|
2
|
+
Version 1.0
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Syed Ali Hamza Chishty. All rights reserved.
|
|
5
|
+
|
|
6
|
+
This license applies to the RIDA software library and its source code
|
|
7
|
+
(“RIDA”). It does not apply to data files created with RIDA.
|
|
8
|
+
|
|
9
|
+
1. Permission to use and modify
|
|
10
|
+
|
|
11
|
+
You may download, install, run, inspect, and modify RIDA for your own use.
|
|
12
|
+
You may use RIDA, including a modified version, as a component of software
|
|
13
|
+
applications that you create.
|
|
14
|
+
|
|
15
|
+
2. Applications and commercial use
|
|
16
|
+
|
|
17
|
+
You may distribute and sell applications that include RIDA, including a
|
|
18
|
+
modified version, as an integrated component. You may not offer RIDA as a
|
|
19
|
+
separately installable or downloadable library, package, or standalone
|
|
20
|
+
software product as part of that distribution.
|
|
21
|
+
|
|
22
|
+
Every application that includes RIDA must display this notice in an About,
|
|
23
|
+
Licenses, or Third-Party Notices section:
|
|
24
|
+
|
|
25
|
+
“Uses RIDA (Range Indexed Data Access).
|
|
26
|
+
Copyright (c) 2026 Syed Ali Hamza Chishty.
|
|
27
|
+
Used under the RIDA Proprietary License.”
|
|
28
|
+
|
|
29
|
+
3. Standalone redistribution and resale
|
|
30
|
+
|
|
31
|
+
You may not sell, sublicense, publish, or otherwise distribute RIDA or a
|
|
32
|
+
modified version of RIDA as a standalone library, package, or software
|
|
33
|
+
product, except for the Node.js edition in this package. The copyright owner
|
|
34
|
+
specifically permits this Node.js edition to be published and distributed as
|
|
35
|
+
the standalone npm package named “rida-format”, including through the public
|
|
36
|
+
npm registry. Recipients may download, install, run, inspect, and modify this
|
|
37
|
+
Node.js edition for their own use, and may use it as an integrated part of an
|
|
38
|
+
application under Section 2. Recipients may not republish or sell it as a
|
|
39
|
+
standalone package. This exception does not authorize standalone distribution
|
|
40
|
+
of another RIDA edition or under another package name. You may not claim that
|
|
41
|
+
you own or authored the original RIDA code.
|
|
42
|
+
|
|
43
|
+
4. Ownership
|
|
44
|
+
|
|
45
|
+
Syed Ali Hamza Chishty retains ownership of RIDA. This license grants only
|
|
46
|
+
the permissions stated above; it does not transfer ownership of RIDA.
|
|
47
|
+
|
|
48
|
+
5. Warranty
|
|
49
|
+
|
|
50
|
+
RIDA is provided “AS IS”, without warranties or conditions of any kind,
|
|
51
|
+
express or implied, to the extent permitted by applicable law.
|
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RIDA Node.js tools — 0.3.3
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This folder contains a Node.js command-line reader and builder for RIDA 0.3.3 files. It reads the same data and index format as the Python release. It does not convert or rewrite an existing dataset when reading it.
|
|
4
|
+
|
|
5
|
+
## Install for local use
|
|
6
|
+
|
|
7
|
+
From this folder:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install
|
|
11
|
+
node ./src/cli.js --help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Node.js 20 or newer is required. `lzma-native` reads and writes the XZ catalog. It includes native code; installation uses a prebuilt binary when available and may need a compiler on other systems.
|
|
15
|
+
|
|
16
|
+
## Build a dataset
|
|
17
|
+
|
|
18
|
+
A folder can contain one JSON object per `.json` file. Nested folders are included. By default RIDA gives each folder and record a numbered ID and writes a `Map.json` when those IDs need folder names.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
node ./src/cli.js scan ./records
|
|
22
|
+
node ./src/cli.js build ./records --output ./books.rida
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The build creates `books.rida` and `booksIndex.rida`. It also creates `booksMap.json` when folder IDs need a map. Existing data or index files are not overwritten.
|
|
26
|
+
|
|
27
|
+
The tool also accepts one large JSON file whose top-level object maps IDs to record objects. A top-level JSON array needs `--id-key`. It reads one record at a time, rather than loading the whole JSON file:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
node ./src/cli.js build ./records.json --id-key id --output ./records.rida
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Useful build options:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
--mode auto|dense|sparse Choose the catalog layout (default: auto)
|
|
37
|
+
--codec deflate-per-field|plain Compress each value, or store raw JSON
|
|
38
|
+
--compression 0-9 Set XZ catalog compression (default: 9)
|
|
39
|
+
--encrypt Create a fresh AES-256-GCM key
|
|
40
|
+
--key FILE_OR_TEXT Reuse a 32-byte key in base64url or hex form
|
|
41
|
+
--records-dir FOLDER Build from a selected folder inside the source
|
|
42
|
+
--combine-groups Combine immediate JSON groups into one dataset
|
|
43
|
+
--combined-schema common|union Keep only shared fields, or every field (default)
|
|
44
|
+
--named-ids Use JSON file names for record IDs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keep the generated `Key.rida` private. Without it encrypted values cannot be read. The key is separate from the data, index, and map files.
|
|
48
|
+
|
|
49
|
+
## Read a dataset
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
node ./src/cli.js info ./books.rida
|
|
53
|
+
node ./src/cli.js get ./books.rida 1/1/1/1 translation.urdu --key ./booksKey.rida
|
|
54
|
+
node ./src/cli.js get-many ./books.rida 1/1/1/1 title translation.urdu --key ./booksKey.rida
|
|
55
|
+
node ./src/cli.js record ./books.rida 1/1/1/1 --key ./booksKey.rida
|
|
56
|
+
node ./src/cli.js verify ./books.rida --key ./booksKey.rida
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The JavaScript API is also available from `src/index.js`:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { RidaReader } from "./src/index.js";
|
|
63
|
+
|
|
64
|
+
const reader = await RidaReader.open("./books.rida", { key: "base64url-key-text" });
|
|
65
|
+
const value = await reader.get("1/1/1/1", "translation.urdu");
|
|
66
|
+
await reader.close();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It supports local data files and HTTP servers/CDNs that honor byte-range requests. `getMany()` can combine nearby value ranges into fewer requests. The index and optional map stay with the reader; the CDN serves the data file.
|
|
70
|
+
|
|
71
|
+
## Current boundaries
|
|
72
|
+
|
|
73
|
+
- Folder builds handle one JSON object per record file. The selected folder is scanned to discover fields, then read again to write values. Large datasets do not need all value contents held in memory at once; record IDs and catalog row metadata are kept in memory.
|
|
74
|
+
- Both folder input and a single large JSON file are read record by record. The IDs and catalog row information stay in memory; the full set of JSON values does not.
|
|
75
|
+
- Multiple JSON groups require `--records-dir` to select one, or `--combine-groups`. Combined builds support both common and union schemas.
|
|
76
|
+
- The CLI provides `scan`, `build`, `info`, `get`, `get-many`, `record`, and `verify`. Python's inspection helpers and automatic remote index-cache installer are not ported yet.
|
|
77
|
+
|
|
78
|
+
## License and publishing
|
|
79
|
+
|
|
80
|
+
The included license specifically permits publishing this Node.js edition as the standalone npm package `rida-format`. It does not grant permission to publish other RIDA editions as standalone packages. Keep the included `LICENSE` file with the package.
|
|
81
|
+
|
|
82
|
+
## Format compatibility
|
|
83
|
+
|
|
84
|
+
RIDA 0.3.3 stores only value bytes in the data file. The index has a 72-byte header followed by an XZ/LZMA catalog. Catalog rows point to values encoded as compact UTF-8 JSON, either plain or raw Deflate per field. Encryption wraps each encoded value as a 12-byte nonce, AES-256-GCM ciphertext, and 16-byte authentication tag. The map contains numbered folder names and field labels; the key is separate and private.
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rida-format",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Node.js reader and tools for RIDA 0.3.3 datasets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=20" },
|
|
7
|
+
"bin": {
|
|
8
|
+
"rida": "./src/cli.js",
|
|
9
|
+
"rida-format": "./src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./builder": "./src/builder.js"
|
|
14
|
+
},
|
|
15
|
+
"files": ["src/", "README.md", "LICENSE"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node ./src/cli.js",
|
|
18
|
+
"check": "node --check src/index.js && node --check src/builder.js && node --check src/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"lzma-native": "^8.0.6"
|
|
22
|
+
},
|
|
23
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
24
|
+
}
|
package/src/builder.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { createHash, randomBytes, createCipheriv } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, mkdir, open, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { deflateRawSync } from "node:zlib";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { RidaError } from "./index.js";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const lzma = require("lzma-native");
|
|
11
|
+
const INDEX_MAGIC = Buffer.from("RIDAIDX1");
|
|
12
|
+
const CATALOG_MAGIC = Buffer.from("RDACAT01");
|
|
13
|
+
const HEADER_SIZE = 72;
|
|
14
|
+
|
|
15
|
+
export function compactJSON(value) {
|
|
16
|
+
const text = JSON.stringify(value, (_, item) => {
|
|
17
|
+
if (typeof item === "number" && !Number.isFinite(item)) throw new RidaError("RIDA stores standard JSON only; NaN and Infinity are not supported");
|
|
18
|
+
return item;
|
|
19
|
+
});
|
|
20
|
+
if (text === undefined) throw new RidaError("Value cannot be represented as JSON");
|
|
21
|
+
return Buffer.from(text, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function encodeVarint(input) {
|
|
25
|
+
let value = BigInt(input);
|
|
26
|
+
const bytes = [];
|
|
27
|
+
while (value >= 128n) { bytes.push(Number(value & 127n) | 128); value >>= 7n; }
|
|
28
|
+
bytes.push(Number(value));
|
|
29
|
+
return Buffer.from(bytes);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function orderKey(name) {
|
|
33
|
+
const stem = name.slice(0, name.lastIndexOf("."));
|
|
34
|
+
return /^\d+$/.test(stem) ? [0, BigInt(stem), name.toLowerCase()] : [1, stem.toLowerCase(), name.toLowerCase()];
|
|
35
|
+
}
|
|
36
|
+
const lexical = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
37
|
+
function compareNames(a, b) {
|
|
38
|
+
const x = orderKey(a), y = orderKey(b);
|
|
39
|
+
if (x[0] !== y[0]) return x[0] - y[0];
|
|
40
|
+
if (x[1] !== y[1]) return x[1] < y[1] ? -1 : 1;
|
|
41
|
+
return lexical(x[2], y[2]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function walkJSON(root, { recursive = true } = {}) {
|
|
45
|
+
const files = [];
|
|
46
|
+
async function walk(dir) {
|
|
47
|
+
const entries = (await readdir(dir, { withFileTypes: true })).filter((entry) => !entry.name.startsWith(".")).sort((a, b) => lexical(a.name, b.name));
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const path = join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) { if (recursive) await walk(path); continue; }
|
|
51
|
+
if (entry.isFile() && /\.json$/i.test(entry.name)) files.push(path);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await walk(root);
|
|
55
|
+
files.sort((a, b) => lexical(relative(root, a).split(/[\\/]/).join("/"), relative(root, b).split(/[\\/]/).join("/")));
|
|
56
|
+
if (!files.length) throw new RidaError(`No JSON files found in ${root}`);
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function numberedIds(paths, root) {
|
|
61
|
+
const folderChildren = new Map();
|
|
62
|
+
const filesByFolder = new Map();
|
|
63
|
+
const rootText = resolve(root);
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
const folder = dirname(path);
|
|
66
|
+
if (!filesByFolder.has(folder)) filesByFolder.set(folder, []);
|
|
67
|
+
filesByFolder.get(folder).push(path);
|
|
68
|
+
let current = folder;
|
|
69
|
+
while (current !== rootText && current.startsWith(rootText + "/")) {
|
|
70
|
+
const parent = dirname(current);
|
|
71
|
+
if (!folderChildren.has(parent)) folderChildren.set(parent, new Set());
|
|
72
|
+
folderChildren.get(parent).add(current);
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const folderIds = new Map([[rootText, ""]]);
|
|
77
|
+
const queue = [rootText];
|
|
78
|
+
const mapPaths = {};
|
|
79
|
+
while (queue.length) {
|
|
80
|
+
const parent = queue.shift();
|
|
81
|
+
const children = [...(folderChildren.get(parent) ?? [])].sort((a, b) => lexical(basename(a), basename(b)));
|
|
82
|
+
for (let i = 0; i < children.length; i++) {
|
|
83
|
+
const child = children[i];
|
|
84
|
+
const prefix = [folderIds.get(parent), String(i + 1)].filter(Boolean).join("/");
|
|
85
|
+
folderIds.set(child, prefix);
|
|
86
|
+
mapPaths[prefix] = basename(child);
|
|
87
|
+
queue.push(child);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const ids = new Map();
|
|
91
|
+
for (const [folder, folderFiles] of filesByFolder) {
|
|
92
|
+
folderFiles.sort((a, b) => compareNames(basename(a), basename(b)));
|
|
93
|
+
const prefix = folderIds.get(folder) ?? "";
|
|
94
|
+
folderFiles.forEach((path, i) => ids.set(path, [prefix, String(i + 1)].filter(Boolean).join("/")));
|
|
95
|
+
}
|
|
96
|
+
return { ids, paths: mapPaths };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function uniqueId(id, used) {
|
|
100
|
+
if (!used.has(id)) { used.add(id); return id; }
|
|
101
|
+
let n = 2;
|
|
102
|
+
while (used.has(`${id}-${n}`)) n++;
|
|
103
|
+
const unique = `${id}-${n}`;
|
|
104
|
+
used.add(unique);
|
|
105
|
+
return unique;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readRecordFile(path) {
|
|
109
|
+
let value;
|
|
110
|
+
try { value = JSON.parse(await readFile(path, "utf8")); }
|
|
111
|
+
catch (cause) { throw new RidaError(`Invalid JSON in ${path}: ${cause.message}`, { cause }); }
|
|
112
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new RidaError(`Record file must contain one JSON object: ${path}`);
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class JsonCursor {
|
|
117
|
+
constructor(path) {
|
|
118
|
+
this.chunks = createReadStream(path, { encoding: "utf8" })[Symbol.asyncIterator]();
|
|
119
|
+
this.chunk = "";
|
|
120
|
+
this.position = 0;
|
|
121
|
+
this.ended = false;
|
|
122
|
+
this.started = false;
|
|
123
|
+
this.path = path;
|
|
124
|
+
}
|
|
125
|
+
async _fill() {
|
|
126
|
+
while (this.position >= this.chunk.length && !this.ended) {
|
|
127
|
+
const next = await this.chunks.next();
|
|
128
|
+
if (next.done) { this.ended = true; this.chunk = ""; this.position = 0; }
|
|
129
|
+
else { this.chunk = next.value; this.position = 0; }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async peek() {
|
|
133
|
+
await this._fill();
|
|
134
|
+
if (!this.started && !this.ended) {
|
|
135
|
+
this.started = true;
|
|
136
|
+
if (this.chunk[this.position] === "\uFEFF") this.position++;
|
|
137
|
+
await this._fill();
|
|
138
|
+
}
|
|
139
|
+
return this.ended ? null : this.chunk[this.position];
|
|
140
|
+
}
|
|
141
|
+
async take() { const value = await this.peek(); if (value !== null) this.position++; return value; }
|
|
142
|
+
async skipWhitespace() { while (/\s/.test(await this.peek() ?? "")) await this.take(); }
|
|
143
|
+
async expect(expected) {
|
|
144
|
+
await this.skipWhitespace();
|
|
145
|
+
const actual = await this.take();
|
|
146
|
+
if (actual !== expected) throw new RidaError(`Invalid JSON in ${this.path}: expected ${JSON.stringify(expected)}, found ${JSON.stringify(actual)}`);
|
|
147
|
+
}
|
|
148
|
+
async readValueText() {
|
|
149
|
+
await this.skipWhitespace();
|
|
150
|
+
const first = await this.peek();
|
|
151
|
+
if (first === null) throw new RidaError(`Invalid or incomplete JSON in ${this.path}`);
|
|
152
|
+
const out = [];
|
|
153
|
+
if (first === '"') {
|
|
154
|
+
let escaped = false;
|
|
155
|
+
while (true) {
|
|
156
|
+
const char = await this.take();
|
|
157
|
+
if (char === null) throw new RidaError(`Invalid or incomplete JSON string in ${this.path}`);
|
|
158
|
+
out.push(char);
|
|
159
|
+
if (escaped) escaped = false;
|
|
160
|
+
else if (char === "\\") escaped = true;
|
|
161
|
+
else if (char === '"') break;
|
|
162
|
+
}
|
|
163
|
+
return out.join("");
|
|
164
|
+
}
|
|
165
|
+
if (first === "{" || first === "[") {
|
|
166
|
+
const stack = [];
|
|
167
|
+
let inString = false;
|
|
168
|
+
let escaped = false;
|
|
169
|
+
while (true) {
|
|
170
|
+
const char = await this.take();
|
|
171
|
+
if (char === null) throw new RidaError(`Invalid or incomplete JSON value in ${this.path}`);
|
|
172
|
+
out.push(char);
|
|
173
|
+
if (inString) {
|
|
174
|
+
if (escaped) escaped = false;
|
|
175
|
+
else if (char === "\\") escaped = true;
|
|
176
|
+
else if (char === '"') inString = false;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (char === '"') inString = true;
|
|
180
|
+
else if (char === "{" || char === "[") stack.push(char);
|
|
181
|
+
else if (char === "}" || char === "]") {
|
|
182
|
+
const opening = stack.pop();
|
|
183
|
+
if ((opening === "{" && char !== "}") || (opening === "[" && char !== "]")) throw new RidaError(`Mismatched JSON delimiters in ${this.path}`);
|
|
184
|
+
if (!stack.length) break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out.join("");
|
|
188
|
+
}
|
|
189
|
+
while (true) {
|
|
190
|
+
const char = await this.peek();
|
|
191
|
+
if (char === null || /[\s,\]}]/.test(char)) break;
|
|
192
|
+
out.push(await this.take());
|
|
193
|
+
}
|
|
194
|
+
return out.join("");
|
|
195
|
+
}
|
|
196
|
+
async finish() {
|
|
197
|
+
await this.skipWhitespace();
|
|
198
|
+
if (await this.peek() !== null) throw new RidaError(`Unexpected content after JSON value in ${this.path}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function* jsonRecords(path, idKey) {
|
|
203
|
+
const cursor = new JsonCursor(path);
|
|
204
|
+
await cursor.skipWhitespace();
|
|
205
|
+
const top = await cursor.peek();
|
|
206
|
+
if (top === "{") {
|
|
207
|
+
await cursor.expect("{");
|
|
208
|
+
if (await cursor.peek() === "}") { await cursor.take(); await cursor.finish(); return; }
|
|
209
|
+
while (true) {
|
|
210
|
+
const idText = await cursor.readValueText();
|
|
211
|
+
let id;
|
|
212
|
+
try { id = JSON.parse(idText); } catch { throw new RidaError(`Invalid record ID in ${path}`); }
|
|
213
|
+
if (typeof id !== "string") throw new RidaError(`JSON object record IDs must be strings in ${path}`);
|
|
214
|
+
await cursor.expect(":");
|
|
215
|
+
const recordText = await cursor.readValueText();
|
|
216
|
+
let record;
|
|
217
|
+
try { record = JSON.parse(recordText); } catch (cause) { throw new RidaError(`Invalid JSON record ${id} in ${path}: ${cause.message}`, { cause }); }
|
|
218
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) throw new RidaError(`Record ${id} is not a JSON object`);
|
|
219
|
+
yield [id, record];
|
|
220
|
+
await cursor.skipWhitespace();
|
|
221
|
+
const delimiter = await cursor.take();
|
|
222
|
+
if (delimiter === "}") break;
|
|
223
|
+
if (delimiter !== ",") throw new RidaError(`Invalid JSON object in ${path}: expected ',' or '}'`);
|
|
224
|
+
}
|
|
225
|
+
await cursor.finish();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (top === "[") {
|
|
229
|
+
if (!idKey) throw new RidaError("A JSON array needs --id-key so RIDA can identify every record");
|
|
230
|
+
await cursor.expect("[");
|
|
231
|
+
if (await cursor.peek() === "]") { await cursor.take(); await cursor.finish(); return; }
|
|
232
|
+
let count = 0;
|
|
233
|
+
while (true) {
|
|
234
|
+
const recordText = await cursor.readValueText();
|
|
235
|
+
let record;
|
|
236
|
+
try { record = JSON.parse(recordText); } catch (cause) { throw new RidaError(`Invalid JSON array item in ${path}: ${cause.message}`, { cause }); }
|
|
237
|
+
count++;
|
|
238
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) throw new RidaError(`Array item ${count} is not a JSON object`);
|
|
239
|
+
if (!Object.hasOwn(record, idKey) || record[idKey] == null) throw new RidaError(`Array item ${count} has no usable ${idKey} ID`);
|
|
240
|
+
yield [String(record[idKey]), record];
|
|
241
|
+
await cursor.skipWhitespace();
|
|
242
|
+
const delimiter = await cursor.take();
|
|
243
|
+
if (delimiter === "]") break;
|
|
244
|
+
if (delimiter !== ",") throw new RidaError(`Invalid JSON array in ${path}: expected ',' or ']'`);
|
|
245
|
+
}
|
|
246
|
+
await cursor.finish();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
throw new RidaError(`JSON input must be an object mapping IDs to records or an array: ${path}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function encodeValue(value, codec, key) {
|
|
253
|
+
let bytes = compactJSON(value);
|
|
254
|
+
if (codec === "deflate-per-field") bytes = deflateRawSync(bytes, { level: 9 });
|
|
255
|
+
if (key) {
|
|
256
|
+
const nonce = randomBytes(12);
|
|
257
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
258
|
+
bytes = Buffer.concat([nonce, cipher.update(bytes), cipher.final(), cipher.getAuthTag()]);
|
|
259
|
+
}
|
|
260
|
+
return bytes;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function keyBytes(value) {
|
|
264
|
+
if (!value) return null;
|
|
265
|
+
if (Buffer.isBuffer(value)) return value;
|
|
266
|
+
const text = String(value).trim();
|
|
267
|
+
const decoded = Buffer.from(text, "base64url");
|
|
268
|
+
if (decoded.length === 32) return decoded;
|
|
269
|
+
const hex = Buffer.from(text, "hex");
|
|
270
|
+
if (hex.length === 32) return hex;
|
|
271
|
+
throw new RidaError("Encryption key must be 32 bytes, base64, or hex");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function companion(output, suffix) {
|
|
275
|
+
let stem = basename(output, extname(output));
|
|
276
|
+
if (stem.endsWith("Data")) stem = stem.slice(0, -4);
|
|
277
|
+
return join(dirname(output), `${stem}${suffix}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function progress(label, done, total) {
|
|
281
|
+
const width = 24;
|
|
282
|
+
const ratio = total > 0 ? Math.min(1, done / total) : 0;
|
|
283
|
+
const filled = Math.floor(width * ratio);
|
|
284
|
+
process.stderr.write(`\r${label}: ${Math.round(ratio * 100)}% [${"█".repeat(filled)}${"░".repeat(width - filled)}] ${done.toLocaleString()}${total ? `/${total.toLocaleString()}` : ""}`);
|
|
285
|
+
if (total && done >= total) process.stderr.write("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function scan(source, options = {}) {
|
|
289
|
+
let sourcePath = resolve(source);
|
|
290
|
+
let sourceStat = await stat(sourcePath);
|
|
291
|
+
let discoveredFiles = null;
|
|
292
|
+
if (!sourceStat.isDirectory()) {
|
|
293
|
+
const fields = [];
|
|
294
|
+
const seenFields = new Set();
|
|
295
|
+
const usedIds = new Set();
|
|
296
|
+
const records = [];
|
|
297
|
+
let count = 0;
|
|
298
|
+
for await (const [rawId, record] of jsonRecords(sourcePath, options.idKey)) {
|
|
299
|
+
count++;
|
|
300
|
+
for (const field of Object.keys(record)) if (!seenFields.has(field)) { seenFields.add(field); fields.push(field); }
|
|
301
|
+
records.push({ id: uniqueId(String(rawId), usedIds), sourceIndex: count - 1 });
|
|
302
|
+
if (count % 1000 === 0) progress("Scanning JSON records", count, 0);
|
|
303
|
+
}
|
|
304
|
+
if (!count) throw new RidaError("RIDA needs at least one record");
|
|
305
|
+
if (!fields.length) throw new RidaError("RIDA records must contain at least one field");
|
|
306
|
+
progress("Scanning JSON records", count, count);
|
|
307
|
+
return { sourcePath, files: [], records, fields, mapPaths: {}, recordCount: records.length, singleJSON: true, idKey: options.idKey };
|
|
308
|
+
}
|
|
309
|
+
if (options.recordsDir) {
|
|
310
|
+
sourcePath = resolve(sourcePath, options.recordsDir);
|
|
311
|
+
sourceStat = await stat(sourcePath);
|
|
312
|
+
if (!sourceStat.isDirectory()) throw new RidaError(`Records folder does not exist: ${sourcePath}`);
|
|
313
|
+
} else {
|
|
314
|
+
const entries = await readdir(sourcePath, { withFileTypes: true });
|
|
315
|
+
const directFiles = entries.filter((entry) => entry.isFile() && /\.json$/i.test(entry.name) && !entry.name.startsWith("."));
|
|
316
|
+
const directJSON = directFiles.length > 0;
|
|
317
|
+
const groups = [];
|
|
318
|
+
if (directJSON) groups.push({ path: sourcePath, files: directFiles.map((entry) => join(sourcePath, entry.name)) });
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
321
|
+
const groupPath = join(sourcePath, entry.name);
|
|
322
|
+
try { groups.push({ path: groupPath, files: await walkJSON(groupPath) }); } catch (error) { if (!(error instanceof RidaError)) throw error; }
|
|
323
|
+
}
|
|
324
|
+
if (groups.length > 1 && !options.combineGroups) {
|
|
325
|
+
throw new RidaError(`This folder has ${groups.length} JSON groups. Choose one with --records-dir, or use --combine-groups.`);
|
|
326
|
+
}
|
|
327
|
+
if (groups.length === 1 && !options.combineGroups) {
|
|
328
|
+
sourcePath = groups[0].path;
|
|
329
|
+
discoveredFiles = groups[0].files;
|
|
330
|
+
} else if (options.combineGroups) {
|
|
331
|
+
discoveredFiles = groups.flatMap((group) => group.files);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (![undefined, "common", "union"].includes(options.combinedSchema)) throw new RidaError("Combined schema must be common or union");
|
|
335
|
+
const files = discoveredFiles ?? await walkJSON(sourcePath);
|
|
336
|
+
files.sort((a, b) => lexical(relative(sourcePath, a).split(/[\\/]/).join("/"), relative(sourcePath, b).split(/[\\/]/).join("/")));
|
|
337
|
+
const mapping = options.numbered === false ? null : numberedIds(files, sourcePath);
|
|
338
|
+
const records = [];
|
|
339
|
+
const fields = [];
|
|
340
|
+
const seenFields = new Set();
|
|
341
|
+
const usedIds = new Set();
|
|
342
|
+
for (let i = 0; i < files.length; i++) {
|
|
343
|
+
let record = await readRecordFile(files[i]);
|
|
344
|
+
let id = options.numbered === false ? basename(files[i], extname(files[i])) : mapping.ids.get(files[i]);
|
|
345
|
+
if (options.numbered === false) id = uniqueId(id, usedIds);
|
|
346
|
+
records.push({ id: options.numbered === false ? id : (usedIds.add(id), id), file: files[i] });
|
|
347
|
+
for (const field of Object.keys(record)) if (!seenFields.has(field)) { seenFields.add(field); fields.push(field); }
|
|
348
|
+
if ((i + 1) % 1000 === 0 || i + 1 === files.length) progress("Scanning JSON records", i + 1, files.length);
|
|
349
|
+
}
|
|
350
|
+
if (options.combineGroups && options.combinedSchema === "common") {
|
|
351
|
+
const groupFields = new Map();
|
|
352
|
+
for (let i = 0; i < files.length; i++) {
|
|
353
|
+
const relativePath = relative(sourcePath, files[i]);
|
|
354
|
+
const firstFolder = relativePath.includes("/") ? relativePath.split("/")[0] : ".";
|
|
355
|
+
if (!groupFields.has(firstFolder)) groupFields.set(firstFolder, new Set());
|
|
356
|
+
for (const field of Object.keys(await readRecordFile(files[i]))) groupFields.get(firstFolder).add(field);
|
|
357
|
+
}
|
|
358
|
+
const [first, ...rest] = [...groupFields.values()];
|
|
359
|
+
const common = new Set([...first].filter((field) => rest.every((fields) => fields.has(field))));
|
|
360
|
+
for (let i = fields.length - 1; i >= 0; i--) if (!common.has(fields[i])) fields.splice(i, 1);
|
|
361
|
+
}
|
|
362
|
+
if (!fields.length) throw new RidaError("RIDA records must contain at least one field");
|
|
363
|
+
return { sourcePath, files, records, fields, mapPaths: mapping?.paths ?? {}, recordCount: records.length };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function build(source, output, options = {}) {
|
|
367
|
+
const codec = options.codec ?? "deflate-per-field";
|
|
368
|
+
if (!["deflate-per-field", "plain"].includes(codec)) throw new RidaError("Codec must be deflate-per-field or plain");
|
|
369
|
+
if (![undefined, "auto", "dense", "sparse"].includes(options.mode)) throw new RidaError("Mode must be auto, dense, or sparse");
|
|
370
|
+
if (options.encrypt && options.key) throw new RidaError("Choose either --encrypt for a new key or --key to reuse a key");
|
|
371
|
+
const compression = Number(options.compression ?? 9);
|
|
372
|
+
if (!Number.isInteger(compression) || compression < 0 || compression > 9) throw new RidaError("Catalog compression must be from 0 to 9");
|
|
373
|
+
const key = options.encrypt ? randomBytes(32) : keyBytes(options.key);
|
|
374
|
+
const plan = await scan(source, options);
|
|
375
|
+
const target = resolve(output);
|
|
376
|
+
const plannedOutputs = [target, companion(target, "Index.rida")];
|
|
377
|
+
if (key) plannedOutputs.push(companion(target, "Key.rida"));
|
|
378
|
+
if (Object.keys(plan.mapPaths).length) plannedOutputs.push(companion(target, "Map.json"));
|
|
379
|
+
if (await Promise.all(plannedOutputs.map(async (path) => { try { await stat(path); return path; } catch { return null; } })).then((values) => values.find(Boolean))) {
|
|
380
|
+
throw new RidaError(`An output file already exists. Choose another output name: ${target}`);
|
|
381
|
+
}
|
|
382
|
+
await mkdir(dirname(target), { recursive: true });
|
|
383
|
+
const tempData = `${target}.data-tmp-${process.pid}`;
|
|
384
|
+
const tempDense = `${target}.dense-tmp-${process.pid}`;
|
|
385
|
+
const tempSparse = `${target}.sparse-tmp-${process.pid}`;
|
|
386
|
+
const tempDenseOffsets = `${target}.dense-offsets-tmp-${process.pid}`;
|
|
387
|
+
const tempSparseOffsets = `${target}.sparse-offsets-tmp-${process.pid}`;
|
|
388
|
+
let dataLength = 0;
|
|
389
|
+
const digest = createHash("sha256");
|
|
390
|
+
const sourceIterator = plan.singleJSON ? jsonRecords(plan.sourcePath, plan.idKey)[Symbol.asyncIterator]() : null;
|
|
391
|
+
const temporary = await open(tempData, "wx");
|
|
392
|
+
const denseFile = await open(tempDense, "wx");
|
|
393
|
+
const sparseFile = await open(tempSparse, "wx");
|
|
394
|
+
const denseOffsetsFile = await open(tempDenseOffsets, "wx");
|
|
395
|
+
const sparseOffsetsFile = await open(tempSparseOffsets, "wx");
|
|
396
|
+
let denseRowsLength = 0;
|
|
397
|
+
let sparseRowsLength = 0;
|
|
398
|
+
let denseOffset = 0;
|
|
399
|
+
let sparseOffset = 0;
|
|
400
|
+
try {
|
|
401
|
+
for (let index = 0; index < plan.records.length; index++) {
|
|
402
|
+
const { file } = plan.records[index];
|
|
403
|
+
let record;
|
|
404
|
+
if (sourceIterator) {
|
|
405
|
+
const next = await sourceIterator.next();
|
|
406
|
+
if (next.done) throw new RidaError("Source JSON changed between scanning and building");
|
|
407
|
+
record = next.value[1];
|
|
408
|
+
} else record = await readRecordFile(file);
|
|
409
|
+
const recordDataStart = dataLength;
|
|
410
|
+
const denseParts = [encodeVarint(recordDataStart)];
|
|
411
|
+
const presentParts = [];
|
|
412
|
+
let presentCount = 0;
|
|
413
|
+
let previousFieldId = 0;
|
|
414
|
+
for (let fieldId = 0; fieldId < plan.fields.length; fieldId++) {
|
|
415
|
+
const field = plan.fields[fieldId];
|
|
416
|
+
if (!Object.hasOwn(record, field)) { denseParts.push(encodeVarint(0)); continue; }
|
|
417
|
+
const block = encodeValue(record[field], codec, key);
|
|
418
|
+
denseParts.push(encodeVarint(block.length));
|
|
419
|
+
presentParts.push(encodeVarint(fieldId - previousFieldId), encodeVarint(block.length));
|
|
420
|
+
previousFieldId = fieldId;
|
|
421
|
+
presentCount++;
|
|
422
|
+
await temporary.write(block);
|
|
423
|
+
digest.update(block);
|
|
424
|
+
dataLength += block.length;
|
|
425
|
+
}
|
|
426
|
+
const denseRow = Buffer.concat(denseParts);
|
|
427
|
+
const sparseRow = Buffer.concat([encodeVarint(recordDataStart), encodeVarint(presentCount), ...presentParts]);
|
|
428
|
+
await denseFile.write(denseRow);
|
|
429
|
+
await sparseFile.write(sparseRow);
|
|
430
|
+
denseRowsLength += denseRow.length;
|
|
431
|
+
sparseRowsLength += sparseRow.length;
|
|
432
|
+
denseOffset += denseRow.length;
|
|
433
|
+
sparseOffset += sparseRow.length;
|
|
434
|
+
const q = Buffer.alloc(8);
|
|
435
|
+
q.writeBigUInt64BE(BigInt(denseOffset));
|
|
436
|
+
await denseOffsetsFile.write(q);
|
|
437
|
+
q.writeBigUInt64BE(BigInt(sparseOffset));
|
|
438
|
+
await sparseOffsetsFile.write(q);
|
|
439
|
+
if ((index + 1) % 1000 === 0 || index + 1 === plan.records.length) progress("Writing RIDA values", index + 1, plan.records.length);
|
|
440
|
+
}
|
|
441
|
+
if (sourceIterator) {
|
|
442
|
+
const end = await sourceIterator.next();
|
|
443
|
+
if (!end.done) throw new RidaError("Source JSON changed between scanning and building");
|
|
444
|
+
}
|
|
445
|
+
await temporary.sync();
|
|
446
|
+
await denseFile.sync();
|
|
447
|
+
await sparseFile.sync();
|
|
448
|
+
await denseOffsetsFile.sync();
|
|
449
|
+
await sparseOffsetsFile.sync();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
await sourceIterator?.return?.();
|
|
452
|
+
await Promise.all([temporary, denseFile, sparseFile, denseOffsetsFile, sparseOffsetsFile].map((file) => file.close().catch(() => {})));
|
|
453
|
+
await Promise.all([tempData, tempDense, tempSparse, tempDenseOffsets, tempSparseOffsets].map((path) => rm(path, { force: true })));
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
await Promise.all([temporary, denseFile, sparseFile, denseOffsetsFile, sparseOffsetsFile].map((file) => file.close()));
|
|
457
|
+
let mode = options.mode;
|
|
458
|
+
if (!mode || mode === "auto") {
|
|
459
|
+
const common = 44 + compactJSON(plan.fields).length + (plan.records.length + 1) * 8;
|
|
460
|
+
mode = common + denseRowsLength <= common + sparseRowsLength ? "dense" : "sparse";
|
|
461
|
+
}
|
|
462
|
+
const sparse = mode === "sparse";
|
|
463
|
+
const selectedRows = await readFile(sparse ? tempSparse : tempDense);
|
|
464
|
+
const selectedOffsets = await readFile(sparse ? tempSparseOffsets : tempDenseOffsets);
|
|
465
|
+
const rowOffsets = [0];
|
|
466
|
+
for (let offset = 0; offset < selectedOffsets.length; offset += 8) rowOffsets.push(Number(selectedOffsets.readBigUInt64BE(offset)));
|
|
467
|
+
const chosenLength = sparse ? sparseRowsLength : denseRowsLength;
|
|
468
|
+
const ids = plan.records.map((record) => record.id);
|
|
469
|
+
const sequential = ids.every((id, index) => id === String(index + 1));
|
|
470
|
+
const schema = compactJSON(plan.fields);
|
|
471
|
+
const idBytes = sequential ? Buffer.alloc(0) : compactJSON(ids);
|
|
472
|
+
const offsets = Buffer.alloc(rowOffsets.length * 8);
|
|
473
|
+
rowOffsets.forEach((offset, index) => offsets.writeBigUInt64BE(BigInt(offset), index * 8));
|
|
474
|
+
const catalogHeader = Buffer.alloc(44);
|
|
475
|
+
CATALOG_MAGIC.copy(catalogHeader, 0);
|
|
476
|
+
catalogHeader.writeUInt32BE(1, 8);
|
|
477
|
+
catalogHeader.writeUInt32BE(sparse ? 2 : 1, 12);
|
|
478
|
+
catalogHeader.writeBigUInt64BE(BigInt(plan.records.length), 16);
|
|
479
|
+
catalogHeader.writeUInt32BE(plan.fields.length, 24);
|
|
480
|
+
catalogHeader.writeUInt32BE(schema.length, 28);
|
|
481
|
+
catalogHeader.writeUInt32BE(idBytes.length, 32);
|
|
482
|
+
catalogHeader.writeBigUInt64BE(BigInt(chosenLength), 36);
|
|
483
|
+
const rawCatalog = Buffer.concat([catalogHeader, schema, idBytes, offsets, selectedRows]);
|
|
484
|
+
const compressedCatalog = Buffer.from(await lzma.compress(rawCatalog, { preset: compression }));
|
|
485
|
+
const indexHeader = Buffer.alloc(HEADER_SIZE);
|
|
486
|
+
INDEX_MAGIC.copy(indexHeader, 0);
|
|
487
|
+
indexHeader.writeUInt32BE(1, 8);
|
|
488
|
+
indexHeader.writeUInt32BE(compressedCatalog.length, 12);
|
|
489
|
+
indexHeader.writeUInt32BE(rawCatalog.length, 16);
|
|
490
|
+
indexHeader.writeUInt32BE(plan.records.length, 20);
|
|
491
|
+
indexHeader.writeBigUInt64BE(BigInt(dataLength), 24);
|
|
492
|
+
indexHeader.writeUInt32BE(codec === "plain" ? 2 : 1, 32);
|
|
493
|
+
indexHeader.writeUInt32BE(key ? 1 : 0, 36);
|
|
494
|
+
digest.digest().copy(indexHeader, 40);
|
|
495
|
+
const indexBytes = Buffer.concat([indexHeader, compressedCatalog]);
|
|
496
|
+
const indexPath = companion(target, "Index.rida");
|
|
497
|
+
const mapPath = companion(target, "Map.json");
|
|
498
|
+
try {
|
|
499
|
+
await rename(tempData, target);
|
|
500
|
+
await writeFile(indexPath, indexBytes, { flag: "wx" });
|
|
501
|
+
if (key) {
|
|
502
|
+
const keyText = `${key.toString("base64url")}=`;
|
|
503
|
+
await writeFile(companion(target, "Key.rida"), `${keyText}\n`, { flag: "wx", mode: 0o600 });
|
|
504
|
+
}
|
|
505
|
+
if (Object.keys(plan.mapPaths).length) {
|
|
506
|
+
const fieldMap = Object.fromEntries(plan.fields.map((field, i) => [String(i + 1), field]));
|
|
507
|
+
await writeFile(mapPath, `${JSON.stringify({ version: 1, records: plan.recordCount, paths: plan.mapPaths, fields: fieldMap })}\n`, { flag: "wx" });
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
await rm(tempData, { force: true });
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
await Promise.all([tempDense, tempSparse, tempDenseOffsets, tempSparseOffsets].map((path) => rm(path, { force: true })));
|
|
514
|
+
return { output: target, index: indexPath, map: Object.keys(plan.mapPaths).length ? mapPath : null, records: plan.recordCount, fields: plan.fields.length, mode, codec, encrypted: Boolean(key), dataBytes: dataLength, catalogBytes: compressedCatalog.length };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function inspectSource(source) {
|
|
518
|
+
const info = await scan(source);
|
|
519
|
+
return { records: info.recordCount, fields: info.fields, groups: info.mapPaths };
|
|
520
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { RidaReader, version } from "./index.js";
|
|
5
|
+
import { build, scan } from "./builder.js";
|
|
6
|
+
|
|
7
|
+
const HELP = `RIDA Node.js tools ${version}
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
rida scan <json-file-or-folder> [--records-dir FOLDER] [--id-key FIELD] [--named-ids]
|
|
11
|
+
rida build <json-file-or-folder> [--output FILE] [--mode auto|dense|sparse]
|
|
12
|
+
[--codec deflate-per-field|plain] [--compression 0-9]
|
|
13
|
+
[--encrypt | --key KEY_OR_FILE] [--records-dir FOLDER]
|
|
14
|
+
[--combine-groups] [--combined-schema common|union]
|
|
15
|
+
[--id-key FIELD] [--named-ids]
|
|
16
|
+
rida info <data.rida> [--index FILE] [--map FILE]
|
|
17
|
+
rida get <data.rida|URL> <record-id> <field> [--index FILE] [--map FILE] [--key KEY_OR_FILE]
|
|
18
|
+
rida get-many <data.rida|URL> <record-id> <field>... [--index FILE] [--map FILE] [--key KEY_OR_FILE]
|
|
19
|
+
rida record <data.rida|URL> <record-id> [--index FILE] [--map FILE] [--key KEY_OR_FILE]
|
|
20
|
+
rida verify <data.rida|URL> [--index FILE] [--map FILE] [--key KEY_OR_FILE]
|
|
21
|
+
|
|
22
|
+
Folder input: each visible .json file is one record object. Subfolders are
|
|
23
|
+
numbered into stable record IDs and a companion map is written when needed.
|
|
24
|
+
Single JSON input: an object maps record IDs to records; an array needs --id-key.
|
|
25
|
+
Build writes the data, Index.rida, optional Map.json, and optional Key.rida.
|
|
26
|
+
Existing output files are never overwritten.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const VALUE_OPTIONS = new Set(["--output", "--mode", "--codec", "--compression", "--key", "--id-key", "--records-dir", "--index", "--map"]);
|
|
30
|
+
const FLAGS = new Set(["--encrypt", "--named-ids", "--combine-groups", "--help", "-h"]);
|
|
31
|
+
function parseArgs(args) {
|
|
32
|
+
const positional = [];
|
|
33
|
+
const options = {};
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (FLAGS.has(arg)) options[arg] = true;
|
|
37
|
+
else if (VALUE_OPTIONS.has(arg)) {
|
|
38
|
+
const value = args[++i];
|
|
39
|
+
if (!value || value.startsWith("--")) throw new Error(`Missing value after ${arg}`);
|
|
40
|
+
options[arg.slice(2).replaceAll("-", "")] = value;
|
|
41
|
+
} else if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
|
|
42
|
+
else positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
return { positional, options };
|
|
45
|
+
}
|
|
46
|
+
async function resolveKey(key) {
|
|
47
|
+
if (!key) return undefined;
|
|
48
|
+
try { return await readFile(key, "utf8"); }
|
|
49
|
+
catch { return key; }
|
|
50
|
+
}
|
|
51
|
+
function output(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); }
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const args = process.argv.slice(2);
|
|
55
|
+
if (!args.length || ["--help", "-h", "help"].includes(args[0])) { process.stdout.write(HELP); return; }
|
|
56
|
+
if (["--version", "-v", "version"].includes(args[0])) { process.stdout.write(`${version}\n`); return; }
|
|
57
|
+
const command = args.shift();
|
|
58
|
+
const { positional, options } = parseArgs(args);
|
|
59
|
+
if (command === "scan") {
|
|
60
|
+
if (positional.length !== 1) throw new Error(`Usage: rida scan <json-file-or-folder>\n\n${HELP}`);
|
|
61
|
+
const result = await scan(positional[0], { idKey: options.idkey, recordsDir: options.recordsdir, numbered: !options["--named-ids"], combineGroups: options["--combine-groups"], combinedSchema: options.combinedschema ?? "union" });
|
|
62
|
+
output({ records: result.recordCount, fields: result.fields.length, fieldNames: result.fields, mappedFolders: Object.keys(result.mapPaths).length });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (command === "build") {
|
|
66
|
+
if (positional.length !== 1) throw new Error(`Usage: rida build <json-file-or-folder> [options]\n\n${HELP}`);
|
|
67
|
+
const source = positional[0];
|
|
68
|
+
const outputPath = options.output ?? `${basename(resolve(source)).replace(/\.json$/i, "")}.rida`;
|
|
69
|
+
const result = await build(source, outputPath, {
|
|
70
|
+
idKey: options.idkey,
|
|
71
|
+
recordsDir: options.recordsdir,
|
|
72
|
+
combineGroups: Boolean(options["--combine-groups"]),
|
|
73
|
+
combinedSchema: options.combinedschema ?? "union",
|
|
74
|
+
numbered: !options["--named-ids"],
|
|
75
|
+
mode: options.mode ?? "auto",
|
|
76
|
+
codec: options.codec ?? "deflate-per-field",
|
|
77
|
+
compression: options.compression ?? 9,
|
|
78
|
+
encrypt: Boolean(options["--encrypt"]),
|
|
79
|
+
key: await resolveKey(options.key),
|
|
80
|
+
});
|
|
81
|
+
output(result);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const minimum = { info: 1, get: 3, "get-many": 3, record: 2, verify: 1 }[command];
|
|
85
|
+
if (!minimum || positional.length < minimum) throw new Error(`Invalid ${command ?? ""} command.\n\n${HELP}`);
|
|
86
|
+
const [data, recordId, ...fields] = positional;
|
|
87
|
+
const reader = await RidaReader.open(data, { index: options.index, map: options.map, key: await resolveKey(options.key) });
|
|
88
|
+
try {
|
|
89
|
+
if (command === "info") output({ format: "RIDA", version, records: reader.recordCount, fields: reader.fields.length, valueCodec: reader.valueCodec, indexMode: reader.mode, encrypted: reader.info.encrypted, dataBytes: reader.info.dataLength, fieldNames: reader.fields });
|
|
90
|
+
else if (command === "get") output(await reader.get(recordId, fields[0]));
|
|
91
|
+
else if (command === "get-many") output(await reader.getMany(recordId, fields));
|
|
92
|
+
else if (command === "record") output(await reader.getRecord(recordId));
|
|
93
|
+
else output({ valid: await reader.verifyData(), dataBytes: reader.info.dataLength });
|
|
94
|
+
} finally { await reader.close(); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((error) => {
|
|
98
|
+
process.stderr.write(`rida: ${error.message}\n`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createDecipheriv, createHash } from "node:crypto";
|
|
2
|
+
import { open, readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join, parse } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { inflateRawSync } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const lzma = require("lzma-native");
|
|
9
|
+
|
|
10
|
+
export const version = "0.3.3";
|
|
11
|
+
export const MERGE_GAP_LIMIT = 660;
|
|
12
|
+
const INDEX_MAGIC = Buffer.from("RIDAIDX1");
|
|
13
|
+
const CATALOG_MAGIC = Buffer.from("RDACAT01");
|
|
14
|
+
|
|
15
|
+
export class RidaError extends Error {
|
|
16
|
+
constructor(message, options) { super(message, options); this.name = "RidaError"; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function need(condition, message) {
|
|
20
|
+
if (!condition) throw new RidaError(message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readVarint(bytes, start) {
|
|
24
|
+
let value = 0n;
|
|
25
|
+
let shift = 0n;
|
|
26
|
+
let pos = start;
|
|
27
|
+
while (pos < bytes.length && shift <= 63n) {
|
|
28
|
+
const byte = bytes[pos++];
|
|
29
|
+
value |= BigInt(byte & 0x7f) << shift;
|
|
30
|
+
if (!(byte & 0x80)) {
|
|
31
|
+
need(value <= BigInt(Number.MAX_SAFE_INTEGER), "RIDA catalog offset is too large for this Node.js runtime");
|
|
32
|
+
return [Number(value), pos];
|
|
33
|
+
}
|
|
34
|
+
shift += 7n;
|
|
35
|
+
}
|
|
36
|
+
throw new RidaError("Invalid variable-length value in RIDA catalog");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseIndex(raw) {
|
|
40
|
+
need(raw.length >= 72, "RIDA index is incomplete");
|
|
41
|
+
need(raw.subarray(0, 8).equals(INDEX_MAGIC), "Unsupported or invalid RIDA index");
|
|
42
|
+
const versionNo = raw.readUInt32BE(8);
|
|
43
|
+
const catalogLength = raw.readUInt32BE(12);
|
|
44
|
+
const rawCatalogLength = raw.readUInt32BE(16);
|
|
45
|
+
const recordCount = raw.readUInt32BE(20);
|
|
46
|
+
const dataLengthBig = raw.readBigUInt64BE(24);
|
|
47
|
+
need(versionNo === 1, `Unsupported RIDA index version: ${versionNo}`);
|
|
48
|
+
need(dataLengthBig <= BigInt(Number.MAX_SAFE_INTEGER), "RIDA data file is too large for this Node.js runtime");
|
|
49
|
+
const codecNo = raw.readUInt32BE(32);
|
|
50
|
+
need(codecNo === 1 || codecNo === 2, "RIDA index has an unknown value codec");
|
|
51
|
+
const catalog = raw.subarray(72);
|
|
52
|
+
need(catalog.length === catalogLength, "RIDA catalog is incomplete");
|
|
53
|
+
return {
|
|
54
|
+
info: {
|
|
55
|
+
version: 3,
|
|
56
|
+
indexVersion: versionNo,
|
|
57
|
+
recordCount,
|
|
58
|
+
dataLength: Number(dataLengthBig),
|
|
59
|
+
valueCodec: codecNo === 1 ? "deflate-per-field" : "plain",
|
|
60
|
+
encrypted: Boolean(raw.readUInt32BE(36) & 1),
|
|
61
|
+
digest: Buffer.from(raw.subarray(40, 72)),
|
|
62
|
+
catalogLength,
|
|
63
|
+
rawCatalogLength,
|
|
64
|
+
},
|
|
65
|
+
catalog,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function parseCatalog(compressed, info) {
|
|
70
|
+
let raw;
|
|
71
|
+
try { raw = await lzma.decompress(compressed); }
|
|
72
|
+
catch (cause) { throw new RidaError("RIDA catalog cannot be decompressed", { cause }); }
|
|
73
|
+
raw = Buffer.from(raw);
|
|
74
|
+
need(raw.length === info.rawCatalogLength, "RIDA catalog length does not match its index");
|
|
75
|
+
need(raw.length >= 44 && raw.subarray(0, 8).equals(CATALOG_MAGIC), "Unsupported or invalid RIDA catalog");
|
|
76
|
+
need(raw.readUInt32BE(8) === 1, "Unsupported RIDA catalog version");
|
|
77
|
+
const modeCode = raw.readUInt32BE(12);
|
|
78
|
+
need(modeCode === 1 || modeCode === 2, "RIDA catalog has an invalid index mode");
|
|
79
|
+
const recordCountBig = raw.readBigUInt64BE(16);
|
|
80
|
+
need(recordCountBig === BigInt(info.recordCount), "RIDA record count does not match its catalog");
|
|
81
|
+
const fieldCount = raw.readUInt32BE(24);
|
|
82
|
+
const schemaLength = raw.readUInt32BE(28);
|
|
83
|
+
const idsLength = raw.readUInt32BE(32);
|
|
84
|
+
const rowsLengthBig = raw.readBigUInt64BE(36);
|
|
85
|
+
need(rowsLengthBig <= BigInt(Number.MAX_SAFE_INTEGER), "RIDA catalog is too large for this Node.js runtime");
|
|
86
|
+
const rowsLength = Number(rowsLengthBig);
|
|
87
|
+
const expected = 44 + schemaLength + idsLength + (info.recordCount + 1) * 8 + rowsLength;
|
|
88
|
+
need(raw.length === expected, "RIDA catalog layout is invalid");
|
|
89
|
+
const schemaEnd = 44 + schemaLength;
|
|
90
|
+
let fields;
|
|
91
|
+
let ids;
|
|
92
|
+
try {
|
|
93
|
+
fields = JSON.parse(raw.subarray(44, schemaEnd).toString("utf8"));
|
|
94
|
+
ids = idsLength ? JSON.parse(raw.subarray(schemaEnd, schemaEnd + idsLength).toString("utf8")) : [];
|
|
95
|
+
} catch (cause) { throw new RidaError("RIDA catalog JSON is invalid", { cause }); }
|
|
96
|
+
need(Array.isArray(fields) && fields.length === fieldCount && fields.every((x) => typeof x === "string") && new Set(fields).size === fields.length, "RIDA field dictionary is invalid");
|
|
97
|
+
need(Array.isArray(ids) && (ids.length === 0 || ids.length === info.recordCount) && ids.every((x) => typeof x === "string") && new Set(ids).size === ids.length, "RIDA record-ID dictionary is invalid");
|
|
98
|
+
const offsetsStart = schemaEnd + idsLength;
|
|
99
|
+
const rowDataStart = offsetsStart + (info.recordCount + 1) * 8;
|
|
100
|
+
const rowOffsets = [];
|
|
101
|
+
for (let n = 0; n <= info.recordCount; n++) {
|
|
102
|
+
const value = raw.readBigUInt64BE(offsetsStart + n * 8);
|
|
103
|
+
need(value <= BigInt(rowsLength), "RIDA row offset is out of bounds");
|
|
104
|
+
rowOffsets.push(Number(value));
|
|
105
|
+
}
|
|
106
|
+
need(rowOffsets[0] === 0 && rowOffsets.at(-1) === rowsLength, "RIDA row offsets are incomplete");
|
|
107
|
+
for (let n = 1; n < rowOffsets.length; n++) need(rowOffsets[n] >= rowOffsets[n - 1], "RIDA row offsets are not ordered");
|
|
108
|
+
return { raw, fields, fieldIds: new Map(fields.map((name, i) => [name, i])), ids, idToRecord: ids.length ? new Map(ids.map((id, i) => [id, i])) : null, mode: modeCode === 1 ? "dense" : "sparse", recordCount: info.recordCount, rowOffsets, rowDataStart };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getRecordNumber(catalog, recordId) {
|
|
112
|
+
const id = String(recordId);
|
|
113
|
+
if (catalog.idToRecord) {
|
|
114
|
+
const n = catalog.idToRecord.get(id);
|
|
115
|
+
if (n === undefined) throw new RangeError(`Unknown RIDA record ID: ${id}`);
|
|
116
|
+
return n;
|
|
117
|
+
}
|
|
118
|
+
if (!/^[1-9]\d*$/.test(id)) throw new RangeError(`Unknown RIDA record ID: ${id}`);
|
|
119
|
+
const n = Number(id) - 1;
|
|
120
|
+
if (!Number.isSafeInteger(n) || n < 0 || n >= catalog.recordCount) throw new RangeError(`Unknown RIDA record ID: ${id}`);
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function locate(catalog, recordId, wantedNames) {
|
|
125
|
+
const rowNo = getRecordNumber(catalog, recordId);
|
|
126
|
+
const start = catalog.rowOffsets[rowNo];
|
|
127
|
+
const end = catalog.rowOffsets[rowNo + 1];
|
|
128
|
+
const row = catalog.raw.subarray(catalog.rowDataStart + start, catalog.rowDataStart + end);
|
|
129
|
+
let [dataOffset, pos] = readVarint(row, 0);
|
|
130
|
+
const wanted = new Map();
|
|
131
|
+
for (const name of new Set(wantedNames)) {
|
|
132
|
+
const id = catalog.fieldIds.get(name);
|
|
133
|
+
if (id === undefined) throw new RangeError(`Unknown RIDA field: ${name}`);
|
|
134
|
+
wanted.set(id, name);
|
|
135
|
+
}
|
|
136
|
+
const found = new Map();
|
|
137
|
+
if (catalog.mode === "dense") {
|
|
138
|
+
for (let fieldId = 0, valueOffset = dataOffset; fieldId < catalog.fields.length; fieldId++) {
|
|
139
|
+
let length; [length, pos] = readVarint(row, pos);
|
|
140
|
+
if (length) {
|
|
141
|
+
const name = wanted.get(fieldId);
|
|
142
|
+
if (name !== undefined) found.set(name, [valueOffset, length]);
|
|
143
|
+
valueOffset += length;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
let count; [count, pos] = readVarint(row, pos);
|
|
149
|
+
let fieldId = 0;
|
|
150
|
+
for (let i = 0; i < count; i++) {
|
|
151
|
+
let delta; [delta, pos] = readVarint(row, pos);
|
|
152
|
+
let length; [length, pos] = readVarint(row, pos);
|
|
153
|
+
need(!(i > 0 && delta === 0) && length > 0, "RIDA sparse row is invalid");
|
|
154
|
+
fieldId += delta;
|
|
155
|
+
need(fieldId < catalog.fields.length, "RIDA sparse field ID is out of range");
|
|
156
|
+
const name = wanted.get(fieldId);
|
|
157
|
+
if (name !== undefined) found.set(name, [dataOffset, length]);
|
|
158
|
+
dataOffset += length;
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeKey(key) {
|
|
164
|
+
if (key == null) return null;
|
|
165
|
+
if (Buffer.isBuffer(key) || key instanceof Uint8Array) {
|
|
166
|
+
const result = Buffer.from(key);
|
|
167
|
+
need(result.length === 32, "RIDA encryption key must be exactly 32 bytes");
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
const text = String(key).trim();
|
|
171
|
+
let result;
|
|
172
|
+
try { result = Buffer.from(text, "base64"); } catch { result = Buffer.alloc(0); }
|
|
173
|
+
if (result.length !== 32) result = Buffer.from(text, "hex");
|
|
174
|
+
need(result.length === 32, "RIDA key must be 32 bytes or its base64/hex text form");
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function decodeValue(bytes, info, key) {
|
|
179
|
+
let raw = bytes;
|
|
180
|
+
if (info.encrypted) {
|
|
181
|
+
need(key, "This RIDA file is encrypted; provide its private key");
|
|
182
|
+
need(raw.length >= 28, "Encrypted RIDA value is incomplete");
|
|
183
|
+
const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, 12));
|
|
184
|
+
decipher.setAuthTag(raw.subarray(raw.length - 16));
|
|
185
|
+
try { raw = Buffer.concat([decipher.update(raw.subarray(12, -16)), decipher.final()]); }
|
|
186
|
+
catch (cause) { throw new RidaError("RIDA field value could not be decrypted", { cause }); }
|
|
187
|
+
}
|
|
188
|
+
if (info.valueCodec === "deflate-per-field") {
|
|
189
|
+
try { raw = inflateRawSync(raw); }
|
|
190
|
+
catch (cause) { throw new RidaError("RIDA field value cannot be decompressed", { cause }); }
|
|
191
|
+
}
|
|
192
|
+
try { return JSON.parse(raw.toString("utf8")); }
|
|
193
|
+
catch (cause) { throw new RidaError("RIDA field value is not valid JSON", { cause }); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function companionPath(dataPath, suffix) {
|
|
197
|
+
const parsed = parse(dataPath);
|
|
198
|
+
const stem = extname(dataPath) ? basename(dataPath, extname(dataPath)) : parsed.base;
|
|
199
|
+
const clean = stem.endsWith("Data") ? stem.slice(0, -4) : stem;
|
|
200
|
+
return join(dirname(dataPath), `${clean}${suffix}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function remoteIndexUrl(dataUrl) {
|
|
204
|
+
const url = new URL(dataUrl);
|
|
205
|
+
const file = basename(url.pathname);
|
|
206
|
+
const extension = extname(file);
|
|
207
|
+
let stem = extension ? file.slice(0, -extension.length) : file;
|
|
208
|
+
if (stem.endsWith("Data")) stem = stem.slice(0, -4);
|
|
209
|
+
url.pathname = `${dirname(url.pathname)}/${stem}Index.rida`.replaceAll("\\", "/");
|
|
210
|
+
return url.toString();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function bytesFrom(location) {
|
|
214
|
+
if (location instanceof Uint8Array) return Buffer.from(location);
|
|
215
|
+
if (location instanceof URL && location.protocol === "file:") return readFile(location);
|
|
216
|
+
if (typeof location === "string" && /^https?:\/\//i.test(location)) {
|
|
217
|
+
const response = await fetch(location);
|
|
218
|
+
if (!response.ok) throw new RidaError(`Could not fetch ${location}: HTTP ${response.status}`);
|
|
219
|
+
return Buffer.from(await response.arrayBuffer());
|
|
220
|
+
}
|
|
221
|
+
return readFile(location);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export class RidaReader {
|
|
225
|
+
static async open(data, { index, map, key } = {}) {
|
|
226
|
+
const dataIsUrl = typeof data === "string" && /^https?:\/\//i.test(data);
|
|
227
|
+
let indexLocation = index;
|
|
228
|
+
if (!indexLocation) {
|
|
229
|
+
if (dataIsUrl) indexLocation = remoteIndexUrl(data);
|
|
230
|
+
else indexLocation = companionPath(String(data), "Index.rida");
|
|
231
|
+
}
|
|
232
|
+
const [indexBytes, mapBytes] = await Promise.all([bytesFrom(indexLocation), map ? bytesFrom(map) : (dataIsUrl ? Promise.resolve(null) : readFile(companionPath(String(data), "Map.json")).catch(() => null))]);
|
|
233
|
+
const { info, catalog: compressed } = parseIndex(indexBytes);
|
|
234
|
+
const catalog = await parseCatalog(compressed, info);
|
|
235
|
+
const normalizedKey = normalizeKey(key);
|
|
236
|
+
need(!info.encrypted || normalizedKey, "This RIDA file is encrypted; provide its private key");
|
|
237
|
+
let mapping = null;
|
|
238
|
+
if (mapBytes) { try { mapping = JSON.parse(mapBytes.toString("utf8")); } catch { throw new RidaError("RIDA map JSON is invalid"); } }
|
|
239
|
+
let handle = null;
|
|
240
|
+
if (!dataIsUrl) {
|
|
241
|
+
handle = await open(data instanceof URL ? data : String(data), "r");
|
|
242
|
+
const stat = await handle.stat();
|
|
243
|
+
need(stat.size === info.dataLength, "RIDA data file does not match its index");
|
|
244
|
+
}
|
|
245
|
+
return new RidaReader(data, handle, info, catalog, mapping, normalizedKey);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
constructor(data, handle, info, catalog, mapping, key) {
|
|
249
|
+
this.data = data; this.handle = handle; this.info = info; this.catalog = catalog; this.map = mapping; this.key = key;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _range(offset, length) {
|
|
253
|
+
need(Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length > 0 && offset + length <= this.info.dataLength, "Requested RIDA byte range is invalid");
|
|
254
|
+
if (this.handle) {
|
|
255
|
+
const result = Buffer.alloc(length);
|
|
256
|
+
const { bytesRead } = await this.handle.read(result, 0, length, offset);
|
|
257
|
+
need(bytesRead === length, "RIDA data range is incomplete");
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
const response = await fetch(this.data, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
|
|
261
|
+
need(response.status === 206, `RIDA host must support HTTP byte ranges (received ${response.status})`);
|
|
262
|
+
const result = Buffer.from(await response.arrayBuffer());
|
|
263
|
+
need(result.length === length, `RIDA range returned ${result.length} bytes; expected ${length}`);
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get fields() { return [...this.catalog.fields]; }
|
|
268
|
+
get recordCount() { return this.info.recordCount; }
|
|
269
|
+
get valueCodec() { return this.info.valueCodec; }
|
|
270
|
+
get mode() { return this.catalog.mode; }
|
|
271
|
+
|
|
272
|
+
recordsUnder(prefix) {
|
|
273
|
+
const start = `${String(prefix).replace(/\/$/, "")}/`;
|
|
274
|
+
return this.catalog.ids.filter((id) => id.startsWith(start));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
resolve(...names) {
|
|
278
|
+
const wanted = names.map((name) => String(name).trim().toLocaleLowerCase()).filter(Boolean);
|
|
279
|
+
const paths = this.map?.paths;
|
|
280
|
+
if (!wanted.length || !paths || typeof paths !== "object") return null;
|
|
281
|
+
let found = null;
|
|
282
|
+
for (const [prefix, folderName] of Object.entries(paths)) {
|
|
283
|
+
const chain = [];
|
|
284
|
+
const parts = prefix.split("/");
|
|
285
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
286
|
+
const key = parts.slice(0, i).join("/");
|
|
287
|
+
if (typeof paths[key] === "string") chain.push(paths[key].toLocaleLowerCase());
|
|
288
|
+
}
|
|
289
|
+
const last = wanted.at(-1);
|
|
290
|
+
const lastMatch = folderName.toLocaleLowerCase();
|
|
291
|
+
if (!(lastMatch === last || lastMatch.endsWith(last) || lastMatch.includes(last))) continue;
|
|
292
|
+
if (wanted.every((name) => chain.some((part) => part.includes(name))) && (!found || prefix.length > found.length)) found = prefix;
|
|
293
|
+
}
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_resolveId(recordId) {
|
|
298
|
+
const text = String(recordId).replace(/\/$/, "");
|
|
299
|
+
if (!this.map || !text || this.catalog.idToRecord?.has(text)) return recordId;
|
|
300
|
+
const parts = text.split("/");
|
|
301
|
+
if (parts.length > 1 && /^\d+$/.test(parts.at(-1))) {
|
|
302
|
+
const prefix = this.resolve(...parts.slice(0, -1));
|
|
303
|
+
if (prefix) {
|
|
304
|
+
const candidate = `${prefix}/${parts.at(-1)}`;
|
|
305
|
+
if (this.catalog.idToRecord?.has(candidate)) return candidate;
|
|
306
|
+
const descendant = this.recordsUnder(prefix).find((id) => id.split("/").at(-1) === parts.at(-1));
|
|
307
|
+
if (descendant) return descendant;
|
|
308
|
+
return candidate;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const prefix = this.resolve(...parts);
|
|
312
|
+
if (prefix) return this.recordsUnder(prefix)[0] ?? recordId;
|
|
313
|
+
return recordId;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async get(recordId, field) {
|
|
317
|
+
recordId = this._resolveId(recordId);
|
|
318
|
+
const pointer = locate(this.catalog, recordId, [field]).get(field);
|
|
319
|
+
if (!pointer) throw new RangeError(`Field ${field} is absent from record ${recordId}`);
|
|
320
|
+
return decodeValue(await this._range(...pointer), this.info, this.key);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async getMany(recordId, fields) {
|
|
324
|
+
recordId = this._resolveId(recordId);
|
|
325
|
+
const pointers = locate(this.catalog, recordId, fields);
|
|
326
|
+
const sorted = [...pointers.entries()].sort((a, b) => a[1][0] - b[1][0]);
|
|
327
|
+
const groups = [];
|
|
328
|
+
for (const entry of sorted) {
|
|
329
|
+
const [offset, length] = entry[1];
|
|
330
|
+
const last = groups.at(-1);
|
|
331
|
+
if (last && offset - last.end <= MERGE_GAP_LIMIT) {
|
|
332
|
+
last.end = Math.max(last.end, offset + length);
|
|
333
|
+
last.entries.push(entry);
|
|
334
|
+
} else groups.push({ start: offset, end: offset + length, entries: [entry] });
|
|
335
|
+
}
|
|
336
|
+
const out = {};
|
|
337
|
+
for (const group of groups) {
|
|
338
|
+
const block = await this._range(group.start, group.end - group.start);
|
|
339
|
+
for (const [name, [offset, length]] of group.entries) {
|
|
340
|
+
out[name] = decodeValue(block.subarray(offset - group.start, offset - group.start + length), this.info, this.key);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async getRecord(recordId) { return this.getMany(this._resolveId(recordId), this.catalog.fields); }
|
|
347
|
+
|
|
348
|
+
async verifyData() {
|
|
349
|
+
const hash = createHash("sha256");
|
|
350
|
+
const chunkSize = 1024 * 1024;
|
|
351
|
+
for (let offset = 0; offset < this.info.dataLength; offset += chunkSize) {
|
|
352
|
+
hash.update(await this._range(offset, Math.min(chunkSize, this.info.dataLength - offset)));
|
|
353
|
+
}
|
|
354
|
+
return this.info.digest.equals(Buffer.alloc(32)) || hash.digest().equals(this.info.digest);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async close() { await this.handle?.close(); this.handle = null; }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export { decodeValue, parseIndex, parseCatalog };
|