inibase 3.7.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/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)
@@ -143,6 +159,15 @@ export default class Inibase {
143
159
  * changes: see the invalidation in `getTable`'s reload branch,
144
160
  * `createTable` and `updateTableLocked`. Bounded by the number of tables. */
145
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();
146
171
  databasePath;
147
172
  uniqueMap;
148
173
  schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
@@ -265,6 +290,23 @@ export default class Inibase {
265
290
  await File.write(join(tablePath, ".prepend.config"), "");
266
291
  if (config.decodeID)
267
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
+ }
268
310
  }
269
311
  if (schema) {
270
312
  const lastSchemaID = { value: 0 };
@@ -339,6 +381,34 @@ export default class Inibase {
339
381
  }
340
382
  }
341
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
+ }
342
412
  if (schema) {
343
413
  this.validateSchema(schema);
344
414
  // remove id from schema
@@ -543,6 +613,13 @@ export default class Inibase {
543
613
  this.computedPlanCache.delete(tableName);
544
614
  if (config?.name && config.name !== tableName)
545
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
+ }
546
623
  }
547
624
  /**
548
625
  * Get table schema and config
@@ -557,22 +634,313 @@ export default class Inibase {
557
634
  throw this.createError("TABLE_NOT_EXISTS", tableName);
558
635
  if (!globalConfig[this.databasePath].tables?.has(tableName) ||
559
636
  globalConfig[this.databasePath].tables?.get(tableName)?.timestamp !==
560
- (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;
561
651
  globalConfig[this.databasePath].tables?.set(tableName, {
562
652
  schema: await this.getTableSchema(tableName),
563
- config: {
564
- compression: await File.isExists(join(tablePath, ".compression.config")),
565
- cache: await File.isExists(join(tablePath, ".cache.config")),
566
- prepend: await File.isExists(join(tablePath, ".prepend.config")),
567
- decodeID: await File.isExists(join(tablePath, ".decodeID.config")),
568
- },
653
+ config,
569
654
  timestamp: await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`)),
570
655
  });
656
+ }
571
657
  // A reload means the on-disk schema changed (create/update/external
572
658
  // edit): the cached computed plan is derived from it.
573
659
  this.computedPlanCache.delete(tableName);
574
660
  return globalConfig[this.databasePath].tables?.get(tableName);
575
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
+ }
576
944
  async getTableSchema(tableName) {
577
945
  this.validateName(tableName);
578
946
  const tablePath = join(this.databasePath, tableName);
@@ -823,7 +1191,7 @@ export default class Inibase {
823
1191
  if (!field)
824
1192
  continue;
825
1193
  fieldsKeys.push(field.key);
826
- 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));
827
1195
  if (totalLines > 0) {
828
1196
  if (valueObject.columnsValues.size === 1 ||
829
1197
  (valueObject.columnsValues.size === index &&
@@ -1051,11 +1419,58 @@ export default class Inibase {
1051
1419
  await this.processTableField(tableName, field, RETURN, linesNumber, options, prefix);
1052
1420
  }
1053
1421
  }
1422
+ await this.mergeOverlayIntoRows(tableName, schema, RETURN);
1054
1423
  return RETURN;
1055
1424
  }
1056
- /* -----------------------------------------------------------------------
1057
- * Computed fields
1058
- * --------------------------------------------------------------------- */
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
+ }
1059
1474
  /**
1060
1475
  * Extract the raw expression from a field's `computed` property (string or
1061
1476
  * persisted `{ expr, ast }` spec).
@@ -1971,7 +2386,7 @@ export default class Inibase {
1971
2386
  }, (options.perPage ?? 0) < 0 ? undefined : options.perPage, (options.perPage ?? 0) < 0
1972
2387
  ? undefined
1973
2388
  : (options.page - 1) * options.perPage +
1974
- ((options.page ?? 1) > 1 ? 1 : 0), true);
2389
+ ((options.page ?? 1) > 1 ? 1 : 0), true, undefined, await this.overlayColumnOverrides(tableName, key));
1975
2390
  if (!searchResult) {
1976
2391
  if (allTrue)
1977
2392
  return null;
@@ -2541,6 +2956,14 @@ export default class Inibase {
2541
2956
  (Utils.isObject(where) && !Object.keys(where).length)))
2542
2957
  where = undefined;
2543
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
+ }
2544
2967
  let sortArray;
2545
2968
  let awkCommand = "";
2546
2969
  if (Utils.isObject(options.sort) && !Array.isArray(options.sort)) {
@@ -2752,8 +3175,15 @@ export default class Inibase {
2752
3175
  }
2753
3176
  else if (Utils.isObject(where)) {
2754
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;
2755
3184
  // Criteria
2756
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache) {
3185
+ if (!overlayActive &&
3186
+ globalConfig[this.databasePath].tables?.get(tableName)?.config.cache) {
2757
3187
  // Cache entries are versioned by the pagination row count so a
2758
3188
  // stale cache written before a post/delete (in this process or
2759
3189
  // another) is detectable: the candidate filename simply stops
@@ -2788,7 +3218,8 @@ export default class Inibase {
2788
3218
  for (const [key] of this.totalItems)
2789
3219
  if (key.startsWith(`${tableName}-`) && key !== `${tableName}-id`)
2790
3220
  this.totalItems.delete(key);
2791
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
3221
+ if (!overlayActive &&
3222
+ globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
2792
3223
  await writeFile(cachedFilePath, Object.keys(LineNumberDataObj).join(","));
2793
3224
  }
2794
3225
  }
@@ -2810,6 +3241,70 @@ export default class Inibase {
2810
3241
  };
2811
3242
  return onlyOne && Array.isArray(RETURN) ? RETURN[0] : RETURN;
2812
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
+ }
2813
3308
  async post(tableName, data, options, returnPostedData) {
2814
3309
  return File.runWithLockStore(() => this._post(tableName, data, options, returnPostedData));
2815
3310
  }
@@ -2958,6 +3453,119 @@ export default class Inibase {
2958
3453
  }, returnUpdatedData, _whereIsLinesNumbers) {
2959
3454
  return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
2960
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
+ }
2961
3569
  async _put(tableName, data, where, options = {
2962
3570
  page: 1,
2963
3571
  perPage: 15,
@@ -2973,6 +3581,16 @@ export default class Inibase {
2973
3581
  if (!this.activeTransaction)
2974
3582
  await this.ensureDatabaseRecovered();
2975
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
+ }
2976
3594
  let clonedData = structuredClone(data);
2977
3595
  if (!where) {
2978
3596
  if (Utils.isArrayOfObjects(clonedData)) {
@@ -3216,6 +3834,13 @@ export default class Inibase {
3216
3834
  await this.ensureDatabaseRecovered();
3217
3835
  const tablePath = join(this.databasePath, tableName);
3218
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);
3219
3844
  if (!where) {
3220
3845
  let txnStaged = false;
3221
3846
  // Crash-atomic truncate: park every column file (pure removal
@@ -3493,11 +4118,11 @@ export default class Inibase {
3493
4118
  // the default page size (15): every matching row counts.
3494
4119
  const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3495
4120
  RETURN[column] = lineNumbers
3496
- ? await File.sum(columnPath, lineNumbers)
4121
+ ? await File.sum(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
3497
4122
  : 0;
3498
4123
  }
3499
4124
  else
3500
- RETURN[column] = await File.sum(columnPath);
4125
+ RETURN[column] = await File.sum(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
3501
4126
  }
3502
4127
  }
3503
4128
  return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
@@ -3682,11 +4307,11 @@ export default class Inibase {
3682
4307
  if (where) {
3683
4308
  const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3684
4309
  RETURN[column] = lineNumbers
3685
- ? await File.avg(columnPath, lineNumbers)
4310
+ ? await File.avg(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
3686
4311
  : 0;
3687
4312
  }
3688
4313
  else
3689
- RETURN[column] = await File.avg(columnPath);
4314
+ RETURN[column] = await File.avg(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
3690
4315
  }
3691
4316
  }
3692
4317
  return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
@@ -3706,11 +4331,11 @@ export default class Inibase {
3706
4331
  if (where) {
3707
4332
  const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3708
4333
  RETURN[column] = lineNumbers
3709
- ? await File.max(columnPath, lineNumbers)
4334
+ ? await File.max(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
3710
4335
  : 0;
3711
4336
  }
3712
4337
  else
3713
- RETURN[column] = await File.max(columnPath);
4338
+ RETURN[column] = await File.max(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
3714
4339
  }
3715
4340
  }
3716
4341
  return RETURN;
@@ -3730,11 +4355,11 @@ export default class Inibase {
3730
4355
  if (where) {
3731
4356
  const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3732
4357
  RETURN[column] = lineNumbers
3733
- ? await File.min(columnPath, lineNumbers)
4358
+ ? await File.min(columnPath, lineNumbers, await this.overlayColumnOverrides(tableName, column))
3734
4359
  : 0;
3735
4360
  }
3736
4361
  else
3737
- RETURN[column] = await File.min(columnPath);
4362
+ RETURN[column] = await File.min(columnPath, undefined, await this.overlayColumnOverrides(tableName, column));
3738
4363
  }
3739
4364
  }
3740
4365
  return RETURN;