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/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { glob, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile, }
5
5
  import { basename, join, parse, resolve } from "node:path";
6
6
  import { inspect } from "node:util";
7
7
  import Inison from "inison";
8
+ import { buildFieldIndex, collectFieldDeps, parseExpression, resolveExpression, resolveFramePath, topoSortComputedFields, } from "./expression.js";
8
9
  import * as File from "./file.js";
9
10
  import { DatabaseJournal, Journal } from "./journal.js";
10
11
  import * as Utils from "./utils.js";
@@ -23,6 +24,15 @@ export const ERROR_CODES = [
23
24
  "TABLE_NOT_EXISTS",
24
25
  "INVALID_REGEX_MATCH",
25
26
  "INVALID_NAME",
27
+ "COMPUTED_FIELD_SYNTAX",
28
+ "COMPUTED_FIELD_UNKNOWN_FIELD",
29
+ "COMPUTED_FIELD_INVALID_LINK",
30
+ "COMPUTED_FIELD_INVALID_TARGET",
31
+ "COMPUTED_FIELD_CONFLICT",
32
+ "COMPUTED_FIELD_CYCLE",
33
+ "COMPUTED_FIELD_SETTABLE",
34
+ "COMPUTED_FIELD_DANGLING_LINK",
35
+ "COMPUTED_FIELD_ARITHMETIC",
26
36
  ];
27
37
  // hide ExperimentalWarning glob()
28
38
  // Guard against non-Node environments (e.g. accidental import in a browser bundle)
@@ -31,6 +41,75 @@ if (typeof process !== "undefined" &&
31
41
  process.removeAllListeners("warning");
32
42
  }
33
43
  export const globalConfig = {};
44
+ /**
45
+ * Batch-scoped link-hop reader for computed-field evaluation: a dry pass
46
+ * records every (table, column, id) triple the expressions need, each
47
+ * distinct triple is resolved exactly once by the engine's `readLinkedRow`
48
+ * (deduplicated across rows and fields of the batch), and subsequent passes
49
+ * are served from a warm per-(table, column) cache.
50
+ */
51
+ class LinkedRowReader {
52
+ readRow;
53
+ pending = new Map();
54
+ cache = new Map();
55
+ constructor(readRow) {
56
+ this.readRow = readRow;
57
+ }
58
+ get hasPending() {
59
+ return this.pending.size > 0;
60
+ }
61
+ record(table, column, id) {
62
+ const key = `${table}\u0000${column}`;
63
+ let ids = this.pending.get(key);
64
+ if (!ids)
65
+ this.pending.set(key, (ids = new Set()));
66
+ ids.add(id);
67
+ }
68
+ async read(table, column, id) {
69
+ const key = `${table}\u0000${column}`;
70
+ let map = this.cache.get(key);
71
+ if (!map) {
72
+ map = await this.resolve(key, this.pending.get(key) ?? new Set());
73
+ this.pending.delete(key);
74
+ this.cache.set(key, map);
75
+ }
76
+ return map.get(id) ?? null;
77
+ }
78
+ /** Resolve every pending bucket now (called between the collection pass
79
+ * and the real evaluation pass). */
80
+ async resolveAll() {
81
+ for (const [key, ids] of this.pending)
82
+ this.cache.set(key, await this.resolve(key, ids));
83
+ this.pending.clear();
84
+ }
85
+ async resolve(key, ids) {
86
+ const separator = key.indexOf("\u0000");
87
+ const table = key.slice(0, separator);
88
+ const column = key.slice(separator + 1);
89
+ const map = new Map();
90
+ // One engine read per distinct triple; the buckets' triples are
91
+ // independent so they resolve concurrently. A missing row caches as
92
+ // null so repeated dangling hops fail once, exactly like a direct
93
+ // readLinkedRow would per hop.
94
+ await Promise.all(Array.from(ids).map(async (id) => {
95
+ map.set(id, await this.readRow(table, column, id));
96
+ }));
97
+ return map;
98
+ }
99
+ }
100
+ /** True when an expression tree contains any link hop (multi-segment path). */
101
+ function astHasHops(node) {
102
+ switch (node.kind) {
103
+ case "num":
104
+ return false;
105
+ case "path":
106
+ return node.ids.length > 1;
107
+ case "bin":
108
+ return astHasHops(node.left) || astHasHops(node.right);
109
+ case "fn":
110
+ return astHasHops(node.arg);
111
+ }
112
+ }
34
113
  /**
35
114
  * @param {string} database - Database name
36
115
  * @param {string} [mainFolder="."] - Main folder path
@@ -46,6 +125,12 @@ export default class Inibase {
46
125
  * resolve numeric ids to line numbers arithmetically instead of scanning
47
126
  * the id file. Set false by any partial row deletion. */
