inibase 3.0.0 → 3.1.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.
@@ -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/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "dotenv/config";
2
+ import { type ComputedFieldSpec } from "./expression.js";
2
3
  import { type JournalFileOp } from "./journal.js";
3
4
  export interface Data {
4
5
  id?: string | number;
@@ -16,6 +17,10 @@ export type Field = {
16
17
  unique?: boolean | number | string;
17
18
  children?: FieldType | FieldType[] | Schema;
18
19
  regex?: string;
20
+ /** Computed-fields expression (see the README). Either a raw expression
21
+ * string, or the persisted `{ expr, ast }` spec written by Inibase once
22
+ * the expression has been compiled. */
23
+ computed?: string | ComputedFieldSpec;
19
24
  };
20
25
  export type Schema = Field[];
21
26
  export interface Options {
@@ -73,7 +78,7 @@ declare global {
73
78
  entries<T extends object>(o: T): Entries<T>;
74
79
  }
75
80
  }
76
- export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME"];
81
+ export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME", "COMPUTED_FIELD_SYNTAX", "COMPUTED_FIELD_UNKNOWN_FIELD", "COMPUTED_FIELD_INVALID_LINK", "COMPUTED_FIELD_INVALID_TARGET", "COMPUTED_FIELD_CONFLICT", "COMPUTED_FIELD_CYCLE", "COMPUTED_FIELD_SETTABLE", "COMPUTED_FIELD_DANGLING_LINK", "COMPUTED_FIELD_ARITHMETIC"];
77
82
  export type ErrorCode = (typeof ERROR_CODES)[number];
78
83
  export type ErrorLang = "en" | "ar" | "fr" | "es";
79
84
  export declare const globalConfig: {
@@ -100,6 +105,12 @@ export default class Inibase {
100
105
  * resolve numeric ids to line numbers arithmetically instead of scanning
101
106
  * the id file. Set false by any partial row deletion. */
102
107
  private idDensity;
108
+ /** Per-table computed-plan cache (null = table has no computed fields).
109
+ * A plan depends only on the table's persisted schema (compiled ASTs are
110
+ * id-based and rename-proof), so it is rebuilt only when the schema
111
+ * changes: see the invalidation in `getTable`'s reload branch,
112
+ * `createTable` and `updateTableLocked`. Bounded by the number of tables. */
113
+ private readonly computedPlanCache;
103
114
  private databasePath;
104
115
  private uniqueMap;
105
116
  private schemaFileExtension;
@@ -167,6 +178,57 @@ export default class Inibase {
167
178
  private joinPathesContents;
168
179
  private _processSchemaDataHelper;
169
180
  private processSchemaData;
181
+ /**
182
+ * Extract the raw expression from a field's `computed` property (string or
183
+ * persisted `{ expr, ast }` spec).
184
+ */
185
+ private computedExprOf;
186
+ /**
187
+ * Compile every `computed` expression of a schema (which must already have
188
+ * ids assigned) into its persisted `{ expr, ast }` form, in dependency
189
+ * order. Throws `COMPUTED_FIELD_CYCLE` on cyclic fields. Used by DDL so the
190
+ * on-disk schema always carries compiled ASTs.
191
+ */
192
+ private compileComputedFields;
193
+ /**
194
+ * Build the evaluation plan (topological order + id index) for a table's
195
+ * computed fields. Throws on cycles or unresolvable expressions.
196
+ */
197
+ private buildComputedPlan;
198
+ /** Id index of a table's schema (link targets are re-resolved at write
199
+ * time, so renames never retarget a compiled expression). */
200
+ private tableFieldIndex;
201
+ /**
202
+ * Evaluate a batch of (merged) rows against the table's computed fields.
203
+ * Returns `lineNo -> { computedKey -> value }` in dependency order.
204
+ *
205
+ * Batched link-hop reads: when the plan contains any link hop, a dry
206
+ * collection pass records every (table, column, id) triple the rows'
207
+ * expressions need (no file I/O), each distinct triple is then resolved
208
+ * exactly once — deduplicated across rows and fields — and a final pass
209
+ * evaluates against the warm cache. Plans without hops run the single
210
+ * evaluation pass unchanged.
211
+ */
212
+ private evaluateComputedRows;
213
+ private createLinkReader;
214
+ /** Evaluate every computed field of one row (topological order) and merge
215
+ * the results back into the row so dependent fields see them. */
216
+ private evaluateRowComputed;
217
+ private evaluateNode;
218
+ /** Evaluate a path (`ids`, dot-separated link hops) against the current
219
+ * frame. Returns `null` when an intermediate link value is missing. */
220
+ private evaluatePath;
221
+ /** Id index lookup with a shared per-evaluation cache. */
222
+ private indexFor;
223
+ /** Read a single column of one linked row via `get`, or null when the
224
+ * row does not exist (dangling link). */
225
+ private readLinkedRow;
226
+ private coerceNumber;
227
+ private applyBinaryOp;
228
+ private aggregate;
229
+ /** Merge evaluated per-line computed values into a `pathesContents` map
230
+ * as line-numbered replace records (encoded cells). */
231
+ private mergeComputedLineRecords;
170
232
  private isSimpleField;
171
233
  private processSimpleField;
172
234
  /**