inibase 3.0.0 → 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 +114 -0
- package/dist/expression.d.ts +159 -0
- package/dist/expression.js +495 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +676 -7
- package/dist/utils.js +54 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -858,6 +858,97 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
|
|
|
858
858
|
</blockquote>
|
|
859
859
|
</details>
|
|
860
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
|
+
|
|
861
952
|
</blockquote>
|
|
862
953
|
</details>
|
|
863
954
|
|
|
@@ -889,6 +980,28 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
|
|
|
889
980
|
> > [!WARNING]
|
|
890
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.
|
|
891
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.
|
|
1004
|
+
|
|
892
1005
|
## Roadmap
|
|
893
1006
|
|
|
894
1007
|
- [x] Actions:
|
|
@@ -930,6 +1043,7 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
|
|
|
930
1043
|
- [ ] Encryption
|
|
931
1044
|
- [x] Data Compression
|
|
932
1045
|
- [x] Caching System
|
|
1046
|
+
- [x] Computed fields (v1 id-only expression language)
|
|
933
1047
|
- [ ] Suggest [new feature +](https://github.com/inicontent/inibase/discussions/new?category=ideas)
|
|
934
1048
|
|
|
935
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;
|