inibase 3.1.1 → 3.3.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
@@ -220,6 +220,9 @@ export default class Inibase {
220
220
  * @param {TableConfig} [config]
221
221
  */
222
222
  async createTable(tableName, schema, config) {
223
+ return File.runWithLockStore(() => this._createTable(tableName, schema, config));
224
+ }
225
+ async _createTable(tableName, schema, config) {
223
226
  this.validateName(tableName);
224
227
  // DDL does not participate in the write-ahead journal: schema surgery
225
228
  // inside a transaction would escape the atomic publish/rollback scope.
@@ -291,6 +294,9 @@ export default class Inibase {
291
294
  * @param {(TableConfig&{name?: string})} [config]
292
295
  */
293
296
  async updateTable(tableName, schema, config) {
297
+ return File.runWithLockStore(() => this._updateTable(tableName, schema, config));
298
+ }
299
+ async _updateTable(tableName, schema, config) {
294
300
  this.validateName(tableName);
295
301
  // DDL does not participate in the write-ahead journal: schema surgery
296
302
  // inside a transaction would escape the atomic publish/rollback scope.
@@ -364,14 +370,14 @@ export default class Inibase {
364
370
  // row. Evaluation happens BEFORE the schema is rewritten, so a
365
371
  // failed migration (dangling link, arithmetic error, unknown
366
372
  // 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)]));
373
+ const oldComputed = new Map(this.computedEntries(table.schema ?? []).map(({ key, expr }) => [
374
+ key,
375
+ expr,
376
+ ]));
370
377
  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)
378
+ ? this.computedEntries(schema)
379
+ .filter(({ key, expr }) => oldComputed.get(key) !== expr)
380
+ .map(({ key }) => key)
375
381
  : []);
376
382
  const backfillReplacers = changedComputedKeys.size
377
383
  ? await (async () => {
@@ -1026,6 +1032,25 @@ export default class Inibase {
1026
1032
  const computed = field.computed;
1027
1033
  return typeof computed === "string" ? computed : (computed?.expr ?? "");
1028
1034
  }
1035
+ /**
1036
+ * Every computed field of a schema as dotted keys (computed children of an
1037
+ * array-of-objects column use their full path, e.g. `items.lineTotal`),
1038
+ * with its raw expression — used for updateTable change detection.
1039
+ */
1040
+ computedEntries(schema) {
1041
+ const out = [];
1042
+ const walk = (fields, prefix) => {
1043
+ for (const field of fields) {
1044
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1045
+ if (typeof field.computed !== "undefined")
1046
+ out.push({ key, expr: this.computedExprOf(field) });
1047
+ if (field.children && Utils.isArrayOfObjects(field.children))
1048
+ walk(field.children, key);
1049
+ }
1050
+ };
1051
+ walk(schema, "");
1052
+ return out;
1053
+ }
1029
1054
  /**
1030
1055
  * Compile every `computed` expression of a schema (which must already have
1031
1056
  * ids assigned) into its persisted `{ expr, ast }` form, in dependency
@@ -1033,27 +1058,67 @@ export default class Inibase {
1033
1058
  * on-disk schema always carries compiled ASTs.
1034
1059
  */
1035
1060
  async compileComputedFields(schema) {
1036
- const computedFields = schema.filter((field) => typeof field.computed !== "undefined");
1037
- if (!computedFields.length)
1038
- return schema;
1061
+ const entries = [];
1039
1062
  const index = buildFieldIndex(schema);
1063
+ const walk = (fields, prefix) => {
1064
+ for (const field of fields) {
1065
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1066
+ if (typeof field.computed !== "undefined") {
1067
+ const ref = index.get(field.id);
1068
+ entries.push({
1069
+ field,
1070
+ key,
1071
+ elementRoot: ref?.arrayAncestor
1072
+ ? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
1073
+ : null,
1074
+ });
1075
+ }
1076
+ if (field.children && Utils.isArrayOfObjects(field.children))
1077
+ walk(field.children, key);
1078
+ }
1079
+ };
1080
+ walk(schema, "");
1081
+ if (!entries.length)
1082
+ return schema;
1040
1083
  const ctx = {
1041
1084
  language: this.language,
1042
1085
  ownKey: "",
1043
1086
  index,
1044
1087
  getTableIndex: async (target) => this.tableFieldIndex(target),
1088
+ elementContext: null,
1045
1089
  };
1046
1090
  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);
1091
+ for (const entry of entries) {
1092
+ const ref = index.get(entry.field.id);
1093
+ if (!ref)
1094
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1095
+ if (entry.elementRoot) {
1096
+ // Element computed field (a child of an array-of-objects
1097
+ // column): must hold a plain value (never a container), and v1
1098
+ // keeps them numeric so writes are always summable.
1099
+ if ((ref.nestedInArrayOfArrays && ref.arrayAncestor) ||
1100
+ entry.field.type !== "number")
1101
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1102
+ ctx.elementContext = {
1103
+ arrayRootId: entry.elementRoot.id,
1104
+ };
1105
+ }
1106
+ else {
1107
+ // Top-level computed field: cannot live inside an array.
1108
+ if (ref.arrayAncestor)
1109
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1110
+ ctx.elementContext = null;
1111
+ }
1112
+ ctx.ownKey = entry.key;
1113
+ const raw = parseExpression(this.computedExprOf(entry.field), this.language, entry.key);
1055
1114
  const { ast, deps } = await resolveExpression(raw, ctx);
1056
- resolved.push({ field, ast, deps });
1115
+ resolved.push({
1116
+ field: entry.field,
1117
+ key: entry.key,
1118
+ elementRoot: entry.elementRoot,
1119
+ ast,
1120
+ deps,
1121
+ });
1057
1122
  }
1058
1123
  const ordered = topoSortComputedFields(resolved.map(({ field, deps }) => ({
1059
1124
  id: field.id,
@@ -1065,14 +1130,27 @@ export default class Inibase {
1065
1130
  const entry = resolved.find(({ field }) => field.id === meta.id);
1066
1131
  if (!entry)
1067
1132
  continue;
1068
- specByKey.set(meta.key, {
1133
+ specByKey.set(entry.key, {
1069
1134
  expr: this.computedExprOf(entry.field),
1070
1135
  ast: entry.ast,
1071
1136
  });
1072
1137
  }
1073
- return schema.map((field) => typeof field.computed !== "undefined"
1074
- ? { ...field, computed: specByKey.get(field.key) }
1075
- : field);
1138
+ const applySpecs = (fields, prefix) => fields.map((field) => {
1139
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1140
+ let next = field;
1141
+ if (typeof field.computed !== "undefined") {
1142
+ const spec = specByKey.get(key);
1143
+ if (spec)
1144
+ next = { ...field, computed: spec };
1145
+ }
1146
+ if (field.children && Utils.isArrayOfObjects(field.children))
1147
+ next = {
1148
+ ...next,
1149
+ children: applySpecs(field.children, key),
1150
+ };
1151
+ return next;
1152
+ });
1153
+ return applySpecs(schema, "");
1076
1154
  }
1077
1155
  /**
1078
1156
  * Build the evaluation plan (topological order + id index) for a table's
@@ -1098,6 +1176,7 @@ export default class Inibase {
1098
1176
  language: this.language,
1099
1177
  ownKey: "",
1100
1178
  index,
1179
+ elementContext: null,
1101
1180
  getTableIndex: async (target) => {
1102
1181
  let cached = indexCache.get(target);
1103
1182
  if (!cached) {
@@ -1108,29 +1187,46 @@ export default class Inibase {
1108
1187
  return cached;
1109
1188
  },
1110
1189
  };
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
- });
1190
+ // Collect every computed field: top-level ones and, when a schema
1191
+ // carries them, the computed children of array-of-objects columns.
1192
+ const walk = async (fieldsSchema, prefix) => {
1193
+ for (const field of fieldsSchema) {
1194
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1195
+ if (typeof field.computed !== "undefined") {
1196
+ const ref = index.get(field.id);
1197
+ const elementRoot = ref?.arrayAncestor
1198
+ ? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
1199
+ : null;
1200
+ const computed = field.computed;
1201
+ if (typeof computed === "string") {
1202
+ ctx.ownKey = key;
1203
+ ctx.elementContext = elementRoot
1204
+ ? { arrayRootId: elementRoot.id }
1205
+ : null;
1206
+ const raw = parseExpression(computed, this.language, key);
1207
+ const { ast, deps } = await resolveExpression(raw, ctx);
1208
+ fields.push({
1209
+ id: field.id,
1210
+ key,
1211
+ ast,
1212
+ deps,
1213
+ elementRoot,
1214
+ });
1215
+ }
1216
+ else
1217
+ fields.push({
1218
+ id: field.id,
1219
+ key,
1220
+ ast: computed.ast,
1221
+ deps: collectFieldDeps(computed.ast),
1222
+ elementRoot,
1223
+ });
1224
+ }
1225
+ if (field.children && Utils.isArrayOfObjects(field.children))
1226
+ await walk(field.children, key);
1125
1227
  }
1126
- else
1127
- fields.push({
1128
- id: field.id,
1129
- key: field.key,
1130
- ast: computed.ast,
1131
- deps: collectFieldDeps(computed.ast),
1132
- });
1133
- }
1228
+ };
1229
+ await walk(s, "");
1134
1230
  if (!fields.length) {
1135
1231
  if (schema === undefined)
1136
1232
  this.computedPlanCache.set(tableName, null);
@@ -1206,6 +1302,42 @@ export default class Inibase {
1206
1302
  collect,
1207
1303
  };
1208
1304
  for (const field of plan.fields) {
1305
+ if (field.elementRoot) {
1306
+ // Element computed field (child of an array-of-objects column):
1307
+ // evaluate once per element (frame = element, strip =
1308
+ // `${rootKey}.`), write back into the element so other computed
1309
+ // fields and the flatten pass see it, and report the
1310
+ // per-element array under the dotted column key. Missing
1311
+ // operands read as 0 (`env.nullAsZero`), never an error.
1312
+ const rootKey = field.elementRoot.key;
1313
+ const childKey = field.key.slice(rootKey.length + 1);
1314
+ const elements = Array.isArray(env.row[rootKey])
1315
+ ? env.row[rootKey]
1316
+ : [];
1317
+ const savedFrame = env.frame;
1318
+ const savedStrip = env.strip;
1319
+ const savedNullAsZero = env.nullAsZero;
1320
+ env.strip = `${rootKey}.`;
1321
+ env.ownKey = field.key;
1322
+ env.nullAsZero = true;
1323
+ const values = [];
1324
+ for (const element of elements) {
1325
+ if (!Utils.isObject(element)) {
1326
+ values.push(0);
1327
+ continue;
1328
+ }
1329
+ env.frame = element;
1330
+ const value = await this.evaluateNode(field.ast, env);
1331
+ const written = value === null || value === undefined ? 0 : value;
1332
+ element[childKey] = written;
1333
+ values.push(written);
1334
+ }
1335
+ env.frame = savedFrame;
1336
+ env.strip = savedStrip;
1337
+ env.nullAsZero = savedNullAsZero;
1338
+ out[field.key] = values;
1339
+ continue;
1340
+ }
1209
1341
  env.strip = "";
1210
1342
  env.ownKey = field.key;
1211
1343
  const value = await this.evaluateNode(field.ast, env);
@@ -1223,7 +1355,7 @@ export default class Inibase {
1223
1355
  case "bin": {
1224
1356
  const a = await this.evaluateNode(node.left, env);
1225
1357
  const b = await this.evaluateNode(node.right, env);
1226
- return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect);
1358
+ return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect, env.nullAsZero);
1227
1359
  }
1228
1360
  case "fn": {
1229
1361
  const arrayRef = env.index.get(node.arrayFieldId);
@@ -1283,7 +1415,12 @@ export default class Inibase {
1283
1415
  strip = "";
1284
1416
  }
1285
1417
  else {
1286
- // `value` is the id of the row this hop lives in.
1418
+ // `value` is the id of the row this hop lives in. Rows that
1419
+ // came off disk are eagerly resolved by processSchemaData, so
1420
+ // table children may already be linked objects — unwrap those
1421
+ // back to their id before the hop lookup. Nothing else hops.
1422
+ if (typeof value === "object" && value !== null)
1423
+ value = value.id;
1287
1424
  if (value === undefined ||
1288
1425
  value === null ||
1289
1426
  value === "" ||
@@ -1302,8 +1439,7 @@ export default class Inibase {
1302
1439
  ? (env.links.record(table, ref.key, value), null)
1303
1440
  : await env.links.read(table, ref.key, value)
1304
1441
  : await this.readLinkedRow(table, value, ref.key);
1305
- if (!env.collect &&
1306
- (row === undefined || row === null))
1442
+ if (!env.collect && (row === undefined || row === null))
1307
1443
  throw this.createError("COMPUTED_FIELD_DANGLING_LINK", [
1308
1444
  env.ownKey,
1309
1445
  table,
@@ -1352,18 +1488,28 @@ export default class Inibase {
1352
1488
  ]);
1353
1489
  return num;
1354
1490
  }
1355
- applyBinaryOp(op, a, b, ownKey, collect = false) {
1491
+ applyBinaryOp(op, a, b, ownKey, collect = false, nullAsZero = false) {
1356
1492
  if (a === null || a === undefined || b === null || b === undefined) {
1357
1493
  // Collection passes must tolerate unresolved (null) link hops:
1358
1494
  // the pass only discovers which links are needed, so a neutral
1359
1495
  // result is fine. Real evaluation still throws.
1360
1496
  if (collect)
1361
1497
  return 0;
1498
+ // Element computed evaluation treats a missing operand as the
1499
+ // value 0 (per the element-sum feature), so a single missing
1500
+ // child never aborts the write.
1501
+ if (nullAsZero) {
1502
+ if (a === null || a === undefined)
1503
+ a = 0;
1504
+ if (b === null || b === undefined)
1505
+ b = 0;
1506
+ }
1507
+ }
1508
+ if (a === null || a === undefined || b === null || b === undefined)
1362
1509
  throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1363
1510
  ownKey,
1364
1511
  "missing or null operand",
1365
1512
  ]);
1366
- }
1367
1513
  const an = this.coerceNumber(a, ownKey);
1368
1514
  const bn = this.coerceNumber(b, ownKey);
1369
1515
  switch (op) {
@@ -1447,9 +1593,11 @@ export default class Inibase {
1447
1593
  if (existing) {
1448
1594
  let matches = true;
1449
1595
  for (const [line, values] of entries) {
1596
+ if (!Object.hasOwn(values, field.key))
1597
+ continue;
1598
+ const lineValue = values[field.key];
1450
1599
  const old = existing[Number(line)]?.[field.key];
1451
- const fresh = File.encode(values[field.key]);
1452
- if (old === undefined || File.encode(old) !== fresh) {
1600
+ if (old === undefined || !this.sameCellValue(lineValue, old)) {
1453
1601
  matches = false;
1454
1602
  break;
1455
1603
  }
@@ -1459,10 +1607,26 @@ export default class Inibase {
1459
1607
  }
1460
1608
  const content = pathesContents[path] ?? {};
1461
1609
  for (const [line, values] of entries)
1462
- content[Number(line)] = File.encode(values[field.key]);
1610
+ if (Object.hasOwn(values, field.key) && values[field.key] !== undefined)
1611
+ content[Number(line)] = File.encode(values[field.key]);
1463
1612
  pathesContents[path] = content;
1464
1613
  }
1465
1614
  }
1615
+ /** Compare a freshly evaluated cell value against a stored (already
1616
+ * decoded) one — both are scalar or index-aligned element arrays. */
1617
+ sameCellValue(fresh, stored) {
1618
+ if (fresh === stored)
1619
+ return true;
1620
+ if (Array.isArray(fresh) &&
1621
+ Array.isArray(stored) &&
1622
+ fresh.length === stored.length) {
1623
+ for (let i = 0; i < fresh.length; i++)
1624
+ if (fresh[i] !== stored[i])
1625
+ return false;
1626
+ return true;
1627
+ }
1628
+ return File.encode(fresh) === File.encode(stored);
1629
+ }
1466
1630
  // Helper function to determine if a field is simple
1467
1631
  isSimpleField(fieldType) {
1468
1632
  const complexTypes = ["array", "object", "table"];
@@ -2150,6 +2314,9 @@ export default class Inibase {
2150
2314
  * locked on first touch, in first-touch order.
2151
2315
  */
2152
2316
  async begin(tables = []) {
2317
+ return File.runWithLockStore(() => this._begin(tables));
2318
+ }
2319
+ async _begin(tables = []) {
2153
2320
  if (this.transaction)
2154
2321
  throw this.createError("INVALID_PARAMETERS");
2155
2322
  await this.ensureDatabaseTmpDir();
@@ -2301,6 +2468,12 @@ export default class Inibase {
2301
2468
  async get(tableName, where, options = {
2302
2469
  page: 1,
2303
2470
  perPage: 15,
2471
+ }, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
2472
+ return File.runWithLockStore(() => this._get(tableName, where, options, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers));
2473
+ }
2474
+ async _get(tableName, where, options = {
2475
+ page: 1,
2476
+ perPage: 15,
2304
2477
  }, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
2305
2478
  this.validateName(tableName);
2306
2479
  await this.ensureTableRecovered(tableName);
@@ -2473,9 +2646,13 @@ export default class Inibase {
2473
2646
  }
2474
2647
  else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
2475
2648
  Utils.isNumber(where)) &&
2476
- (_whereIsLinesNumbers ||
2477
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
2478
- .decodeID)) {
2649
+ // Numeric where is a line number only through the internal
2650
+ // recursion (flag set). An external numeric where is an id — even
2651
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
2652
+ // layout / rows deleted); using them as lines edited the wrong row
2653
+ // and padded the column file (Sep-2026 rename bug). Ids are
2654
+ // resolved by the next branch.
2655
+ _whereIsLinesNumbers) {
2479
2656
  // "where" in this case, is the line(s) number(s) and not id(s)
2480
2657
  let lineNumbers = where;
2481
2658
  if (!Array.isArray(lineNumbers))
@@ -2489,8 +2666,6 @@ export default class Inibase {
2489
2666
  RETURN = RETURN[0];
2490
2667
  }
2491
2668
  else if ((!_whereIsLinesNumbers &&
2492
- globalConfig[this.databasePath].tables?.get(tableName)?.config
2493
- .decodeID &&
2494
2669
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
2495
2670
  Utils.isNumber(where))) ||
2496
2671
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
@@ -2616,6 +2791,9 @@ export default class Inibase {
2616
2791
  return onlyOne && Array.isArray(RETURN) ? RETURN[0] : RETURN;
2617
2792
  }
2618
2793
  async post(tableName, data, options, returnPostedData) {
2794
+ return File.runWithLockStore(() => this._post(tableName, data, options, returnPostedData));
2795
+ }
2796
+ async _post(tableName, data, options, returnPostedData) {
2619
2797
  if (!options)
2620
2798
  options = {
2621
2799
  page: 1,
@@ -2713,7 +2891,7 @@ export default class Inibase {
2713
2891
  // No read-your-writes yet: return the formatted staged rows
2714
2892
  // (ids + defaults) instead of a committed-state read.
2715
2893
  return (Array.isArray(clonedData) ? clonedData : clonedData);
2716
- return this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
2894
+ return await this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
2717
2895
  ? Array.isArray(clonedData)
2718
2896
  ? clonedData.map((_, index) => index + 1).toReversed()
2719
2897
  : 1
@@ -2738,13 +2916,13 @@ export default class Inibase {
2738
2916
  // here so nothing leaks.
2739
2917
  if (!txnStaged && renameList.length)
2740
2918
  await Promise.allSettled(renameList
2741
- .filter((pair) => Boolean(pair[1]))
2919
+ .filter((pair) => Boolean(pair[0]))
2742
2920
  .map(async ([tempPath, _]) => unlink(tempPath)));
2743
2921
  }
2744
2922
  else {
2745
2923
  if (renameList.length)
2746
2924
  await Promise.allSettled(renameList
2747
- .filter((pair) => Boolean(pair[1]))
2925
+ .filter((pair) => Boolean(pair[0]))
2748
2926
  .map(async ([tempPath, _]) => unlink(tempPath)));
2749
2927
  await File.unlock(join(tablePath, ".tmp"));
2750
2928
  }
@@ -2753,6 +2931,12 @@ export default class Inibase {
2753
2931
  async put(tableName, data, where, options = {
2754
2932
  page: 1,
2755
2933
  perPage: 15,
2934
+ }, returnUpdatedData, _whereIsLinesNumbers) {
2935
+ return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
2936
+ }
2937
+ async _put(tableName, data, where, options = {
2938
+ page: 1,
2939
+ perPage: 15,
2756
2940
  }, returnUpdatedData, _whereIsLinesNumbers) {
2757
2941
  const renameList = [];
2758
2942
  let txnStaged = false;
@@ -2843,13 +3027,13 @@ export default class Inibase {
2843
3027
  // owns them.
2844
3028
  if (!txnStaged && renameList.length)
2845
3029
  await Promise.allSettled(renameList
2846
- .filter((pair) => Boolean(pair[1]))
3030
+ .filter((pair) => Boolean(pair[0]))
2847
3031
  .map(async ([tempPath, _]) => unlink(tempPath)));
2848
3032
  }
2849
3033
  else {
2850
3034
  if (renameList.length)
2851
3035
  await Promise.allSettled(renameList
2852
- .filter((pair) => Boolean(pair[1]))
3036
+ .filter((pair) => Boolean(pair[0]))
2853
3037
  .map(async ([tempPath, _]) => unlink(tempPath)));
2854
3038
  await File.unlock(join(tablePath, ".tmp"));
2855
3039
  }
@@ -2857,9 +3041,13 @@ export default class Inibase {
2857
3041
  }
2858
3042
  else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
2859
3043
  Utils.isNumber(where)) &&
2860
- (_whereIsLinesNumbers ||
2861
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
2862
- .decodeID)) {
3044
+ // Numeric where is a line number only through the internal
3045
+ // recursion (flag set). An external numeric where is an id — even
3046
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3047
+ // layout / rows deleted); using them as lines edited the wrong row
3048
+ // and padded the column file (Sep-2026 rename bug). Ids are
3049
+ // resolved by the next branch.
3050
+ _whereIsLinesNumbers) {
2863
3051
  // "where" in this case, is the line(s) number(s) and not id(s)
2864
3052
  await this.validateTableData(tableName, clonedData, true);
2865
3053
  clonedData = this.formatData(clonedData, globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [], true);
@@ -2922,42 +3110,63 @@ export default class Inibase {
2922
3110
  if (returnUpdatedData) {
2923
3111
  if (this.transaction)
2924
3112
  throw this.createError("INVALID_PARAMETERS");
2925
- return this.get(tableName, where, options, !Array.isArray(where), undefined, true);
3113
+ // AWAITED readback: must complete inside the writer lock. An
3114
+ // async-tail `return this.get(...)` lets the finally below
3115
+ // unlock before the readback's file reads resume.
3116
+ return await this.get(tableName, where, options, !Array.isArray(where), undefined, true);
2926
3117
  }
2927
3118
  }
2928
3119
  finally {
2929
3120
  if (this.transaction) {
2930
3121
  if (!txnStaged && renameList.length)
2931
3122
  await Promise.allSettled(renameList
2932
- .filter((pair) => Boolean(pair[1]))
3123
+ .filter((pair) => Boolean(pair[0]))
2933
3124
  .map(async ([tempPath, _]) => unlink(tempPath)));
2934
3125
  }
2935
3126
  else {
2936
3127
  if (renameList.length)
2937
3128
  await Promise.allSettled(renameList
2938
- .filter((pair) => Boolean(pair[1]))
3129
+ .filter((pair) => Boolean(pair[0]))
2939
3130
  .map(async ([tempPath, _]) => unlink(tempPath)));
2940
3131
  await File.unlock(join(tablePath, ".tmp"));
2941
3132
  }
2942
3133
  }
2943
3134
  }
2944
3135
  else if ((!_whereIsLinesNumbers &&
2945
- globalConfig[this.databasePath].tables?.get(tableName)?.config
2946
- .decodeID &&
2947
3136
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
2948
3137
  Utils.isNumber(where))) ||
2949
3138
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
2950
3139
  Utils.isValidID(where)) {
2951
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
2952
- if (lineNumbers)
2953
- return this.put(tableName, clonedData,
2954
- // get() with onlyLinesNumbers always returns an array; a
2955
- // single-id update must keep the scalar so the recursive
2956
- // line-numbers branch returns a single row (matching the
2957
- // shape of get(singleId)) instead of a one-element array.
2958
- !Array.isArray(where) && Array.isArray(lineNumbers)
2959
- ? lineNumbers[0]
2960
- : lineNumbers, options, returnUpdatedData, true);
3140
+ // Resolve id -> line(s) WHILE holding the writer lock: a post
3141
+ // landing between the resolution and the write would shift every
3142
+ // line of a prepend table and the update would hit the wrong row.
3143
+ // The recursive call re-takes the lock reentrantly (same task), so
3144
+ // the lock is held continuously from resolution to publish.
3145
+ let inlineLock = false;
3146
+ try {
3147
+ if (this.transaction)
3148
+ await this.ensureTxnLock(tableName);
3149
+ else {
3150
+ await File.lock(join(tablePath, ".tmp"));
3151
+ inlineLock = true;
3152
+ }
3153
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3154
+ if (lineNumbers)
3155
+ return await this.put(tableName, clonedData,
3156
+ // get() with onlyLinesNumbers always returns an array; a
3157
+ // single-id update must keep the scalar so the recursive
3158
+ // line-numbers branch returns a single row (matching the
3159
+ // shape of get(singleId)) instead of a one-element array.
3160
+ !Array.isArray(where) && Array.isArray(lineNumbers)
3161
+ ? lineNumbers[0]
3162
+ : lineNumbers, options, returnUpdatedData, true);
3163
+ }
3164
+ finally {
3165
+ // The recursion released its own (reentrant) acquisition;
3166
+ // release ours too (transactions keep the lock until commit).
3167
+ if (inlineLock)
3168
+ await File.unlock(join(tablePath, ".tmp"));
3169
+ }
2961
3170
  }
2962
3171
  else if (Utils.isObject(where)) {
2963
3172
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
@@ -2975,6 +3184,9 @@ export default class Inibase {
2975
3184
  * @return {boolean | null} {(Promise<boolean | null>)}
2976
3185
  */
2977
3186
  async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3187
+ return File.runWithLockStore(() => this._delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard));
3188
+ }
3189
+ async _delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
2978
3190
  this.validateName(tableName);
2979
3191
  if (!this.transaction)
2980
3192
  await this.ensureDatabaseRecovered();
@@ -3023,13 +3235,13 @@ export default class Inibase {
3023
3235
  if (this.transaction) {
3024
3236
  if (!txnStaged && renameList.length)
3025
3237
  await Promise.allSettled(renameList
3026
- .filter((pair) => Boolean(pair[1]))
3238
+ .filter((pair) => Boolean(pair[0]))
3027
3239
  .map(async ([tempPath, _]) => unlink(tempPath)));
3028
3240
  }
3029
3241
  else {
3030
3242
  if (renameList.length)
3031
3243
  await Promise.allSettled(renameList
3032
- .filter((pair) => Boolean(pair[1]))
3244
+ .filter((pair) => Boolean(pair[0]))
3033
3245
  .map(async ([tempPath, _]) => unlink(tempPath)));
3034
3246
  await File.unlock(join(tablePath, ".tmp"));
3035
3247
  }
@@ -3037,9 +3249,13 @@ export default class Inibase {
3037
3249
  }
3038
3250
  if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
3039
3251
  Utils.isNumber(where)) &&
3040
- (_whereIsLinesNumbers ||
3041
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
3042
- .decodeID)) {
3252
+ // Numeric where is a line number only through the internal
3253
+ // recursion (flag set). An external numeric where is an id — even
3254
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3255
+ // layout / rows deleted); using them as lines edited the wrong row
3256
+ // and padded the column file (Sep-2026 rename bug). Ids are
3257
+ // resolved by the next branch.
3258
+ _whereIsLinesNumbers) {
3043
3259
  // "where" in this case, is the line(s) number(s) and not id(s)
3044
3260
  const files = (await readdir(tablePath))?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)));
3045
3261
  if (files.length) {
@@ -3105,13 +3321,13 @@ export default class Inibase {
3105
3321
  if (this.transaction) {
3106
3322
  if (!txnStaged && renameList.length)
3107
3323
  await Promise.allSettled(renameList
3108
- .filter((pair) => Boolean(pair[1]))
3324
+ .filter((pair) => Boolean(pair[0]))
3109
3325
  .map(async ([tempPath, _]) => unlink(tempPath)));
3110
3326
  }
3111
3327
  else {
3112
3328
  if (renameList.length)
3113
3329
  await Promise.allSettled(renameList
3114
- .filter((pair) => Boolean(pair[1]))
3330
+ .filter((pair) => Boolean(pair[0]))
3115
3331
  .map(async ([tempPath, _]) => unlink(tempPath)));
3116
3332
  await File.unlock(join(tablePath, ".tmp"));
3117
3333
  }
@@ -3119,20 +3335,34 @@ export default class Inibase {
3119
3335
  }
3120
3336
  }
3121
3337
  if ((!_whereIsLinesNumbers &&
3122
- globalConfig[this.databasePath].tables?.get(tableName)?.config
3123
- .decodeID &&
3124
3338
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
3125
3339
  Utils.isNumber(where))) ||
3126
3340
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
3127
3341
  Utils.isValidID(where)) {
3128
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3129
- // Deleting a non-existent id must not fall through to the
3130
- // "delete all rows" branch (this.delete(_, null, _) would truncate
3131
- // the whole table), so resolve the id to line numbers first and
3132
- // only delegate when something actually matched.
3133
- if (lineNumbers)
3134
- return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
3135
- return false;
3342
+ // Resolve id -> line(s) WHILE holding the writer lock (see put():
3343
+ // a concurrent post between resolution and delete would shift the
3344
+ // lines of a prepend table and remove the wrong row).
3345
+ let inlineLock = false;
3346
+ try {
3347
+ if (this.transaction)
3348
+ await this.ensureTxnLock(tableName);
3349
+ else {
3350
+ await File.lock(join(tablePath, ".tmp"));
3351
+ inlineLock = true;
3352
+ }
3353
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3354
+ // Deleting a non-existent id must not fall through to the
3355
+ // "delete all rows" branch (this.delete(_, null, _) would truncate
3356
+ // the whole table), so resolve the id to line numbers first and
3357
+ // only delegate when something actually matched.
3358
+ if (lineNumbers)
3359
+ return await this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
3360
+ return false;
3361
+ }
3362
+ finally {
3363
+ if (inlineLock)
3364
+ await File.unlock(join(tablePath, ".tmp"));
3365
+ }
3136
3366
  }
3137
3367
  if (Utils.isObject(where)) {
3138
3368
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
@@ -3214,20 +3444,30 @@ export default class Inibase {
3214
3444
  }
3215
3445
  }
3216
3446
  }
3217
- async sum(tableName, columns, where) {
3447
+ async sum(tableName, columns, where, options) {
3218
3448
  this.validateName(tableName);
3219
3449
  if (!Array.isArray(columns))
3220
3450
  columns = [columns];
3221
- for (const column of columns)
3222
- this.validateName(column);
3451
+ for (const column of columns) {
3452
+ // Nested columns are dotted child paths (validated structurally by
3453
+ // `sumNested`); everything else must be a plain safe name.
3454
+ if (!options?.nested)
3455
+ this.validateName(column);
3456
+ }
3223
3457
  await this.throwErrorIfTableEmpty(tableName);
3224
3458
  const RETURN = {};
3225
3459
  const tablePath = join(this.databasePath, tableName);
3460
+ if (options?.nested) {
3461
+ const nested = await this.sumNested(tableName, columns, where, tablePath);
3462
+ return columns.length > 1 ? nested : Object.values(nested)[0];
3463
+ }
3226
3464
  for await (const column of columns) {
3227
3465
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3228
3466
  if (await File.isExists(columnPath)) {
3229
3467
  if (where) {
3230
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3468
+ // `{ perPage: -1 }` so criteria matching is never capped by
3469
+ // the default page size (15): every matching row counts.
3470
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3231
3471
  RETURN[column] = lineNumbers
3232
3472
  ? await File.sum(columnPath, lineNumbers)
3233
3473
  : 0;
@@ -3238,6 +3478,168 @@ export default class Inibase {
3238
3478
  }
3239
3479
  return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
3240
3480
  }
3481
+ /**
3482
+ * Element-wise aggregation over an array-of-objects column (`nested` mode).
3483
+ *
3484
+ * `where` keys that resolve as children of the array root become per-element
3485
+ * predicates (AND); everything else is a row-level filter. Only elements
3486
+ * passing every element predicate, inside rows passing the row filters, are
3487
+ * accumulated.
3488
+ */
3489
+ async sumNested(tableName, columns, where, tablePath) {
3490
+ const tablePathSafe = tablePath ?? join(this.databasePath, tableName);
3491
+ const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [];
3492
+ const RETURN = {};
3493
+ // Resolve the array-of-objects root from the summed columns (v1: exactly
3494
+ // one nesting level, e.g. "items.quantity" -> root "items").
3495
+ let rootKey = null;
3496
+ let rootField = null;
3497
+ const targetFields = new Map();
3498
+ for (const column of columns) {
3499
+ const dot = column.indexOf(".");
3500
+ if (dot <= 0 || dot === column.length - 1 || column.indexOf(".", dot + 1) !== -1)
3501
+ throw this.createError("INVALID_PARAMETERS", [
3502
+ `sum nested: '${column}' must be a single-level child path of an array-of-objects column`,
3503
+ ]);
3504
+ const rk = column.slice(0, dot);
3505
+ if (rootKey !== null && rk !== rootKey)
3506
+ throw this.createError("INVALID_PARAMETERS", [
3507
+ "sum nested: all summed columns must share the same array root",
3508
+ ]);
3509
+ rootKey = rk;
3510
+ // NB: `getField` on a single segment that resolves to an
3511
+ // array-of-objects field returns its *first child*, so resolve the
3512
+ // root field directly from the top-level schema.
3513
+ rootField = schema.find((f) => f.key === rk) ?? null;
3514
+ const okRoot = rootField &&
3515
+ rootField.type === "array" &&
3516
+ Array.isArray(rootField.children) &&
3517
+ Utils.isArrayOfObjects(rootField.children);
3518
+ if (!okRoot)
3519
+ throw this.createError("INVALID_PARAMETERS", [
3520
+ `sum nested: '${rk}' is not an array-of-objects column`,
3521
+ ]);
3522
+ const child = Utils.getField(column, schema);
3523
+ if (!child || child.type === "array" || child.type === "object")
3524
+ throw this.createError("INVALID_PARAMETERS", [
3525
+ `sum nested: '${column}' does not resolve to a scalar child`,
3526
+ ]);
3527
+ targetFields.set(column, child);
3528
+ }
3529
+ if (!rootKey)
3530
+ return RETURN;
3531
+ // Split `where` into element predicates (children of the root) and a
3532
+ // row-level filter (everything else, incl. `and`/`or` groups).
3533
+ const elementPredicates = [];
3534
+ const rowWhere = {};
3535
+ let idWhere;
3536
+ if (where && Utils.isObject(where)) {
3537
+ for (const [key, value] of Object.entries(where)) {
3538
+ if (key === "and" || key === "or") {
3539
+ rowWhere[key] = value;
3540
+ continue;
3541
+ }
3542
+ const candidate = key.startsWith(`${rootKey}.`)
3543
+ ? key
3544
+ : `${rootKey}.${key}`;
3545
+ const child = Utils.getField(candidate, schema);
3546
+ if (child) {
3547
+ if (Utils.isObject(value))
3548
+ throw this.createError("INVALID_PARAMETERS", [
3549
+ `sum nested: element predicate '${key}' must be a scalar, not an object`,
3550
+ ]);
3551
+ const [operator, comparedValue] = typeof value === "string"
3552
+ ? Utils.FormatObjectCriteriaValue(value)
3553
+ : ["=", value];
3554
+ if (operator === "[]" || operator === "![]")
3555
+ throw this.createError("INVALID_PARAMETERS", [
3556
+ `sum nested: operator '${operator}' is not supported on element predicates`,
3557
+ ]);
3558
+ elementPredicates.push({
3559
+ field: child,
3560
+ operator,
3561
+ comparedValue,
3562
+ });
3563
+ }
3564
+ else {
3565
+ if (!Utils.getField(key, schema))
3566
+ throw this.createError("INVALID_PARAMETERS", [
3567
+ `sum nested: criteria key '${key}' matches neither the array root nor a row column`,
3568
+ ]);
3569
+ rowWhere[key] = value;
3570
+ }
3571
+ }
3572
+ }
3573
+ else if (typeof where === "number" ||
3574
+ typeof where === "string" ||
3575
+ Array.isArray(where)) {
3576
+ idWhere = where;
3577
+ }
3578
+ // Row-level narrowing (ids or criteria). `undefined` lines = whole file.
3579
+ let lines;
3580
+ if (idWhere !== undefined)
3581
+ lines = (await this.get(tableName, idWhere, { perPage: -1 }, undefined, true));
3582
+ else if (Object.keys(rowWhere).length)
3583
+ lines = (await this.get(tableName, rowWhere, { perPage: -1 }, undefined, true));
3584
+ if (lines === null) {
3585
+ for (const column of columns)
3586
+ RETURN[column] = 0;
3587
+ return RETURN;
3588
+ }
3589
+ const fieldOpt = (field) => ({
3590
+ ...field,
3591
+ databasePath: this.databasePath,
3592
+ });
3593
+ const ext = this.getFileExtension(tableName);
3594
+ // Element predicate cells, read once for every summed column.
3595
+ const predCells = new Map();
3596
+ for (const p of elementPredicates) {
3597
+ const path = join(tablePathSafe, `${rootKey}.${p.field.key}${ext}`);
3598
+ if (!(await File.isExists(path)))
3599
+ continue;
3600
+ const cell = (await File.get(path, lines, fieldOpt(p.field)));
3601
+ if (cell)
3602
+ predCells.set(p.field.key, cell);
3603
+ }
3604
+ for (const [column, child] of targetFields) {
3605
+ const path = join(tablePathSafe, `${column}${ext}`);
3606
+ if (!(await File.isExists(path))) {
3607
+ RETURN[column] = 0;
3608
+ continue;
3609
+ }
3610
+ const cell = (await File.get(path, lines, fieldOpt(child)));
3611
+ if (!cell) {
3612
+ RETURN[column] = 0;
3613
+ continue;
3614
+ }
3615
+ let sum = 0;
3616
+ for (const [lineStr, values] of Object.entries(cell)) {
3617
+ if (!Array.isArray(values))
3618
+ continue;
3619
+ for (let i = 0; i < values.length; i++) {
3620
+ const v = values[i];
3621
+ if (v === null || v === undefined)
3622
+ continue;
3623
+ let matches = true;
3624
+ for (const p of elementPredicates) {
3625
+ const pv = predCells.get(p.field.key)?.[Number(lineStr)]?.[i];
3626
+ if (pv === undefined || !UtilsServer.compare(p.operator, pv, p.comparedValue, p.field.type)) {
3627
+ matches = false;
3628
+ break;
3629
+ }
3630
+ }
3631
+ if (!matches)
3632
+ continue;
3633
+ const num = Number(v);
3634
+ if (Number.isNaN(num))
3635
+ continue;
3636
+ sum += num;
3637
+ }
3638
+ }
3639
+ RETURN[column] = sum;
3640
+ }
3641
+ return RETURN;
3642
+ }
3241
3643
  async avg(tableName, columns, where) {
3242
3644
  this.validateName(tableName);
3243
3645
  if (!Array.isArray(columns))
@@ -3251,7 +3653,7 @@ export default class Inibase {
3251
3653
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3252
3654
  if (await File.isExists(columnPath)) {
3253
3655
  if (where) {
3254
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3656
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3255
3657
  RETURN[column] = lineNumbers
3256
3658
  ? await File.avg(columnPath, lineNumbers)
3257
3659
  : 0;
@@ -3275,7 +3677,7 @@ export default class Inibase {
3275
3677
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3276
3678
  if (await File.isExists(columnPath)) {
3277
3679
  if (where) {
3278
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3680
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3279
3681
  RETURN[column] = lineNumbers
3280
3682
  ? await File.max(columnPath, lineNumbers)
3281
3683
  : 0;
@@ -3299,7 +3701,7 @@ export default class Inibase {
3299
3701
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3300
3702
  if (await File.isExists(columnPath)) {
3301
3703
  if (where) {
3302
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3704
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3303
3705
  RETURN[column] = lineNumbers
3304
3706
  ? await File.min(columnPath, lineNumbers)
3305
3707
  : 0;