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 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
- [![Inibase banner](./.github/assets/banner.jpg)](https://github.com/inicontent/inibase)
5
+ [![Inibase: TypeScript API, column-file storage, relations, transactions, and durability](./.github/assets/overview.svg)](#usage)
6
6
 
7
7
  [![npmjs](https://img.shields.io/npm/dm/inibase.svg?style=flat)](https://www.npmjs.org/package/inibase) [![License](https://img.shields.io/github/license/inicontent/inibase.svg?style=flat&colorA=18181B&colorB=28CF8D)](./LICENSE) [![Activity](https://img.shields.io/github/commit-activity/m/inicontent/inibase)](https://github.com/inicontent/inibase/pulse) [![GitHub stars](https://img.shields.io/github/stars/inicontent/inibase?style=social)](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
+ [![Database storage comparison for 100,000 CMS records: Inibase gzip uses 1.64 MiB of allocated disk; lower is better](./.github/assets/storage-benchmark-100k.png)](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
- - **Super-Fast** :zap: (built-in caching system)
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
- - **Low memory-usage** :chart_with_downwards_trend: (3-5mb)
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, with only the relevant file being modified.
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
+ ![Bulk POST benchmark comparing plain and computed fields for 10, 100, and 1,000 rows; lower is better](./.github/assets/computed-benchmark.svg)
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) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
164
- export declare const sum: (fp: string, ln?: number | number[]) => Promise<number>;
165
- export declare const avg: (fp: string, ln?: number | number[]) => Promise<number>;
166
- export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
167
- export declare const max: (fp: string, ln?: number | number[]) => Promise<number>;
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 (operator === "=" &&
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
- const decodedLine = decode(line, config);
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
- const num = Number(decode(txt, { key: "BLABLA", type: "number" }));
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());
@@ -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