inibase 3.7.0 → 3.8.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.
- package/README.md +67 -4
- package/dist/file.d.ts +12 -5
- package/dist/file.js +27 -10
- package/dist/format.d.ts +60 -0
- package/dist/format.js +126 -0
- package/dist/index.d.ts +114 -1
- package/dist/index.js +668 -22
- package/dist/overlay.d.ts +59 -0
- package/dist/overlay.js +106 -0
- package/dist/updatelog.d.ts +115 -0
- package/dist/updatelog.js +377 -0
- package/dist/utils.js +16 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,22 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
> A file-based & memory-efficient, serverless relational database with **crash-atomic ACID: single-table DML + multi-table transactions** :fire: — per-table writer locks, write-ahead journal + crash recovery, fsync-backed durability, and a `begin`/`commit`/`rollback` transaction API (exact scope in [Durability & crash safety](#durability--crash-safety)).
|
|
4
4
|
|
|
5
|
-
[](#usage)
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.org/package/inibase) [](./LICENSE) [](https://github.com/inicontent/inibase/pulse) [](https://github.com/inicontent/inibase)
|
|
8
8
|
|
|
9
|
+
**[Quick start](#usage) · [How it works](#how-it-works) · [Durability](#durability--crash-safety) · [Benchmarks](#benchmark)**
|
|
10
|
+
|
|
11
|
+
## Small footprint. Real benchmark results.
|
|
12
|
+
|
|
13
|
+
Inibase is a file-based TypeScript database designed for **fast data access and low memory usage**, with column selection, built-in caching, and optional gzip compression.
|
|
14
|
+
|
|
15
|
+
In the **100,000-record CMS benchmark**, Inibase **3.7.0 with gzip** used **1.64 MiB of allocated disk** — **89.4% less than LevelDB** and **98.7% less than SQLite**. Reading two selected columns took **44.3 ms** in the same workload.
|
|
16
|
+
|
|
17
|
+
[](https://github.com/inicontent/benchmarks/blob/a6c4a644aa5472192fefefe0f3d93b56edc9726a/results/large-cms/REPORT.md)
|
|
18
|
+
|
|
19
|
+
*Source: [inicontent/benchmarks](https://github.com/inicontent/benchmarks/blob/a6c4a644aa5472192fefefe0f3d93b56edc9726a/results/large-cms/REPORT.md), median of three trials on Apple M5 with repetitive synthetic CMS content and full durability. The chart measures disk storage, not RAM. Results depend on the workload; indexed competitors were faster for key lookups.*
|
|
20
|
+
|
|
21
|
+
[Explore the full results](https://github.com/inicontent/benchmarks/blob/a6c4a644aa5472192fefefe0f3d93b56edc9726a/results/large-cms/REPORT.md) · [Read the methodology](https://github.com/inicontent/benchmarks/blob/a6c4a644aa5472192fefefe0f3d93b56edc9726a/METHODOLOGY.md)
|
|
22
|
+
|
|
9
23
|
## Features
|
|
10
24
|
|
|
11
25
|
- **Lightweight** 🪶
|
|
12
26
|
- **Minimalist** :white_circle: (but powerful)
|
|
13
27
|
- **100% TypeScript** :large_blue_diamond:
|
|
14
|
-
- **
|
|
28
|
+
- **Built for fast access** :zap: Built-in caching and column projection
|
|
15
29
|
- **ATOMIC** :lock: Per-table writer locks, write-ahead journal + crash recovery, fsync-backed durability, and multi-table transactions (`begin`/`commit`/`rollback`) for atomic cascades (exact scope below)
|
|
16
30
|
- **Built-in** form validation (+unique values :new: ) :sunglasses:
|
|
17
31
|
- **Suitable for large data** :page_with_curl: (tested with 4M records)
|
|
32
|
+
- **Incremental updates** :recycle: Opt-in update log (`log: true`) appends small frames instead of rewriting whole columns
|
|
18
33
|
- **Support Compression** :eight_spoked_asterisk: (using built-in nodejs zlib)
|
|
19
34
|
- **Support Table Joins** :link:
|
|
20
|
-
- **
|
|
35
|
+
- **Memory-conscious design** :chart_with_downwards_trend: File-based storage and streaming reads
|
|
21
36
|
- **Safe** :lock: (no sql or javascript injections)
|
|
22
37
|
- **Easy to use** :bread:
|
|
23
38
|
- **...** and much more :rocket:
|
|
@@ -70,7 +85,7 @@ const users = await db.get("user", { favoriteFoods: "![]Pizza,Burger" });
|
|
|
70
85
|
|
|
71
86
|
- **POST**: New data is appended to column files efficiently.
|
|
72
87
|
- **GET**: Data retrieval is optimized by reading files line-by-line.
|
|
73
|
-
- **PUT**: Updates are streamlined
|
|
88
|
+
- **PUT**: Updates are streamlined: on a plain table only the relevant column file is rewritten, while a table with the [update log](#incremental-update-log) enabled appends a small frame instead.
|
|
74
89
|
- **DELETE**: Removes lines from column files for swift deletion.
|
|
75
90
|
|
|
76
91
|
This structure ensures efficient storage, retrieval, and updates, making our system scalable and high-performing for diverse datasets and applications.
|
|
@@ -167,6 +182,50 @@ live-reader and stale-lock test suite, `pnpm test:transaction` for the
|
|
|
167
182
|
transaction/cascade/crash-recovery suite, and `pnpm benchmark:durability` for
|
|
168
183
|
the `full` vs `none` throughput comparison.
|
|
169
184
|
|
|
185
|
+
## Incremental update log
|
|
186
|
+
|
|
187
|
+
Opt a table into the append-only **update log** (on-disk format 2) to make `put`
|
|
188
|
+
cheap when a table is large but each update touches few rows:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
await db.createTable("user", userTableSchema, { log: true });
|
|
192
|
+
// or migrate an existing table in place:
|
|
193
|
+
await db.updateTable("user", undefined, { log: true });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- **Writes.** `put` appends one length-prefixed, `crc32`-checked frame
|
|
197
|
+
(`{ id, cols, updatedAt }`) to `updates.log` instead of rewriting column
|
|
198
|
+
files, so the fast path is proportional to the number of updated rows rather
|
|
199
|
+
than the table size. `post` and `delete` still rewrite the base columns.
|
|
200
|
+
- **Reads.** Every read — `get`, criteria, `columns`, sort, pagination,
|
|
201
|
+
aggregates, and uniqueness checks — merges the log over the base columns, so
|
|
202
|
+
results match an un-logged table. With an empty log the merge is a no-op.
|
|
203
|
+
- **Compaction.** `await db.compact(tableName)` folds the log into the base
|
|
204
|
+
columns and resets it, returning the new `baseGen` — or `null` when there was
|
|
205
|
+
nothing to fold. Compaction is crash-safe and semantically neutral: it only
|
|
206
|
+
materializes already-acknowledged updates. It also runs automatically under
|
|
207
|
+
backpressure so the overlay and log stay bounded
|
|
208
|
+
(`INIBASE_MAX_OVERLAY_RECORDS`, default `100000`; `INIBASE_MAX_LOG_BYTES`,
|
|
209
|
+
default 64 MiB); if the bound is hit and compaction cannot run, the write
|
|
210
|
+
fails with `MAINTENANCE_REQUIRED` rather than growing without limit.
|
|
211
|
+
- **Requirements.** The table must use the append layout (no `prepend`) and a
|
|
212
|
+
schema of simple top-level fields. Transactions, schema changes, and
|
|
213
|
+
disabling the log on a log table throw `MAINTENANCE_REQUIRED` for now (they
|
|
214
|
+
need compaction rewiring, planned for 3.9).
|
|
215
|
+
- **On disk.** A `.format` marker records `format`, `baseGen`, and the
|
|
216
|
+
compatibility contract (`minReader`); `updates.log` holds the frames. A table
|
|
217
|
+
with no `.format` is legacy format 1 and behaves exactly as before.
|
|
218
|
+
- **Downgrade safety.** A reader older than the log format ignores the marker
|
|
219
|
+
and would silently serve **stale** base columns. The marker's `minReader`
|
|
220
|
+
makes a conforming engine refuse such a table with `UNSUPPORTED_FORMAT`
|
|
221
|
+
instead of mis-reading it.
|
|
222
|
+
|
|
223
|
+
> [!WARNING]
|
|
224
|
+
> **Backups.** A raw directory copy is only a valid backup when writes are
|
|
225
|
+
> quiesced or the copy is a filesystem snapshot. Copying a logged table *during*
|
|
226
|
+
> writes can capture base columns paired with a mismatched `updates.log` /
|
|
227
|
+
> `baseGen`. Running `compact` first does not remove the need to quiesce writes.
|
|
228
|
+
|
|
170
229
|
## Inibase CLI
|
|
171
230
|
|
|
172
231
|
```shell
|
|
@@ -1086,6 +1145,10 @@ are derived first, in dependency order.
|
|
|
1086
1145
|
|
|
1087
1146
|
### Computed fields
|
|
1088
1147
|
|
|
1148
|
+

|
|
1149
|
+
|
|
1150
|
+
*Charted from the results below, with fsync and journaling enabled. These are recorded sample measurements, not a new benchmark run; hardware and version were not recorded with this table.*
|
|
1151
|
+
|
|
1089
1152
|
Write-time evaluation cost (helpers `sum(3 * 4)`, `avg(4)`, `min(4)`, `max(4)`, `count(3)` over an `items` array with 3 lines, plus an arithmetic field `itemTotal × (314 / 100)`), compared against the identical table without computed fields:
|
|
1090
1153
|
|
|
1091
1154
|
| rows | POST bulk (plain / computed) | POST single (plain / computed) | PUT recompute (plain / computed) |
|
package/dist/file.d.ts
CHANGED
|
@@ -160,9 +160,16 @@ export declare const remove: (filePath: string, linesToDelete: number | number[]
|
|
|
160
160
|
*/
|
|
161
161
|
export declare const search: (filePath: string, operator: ComparisonOperator | ComparisonOperator[], comparedAtValue: string | number | boolean | null | (string | number | boolean | null)[], logicalOperator?: "and" | "or", searchIn?: Set<number>, field?: Field & {
|
|
162
162
|
databasePath?: string;
|
|
163
|
-
}, limit?: number, offset?: number, readWholeFile?: boolean, earlyStop?: boolean
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
}, limit?: number, offset?: number, readWholeFile?: boolean, earlyStop?: boolean,
|
|
164
|
+
/**
|
|
165
|
+
* Per-line raw cell overrides (update-log overlay). When a line is present
|
|
166
|
+
* the overridden raw value is decoded and compared instead of the file's
|
|
167
|
+
* line, so a search reflects un-compacted `put`s. The native whole-line
|
|
168
|
+
* grep fast path is bypassed whenever overrides are supplied.
|
|
169
|
+
*/
|
|
170
|
+
overrides?: Map<number, string | number | boolean | null>) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
|
|
171
|
+
export declare const sum: (fp: string, ln?: number | number[], overrides?: Map<number, string | number | boolean | null>) => Promise<number>;
|
|
172
|
+
export declare const avg: (fp: string, ln?: number | number[], overrides?: Map<number, string | number | boolean | null>) => Promise<number>;
|
|
173
|
+
export declare const min: (fp: string, ln?: number | number[], overrides?: Map<number, string | number | boolean | null>) => Promise<number>;
|
|
174
|
+
export declare const max: (fp: string, ln?: number | number[], overrides?: Map<number, string | number | boolean | null>) => Promise<number>;
|
|
168
175
|
export declare const getFileDate: (path: string) => Promise<Date>;
|
package/dist/file.js
CHANGED
|
@@ -1169,9 +1169,17 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
|
|
|
1169
1169
|
*
|
|
1170
1170
|
* Note: Decodes each line for comparison and can handle complex queries with multiple conditions.
|
|
1171
1171
|
*/
|
|
1172
|
-
export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile, earlyStop
|
|
1172
|
+
export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile, earlyStop,
|
|
1173
|
+
/**
|
|
1174
|
+
* Per-line raw cell overrides (update-log overlay). When a line is present
|
|
1175
|
+
* the overridden raw value is decoded and compared instead of the file's
|
|
1176
|
+
* line, so a search reflects un-compacted `put`s. The native whole-line
|
|
1177
|
+
* grep fast path is bypassed whenever overrides are supplied.
|
|
1178
|
+
*/
|
|
1179
|
+
overrides) => {
|
|
1173
1180
|
// Native fast path for exact-equality searches (whole-line `grep -x -F`).
|
|
1174
|
-
if (
|
|
1181
|
+
if (!overrides?.size &&
|
|
1182
|
+
operator === "=" &&
|
|
1175
1183
|
!Array.isArray(operator) &&
|
|
1176
1184
|
!logicalOperator &&
|
|
1177
1185
|
comparedAtValue !== null &&
|
|
@@ -1217,8 +1225,11 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
|
|
|
1217
1225
|
if (searchIn?.size &&
|
|
1218
1226
|
(!searchIn.has(linesCount) || searchIn.has(-linesCount)))
|
|
1219
1227
|
continue;
|
|
1220
|
-
// Decode the line for comparison.
|
|
1221
|
-
|
|
1228
|
+
// Decode the line for comparison. A per-line overlay override (raw
|
|
1229
|
+
// cell) wins over the file content so searches see un-compacted puts.
|
|
1230
|
+
const decodedLine = decode(overrides?.has(linesCount)
|
|
1231
|
+
? overrides.get(linesCount)
|
|
1232
|
+
: line, config);
|
|
1222
1233
|
// Check if the line meets the specified conditions based on comparison and logical operators.
|
|
1223
1234
|
const doesMeetCondition = (Array.isArray(decodedLine) &&
|
|
1224
1235
|
operator !== "=" &&
|
|
@@ -1270,7 +1281,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
|
|
|
1270
1281
|
*
|
|
1271
1282
|
* @returns Promise<number> The requested metric, or 0 if no numeric value found.
|
|
1272
1283
|
*/
|
|
1273
|
-
async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
|
|
1284
|
+
async function reduceNumbers(filePath, wanted = "sum", lineNumbers, overrides) {
|
|
1274
1285
|
/* optional subset */
|
|
1275
1286
|
const filter = lineNumbers
|
|
1276
1287
|
? new Set(Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers])
|
|
@@ -1290,7 +1301,13 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
|
|
|
1290
1301
|
/* skip unwanted lines */
|
|
1291
1302
|
if (filter && !filter.has(line))
|
|
1292
1303
|
continue;
|
|
1293
|
-
|
|
1304
|
+
// A per-line overlay override (raw cell) wins over the base file so
|
|
1305
|
+
// aggregates reflect un-compacted puts.
|
|
1306
|
+
const raw = overrides?.has(line) ? overrides.get(line) : txt;
|
|
1307
|
+
const num = Number(decode(raw, {
|
|
1308
|
+
key: "BLABLA",
|
|
1309
|
+
type: "number",
|
|
1310
|
+
}));
|
|
1294
1311
|
if (Number.isNaN(num))
|
|
1295
1312
|
continue;
|
|
1296
1313
|
processed++;
|
|
@@ -1324,10 +1341,10 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
|
|
|
1324
1341
|
: max;
|
|
1325
1342
|
}
|
|
1326
1343
|
/* Optional convenience wrappers (signatures unchanged) */
|
|
1327
|
-
export const sum = (fp, ln) => reduceNumbers(fp, "sum", ln);
|
|
1328
|
-
export const avg = (fp, ln) => reduceNumbers(fp, "avg", ln);
|
|
1329
|
-
export const min = (fp, ln) => reduceNumbers(fp, "min", ln);
|
|
1330
|
-
export const max = (fp, ln) => reduceNumbers(fp, "max", ln);
|
|
1344
|
+
export const sum = (fp, ln, overrides) => reduceNumbers(fp, "sum", ln, overrides);
|
|
1345
|
+
export const avg = (fp, ln, overrides) => reduceNumbers(fp, "avg", ln, overrides);
|
|
1346
|
+
export const min = (fp, ln, overrides) => reduceNumbers(fp, "min", ln, overrides);
|
|
1347
|
+
export const max = (fp, ln, overrides) => reduceNumbers(fp, "max", ln, overrides);
|
|
1331
1348
|
export const getFileDate = (path) => stat(path)
|
|
1332
1349
|
.then((s) => s.mtime || s.birthtime)
|
|
1333
1350
|
.catch(() => new Date());
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk format marker for a table directory.
|
|
3
|
+
*
|
|
4
|
+
* Format 1 is the implicit legacy format (no `.format` file): base columns plus
|
|
5
|
+
* pagination only. Format 2 opts the table into the append-only update log
|
|
6
|
+
* written by `put` (see `updatelog.ts`).
|
|
7
|
+
*
|
|
8
|
+
* The marker is the single source of truth for "does this table have a log?".
|
|
9
|
+
* It also carries the compatibility contract read by a later 3.7.1 detector:
|
|
10
|
+
* `minReader` is the oldest engine that may open the table, `detector` the
|
|
11
|
+
* release that hard-fails instead of silently mis-reading. `baseGen` is the
|
|
12
|
+
* generation the base columns were last compacted at; it is bumped by
|
|
13
|
+
* compaction (3.9) so a stale in-memory overlay can detect that its base moved
|
|
14
|
+
* and rebuild instead of replaying frames that are already merged.
|
|
15
|
+
*/
|
|
16
|
+
export declare const FORMAT_FILE = ".format";
|
|
17
|
+
export interface TableFormat {
|
|
18
|
+
format: number;
|
|
19
|
+
log: boolean;
|
|
20
|
+
baseGen: number;
|
|
21
|
+
/** Oldest engine that may open the table, as written into the marker. */
|
|
22
|
+
minReader?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const LEGACY_FORMAT: TableFormat;
|
|
25
|
+
export declare const CURRENT_FORMAT_VERSION = 2;
|
|
26
|
+
export declare const ENGINE_NAME = "inibase";
|
|
27
|
+
/** This build's engine version, compared against a marker's `minReader`. */
|
|
28
|
+
export declare const ENGINE_VERSION = "3.8.0";
|
|
29
|
+
/** Oldest reader that understands format 2. */
|
|
30
|
+
export declare const MIN_READER = "3.8.0";
|
|
31
|
+
/** Release expected to detect (and refuse) format 2 rather than mis-read it. */
|
|
32
|
+
export declare const DETECTOR = "3.7.1";
|
|
33
|
+
/** Compare dotted numeric versions: >0 when `a > b`, <0 when `a < b`, else 0. */
|
|
34
|
+
export declare const compareVersions: (a: string, b: string) => number;
|
|
35
|
+
/**
|
|
36
|
+
* Why this engine cannot open a marker, or null when it can.
|
|
37
|
+
*
|
|
38
|
+
* Refusing *before* any column is read keeps a newer-format table from being
|
|
39
|
+
* silently mis-read as format 2 (or legacy). Two independent conditions:
|
|
40
|
+
* a `format` above what this build writes, or a `minReader` above the running
|
|
41
|
+
* engine version.
|
|
42
|
+
*/
|
|
43
|
+
export declare const unsupportedFormatReason: (format: TableFormat) => string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Read a table's format marker. A missing or unreadable marker is legacy
|
|
46
|
+
* (format 1), which keeps every pre-3.8 table byte-for-byte compatible.
|
|
47
|
+
*/
|
|
48
|
+
export declare const readFormat: (tablePath: string) => Promise<TableFormat>;
|
|
49
|
+
/** The exact bytes of a format marker (shared by write and compaction commit). */
|
|
50
|
+
export declare const serializeFormat: (format: TableFormat) => string;
|
|
51
|
+
/**
|
|
52
|
+
* Write (or replace) the format marker durably and atomically (§9): stage the
|
|
53
|
+
* bytes in the table's `.tmp` dir, fsync the temp, rename it into place, then
|
|
54
|
+
* fsync the table directory. A crash can therefore leave either the old marker
|
|
55
|
+
* or the new one, never a partial file — a torn marker would read back as
|
|
56
|
+
* legacy and silently drop the log.
|
|
57
|
+
*/
|
|
58
|
+
export declare const writeFormat: (tablePath: string, format: TableFormat) => Promise<void>;
|
|
59
|
+
/** Remove the marker (used when downgrading a table back to legacy). */
|
|
60
|
+
export declare const removeFormat: (tablePath: string) => Promise<void>;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as File from "./file.js";
|
|
5
|
+
/**
|
|
6
|
+
* On-disk format marker for a table directory.
|
|
7
|
+
*
|
|
8
|
+
* Format 1 is the implicit legacy format (no `.format` file): base columns plus
|
|
9
|
+
* pagination only. Format 2 opts the table into the append-only update log
|
|
10
|
+
* written by `put` (see `updatelog.ts`).
|
|
11
|
+
*
|
|
12
|
+
* The marker is the single source of truth for "does this table have a log?".
|
|
13
|
+
* It also carries the compatibility contract read by a later 3.7.1 detector:
|
|
14
|
+
* `minReader` is the oldest engine that may open the table, `detector` the
|
|
15
|
+
* release that hard-fails instead of silently mis-reading. `baseGen` is the
|
|
16
|
+
* generation the base columns were last compacted at; it is bumped by
|
|
17
|
+
* compaction (3.9) so a stale in-memory overlay can detect that its base moved
|
|
18
|
+
* and rebuild instead of replaying frames that are already merged.
|
|
19
|
+
*/
|
|
20
|
+
export const FORMAT_FILE = ".format";
|
|
21
|
+
export const LEGACY_FORMAT = {
|
|
22
|
+
format: 1,
|
|
23
|
+
log: false,
|
|
24
|
+
baseGen: 0,
|
|
25
|
+
};
|
|
26
|
+
export const CURRENT_FORMAT_VERSION = 2;
|
|
27
|
+
export const ENGINE_NAME = "inibase";
|
|
28
|
+
/** This build's engine version, compared against a marker's `minReader`. */
|
|
29
|
+
export const ENGINE_VERSION = "3.8.0";
|
|
30
|
+
/** Oldest reader that understands format 2. */
|
|
31
|
+
export const MIN_READER = "3.8.0";
|
|
32
|
+
/** Release expected to detect (and refuse) format 2 rather than mis-read it. */
|
|
33
|
+
export const DETECTOR = "3.7.1";
|
|
34
|
+
/** Compare dotted numeric versions: >0 when `a > b`, <0 when `a < b`, else 0. */
|
|
35
|
+
export const compareVersions = (a, b) => {
|
|
36
|
+
const pa = a.split(".");
|
|
37
|
+
const pb = b.split(".");
|
|
38
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
39
|
+
const diff = (Number(pa[i]) || 0) - (Number(pb[i]) || 0);
|
|
40
|
+
if (diff !== 0)
|
|
41
|
+
return diff > 0 ? 1 : -1;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Why this engine cannot open a marker, or null when it can.
|
|
47
|
+
*
|
|
48
|
+
* Refusing *before* any column is read keeps a newer-format table from being
|
|
49
|
+
* silently mis-read as format 2 (or legacy). Two independent conditions:
|
|
50
|
+
* a `format` above what this build writes, or a `minReader` above the running
|
|
51
|
+
* engine version.
|
|
52
|
+
*/
|
|
53
|
+
export const unsupportedFormatReason = (format) => {
|
|
54
|
+
if (format.format > CURRENT_FORMAT_VERSION)
|
|
55
|
+
return `format ${format.format} (this engine reads up to ${CURRENT_FORMAT_VERSION})`;
|
|
56
|
+
if (format.minReader && compareVersions(format.minReader, ENGINE_VERSION) > 0)
|
|
57
|
+
return `minReader ${format.minReader} (this engine is ${ENGINE_VERSION})`;
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
const formatPath = (tablePath) => join(tablePath, FORMAT_FILE);
|
|
61
|
+
/**
|
|
62
|
+
* Read a table's format marker. A missing or unreadable marker is legacy
|
|
63
|
+
* (format 1), which keeps every pre-3.8 table byte-for-byte compatible.
|
|
64
|
+
*/
|
|
65
|
+
export const readFormat = async (tablePath) => {
|
|
66
|
+
if (!(await File.isExists(formatPath(tablePath))))
|
|
67
|
+
return { ...LEGACY_FORMAT };
|
|
68
|
+
try {
|
|
69
|
+
const raw = JSON.parse(await readFile(formatPath(tablePath), "utf8"));
|
|
70
|
+
return {
|
|
71
|
+
// Accept both the current `format` key and the earlier `version`
|
|
72
|
+
// spelling so a marker written by any 3.8 prerelease still opens.
|
|
73
|
+
format: Number(raw?.format ?? raw?.version) || LEGACY_FORMAT.format,
|
|
74
|
+
log: raw?.log === true,
|
|
75
|
+
baseGen: Number(raw?.baseGen) || 0,
|
|
76
|
+
minReader: typeof raw?.minReader === "string" ? raw.minReader : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// A corrupt marker means we cannot trust "no log": refusing to write
|
|
81
|
+
// over it is safer, but reading must still work, so treat as legacy and
|
|
82
|
+
// let the write path surface UNSUPPORTED_FORMAT.
|
|
83
|
+
return { ...LEGACY_FORMAT };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/** The exact bytes of a format marker (shared by write and compaction commit). */
|
|
87
|
+
export const serializeFormat = (format) => `${JSON.stringify({
|
|
88
|
+
engine: ENGINE_NAME,
|
|
89
|
+
format: format.format,
|
|
90
|
+
minReader: MIN_READER,
|
|
91
|
+
detector: DETECTOR,
|
|
92
|
+
layout: "append",
|
|
93
|
+
log: format.log,
|
|
94
|
+
baseGen: format.baseGen,
|
|
95
|
+
})}\n`;
|
|
96
|
+
/**
|
|
97
|
+
* Write (or replace) the format marker durably and atomically (§9): stage the
|
|
98
|
+
* bytes in the table's `.tmp` dir, fsync the temp, rename it into place, then
|
|
99
|
+
* fsync the table directory. A crash can therefore leave either the old marker
|
|
100
|
+
* or the new one, never a partial file — a torn marker would read back as
|
|
101
|
+
* legacy and silently drop the log.
|
|
102
|
+
*/
|
|
103
|
+
export const writeFormat = async (tablePath, format) => {
|
|
104
|
+
const path = formatPath(tablePath);
|
|
105
|
+
const tmp = join(tablePath, ".tmp", `${FORMAT_FILE}.${randomUUID()}`);
|
|
106
|
+
try {
|
|
107
|
+
await File.write(tmp, serializeFormat(format));
|
|
108
|
+
await File.syncFile(tmp);
|
|
109
|
+
await rename(tmp, path);
|
|
110
|
+
await File.syncDir(tablePath);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
await unlink(tmp).catch(() => { });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** Remove the marker (used when downgrading a table back to legacy). */
|
|
118
|
+
export const removeFormat = async (tablePath) => {
|
|
119
|
+
const path = formatPath(tablePath);
|
|
120
|
+
try {
|
|
121
|
+
await unlink(path);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Already absent.
|
|
125
|
+
}
|
|
126
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -44,6 +44,14 @@ export interface TableConfig {
|
|
|
44
44
|
cache?: boolean;
|
|
45
45
|
prepend?: boolean;
|
|
46
46
|
decodeID?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Opt a table into the incremental update log (format 2): `put` appends a
|
|
49
|
+
* small frame instead of rewriting whole columns, and reads merge the log
|
|
50
|
+
* over the base columns. Requires a schema of simple top-level fields and
|
|
51
|
+
* is incompatible with `prepend`, transactions and DDL on the table
|
|
52
|
+
* (those need compaction, shipped in 3.9).
|
|
53
|
+
*/
|
|
54
|
+
log?: boolean;
|
|
47
55
|
}
|
|
48
56
|
export interface TableObject {
|
|
49
57
|
schema?: Schema;
|
|
@@ -88,7 +96,7 @@ declare global {
|
|
|
88
96
|
entries<T extends object>(o: T): Entries<T>;
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
|
-
export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME", "COMPUTED_FIELD_SYNTAX", "COMPUTED_FIELD_UNKNOWN_FIELD", "COMPUTED_FIELD_INVALID_LINK", "COMPUTED_FIELD_INVALID_TARGET", "COMPUTED_FIELD_CONFLICT", "COMPUTED_FIELD_CYCLE", "COMPUTED_FIELD_SETTABLE", "COMPUTED_FIELD_DANGLING_LINK", "COMPUTED_FIELD_ARITHMETIC"];
|
|
99
|
+
export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME", "COMPUTED_FIELD_SYNTAX", "COMPUTED_FIELD_UNKNOWN_FIELD", "COMPUTED_FIELD_INVALID_LINK", "COMPUTED_FIELD_INVALID_TARGET", "COMPUTED_FIELD_CONFLICT", "COMPUTED_FIELD_CYCLE", "COMPUTED_FIELD_SETTABLE", "COMPUTED_FIELD_DANGLING_LINK", "COMPUTED_FIELD_ARITHMETIC", "UPDATE_LOG_CORRUPT", "UPDATE_LOG_FRAME_TOO_LARGE", "MAINTENANCE_REQUIRED", "UNSUPPORTED_FORMAT"];
|
|
92
100
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
93
101
|
export type ErrorLang = "en" | "ar" | "fr" | "es";
|
|
94
102
|
export declare const globalConfig: {
|
|
@@ -121,6 +129,15 @@ export default class Inibase {
|
|
|
121
129
|
* changes: see the invalidation in `getTable`'s reload branch,
|
|
122
130
|
* `createTable` and `updateTableLocked`. Bounded by the number of tables. */
|
|
123
131
|
private readonly computedPlanCache;
|
|
132
|
+
/** Per-table `.format` marker + live update-log overlays (format 2 tables).
|
|
133
|
+
* Keyed by `databasePath\0tableName` so two databases in one process never
|
|
134
|
+
* share state. Populated lazily by `getOverlay`. */
|
|
135
|
+
private readonly tableFormats;
|
|
136
|
+
private readonly overlays;
|
|
137
|
+
/** Last observed identity of each table's `.format` file (`mtime:size:ino`),
|
|
138
|
+
* used to detect a compaction by another process without re-reading the
|
|
139
|
+
* marker on every access. */
|
|
140
|
+
private readonly formatStamps;
|
|
124
141
|
private databasePath;
|
|
125
142
|
private uniqueMap;
|
|
126
143
|
private schemaFileExtension;
|
|
@@ -176,6 +193,74 @@ export default class Inibase {
|
|
|
176
193
|
* @return {*} {Promise<TableObject | undefined>}
|
|
177
194
|
*/
|
|
178
195
|
getTable(tableName: string): Promise<TableObject | undefined>;
|
|
196
|
+
private overlayKey;
|
|
197
|
+
/** True when the table opted into the incremental update log. */
|
|
198
|
+
private isLogTable;
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a table's format marker, revalidating a cached *log* table when
|
|
201
|
+
* its `.format` file changed underneath us. A compaction by another process
|
|
202
|
+
* bumps `baseGen` and empties the log; without re-reading the marker this
|
|
203
|
+
* process would scan the new (empty) log with the old `baseGen`, and once a
|
|
204
|
+
* post-compaction frame appears the generation gap would look like
|
|
205
|
+
* corruption. Legacy tables never change format implicitly, so they skip
|
|
206
|
+
* the stat entirely and keep the pre-3.8 read path cost.
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* Read a table's `.format` marker and refuse a table written by a newer
|
|
210
|
+
* engine (§4.1) *before* any column is read: a higher `format`, or a
|
|
211
|
+
* `minReader` above this build, throws UNSUPPORTED_FORMAT rather than being
|
|
212
|
+
* silently mis-read as format 2/legacy.
|
|
213
|
+
*/
|
|
214
|
+
private readValidatedFormat;
|
|
215
|
+
private currentFormat;
|
|
216
|
+
/**
|
|
217
|
+
* Load (or refresh) a table's live overlay. Returns null for legacy tables
|
|
218
|
+
* (no `.format` marker) so every existing code path stays untouched. With
|
|
219
|
+
* `forWrite`, repairs a torn tail and rebuilds to the committed boundary;
|
|
220
|
+
* the caller must already hold the table writer lock.
|
|
221
|
+
*/
|
|
222
|
+
private getOverlay;
|
|
223
|
+
private stampFormat;
|
|
224
|
+
/**
|
|
225
|
+
* The format-2 update log stores one encoded cell per changed *top-level
|
|
226
|
+
* simple* column; nested (object/array/table) and computed fields are not
|
|
227
|
+
* representable without a compaction step (3.9). A log table whose schema
|
|
228
|
+
* violates this is rejected at DDL time so the write path can always take
|
|
229
|
+
* the log and never needs to interleave a whole-column rewrite.
|
|
230
|
+
*/
|
|
231
|
+
private assertLogCompatibleSchema;
|
|
232
|
+
private idFieldOf;
|
|
233
|
+
/** Read the internal numeric id for each requested line (stable overlay key). */
|
|
234
|
+
private overlayLineIds;
|
|
235
|
+
/** Internal id -> current line number(s), built from the id column. */
|
|
236
|
+
private overlayIdToLines;
|
|
237
|
+
/**
|
|
238
|
+
* Per-line raw-cell overrides for one column, from the overlay. Used by
|
|
239
|
+
* `File.search` so criteria, uniqueness and aggregate scans see the merged
|
|
240
|
+
* (un-compacted) values. Returns undefined when the table has no overlay or
|
|
241
|
+
* the column is never overlaid.
|
|
242
|
+
*/
|
|
243
|
+
private overlayColumnOverrides;
|
|
244
|
+
/**
|
|
245
|
+
* Fold every logged update into the base columns and reset the log.
|
|
246
|
+
*
|
|
247
|
+
* Safe to call on any table: legacy tables (no `.format` marker) are a
|
|
248
|
+
* no-op. Semantically neutral — it only materializes already-acknowledged
|
|
249
|
+
* updates, so a failure never makes an acknowledged `put` disappear.
|
|
250
|
+
* Returns the new `baseGen` for a compacted table, or `null` when there was
|
|
251
|
+
* nothing to fold.
|
|
252
|
+
*/
|
|
253
|
+
compact(tableName: string): Promise<number | null>;
|
|
254
|
+
/**
|
|
255
|
+
* Compaction core. The caller must hold the table writer lock. Streams each
|
|
256
|
+
* overlaid base column through `File.replace` (which handles compression),
|
|
257
|
+
* then publishes the new columns, an empty `updates.log`, and a `.format`
|
|
258
|
+
* marker with the bumped generation in one journaled commit, so a crash
|
|
259
|
+
* either rolls every file back (old base + old log + old `baseGen`) or
|
|
260
|
+
* forward (new base + empty log + new `baseGen`). No acknowledged update is
|
|
261
|
+
* ever lost.
|
|
262
|
+
*/
|
|
263
|
+
private compactLogTable;
|
|
179
264
|
getTableSchema(tableName: string): Promise<Schema>;
|
|
180
265
|
private throwErrorIfTableEmpty;
|
|
181
266
|
validateData(data: Data | Data[], schema: Schema, skipRequiredField?: boolean): void;
|
|
@@ -190,6 +275,17 @@ export default class Inibase {
|
|
|
190
275
|
private joinPathesContents;
|
|
191
276
|
private _processSchemaDataHelper;
|
|
192
277
|
private processSchemaData;
|
|
278
|
+
/**
|
|
279
|
+
* Fold the table's un-compacted update-log overlay onto decoded rows.
|
|
280
|
+
*
|
|
281
|
+
* `RETURN` is keyed by line number and its `id` is the user-facing id, so
|
|
282
|
+
* the overlay (keyed by the internal numeric id) is matched through a fresh
|
|
283
|
+
* id-column read of just the requested lines. Only columns present in
|
|
284
|
+
* `schema` (which is already narrowed to the requested `columns`) are
|
|
285
|
+
* written, so a partial projection never grows extra keys. Legacy tables
|
|
286
|
+
* (no `.format` marker) return immediately.
|
|
287
|
+
*/
|
|
288
|
+
private mergeOverlayIntoRows;
|
|
193
289
|
/**
|
|
194
290
|
* Extract the raw expression from a field's `computed` property (string or
|
|
195
291
|
* persisted `{ expr, ast }` spec).
|
|
@@ -382,6 +478,13 @@ export default class Inibase {
|
|
|
382
478
|
get<_TData extends Record<string, any> & Partial<Data>>(tableName: string, where: string | number | (string | number)[] | Criteria | undefined, options: Options | undefined, onlyOne: true, onlyLinesNumbers: true, _whereIsLinesNumbers?: boolean): Promise<number | null>;
|
|
383
479
|
private _get;
|
|
384
480
|
private getOnce;
|
|
481
|
+
/**
|
|
482
|
+
* In-process sort fallback for log tables with a non-empty overlay: the
|
|
483
|
+
* shell `paste | sort | awk` path reads base cells and would miss puts.
|
|
484
|
+
* Reads the merged rows for the (optionally filtered) lines, sorts them by
|
|
485
|
+
* the requested keys with type-aware comparison, then paginates.
|
|
486
|
+
*/
|
|
487
|
+
private getOnceSortedWithOverlay;
|
|
385
488
|
/**
|
|
386
489
|
* Create new item(s) in a table
|
|
387
490
|
*
|
|
@@ -414,6 +517,16 @@ export default class Inibase {
|
|
|
414
517
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | null>;
|
|
415
518
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData)[] | null>;
|
|
416
519
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData) | (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | (Data & TData)[] | null>;
|
|
520
|
+
/**
|
|
521
|
+
* Incremental `put` for format-2 (log) tables.
|
|
522
|
+
*
|
|
523
|
+
* Resolves the target rows to their stable internal ids, validates the
|
|
524
|
+
* payload against the merged view (uniqueness included), and appends one
|
|
525
|
+
* bounded frame instead of rewriting whole columns. The base files are left
|
|
526
|
+
* untouched; readers merge the overlay. Returns `undefined` when nothing
|
|
527
|
+
* matches, matching the legacy `put` contract.
|
|
528
|
+
*/
|
|
529
|
+
private putViaLog;
|
|
417
530
|
private _put;
|
|
418
531
|
/**
|
|
419
532
|
* Delete item(s) in a table
|