48
127
  idDensity = new Map();
128
+ /** Per-table computed-plan cache (null = table has no computed fields).
129
+ * A plan depends only on the table's persisted schema (compiled ASTs are
130
+ * id-based and rename-proof), so it is rebuilt only when the schema
131
+ * changes: see the invalidation in `getTable`'s reload branch,
132
+ * `createTable` and `updateTableLocked`. Bounded by the number of tables. */
133
+ computedPlanCache = new Map();
49
134
  databasePath;
50
135
  uniqueMap;
51
136
  schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
@@ -168,9 +253,13 @@ export default class Inibase {
168
253
  }
169
254
  if (schema) {
170
255
  const lastSchemaID = { value: 0 };
256
+ // Compile computed expressions only after ids are assigned (the
257
+ // persisted AST carries ids, making it rename-proof), and before
258
+ // the schema is written so a bad expression never lands on disk.
259
+ schema = await this.compileComputedFields(Utils.addIdToSchema(schema, lastSchemaID));
171
260
  await File.write(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
172
- ? JSON.stringify(Utils.addIdToSchema(schema, lastSchemaID), null, 2)
173
- : Inison.stringify(Utils.addIdToSchema(schema, lastSchemaID)));
261
+ ? JSON.stringify(schema, null, 2)
262
+ : Inison.stringify(schema));
174
263
  await File.write(join(tablePath, `${lastSchemaID.value}.schema`), "");
175
264
  }
176
265
  else
@@ -180,6 +269,9 @@ export default class Inibase {
180
269
  await File.syncDir(tablePath);
181
270
  await File.syncDir(join(tablePath, ".tmp"));
182
271
  this.idDensity.set(tableName, true);
272
+ // Defensive: a table of the same name may have had a cached plan from
273
+ // before a delete+recreate.
274
+ this.computedPlanCache.delete(tableName);
183
275
  }
184
276
  // Function to replace the string in one schema file
185
277
  async replaceStringInFile(filePath, targetString, replaceString) {
@@ -240,6 +332,18 @@ export default class Inibase {
240
332
  value: schemaIdFilePath ? Number(parse(schemaIdFilePath).name) : 0,
241
333
  };
242
334
  schema = Utils.addIdToSchema(schema, lastSchemaID);
335
+ // Compile computed expressions now that ids are assigned (the
336
+ // persisted AST only stores ids, so later renames never retarget
337
+ // an expression). Compilation happens before any file surgery so
338
+ // a bad expression aborts the migration cleanly.
339
+ schema = await this.compileComputedFields(schema);
340
+ // Row count is needed for the computed backfill below, so read the
341
+ // pagination before the schema is rewritten.
342
+ let totalLines = 0;
343
+ for await (const paginationFileName of glob("*.pagination", {
344
+ cwd: tablePath,
345
+ }))
346
+ totalLines = parse(paginationFileName).name.split("-").map(Number)[1];
243
347
  // if schema file exists, update columns files names based on field id
244
348
  if ((await File.isExists(join(tablePath, `schema.${this.schemaFileExtension}`))) &&
245
349
  table.schema?.length) {
@@ -255,6 +359,46 @@ export default class Inibase {
255
359
  }
256
360
  }));
257
361
  }
362
+ // Computed-field backfill: a computed expression that is added or
363
+ // changed by this migration must be (re)derived for every existing
364
+ // row. Evaluation happens BEFORE the schema is rewritten, so a
365
+ // failed migration (dangling link, arithmetic error, unknown
366
+ // field, ...) leaves the old schema and column values untouched.
367
+ const oldComputed = new Map((table.schema ?? [])
368
+ .filter((field) => typeof field.computed !== "undefined")
369
+ .map((field) => [field.key, this.computedExprOf(field)]));
370
+ const changedComputedKeys = new Set(totalLines > 0
371
+ ? schema
372
+ .filter((field) => typeof field.computed !== "undefined" &&
373
+ oldComputed.get(field.key) !== this.computedExprOf(field))
374
+ .map((field) => field.key)
375
+ : []);
376
+ const backfillReplacers = changedComputedKeys.size
377
+ ? await (async () => {
378
+ const plan = await this.buildComputedPlan(tableName, schema);
379
+ if (!plan)
380
+ return undefined;
381
+ const lineNumbers = Array.from({ length: totalLines }, (_, index) => index + 1);
382
+ const existing = await this.processSchemaData(tableName, schema.filter((field) => typeof field.computed === "undefined"), lineNumbers);
383
+ return this.evaluateComputedRows(tableName, plan, existing);
384
+ })()
385
+ : undefined;
386
+ // Publish the backfilled values before the schema write.
387
+ if (backfillReplacers) {
388
+ const backfillRenameList = [];
389
+ for (const key of changedComputedKeys) {
390
+ const lines = {};
391
+ for (const [line, values] of Object.entries(backfillReplacers))
392
+ if (Object.hasOwn(values, key))
393
+ lines[Number(line)] = File.encode(values[key]);
394
+ if (!Object.keys(lines).length)
395
+ continue;
396
+ backfillRenameList.push(await File.replace(join(tablePath, `${key}${this.getFileExtension(tableName)}`), lines));
397
+ }
398
+ for (const [tmp, live] of backfillRenameList)
399
+ if (tmp && live)
400
+ await rename(tmp, live);
401
+ }
258
402
  // Data-bearing schema writes go through File.write so they are
