inibase 3.7.1 → 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
@@ -29,6 +29,7 @@ In the **100,000-record CMS benchmark**, Inibase **3.7.0 with gzip** used **1.64
29
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)
30
30
  - **Built-in** form validation (+unique values :new: ) :sunglasses:
31
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
32
33
  - **Support Compression** :eight_spoked_asterisk: (using built-in nodejs zlib)
33
34
  - **Support Table Joins** :link:
34
35
  - **Memory-conscious design** :chart_with_downwards_trend: File-based storage and streaming reads
@@ -84,7 +85,7 @@ const users = await db.get("user", { favoriteFoods: "![]Pizza,Burger" });
84
85
 
85
86
  - **POST**: New data is appended to column files efficiently.
86
87
  - **GET**: Data retrieval is optimized by reading files line-by-line.
87
- - **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.
88
89
  - **DELETE**: Removes lines from column files for swift deletion.
89
90
 
90
91
  This structure ensures efficient storage, retrieval, and updates, making our system scalable and high-performing for diverse datasets and applications.
@@ -181,6 +182,50 @@ live-reader and stale-lock test suite, `pnpm test:transaction` for the
181
182
  transaction/cascade/crash-recovery suite, and `pnpm benchmark:durability` for
182
183
  the `full` vs `none` throughput comparison.
183
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
+
184
229
  ## Inibase CLI
185
230
 
186
231
  ```shell
package/dist/format.d.ts CHANGED
@@ -48,7 +48,13 @@ export declare const unsupportedFormatReason: (format: TableFormat) => string |
48
48
  export declare const readFormat: (tablePath: string) => Promise<TableFormat>;
49
49
  /** The exact bytes of a format marker (shared by write and compaction commit). */
50
50
  export declare const serializeFormat: (format: TableFormat) => string;
51
- /** Write (or replace) the format marker, fsync-backed. */
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
+ */
52
58
  export declare const writeFormat: (tablePath: string, format: TableFormat) => Promise<void>;
53
59
  /** Remove the marker (used when downgrading a table back to legacy). */
54
60
  export declare const removeFormat: (tablePath: string) => Promise<void>;
package/dist/format.js CHANGED
@@ -1,4 +1,5 @@
1
- import { readFile, unlink } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, rename, unlink } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import * as File from "./file.js";
4
5
  /**
@@ -92,9 +93,26 @@ export const serializeFormat = (format) => `${JSON.stringify({
92
93
  log: format.log,
93
94
  baseGen: format.baseGen,
94
95
  })}\n`;
95
- /** Write (or replace) the format marker, fsync-backed. */
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
+ */
96
103
  export const writeFormat = async (tablePath, format) => {
97
- await File.write(formatPath(tablePath), serializeFormat(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
+ }
98
116
  };
99
117
  /** Remove the marker (used when downgrading a table back to legacy). */
100
118
  export const removeFormat = async (tablePath) => {
package/dist/index.js CHANGED
@@ -295,12 +295,14 @@ export default class Inibase {
295
295
  throw this.createError("UNSUPPORTED_FORMAT", "append layout (no prepend)");
296
296
  if (schema)
297
297
  this.assertLogCompatibleSchema(schema);
298
- await File.write(join(tablePath, UPDATES_LOG_NAME), "");
298
+ // Publish the marker before the log: a missing log reads as an
299
+ // empty log, whereas a log without a marker would be legacy.
299
300
  await writeFormat(tablePath, {
300
301
  format: CURRENT_FORMAT_VERSION,
301
302
  log: true,
302
303
  baseGen: 0,
303
304
  });
305
+ await File.write(join(tablePath, UPDATES_LOG_NAME), "");
304
306
  this.tableFormats.set(tableName, {
305
307
  format: CURRENT_FORMAT_VERSION,
306
308
  log: true,
@@ -397,12 +399,31 @@ export default class Inibase {
397
399
  throw this.createError("UNSUPPORTED_FORMAT", "append layout (no prepend)");
398
400
  if (table.schema?.length)
399
401
  this.assertLogCompatibleSchema(table.schema);
400
- await File.write(join(tablePath, UPDATES_LOG_NAME), "");
401
- await writeFormat(tablePath, {
402
- format: CURRENT_FORMAT_VERSION,
403
- log: true,
404
- baseGen: 0,
405
- });
402
+ // Durable migration (§9): publish the marker durably and atomically
403
+ // *before* the table is treated as log-format. Format 2 shares the
404
+ // format-1 base layout, so no column rewrite is needed. A missing
405
+ // `updates.log` reads as an empty log, so creating it after the
406
+ // marker is safe.
407
+ try {
408
+ await writeFormat(tablePath, {
409
+ format: CURRENT_FORMAT_VERSION,
410
+ log: true,
411
+ baseGen: 0,
412
+ });
413
+ await File.write(join(tablePath, UPDATES_LOG_NAME), "");
414
+ await File.syncDir(tablePath);
415
+ }
416
+ catch (error) {
417
+ // Re-read the marker to decide: if it never became visible the
418
+ // table is unchanged and must stay legacy; if it did become
419
+ // visible, keep treating it as log-format rather than writing
420
+ // through the legacy path over a log-format table.
421
+ const landed = await readFormat(tablePath);
422
+ if (!landed.log) {
423
+ this.tableFormats.delete(tableName);
424
+ throw error;
425
+ }
426
+ }
406
427
  this.tableFormats.set(tableName, {
407
428
  format: CURRENT_FORMAT_VERSION,
408
429
  log: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "3.7.1",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",