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.
- package/README.md +114 -0
- package/dist/expression.d.ts +159 -0
- package/dist/expression.js +495 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +676 -7
- package/dist/utils.js +54 -0
- package/package.json +4 -2
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(
|
|
173
|
-
: Inison.stringify(
|
|
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);
|