@rangedb/js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +168 -0
  2. package/index.js +332 -0
  3. package/package.json +30 -0
@@ -0,0 +1,168 @@
1
+ export type Compression = number;
2
+ /**
3
+ * @typedef {Object} RangeDBOptions
4
+ * @property {number} [firstReadSize] Specify how much should be read from the file on first call.
5
+ * If known, it can be set to size of header + index. It will save an additional request.
6
+ */
7
+ /**
8
+ * @typedef {Object} Range
9
+ * @property {bigint} [start]
10
+ * @property {bigint} [end]
11
+ */
12
+ /**
13
+ * @enum {number}
14
+ */
15
+ export const Compression: Readonly<{
16
+ none: 0;
17
+ gzip: 1;
18
+ brotli: 2;
19
+ }>;
20
+ export type ContentType = number;
21
+ /**
22
+ * @enum {number}
23
+ */
24
+ export const ContentType: Readonly<{
25
+ unknown: 0;
26
+ json: 1;
27
+ xml: 2;
28
+ }>;
29
+ /**
30
+ * @typedef {Object} Header
31
+ * @property {number} specVersion
32
+ * @property {bigint} metadataOffset
33
+ * @property {number} metadataLength
34
+ * @property {bigint} indexOffset
35
+ * @property {number} indexLength
36
+ * @property {bigint} dataOffset
37
+ * @property {bigint} dataLength
38
+ * @property {Compression} compression
39
+ * @property {ContentType} contentType
40
+ */
41
+ /**
42
+ * @typedef {string | number | boolean | null | JSONObject | JSONArray} JSONValue
43
+ */
44
+ /**
45
+ * @typedef {Array<JSONValue>} JSONArray
46
+ */
47
+ /**
48
+ * @typedef {{ [key: string]: JSONValue }} JSONObject
49
+ */
50
+ export class RangeDB {
51
+ /**
52
+ * Traverse chunk consisting of mulitple key/value pairs and returns value
53
+ * for given key or null if not founded
54
+ * @private
55
+ * @param {ArrayBuffer} chunk
56
+ * @param {bigint} key
57
+ *
58
+ * @returns {ArrayBuffer | null}
59
+ */
60
+ private static findInChunk;
61
+ /**
62
+ * Binary search in index for a given key and return value
63
+ * @param {bigint} key
64
+ * @param {BigUint64Array} index
65
+ * @param {bigint} dataEndOffset one behind data ends
66
+ *
67
+ * @return {Range | null} Offset of data
68
+ */
69
+ static binarySearch(key: bigint, index: BigUint64Array, dataEndOffset: bigint): Range | null;
70
+ /**
71
+ * Initialize database by providing url of rangedb file.
72
+ *
73
+ * @param {string} url
74
+ * @param {RangeDBOptions} options
75
+ */
76
+ constructor(url: string, options?: RangeDBOptions);
77
+ /** @protected @type {string} */
78
+ protected url: string;
79
+ /** @protected @type {string| null} */
80
+ protected etag: string | null;
81
+ /** @private @type {Header | null} */
82
+ private header;
83
+ /** @private @type {BigUint64Array | null} */
84
+ private index;
85
+ /** @private @type {JSONObject| JSONArray | null} */
86
+ private metadata;
87
+ /** @private @type {number} */
88
+ private firstReadSize;
89
+ /**
90
+ * Invalidate header and index.
91
+ *
92
+ * @returns {void}
93
+ */
94
+ invalidate(): void;
95
+ /**
96
+ * Perform HTTP range request.
97
+ *
98
+ * @protected
99
+ * @param {bigint} start
100
+ * @param {bigint} end
101
+ * @returns {Promise<ArrayBuffer>}
102
+ */
103
+ protected readRange(start: bigint, end: bigint): Promise<ArrayBuffer>;
104
+ /**
105
+ * Get header from database or return cached.
106
+ *
107
+ * @returns {Promise<Header>}
108
+ */
109
+ getHeader(): Promise<Header>;
110
+ /**
111
+ * Load index from database or return cached
112
+ *
113
+ * @private
114
+ * @returns {Promise<number>}
115
+ */
116
+ private getIndex;
117
+ /**
118
+ * Get metadata from database or return cached
119
+ *
120
+ * @return {Promise<JSONObject | JSONArray>}
121
+ */
122
+ getMetadata(): Promise<JSONObject | JSONArray>;
123
+ /**
124
+ * Get a raw ArrayBuffer from database for given key or null if not exists
125
+ *
126
+ * @param {bigint} key
127
+ *
128
+ * @returns {Promise<ArrayBuffer | null>}
129
+ * */
130
+ getRaw(key: bigint): Promise<ArrayBuffer | null>;
131
+ /**
132
+ * Get a JSON from database for a given key or null if not exists
133
+ * It may throw JSON parsing error
134
+ *
135
+ * @param {bigint} key
136
+ *
137
+ * @returns {Promise<JSONValue | null>}
138
+ * @throws {SyntaxError}
139
+ */
140
+ getJson(key: bigint): Promise<JSONValue | null>;
141
+ }
142
+ export type RangeDBOptions = {
143
+ /**
144
+ * Specify how much should be read from the file on first call.
145
+ * If known, it can be set to size of header + index. It will save an additional request.
146
+ */
147
+ firstReadSize?: number;
148
+ };
149
+ export type Range = {
150
+ start?: bigint;
151
+ end?: bigint;
152
+ };
153
+ export type Header = {
154
+ specVersion: number;
155
+ metadataOffset: bigint;
156
+ metadataLength: number;
157
+ indexOffset: bigint;
158
+ indexLength: number;
159
+ dataOffset: bigint;
160
+ dataLength: bigint;
161
+ compression: Compression;
162
+ contentType: ContentType;
163
+ };
164
+ export type JSONValue = string | number | boolean | null | JSONObject | JSONArray;
165
+ export type JSONArray = Array<JSONValue>;
166
+ export type JSONObject = {
167
+ [key: string]: JSONValue;
168
+ };
package/index.js ADDED
@@ -0,0 +1,332 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {Object} RangeDBOptions
5
+ * @property {number} [firstReadSize] Specify how much should be read from the file on first call.
6
+ * If known, it can be set to size of header + index. It will save an additional request.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} Range
11
+ * @property {bigint} [start]
12
+ * @property {bigint} [end]
13
+ */
14
+
15
+ /**
16
+ * @enum {number}
17
+ */
18
+ export const Compression = Object.freeze({
19
+ none: 0,
20
+ gzip: 1,
21
+ brotli: 2,
22
+ })
23
+
24
+ /**
25
+ * @enum {number}
26
+ */
27
+ export const ContentType = Object.freeze({
28
+ unknown: 0,
29
+ json: 1,
30
+ xml: 2,
31
+ })
32
+
33
+ /**
34
+ * @typedef {Object} Header
35
+ * @property {number} specVersion
36
+ * @property {bigint} metadataOffset
37
+ * @property {number} metadataLength
38
+ * @property {bigint} indexOffset
39
+ * @property {number} indexLength
40
+ * @property {bigint} dataOffset
41
+ * @property {bigint} dataLength
42
+ * @property {Compression} compression
43
+ * @property {ContentType} contentType
44
+ */
45
+
46
+ /**
47
+ * @typedef {string | number | boolean | null | JSONObject | JSONArray} JSONValue
48
+ */
49
+
50
+ /**
51
+ * @typedef {Array<JSONValue>} JSONArray
52
+ */
53
+
54
+ /**
55
+ * @typedef {{ [key: string]: JSONValue }} JSONObject
56
+ */
57
+
58
+ export class RangeDB {
59
+ /**
60
+ * Initialize database by providing url of rangedb file.
61
+ *
62
+ * @param {string} url
63
+ * @param {RangeDBOptions} options
64
+ */
65
+ constructor(url, options = {}) {
66
+ /** @protected @type {string} */
67
+ this.url = url
68
+
69
+ /** @protected @type {string| null} */
70
+ this.etag = null
71
+
72
+ /** @private @type {Header | null} */
73
+ this.header = null
74
+
75
+ /** @private @type {BigUint64Array | null} */
76
+ this.index = null
77
+
78
+ /** @private @type {JSONObject| JSONArray | null} */
79
+ this.metadata = null
80
+
81
+ /** @private @type {number} */
82
+ this.firstReadSize = options.firstReadSize ?? 64 * 1024
83
+ }
84
+
85
+ /**
86
+ * Invalidate header and index.
87
+ *
88
+ * @returns {void}
89
+ */
90
+ invalidate() {
91
+ this.header = null
92
+ this.index = null
93
+ }
94
+
95
+ /**
96
+ * Perform HTTP range request.
97
+ *
98
+ * @protected
99
+ * @param {bigint} start
100
+ * @param {bigint} end
101
+ * @returns {Promise<ArrayBuffer>}
102
+ */
103
+ async readRange(start, end) {
104
+ const {headers, arrayBuffer} = await fetch(this.url, {
105
+ headers: {
106
+ range: `bytes=${start}-${end}`,
107
+ },
108
+ })
109
+
110
+ const etag = headers.get('etag')
111
+ if (this.etag && this.etag !== etag) {
112
+ this.invalidate()
113
+ throw new Error('Database file has changed based on ETag.')
114
+ }
115
+ this.etag = etag
116
+
117
+ return arrayBuffer()
118
+ }
119
+
120
+ /**
121
+ * Get header from database or return cached.
122
+ *
123
+ * @returns {Promise<Header>}
124
+ */
125
+ async getHeader() {
126
+ if (this.header) {
127
+ return this.header
128
+ }
129
+ const buffer = await this.readRange(0n, BigInt(this.firstReadSize) - 1n)
130
+ const view = new DataView(buffer)
131
+
132
+ const magicNumber =
133
+ view.getUint32(0, false) === 0x52616e67 && // Rang
134
+ view.getUint16(4, false) === 0x6544 && // eD
135
+ view.getUint8(6) === 0x42 // B
136
+
137
+ if (!magicNumber) {
138
+ throw new Error(
139
+ 'Invalid Magic Number: Expected file starting with "RangeDB" or [0x52 0x61 0x6E 0x67 0x65 0x44 0x42]',
140
+ )
141
+ }
142
+
143
+ const specVersion = view.getUint8(7)
144
+ if (specVersion !== 1) {
145
+ throw new Error(`Unsupported spec version. Expected 1 got ${specVersion}`)
146
+ }
147
+ const metadataOffset = view.getBigUint64(8, true)
148
+ const metadataLength = view.getUint32(16, true)
149
+ const indexOffset = view.getBigUint64(20, true)
150
+ const indexLength = view.getUint32(28, true)
151
+ const dataOffset = view.getBigUint64(32, true)
152
+ const dataLength = view.getBigUint64(40, true)
153
+ const compression = view.getInt8(48)
154
+ const contentType = view.getInt8(49)
155
+
156
+ this.header = {
157
+ specVersion,
158
+ metadataOffset,
159
+ metadataLength,
160
+ indexOffset,
161
+ indexLength,
162
+ dataOffset,
163
+ dataLength,
164
+ compression,
165
+ contentType,
166
+ }
167
+ return this.header
168
+ }
169
+
170
+ /**
171
+ * Load index from database or return cached
172
+ *
173
+ * @private
174
+ * @returns {Promise<number>}
175
+ */
176
+ async getIndex() {
177
+ if (this.index) {
178
+ return this.index.length / 2
179
+ }
180
+
181
+ const { indexLength, indexOffset } = await this.getHeader()
182
+
183
+ const buffer = await this.readRange(
184
+ indexOffset,
185
+ indexOffset + BigInt(indexLength) - 1n,
186
+ )
187
+ const view = new DataView(buffer)
188
+
189
+ const indexType = view.getUint8(0)
190
+ if (indexType !== 1) {
191
+ throw new Error(`Unsuported index type: Expected 1 got ${indexType}`)
192
+ }
193
+ const count = view.getUint32(1, true)
194
+
195
+ this.index = new BigUint64Array(buffer, 1 + 4 + 3, count * 2)
196
+ return count
197
+ }
198
+
199
+ /**
200
+ * Get metadata from database or return cached
201
+ *
202
+ * @return {Promise<JSONObject | JSONArray>}
203
+ */
204
+ async getMetadata() {
205
+ if (this.metadata) {
206
+ return this.metadata
207
+ }
208
+
209
+ const { metadataOffset, metadataLength } = await this.getHeader()
210
+ const buffer = await this.readRange(
211
+ metadataOffset,
212
+ metadataOffset + BigInt(metadataLength) - 1n,
213
+ )
214
+ const text = new TextDecoder().decode(buffer)
215
+ this.metadata = JSON.parse(text)
216
+ return this.metadata
217
+ }
218
+
219
+ /**
220
+ * Traverse chunk consisting of mulitple key/value pairs and returns value
221
+ * for given key or null if not founded
222
+ * @private
223
+ * @param {ArrayBuffer} chunk
224
+ * @param {bigint} key
225
+ *
226
+ * @returns {ArrayBuffer | null}
227
+ */
228
+ static findInChunk(key, chunk) {
229
+ const view = new DataView(chunk)
230
+ const length = chunk.byteLength
231
+ let offset = 0
232
+
233
+ // Chunk format
234
+ // [Key: BigUint64][Data Length: UInt32][Data bytes]
235
+ while (offset < length) {
236
+ const recordKey = view.getBigUint64(offset, true)
237
+ if (key < recordKey) {
238
+ return null
239
+ }
240
+ offset += 8
241
+
242
+ const dataLength = view.getUint32(offset, true)
243
+ offset += 4
244
+ if (recordKey === key) {
245
+ return chunk.slice(offset, offset + dataLength)
246
+ }
247
+ offset += dataLength
248
+ }
249
+ return null
250
+ }
251
+
252
+ /**
253
+ * Binary search in index for a given key and return value
254
+ * @param {bigint} key
255
+ * @param {BigUint64Array} index
256
+ * @param {bigint} dataEndOffset one behind data ends
257
+ *
258
+ * @return {Range | null} Offset of data
259
+ */
260
+ static binarySearch(key, index, dataEndOffset) {
261
+ let low = 0,
262
+ high = (index.length >> 1) - 1,
263
+ blockIndex = -1
264
+
265
+ while (low <= high) {
266
+ const midPair = (low + high) >>> 1
267
+ const midIdx = midPair * 2
268
+ const midKey = index[midIdx]
269
+
270
+ if (midKey === key) {
271
+ blockIndex = midIdx
272
+ break
273
+ } else if (midKey < key) {
274
+ blockIndex = midIdx
275
+ low = midPair + 1
276
+ } else {
277
+ high = midPair - 1
278
+ }
279
+ }
280
+
281
+ if (blockIndex === -1) {
282
+ return null
283
+ }
284
+ const start = index[blockIndex + 1]
285
+ const end =
286
+ blockIndex + 2 < index.length ? index[blockIndex + 3] : dataEndOffset
287
+ return {
288
+ start,
289
+ end,
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Get a raw ArrayBuffer from database for given key or null if not exists
295
+ *
296
+ * @param {bigint} key
297
+ *
298
+ * @returns {Promise<ArrayBuffer | null>}
299
+ * */
300
+ async getRaw(key) {
301
+ if (!this.index) {
302
+ await this.getIndex()
303
+ }
304
+ const { dataOffset, dataLength } = this.header
305
+ const range = RangeDB.binarySearch(key, this.index, dataOffset + dataLength)
306
+ if (!range) {
307
+ return null
308
+ }
309
+ const { start, end } = range
310
+ const chunkBuffer = await this.readRange(start, end - 1n)
311
+
312
+ return RangeDB.findInChunk(key, chunkBuffer)
313
+ }
314
+
315
+ /**
316
+ * Get a JSON from database for a given key or null if not exists
317
+ * It may throw JSON parsing error
318
+ *
319
+ * @param {bigint} key
320
+ *
321
+ * @returns {Promise<JSONValue | null>}
322
+ * @throws {SyntaxError}
323
+ */
324
+ async getJson(key) {
325
+ const buffer = await this.getRaw(key)
326
+ if (buffer === null) {
327
+ return null
328
+ }
329
+ const string = new TextDecoder().decode(buffer)
330
+ return JSON.parse(string)
331
+ }
332
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@rangedb/js",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build:types": "npx -p typescript tsc",
15
+ "prepare": "npm run build:types",
16
+ "test": "node --test --experimental-test-coverage",
17
+ "test:watch": "node --test --watch"
18
+ },
19
+ "engines": {
20
+ "node": "^22.0.0 || >=23.6.0"
21
+ },
22
+ "files": [
23
+ "index.js",
24
+ "dist/index.d.ts"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": false
29
+ }
30
+ }