inibase 3.6.0 → 3.7.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 +21 -3
- package/dist/file.d.ts +26 -5
- package/dist/file.js +132 -32
- package/dist/format.d.ts +54 -0
- package/dist/format.js +108 -0
- package/dist/index.d.ts +114 -9
- package/dist/index.js +697 -108
- package/dist/overlay.d.ts +59 -0
- package/dist/overlay.js +106 -0
- package/dist/updatelog.d.ts +115 -0
- package/dist/updatelog.js +377 -0
- package/dist/utils.js +16 -0
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -7,9 +7,21 @@ import { inspect } from "node:util";
|
|
|
7
7
|
import Inison from "inison";
|
|
8
8
|
import { buildFieldIndex, collectFieldDeps, parseExpression, resolveExpression, resolveFramePath, topoSortComputedFields, } from "./expression.js";
|
|
9
9
|
import * as File from "./file.js";
|
|
10
|
+
import { CURRENT_FORMAT_VERSION, FORMAT_FILE, readFormat, serializeFormat, unsupportedFormatReason, writeFormat, } from "./format.js";
|
|
10
11
|
import { DatabaseJournal, Journal } from "./journal.js";
|
|
12
|
+
import { TableOverlay } from "./overlay.js";
|
|
13
|
+
import { buildFrames, DEFAULT_MAX_FRAME_BYTES, UPDATES_LOG_NAME, } from "./updatelog.js";
|
|
11
14
|
import * as Utils from "./utils.js";
|
|
12
15
|
import * as UtilsServer from "./utils.server.js";
|
|
16
|
+
/**
|
|
17
|
+
* Default accumulation bounds for a format-2 table. Before each append the
|
|
18
|
+
* writer compacts when the log would exceed `maxLogBytes()` bytes or the
|
|
19
|
+
* overlay would exceed `maxOverlayRecords()` distinct rows; the log resets to
|
|
20
|
+
* empty on compaction, so these bound *between* compactions, not total writes.
|
|
21
|
+
* Both are overridable per deployment via environment for large tables.
|
|
22
|
+
*/
|
|
23
|
+
const maxLogBytes = () => Number(process.env.INIBASE_MAX_LOG_BYTES) || 64 * 1024 * 1024;
|
|
24
|
+
const maxOverlayRecords = () => Number(process.env.INIBASE_MAX_OVERLAY_RECORDS) || 100_000;
|
|
13
25
|
export const ERROR_CODES = [
|
|
14
26
|
"GROUP_UNIQUE",
|
|
15
27
|
"FIELD_UNIQUE",
|
|
@@ -33,6 +45,10 @@ export const ERROR_CODES = [
|
|
|
33
45
|
"COMPUTED_FIELD_SETTABLE",
|
|
34
46
|
"COMPUTED_FIELD_DANGLING_LINK",
|
|
35
47
|
"COMPUTED_FIELD_ARITHMETIC",
|
|
48
|
+
"UPDATE_LOG_CORRUPT",
|
|
49
|
+
"UPDATE_LOG_FRAME_TOO_LARGE",
|
|
50
|
+
"MAINTENANCE_REQUIRED",
|
|
51
|
+
"UNSUPPORTED_FORMAT",
|
|
36
52
|
];
|
|
37
53
|
// hide ExperimentalWarning glob()
|
|
38
54
|
// Guard against non-Node environments (e.g. accidental import in a browser bundle)
|
|
@@ -110,6 +126,18 @@ function astHasHops(node) {
|
|
|
110
126
|
return astHasHops(node.arg);
|
|
111
127
|
}
|
|
112
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Await a batch of column-write promises and surface the first rejection.
|
|
131
|
+
* `Promise.allSettled` never short-circuits, so a swallowed failure would let
|
|
132
|
+
* the caller publish a partial column set — the pagination row count and the
|
|
133
|
+
* column line counts would diverge. Every batch that fills a `renameList` must
|
|
134
|
+
* be inspected through here before the journal/pagination is touched.
|
|
135
|
+
*/
|
|
136
|
+
async function settleWrites(results) {
|
|
137
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
138
|
+
if (failed)
|
|
139
|
+
throw failed.reason;
|
|
140
|
+
}
|
|
113
141
|
/**
|
|
114
142
|
* @param {string} database - Database name
|
|
115
143
|
* @param {string} [mainFolder="."] - Main folder path
|
|
@@ -131,6 +159,15 @@ export default class Inibase {
|
|
|
131
159
|
* changes: see the invalidation in `getTable`'s reload branch,
|
|
132
160
|
* `createTable` and `updateTableLocked`. Bounded by the number of tables. */
|
|
133
161
|
computedPlanCache = new Map();
|
|
162
|
+
/** Per-table `.format` marker + live update-log overlays (format 2 tables).
|
|
163
|
+
* Keyed by `databasePath\0tableName` so two databases in one process never
|
|
164
|
+
* share state. Populated lazily by `getOverlay`. */
|
|
165
|
+
tableFormats = new Map();
|
|
166
|
+
overlays = new Map();
|
|
167
|
+
/** Last observed identity of each table's `.format` file (`mtime:size:ino`),
|
|
168
|
+
* used to detect a compaction by another process without re-reading the
|
|
169
|
+
* marker on every access. */
|
|
170
|
+
formatStamps = new Map();
|
|
134
171
|
databasePath;
|
|
135
172
|
uniqueMap;
|
|
136
173
|
schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
|
|
@@ -253,6 +290,23 @@ export default class Inibase {
|
|
|
253
290
|
await File.write(join(tablePath, ".prepend.config"), "");
|
|
254
291
|
if (config.decodeID)
|
|
255
292
|
await File.write(join(tablePath, ".decodeID.config"), "");
|
|
293
|
+
if (config.log) {
|
|
294
|
+
if (config.prepend)
|
|
295
|
+
throw this.createError("UNSUPPORTED_FORMAT", "append layout (no prepend)");
|
|
296
|
+
if (schema)
|
|
297
|
+
this.assertLogCompatibleSchema(schema);
|
|
298
|
+
await File.write(join(tablePath, UPDATES_LOG_NAME), "");
|
|
299
|
+
await writeFormat(tablePath, {
|
|
300
|
+
format: CURRENT_FORMAT_VERSION,
|
|
301
|
+
log: true,
|
|
302
|
+
baseGen: 0,
|
|
303
|
+
});
|
|
304
|
+
this.tableFormats.set(tableName, {
|
|
305
|
+
format: CURRENT_FORMAT_VERSION,
|
|
306
|
+
log: true,
|
|
307
|
+
baseGen: 0,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
256
310
|
}
|
|
257
311
|
if (schema) {
|
|
258
312
|
const lastSchemaID = { value: 0 };
|
|
@@ -327,6 +381,34 @@ export default class Inibase {
|
|
|
327
381
|
}
|
|
328
382
|
}
|
|
329
383
|
async updateTableLocked(tableName, table, tablePath, schema, config) {
|
|
384
|
+
// A log table cannot be reshaped in place yet: its log frames reference
|
|
385
|
+
// column names and its overlay would have to be materialised into the
|
|
386
|
+
// base columns first (compaction, 3.9). Rename/other config are fine.
|
|
387
|
+
if (this.isLogTable(tableName)) {
|
|
388
|
+
if (schema)
|
|
389
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (schema change on update-log table)`);
|
|
390
|
+
if (config?.prepend)
|
|
391
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (prepend layout on update-log table)`);
|
|
392
|
+
if (config?.log === false)
|
|
393
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (disabling the update log needs compaction)`);
|
|
394
|
+
}
|
|
395
|
+
else if (config?.log) {
|
|
396
|
+
if (config.prepend || table.config.prepend)
|
|
397
|
+
throw this.createError("UNSUPPORTED_FORMAT", "append layout (no prepend)");
|
|
398
|
+
if (table.schema?.length)
|
|
399
|
+
this.assertLogCompatibleSchema(table.schema);
|
|
400
|
+
await File.write(join(tablePath, UPDATES_LOG_NAME), "");
|
|
401
|
+
await writeFormat(tablePath, {
|
|
402
|
+
format: CURRENT_FORMAT_VERSION,
|
|
403
|
+
log: true,
|
|
404
|
+
baseGen: 0,
|
|
405
|
+
});
|
|
406
|
+
this.tableFormats.set(tableName, {
|
|
407
|
+
format: CURRENT_FORMAT_VERSION,
|
|
408
|
+
log: true,
|
|
409
|
+
baseGen: 0,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
330
412
|
if (schema) {
|
|
331
413
|
this.validateSchema(schema);
|
|
332
414
|
// remove id from schema
|
|
@@ -531,6 +613,13 @@ export default class Inibase {
|
|
|
531
613
|
this.computedPlanCache.delete(tableName);
|
|
532
614
|
if (config?.name && config.name !== tableName)
|
|
533
615
|
this.computedPlanCache.delete(config.name);
|
|
616
|
+
// Drop log/format caches for the touched table(s) so next access re-reads
|
|
617
|
+
// the (possibly moved) marker and overlay from disk.
|
|
618
|
+
for (const name of config?.name ? [tableName, config.name] : [tableName]) {
|
|
619
|
+
this.tableFormats.delete(name);
|
|
620
|
+
this.formatStamps.delete(name);
|
|
621
|
+
this.overlays.delete(this.overlayKey(name));
|
|
622
|
+
}
|
|
534
623
|
}
|
|
535
624
|
/**
|
|
536
625
|
* Get table schema and config
|
|
@@ -545,22 +634,313 @@ export default class Inibase {
|
|
|
545
634
|
throw this.createError("TABLE_NOT_EXISTS", tableName);
|
|
546
635
|
if (!globalConfig[this.databasePath].tables?.has(tableName) ||
|
|
547
636
|
globalConfig[this.databasePath].tables?.get(tableName)?.timestamp !==
|
|
548
|
-
(await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`))))
|
|
637
|
+
(await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`)))) {
|
|
638
|
+
const format = await this.readValidatedFormat(tableName, tablePath);
|
|
639
|
+
this.tableFormats.set(tableName, format);
|
|
640
|
+
await this.stampFormat(tableName);
|
|
641
|
+
const config = {
|
|
642
|
+
compression: await File.isExists(join(tablePath, ".compression.config")),
|
|
643
|
+
cache: await File.isExists(join(tablePath, ".cache.config")),
|
|
644
|
+
prepend: await File.isExists(join(tablePath, ".prepend.config")),
|
|
645
|
+
decodeID: await File.isExists(join(tablePath, ".decodeID.config")),
|
|
646
|
+
};
|
|
647
|
+
// Only surface `log` when enabled, so the public config shape of a
|
|
648
|
+
// legacy table is byte-for-byte what it was before 3.8.
|
|
649
|
+
if (format.log)
|
|
650
|
+
config.log = true;
|
|
549
651
|
globalConfig[this.databasePath].tables?.set(tableName, {
|
|
550
652
|
schema: await this.getTableSchema(tableName),
|
|
551
|
-
config
|
|
552
|
-
compression: await File.isExists(join(tablePath, ".compression.config")),
|
|
553
|
-
cache: await File.isExists(join(tablePath, ".cache.config")),
|
|
554
|
-
prepend: await File.isExists(join(tablePath, ".prepend.config")),
|
|
555
|
-
decodeID: await File.isExists(join(tablePath, ".decodeID.config")),
|
|
556
|
-
},
|
|
653
|
+
config,
|
|
557
654
|
timestamp: await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`)),
|
|
558
655
|
});
|
|
656
|
+
}
|
|
559
657
|
// A reload means the on-disk schema changed (create/update/external
|
|
560
658
|
// edit): the cached computed plan is derived from it.
|
|
561
659
|
this.computedPlanCache.delete(tableName);
|
|
562
660
|
return globalConfig[this.databasePath].tables?.get(tableName);
|
|
563
661
|
}
|
|
662
|
+
/* -----------------------------------------------------------------------
|
|
663
|
+
* Update-log overlay (format 2 tables)
|
|
664
|
+
* --------------------------------------------------------------------- */
|
|
665
|
+
overlayKey(tableName) {
|
|
666
|
+
return `${this.databasePath}\u0000${tableName}`;
|
|
667
|
+
}
|
|
668
|
+
/** True when the table opted into the incremental update log. */
|
|
669
|
+
isLogTable(tableName) {
|
|
670
|
+
const cached = this.tableFormats.get(tableName);
|
|
671
|
+
if (cached)
|
|
672
|
+
return cached.log;
|
|
673
|
+
return (globalConfig[this.databasePath].tables?.get(tableName)?.config.log ===
|
|
674
|
+
true);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Resolve a table's format marker, revalidating a cached *log* table when
|
|
678
|
+
* its `.format` file changed underneath us. A compaction by another process
|
|
679
|
+
* bumps `baseGen` and empties the log; without re-reading the marker this
|
|
680
|
+
* process would scan the new (empty) log with the old `baseGen`, and once a
|
|
681
|
+
* post-compaction frame appears the generation gap would look like
|
|
682
|
+
* corruption. Legacy tables never change format implicitly, so they skip
|
|
683
|
+
* the stat entirely and keep the pre-3.8 read path cost.
|
|
684
|
+
*/
|
|
685
|
+
/**
|
|
686
|
+
* Read a table's `.format` marker and refuse a table written by a newer
|
|
687
|
+
* engine (§4.1) *before* any column is read: a higher `format`, or a
|
|
688
|
+
* `minReader` above this build, throws UNSUPPORTED_FORMAT rather than being
|
|
689
|
+
* silently mis-read as format 2/legacy.
|
|
690
|
+
*/
|
|
691
|
+
async readValidatedFormat(tableName, tablePath) {
|
|
692
|
+
const format = await readFormat(tablePath);
|
|
693
|
+
const reason = unsupportedFormatReason(format);
|
|
694
|
+
if (reason)
|
|
695
|
+
throw this.createError("UNSUPPORTED_FORMAT", `${tableName} (${reason})`);
|
|
696
|
+
return format;
|
|
697
|
+
}
|
|
698
|
+
async currentFormat(tableName) {
|
|
699
|
+
const cached = this.tableFormats.get(tableName);
|
|
700
|
+
if (cached && !cached.log)
|
|
701
|
+
return cached;
|
|
702
|
+
const tablePath = join(this.databasePath, tableName);
|
|
703
|
+
let stamp = "none";
|
|
704
|
+
try {
|
|
705
|
+
const stat_ = await stat(join(tablePath, FORMAT_FILE));
|
|
706
|
+
stamp = `${stat_.mtimeMs}:${stat_.size}:${stat_.ino}`;
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
stamp = "none";
|
|
710
|
+
}
|
|
711
|
+
if (cached && this.formatStamps.get(tableName) === stamp)
|
|
712
|
+
return cached;
|
|
713
|
+
const format = await this.readValidatedFormat(tableName, tablePath);
|
|
714
|
+
this.tableFormats.set(tableName, format);
|
|
715
|
+
this.formatStamps.set(tableName, stamp);
|
|
716
|
+
// A changed baseGen invalidates the cached overlay: rebuild from the
|
|
717
|
+
// (possibly now empty) log at the new generation.
|
|
718
|
+
const key = this.overlayKey(tableName);
|
|
719
|
+
const overlay = this.overlays.get(key);
|
|
720
|
+
if (overlay && overlay.baseGen !== format.baseGen)
|
|
721
|
+
this.overlays.delete(key);
|
|
722
|
+
return format;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Load (or refresh) a table's live overlay. Returns null for legacy tables
|
|
726
|
+
* (no `.format` marker) so every existing code path stays untouched. With
|
|
727
|
+
* `forWrite`, repairs a torn tail and rebuilds to the committed boundary;
|
|
728
|
+
* the caller must already hold the table writer lock.
|
|
729
|
+
*/
|
|
730
|
+
async getOverlay(tableName, forWrite = false) {
|
|
731
|
+
const format = await this.currentFormat(tableName);
|
|
732
|
+
if (!format?.log)
|
|
733
|
+
return null;
|
|
734
|
+
const key = this.overlayKey(tableName);
|
|
735
|
+
let overlay = this.overlays.get(key);
|
|
736
|
+
if (!overlay || overlay.baseGen !== format.baseGen) {
|
|
737
|
+
overlay = new TableOverlay(join(this.databasePath, tableName), format.baseGen);
|
|
738
|
+
this.overlays.set(key, overlay);
|
|
739
|
+
}
|
|
740
|
+
if (forWrite)
|
|
741
|
+
await overlay.refreshForWrite();
|
|
742
|
+
else
|
|
743
|
+
await overlay.refresh();
|
|
744
|
+
return overlay;
|
|
745
|
+
}
|
|
746
|
+
async stampFormat(tableName) {
|
|
747
|
+
let stamp = "none";
|
|
748
|
+
try {
|
|
749
|
+
const stat_ = await stat(join(this.databasePath, tableName, FORMAT_FILE));
|
|
750
|
+
stamp = `${stat_.mtimeMs}:${stat_.size}:${stat_.ino}`;
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
stamp = "none";
|
|
754
|
+
}
|
|
755
|
+
this.formatStamps.set(tableName, stamp);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The format-2 update log stores one encoded cell per changed *top-level
|
|
759
|
+
* simple* column; nested (object/array/table) and computed fields are not
|
|
760
|
+
* representable without a compaction step (3.9). A log table whose schema
|
|
761
|
+
* violates this is rejected at DDL time so the write path can always take
|
|
762
|
+
* the log and never needs to interleave a whole-column rewrite.
|
|
763
|
+
*/
|
|
764
|
+
assertLogCompatibleSchema(schema) {
|
|
765
|
+
for (const field of schema) {
|
|
766
|
+
if (["id", "createdAt", "updatedAt"].includes(field.key))
|
|
767
|
+
continue;
|
|
768
|
+
if (typeof field.computed !== "undefined")
|
|
769
|
+
throw this.createError("UNSUPPORTED_FORMAT", `simple top-level fields (computed field "${field.key}" is not supported with log)`);
|
|
770
|
+
if (!this.isSimpleField(field.type))
|
|
771
|
+
throw this.createError("UNSUPPORTED_FORMAT", `simple top-level fields (field "${field.key}" is ${field.type})`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
idFieldOf(tableName) {
|
|
775
|
+
const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [];
|
|
776
|
+
return (schema.find((field) => field.key === "id") ?? { key: "id", type: "id" });
|
|
777
|
+
}
|
|
778
|
+
/** Read the internal numeric id for each requested line (stable overlay key). */
|
|
779
|
+
async overlayLineIds(tableName, lines) {
|
|
780
|
+
const map = new Map();
|
|
781
|
+
if (!lines.length)
|
|
782
|
+
return map;
|
|
783
|
+
const filePath = join(this.databasePath, tableName, `id${this.getFileExtension(tableName)}`);
|
|
784
|
+
// Streaming reader (no shell spawn): like the sparse column read path,
|
|
785
|
+
// stop at the last requested line instead of shelling out to `sed -n`,
|
|
786
|
+
// which costs ~ms per call on a log-table read.
|
|
787
|
+
const values = await File.getLines(filePath, lines, {
|
|
788
|
+
...this.idFieldOf(tableName),
|
|
789
|
+
key: "id",
|
|
790
|
+
type: "number",
|
|
791
|
+
});
|
|
792
|
+
for (const [line, value] of Object.entries(values))
|
|
793
|
+
map.set(Number(line), Number(value));
|
|
794
|
+
return map;
|
|
795
|
+
}
|
|
796
|
+
/** Internal id -> current line number(s), built from the id column. */
|
|
797
|
+
async overlayIdToLines(tableName) {
|
|
798
|
+
const filePath = join(this.databasePath, tableName, `id${this.getFileExtension(tableName)}`);
|
|
799
|
+
const values = await File.get(filePath, undefined, {
|
|
800
|
+
...this.idFieldOf(tableName),
|
|
801
|
+
key: "id",
|
|
802
|
+
type: "number",
|
|
803
|
+
});
|
|
804
|
+
const map = new Map();
|
|
805
|
+
for (const [line, value] of Object.entries(values)) {
|
|
806
|
+
if (typeof value === "undefined" || value === null)
|
|
807
|
+
continue;
|
|
808
|
+
const id = Number(value);
|
|
809
|
+
const bucket = map.get(id);
|
|
810
|
+
if (bucket)
|
|
811
|
+
bucket.push(Number(line));
|
|
812
|
+
else
|
|
813
|
+
map.set(id, [Number(line)]);
|
|
814
|
+
}
|
|
815
|
+
return map;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Per-line raw-cell overrides for one column, from the overlay. Used by
|
|
819
|
+
* `File.search` so criteria, uniqueness and aggregate scans see the merged
|
|
820
|
+
* (un-compacted) values. Returns undefined when the table has no overlay or
|
|
821
|
+
* the column is never overlaid.
|
|
822
|
+
*/
|
|
823
|
+
async overlayColumnOverrides(tableName, columnKey) {
|
|
824
|
+
const overlay = await this.getOverlay(tableName);
|
|
825
|
+
if (!overlay || overlay.size === 0)
|
|
826
|
+
return undefined;
|
|
827
|
+
const entries = [];
|
|
828
|
+
for (const [id, entry] of overlay.byId)
|
|
829
|
+
if (Object.hasOwn(entry.cols, columnKey))
|
|
830
|
+
entries.push([Number(id), entry.cols[columnKey]]);
|
|
831
|
+
if (!entries.length)
|
|
832
|
+
return undefined;
|
|
833
|
+
const idToLines = await this.overlayIdToLines(tableName);
|
|
834
|
+
const overrides = new Map();
|
|
835
|
+
for (const [id, encoded] of entries) {
|
|
836
|
+
const lines = idToLines.get(id);
|
|
837
|
+
if (!lines)
|
|
838
|
+
continue;
|
|
839
|
+
for (const line of lines)
|
|
840
|
+
overrides.set(line, encoded);
|
|
841
|
+
}
|
|
842
|
+
return overrides.size ? overrides : undefined;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Fold every logged update into the base columns and reset the log.
|
|
846
|
+
*
|
|
847
|
+
* Safe to call on any table: legacy tables (no `.format` marker) are a
|
|
848
|
+
* no-op. Semantically neutral — it only materializes already-acknowledged
|
|
849
|
+
* updates, so a failure never makes an acknowledged `put` disappear.
|
|
850
|
+
* Returns the new `baseGen` for a compacted table, or `null` when there was
|
|
851
|
+
* nothing to fold.
|
|
852
|
+
*/
|
|
853
|
+
async compact(tableName) {
|
|
854
|
+
this.validateName(tableName);
|
|
855
|
+
if (this.activeTransaction)
|
|
856
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (compaction inside a transaction)`);
|
|
857
|
+
await this.ensureDatabaseRecovered();
|
|
858
|
+
const tablePath = join(this.databasePath, tableName);
|
|
859
|
+
if (!(await File.isExists(tablePath)))
|
|
860
|
+
throw this.createError("TABLE_NOT_EXISTS", tableName);
|
|
861
|
+
const format = await this.currentFormat(tableName);
|
|
862
|
+
if (!format?.log)
|
|
863
|
+
return null; // legacy table: nothing to fold
|
|
864
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
865
|
+
try {
|
|
866
|
+
return await this.compactLogTable(tableName);
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Compaction core. The caller must hold the table writer lock. Streams each
|
|
874
|
+
* overlaid base column through `File.replace` (which handles compression),
|
|
875
|
+
* then publishes the new columns, an empty `updates.log`, and a `.format`
|
|
876
|
+
* marker with the bumped generation in one journaled commit, so a crash
|
|
877
|
+
* either rolls every file back (old base + old log + old `baseGen`) or
|
|
878
|
+
* forward (new base + empty log + new `baseGen`). No acknowledged update is
|
|
879
|
+
* ever lost.
|
|
880
|
+
*/
|
|
881
|
+
async compactLogTable(tableName) {
|
|
882
|
+
const tablePath = join(this.databasePath, tableName);
|
|
883
|
+
const overlay = await this.getOverlay(tableName, true);
|
|
884
|
+
if (!overlay || overlay.size === 0)
|
|
885
|
+
return null; // already compact
|
|
886
|
+
const schema = (await this.getTable(tableName))?.schema ?? [];
|
|
887
|
+
const schemaKeys = new Set(schema.map((field) => field.key));
|
|
888
|
+
// Only columns that some frame actually overlaid need rewriting; the
|
|
889
|
+
// untouched base columns are left exactly as they are.
|
|
890
|
+
const overlaidKeys = new Set();
|
|
891
|
+
for (const entry of overlay.byId.values())
|
|
892
|
+
for (const key of Object.keys(entry.cols))
|
|
893
|
+
if (schemaKeys.has(key))
|
|
894
|
+
overlaidKeys.add(key);
|
|
895
|
+
if (!overlaidKeys.size)
|
|
896
|
+
return null;
|
|
897
|
+
const idToLines = await this.overlayIdToLines(tableName);
|
|
898
|
+
const extension = this.getFileExtension(tableName);
|
|
899
|
+
const renameList = [];
|
|
900
|
+
for (const key of overlaidKeys) {
|
|
901
|
+
const record = {};
|
|
902
|
+
for (const [id, entry] of overlay.byId) {
|
|
903
|
+
if (!Object.hasOwn(entry.cols, key))
|
|
904
|
+
continue;
|
|
905
|
+
const lines = idToLines.get(Number(id));
|
|
906
|
+
if (!lines)
|
|
907
|
+
continue;
|
|
908
|
+
const encoded = entry.cols[key];
|
|
909
|
+
for (const line of lines)
|
|
910
|
+
record[line] = encoded;
|
|
911
|
+
}
|
|
912
|
+
if (!Object.keys(record).length)
|
|
913
|
+
continue;
|
|
914
|
+
renameList.push(await File.replace(join(tablePath, `${key}${extension}`), record));
|
|
915
|
+
}
|
|
916
|
+
if (!renameList.length)
|
|
917
|
+
return null;
|
|
918
|
+
const newGen = overlay.committedGen;
|
|
919
|
+
const staging = (name) => join(tablePath, ".tmp", `${name}.compact-${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
920
|
+
// Empty log (frames are now folded into base) and the bumped marker,
|
|
921
|
+
// committed atomically with the column swaps.
|
|
922
|
+
const logTmp = staging(UPDATES_LOG_NAME);
|
|
923
|
+
await File.write(logTmp, "");
|
|
924
|
+
renameList.push([logTmp, join(tablePath, UPDATES_LOG_NAME)]);
|
|
925
|
+
const fmtTmp = staging(FORMAT_FILE);
|
|
926
|
+
await File.write(fmtTmp, serializeFormat({
|
|
927
|
+
format: CURRENT_FORMAT_VERSION,
|
|
928
|
+
log: true,
|
|
929
|
+
baseGen: newGen,
|
|
930
|
+
}));
|
|
931
|
+
renameList.push([fmtTmp, join(tablePath, FORMAT_FILE)]);
|
|
932
|
+
await this.commitFiles(tablePath, renameList, null);
|
|
933
|
+
// In-memory: the base now carries every update; drop the overlay so the
|
|
934
|
+
// next access rebuilds an empty one at the new generation.
|
|
935
|
+
this.tableFormats.set(tableName, {
|
|
936
|
+
format: CURRENT_FORMAT_VERSION,
|
|
937
|
+
log: true,
|
|
938
|
+
baseGen: newGen,
|
|
939
|
+
});
|
|
940
|
+
await this.stampFormat(tableName);
|
|
941
|
+
this.overlays.delete(this.overlayKey(tableName));
|
|
942
|
+
return newGen;
|
|
943
|
+
}
|
|
564
944
|
async getTableSchema(tableName) {
|
|
565
945
|
this.validateName(tableName);
|
|
566
946
|
const tablePath = join(this.databasePath, tableName);
|
|
@@ -811,7 +1191,7 @@ export default class Inibase {
|
|
|
811
1191
|
if (!field)
|
|
812
1192
|
continue;
|
|
813
1193
|
fieldsKeys.push(field.key);
|
|
814
|
-
const [_, totalLines, lineNumbers] = await File.search(join(tablePath, `${field.key}${this.getFileExtension(tableName)}`), "[]", Array.from(values), undefined, valueObject.exclude, { ...field, databasePath: this.databasePath }, 1, undefined, false);
|
|
1194
|
+
const [_, totalLines, lineNumbers] = await File.search(join(tablePath, `${field.key}${this.getFileExtension(tableName)}`), "[]", Array.from(values), undefined, valueObject.exclude, { ...field, databasePath: this.databasePath }, 1, undefined, false, undefined, await this.overlayColumnOverrides(tableName, field.key));
|
|
815
1195
|
if (totalLines > 0) {
|
|
816
1196
|
if (valueObject.columnsValues.size === 1 ||
|
|
817
1197
|
(valueObject.columnsValues.size === index &&
|
|
@@ -982,19 +1362,39 @@ export default class Inibase {
|
|
|
982
1362
|
}
|
|
983
1363
|
async processSchemaData(tableName, schema, linesNumber, options, prefix) {
|
|
984
1364
|
const RETURN = {};
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
1365
|
+
// Sparse path: for an explicit finite selection, stream each top-level
|
|
1366
|
+
// simple column only up to the last requested line (never to EOF) and
|
|
1367
|
+
// decode it independently, instead of pasting every column through a
|
|
1368
|
+
// shell pipeline that always scans the whole table and can misalign on
|
|
1369
|
+
// values containing a literal tab. Nested (prefixed) schemas keep the
|
|
1370
|
+
// per-column path below.
|
|
988
1371
|
let batchedKeys = null;
|
|
989
1372
|
if (!prefix && linesNumber?.length) {
|
|
990
1373
|
const simpleFields = schema.filter((field) => this.isSimpleField(field.type));
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1374
|
+
const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1375
|
+
.decodeID === true;
|
|
1376
|
+
const extension = this.getFileExtension(tableName);
|
|
1377
|
+
const reads = await Promise.all(simpleFields.map(async (field) => {
|
|
1378
|
+
const fieldPath = join(this.databasePath, tableName, `${field.key}${extension}`);
|
|
1379
|
+
if (!(await File.isExists(fieldPath)))
|
|
1380
|
+
return null;
|
|
1381
|
+
const items = await File.getLines(fieldPath, linesNumber, {
|
|
1382
|
+
...field,
|
|
1383
|
+
type: field.key === "id" && decodeID ? "number" : field.type,
|
|
1384
|
+
databasePath: this.databasePath,
|
|
1385
|
+
});
|
|
1386
|
+
return { key: field.key, items };
|
|
1387
|
+
}));
|
|
1388
|
+
batchedKeys = new Set(simpleFields.map((field) => field.key));
|
|
1389
|
+
for (const read of reads) {
|
|
1390
|
+
if (!read)
|
|
1391
|
+
continue;
|
|
1392
|
+
for (const [line, value] of Object.entries(read.items)) {
|
|
1393
|
+
if (typeof value === "undefined")
|
|
1394
|
+
continue;
|
|
995
1395
|
if (!RETURN[line])
|
|
996
1396
|
RETURN[line] = {};
|
|
997
|
-
Object.assign(RETURN[line],
|
|
1397
|
+
Object.assign(RETURN[line], { [read.key]: value });
|
|
998
1398
|
}
|
|
999
1399
|
}
|
|
1000
1400
|
}
|
|
@@ -1019,11 +1419,58 @@ export default class Inibase {
|
|
|
1019
1419
|
await this.processTableField(tableName, field, RETURN, linesNumber, options, prefix);
|
|
1020
1420
|
}
|
|
1021
1421
|
}
|
|
1422
|
+
await this.mergeOverlayIntoRows(tableName, schema, RETURN);
|
|
1022
1423
|
return RETURN;
|
|
1023
1424
|
}
|
|
1024
|
-
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1425
|
+
/**
|
|
1426
|
+
* Fold the table's un-compacted update-log overlay onto decoded rows.
|
|
1427
|
+
*
|
|
1428
|
+
* `RETURN` is keyed by line number and its `id` is the user-facing id, so
|
|
1429
|
+
* the overlay (keyed by the internal numeric id) is matched through a fresh
|
|
1430
|
+
* id-column read of just the requested lines. Only columns present in
|
|
1431
|
+
* `schema` (which is already narrowed to the requested `columns`) are
|
|
1432
|
+
* written, so a partial projection never grows extra keys. Legacy tables
|
|
1433
|
+
* (no `.format` marker) return immediately.
|
|
1434
|
+
*/
|
|
1435
|
+
async mergeOverlayIntoRows(tableName, schema, RETURN) {
|
|
1436
|
+
if (!this.isLogTable(tableName))
|
|
1437
|
+
return;
|
|
1438
|
+
const lines = Object.keys(RETURN).map(Number);
|
|
1439
|
+
if (!lines.length)
|
|
1440
|
+
return;
|
|
1441
|
+
const overlay = await this.getOverlay(tableName);
|
|
1442
|
+
if (!overlay || overlay.size === 0)
|
|
1443
|
+
return;
|
|
1444
|
+
const lineIds = await this.overlayLineIds(tableName, lines);
|
|
1445
|
+
if (!lineIds.size)
|
|
1446
|
+
return;
|
|
1447
|
+
const allowed = new Map();
|
|
1448
|
+
for (const field of schema)
|
|
1449
|
+
allowed.set(field.key, field);
|
|
1450
|
+
const hasUpdatedAt = allowed.has("updatedAt");
|
|
1451
|
+
for (const line of lines) {
|
|
1452
|
+
const id = lineIds.get(line);
|
|
1453
|
+
if (id === undefined)
|
|
1454
|
+
continue;
|
|
1455
|
+
const entry = overlay.get(id);
|
|
1456
|
+
if (!entry)
|
|
1457
|
+
continue;
|
|
1458
|
+
const row = RETURN[line];
|
|
1459
|
+
if (!row)
|
|
1460
|
+
continue;
|
|
1461
|
+
for (const [key, encoded] of Object.entries(entry.cols)) {
|
|
1462
|
+
const field = allowed.get(key);
|
|
1463
|
+
if (!field)
|
|
1464
|
+
continue;
|
|
1465
|
+
row[key] = File.decode(encoded, {
|
|
1466
|
+
...field,
|
|
1467
|
+
databasePath: this.databasePath,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
if (hasUpdatedAt && entry.updatedAt !== undefined)
|
|
1471
|
+
row.updatedAt = entry.updatedAt;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1027
1474
|
/**
|
|
1028
1475
|
* Extract the raw expression from a field's `computed` property (string or
|
|
1029
1476
|
* persisted `{ expr, ast }` spec).
|
|
@@ -1658,78 +2105,6 @@ export default class Inibase {
|
|
|
1658
2105
|
}
|
|
1659
2106
|
}
|
|
1660
2107
|
}
|
|
1661
|
-
/**
|
|
1662
|
-
* Batched read for top-level simple columns: a single `paste | sed` child
|
|
1663
|
-
* process returns every requested line of every column at once instead of
|
|
1664
|
-
* spawning one `sed`/`gunzip|sed` child process per column. Returning null
|
|
1665
|
-
* (e.g. when any column file is missing, or the shell command fails) makes
|
|
1666
|
-
* the caller fall back to the existing per-column reads.
|
|
1667
|
-
*/
|
|
1668
|
-
async processSimpleFieldsBatch(tableName, fields, linesNumber) {
|
|
1669
|
-
if (!fields.length || !linesNumber.length)
|
|
1670
|
-
return null;
|
|
1671
|
-
const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1672
|
-
.decodeID === true;
|
|
1673
|
-
// Decode configs are computed once per column (the per-column read path
|
|
1674
|
-
// builds one config per file too), not once per cell.
|
|
1675
|
-
const cols = [];
|
|
1676
|
-
for (const field of fields) {
|
|
1677
|
-
const path = join(this.databasePath, tableName, `${field.key}${this.getFileExtension(tableName)}`);
|
|
1678
|
-
if (!(await File.isExists(path)))
|
|
1679
|
-
return null;
|
|
1680
|
-
cols.push({
|
|
1681
|
-
path,
|
|
1682
|
-
key: field.key,
|
|
1683
|
-
config: {
|
|
1684
|
-
...field,
|
|
1685
|
-
type: field.key === "id" && decodeID ? "number" : field.type,
|
|
1686
|
-
databasePath: this.databasePath,
|
|
1687
|
-
},
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
const sortedLines = [...linesNumber].sort((a, b) => a - b);
|
|
1691
|
-
const range = File._groupIntoRanges(sortedLines);
|
|
1692
|
-
const files = cols.map(({ path }) => File.escapeShellPath(path)).join(" ");
|
|
1693
|
-
const isGz = this.getFileExtension(tableName).endsWith(".gz");
|
|
1694
|
-
// Each compressed column needs its own process substitution so the
|
|
1695
|
-
// streams stay aligned column-by-column inside `paste`.
|
|
1696
|
-
const pasteInputs = isGz
|
|
1697
|
-
? cols
|
|
1698
|
-
.map(({ path }) => `<(gunzip -c ${File.escapeShellPath(path)})`)
|
|
1699
|
-
.join(" ")
|
|
1700
|
-
: files;
|
|
1701
|
-
const command = isGz
|
|
1702
|
-
? `bash -c 'paste -d "\\t" ${pasteInputs} | sed -n "${range}"'`
|
|
1703
|
-
: `paste -d'\\t' ${files} | sed -n '${range}'`;
|
|
1704
|
-
let output;
|
|
1705
|
-
try {
|
|
1706
|
-
output = (await UtilsServer.exec(command)).stdout;
|
|
1707
|
-
}
|
|
1708
|
-
catch {
|
|
1709
|
-
return null;
|
|
1710
|
-
}
|
|
1711
|
-
const outLines = output.trimEnd().split("\n");
|
|
1712
|
-
const RETURN = {};
|
|
1713
|
-
for (let i = 0; i < outLines.length && i < sortedLines.length; i++) {
|
|
1714
|
-
const lineNo = sortedLines[i];
|
|
1715
|
-
const cells = outLines[i].split("\t");
|
|
1716
|
-
const row = {};
|
|
1717
|
-
let added = false;
|
|
1718
|
-
for (let c = 0; c < cols.length; c++) {
|
|
1719
|
-
const raw = cells[c];
|
|
1720
|
-
if (raw === undefined)
|
|
1721
|
-
continue;
|
|
1722
|
-
const value = File.decode(raw, cols[c].config);
|
|
1723
|
-
if (value !== undefined) {
|
|
1724
|
-
row[cols[c].key] = value;
|
|
1725
|
-
added = true;
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
if (added)
|
|
1729
|
-
RETURN[lineNo] = row;
|
|
1730
|
-
}
|
|
1731
|
-
return Object.keys(RETURN).length ? RETURN : null;
|
|
1732
|
-
}
|
|
1733
2108
|
// Helper function to check if the field type is array
|
|
1734
2109
|
isArrayField(fieldType) {
|
|
1735
2110
|
return ((Array.isArray(fieldType) &&
|
|
@@ -2011,7 +2386,7 @@ export default class Inibase {
|
|
|
2011
2386
|
}, (options.perPage ?? 0) < 0 ? undefined : options.perPage, (options.perPage ?? 0) < 0
|
|
2012
2387
|
? undefined
|
|
2013
2388
|
: (options.page - 1) * options.perPage +
|
|
2014
|
-
((options.page ?? 1) > 1 ? 1 : 0), true);
|
|
2389
|
+
((options.page ?? 1) > 1 ? 1 : 0), true, undefined, await this.overlayColumnOverrides(tableName, key));
|
|
2015
2390
|
if (!searchResult) {
|
|
2016
2391
|
if (allTrue)
|
|
2017
2392
|
return null;
|
|
@@ -2581,6 +2956,14 @@ export default class Inibase {
|
|
|
2581
2956
|
(Utils.isObject(where) && !Object.keys(where).length)))
|
|
2582
2957
|
where = undefined;
|
|
2583
2958
|
if (options.sort) {
|
|
2959
|
+
// With an un-compacted overlay the shell `paste | sort | awk` path
|
|
2960
|
+
// would read stale base cells. Fall back to an in-process sort of
|
|
2961
|
+
// the merged rows, which is correct for every sort shape.
|
|
2962
|
+
if (this.isLogTable(tableName)) {
|
|
2963
|
+
const overlay = await this.getOverlay(tableName);
|
|
2964
|
+
if (overlay?.size)
|
|
2965
|
+
return await this.getOnceSortedWithOverlay(tableName, schema, where, options, onlyOne);
|
|
2966
|
+
}
|
|
2584
2967
|
let sortArray;
|
|
2585
2968
|
let awkCommand = "";
|
|
2586
2969
|
if (Utils.isObject(options.sort) && !Array.isArray(options.sort)) {
|
|
@@ -2768,7 +3151,11 @@ export default class Inibase {
|
|
|
2768
3151
|
}
|
|
2769
3152
|
}
|
|
2770
3153
|
if (!lineNumbers) {
|
|
2771
|
-
[lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0,
|
|
3154
|
+
[lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0,
|
|
3155
|
+
// The caller only needs the matching line numbers, not a
|
|
3156
|
+
// whole-table count: stop as soon as every requested id has
|
|
3157
|
+
// been found instead of scanning the column to EOF.
|
|
3158
|
+
false, true);
|
|
2772
3159
|
}
|
|
2773
3160
|
if (!lineNumbers)
|
|
2774
3161
|
return null;
|
|
@@ -2788,8 +3175,15 @@ export default class Inibase {
|
|
|
2788
3175
|
}
|
|
2789
3176
|
else if (Utils.isObject(where)) {
|
|
2790
3177
|
let cachedFilePath = "";
|
|
3178
|
+
// The criteria cache is versioned by the row count only; a put
|
|
3179
|
+
// changes matching rows without changing the count, so a cached
|
|
3180
|
+
// line set would go stale. Bypass it entirely while an overlay
|
|
3181
|
+
// exists.
|
|
3182
|
+
const overlayActive = this.isLogTable(tableName) &&
|
|
3183
|
+
((await this.getOverlay(tableName))?.size ?? 0) > 0;
|
|
2791
3184
|
// Criteria
|
|
2792
|
-
if (
|
|
3185
|
+
if (!overlayActive &&
|
|
3186
|
+
globalConfig[this.databasePath].tables?.get(tableName)?.config.cache) {
|
|
2793
3187
|
// Cache entries are versioned by the pagination row count so a
|
|
2794
3188
|
// stale cache written before a post/delete (in this process or
|
|
2795
3189
|
// another) is detectable: the candidate filename simply stops
|
|
@@ -2824,7 +3218,8 @@ export default class Inibase {
|
|
|
2824
3218
|
for (const [key] of this.totalItems)
|
|
2825
3219
|
if (key.startsWith(`${tableName}-`) && key !== `${tableName}-id`)
|
|
2826
3220
|
this.totalItems.delete(key);
|
|
2827
|
-
if (
|
|
3221
|
+
if (!overlayActive &&
|
|
3222
|
+
globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
2828
3223
|
await writeFile(cachedFilePath, Object.keys(LineNumberDataObj).join(","));
|
|
2829
3224
|
}
|
|
2830
3225
|
}
|
|
@@ -2846,6 +3241,70 @@ export default class Inibase {
|
|
|
2846
3241
|
};
|
|
2847
3242
|
return onlyOne && Array.isArray(RETURN) ? RETURN[0] : RETURN;
|
|
2848
3243
|
}
|
|
3244
|
+
/**
|
|
3245
|
+
* In-process sort fallback for log tables with a non-empty overlay: the
|
|
3246
|
+
* shell `paste | sort | awk` path reads base cells and would miss puts.
|
|
3247
|
+
* Reads the merged rows for the (optionally filtered) lines, sorts them by
|
|
3248
|
+
* the requested keys with type-aware comparison, then paginates.
|
|
3249
|
+
*/
|
|
3250
|
+
async getOnceSortedWithOverlay(tableName, schema, where, options, onlyOne) {
|
|
3251
|
+
let sortArray;
|
|
3252
|
+
if (Utils.isObject(options.sort) && !Array.isArray(options.sort))
|
|
3253
|
+
sortArray = Object.entries(options.sort).map(([key, value]) => [
|
|
3254
|
+
key,
|
|
3255
|
+
typeof value === "string" ? value.toLowerCase() === "asc" : value > 0,
|
|
3256
|
+
]);
|
|
3257
|
+
else
|
|
3258
|
+
sortArray = []
|
|
3259
|
+
.concat(options.sort)
|
|
3260
|
+
.map((column) => [column, true]);
|
|
3261
|
+
let lines;
|
|
3262
|
+
if (where) {
|
|
3263
|
+
const found = await this.get(tableName, where, undefined, undefined, true);
|
|
3264
|
+
if (!found)
|
|
3265
|
+
return null;
|
|
3266
|
+
lines = Array.isArray(found) ? found : [found];
|
|
3267
|
+
}
|
|
3268
|
+
else {
|
|
3269
|
+
const { total } = await this.resolvePagination(tableName);
|
|
3270
|
+
lines = Array.from({ length: total }, (_, index) => index + 1);
|
|
3271
|
+
}
|
|
3272
|
+
if (!lines.length)
|
|
3273
|
+
return null;
|
|
3274
|
+
const rows = Object.values(await this.processSchemaData(tableName, schema, lines, options));
|
|
3275
|
+
const comparator = (a, b) => {
|
|
3276
|
+
for (const [key, ascending] of sortArray) {
|
|
3277
|
+
const field = Utils.getField(key, Utils.flattenSchema(schema));
|
|
3278
|
+
const aValue = a?.[key];
|
|
3279
|
+
const bValue = b?.[key];
|
|
3280
|
+
let comparison;
|
|
3281
|
+
if (field && Utils.isFieldType(field, ["id", "number", "date"]))
|
|
3282
|
+
comparison = (Number(aValue) || 0) - (Number(bValue) || 0);
|
|
3283
|
+
else
|
|
3284
|
+
comparison = String(aValue ?? "").localeCompare(String(bValue ?? ""));
|
|
3285
|
+
if (comparison)
|
|
3286
|
+
return ascending ? comparison : -comparison;
|
|
3287
|
+
}
|
|
3288
|
+
// Deterministic tiebreak (legacy `sort` leaves ties unspecified).
|
|
3289
|
+
return (Number(a?.id) || 0) - (Number(b?.id) || 0);
|
|
3290
|
+
};
|
|
3291
|
+
rows.sort(comparator);
|
|
3292
|
+
const total = rows.length;
|
|
3293
|
+
const perPage = options.perPage;
|
|
3294
|
+
const pageRows = perPage < 0
|
|
3295
|
+
? rows
|
|
3296
|
+
: rows.slice((options.page - 1) * perPage, options.page * perPage);
|
|
3297
|
+
if (!pageRows.length)
|
|
3298
|
+
return null;
|
|
3299
|
+
this.totalItems.set(`${tableName}-*`, total);
|
|
3300
|
+
this.pageInfo[tableName] = {
|
|
3301
|
+
...(({ columns, ...restOfOptions }) => restOfOptions)(options),
|
|
3302
|
+
perPage: pageRows.length,
|
|
3303
|
+
totalPages: perPage < 0 ? 1 : Math.ceil(total / perPage),
|
|
3304
|
+
total,
|
|
3305
|
+
};
|
|
3306
|
+
return onlyOne ? pageRows[0] : pageRows;
|
|
3307
|
+
}
|
|
2849
3308
|
async post(tableName, data, options, returnPostedData) {
|
|
2850
3309
|
return File.runWithLockStore(() => this._post(tableName, data, options, returnPostedData));
|
|
2851
3310
|
}
|
|
@@ -2915,10 +3374,10 @@ export default class Inibase {
|
|
|
2915
3374
|
? clonedData.toReversed()
|
|
2916
3375
|
: clonedData
|
|
2917
3376
|
: clonedData);
|
|
2918
|
-
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
3377
|
+
await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
2919
3378
|
.prepend
|
|
2920
3379
|
? await File.prepend(path, content)
|
|
2921
|
-
: await File.append(path, content))));
|
|
3380
|
+
: await File.append(path, content)))));
|
|
2922
3381
|
const newTotal = _totalItems + (Array.isArray(data) ? data.length : 1);
|
|
2923
3382
|
const pagination = {
|
|
2924
3383
|
from: paginationFilePath,
|
|
@@ -2994,6 +3453,119 @@ export default class Inibase {
|
|
|
2994
3453
|
}, returnUpdatedData, _whereIsLinesNumbers) {
|
|
2995
3454
|
return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
|
|
2996
3455
|
}
|
|
3456
|
+
/**
|
|
3457
|
+
* Incremental `put` for format-2 (log) tables.
|
|
3458
|
+
*
|
|
3459
|
+
* Resolves the target rows to their stable internal ids, validates the
|
|
3460
|
+
* payload against the merged view (uniqueness included), and appends one
|
|
3461
|
+
* bounded frame instead of rewriting whole columns. The base files are left
|
|
3462
|
+
* untouched; readers merge the overlay. Returns `undefined` when nothing
|
|
3463
|
+
* matches, matching the legacy `put` contract.
|
|
3464
|
+
*/
|
|
3465
|
+
async putViaLog(tableName, data, where, options = { page: 1, perPage: 15 }, returnUpdatedData, _whereIsLinesNumbers) {
|
|
3466
|
+
const tablePath = join(this.databasePath, tableName);
|
|
3467
|
+
const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [];
|
|
3468
|
+
const payloads = Array.isArray(data) ? data : [data];
|
|
3469
|
+
const isWhereLess = !where;
|
|
3470
|
+
const isLines = _whereIsLinesNumbers === true;
|
|
3471
|
+
const scalarWhere = !isWhereLess && !isLines && !Array.isArray(where);
|
|
3472
|
+
await this.throwErrorIfTableEmpty(tableName);
|
|
3473
|
+
let overlay = null;
|
|
3474
|
+
let resolvedLines = [];
|
|
3475
|
+
let inlineLock = false;
|
|
3476
|
+
try {
|
|
3477
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
3478
|
+
inlineLock = true;
|
|
3479
|
+
// Writer-side refresh: repairs a torn tail so the next frame is
|
|
3480
|
+
// never appended behind unreadable bytes, and gives us the current
|
|
3481
|
+
// committed generation.
|
|
3482
|
+
overlay = await this.getOverlay(tableName, true);
|
|
3483
|
+
if (!overlay)
|
|
3484
|
+
return undefined;
|
|
3485
|
+
// Backpressure before the write: if the log or the overlay has grown
|
|
3486
|
+
// past its bound, fold it into the base first. Compaction is
|
|
3487
|
+
// maintenance (it only materializes acknowledged frames), so a
|
|
3488
|
+
// failure surfaces as MAINTENANCE_REQUIRED without losing any
|
|
3489
|
+
// committed update.
|
|
3490
|
+
if (overlay.size >= maxOverlayRecords() ||
|
|
3491
|
+
(await overlay.log.bytes()) >= maxLogBytes()) {
|
|
3492
|
+
try {
|
|
3493
|
+
await this.compactLogTable(tableName);
|
|
3494
|
+
}
|
|
3495
|
+
catch (error) {
|
|
3496
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (compaction failed: ${error?.message ?? error})`);
|
|
3497
|
+
}
|
|
3498
|
+
overlay = await this.getOverlay(tableName, true);
|
|
3499
|
+
if (overlay.size >= maxOverlayRecords())
|
|
3500
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (overlay still exceeds ${maxOverlayRecords()} rows after compaction)`);
|
|
3501
|
+
}
|
|
3502
|
+
if (isWhereLess) {
|
|
3503
|
+
const { total } = await this.resolvePagination(tableName);
|
|
3504
|
+
resolvedLines = Array.from({ length: total }, (_, index) => index + 1);
|
|
3505
|
+
}
|
|
3506
|
+
else if (isLines) {
|
|
3507
|
+
resolvedLines = Array.isArray(where)
|
|
3508
|
+
? where
|
|
3509
|
+
: [where];
|
|
3510
|
+
}
|
|
3511
|
+
else {
|
|
3512
|
+
const lines = await this.get(tableName, where, undefined, undefined, true);
|
|
3513
|
+
if (!lines)
|
|
3514
|
+
return undefined;
|
|
3515
|
+
resolvedLines = Array.isArray(lines)
|
|
3516
|
+
? lines
|
|
3517
|
+
: [lines];
|
|
3518
|
+
}
|
|
3519
|
+
if (!resolvedLines.length)
|
|
3520
|
+
return undefined;
|
|
3521
|
+
const lineIds = await this.overlayLineIds(tableName, resolvedLines);
|
|
3522
|
+
// Validate against the merged view: `checkUnique` searches the base
|
|
3523
|
+
// columns through `File.search` overrides, so this sees un-compacted
|
|
3524
|
+
// puts. Required fields are skipped (partial update).
|
|
3525
|
+
await this.validateTableData(tableName, structuredClone(payloads), true);
|
|
3526
|
+
const now = Date.now();
|
|
3527
|
+
const records = [];
|
|
3528
|
+
for (let index = 0; index < resolvedLines.length; index++) {
|
|
3529
|
+
const id = lineIds.get(resolvedLines[index]);
|
|
3530
|
+
if (id === undefined)
|
|
3531
|
+
continue;
|
|
3532
|
+
const payload = payloads.length === 1
|
|
3533
|
+
? payloads[0]
|
|
3534
|
+
: (payloads[index % payloads.length] ?? payloads[0]);
|
|
3535
|
+
const formatted = this.formatData(payload, schema, true);
|
|
3536
|
+
const cols = {};
|
|
3537
|
+
for (const [key, value] of Object.entries(formatted)) {
|
|
3538
|
+
if (key === "id" || key === "createdAt" || key === "updatedAt")
|
|
3539
|
+
continue;
|
|
3540
|
+
// Only columns the caller actually supplied are overlaid, so a
|
|
3541
|
+
// partial `put` never blanks untouched fields.
|
|
3542
|
+
if (!Object.hasOwn(payload, key))
|
|
3543
|
+
continue;
|
|
3544
|
+
cols[key] = File.encode(value);
|
|
3545
|
+
}
|
|
3546
|
+
cols.updatedAt = now;
|
|
3547
|
+
records.push({ id, cols, updatedAt: now });
|
|
3548
|
+
}
|
|
3549
|
+
if (records.length) {
|
|
3550
|
+
const frames = buildFrames(records, overlay.committedGen + 1, DEFAULT_MAX_FRAME_BYTES, overlay.log.path);
|
|
3551
|
+
for (const frame of frames)
|
|
3552
|
+
await overlay.log.append(frame);
|
|
3553
|
+
await overlay.applyLocal(frames);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
finally {
|
|
3557
|
+
if (inlineLock)
|
|
3558
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
3559
|
+
}
|
|
3560
|
+
if (returnUpdatedData) {
|
|
3561
|
+
if (isWhereLess)
|
|
3562
|
+
return await this.get(tableName, undefined, options);
|
|
3563
|
+
if (scalarWhere)
|
|
3564
|
+
return await this.get(tableName, resolvedLines[0], options, true, undefined, true);
|
|
3565
|
+
return await this.get(tableName, resolvedLines, options, undefined, undefined, true);
|
|
3566
|
+
}
|
|
3567
|
+
return undefined;
|
|
3568
|
+
}
|
|
2997
3569
|
async _put(tableName, data, where, options = {
|
|
2998
3570
|
page: 1,
|
|
2999
3571
|
perPage: 15,
|
|
@@ -3009,6 +3581,16 @@ export default class Inibase {
|
|
|
3009
3581
|
if (!this.activeTransaction)
|
|
3010
3582
|
await this.ensureDatabaseRecovered();
|
|
3011
3583
|
await this.throwErrorIfTableEmpty(tableName);
|
|
3584
|
+
// Format-2 tables route every put through the append-only log, which
|
|
3585
|
+
// is proportional to the changed data. Transactions and DDL need a
|
|
3586
|
+
// compaction that 3.9 ships, so they are refused loudly rather than
|
|
3587
|
+
// silently falling back to a whole-column rewrite that would leave the
|
|
3588
|
+
// overlay shadowing newer base values.
|
|
3589
|
+
if (this.isLogTable(tableName)) {
|
|
3590
|
+
if (this.activeTransaction)
|
|
3591
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (put inside a transaction on update-log table)`);
|
|
3592
|
+
return this.putViaLog(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers);
|
|
3593
|
+
}
|
|
3012
3594
|
let clonedData = structuredClone(data);
|
|
3013
3595
|
if (!where) {
|
|
3014
3596
|
if (Utils.isArrayOfObjects(clonedData)) {
|
|
@@ -3060,7 +3642,7 @@ export default class Inibase {
|
|
|
3060
3642
|
const evaluated = await this.evaluateComputedRows(tableName, computedPlan, mergedRows);
|
|
3061
3643
|
this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
|
|
3062
3644
|
}
|
|
3063
|
-
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total))));
|
|
3645
|
+
await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total)))));
|
|
3064
3646
|
if (this.activeTransaction) {
|
|
3065
3647
|
// Stage instead of publishing: row count is unchanged so
|
|
3066
3648
|
// there is no pagination rename to journal.
|
|
@@ -3157,7 +3739,7 @@ export default class Inibase {
|
|
|
3157
3739
|
}
|
|
3158
3740
|
this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
|
|
3159
3741
|
}
|
|
3160
|
-
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
|
|
3742
|
+
await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content)))));
|
|
3161
3743
|
if (this.activeTransaction) {
|
|
3162
3744
|
await this.stageTxnOp(tableName, renameList, null);
|
|
3163
3745
|
txnStaged = true;
|
|
@@ -3252,6 +3834,13 @@ export default class Inibase {
|
|
|
3252
3834
|
await this.ensureDatabaseRecovered();
|
|
3253
3835
|
const tablePath = join(this.databasePath, tableName);
|
|
3254
3836
|
await this.throwErrorIfTableEmpty(tableName);
|
|
3837
|
+
if (this.isLogTable(tableName) && this.activeTransaction)
|
|
3838
|
+
throw this.createError("MAINTENANCE_REQUIRED", `${tableName} (delete inside a transaction on update-log table)`);
|
|
3839
|
+
// Fold any pending puts into the base before a tombstone rewrite, so the
|
|
3840
|
+
// log is empty and the rewritten base carries every acknowledged value.
|
|
3841
|
+
// `compact` is a no-op when there is nothing to fold.
|
|
3842
|
+
if (this.isLogTable(tableName))
|
|
3843
|
+
await this.compact(tableName);
|
|
3255
3844
|
if (!where) {
|
|
3256
3845
|
let txnStaged = false;
|
|
3257
3846
|
// Crash-atomic truncate: park every column file (pure removal
|
|
@@ -3330,7 +3919,7 @@ export default class Inibase {
|
|
|
3330
3919
|
const remaining = total - (Array.isArray(where) ? where.length : 1);
|
|
3331
3920
|
if (total && remaining > 0) {
|
|
3332
3921
|
this.idDensity.set(tableName, false);
|
|
3333
|
-
await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where))));
|
|
3922
|
+
await settleWrites(await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where)))));
|
|
3334
3923
|
const pagination = {
|
|
3335
3924
|
from: paginationFilePath,
|
|
3336
3925
|
to: join(tablePath, `${lastId}-${remaining}.pagination`),
|
|
@@ -3529,11 +4118,11 @@ export default class Inibase {
|
|
|
3529
4118
|
// the default page size (15): every matching row counts.
|
|
3530
4119
|
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3531
4120
|
RETURN[column] = lineNumbers
|
|
3532
|
-
? await File.sum(columnPath, lineNumbers)
|
|
4121
|
+
? await File.sum(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
|
|
3533
4122
|
: 0;
|
|
3534
4123
|
}
|
|
3535
4124
|
else
|
|
3536
|
-
RETURN[column] = await File.sum(columnPath);
|
|
4125
|
+
RETURN[column] = await File.sum(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
|
|
3537
4126
|
}
|
|
3538
4127
|
}
|
|
3539
4128
|
return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
|
|
@@ -3718,11 +4307,11 @@ export default class Inibase {
|
|
|
3718
4307
|
if (where) {
|
|
3719
4308
|
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3720
4309
|
RETURN[column] = lineNumbers
|
|
3721
|
-
? await File.avg(columnPath, lineNumbers)
|
|
4310
|
+
? await File.avg(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
|
|
3722
4311
|
: 0;
|
|
3723
4312
|
}
|
|
3724
4313
|
else
|
|
3725
|
-
RETURN[column] = await File.avg(columnPath);
|
|
4314
|
+
RETURN[column] = await File.avg(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
|
|
3726
4315
|
}
|
|
3727
4316
|
}
|
|
3728
4317
|
return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
|
|
@@ -3742,11 +4331,11 @@ export default class Inibase {
|
|
|
3742
4331
|
if (where) {
|
|
3743
4332
|
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3744
4333
|
RETURN[column] = lineNumbers
|
|
3745
|
-
? await File.max(columnPath, lineNumbers)
|
|
4334
|
+
? await File.max(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
|
|
3746
4335
|
: 0;
|
|
3747
4336
|
}
|
|
3748
4337
|
else
|
|
3749
|
-
RETURN[column] = await File.max(columnPath);
|
|
4338
|
+
RETURN[column] = await File.max(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
|
|
3750
4339
|
}
|
|
3751
4340
|
}
|
|
3752
4341
|
return RETURN;
|
|
@@ -3766,11 +4355,11 @@ export default class Inibase {
|
|
|
3766
4355
|
if (where) {
|
|
3767
4356
|
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3768
4357
|
RETURN[column] = lineNumbers
|
|
3769
|
-
? await File.min(columnPath, lineNumbers)
|
|
4358
|
+
? await File.min(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
|
|
3770
4359
|
: 0;
|
|
3771
4360
|
}
|
|
3772
4361
|
else
|
|
3773
|
-
RETURN[column] = await File.min(columnPath);
|
|
4362
|
+
RETURN[column] = await File.min(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
|
|
3774
4363
|
}
|
|
3775
4364
|
}
|
|
3776
4365
|
return RETURN;
|