@rljson/io 0.0.69 → 0.0.71

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/io.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { hip, hsh } from "@rljson/hash";
2
2
  import { IsReady } from "@rljson/is-ready";
3
- import { copy, equals, merge } from "@rljson/json";
3
+ import { equals, copy, merge } from "@rljson/json";
4
4
  import { iterateTables, throwOnInvalidTableCfg, validateRljsonAgainstTableCfg, iterateTablesSync } from "@rljson/rljson";
5
5
  class IoDbNameMapping {
6
6
  // The primary key column is always named '_hash'
@@ -411,6 +411,27 @@ class IoMem {
411
411
  readRows(request) {
412
412
  return this._readRows(request);
413
413
  }
414
+ async readRowsByHashes(request) {
415
+ await this._ioTools.throwWhenTableDoesNotExist(request.table);
416
+ const table = this._mem[request.table];
417
+ const rowIndex = this._rowIndexFor(request.table);
418
+ const seen = /* @__PURE__ */ new Set();
419
+ const rows = [];
420
+ for (const hash of request.hashes) {
421
+ if (seen.has(hash)) continue;
422
+ seen.add(hash);
423
+ const row = rowIndex.get(hash);
424
+ if (row) {
425
+ rows.push(row);
426
+ }
427
+ }
428
+ const tableFiltered = {
429
+ _type: table._type,
430
+ _data: rows
431
+ };
432
+ this._ioTools.sortTableDataAndUpdateHash(tableFiltered);
433
+ return { [request.table]: tableFiltered };
434
+ }
414
435
  async rowCount(table) {
415
436
  const tableData = this._mem[table];
416
437
  if (!tableData) {
@@ -443,8 +464,182 @@ class IoMem {
443
464
  _isReady = new IsReady();
444
465
  _isOpen = false;
445
466
  _mem = hip({});
467
+ /**
468
+ * Latest table configuration per table (the one with the most
469
+ * columns). Kept in sync by _createTable/_extendTable so that reads
470
+ * and writes need no repeated scan over all configurations.
471
+ */
472
+ _latestCfgs = /* @__PURE__ */ new Map();
473
+ /** Column key sets per table, derived from _latestCfgs */
474
+ _columnKeys = /* @__PURE__ */ new Map();
475
+ /**
476
+ * Per-table index of rows by content hash. Rows are only ever added,
477
+ * never removed, so the index cannot go stale.
478
+ */
479
+ _rowIndex = /* @__PURE__ */ new Map();
480
+ /**
481
+ * Tables whose table hash (and thus the global hash) is outdated
482
+ * after writes. Hashes are refreshed lazily before they become
483
+ * observable (dump, dumpTable, create/extend) instead of after every
484
+ * single write — row hashes themselves are always up to date.
485
+ */
486
+ _dirtyTableHashes = /* @__PURE__ */ new Set();
487
+ // ...........................................................................
488
+ /**
489
+ * Recomputes the hashes of all dirty tables and the global hash.
490
+ * Produces exactly the state an eager per-write update would have
491
+ * produced (hashes are deterministic over the same data).
492
+ */
493
+ _refreshHashes() {
494
+ if (this._dirtyTableHashes.size === 0) {
495
+ return;
496
+ }
497
+ for (const tableKey of this._dirtyTableHashes) {
498
+ const table = this._mem[tableKey];
499
+ table._hash = "";
500
+ hip(table, {
501
+ updateExistingHashes: false,
502
+ throwOnWrongHashes: false
503
+ });
504
+ }
505
+ this._dirtyTableHashes.clear();
506
+ this._updateGlobalHash();
507
+ }
508
+ // ...........................................................................
509
+ /**
510
+ * Returns the row index of a table, building it lazily from the
511
+ * table data on first access.
512
+ * @param table - The table to index
513
+ */
514
+ _rowIndexFor(table) {
515
+ let index = this._rowIndex.get(table);
516
+ if (!index) {
517
+ index = /* @__PURE__ */ new Map();
518
+ const tableData = this._mem[table]._data;
519
+ for (const row of tableData) {
520
+ index.set(row._hash, row);
521
+ }
522
+ this._rowIndex.set(table, index);
523
+ }
524
+ return index;
525
+ }
526
+ // ...........................................................................
527
+ /**
528
+ * Inserts a row into hash-sorted table data at its sorted position —
529
+ * preserves the order sortTableDataAndUpdateHash establishes without
530
+ * a full re-sort.
531
+ * @param data - The hash-sorted table data
532
+ * @param row - The row to insert
533
+ */
534
+ static _insertSortedByHash(data, row) {
535
+ const hash = row._hash;
536
+ let lo = 0;
537
+ let hi = data.length;
538
+ while (lo < hi) {
539
+ const mid = lo + hi >> 1;
540
+ if (data[mid]._hash < hash) {
541
+ lo = mid + 1;
542
+ } else {
543
+ hi = mid;
544
+ }
545
+ }
546
+ data.splice(lo, 0, row);
547
+ }
548
+ // ...........................................................................
549
+ /**
550
+ * The row filter predicate of readRows. Extracted so that the indexed
551
+ * fast path and the full scan share identical semantics.
552
+ * @param row - The row to check
553
+ * @param where - The where clause
554
+ */
555
+ static _rowMatchesWhere(row, where) {
556
+ for (const column in where) {
557
+ const a = row[column];
558
+ const b = where[column];
559
+ if (b === null && a === void 0) {
560
+ return true;
561
+ }
562
+ if (!equals(a, b)) {
563
+ return false;
564
+ }
565
+ }
566
+ return true;
567
+ }
568
+ // ...........................................................................
569
+ /**
570
+ * Returns the latest table configuration, filling the cache lazily
571
+ * from IoTools (which picks the config with the most columns).
572
+ * @param table - The table to get the configuration for
573
+ */
574
+ async _latestCfg(table) {
575
+ let cfg = this._latestCfgs.get(table);
576
+ if (!cfg) {
577
+ cfg = await this._ioTools.tableCfg(table);
578
+ this._latestCfgs.set(table, cfg);
579
+ }
580
+ return cfg;
581
+ }
582
+ /**
583
+ * Updates the cached latest configuration of a table
584
+ * @param cfg - The new latest configuration
585
+ */
586
+ _setLatestCfg(cfg) {
587
+ this._latestCfgs.set(cfg.key, cfg);
588
+ this._columnKeys.set(cfg.key, new Set(cfg.columns.map((c) => c.key)));
589
+ }
590
+ /**
591
+ * Throws when one of the given columns does not exist in the table.
592
+ * Mirrors IoTools.throwWhenColumnDoesNotExist but uses the cached
593
+ * configuration.
594
+ * @param table - The table to check
595
+ * @param columns - The columns to check
596
+ */
597
+ async _throwWhenColumnDoesNotExist(table, columns) {
598
+ let columnKeys = this._columnKeys.get(table);
599
+ if (!columnKeys) {
600
+ const cfg = await this._latestCfg(table);
601
+ columnKeys = new Set(cfg.columns.map((c) => c.key));
602
+ this._columnKeys.set(table, columnKeys);
603
+ }
604
+ const missingColumns = columns.filter((column) => !columnKeys.has(column));
605
+ if (missingColumns.length > 0) {
606
+ throw new Error(
607
+ `The following columns do not exist in table "${table}": ${missingColumns.join(
608
+ ", "
609
+ )}.`
610
+ );
611
+ }
612
+ }
613
+ /**
614
+ * Throws when the data does not match the table configurations.
615
+ * Mirrors IoTools.throwWhenTableDataDoesNotMatchCfg but uses the
616
+ * cached configurations.
617
+ * @param data - The data to validate
618
+ */
619
+ async _throwWhenTableDataDoesNotMatchCfg(data) {
620
+ const errors = [];
621
+ for (const tableKey of Object.keys(data)) {
622
+ const table = data[tableKey];
623
+ if (typeof table !== "object" || !Array.isArray(table?._data)) continue;
624
+ if (table._type === "tableCfgs") continue;
625
+ const tableCfg = await this._latestCfg(tableKey);
626
+ errors.push(...validateRljsonAgainstTableCfg(table._data, tableCfg));
627
+ }
628
+ if (errors.length > 0) {
629
+ throw new Error(
630
+ `Table data does not match the configuration.
631
+
632
+ Errors:
633
+ ${errors.map((e) => `- ${e}`).join("\n")}`
634
+ );
635
+ }
636
+ }
446
637
  // ...........................................................................
447
638
  async _init() {
639
+ this._refreshHashes();
640
+ this._rowIndex.clear();
641
+ this._latestCfgs.clear();
642
+ this._columnKeys.clear();
448
643
  this._ioTools = new IoTools(this);
449
644
  this._initTableCfgs();
450
645
  this._updateGlobalHash();
@@ -476,6 +671,7 @@ class IoMem {
476
671
  }
477
672
  // ...........................................................................
478
673
  async _createOrExtendTable(request) {
674
+ this._refreshHashes();
479
675
  const tableCfg = request.tableCfg;
480
676
  await this._ioTools.throwWhenTableIsNotCompatible(tableCfg);
481
677
  const { key } = tableCfg;
@@ -492,6 +688,8 @@ class IoMem {
492
688
  newConfig = hsh(newConfig);
493
689
  this._mem.tableCfgs._data.push(newConfig);
494
690
  this._ioTools.sortTableDataAndUpdateHash(this._mem.tableCfgs);
691
+ this._setLatestCfg(newConfig);
692
+ this._rowIndex.get("tableCfgs")?.set(newConfig._hash, newConfig);
495
693
  const table = {
496
694
  _type: newConfig.type,
497
695
  _data: [],
@@ -509,6 +707,8 @@ class IoMem {
509
707
  newConfig = hsh(newConfig);
510
708
  this._mem.tableCfgs._data.push(newConfig);
511
709
  this._ioTools.sortTableDataAndUpdateHash(this._mem.tableCfgs);
710
+ this._setLatestCfg(newConfig);
711
+ this._rowIndex.get("tableCfgs")?.set(newConfig._hash, newConfig);
512
712
  const table = this._mem[newConfig.key];
513
713
  table._tableCfg = newConfig._hash;
514
714
  this._updateTableHash(newConfig.key);
@@ -516,11 +716,13 @@ class IoMem {
516
716
  }
517
717
  // ...........................................................................
518
718
  async _dump() {
719
+ this._refreshHashes();
519
720
  return copy(this._mem);
520
721
  }
521
722
  // ...........................................................................
522
723
  async _dumpTable(request) {
523
724
  await this._ioTools.throwWhenTableDoesNotExist(request.table);
725
+ this._refreshHashes();
524
726
  const table = this._mem[request.table];
525
727
  return {
526
728
  [request.table]: copy(table)
@@ -534,49 +736,48 @@ class IoMem {
534
736
  // ...........................................................................
535
737
  async _write(request) {
536
738
  const addedData = hsh(request.data);
537
- this._removeNullValues(addedData);
739
+ const removedNullValues = this._removeNullValues(addedData);
538
740
  const tables = Object.keys(addedData);
539
- hsh(addedData);
741
+ if (removedNullValues) {
742
+ hsh(addedData);
743
+ }
540
744
  await this._ioTools.throwWhenTablesDoNotExist(request.data);
541
- await this._ioTools.throwWhenTableDataDoesNotMatchCfg(request.data);
745
+ await this._throwWhenTableDataDoesNotMatchCfg(request.data);
542
746
  for (const table of tables) {
543
747
  if (table.startsWith("_")) {
544
748
  continue;
545
749
  }
546
750
  const oldTable = this._mem[table];
547
751
  const newTable = addedData[table];
752
+ const rowIndex = this._rowIndexFor(table);
548
753
  for (const item of newTable._data) {
549
754
  const hash = item._hash;
550
- const exists = oldTable._data.find((i) => i._hash === hash);
551
- if (!exists) {
552
- oldTable._data.push(item);
755
+ if (!rowIndex.has(hash)) {
756
+ rowIndex.set(hash, item);
757
+ IoMem._insertSortedByHash(oldTable._data, item);
553
758
  }
554
759
  }
555
- this._ioTools.sortTableDataAndUpdateHash(oldTable);
760
+ this._dirtyTableHashes.add(table);
556
761
  }
557
- this._updateGlobalHash();
558
762
  }
559
763
  // ...........................................................................
560
764
  async _readRows(request) {
561
765
  await this._ioTools.throwWhenTableDoesNotExist(request.table);
562
- await this._ioTools.throwWhenColumnDoesNotExist(
766
+ await this._throwWhenColumnDoesNotExist(
563
767
  request.table,
564
768
  Object.keys(request.where)
565
769
  );
566
770
  const table = this._mem[request.table];
567
- const tableDataFiltered = table._data.filter((row) => {
568
- for (const column in request.where) {
569
- const a = row[column];
570
- const b = request.where[column];
571
- if (b === null && a === void 0) {
572
- return true;
573
- }
574
- if (!equals(a, b)) {
575
- return false;
576
- }
577
- }
578
- return true;
579
- });
771
+ const whereHash = request.where["_hash"];
772
+ let tableDataFiltered;
773
+ if (typeof whereHash === "string") {
774
+ const row = this._rowIndexFor(request.table).get(whereHash);
775
+ tableDataFiltered = row && IoMem._rowMatchesWhere(row, request.where) ? [row] : [];
776
+ } else {
777
+ tableDataFiltered = table._data.filter(
778
+ (row) => IoMem._rowMatchesWhere(row, request.where)
779
+ );
780
+ }
580
781
  const tableFiltered = {
581
782
  _type: table._type,
582
783
  _data: tableDataFiltered
@@ -588,16 +789,19 @@ class IoMem {
588
789
  return result;
589
790
  }
590
791
  _removeNullValues(rljson) {
792
+ let removedAny = false;
591
793
  iterateTablesSync(rljson, (table) => {
592
794
  const data = rljson[table]._data;
593
795
  for (const row of data) {
594
796
  for (const key in row) {
595
797
  if (row[key] === null) {
596
798
  delete row[key];
799
+ removedAny = true;
597
800
  }
598
801
  }
599
802
  }
600
803
  });
804
+ return removedAny;
601
805
  }
602
806
  }
603
807
  class PeerSocketMock {
@@ -925,6 +1129,65 @@ class IoPeer {
925
1129
  );
926
1130
  }
927
1131
  // ...........................................................................
1132
+ /**
1133
+ * True once the remote side signalled that it does not support batch
1134
+ * reads — all further batch reads then use per-hash readRows.
1135
+ */
1136
+ _batchReadsUnsupported = false;
1137
+ /**
1138
+ * Batch read over the socket. Falls back to per-hash readRows when
1139
+ * the remote side does not support it (older server) and remembers
1140
+ * the capability for subsequent calls.
1141
+ * @param request - The table and the row hashes to read
1142
+ */
1143
+ async readRowsByHashes(request) {
1144
+ if (!this._batchReadsUnsupported) {
1145
+ try {
1146
+ return await this._withTimeout(
1147
+ new Promise((resolve, reject) => {
1148
+ this._socket.emit(
1149
+ "readRowsByHashes",
1150
+ request,
1151
+ (result, error) => {
1152
+ if (error) reject(error);
1153
+ resolve(result);
1154
+ }
1155
+ );
1156
+ }),
1157
+ "readRowsByHashes"
1158
+ );
1159
+ } catch (error) {
1160
+ const message = String(error.message);
1161
+ const unsupported = message.includes("not found on Io instance") || message.includes("not supported") || message.includes("Timeout after");
1162
+ if (!unsupported) {
1163
+ throw error;
1164
+ }
1165
+ this._batchReadsUnsupported = true;
1166
+ }
1167
+ }
1168
+ const hashes = Array.from(new Set(request.hashes));
1169
+ const results = await Promise.all(
1170
+ hashes.map(
1171
+ (hash) => this.readRows({ table: request.table, where: { _hash: hash } })
1172
+ )
1173
+ );
1174
+ let type = void 0;
1175
+ const rows = [];
1176
+ for (const result of results) {
1177
+ const tableData = result[request.table];
1178
+ type ??= tableData._type;
1179
+ rows.push(...tableData._data);
1180
+ }
1181
+ if (type === void 0) {
1182
+ const empty = await this.readRows({
1183
+ table: request.table,
1184
+ where: { _hash: "__NONE__" }
1185
+ });
1186
+ type = empty[request.table]._type;
1187
+ }
1188
+ return { [request.table]: { _data: rows, _type: type } };
1189
+ }
1190
+ // ...........................................................................
928
1191
  /**
929
1192
  * Retrieves the number of rows in a specific table.
930
1193
  * @param table The name of the table to count rows in.
@@ -1265,6 +1528,103 @@ class IoMulti {
1265
1528
  return Promise.resolve(tableData._data.length);
1266
1529
  }
1267
1530
  // ...........................................................................
1531
+ /**
1532
+ * Batch read with PER-HASH cascade: every hash not found in a
1533
+ * higher-priority readable is looked up in the next one. Readables
1534
+ * without readRowsByHashes are queried per hash via readRows.
1535
+ * @param request - The table and the row hashes to read
1536
+ */
1537
+ async readRowsByHashes(request) {
1538
+ if (this.readables.length === 0) {
1539
+ throw new Error("No readable Io available");
1540
+ }
1541
+ let tableExistsAny = false;
1542
+ const rows = /* @__PURE__ */ new Map();
1543
+ let type = void 0;
1544
+ let readFrom = "";
1545
+ const errors = [];
1546
+ let remaining = Array.from(new Set(request.hashes));
1547
+ for (const readable of this.readables) {
1548
+ if (remaining.length === 0) break;
1549
+ try {
1550
+ let result;
1551
+ if (readable.io.readRowsByHashes) {
1552
+ result = await readable.io.readRowsByHashes({
1553
+ table: request.table,
1554
+ hashes: remaining
1555
+ });
1556
+ } else {
1557
+ result = await IoMulti._readHashesViaReadRows(
1558
+ readable.io,
1559
+ request.table,
1560
+ remaining
1561
+ );
1562
+ }
1563
+ const tableData = result[request.table];
1564
+ tableExistsAny = true;
1565
+ type = tableData._type;
1566
+ if (tableData._data.length > 0) {
1567
+ readFrom = readable.id ?? "";
1568
+ for (const tableRow of tableData._data) {
1569
+ rows.set(tableRow._hash, tableRow);
1570
+ }
1571
+ remaining = remaining.filter((hash) => !rows.has(hash));
1572
+ }
1573
+ } catch (e) {
1574
+ errors.push(e);
1575
+ }
1576
+ }
1577
+ if (!tableExistsAny) {
1578
+ if (errors.length === 0) {
1579
+ throw new Error(`Table "${request.table}" not found`);
1580
+ } else {
1581
+ const preciseErrors = errors.filter(
1582
+ (err) => !err.message.includes(`Table "${request.table}" not found`)
1583
+ );
1584
+ if (preciseErrors.length > 0) {
1585
+ throw preciseErrors[0];
1586
+ } else {
1587
+ throw errors[0];
1588
+ }
1589
+ }
1590
+ }
1591
+ const rljson = {
1592
+ [request.table]: hip({ _data: Array.from(rows.values()), _type: type })
1593
+ };
1594
+ if (this.writables.length > 0 && rows.size > 0) {
1595
+ for (const writeable of this.writables) {
1596
+ if (writeable.id === readFrom) {
1597
+ continue;
1598
+ }
1599
+ try {
1600
+ await writeable.io.write({ data: rljson });
1601
+ } catch {
1602
+ continue;
1603
+ }
1604
+ }
1605
+ }
1606
+ return rljson;
1607
+ }
1608
+ /**
1609
+ * Per-hash fallback for readables without readRowsByHashes.
1610
+ * @param io - The readable io
1611
+ * @param table - The table to read from
1612
+ * @param hashes - The row hashes to read
1613
+ */
1614
+ static async _readHashesViaReadRows(io, table, hashes) {
1615
+ const results = await Promise.all(
1616
+ hashes.map((hash) => io.readRows({ table, where: { _hash: hash } }))
1617
+ );
1618
+ let type = void 0;
1619
+ const rows = [];
1620
+ for (const result of results) {
1621
+ const tableData = result[table];
1622
+ type ??= tableData._type;
1623
+ rows.push(...tableData._data);
1624
+ }
1625
+ return { [table]: { _data: rows, _type: type } };
1626
+ }
1627
+ // ...........................................................................
1268
1628
  /**
1269
1629
  * Gets the list of underlying readable Io instances, sorted by priority.
1270
1630
  */
@@ -1364,6 +1724,7 @@ class IoPeerBridge {
1364
1724
  "createOrExtendTable",
1365
1725
  "write",
1366
1726
  "readRows",
1727
+ "readRowsByHashes",
1367
1728
  "rowCount",
1368
1729
  "dumpTable",
1369
1730
  "dump",
@@ -1551,6 +1912,11 @@ class IoServer {
1551
1912
  rawTableCfgs: () => this._io.rawTableCfgs(),
1552
1913
  write: (request) => this._io.write(request),
1553
1914
  readRows: (request) => this._io.readRows(request),
1915
+ readRowsByHashes: (request) => this._io.readRowsByHashes ? this._io.readRowsByHashes(request) : Promise.reject(
1916
+ new Error(
1917
+ 'Method "readRowsByHashes" not found on Io instance'
1918
+ )
1919
+ ),
1554
1920
  rowCount: (table) => this._io.rowCount(table)
1555
1921
  });
1556
1922
  }