inibase 3.6.0 → 3.7.1

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,36 @@
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)
18
32
  - **Support Compression** :eight_spoked_asterisk: (using built-in nodejs zlib)
19
33
  - **Support Table Joins** :link:
20
- - **Low memory-usage** :chart_with_downwards_trend: (3-5mb)
34
+ - **Memory-conscious design** :chart_with_downwards_trend: File-based storage and streaming reads
21
35
  - **Safe** :lock: (no sql or javascript injections)
22
36
  - **Easy to use** :bread:
23
37
  - **...** and much more :rocket:
@@ -1086,6 +1100,10 @@ are derived first, in dependency order.
1086
1100
 
1087
1101
  ### Computed fields
1088
1102
 
1103
+ ![Bulk POST benchmark comparing plain and computed fields for 10, 100, and 1,000 rows; lower is better](./.github/assets/computed-benchmark.svg)
1104
+
1105
+ *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.*
1106
+
1089
1107
  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
1108
 
1091
1109
  | rows | POST bulk (plain / computed) | POST single (plain / computed) | PUT recompute (plain / computed) |
package/dist/file.d.ts CHANGED
@@ -88,6 +88,20 @@ export declare function get(filePath: string, lineNumbers: undefined | number |
88
88
  Record<number, string | number | boolean | null | undefined | (string | number | boolean | (string | number | boolean)[] | null)[]> | null,
89
89
  number
90
90
  ]>;
91
+ /**
92
+ * Reads only the requested line numbers of one column file and decodes each
93
+ * with `field`. Unlike the shell `sed -n` reader this stops as soon as the last
94
+ * requested line has been seen, so a point lookup never scans the column to
95
+ * EOF, and it decodes each column independently (no tab-delimited `paste`
96
+ * alignment to break on values that contain a literal tab). Compression is
97
+ * handled transparently: `.gz` columns are gunzipped on the fly.
98
+ *
99
+ * Every resource (readline, gunzip/source stream, file handle) is released on
100
+ * both the normal and the early-exit path.
101
+ */
102
+ export declare const getLines: (filePath: string, lineNumbers: number[], field?: Field & {
103
+ databasePath?: string;
104
+ }) => Promise<Record<number, ReturnType<typeof decode>>>;
91
105
  /**
92
106
  * Asynchronously replaces specific lines in a file based on the provided replacements map or string.
93
107
  *
@@ -146,9 +160,16 @@ export declare const remove: (filePath: string, linesToDelete: number | number[]
146
160
  */
147
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 & {
148
162
  databasePath?: string;
149
- }, limit?: number, offset?: number, readWholeFile?: boolean) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
150
- export declare const sum: (fp: string, ln?: number | number[]) => Promise<number>;
151
- export declare const avg: (fp: string, ln?: number | number[]) => Promise<number>;
152
- export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
153
- 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>;
154
175
  export declare const getFileDate: (path: string) => Promise<Date>;