259
403
  // fsynced before updateTable returns.
260
404
  await File.write(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
@@ -271,11 +415,6 @@ export default class Inibase {
271
415
  // files padded with one empty line per existing row so appends keep
272
416
  // line-aligned with the other column files (decode("") is
273
417
  // undefined/null, matching the "no value yet" semantics).
274
- let totalLines = 0;
275
- for await (const paginationFileName of glob("*.pagination", {
276
- cwd: tablePath,
277
- }))
278
- totalLines = parse(paginationFileName).name.split("-").map(Number)[1];
279
418
  await Promise.allSettled(schema.map(async ({ key }) => {
280
419
  const filePath = join(tablePath, `${key}${this.getFileExtension(tableName)}`);
281
420
  if (!(await File.isExists(filePath)))
@@ -380,6 +519,12 @@ export default class Inibase {
380
519
  // change survives a crash.
381
520
  await File.syncDir(config?.name ? this.databasePath : tablePath);
382
521
  globalConfig[this.databasePath].tables?.delete(tableName);
522
+ // The schema on disk changed under the DDL lock: drop the cached plan
523
+ // (also under the old name when the table was renamed) so the next DML
524
+ // rebuilds from the reloaded schema.
525
+ this.computedPlanCache.delete(tableName);
526
+ if (config?.name && config.name !== tableName)
527
+ this.computedPlanCache.delete(config.name);
383
528
  }
384
529
  /**
385
530
  * Get table schema and config
@@ -405,6 +550,9 @@ export default class Inibase {
405
550
  },
406
551
  timestamp: await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`)),
407
552
  });
553
+ // A reload means the on-disk schema changed (create/update/external
554
+ // edit): the cached computed plan is derived from it.
555
+ this.computedPlanCache.delete(tableName);
408
556
  return globalConfig[this.databasePath].tables?.get(tableName);
409
557
  }
410
558
  async getTableSchema(tableName) {
@@ -477,6 +625,13 @@ export default class Inibase {
477
625
  }
478
626
  if (Utils.isObject(data)) {
479
627
  for (const field of schema) {
628
+ // Computed columns are never user-settable: their value is
629
+ // always derived by the engine at write time.
630
+ if (typeof field.computed !== "undefined") {
631
+ if (Object.hasOwn(data, field.key))
632
+ throw this.createError("COMPUTED_FIELD_SETTABLE", field.key);
633
+ continue;
634
+ }
480
635
  if (!Object.hasOwn(data, field.key) ||
481
636
  data[field.key] === null ||
482
637
  data[field.key] === undefined ||
@@ -860,6 +1015,454 @@ export default class Inibase {
860
1015
  }
861
1016
  return RETURN;
862
1017
  }
1018
+ /* -----------------------------------------------------------------------
1019
+ * Computed fields
1020
+ * --------------------------------------------------------------------- */
1021
+ /**
1022
+ * Extract the raw expression from a field's `computed` property (string or
1023
+ * persisted `{ expr, ast }` spec).
1024
+ */
1025
+ computedExprOf(field) {
1026
+ const computed = field.computed;
1027
+ return typeof computed === "string" ? computed : (computed?.expr ?? "");
1028
+ }
1029
+ /**
1030
+ * Compile every `computed` expression of a schema (which must already have
1031
+ * ids assigned) into its persisted `{ expr, ast }` form, in dependency
1032
+ * order. Throws `COMPUTED_FIELD_CYCLE` on cyclic fields. Used by DDL so the
1033
+ * on-disk schema always carries compiled ASTs.
1034
+ */
1035
+ async compileComputedFields(schema) {
1036
+ const computedFields = schema.filter((field) => typeof field.computed !== "undefined");
1037
+ if (!computedFields.length)
1038
+ return schema;
1039
+ const index = buildFieldIndex(schema);
1040
+ const ctx = {
1041
+ language: this.language,
1042
+ ownKey: "",
1043
+ index,
1044
+ getTableIndex: async (target) => this.tableFieldIndex(target),
1045
+ };
1046
+ const resolved = [];
1047
+ for (const field of computedFields) {
1048
+ // Computed fields are evaluated from the row of the table they
1049
+ // belong to, so v1 restricts them to top-level schema fields.
1050
+ const ref = index.get(field.id);
1051
+ if (!ref || ref.arrayAncestor)
1052
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", field.key);
1053
+ ctx.ownKey = field.key;
1054
+ const raw = parseExpression(this.computedExprOf(field), this.language, field.key);
1055
+ const { ast, deps } = await resolveExpression(raw, ctx);
1056
+ resolved.push({ field, ast, deps });
1057
+ }
1058
+ const ordered = topoSortComputedFields(resolved.map(({ field, deps }) => ({
1059
+ id: field.id,
1060
+ key: field.key,
1061
+ deps,
1062
+ })), this.language);
1063
+ const specByKey = new Map();
1064
+ for (const meta of ordered) {
1065
+ const entry = resolved.find(({ field }) => field.id === meta.id);
1066
+ if (!entry)
1067
+ continue;
1068
+ specByKey.set(meta.key, {
1069
+ expr: this.computedExprOf(entry.field),
1070
+ ast: entry.ast,
1071
+ });
1072
+ }
1073
+ return schema.map((field) => typeof field.computed !== "undefined"
1074
+ ? { ...field, computed: specByKey.get(field.key) }
1075
+ : field);
1076
+ }
1077
+ /**
1078
+ * Build the evaluation plan (topological order + id index) for a table's
1079
+ * computed fields. Throws on cycles or unresolvable expressions.
1080
+ */
1081
+ async buildComputedPlan(tableName, schema) {
1082
+ // Plans only depend on the persisted schema (compiled, id-based ASTs),
1083
+ // so cache per table and invalidate on schema changes. The explicit
1084
+ // `schema` argument (backfill during updateTable) always bypasses the
1085
+ // cache — the caller owns that freshly-migrated schema.
1086
+ if (schema === undefined) {
1087
+ const cached = this.computedPlanCache.get(tableName);
1088
+ if (cached !== undefined)
1089
+ return cached;
1090
+ }
1091
+ const s = schema ?? globalConfig[this.databasePath].tables?.get(tableName)?.schema;
1092
+ if (!s)
1093
+ return null;
1094
+ const index = buildFieldIndex(s);
1095
+ const fields = [];
1096
+ const indexCache = new Map();
1097
+ const ctx = {
1098
+ language: this.language,
1099
+ ownKey: "",
1100
+ index,
1101
+ getTableIndex: async (target) => {
1102
+ let cached = indexCache.get(target);
1103
+ if (!cached) {
1104
+ cached = await this.tableFieldIndex(target);
1105
+ if (cached)
1106
+ indexCache.set(target, cached);
1107
+ }
1108
+ return cached;
1109
+ },
1110
+ };
1111
+ for (const field of s) {
1112
+ if (typeof field.computed === "undefined")
1113
+ continue;
1114
+ const computed = field.computed;
1115
+ if (typeof computed === "string") {
1116
+ ctx.ownKey = field.key;
1117
+ const raw = parseExpression(computed, this.language, field.key);
1118
+ const { ast, deps } = await resolveExpression(raw, ctx);
1119
+ fields.push({
1120
+ id: field.id,
1121
+ key: field.key,
1122
+ ast,
1123
+ deps,
1124
+ });
1125
+ }
1126
+ else
1127
+ fields.push({
1128
+ id: field.id,
1129
+ key: field.key,
1130
+ ast: computed.ast,
1131
+ deps: collectFieldDeps(computed.ast),
1132
+ });
1133
+ }
1134
+ if (!fields.length) {
1135
+ if (schema === undefined)
1136
+ this.computedPlanCache.set(tableName, null);
1137
+ return null;
1138
+ }
1139
+ const ordered = topoSortComputedFields(fields.map(({ id, key, deps }) => ({ id, key, deps })), this.language);
1140
+ const byId = new Map(fields.map((f) => [f.id, f]));
1141
+ const planFields = ordered.map((meta) => byId.get(meta.id));
1142
+ const plan = {
1143
+ fields: planFields,
1144
+ index,
1145
+ hasHops: planFields.some((field) => astHasHops(field.ast)),
1146
+ };
1147
+ if (schema === undefined)
1148
+ this.computedPlanCache.set(tableName, plan);
1149
+ return plan;
1150
+ }
1151
+ /** Id index of a table's schema (link targets are re-resolved at write
1152
+ * time, so renames never retarget a compiled expression). */
1153
+ async tableFieldIndex(tableName) {
1154
+ const schema = await this.getTableSchema(tableName);
1155
+ return schema ? buildFieldIndex(schema) : undefined;
1156
+ }
1157
+ /**
1158
+ * Evaluate a batch of (merged) rows against the table's computed fields.
1159
+ * Returns `lineNo -> { computedKey -> value }` in dependency order.
1160
+ *
1161
+ * Batched link-hop reads: when the plan contains any link hop, a dry
1162
+ * collection pass records every (table, column, id) triple the rows'
1163
+ * expressions need (no file I/O), each distinct triple is then resolved
1164
+ * exactly once — deduplicated across rows and fields — and a final pass
1165
+ * evaluates against the warm cache. Plans without hops run the single
1166
+ * evaluation pass unchanged.
1167
+ */
1168
+ async evaluateComputedRows(tableName, plan, rows) {
1169
+ const out = {};
1170
+ const indexCache = new Map([
1171
+ [tableName, plan.index],
1172
+ ]);
1173
+ const entries = Object.entries(rows);
1174
+ const reader = plan.hasHops ? this.createLinkReader() : null;
1175
+ if (reader) {
1176
+ for (const [, row] of entries)
1177
+ await this.evaluateRowComputed(tableName, plan, row, indexCache, reader, true);
1178
+ if (reader.hasPending)
1179
+ await reader.resolveAll();
1180
+ }
1181
+ for (const [line, row] of entries)
1182
+ out[Number(line)] = await this.evaluateRowComputed(tableName, plan, row, indexCache, reader ?? undefined);
1183
+ return out;
1184
+ }
1185
+ createLinkReader() {
1186
+ return new LinkedRowReader((table, column, id) => this.readLinkedRow(table, id, column));
1187
+ }
1188
+ /** Evaluate every computed field of one row (topological order) and merge
1189
+ * the results back into the row so dependent fields see them. */
1190
+ async evaluateRowComputed(tableName, plan, row, indexCache, links, collect = false) {
1191
+ const out = {};
1192
+ // One structured frame per row, shared by every computed field: paths
1193
+ // resolve against the row in place (direct key-path reads), so no
1194
+ // flattened copy is ever built. Freshly evaluated values are written
1195
+ // back into the row — computed fields are top-level keys, so dependent
1196
+ // fields see earlier results through the same key-path reads.
1197
+ const env = {
1198
+ table: tableName,
1199
+ index: plan.index,
1200
+ frame: row,
1201
+ row,
1202
+ strip: "",
1203
+ ownKey: "",
1204
+ indexCache,
1205
+ links,
1206
+ collect,
1207
+ };
1208
+ for (const field of plan.fields) {
1209
+ env.strip = "";
1210
+ env.ownKey = field.key;
1211
+ const value = await this.evaluateNode(field.ast, env);
1212
+ out[field.key] = value;
1213
+ row[field.key] = value;
1214
+ }
1215
+ return out;
1216
+ }
1217
+ async evaluateNode(node, env) {
1218
+ switch (node.kind) {
1219
+ case "num":
1220
+ return node.value;
1221
+ case "path":
1222
+ return this.evaluatePath(node, env);
1223
+ case "bin": {
1224
+ const a = await this.evaluateNode(node.left, env);
1225
+ const b = await this.evaluateNode(node.right, env);
1226
+ return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect);
1227
+ }
1228
+ case "fn": {
1229
+ const arrayRef = env.index.get(node.arrayFieldId);
1230
+ const arrayKey = arrayRef?.key;
1231
+ const raw = arrayKey ? env.row[arrayKey] : undefined;
1232
+ // formatData turns a missing/empty array-of-objects into `{}`;
1233
+ // treat anything that is not an actual array as empty.
1234
+ const elements = Array.isArray(raw) ? raw : [];
1235
+ const values = [];
1236
+ // Reuse the env object across elements (only frame/strip
1237
+ // change) to avoid allocating a frame per element. Elements
1238
+ // are walked in place via direct key-path reads, so nothing is
1239
+ // flattened or cached.
1240
+ const savedFrame = env.frame;
1241
+ const savedStrip = env.strip;
1242
+ const stripPrefix = arrayKey ? `${arrayKey}.` : "";
1243
+ for (const element of elements) {
1244
+ if (!Utils.isObject(element))
1245
+ continue;
1246
+ env.frame = element;
1247
+ env.strip = stripPrefix;
1248
+ const value = await this.evaluateNode(node.arg, env);
1249
+ if (value === null || value === undefined)
1250
+ continue;
1251
+ const num = this.coerceNumber(value, env.ownKey);
1252
+ values.push(num);
1253
+ }
1254
+ env.frame = savedFrame;
1255
+ env.strip = savedStrip;
1256
+ return this.aggregate(node.name, values, elements.length, env.ownKey, env.collect);
1257
+ }
1258
+ }
1259
+ }
1260
+ /** Evaluate a path (`ids`, dot-separated link hops) against the current
1261
+ * frame. Returns `null` when an intermediate link value is missing. */
1262
+ async evaluatePath(node, env) {
1263
+ const ids = node.ids;
1264
+ let table = env.table;
1265
+ let index = env.index;
1266
+ let frame = env.frame;
1267
+ let strip = env.strip;
1268
+ let value;
1269
+ let ref;
1270
+ for (let i = 0; i < ids.length; i++) {
1271
+ ref = index.get(ids[i]);
1272
+ if (!ref)
1273
+ throw this.createError("COMPUTED_FIELD_UNKNOWN_FIELD", [
1274
+ env.ownKey,
1275
+ ids[i],
1276
+ ]);
1277
+ const key = strip && ref.key.startsWith(strip)
1278
+ ? ref.key.slice(strip.length)
1279
+ : ref.key;
1280
+ if (i === 0) {
1281
+ // Direct key-path read: walk the structured frame in place.
1282
+ value = resolveFramePath(frame, key);
1283
+ strip = "";
1284
+ }
1285
+ else {
1286
+ // `value` is the id of the row this hop lives in.
1287
+ if (value === undefined ||
1288
+ value === null ||
1289
+ value === "" ||
1290
+ (typeof value === "object" && value !== null))
1291
+ return null;
1292
+ // A transaction cannot read a dependency table it has already
1293
+ // staged (no read-your-writes past its commit point).
1294
+ if (this.transaction?.tables.has(table))
1295
+ throw this.createError("INVALID_PARAMETERS");
1296
+ // Batched link-hop reads: during the collection pass a hop
1297
+ // only records its (table, column, id) triple; the real pass
1298
+ // is served from the reader's warm cache. Without a reader
1299
+ // this is the plain per-hop engine read.
1300
+ const row = env.links
1301
+ ? env.collect
1302
+ ? (env.links.record(table, ref.key, value), null)
1303
+ : await env.links.read(table, ref.key, value)
1304
+ : await this.readLinkedRow(table, value, ref.key);
1305
+ if (!env.collect &&
1306
+ (row === undefined || row === null))
1307
+ throw this.createError("COMPUTED_FIELD_DANGLING_LINK", [
1308
+ env.ownKey,
1309
+ table,
1310
+ ]);
1311
+ // The linked row is the next frame, walked in place (no
1312
+ // flattened copy). readLinkedRow already fetched only the
1313
+ // `ref.key` column, so the frame is minimal.
1314
+ frame = row;
1315
+ value = row ? resolveFramePath(frame, ref.key) : null;
1316
+ index = (await this.indexFor(table, env.indexCache)) ?? index;
1317
+ }
1318
+ if (i < ids.length - 1) {
1319
+ if (ref.field.type !== "table" || typeof ref.field.table !== "string")
1320
+ throw this.createError("COMPUTED_FIELD_INVALID_LINK", [
1321
+ env.ownKey,
1322
+ ids[i + 1],
1323
+ ]);
1324
+ table = ref.field.table;
1325
+ index = (await this.indexFor(table, env.indexCache)) ?? index;
1326
+ }
1327
+ }
1328
+ return value === undefined ? null : value;
1329
+ }
1330
+ /** Id index lookup with a shared per-evaluation cache. */
1331
+ async indexFor(tableName, cache) {
1332
+ const cached = cache.get(tableName);
1333
+ if (cached)
1334
+ return cached;
1335
+ const index = await this.tableFieldIndex(tableName);
1336
+ if (index)
1337
+ cache.set(tableName, index);
1338
+ return index;
1339
+ }
1340
+ /** Read a single column of one linked row via `get`, or null when the
1341
+ * row does not exist (dangling link). */
1342
+ async readLinkedRow(tableName, id, column) {
1343
+ const row = await this.get(tableName, id, { columns: [column] }, true);
1344
+ return row ?? null;
1345
+ }
1346
+ coerceNumber(value, ownKey) {
1347
+ const num = Number(value);
1348
+ if (!Number.isFinite(num))
1349
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1350
+ ownKey,
1351
+ `non-numeric value '${String(value)}'`,
1352
+ ]);
1353
+ return num;
1354
+ }
1355
+ applyBinaryOp(op, a, b, ownKey, collect = false) {
1356
+ if (a === null || a === undefined || b === null || b === undefined) {
1357
+ // Collection passes must tolerate unresolved (null) link hops:
1358
+ // the pass only discovers which links are needed, so a neutral
1359
+ // result is fine. Real evaluation still throws.
1360
+ if (collect)
1361
+ return 0;
1362
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1363
+ ownKey,
1364
+ "missing or null operand",
1365
+ ]);
1366
+ }
1367
+ const an = this.coerceNumber(a, ownKey);
1368
+ const bn = this.coerceNumber(b, ownKey);
1369
+ switch (op) {
1370
+ case "add":
1371
+ return an + bn;
1372
+ case "sub":
1373
+ return an - bn;
1374
+ case "mul":
1375
+ return an * bn;
1376
+ case "div":
1377
+ if (bn === 0)
1378
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1379
+ ownKey,
1380
+ "division by zero",
1381
+ ]);
1382
+ return an / bn;
1383
+ case "mod":
1384
+ if (bn === 0)
1385
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1386
+ ownKey,
1387
+ "modulo by zero",
1388
+ ]);
1389
+ return an % bn;
1390
+ }
1391
+ }
1392
+ aggregate(name, values, elementCount, ownKey, collect = false) {
1393
+ switch (name) {
1394
+ case "count":
1395
+ // Aggregates iterate the row's actual array, so count is the
1396
+ // element count (regardless of value presence).
1397
+ return elementCount;
1398
+ case "sum":
1399
+ return values.reduce((acc, v) => acc + v, 0);
1400
+ case "avg": {
1401
+ if (!values.length) {
1402
+ // Collection passes may have skipped every value of an
1403
+ // array whose elements only held unresolved link hops.
1404
+ if (collect)
1405
+ return 0;
1406
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1407
+ ownKey,
1408
+ "average over no values",
1409
+ ]);
1410
+ }
1411
+ return values.reduce((acc, v) => acc + v, 0) / values.length;
1412
+ }
1413
+ case "min": {
1414
+ if (!values.length) {
1415
+ if (collect)
1416
+ return 0;
1417
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1418
+ ownKey,
1419
+ "min over no values",
1420
+ ]);
1421
+ }
1422
+ return Math.min(...values);
1423
+ }
1424
+ case "max": {
1425
+ if (!values.length) {
1426
+ if (collect)
1427
+ return 0;
1428
+ throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1429
+ ownKey,
1430
+ "max over no values",
1431
+ ]);
1432
+ }
1433
+ return Math.max(...values);
1434
+ }
1435
+ }
1436
+ }
1437
+ /** Merge evaluated per-line computed values into a `pathesContents` map
1438
+ * as line-numbered replace records (encoded cells). */
1439
+ mergeComputedLineRecords(tableName, plan, evaluated, pathesContents, existing) {
1440
+ for (const field of plan.fields) {
1441
+ const path = join(this.databasePath, tableName, `${field.key}${this.getFileExtension(tableName)}`);
1442
+ const entries = Object.entries(evaluated);
1443
+ // When the pre-update rows are available, compare each column's
1444
+ // re-evaluated line values against the stored ones (deterministic
1445
+ // encode). A column that is identical for every evaluated line is
1446
+ // left untouched — no content buffer, no File.replace, no fsync.
1447
+ if (existing) {
1448
+ let matches = true;
1449
+ for (const [line, values] of entries) {
1450
+ const old = existing[Number(line)]?.[field.key];
1451
+ const fresh = File.encode(values[field.key]);
1452
+ if (old === undefined || File.encode(old) !== fresh) {
1453
+ matches = false;
1454
+ break;
1455
+ }
1456
+ }
1457
+ if (matches)
1458
+ continue;
1459
+ }
1460
+ const content = pathesContents[path] ?? {};
1461
+ for (const [line, values] of entries)
1462
+ content[Number(line)] = File.encode(values[field.key]);
1463
+ pathesContents[path] = content;
1464
+ }
1465
+ }
863
1466
  // Helper function to determine if a field is simple
