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.
@@ -0,0 +1,495 @@
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 { createError, isArrayOfObjects } from "./utils.js";
32
+ /** Cap on the raw `computed` string length (bound work at validation). */
33
+ export const COMPUTED_EXPR_MAX_LENGTH = 512;
34
+ /** Cap on the parsed AST depth (protects the parser/evaluator recursion). */
35
+ export const COMPUTED_EXPR_MAX_DEPTH = 64;
36
+ const FN_NAMES = ["sum", "count", "avg", "min", "max"];
37
+ const syntaxError = (language, fieldKey) => createError(language, "COMPUTED_FIELD_SYNTAX", fieldKey);
38
+ function tokenize(source) {
39
+ const tokens = [];
40
+ let i = 0;
41
+ while (i < source.length) {
42
+ const ch = source[i];
43
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
44
+ i++;
45
+ continue;
46
+ }
47
+ if (ch >= "0" && ch <= "9") {
48
+ let j = i;
49
+ while (j < source.length && source[j] >= "0" && source[j] <= "9")
50
+ j++;
51
+ tokens.push({ t: "num", v: Number(source.slice(i, j)) });
52
+ i = j;
53
+ continue;
54
+ }
55
+ if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) {
56
+ let j = i;
57
+ while (j < source.length &&
58
+ ((source[j] >= "a" && source[j] <= "z") ||
59
+ (source[j] >= "A" && source[j] <= "Z") ||
60
+ (source[j] >= "0" && source[j] <= "9") ||
61
+ source[j] === "_"))
62
+ j++;
63
+ tokens.push({ t: "ident", v: source.slice(i, j) });
64
+ i = j;
65
+ continue;
66
+ }
67
+ switch (ch) {
68
+ case ".":
69
+ tokens.push({ t: "dot" });
70
+ i++;
71
+ continue;
72
+ case "(":
73
+ tokens.push({ t: "lp" });
74
+ i++;
75
+ continue;
76
+ case ")":
77
+ tokens.push({ t: "rp" });
78
+ i++;
79
+ continue;
80
+ case "+":
81
+ case "-":
82
+ case ",":
83
+ case "/":
84
+ case "%":
85
+ tokens.push({ t: "op", v: ch });
86
+ i++;
87
+ continue;
88
+ default:
89
+ throw syntaxError("en", "");
90
+ }
91
+ }
92
+ tokens.push({ t: "eof" });
93
+ return tokens;
94
+ }
95
+ /**
96
+ * Parse a `computed` expression into a raw AST. Throws
97
+ * `COMPUTED_FIELD_SYNTAX` on invalid syntax, oversized input or excessive
98
+ * nesting.
99
+ */
100
+ export function parseExpression(source, language = "en", fieldKey = "") {
101
+ if (typeof source !== "string" || !source.length)
102
+ throw syntaxError(language, fieldKey);
103
+ if (source.length > COMPUTED_EXPR_MAX_LENGTH)
104
+ throw syntaxError(language, fieldKey);
105
+ const tokens = tokenize(source);
106
+ let pos = 0;
107
+ let depth = 0;
108
+ const peek = () => tokens[pos];
109
+ const next = () => tokens[pos++];
110
+ const fail = () => syntaxError(language, fieldKey);
111
+ const enter = () => {
112
+ if (++depth > COMPUTED_EXPR_MAX_DEPTH)
113
+ throw fail();
114
+ };
115
+ const leave = () => {
116
+ depth--;
117
+ };
118
+ const expression = () => {
119
+ enter();
120
+ let left = term();
121
+ let token = peek();
122
+ while (token.t === "op" && (token.v === "+" || token.v === "-")) {
123
+ const op = next().v;
124
+ left = {
125
+ kind: "bin",
126
+ op: op === "+" ? "add" : "sub",
127
+ left,
128
+ right: term(),
129
+ };
130
+ token = peek();
131
+ }
132
+ leave();
133
+ return left;
134
+ };
135
+ const term = () => {
136
+ enter();
137
+ let left = factor();
138
+ while (peek().t === "op" && [",", "/", "%"].includes(peek().v)) {
139
+ const op = next().v;
140
+ const binOp = op === "," ? "mul" : op === "/" ? "div" : "mod";
141
+ left = { kind: "bin", op: binOp, left, right: factor() };
142
+ }
143
+ leave();
144
+ return left;
145
+ };
146
+ const factor = () => {
147
+ enter();
148
+ const token = next();
149
+ if (token.t === "num") {
150
+ if (peek().t === "dot") {
151
+ const ids = [token.v];
152
+ while (peek().t === "dot") {
153
+ next();
154
+ const seg = next();
155
+ if (seg.t !== "num")
156
+ throw fail();
157
+ ids.push(seg.v);
158
+ }
159
+ leave();
160
+ return { kind: "path", ids };
161
+ }
162
+ leave();
163
+ return { kind: "num", value: token.v };
164
+ }
165
+ if (token.t === "ident") {
166
+ const name = token.v;
167
+ if (!FN_NAMES.includes(name))
168
+ throw fail();
169
+ if (next().t !== "lp")
170
+ throw fail();
171
+ const arg = expression();
172
+ if (next().t !== "rp")
173
+ throw fail();
174
+ leave();
175
+ return { kind: "fn", name: name, arg };
176
+ }
177
+ if (token.t === "lp") {
178
+ const inner = expression();
179
+ if (next().t !== "rp")
180
+ throw fail();
181
+ leave();
182
+ return inner;
183
+ }
184
+ throw fail();
185
+ };
186
+ const root = expression();
187
+ if (next().t !== "eof")
188
+ throw fail();
189
+ return root;
190
+ }
191
+ /* ---------------------------------------------------------------------------
192
+ * Schema index
193
+ * ------------------------------------------------------------------------- */
194
+ /**
195
+ * Build the id → {@link FieldRef} index of a schema (nested children included,
196
+ * using dotted key paths). Container fields (array/object with object
197
+ * children) are indexed too so unknown ids are detected, but they can't be
198
+ * referenced by an expression.
199
+ */
200
+ export function buildFieldIndex(schema) {
201
+ const index = new Map();
202
+ const isArrayOfObjectsContainer = (field) => Array.isArray(field.children) && isArrayOfObjects(field.children);
203
+ const walk = (fields, prefix, ancestors) => {
204
+ for (const field of fields) {
205
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
206
+ if (field.id !== undefined) {
207
+ let arrayAncestor = null;
208
+ let nestedInArrayOfArrays = false;
209
+ for (let i = ancestors.length - 1; i >= 0; i--) {
210
+ const ancestor = ancestors[i];
211
+ if (ancestor.field.type === "array" &&
212
+ isArrayOfObjectsContainer(ancestor.field)) {
213
+ arrayAncestor = {
214
+ id: ancestor.field.id,
215
+ key: ancestor.key,
216
+ };
217
+ for (let j = i - 1; j >= 0; j--) {
218
+ const parent = ancestors[j].field;
219
+ if (parent.type === "array" &&
220
+ isArrayOfObjectsContainer(parent)) {
221
+ nestedInArrayOfArrays = true;
222
+ break;
223
+ }
224
+ }
225
+ break;
226
+ }
227
+ }
228
+ index.set(field.id, {
229
+ key,
230
+ field,
231
+ arrayAncestor,
232
+ nestedInArrayOfArrays,
233
+ });
234
+ }
235
+ if (isArrayOfObjectsContainer(field))
236
+ walk(field.children, key, [...ancestors, { field, key }]);
237
+ }
238
+ };
239
+ walk(schema, "", []);
240
+ return index;
241
+ }
242
+ const isContainer = (field) => Array.isArray(field.children) && isArrayOfObjects(field.children);
243
+ /** Collect every path node of a (compiled or raw) expression tree. */
244
+ function collectPaths(node) {
245
+ if (node.kind === "path")
246
+ return [node];
247
+ const paths = [];
248
+ if (node.kind === "bin") {
249
+ paths.push(...collectPaths(node.left));
250
+ paths.push(...collectPaths(node.right));
251
+ }
252
+ else if (node.kind === "fn")
253
+ paths.push(...collectPaths(node.arg));
254
+ return paths;
255
+ }
256
+ async function resolvePathNode(node, ctx, inHelper) {
257
+ const ids = node.ids;
258
+ const hop0 = ctx.index.get(ids[0]);
259
+ if (!hop0)
260
+ throw createError(ctx.language, "COMPUTED_FIELD_UNKNOWN_FIELD", [
261
+ ctx.ownKey,
262
+ ids[0],
263
+ ]);
264
+ if (isContainer(hop0.field))
265
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
266
+ ctx.ownKey,
267
+ ids[0],
268
+ ]);
269
+ const arrayFieldId = hop0.arrayAncestor?.id ?? null;
270
+ // A bare (non-helper) reference may only address fields outside arrays;
271
+ // array contents are reachable exclusively through sum/count/avg/min/max.
272
+ if (!inHelper && arrayFieldId !== null)
273
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
274
+ ctx.ownKey,
275
+ ids[0],
276
+ ]);
277
+ if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
278
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
279
+ ctx.ownKey,
280
+ ids[0],
281
+ ]);
282
+ // Link hops: each hop beyond the first must live in the table the previous
283
+ // hop's field links to.
284
+ let currentRef = hop0;
285
+ for (let i = 1; i < ids.length; i++) {
286
+ const linkField = currentRef.field;
287
+ if (linkField.type !== "table" || typeof linkField.table !== "string")
288
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_LINK", [
289
+ ctx.ownKey,
290
+ ids[i],
291
+ ]);
292
+ const targetIndex = await ctx.getTableIndex(linkField.table);
293
+ if (!targetIndex)
294
+ throw createError(ctx.language, "TABLE_NOT_EXISTS", linkField.table);
295
+ const ref = targetIndex.get(ids[i]);
296
+ if (!ref)
297
+ throw createError(ctx.language, "COMPUTED_FIELD_UNKNOWN_FIELD", [
298
+ ctx.ownKey,
299
+ ids[i],
300
+ ]);
301
+ if (isContainer(ref.field))
302
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
303
+ ctx.ownKey,
304
+ ids[i],
305
+ ]);
306
+ currentRef = ref;
307
+ }
308
+ return {
309
+ node: { kind: "path", ids, arrayFieldId },
310
+ deps: new Set([ids[0]]),
311
+ };
312
+ }
313
+ async function resolveNode(node, ctx, inHelper) {
314
+ switch (node.kind) {
315
+ case "num":
316
+ // A bare integer that matches an existing field id is that field
317
+ // (ids are locative, inside helpers included — this is what makes
318
+ // `sum(5, 4.2)` and `sum(5, 6)` usable); any other bare integer is
319
+ // an integer literal (`314 / 100`).
320
+ if (ctx.index.has(node.value))
321
+ return resolvePathNode({ kind: "path", ids: [node.value] }, ctx, inHelper);
322
+ return { node: { kind: "num", value: node.value }, deps: new Set() };
323
+ case "path":
324
+ return resolvePathNode(node, ctx, inHelper);
325
+ case "bin": {
326
+ const left = await resolveNode(node.left, ctx, inHelper);
327
+ const right = await resolveNode(node.right, ctx, inHelper);
328
+ const deps = new Set([...left.deps, ...right.deps]);
329
+ return {
330
+ node: { kind: "bin", op: node.op, left: left.node, right: right.node },
331
+ deps,
332
+ };
333
+ }
334
+ case "fn": {
335
+ if (inHelper)
336
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
337
+ ctx.ownKey,
338
+ ]);
339
+ const arg = await resolveNode(node.arg, ctx, true);
340
+ const paths = collectPaths(arg.node);
341
+ if (!paths.length)
342
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
343
+ ctx.ownKey,
344
+ ]);
345
+ const arrayFieldIds = new Set(paths.map((path) => path.arrayFieldId ?? null));
346
+ if (arrayFieldIds.size !== 1 || arrayFieldIds.has(null))
347
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
348
+ ctx.ownKey,
349
+ ]);
350
+ return {
351
+ node: {
352
+ kind: "fn",
353
+ name: node.name,
354
+ arrayFieldId: arrayFieldIds.values().next().value,
355
+ arg: arg.node,
356
+ },
357
+ deps: arg.deps,
358
+ };
359
+ }
360
+ }
361
+ }
362
+ /**
363
+ * Resolve a raw expression against the schema (and linked tables), returning
364
+ * the compiled node plus the set of field ids it reads from the current
365
+ * table (used for dependency ordering / cycle detection between computed
366
+ * fields).
367
+ */
368
+ export async function resolveExpression(expression, ctx) {
369
+ const resolved = await resolveNode(expression, ctx, false);
370
+ return { ast: resolved.node, deps: resolved.deps };
371
+ }
372
+ /** Every field id a compiled expression reads from the *current* table. */
373
+ export function collectFieldDeps(node) {
374
+ const deps = new Set();
375
+ const visit = (n) => {
376
+ switch (n.kind) {
377
+ case "num":
378
+ return;
379
+ case "path":
380
+ deps.add(n.ids[0]);
381
+ return;
382
+ case "bin":
383
+ visit(n.left);
384
+ visit(n.right);
385
+ return;
386
+ case "fn":
387
+ visit(n.arg);
388
+ return;
389
+ }
390
+ };
391
+ visit(node);
392
+ return deps;
393
+ }
394
+ /**
395
+ * Order computed fields so every dependency is evaluated before its dependents.
396
+ * Throws `COMPUTED_FIELD_CYCLE` when a field (transitively) depends on itself.
397
+ */
398
+ export function topoSortComputedFields(fields, language) {
399
+ const byId = new Map(fields.map((field) => [field.id, field]));
400
+ const remaining = new Set(fields.map((field) => field.id));
401
+ const ordered = [];
402
+ const blocked = new Map(); // dependent -> dependencies still missing
403
+ const indegree = new Map();
404
+ for (const field of fields) {
405
+ const dependents = [];
406
+ for (const depId of field.deps)
407
+ if (byId.has(depId))
408
+ dependents.push(depId);
409
+ blocked.set(field.id, dependents);
410
+ }
411
+ const ready = [];
412
+ for (const [id, deps] of blocked) {
413
+ indegree.set(id, deps.length);
414
+ if (deps.length === 0)
415
+ ready.push(id);
416
+ }
417
+ while (ready.length) {
418
+ const id = ready.shift();
419
+ const field = byId.get(id);
420
+ if (!field)
421
+ continue;
422
+ ordered.push(field);
423
+ remaining.delete(id);
424
+ for (const [otherId, deps] of blocked) {
425
+ if (!remaining.has(otherId))
426
+ continue;
427
+ if (deps.includes(id)) {
428
+ const nextIndegree = (indegree.get(otherId) ?? 1) - 1;
429
+ indegree.set(otherId, nextIndegree);
430
+ if (nextIndegree === 0)
431
+ ready.push(otherId);
432
+ }
433
+ }
434
+ }
435
+ if (remaining.size) {
436
+ const keys = fields
437
+ .filter((field) => remaining.has(field.id))
438
+ .map((field) => field.key);
439
+ throw createError(language, "COMPUTED_FIELD_CYCLE", keys.join(", "));
440
+ }
441
+ return ordered;
442
+ }
443
+ /* ---------------------------------------------------------------------------
444
+ * Row flattening (shared by the evaluator)
445
+ * ------------------------------------------------------------------------- */
446
+ /**
447
+ * Flatten a formatted row into a dot-notation record. Objects are flattened
448
+ * (`meta.age`), arrays of objects are combined per child key
449
+ * (`items.quantity` -> array of per-element values) and everything else is
450
+ * stored under its dotted key.
451
+ */
452
+ export function flattenRecord(obj, prefix = "") {
453
+ const out = {};
454
+ for (const [k, v] of Object.entries(obj)) {
455
+ const key = prefix ? `${prefix}.${k}` : k;
456
+ if (v !== null && typeof v === "object" && !Array.isArray(v))
457
+ Object.assign(out, flattenRecord(v, key));
458
+ else if (Array.isArray(v) && v.length && isArrayOfObjects(v)) {
459
+ const combined = {};
460
+ for (const element of v)
461
+ for (const [childKey, childValue] of Object.entries(element)) {
462
+ if (!combined[childKey])
463
+ combined[childKey] = [];
464
+ combined[childKey].push(childValue);
465
+ }
466
+ for (const [childKey, values] of Object.entries(combined))
467
+ out[`${key}.${childKey}`] = values;
468
+ }
469
+ else
470
+ out[key] = v;
471
+ }
472
+ return out;
473
+ }
474
+ /**
475
+ * Resolve a dotted key path against a structured frame in place — the direct
476
+ * read-path counterpart of `flattenRecord`. Walks `obj` segment by segment
477
+ * and returns `undefined` when any intermediate is null, undefined, a
478
+ * non-object, or an array (arrays resolve numerically only, so they never
479
+ * match a dotted segment), mirroring the leaves `flattenRecord` would have
480
+ * produced without materialising the flattened record.
481
+ */
482
+ export function resolveFramePath(obj, dottedKey) {
483
+ if (dottedKey.length === 0)
484
+ return obj;
485
+ let cur = obj;
486
+ for (const segment of dottedKey.split(".")) {
487
+ if (cur === null ||
488
+ cur === undefined ||
489
+ typeof cur !== "object" ||
490
+ Array.isArray(cur))
491
+ return undefined;
492
+ cur = cur[segment];
493
+ }
494
+ return cur;
495
+ }
package/dist/file.d.ts CHANGED
@@ -1,7 +1,26 @@
1
1
  import { type ComparisonOperator, type Field } from "./index.js";
