inibase 2.0.1 → 3.1.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
@@ -1,6 +1,6 @@
1
1
  # Inibase :pencil:
2
2
 
3
- > A file-based & memory-efficient, serverless, ACID compliant, relational database management system :fire:
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
5
  [![Inibase banner](./.github/assets/banner.jpg)](https://github.com/inicontent/inibase)
6
6
 
@@ -12,7 +12,7 @@
12
12
  - **Minimalist** :white_circle: (but powerful)
13
13
  - **100% TypeScript** :large_blue_diamond:
14
14
  - **Super-Fast** :zap: (built-in caching system)
15
- - **ATOMIC** :lock: File lock for writing
15
+ - **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
16
  - **Built-in** form validation (+unique values :new: ) :sunglasses:
17
17
  - **Suitable for large data** :page_with_curl: (tested with 4M records)
18
18
  - **Support Compression** :eight_spoked_asterisk: (using built-in nodejs zlib)
@@ -75,6 +75,98 @@ const users = await db.get("user", { favoriteFoods: "![]Pizza,Burger" });
75
75
 
76
76
  This structure ensures efficient storage, retrieval, and updates, making our system scalable and high-performing for diverse datasets and applications.
77
77
 
78
+ ## Durability & crash safety
79
+
80
+ > [!IMPORTANT]
81
+ > **DML (post / put / delete / truncate) is crash-atomic per table.** A logical
82
+ > operation spans many column files plus a pagination-metadata file; all of
83
+ > those files are committed as one journaled unit, so a table is never observed
84
+ > half-applied. **Multi-table atomicity** is available explicitly through the
85
+ > transaction API (`begin` / `commit` / `rollback`, see below) — cascade
86
+ > deletes and multi-table writes are atomic *inside* a transaction, and
87
+ > best-effort without one.
88
+
89
+ ### How each property is provided
90
+
91
+ | Property | Mechanism |
92
+ |---|---|
93
+ | **A**tomicity | Write-ahead journal: one journal per table (`.tmp/journal.jsonl`) for single-table DML, plus a database journal (`<db>/.tmp/journal.jsonl`) that transactions append `op` entries to. Intent is fsynced *before* any live file changes; a `commit` marker is fsynced after publication. On crash: no commit marker → roll back (restore backups), commit present → roll forward (complete swaps, discard backups). Recovery runs automatically under the lock before any mutation/read. |
94
+ | **C**onsistency | Schema validation + uniqueness enforcement happen before anything is written; every operation is atomic, so a table is never observed half-applied. |
95
+ | **I**solation | **One writer lock per table** serializes every mutation (single-host and multi-host / NFS); a transaction holds the writer lock of every table it touches for its whole lifetime, plus a database lock to serialize transactions. Reads are **lock-free** with optimistic version-retry: each read snapshots the identity of every column + pagination file, and is re-run if anything changed mid-scan — a reader never sees torn rows. |
96
+ | **D**urability | Durability knob `INIBASE_DURABILITY=full` (default) fsyncs the temp file, the journal (begin/commit), and the affected directories (incl. shell `sed`/`gzip` temp paths and stream pipelines). DDL (create/update table, compression & prepend toggles) is fsynced too. `INIBASE_DURABILITY=none` skips **every** fsync while keeping the exact journal protocol — the data is process-crash safe (a crash leaves an in-flight journal that recovery rolls back or forward) but **not** power-loss safe, because the OS page cache can be lost. |
97
+
98
+ **Commit-point ordering:** the pagination metadata rename happens *first* (the atomic publication point — the row count flips in a single rename that lock-free readers observe), then column files are swapped `live → backup` + `tmp → live`.
99
+
100
+ ### Transactions (multi-table atomicity)
101
+
102
+ ```js
103
+ await db.begin(["orders", "invoices"]); // pre-lock tables in sorted order (deadlock-free)
104
+ try {
105
+ await db.post("orders", order);
106
+ await db.post("invoices", invoice);
107
+ await db.commit(); // publish everything crash-atomically
108
+ } catch {
109
+ await db.rollback(); // discard everything; no live file was touched
110
+ }
111
+ ```
112
+
113
+ - **Semantics:** mutations issued inside a transaction are *staged* — their
114
+ temps are fsynced and an `op` entry appended to the database journal — but no
115
+ live file changes until `commit()`. `commit()` publishes every staged
116
+ mutation (per table: pagination rename first, then column swaps) and ends
117
+ with a single fsynced `commit` marker, making the whole set atomic with
118
+ respect to crashes and process kills. `rollback()` removes the temps and the
119
+ journal without touching any live file.
120
+ - **Cascade:** a `delete` inside a transaction cascades into referencing tables
121
+ *through the same journal*, so the whole parent + children removal is atomic;
122
+ a failure during the cascade aborts the transaction.
123
+ - **Rules & limits (v1):**
124
+ - **No read-your-writes:** reads inside a transaction observe the last
125
+ committed state, not staged writes.
126
+ - **One mutation per table per transaction.** A second post/put/delete on a
127
+ table already staged in the same transaction throws `INVALID_PARAMETERS`
128
+ (composing same-table writes would require read-your-writes).
129
+ - **DDL is not allowed inside a transaction** (`createTable` / `updateTable`
130
+ throw `INVALID_PARAMETERS`).
131
+ - `begin` without a table list locks tables on first touch, in first-touch
132
+ order; list the tables up front to get sorted, cycle-free ordering.
133
+ - `returnPostedData` inside a transaction returns the formatted staged rows
134
+ (no joins); `returnUpdatedData` inside a transaction throws
135
+ `INVALID_PARAMETERS`.
136
+ - Transactions are excluded from structural lock-upgrade deadlocks, but two
137
+ long transactions that touch overlapping tables can only deadlock in the
138
+ first-touch-order case (pre-listing avoids it).
139
+
140
+ ### Scope & caveats
141
+
142
+ - **Best-effort without a transaction.** Cross-table cascade deletes outside a
143
+ transaction stay best-effort: referencing rows are removed *after* the
144
+ primary delete commits, and a crash in between can leave orphaned references.
145
+ Wrap them in `begin`/`commit` for atomicity.
146
+ - **NFS:** lock files use `O_EXCL` creation, which is advisory on some NFS
147
+ servers — two hosts may briefly both believe they hold a lock. Locks store
148
+ `{pid, host, startedAt}`: a same-host owner that is **provably dead**
149
+ (`kill(pid, 0)` fails) is stolen immediately; foreign/unknown owners fall
150
+ back to the TTL (`INIBASE_LOCK_TTL_MS`, default `60000`).
151
+ - **fsync guarantees:** durability assumes the OS/filesystem honours `fsync`.
152
+ Some disks and virtualized filesystems silently ignore it; directory `fsync`
153
+ is unsupported on a few platforms (best-effort there). `none` mode
154
+ deliberately gives up power-loss durability — that is the whole point of the
155
+ knob.
156
+ - **Cost:** at `full`, every mutation fsyncs the temp file, journal, and
157
+ directories — expect noticeably slower hot-path writes than pre-durability
158
+ builds (the benchmark compares `full` vs `none` side by side). The swap
159
+ protocol also transiently holds temp + backup + live copies (~2–3× a
160
+ column's size; worst case is a `put` with no `where` — a full-table rewrite).
161
+ - **Cache (`.cache`):** entries are derived, rebuildable artifacts versioned by
162
+ the row count; they detect staleness but never participate in the ACID
163
+ guarantee. Non-fsync'd.
164
+
165
+ Run `pnpm test:durability` for the journal-recovery, multi-process writer,
166
+ live-reader and stale-lock test suite, `pnpm test:transaction` for the
167
+ transaction/cascade/crash-recovery suite, and `pnpm benchmark:durability` for
168
+ the `full` vs `none` throughput comparison.
169
+
78
170
  ## Inibase CLI
79
171
 
80
172
  ```shell
@@ -766,6 +858,97 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
766
858
  </blockquote>
767
859
  </details>
768
860
 
861
+ <details>
862
+ <summary>Computed Fields</summary>
863
+ <blockquote>
864
+
865
+ A `computed` column is derived from other columns of the same row whenever the
866
+ row is written (`post` / `put`, including backfill on `updateTable`). The
867
+ result is stored in its own column file like any other value, so reads and
868
+ criteria queries work unchanged.
869
+
870
+ ```ts
871
+ const db = new Inibase("/databaseName");
872
+
873
+ await db.createTable("product", [
874
+ { key: "name", type: "string" },
875
+ { key: "price", type: "number" },
876
+ ]);
877
+
878
+ // ids are assigned per table in schema order: customer=1, status=2, items=3,
879
+ // product=4, quantity=5, unitPriceCents=6, totalCents=7, totalCentsLive=8
880
+ await db.createTable("orders", [
881
+ { key: "customer", type: "string" },
882
+ { key: "status", type: "number" },
883
+ {
884
+ key: "items",
885
+ type: "array",
886
+ children: [
887
+ { key: "product", type: "table", table: "product" },
888
+ { key: "quantity", type: "number" },
889
+ { key: "unitPriceCents", type: "number" },
890
+ ],
891
+ },
892
+ { key: "totalCents", type: "number", computed: "sum(5, 6)" }, // quantity x unitPriceCents
893
+ { key: "totalCentsLive", type: "number", computed: "sum(5, 4.2)" }, // quantity x product.price
894
+ ]);
895
+
896
+ const posted = await db.post(
897
+ "orders",
898
+ {
899
+ customer: "acme",
900
+ status: 1,
901
+ items: [
902
+ { product: "id-of-widget", quantity: 2, unitPriceCents: 250 },
903
+ { product: "id-of-gadget", quantity: 1, unitPriceCents: 100 },
904
+ ],
905
+ },
906
+ undefined,
907
+ true,
908
+ );
909
+ // posted.totalCents === 600 (2*250 + 1*100)
910
+ // posted.totalCentsLive === 847 (2*price(widget) + 1*price(gadget))
911
+ ```
912
+
913
+ **Expression language (v1, integer-only).**
914
+
915
+ - Operators: `+` `-` `,` (multiply) `/` `%`; `( )` for grouping. Multiplication
916
+ binds tighter than addition (`1 , 2 + 3` = `(1*2) + 3`).
917
+ - Helpers: `sum` `count` `avg` `min` `max` iterate an array-of-objects found by
918
+ the ids inside the parentheses (`sum(5, 6)` = quantity x unit-price per item).
919
+ - Paths: `id ("." id)*` where `.` hops through a `table` link
920
+ (`4.2` = price of the linked product row).
921
+ - Integers only: there are no decimal literals — `.` is the path separator, so
922
+ `3.14` is a path (`field 3`, hop `field 14`), never 3.14. Fractional results
923
+ are written as division: `314 / 100` → 3.14.
924
+ - A bare integer that matches a field id in the table's schema is that field
925
+ (ids are locative); a bare integer that matches no field is a literal.
926
+
927
+ **Rules & limits (v1).**
928
+
929
+ - Computed fields cannot be `required`, `unique` or `regex` (a
930
+ `COMPUTED_FIELD_CONFLICT` error).
931
+ - Computed values are never user-settable (`post`/`put` with a computed key
932
+ throws `COMPUTED_FIELD_SETTABLE`).
933
+ - Values are evaluated at write time and persisted; compiled expressions are
934
+ stored as `{ expr, ast }` in the schema. The AST carries only field ids, so
935
+ renaming a column (or its linked table's columns) never retargets an
936
+ expression.
937
+ - `updateTable` backfills existing rows when a computed field is added or its
938
+ expression changes; a backfill that fails (missing dependency, arithmetic
939
+ error, dangling link) aborts the migration and leaves the schema untouched.
940
+ - Aggregates: `sum` over no values is `0`, `count` is the element count, and
941
+ `avg`/`min`/`max` over no values throw `COMPUTED_FIELD_ARITHMETIC`.
942
+ - Non-numeric operands, division/modulo by zero, and arithmetic over missing
943
+ (null) values throw `COMPUTED_FIELD_ARITHMETIC`; a link to a missing row
944
+ throws `COMPUTED_FIELD_DANGLING_LINK`.
945
+ - Only top-level schema fields can be computed in v1; array contents are
946
+ reachable through helpers. Missing link values make a single bare path
947
+ evaluate to null (stored empty).
948
+
949
+ </blockquote>
950
+ </details>
951
+
769
952
  </blockquote>
770
953
  </details>
771
954
 
@@ -793,6 +976,31 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
793
976
  > Default testing uses a table with username, email, and password fields, ensuring password encryption is included in the process<br>
794
977
  > Results are measured on a default table plus dedicated tables with `prepend`, `compression`, and `decodeID` configs enabled<br>
795
978
  > To run benchmarks, install _typescript_ & _[tsx](https://github.com/privatenumber/tsx)_ globally and run `benchmark` by default bulk, for single use `benchmark --single|-s`
979
+ >
980
+ > > [!WARNING]
981
+ > > The numbers above were measured **before** always-on fsync + write-ahead journaling landed (they no longer reflect current hot-path write costs). Run `pnpm benchmark:durability` for the crash-atomic numbers.
982
+
983
+ ### Computed fields
984
+
985
+ 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:
986
+
987
+ | rows | POST bulk (plain / computed) | POST single (plain / computed) | PUT recompute (plain / computed) |
988
+ |------|------------------------------|--------------------------------|----------------------------------|
989
+ | 10 | 21.90 / 22.69 ms | 25.24 / 26.80 ms | 23.10 / 37.07 ms |
990
+ | 100 | 22.00 / 25.23 ms | 25.24 / 26.80 ms | 22.16 / 40.00 ms |
991
+ | 1000 | 28.79 / 35.15 ms | 25.24 / 26.80 ms | 21.01 / 45.05 ms |
992
+
993
+ > Min of 3 rounds (fsync + journal on); the plain-vs-computed delta is the pure expression-evaluation cost (~6 ms/1000 rows of helpers on POST, more on `PUT` because every matched row is recomputed). GET all (1110 rows): 11.44 / 15.90 ms — computed values are stored in real column files, so reads never evaluate; the gap is the larger column count to scan. Run `pnpm benchmark:computed` to reproduce.
994
+
995
+ **Link-heavy: shared product catalog with batched link-hop reads.** Every order's line items reference a shared 20-row catalog; `totalCentsLive = sum(quantity, product.price)` follows one link hop per line item. The engine batches the hops across the whole post: each batch resolves at most one read per **distinct** `(table, column, id)` triple (here: catalogSize=20 product rows) instead of one full row-level read per line item (2000 reads for 1000 orders × 2 items):
996
+
997
+ | rows | POST bulk (lk link hop) |
998
+ |------|-------------------------|
999
+ | 10 | 42.29 ms |
1000
+ | 100 | 47.76 ms |
1001
+ | 1000 | 59.46 ms |
1002
+
1003
+ > Shared 20-row catalog (10/100/1000 orders × 2 line items). Without batching every line item re-reads its product row; with it each batch resolves ≤ catalogSize distinct linked rows once (deduplicated across every line item and every order of the batch) and re-evaluates against the warm in-memory cache. A dangling link anywhere in the batch still rejects the whole post (`COMPUTED_FIELD_DANGLING_LINK`). HTTP-request bound, not disk-bound: the cost scales with the number of *distinct* linked rows the catalog actually has, not with line-item count.
796
1004
 
797
1005
  ## Roadmap
798
1006
 
@@ -835,6 +1043,7 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
835
1043
  - [ ] Encryption
836
1044
  - [x] Data Compression
837
1045
  - [x] Caching System
1046
+ - [x] Computed fields (v1 id-only expression language)
838
1047
  - [ ] Suggest [new feature +](https://github.com/inicontent/inibase/discussions/new?category=ideas)
839
1048
 
840
1049
  ## License
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Computed-fields expression language (v1 — id-only).
3
+ *
4
+ * Grammar
5
+ * -------
6
+ * ```
7
+ * expression := term (("+" | "-") term)*
8
+ * term := factor (("," | "/" | "%") factor)* // "," = multiply
9
+ * factor := integer-literal | path | function-call | "(" expression ")"
10
+ * path := id ( "." id )* // "." = link/binding hop
11
+ * function := "sum" | "count" | "avg" | "min" | "max" "(" expression ")"
12
+ * ```
13
+ *
14
+ * Every symbol is a numeric field `id` (per-table dense counter, nested
15
+ * children included). There are no decimal literals: `.` is reserved as the
16
+ * path separator, so `3.4` is a link hop (`field 3`, then `field 4` in the
17
+ * table `field 3` links to), never the decimal 3.4. Fractional results are
18
+ * reachable via division (`314 / 100` → 3.14).
19
+ *
20
+ * A bare integer that **matches a field id in the table's schema** is that
21
+ * field's value (ids are locative); a bare integer that matches no field id
22
+ * is an integer literal. This is what makes both `sum(4, 3.4)` (id 4 =
23
+ * quantity) and `314 / 100` (no such ids) usable.
24
+ *
25
+ * Compiled expressions are persisted with the schema as
26
+ * `{ expr: string, ast: CompiledExpressionNode }` so key/table renames never
27
+ * retarget an expression: the AST carries only field ids (+ the id of the
28
+ * array ancestor a helper iterates), and every key path is re-derived from
29
+ * the *current* schema at write time.
30
+ */
31
+ import type { ErrorLang, Field, Schema } from "./index.js";
32
+ /** Cap on the raw `computed` string length (bound work at validation). */
33
+ export declare const COMPUTED_EXPR_MAX_LENGTH = 512;
34
+ /** Cap on the parsed AST depth (protects the parser/evaluator recursion). */
35
+ export declare const COMPUTED_EXPR_MAX_DEPTH = 64;
36
+ export type ComputedFunctionName = "sum" | "count" | "avg" | "min" | "max";
37
+ export type BinaryOp = "add" | "sub" | "mul" | "div" | "mod";
38
+ /** Raw AST produced by {@link parseExpression} (field ids unresolved). */
39
+ export type RawExpressionNode = {
40
+ kind: "num";
41
+ value: number;
42
+ } | {
43
+ kind: "path";
44
+ ids: number[];
45
+ } | {
46
+ kind: "bin";
47
+ op: BinaryOp;
48
+ left: RawExpressionNode;
49
+ right: RawExpressionNode;
50
+ } | {
51
+ kind: "fn";
52
+ name: ComputedFunctionName;
53
+ arg: RawExpressionNode;
54
+ };
55
+ /**
56
+ * Compiled AST stored in the schema. Path nodes carry only ids (+ the id of
57
+ * the array ancestor a helper iterates); all key paths are resolved against
58
+ * the current schema at evaluation time, so renames never break expressions.
59
+ */
60
+ export type CompiledExpressionNode = {
61
+ kind: "num";
62
+ value: number;
63
+ } | {
64
+ kind: "path";
65
+ ids: number[];
66
+ arrayFieldId: number | null;
67
+ } | {
68
+ kind: "bin";
69
+ op: BinaryOp;
70
+ left: CompiledExpressionNode;
71
+ right: CompiledExpressionNode;
72
+ } | {
73
+ kind: "fn";
74
+ name: ComputedFunctionName;
75
+ /** id of the array ancestor every path in `arg` lives in. */
76
+ arrayFieldId: number;
77
+ arg: CompiledExpressionNode;
78
+ };
79
+ /** Persisted form of a `computed` schema property. (A type alias so it stays
80
+ * assignable to Inison's recursive `Data` type.) */
81
+ export type ComputedFieldSpec = {
82
+ expr: string;
83
+ ast: CompiledExpressionNode;
84
+ };
85
+ /**
86
+ * Schema index used for id resolution: maps a field id to its dotted key path,
87
+ * the resolved field, and the nearest array-of-objects ancestor (if any).
88
+ */
89
+ export interface FieldRef {
90
+ key: string;
91
+ field: Field;
92
+ /** Nearest `array`-typed ancestor with object children, or null. */
93
+ arrayAncestor: {
94
+ id: number;
95
+ key: string;
96
+ } | null;
97
+ /** True when `arrayAncestor` itself sits inside another array of objects. */
98
+ nestedInArrayOfArrays: boolean;
99
+ }
100
+ /**
101
+ * Parse a `computed` expression into a raw AST. Throws
102
+ * `COMPUTED_FIELD_SYNTAX` on invalid syntax, oversized input or excessive
103
+ * nesting.
104
+ */
105
+ export declare function parseExpression(source: string, language?: ErrorLang, fieldKey?: string): RawExpressionNode;
106
+ /**
107
+ * Build the id → {@link FieldRef} index of a schema (nested children included,
108
+ * using dotted key paths). Container fields (array/object with object
109
+ * children) are indexed too so unknown ids are detected, but they can't be
110
+ * referenced by an expression.
111
+ */
112
+ export declare function buildFieldIndex(schema: Schema): Map<number, FieldRef>;
113
+ export interface ResolveContext {
114
+ language: ErrorLang;
115
+ /** Key of the computed field being compiled (error context). */
116
+ ownKey: string;
117
+ /** Id index of the table the computed field belongs to. */
118
+ index: Map<number, FieldRef>;
119
+ /** Fetch (and cache) the id index of another table, or undefined. */
120
+ getTableIndex: (tableName: string) => Promise<Map<number, FieldRef> | undefined>;
121
+ }
122
+ /**
123
+ * Resolve a raw expression against the schema (and linked tables), returning
124
+ * the compiled node plus the set of field ids it reads from the current
125
+ * table (used for dependency ordering / cycle detection between computed
126
+ * fields).
127
+ */
128
+ export declare function resolveExpression(expression: RawExpressionNode, ctx: ResolveContext): Promise<{
129
+ ast: CompiledExpressionNode;
130
+ deps: Set<number>;
131
+ }>;
132
+ /** Every field id a compiled expression reads from the *current* table. */
133
+ export declare function collectFieldDeps(node: CompiledExpressionNode): Set<number>;
134
+ export interface ComputedFieldMeta {
135
+ id: number;
136
+ key: string;
137
+ deps: Set<number>;
138
+ }
139
+ /**
140
+ * Order computed fields so every dependency is evaluated before its dependents.
141
+ * Throws `COMPUTED_FIELD_CYCLE` when a field (transitively) depends on itself.
142
+ */
143
+ export declare function topoSortComputedFields(fields: ComputedFieldMeta[], language: ErrorLang): ComputedFieldMeta[];
144
+ /**
145
+ * Flatten a formatted row into a dot-notation record. Objects are flattened
146
+ * (`meta.age`), arrays of objects are combined per child key
147
+ * (`items.quantity` -> array of per-element values) and everything else is
148
+ * stored under its dotted key.
149
+ */
150
+ export declare function flattenRecord(obj: Record<string, any>, prefix?: string): Record<string, any>;
151
+ /**
152
+ * Resolve a dotted key path against a structured frame in place — the direct
153
+ * read-path counterpart of `flattenRecord`. Walks `obj` segment by segment
154
+ * and returns `undefined` when any intermediate is null, undefined, a
155
+ * non-object, or an array (arrays resolve numerically only, so they never
156
+ * match a dotted segment), mirroring the leaves `flattenRecord` would have
157
+ * produced without materialising the flattened record.
158
+ */
159
+ export declare function resolveFramePath(obj: Record<string, any>, dottedKey: string): any;