864
1467
  isSimpleField(fieldType) {
865
1468
  const complexTypes = ["array", "object", "table"];
@@ -2059,6 +2662,20 @@ export default class Inibase {
2059
2662
  clonedData.updatedAt = undefined;
2060
2663
  }
2061
2664
  clonedData = this.formatData(clonedData, globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [], false);
2665
+ // Derived columns: evaluate every computed expression against the
2666
+ // final formatted row (the row is pieced back in place so helpers
2667
+ // iterate the real array values and dependent fields see each
2668
+ // other). Evaluation happens before any file is touched.
2669
+ const computedPlan = await this.buildComputedPlan(tableName);
2670
+ if (computedPlan) {
2671
+ const rows = Array.isArray(clonedData)
2672
+ ? clonedData
2673
+ : [clonedData];
2674
+ // Evaluate every row against the shared plan in one batch:
2675
+ // link hops are read once per distinct (table, column, id)
2676
+ // across the whole post instead of once per hop.
2677
+ await this.evaluateComputedRows(tableName, computedPlan, Object.fromEntries(rows.map((row, index) => [index, row])));
2678
+ }
2062
2679
  const pathesContents = this.joinPathesContents(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
2063
2680
  ? Array.isArray(clonedData)
2064
2681
  ? clonedData.toReversed()
@@ -2168,12 +2785,37 @@ export default class Inibase {
2168
2785
  ...(({ id, ...restOfData }) => restOfData)(clonedData),
2169
2786
  updatedAt: Date.now(),
2170
2787
  });
2788
+ // Derived columns: a where-less put rewrites every row, so each
2789
+ // computed expression must be re-evaluated against the existing
2790
+ // row overlaid with the payload. The plan itself is schema-only.
2791
+ const computedPlan = await this.buildComputedPlan(tableName);
2171
2792
  try {
2172
2793
  if (this.transaction)
2173
2794
  await this.ensureTxnLock(tableName);
2174
2795
  else
2175
2796
  await File.lock(join(tablePath, ".tmp"));
2176
2797
  const { total } = await this.resolvePagination(tableName);
2798
+ if (computedPlan) {
2799
+ const lineNumbers = Array.from({ length: total }, (_, index) => index + 1);
2800
+ const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ??
2801
+ [];
2802
+ // Read every stored column (computed ones included) so the
2803
+ // merge can skip columns whose re-evaluated values are
2804
+ // unchanged — no rewrite, no fsync for them.
2805
+ const existing = await this.processSchemaData(tableName, schema, lineNumbers);
2806
+ const payloadRows = (Array.isArray(clonedData) ? clonedData : [clonedData]).map((row) => Object.fromEntries(Object.entries(row).filter(([, v]) => v !== "undefined")));
2807
+ const mergedRows = {};
2808
+ for (let index = 0; index < lineNumbers.length; index++) {
2809
+ const line = lineNumbers[index];
2810
+ mergedRows[line] = {
2811
+ ...(existing[line] ?? {}),
2812
+ ...(payloadRows[index % payloadRows.length] ?? {}),
2813
+ updatedAt: Date.now(),
2814
+ };
2815
+ }
2816
+ const evaluated = await this.evaluateComputedRows(tableName, computedPlan, mergedRows);
2817
+ this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
2818
+ }
2177
2819
  await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total))));