2
- export declare const lock: (folderPath: string, prefix?: string) => Promise<void>;
2
+ export declare const DURABLE: boolean;
3
+ export declare const lock: (folderPath: string, prefix?: string, ttl?: number) => Promise<void>;
4
+ /**
5
+ * Non-blocking lock acquisition (used by the read path and the open-time
6
+ * recovery sweep). Returns true when the lock was acquired (running crash
7
+ * recovery for prefix-less locks, exactly like `lock`), false when another
8
+ * process holds it. A single stale-lock steal (dead same-host owner, or aged
9
+ * foreign/unknown owner) is attempted so a crashed owner can't wedge readers
10
+ * behind it forever.
11
+ */
12
+ export declare const tryLock: (folderPath: string, prefix?: string, ttl?: number) => Promise<boolean>;
3
13
  export declare const unlock: (folderPath: string, prefix?: string) => Promise<void>;
4
14
  export declare const write: (filePath: string, data: any) => Promise<void>;
15
+ /**
16
+ * fsync an existing file. Used to flush temp files (written via streams or
17
+ * shell pipelines) before they are renamed into place.
18
+ */
19
+ export declare const syncFile: (filePath: string) => Promise<void>;
20
+ /**
21
+ * fsync a directory so that renames performed inside it are durable.
22
+ */
23
+ export declare const syncDir: (dirPath: string) => Promise<void>;
5
24
  export declare const read: (filePath: string) => Promise<string>;
6
25
  export declare function escapeShellPath(filePath: string): string;
7
26
  /**