package/dist/file.js CHANGED
@@ -343,6 +343,16 @@ export function escapeShellPath(filePath) {
343
343
  */
344
344
  let tempSeq = 0;
345
345
  const tempPathFor = (filePath) => filePath.replace(/([^/]+)\/?$/, `.tmp/$1.${process.pid}-${(tempSeq++).toString(36)}`);
346
+ /**
347
+ * Cleanup-and-rethrow for a failed column write. A partial temp file must never
348
+ * be left behind, and the caller must observe the failure so it can abort the
349
+ * operation instead of publishing a column that is missing a line — which would
350
+ * desync the pagination row count from the column files.
351
+ */
352
+ const failWrite = async (fileTempPath, error) => {
353
+ await unlink(fileTempPath).catch(() => { });
354
+ throw error;
355
+ };
346
356
  const _pipeline = async (filePath, rl, writeStream, transform) => {
347
357
  if (filePath.endsWith(".gz"))
348
358
  await pipeline(rl, transform, createGzip(), writeStream);
@@ -361,6 +371,30 @@ const createReadLineInternface = (filePath, fileHandle) => createInterface({
361
371
  : fileHandle.createReadStream(),
362
372
  crlfDelay: Number.POSITIVE_INFINITY,
363
373
  });
374
+ /**
375
+ * Like {@link createReadLineInternface} but also hands back a disposer that
376
+ * tears down the readline interface and both the source and (for `.gz`
377
+ * columns) gunzip streams. Sparse readers call the disposer in their `finally`
378
+ * so an early `break` — the whole point of a point lookup — cannot leave a read
379
+ * stream (and its libuv handle) dangling, which would keep the process alive.
380
+ */
381
+ const openReadline = (filePath, fileHandle) => {
382
+ const source = fileHandle.createReadStream();
383
+ const input = filePath.endsWith(".gz") ? source.pipe(createGunzip()) : source;
384
+ const rl = createInterface({
385
+ input,
386
+ crlfDelay: Number.POSITIVE_INFINITY,
387
+ });
388
+ return {
389
+ rl,
390
+ destroy: () => {
391
+ rl.close();
392
+ input.destroy();
393
+ if (input !== source)
394
+ source.destroy();
395
+ },
396
+ };
397
+ };
364
398
  /**
365
399
  * Checks if a file or directory exists at the specified path.
366
400
  *
@@ -619,6 +653,45 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
619
653
  await fileHandle?.close();
620
654
  }
621
655
  }
656
+ /**
657
+ * Reads only the requested line numbers of one column file and decodes each
658
+ * with `field`. Unlike the shell `sed -n` reader this stops as soon as the last
659
+ * requested line has been seen, so a point lookup never scans the column to
660
+ * EOF, and it decodes each column independently (no tab-delimited `paste`
661
+ * alignment to break on values that contain a literal tab). Compression is
662
+ * handled transparently: `.gz` columns are gunzipped on the fly.
663
+ *
664
+ * Every resource (readline, gunzip/source stream, file handle) is released on
665
+ * both the normal and the early-exit path.
666
+ */
667
+ export const getLines = async (filePath, lineNumbers, field) => {
668
+ const lines = {};
669
+ if (!lineNumbers.length)
670
+ return lines;
671
+ const wanted = new Set(lineNumbers);
672
+ const last = Math.max(...lineNumbers);
673
+ const config = field ?? {
674
+ key: "BLABLA",
675
+ type: "string",
676
+ };
677
+ const fileHandle = await open(filePath, "r");
678
+ const { rl, destroy } = openReadline(filePath, fileHandle);
679
+ try {
680
+ let linesCount = 0;
681
+ for await (const line of rl) {
682
+ linesCount++;
683
+ if (wanted.has(linesCount))
684
+ lines[linesCount] = decode(line, config);
685
+ if (linesCount >= last)
686
+ break;
687
+ }
688
+ return lines;
689
+ }
690
+ finally {
691
+ destroy();
692
+ await fileHandle.close().catch(() => { });
693
+ }
694
+ };
622
695
  /**
623
696
  * Asynchronously replaces specific lines in a file based on the provided replacements map or string.
624
697
  *
@@ -675,8 +748,8 @@ export const replace = async (filePath, replacements, totalItems) => {
675
748
  }));
676
749
  return [fileTempPath, filePath];
677
750
  }
678
- catch {
679
- return [fileTempPath, null];
751
+ catch (error) {
752
+ return failWrite(fileTempPath, error);
680
753
  }
681
754
  finally {
682
755
  // Ensure that file handles are closed, even if an error occurred
@@ -695,8 +768,8 @@ export const replace = async (filePath, replacements, totalItems) => {
695
768
  await exec(command);
696
769
  return [fileTempPath, filePath];
697
770
  }
698
- catch {
699
- return [fileTempPath, null];
771
+ catch (error) {
772
+ return failWrite(fileTempPath, error);
700
773
  }
701
774
  }
702
775
  }
@@ -736,8 +809,8 @@ export const replace = async (filePath, replacements, totalItems) => {
736
809
  }
737
810
  return [fileTempPath, filePath];
738
811
  }
739
- catch {
740
- return [fileTempPath, null];
812
+ catch (error) {
813
+ return failWrite(fileTempPath, error);
741
814
  }
742
815
  }
743
816
  return [];
@@ -759,10 +832,12 @@ export const append = async (filePath, data) => {
759
832
  await appendFile(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
760
833
  }
761
834
  else {
762
- const escapedFileTempPath = escapeShellPath(fileTempPath);
763
- await exec(`echo '${(Array.isArray(data) ? data.join("\n") : data)
764
- .toString()
765
- .replace(/'/g, "\\'")}' | gzip - >> ${escapedFileTempPath}`);
835
+ // Append a complete gzip member. Readers gunzip every member in
836
+ // sequence (multi-member stream), and this avoids the shell's
837
+ // argument-length limits and single-quote escaping the old
838
+ // `echo … | gzip - >> file` pipeline suffered from.
839
+ const text = `${Array.isArray(data) ? data.join("\n") : data}\n`;
840
+ await appendFile(fileTempPath, await gzip(text));
766
841
  }
767
842
  }
768
843
  else
@@ -775,8 +850,8 @@ export const append = async (filePath, data) => {
775
850
  : `${Array.isArray(data) ? data.join("\n") : data}\n`);
776
851
  return [fileTempPath, filePath];
777
852
  }
778
- catch {
779
- return [fileTempPath, null];
853
+ catch (error) {
854
+ return failWrite(fileTempPath, error);
780
855
  }
781
856
  };
782
857
  /**
@@ -808,8 +883,8 @@ export const prepend = async (filePath, data) => {
808
883
  },
809
884
  }));
810
885
  }
811
- catch {
812
- return [fileTempPath, null];
886
+ catch (error) {
887
+ return failWrite(fileTempPath, error);
813
888
  }
814
889
  finally {
815
890
  // Ensure that file handles are closed, even if an error occurred
@@ -826,8 +901,8 @@ export const prepend = async (filePath, data) => {
826
901
  const escapedFileChildTempPath = escapeShellPath(fileChildTempPath);
827
902
  await exec(`cat ${escapedFileChildTempPath} ${escapedFilePath} > ${escapedFileTempPath}`);
828
903
  }
829
- catch {
830
- return [fileTempPath, null];
904
+ catch (error) {
905
+ return failWrite(fileTempPath, error);
831
906
  }
832
907
  finally {
833
908
  await unlink(fileChildTempPath);
@@ -843,8 +918,8 @@ export const prepend = async (filePath, data) => {
843
918
  ? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
844
919
  : `${Array.isArray(data) ? data.join("\n") : data}\n`);
845
920
  }
846
- catch {
847
- return [fileTempPath, null];
921
+ catch (error) {
922
+ return failWrite(fileTempPath, error);
848
923
  }
849
924
  }
850
925
  return [fileTempPath, filePath];
@@ -874,8 +949,8 @@ export const remove = async (filePath, linesToDelete) => {
874
949
  await exec(command);
875
950
  return [fileTempPath, filePath];
876
951
  }
877
- catch {
878
- return [fileTempPath, null];
952
+ catch (error) {
953
+ return failWrite(fileTempPath, error);
879
954
  }
880
955
  };
881
956
  /**
@@ -1094,9 +1169,17 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
1094
1169
  *
1095
1170
  * Note: Decodes each line for comparison and can handle complex queries with multiple conditions.
1096
1171
  */
1097
- export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
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) => {
1098
1180
  // Native fast path for exact-equality searches (whole-line `grep -x -F`).
1099
- if (operator === "=" &&
1181
+ if (!overrides?.size &&
1182
+ operator === "=" &&
1100
1183
  !Array.isArray(operator) &&
1101
1184
  !logicalOperator &&
1102
1185
  comparedAtValue !== null &&
@@ -1116,6 +1199,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1116
1199
  let linesCount = 0;
1117
1200
  const linesNumbers = new Set();
1118
1201
  let fileHandle = null;
1202
+ let destroyStreams = () => { };
1119
1203
  const config = field ?? {
1120
1204
  key: "BLABLA",
1121
1205
  type: "string",
@@ -1131,7 +1215,8 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1131
1215
  // Open the file for reading.
1132
1216
  fileHandle = await open(filePath, "r");
1133
1217
  // Create a Readline interface to read the file line by line.
1134
- const rl = createReadLineInternface(filePath, fileHandle);
1218
+ const { rl, destroy } = openReadline(filePath, fileHandle);
1219
+ destroyStreams = destroy;
1135
1220
  // Iterate through each line in the file.
1136
1221
  for await (const line of rl) {
1137
1222
  // Increment the line count for each line.
@@ -1140,8 +1225,11 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1140
1225
  if (searchIn?.size &&
1141
1226
  (!searchIn.has(linesCount) || searchIn.has(-linesCount)))
1142
1227
  continue;
1143
- // Decode the line for comparison.
1144
- 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);
1145
1233
  // Check if the line meets the specified conditions based on comparison and logical operators.
1146
1234
  const doesMeetCondition = (Array.isArray(decodedLine) &&
1147
1235
  operator !== "=" &&
@@ -1162,6 +1250,10 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1162
1250
  }
1163
1251
  // Store the decoded line in the result object.
1164
1252
  matchingLines[linesCount] = decodedLine;
1253
+ // Every requested match is now in hand: stop instead of
1254
+ // scanning the rest of the column (id -> line resolution).
1255
+ if (earlyStop && limit && linesNumbers.size >= limit)
1256
+ break;
1165
1257
  }
1166
1258
  }
1167
1259
  // Convert the Map to an object using Object.fromEntries and return the result.
@@ -1173,7 +1265,9 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1173
1265
  return [null, 0, null];
1174
1266
  }
1175
1267
  finally {
1176
- // Close the file handle in the finally block to ensure it is closed even if an error occurs.
1268
+ // Tear down the read streams (an early `break` must not leave a handle
1269
+ // open) and then the file handle, even if an error occurred.
1270
+ destroyStreams();
1177
1271
  await fileHandle?.close();
1178
1272
  }
1179
1273
  };
@@ -1187,7 +1281,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1187
1281
  *
1188
1282
  * @returns Promise<number> The requested metric, or 0 if no numeric value found.
1189
1283
  */
1190
- async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
1284
+ async function reduceNumbers(filePath, wanted = "sum", lineNumbers, overrides) {
1191
1285
  /* optional subset */
1192
1286
  const filter = lineNumbers
1193
1287
  ? new Set(Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers])
@@ -1207,7 +1301,13 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
1207
1301
  /* skip unwanted lines */
1208
1302
  if (filter && !filter.has(line))
1209
1303
  continue;
1210
- 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
+ }));
1211
1311
  if (Number.isNaN(num))