2178
2820
  if (this.transaction) {
2179
2821
  // Stage instead of publishing: row count is unchanged so
@@ -2233,6 +2875,9 @@ export default class Inibase {
2233
2875
  return obj;
2234
2876
  }, {}),
2235
2877
  ]));
2878
+ // Derived columns re-evaluate the target lines: existing values +
2879
+ // payload overlay feed the expressions, results go back per line.
2880
+ const computedPlan = await this.buildComputedPlan(tableName);
2236
2881
  try {
2237
2882
  // One global lock per table serializes every writer; inside a
2238
2883
  // transaction the lock is held for the whole txn.
@@ -2240,6 +2885,30 @@ export default class Inibase {
2240
2885
  await this.ensureTxnLock(tableName);
2241
2886
  else
2242
2887
  await File.lock(join(tablePath, ".tmp"));
2888
+ if (computedPlan) {
2889
+ const whereLines = Array.isArray(where) ? where : [where];
2890
+ const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ??
2891
+ [];
2892
+ // Read every stored column (computed ones included) so the
2893
+ // merge can skip columns whose re-evaluated values are
2894
+ // unchanged — no rewrite, no fsync for them.
2895
+ const existing = await this.processSchemaData(tableName, schema, whereLines);
2896
+ const payloadRows = (Array.isArray(clonedData) ? clonedData : [clonedData]).map((row) => Object.fromEntries(Object.entries(row).filter(([, v]) => v !== "undefined")));
2897
+ const evaluated = {};
2898
+ const indexCache = new Map([
2899
+ [tableName, computedPlan.index],
2900
+ ]);
2901
+ for (let index = 0; index < whereLines.length; index++) {
2902
+ const line = whereLines[index];
2903
+ const merged = {
2904
+ ...(existing[line] ?? {}),
2905
+ ...(payloadRows[index] ?? {}),
2906
+ updatedAt: Date.now(),
2907
+ };
2908
+ evaluated[line] = await this.evaluateRowComputed(tableName, computedPlan, merged, indexCache);
2909
+ }
2910
+ this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
2911
+ }
2243
2912
  await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
2244
2913
  if (this.transaction) {
2245
2914
  await this.stageTxnOp(tableName, renameList, null);