1212
1312
  continue;
1213
1313
  processed++;
@@ -1241,10 +1341,10 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
1241
1341
  : max;
1242
1342
  }
1243
1343
  /* Optional convenience wrappers (signatures unchanged) */
1244
- export const sum = (fp, ln) => reduceNumbers(fp, "sum", ln);
1245
- export const avg = (fp, ln) => reduceNumbers(fp, "avg", ln);
1246
- export const min = (fp, ln) => reduceNumbers(fp, "min", ln);
1247
- 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);
1248
1348
  export const getFileDate = (path) => stat(path)
1249
1349
  .then((s) => s.mtime || s.birthtime)
1250
1350
  .catch(() => new Date());
@@ -0,0 +1,54 @@
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
+ /** Write (or replace) the format marker, fsync-backed. */
52
+ export declare const writeFormat: (tablePath: string, format: TableFormat) => Promise<void>;
53
+ /** Remove the marker (used when downgrading a table back to legacy). */
54
+ export declare const removeFormat: (tablePath: string) => Promise<void>;
package/dist/format.js ADDED
@@ -0,0 +1,108 @@
1
+ import { readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import * as File from "./file.js";
4
+ /**
5
+ * On-disk format marker for a table directory.
6
+ *
7
+ * Format 1 is the implicit legacy format (no `.format` file): base columns plus
8
+ * pagination only. Format 2 opts the table into the append-only update log
9
+ * written by `put` (see `updatelog.ts`).
10
+ *
11
+ * The marker is the single source of truth for "does this table have a log?".
12
+ * It also carries the compatibility contract read by a later 3.7.1 detector:
13
+ * `minReader` is the oldest engine that may open the table, `detector` the
14
+ * release that hard-fails instead of silently mis-reading. `baseGen` is the
15
+ * generation the base columns were last compacted at; it is bumped by
16
+ * compaction (3.9) so a stale in-memory overlay can detect that its base moved
17
+ * and rebuild instead of replaying frames that are already merged.
18
+ */
19
+ export const FORMAT_FILE = ".format";
20
+ export const LEGACY_FORMAT = {
21
+ format: 1,
22
+ log: false,
23
+ baseGen: 0,
24
+ };
25
+ export const CURRENT_FORMAT_VERSION = 2;
26
+ export const ENGINE_NAME = "inibase";
27
+ /** This build's engine version, compared against a marker's `minReader`. */
28
+ export const ENGINE_VERSION = "3.8.0";
29
+ /** Oldest reader that understands format 2. */
30
+ export const MIN_READER = "3.8.0";
31
+ /** Release expected to detect (and refuse) format 2 rather than mis-read it. */
32
+ export const DETECTOR = "3.7.1";
33
+ /** Compare dotted numeric versions: >0 when `a > b`, <0 when `a < b`, else 0. */
34
+ export const compareVersions = (a, b) => {
35
+ const pa = a.split(".");
36
+ const pb = b.split(".");
37
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
38
+ const diff = (Number(pa[i]) || 0) - (Number(pb[i]) || 0);
39
+ if (diff !== 0)
40
+ return diff > 0 ? 1 : -1;
41
+ }
42
+ return 0;
43
+ };
44
+ /**
45
+ * Why this engine cannot open a marker, or null when it can.
46
+ *
47
+ * Refusing *before* any column is read keeps a newer-format table from being
48
+ * silently mis-read as format 2 (or legacy). Two independent conditions:
49
+ * a `format` above what this build writes, or a `minReader` above the running
50
+ * engine version.
51
+ */
52
+ export const unsupportedFormatReason = (format) => {
53
+ if (format.format > CURRENT_FORMAT_VERSION)
54
+ return `format ${format.format} (this engine reads up to ${CURRENT_FORMAT_VERSION})`;
55
+ if (format.minReader && compareVersions(format.minReader, ENGINE_VERSION) > 0)
56
+ return `minReader ${format.minReader} (this engine is ${ENGINE_VERSION})`;
57
+ return null;
58
+ };
59
+ const formatPath = (tablePath) => join(tablePath, FORMAT_FILE);
60
+ /**
61
+ * Read a table's format marker. A missing or unreadable marker is legacy
62
+ * (format 1), which keeps every pre-3.8 table byte-for-byte compatible.
63
+ */
64
+ export const readFormat = async (tablePath) => {
65
+ if (!(await File.isExists(formatPath(tablePath))))
66
+ return { ...LEGACY_FORMAT };
67
+ try {
68
+ const raw = JSON.parse(await readFile(formatPath(tablePath), "utf8"));
69
+ return {
70
+ // Accept both the current `format` key and the earlier `version`
71
+ // spelling so a marker written by any 3.8 prerelease still opens.
72
+ format: Number(raw?.format ?? raw?.version) || LEGACY_FORMAT.format,
73
+ log: raw?.log === true,
74
+ baseGen: Number(raw?.baseGen) || 0,
75
+ minReader: typeof raw?.minReader === "string" ? raw.minReader : undefined,
76
+ };
77
+ }
78
+ catch {
79
+ // A corrupt marker means we cannot trust "no log": refusing to write
80
+ // over it is safer, but reading must still work, so treat as legacy and
81
+ // let the write path surface UNSUPPORTED_FORMAT.
82
+ return { ...LEGACY_FORMAT };
83
+ }
84
+ };
85
+ /** The exact bytes of a format marker (shared by write and compaction commit). */
86
+ export const serializeFormat = (format) => `${JSON.stringify({
87
+ engine: ENGINE_NAME,
88
+ format: format.format,
89
+ minReader: MIN_READER,
90
+ detector: DETECTOR,
91
+ layout: "append",
92
+ log: format.log,
93
+ baseGen: format.baseGen,
94
+ })}\n`;
95
+ /** Write (or replace) the format marker, fsync-backed. */
96
+ export const writeFormat = async (tablePath, format) => {
97
+ await File.write(formatPath(tablePath), serializeFormat(format));
98
+ };
99
+ /** Remove the marker (used when downgrading a table back to legacy). */
100
+ export const removeFormat = async (tablePath) => {
101
+ const path = formatPath(tablePath);
102
+ try {
103
+ await unlink(path);
104
+ }
105
+ catch {
106
+ // Already absent.
107
+ }
108
+ };