@naturalcycles/db-lib 10.41.1 → 10.42.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.
@@ -1,4 +1,4 @@
1
- import { type ObjectWithId } from '@naturalcycles/js-lib/types';
1
+ import type { ObjectWithId } from '@naturalcycles/js-lib/types';
2
2
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv';
3
3
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
4
4
  import { BaseCommonDB } from '../../commondb/base.common.db.js';
@@ -3,7 +3,7 @@ import { _by, _sortBy } from '@naturalcycles/js-lib/array';
3
3
  import { _since, localTime } from '@naturalcycles/js-lib/datetime';
4
4
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
5
5
  import { _deepEquals, _filterUndefinedValues, _sortObjectDeep } from '@naturalcycles/js-lib/object';
6
- import { _stringMapValues, } from '@naturalcycles/js-lib/types';
6
+ import { _stringMapValues } from '@naturalcycles/js-lib/types';
7
7
  import { generateJsonSchemaFromData } from '@naturalcycles/nodejs-lib/ajv';
8
8
  import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
9
9
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
@@ -1,4 +1,4 @@
1
- import { type BaseDBEntity, type NonNegativeInteger, type StringMap, type Unsaved } from '@naturalcycles/js-lib/types';
1
+ import type { BaseDBEntity, NonNegativeInteger, ObjectWithId, StringMap, Unsaved } from '@naturalcycles/js-lib/types';
2
2
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv';
3
3
  import type { Pipeline } from '@naturalcycles/nodejs-lib/stream';
4
4
  import type { CommonDBTransactionOptions, RunQueryResult } from '../db.model.js';
@@ -9,9 +9,12 @@ import { CommonDaoTransaction } from './commonDaoTransaction.js';
9
9
  /**
10
10
  * Lowest common denominator API between supported Databases.
11
11
  *
12
- * DBM = Database model (how it's stored in DB)
13
12
  * BM = Backend model (optimized for API access)
13
+ * DBM = Database model (logical representation, before compression)
14
14
  * TM = Transport model (optimized to be sent over the wire)
15
+ *
16
+ * Note: When auto-compression is enabled, the physical storage format differs from DBM.
17
+ * Compression/decompression is handled transparently at the storage boundary.
15
18
  */
16
19
  export declare class CommonDao<BM extends BaseDBEntity, DBM extends BaseDBEntity = BM, ID extends string = BM['id']> {
17
20
  cfg: CommonDaoCfg<BM, DBM, ID>;
@@ -46,7 +49,6 @@ export declare class CommonDao<BM extends BaseDBEntity, DBM extends BaseDBEntity
46
49
  runQueryCount(q: DBQuery<DBM>, opt?: CommonDaoReadOptions): Promise<number>;
47
50
  streamQueryAsDBM(q: DBQuery<DBM>, opt?: CommonDaoStreamOptions<DBM>): Pipeline<DBM>;
48
51
  streamQuery(q: DBQuery<DBM>, opt?: CommonDaoStreamOptions<BM>): Pipeline<BM>;
49
- private streamQueryRaw;
50
52
  queryIds(q: DBQuery<DBM>, opt?: CommonDaoReadOptions): Promise<ID[]>;
51
53
  streamQueryIds(q: DBQuery<DBM>, opt?: CommonDaoStreamOptions<ID>): Pipeline<ID>;
52
54
  /**
@@ -131,14 +133,43 @@ export declare class CommonDao<BM extends BaseDBEntity, DBM extends BaseDBEntity
131
133
  bmToDBM(bm: undefined, opt?: CommonDaoOptions): Promise<null>;
132
134
  bmToDBM(bm?: BM, opt?: CommonDaoOptions): Promise<DBM>;
133
135
  bmsToDBM(bms: BM[], opt?: CommonDaoOptions): Promise<DBM[]>;
136
+ /**
137
+ * Converts a DBM to storage format, applying compression if configured.
138
+ *
139
+ * Use this when you need to write directly to the database, bypassing the DAO save methods.
140
+ * The returned value is opaque and should only be passed to db.saveBatch() or similar.
141
+ *
142
+ * @example
143
+ * const storageRow = await dao.dbmToStorageRow(dbm)
144
+ * await db.saveBatch(table, [storageRow])
145
+ */
146
+ dbmToStorageRow(dbm: DBM): Promise<ObjectWithId>;
147
+ /**
148
+ * Converts multiple DBMs to storage rows.
149
+ */
150
+ dbmsToStorageRows(dbms: DBM[]): Promise<ObjectWithId[]>;
151
+ /**
152
+ * Converts a storage row back to a DBM, applying decompression if needed.
153
+ *
154
+ * Use this when you need to read directly from the database, bypassing the DAO load methods.
155
+ *
156
+ * @example
157
+ * const rows = await db.getByIds(table, ids)
158
+ * const dbms = await Promise.all(rows.map(row => dao.storageRowToDBM(row)))
159
+ */
160
+ storageRowToDBM(row: ObjectWithId): Promise<DBM>;
161
+ /**
162
+ * Converts multiple storage rows to DBMs.
163
+ */
164
+ storageRowsToDBMs(rows: ObjectWithId[]): Promise<DBM[]>;
134
165
  /**
135
166
  * Mutates `dbm`.
136
167
  */
137
- compress(dbm: DBM): Promise<void>;
168
+ private compress;
138
169
  /**
139
170
  * Mutates `dbm`.
140
171
  */
141
- decompress(dbm: DBM): Promise<void>;
172
+ private decompress;
142
173
  anyToDBM(dbm: undefined, opt?: CommonDaoOptions): Promise<null>;
143
174
  anyToDBM(dbm?: any, opt?: CommonDaoOptions): Promise<DBM>;
144
175
  anyToDBMs(rows: DBM[], opt?: CommonDaoOptions): Promise<DBM[]>;
@@ -158,6 +189,43 @@ export declare class CommonDao<BM extends BaseDBEntity, DBM extends BaseDBEntity
158
189
  withIds(ids: ID[]): DaoWithIds<CommonDao<BM, DBM, ID>>;
159
190
  withRowsToSave(rows: Unsaved<BM>[]): DaoWithRows<CommonDao<BM, DBM, ID>>;
160
191
  withRowToSave(row: Unsaved<BM>, opt?: DaoWithRowOptions<BM>): DaoWithRow<CommonDao<BM, DBM, ID>>;
192
+ /**
193
+ * Helper to decompress legacy compressed data when migrating away from auto-compression.
194
+ * Use as your `beforeDBMToBM` hook to decompress legacy rows on read.
195
+ *
196
+ * @example
197
+ * const dao = new CommonDao({
198
+ * hooks: {
199
+ * beforeDBMToBM: CommonDao.decompressLegacyRow,
200
+ * }
201
+ * })
202
+ *
203
+ * // Or within an existing hook:
204
+ * beforeDBMToBM: async (dbm) => {
205
+ * await CommonDao.decompressLegacyRow(dbm)
206
+ * // ... other transformations
207
+ * return dbm
208
+ * }
209
+ */
210
+ static decompressLegacyRow<T extends ObjectWithId>(row: T): Promise<T>;
211
+ /**
212
+ * Temporary helper to migrate from the old `data` compressed property to the new `__compressed` property.
213
+ * Use as your `beforeDBMToBM` hook during the migration period.
214
+ *
215
+ * Migration steps:
216
+ * 1. Add `beforeDBMToBM: CommonDao.migrateCompressedDataProperty` to your hooks
217
+ * 2. Deploy - old data (with `data` property) will be decompressed on read and recompressed to `__compressed` on write
218
+ * 3. Once all data has been naturally rewritten, remove the hook
219
+ *
220
+ * @example
221
+ * const dao = new CommonDao({
222
+ * compress: { keys: ['field1', 'field2'] },
223
+ * hooks: {
224
+ * beforeDBMToBM: CommonDao.migrateCompressedDataProperty,
225
+ * }
226
+ * })
227
+ */
228
+ static migrateCompressedDataProperty<T extends ObjectWithId>(row: T): Promise<T>;
161
229
  /**
162
230
  * Load rows (by their ids) from Multiple tables at once.
163
231
  * An optimized way to load data, minimizing DB round-trips.
@@ -14,9 +14,12 @@ import { CommonDaoTransaction } from './commonDaoTransaction.js';
14
14
  /**
15
15
  * Lowest common denominator API between supported Databases.
16
16
  *
17
- * DBM = Database model (how it's stored in DB)
18
17
  * BM = Backend model (optimized for API access)
18
+ * DBM = Database model (logical representation, before compression)
19
19
  * TM = Transport model (optimized to be sent over the wire)
20
+ *
21
+ * Note: When auto-compression is enabled, the physical storage format differs from DBM.
22
+ * Compression/decompression is handled transparently at the storage boundary.
20
23
  */
21
24
  export class CommonDao {
22
25
  cfg;
@@ -44,6 +47,15 @@ export class CommonDao {
44
47
  else {
45
48
  delete this.cfg.hooks.createRandomId;
46
49
  }
50
+ // If the auto-compression is enabled,
51
+ // then we need to ensure that the '__compressed' property is part of the index exclusion list.
52
+ if (this.cfg.compress?.keys) {
53
+ const current = this.cfg.excludeFromIndexes;
54
+ this.cfg.excludeFromIndexes = current ? [...current] : [];
55
+ if (!this.cfg.excludeFromIndexes.includes('__compressed')) {
56
+ this.cfg.excludeFromIndexes.push('__compressed');
57
+ }
58
+ }
47
59
  }
48
60
  // CREATE
49
61
  create(part = {}, opt = {}) {
@@ -92,7 +104,8 @@ export class CommonDao {
92
104
  if (!ids.length)
93
105
  return [];
94
106
  const table = opt.table || this.cfg.table;
95
- return await (opt.tx || this.cfg.db).getByIds(table, ids, opt);
107
+ const rows = await (opt.tx || this.cfg.db).getByIds(table, ids, opt);
108
+ return await this.storageRowsToDBMs(rows);
96
109
  }
97
110
  async getBy(by, value, limit = 0, opt) {
98
111
  return await this.query().filterEq(by, value).limit(limit).runQuery(opt);
@@ -133,8 +146,9 @@ export class CommonDao {
133
146
  async runQueryExtended(q, opt = {}) {
134
147
  this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
135
148
  q.table = opt.table || q.table;
136
- const { rows, ...queryResult } = await this.cfg.db.runQuery(q, opt);
149
+ const { rows: rawRows, ...queryResult } = await this.cfg.db.runQuery(q, opt);
137
150
  const isPartialQuery = !!q._selectedFieldNames;
151
+ const rows = isPartialQuery ? rawRows : await this.storageRowsToDBMs(rawRows);
138
152
  const bms = isPartialQuery ? rows : await this.dbmsToBM(rows, opt);
139
153
  return {
140
154
  rows: bms,
@@ -148,8 +162,9 @@ export class CommonDao {
148
162
  async runQueryExtendedAsDBM(q, opt = {}) {
149
163
  this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
150
164
  q.table = opt.table || q.table;
151
- const { rows, ...queryResult } = await this.cfg.db.runQuery(q, opt);
165
+ const { rows: rawRows, ...queryResult } = await this.cfg.db.runQuery(q, opt);
152
166
  const isPartialQuery = !!q._selectedFieldNames;
167
+ const rows = isPartialQuery ? rawRows : await this.storageRowsToDBMs(rawRows);
153
168
  const dbms = isPartialQuery ? rows : await this.anyToDBMs(rows, opt);
154
169
  return { rows: dbms, ...queryResult };
155
170
  }
@@ -159,7 +174,12 @@ export class CommonDao {
159
174
  return await this.cfg.db.runQueryCount(q, opt);
160
175
  }
161
176
  streamQueryAsDBM(q, opt = {}) {
162
- const pipeline = this.streamQueryRaw(q, opt);
177
+ this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
178
+ q.table = opt.table || q.table;
179
+ let pipeline = this.cfg.db.streamQuery(q, opt);
180
+ if (this.cfg.compress?.keys.length) {
181
+ pipeline = pipeline.map(async (row) => await this.storageRowToDBM(row));
182
+ }
163
183
  const isPartialQuery = !!q._selectedFieldNames;
164
184
  if (isPartialQuery)
165
185
  return pipeline;
@@ -168,7 +188,12 @@ export class CommonDao {
168
188
  return pipeline.map(async (dbm) => await this.anyToDBM(dbm, opt), { errorMode: opt.errorMode });
169
189
  }
170
190
  streamQuery(q, opt = {}) {
171
- const pipeline = this.streamQueryRaw(q, opt);
191
+ this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
192
+ q.table = opt.table || q.table;
193
+ let pipeline = this.cfg.db.streamQuery(q, opt);
194
+ if (this.cfg.compress?.keys.length) {
195
+ pipeline = pipeline.map(async (row) => await this.storageRowToDBM(row));
196
+ }
172
197
  const isPartialQuery = !!q._selectedFieldNames;
173
198
  if (isPartialQuery)
174
199
  return pipeline;
@@ -176,11 +201,6 @@ export class CommonDao {
176
201
  opt.errorMode ||= ErrorMode.SUPPRESS;
177
202
  return pipeline.map(async (dbm) => await this.dbmToBM(dbm, opt), { errorMode: opt.errorMode });
178
203
  }
179
- streamQueryRaw(q, opt = {}) {
180
- this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
181
- q.table = opt.table || q.table;
182
- return this.cfg.db.streamQuery(q, opt);
183
- }
184
204
  async queryIds(q, opt = {}) {
185
205
  this.validateQueryIndexes(q); // throws if query uses `excludeFromIndexes` property
186
206
  q.table = opt.table || q.table;
@@ -331,7 +351,8 @@ export class CommonDao {
331
351
  this.cfg.hooks.beforeSave?.(dbm);
332
352
  const table = opt.table || this.cfg.table;
333
353
  const saveOptions = this.prepareSaveOptions(opt);
334
- await (opt.tx || this.cfg.db).saveBatch(table, [dbm], saveOptions);
354
+ const row = await this.dbmToStorageRow(dbm);
355
+ await (opt.tx || this.cfg.db).saveBatch(table, [row], saveOptions);
335
356
  if (saveOptions.assignGeneratedIds) {
336
357
  bm.id = dbm.id;
337
358
  }
@@ -340,15 +361,16 @@ export class CommonDao {
340
361
  async saveAsDBM(dbm, opt = {}) {
341
362
  this.requireWriteAccess();
342
363
  this.assignIdCreatedUpdated(dbm, opt); // mutates
343
- const row = await this.anyToDBM(dbm, opt);
344
- this.cfg.hooks.beforeSave?.(row);
364
+ const validDbm = await this.anyToDBM(dbm, opt);
365
+ this.cfg.hooks.beforeSave?.(validDbm);
345
366
  const table = opt.table || this.cfg.table;
346
367
  const saveOptions = this.prepareSaveOptions(opt);
368
+ const row = await this.dbmToStorageRow(validDbm);
347
369
  await (opt.tx || this.cfg.db).saveBatch(table, [row], saveOptions);
348
370
  if (saveOptions.assignGeneratedIds) {
349
- dbm.id = row.id;
371
+ dbm.id = validDbm.id;
350
372
  }
351
- return row;
373
+ return validDbm;
352
374
  }
353
375
  async saveBatch(bms, opt = {}) {
354
376
  if (!bms.length)
@@ -361,7 +383,8 @@ export class CommonDao {
361
383
  }
362
384
  const table = opt.table || this.cfg.table;
363
385
  const saveOptions = this.prepareSaveOptions(opt);
364
- await (opt.tx || this.cfg.db).saveBatch(table, dbms, saveOptions);
386
+ const rows = await this.dbmsToStorageRows(dbms);
387
+ await (opt.tx || this.cfg.db).saveBatch(table, rows, saveOptions);
365
388
  if (saveOptions.assignGeneratedIds) {
366
389
  dbms.forEach((dbm, i) => (bms[i].id = dbm.id));
367
390
  }
@@ -372,26 +395,36 @@ export class CommonDao {
372
395
  return [];
373
396
  this.requireWriteAccess();
374
397
  dbms.forEach(dbm => this.assignIdCreatedUpdated(dbm, opt));
375
- const rows = await this.anyToDBMs(dbms, opt);
398
+ const validDbms = await this.anyToDBMs(dbms, opt);
376
399
  if (this.cfg.hooks.beforeSave) {
377
- rows.forEach(row => this.cfg.hooks.beforeSave(row));
400
+ validDbms.forEach(dbm => this.cfg.hooks.beforeSave(dbm));
378
401
  }
379
402
  const table = opt.table || this.cfg.table;
380
403
  const saveOptions = this.prepareSaveOptions(opt);
404
+ const rows = await this.dbmsToStorageRows(validDbms);
381
405
  await (opt.tx || this.cfg.db).saveBatch(table, rows, saveOptions);
382
406
  if (saveOptions.assignGeneratedIds) {
383
- rows.forEach((row, i) => (dbms[i].id = row.id));
407
+ validDbms.forEach((dbm, i) => (dbms[i].id = dbm.id));
384
408
  }
385
- return rows;
409
+ return validDbms;
386
410
  }
387
411
  prepareSaveOptions(opt) {
388
412
  let { saveMethod, assignGeneratedIds = this.cfg.assignGeneratedIds, excludeFromIndexes = this.cfg.excludeFromIndexes, } = opt;
413
+ // If the user passed in custom `excludeFromIndexes` with the save() call,
414
+ // and the auto-compression is enabled,
415
+ // then we need to ensure that the '__compressed' property is part of the list.
416
+ if (this.cfg.compress?.keys) {
417
+ excludeFromIndexes ??= [];
418
+ if (!excludeFromIndexes.includes('__compressed')) {
419
+ excludeFromIndexes.push('__compressed');
420
+ }
421
+ }
389
422
  if (this.cfg.immutable && !opt.allowMutability && !opt.saveMethod) {
390
423
  saveMethod = 'insert';
391
424
  }
392
425
  return {
393
426
  ...opt,
394
- excludeFromIndexes,
427
+ excludeFromIndexes: excludeFromIndexes,
395
428
  saveMethod,
396
429
  assignGeneratedIds,
397
430
  };
@@ -410,10 +443,7 @@ export class CommonDao {
410
443
  const table = opt.table || this.cfg.table;
411
444
  opt.skipValidation ??= true;
412
445
  opt.errorMode ||= ErrorMode.SUPPRESS;
413
- if (this.cfg.immutable && !opt.allowMutability && !opt.saveMethod) {
414
- opt = { ...opt, saveMethod: 'insert' };
415
- }
416
- const excludeFromIndexes = opt.excludeFromIndexes || this.cfg.excludeFromIndexes;
446
+ const saveOptions = this.prepareSaveOptions(opt);
417
447
  const { beforeSave } = this.cfg.hooks;
418
448
  const { chunkSize = 500, chunkConcurrency = 32, errorMode } = opt;
419
449
  await p
@@ -421,14 +451,11 @@ export class CommonDao {
421
451
  this.assignIdCreatedUpdated(bm, opt);
422
452
  const dbm = await this.bmToDBM(bm, opt);
423
453
  beforeSave?.(dbm);
424
- return dbm;
454
+ return await this.dbmToStorageRow(dbm);
425
455
  }, { errorMode })
426
456
  .chunk(chunkSize)
427
457
  .map(async (batch) => {
428
- await this.cfg.db.saveBatch(table, batch, {
429
- ...opt,
430
- excludeFromIndexes,
431
- });
458
+ await this.cfg.db.saveBatch(table, batch, saveOptions);
432
459
  return batch;
433
460
  }, {
434
461
  concurrency: chunkConcurrency,
@@ -539,8 +566,6 @@ export class CommonDao {
539
566
  // optimization: no need to run full joi DBM validation, cause BM validation will be run
540
567
  // const dbm = this.anyToDBM(_dbm, opt)
541
568
  const dbm = { ..._dbm, ...this.cfg.hooks.parseNaturalId(_dbm.id) };
542
- // Decompress
543
- await this.decompress(dbm);
544
569
  // DBM > BM
545
570
  const bm = ((await this.cfg.hooks.beforeDBMToBM?.(dbm)) || dbm);
546
571
  // Validate/convert BM
@@ -556,15 +581,65 @@ export class CommonDao {
556
581
  bm = this.validateAndConvert(bm, 'save', opt);
557
582
  // BM > DBM
558
583
  const dbm = ((await this.cfg.hooks.beforeBMToDBM?.(bm)) || bm);
559
- // Compress
560
- if (this.cfg.compress)
561
- await this.compress(dbm);
562
584
  return dbm;
563
585
  }
564
586
  async bmsToDBM(bms, opt = {}) {
565
587
  // try/catch?
566
588
  return await pMap(bms, async (bm) => await this.bmToDBM(bm, opt));
567
589
  }
590
+ // STORAGE LAYER (compression/decompression at DB boundary)
591
+ // These methods convert between DBM (logical model) and storage format (physical, possibly compressed).
592
+ // Public methods allow external code to bypass the DAO layer for direct DB access
593
+ // (e.g., cross-environment data copy).
594
+ /**
595
+ * Converts a DBM to storage format, applying compression if configured.
596
+ *
597
+ * Use this when you need to write directly to the database, bypassing the DAO save methods.
598
+ * The returned value is opaque and should only be passed to db.saveBatch() or similar.
599
+ *
600
+ * @example
601
+ * const storageRow = await dao.dbmToStorageRow(dbm)
602
+ * await db.saveBatch(table, [storageRow])
603
+ */
604
+ async dbmToStorageRow(dbm) {
605
+ if (!this.cfg.compress?.keys.length)
606
+ return dbm;
607
+ const row = { ...dbm };
608
+ await this.compress(row);
609
+ return row;
610
+ }
611
+ /**
612
+ * Converts multiple DBMs to storage rows.
613
+ */
614
+ async dbmsToStorageRows(dbms) {
615
+ if (!this.cfg.compress?.keys.length)
616
+ return dbms;
617
+ return await pMap(dbms, async (dbm) => await this.dbmToStorageRow(dbm));
618
+ }
619
+ /**
620
+ * Converts a storage row back to a DBM, applying decompression if needed.
621
+ *
622
+ * Use this when you need to read directly from the database, bypassing the DAO load methods.
623
+ *
624
+ * @example
625
+ * const rows = await db.getByIds(table, ids)
626
+ * const dbms = await Promise.all(rows.map(row => dao.storageRowToDBM(row)))
627
+ */
628
+ async storageRowToDBM(row) {
629
+ if (!this.cfg.compress?.keys.length)
630
+ return row;
631
+ const dbm = { ...row };
632
+ await this.decompress(dbm);
633
+ return dbm;
634
+ }
635
+ /**
636
+ * Converts multiple storage rows to DBMs.
637
+ */
638
+ async storageRowsToDBMs(rows) {
639
+ if (!this.cfg.compress?.keys.length)
640
+ return rows;
641
+ return await pMap(rows, async (row) => await this.storageRowToDBM(row));
642
+ }
568
643
  /**
569
644
  * Mutates `dbm`.
570
645
  */
@@ -573,26 +648,22 @@ export class CommonDao {
573
648
  return; // No compression requested
574
649
  const { keys } = this.cfg.compress;
575
650
  const properties = _pick(dbm, keys);
576
- _assert(!('data' in dbm) || 'data' in properties, `Data (${dbm.id}) already has a "data" property. When using compression, this property must be included in the compression keys list.`);
577
651
  const bufferString = JSON.stringify(properties);
578
- const data = await zstdCompress(bufferString);
652
+ const __compressed = await zstdCompress(bufferString);
579
653
  _omitWithUndefined(dbm, _objectKeys(properties), { mutate: true });
580
- Object.assign(dbm, { data });
654
+ Object.assign(dbm, { __compressed });
581
655
  }
582
656
  /**
583
657
  * Mutates `dbm`.
584
658
  */
585
659
  async decompress(dbm) {
586
660
  _typeCast(dbm);
587
- if (!this.cfg.compress)
588
- return; // Auto-compression not turned on
589
- if (!Buffer.isBuffer(dbm.data))
661
+ if (!Buffer.isBuffer(dbm.__compressed))
590
662
  return; // No compressed data
591
- // try-catch to avoid a `data` with Buffer which is not compressed, but legit data
592
663
  try {
593
- const bufferString = await decompressZstdOrInflateToString(dbm.data);
664
+ const bufferString = await decompressZstdOrInflateToString(dbm.__compressed);
594
665
  const properties = JSON.parse(bufferString);
595
- dbm.data = undefined;
666
+ dbm.__compressed = undefined;
596
667
  Object.assign(dbm, properties);
597
668
  }
598
669
  catch { }
@@ -603,8 +674,6 @@ export class CommonDao {
603
674
  // this shouldn't be happening on load! but should on save!
604
675
  // this.assignIdCreatedUpdated(dbm, opt)
605
676
  dbm = { ...dbm, ...this.cfg.hooks.parseNaturalId(dbm.id) };
606
- // Decompress
607
- await this.decompress(dbm);
608
677
  // Validate/convert DBM
609
678
  // return this.validateAndConvert(dbm, this.cfg.dbmSchema, DBModelType.DBM, opt)
610
679
  return dbm;
@@ -682,6 +751,73 @@ export class CommonDao {
682
751
  opt: opt,
683
752
  };
684
753
  }
754
+ /**
755
+ * Helper to decompress legacy compressed data when migrating away from auto-compression.
756
+ * Use as your `beforeDBMToBM` hook to decompress legacy rows on read.
757
+ *
758
+ * @example
759
+ * const dao = new CommonDao({
760
+ * hooks: {
761
+ * beforeDBMToBM: CommonDao.decompressLegacyRow,
762
+ * }
763
+ * })
764
+ *
765
+ * // Or within an existing hook:
766
+ * beforeDBMToBM: async (dbm) => {
767
+ * await CommonDao.decompressLegacyRow(dbm)
768
+ * // ... other transformations
769
+ * return dbm
770
+ * }
771
+ */
772
+ static async decompressLegacyRow(row) {
773
+ // Check both __compressed (current) and data (legacy) for backward compatibility
774
+ const compressed = row.__compressed ?? row.data;
775
+ if (!Buffer.isBuffer(compressed))
776
+ return row;
777
+ try {
778
+ const bufferString = await decompressZstdOrInflateToString(compressed);
779
+ const properties = JSON.parse(bufferString);
780
+ row.__compressed = undefined;
781
+ row.data = undefined;
782
+ Object.assign(row, properties);
783
+ }
784
+ catch {
785
+ // Decompression failed - field is not compressed, leave as-is
786
+ }
787
+ return row;
788
+ }
789
+ /**
790
+ * Temporary helper to migrate from the old `data` compressed property to the new `__compressed` property.
791
+ * Use as your `beforeDBMToBM` hook during the migration period.
792
+ *
793
+ * Migration steps:
794
+ * 1. Add `beforeDBMToBM: CommonDao.migrateCompressedDataProperty` to your hooks
795
+ * 2. Deploy - old data (with `data` property) will be decompressed on read and recompressed to `__compressed` on write
796
+ * 3. Once all data has been naturally rewritten, remove the hook
797
+ *
798
+ * @example
799
+ * const dao = new CommonDao({
800
+ * compress: { keys: ['field1', 'field2'] },
801
+ * hooks: {
802
+ * beforeDBMToBM: CommonDao.migrateCompressedDataProperty,
803
+ * }
804
+ * })
805
+ */
806
+ static async migrateCompressedDataProperty(row) {
807
+ const data = row.data;
808
+ if (!Buffer.isBuffer(data))
809
+ return row;
810
+ try {
811
+ const bufferString = await decompressZstdOrInflateToString(data);
812
+ const properties = JSON.parse(bufferString);
813
+ row.data = undefined;
814
+ Object.assign(row, properties);
815
+ }
816
+ catch {
817
+ // Decompression failed - data field is not compressed, leave as-is
818
+ }
819
+ return row;
820
+ }
685
821
  /**
686
822
  * Load rows (by their ids) from Multiple tables at once.
687
823
  * An optimized way to load data, minimizing DB round-trips.
@@ -742,14 +878,18 @@ export class CommonDao {
742
878
  const { table } = dao.cfg;
743
879
  if ('id' in input) {
744
880
  // Singular
745
- const dbm = dbmByTableById[table][input.id];
881
+ const row = dbmByTableById[table][input.id];
882
+ // Decompress before converting to BM
883
+ const dbm = row ? await dao.storageRowToDBM(row) : undefined;
746
884
  bmsByProp[prop] = (await dao.dbmToBM(dbm, opt)) || null;
747
885
  }
748
886
  else {
749
887
  // Plural
750
888
  // We apply filtering, to be able to support multiple input props fetching from the same table.
751
889
  // Without filtering - every prop will get ALL rows from that table.
752
- const dbms = input.ids.map(id => dbmByTableById[table][id]).filter(_isTruthy);
890
+ const rows = input.ids.map(id => dbmByTableById[table][id]).filter(_isTruthy);
891
+ // Decompress before converting to BM
892
+ const dbms = await dao.storageRowsToDBMs(rows);
753
893
  bmsByProp[prop] = await dao.dbmsToBM(dbms, opt);
754
894
  }
755
895
  });
@@ -803,7 +943,8 @@ export class CommonDao {
803
943
  dao.assignIdCreatedUpdated(row, opt);
804
944
  const dbm = await dao.bmToDBM(row, opt);
805
945
  dao.cfg.hooks.beforeSave?.(dbm);
806
- dbmsByTable[table].push(dbm);
946
+ const storageRow = await dao.dbmToStorageRow(dbm);
947
+ dbmsByTable[table].push(storageRow);
807
948
  }
808
949
  else {
809
950
  // Plural
@@ -812,7 +953,8 @@ export class CommonDao {
812
953
  if (dao.cfg.hooks.beforeSave) {
813
954
  dbms.forEach(dbm => dao.cfg.hooks.beforeSave(dbm));
814
955
  }
815
- dbmsByTable[table].push(...dbms);
956
+ const storageRows = await dao.dbmsToStorageRows(dbms);
957
+ dbmsByTable[table].push(...storageRows);
816
958
  }
817
959
  });
818
960
  await db.multiSave(dbmsByTable);
@@ -168,14 +168,15 @@ export interface CommonDaoCfg<BM extends BaseDBEntity, DBM extends BaseDBEntity
168
168
  */
169
169
  patchInTransaction?: boolean;
170
170
  /**
171
- * When specified, the listed properties will be compressed under a `data` property in the DBM.
172
- * If DBM already has a `data` property and you don't add it to the list, an error will be thrown.
173
- *
174
- * When specified with an empty `keys` list, then compression will be skipped, but all previously compressed data
175
- * will be decompressed, so the Dao can still work.
171
+ * When specified, the listed properties will be compressed into the `__compressed` property.
176
172
  *
177
173
  * Compression happens after the `beforeBMToDBM` hook and before the DBM is saved to the database.
178
174
  * Decompression happens after the DBM is loaded from the database and before the `beforeDBMToBM` hook.
175
+ *
176
+ * To migrate away from compression:
177
+ * 1. Remove this config (or set `keys` to empty array)
178
+ * 2. Add `beforeDBMToBM: CommonDao.decompressLegacyRow` to your hooks to decompress legacy data on read
179
+ * 3. Once all data has been naturally rewritten without compression, remove the hook
179
180
  */
180
181
  compress?: {
181
182
  keys: (keyof DBM)[];
@@ -1,5 +1,5 @@
1
1
  import type { CommonLogger } from '@naturalcycles/js-lib/log';
2
- import { type AnyObjectWithId, type ObjectWithId, type StringMap } from '@naturalcycles/js-lib/types';
2
+ import type { AnyObjectWithId, ObjectWithId, StringMap } from '@naturalcycles/js-lib/types';
3
3
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv';
4
4
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
5
5
  import type { CommonDB, CommonDBSupport } from '../commondb/common.db.js';
@@ -1,7 +1,7 @@
1
1
  import { _isEmptyObject } from '@naturalcycles/js-lib';
2
2
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
3
3
  import { _deepCopy, _sortObjectDeep } from '@naturalcycles/js-lib/object';
4
- import { _stringMapEntries, _stringMapValues, } from '@naturalcycles/js-lib/types';
4
+ import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types';
5
5
  import { generateJsonSchemaFromData } from '@naturalcycles/nodejs-lib/ajv';
6
6
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
7
7
  import { bufferReviver } from '@naturalcycles/nodejs-lib/stream/ndjson/transformJsonParse.js';
@@ -1,5 +1,5 @@
1
1
  import type { CommonLogger } from '@naturalcycles/js-lib/log';
2
- import { type Integer, type KeyValueTuple } from '@naturalcycles/js-lib/types';
2
+ import type { Integer, KeyValueTuple } from '@naturalcycles/js-lib/types';
3
3
  import type { Pipeline } from '@naturalcycles/nodejs-lib/stream';
4
4
  import type { CommonDaoLogLevel } from '../commondao/common.dao.model.js';
5
5
  import type { CommonDBCreateOptions } from '../db.model.js';
@@ -1,6 +1,7 @@
1
1
  import { ErrorMode } from '@naturalcycles/js-lib/error/errorMode.js';
2
2
  import type { AsyncMapper, UnixTimestamp } from '@naturalcycles/js-lib/types';
3
- import { NDJsonStats, type TransformLogProgressOptions, type TransformMapOptions } from '@naturalcycles/nodejs-lib/stream';
3
+ import { NDJsonStats } from '@naturalcycles/nodejs-lib/stream';
4
+ import type { TransformLogProgressOptions, TransformMapOptions } from '@naturalcycles/nodejs-lib/stream';
4
5
  import type { CommonDB } from '../commondb/common.db.js';
5
6
  import type { CommonDBSaveOptions } from '../db.model.js';
6
7
  export interface DBPipelineRestoreOptions extends TransformLogProgressOptions {
@@ -6,7 +6,7 @@ import { pMap } from '@naturalcycles/js-lib/promise/pMap.js';
6
6
  import { _passthroughMapper } from '@naturalcycles/js-lib/types';
7
7
  import { boldWhite, dimWhite, grey, yellow } from '@naturalcycles/nodejs-lib/colors';
8
8
  import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
9
- import { NDJsonStats, Pipeline, } from '@naturalcycles/nodejs-lib/stream';
9
+ import { NDJsonStats, Pipeline } from '@naturalcycles/nodejs-lib/stream';
10
10
  /**
11
11
  * Pipeline from NDJSON files in a folder (optionally gzipped) to CommonDB.
12
12
  * Allows to define a mapper and a predicate to map/filter objects between input and output.
@@ -1,5 +1,5 @@
1
1
  import type { BaseDBEntity } from '@naturalcycles/js-lib/types';
2
- import { type JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv';
2
+ import type { JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv';
3
3
  export declare const TEST_TABLE = "TEST_TABLE";
4
4
  export declare const TEST_TABLE_2 = "TEST_TABLE_2";
5
5
  export interface TestItemBM extends BaseDBEntity {
@@ -1,5 +1,5 @@
1
1
  import type { ObjectWithId } from '@naturalcycles/js-lib/types';
2
- import { type JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv';
2
+ import type { JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv';
3
3
  import type { CommonDBOptions } from '../db.model.js';
4
4
  export declare const commonDBOptionsSchema: () => JsonSchemaObjectBuilder<CommonDBOptions, CommonDBOptions>;
5
5
  export declare const commonDBSaveOptionsSchema: <ROW extends ObjectWithId>() => any;
@@ -1,5 +1,5 @@
1
- import { j, JsonSchemaAnyBuilder, } from '@naturalcycles/nodejs-lib/ajv';
2
- import { dbQueryFilterOperatorValues, } from '../query/dbQuery.js';
1
+ import { j, JsonSchemaAnyBuilder } from '@naturalcycles/nodejs-lib/ajv';
2
+ import { dbQueryFilterOperatorValues } from '../query/dbQuery.js';
3
3
  // oxlint-disable typescript/explicit-function-return-type
4
4
  // DBTransaction schema - validates presence without deep validation
5
5
  const dbTransactionSchema = j.object.any().castAs();
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@naturalcycles/db-lib",
3
3
  "type": "module",
4
- "version": "10.41.1",
4
+ "version": "10.42.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@naturalcycles/nodejs-lib": "^15"
8
8
  },
9
9
  "devDependencies": {
10
+ "@typescript/native-preview": "^7.0.0-0",
10
11
  "@naturalcycles/dev-lib": "18.4.2"
11
12
  },
12
13
  "files": [
@@ -3,11 +3,8 @@ import { _by, _sortBy } from '@naturalcycles/js-lib/array'
3
3
  import { _since, localTime } from '@naturalcycles/js-lib/datetime'
4
4
  import { _assert } from '@naturalcycles/js-lib/error/assert.js'
5
5
  import { _deepEquals, _filterUndefinedValues, _sortObjectDeep } from '@naturalcycles/js-lib/object'
6
- import {
7
- _stringMapValues,
8
- type ObjectWithId,
9
- type UnixTimestampMillis,
10
- } from '@naturalcycles/js-lib/types'
6
+ import { _stringMapValues } from '@naturalcycles/js-lib/types'
7
+ import type { ObjectWithId, UnixTimestampMillis } from '@naturalcycles/js-lib/types'
11
8
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv'
12
9
  import { generateJsonSchemaFromData } from '@naturalcycles/nodejs-lib/ajv'
13
10
  import { dimGrey } from '@naturalcycles/nodejs-lib/colors'
@@ -207,14 +207,15 @@ export interface CommonDaoCfg<
207
207
  patchInTransaction?: boolean
208
208
 
209
209
  /**
210
- * When specified, the listed properties will be compressed under a `data` property in the DBM.
211
- * If DBM already has a `data` property and you don't add it to the list, an error will be thrown.
212
- *
213
- * When specified with an empty `keys` list, then compression will be skipped, but all previously compressed data
214
- * will be decompressed, so the Dao can still work.
210
+ * When specified, the listed properties will be compressed into the `__compressed` property.
215
211
  *
216
212
  * Compression happens after the `beforeBMToDBM` hook and before the DBM is saved to the database.
217
213
  * Decompression happens after the DBM is loaded from the database and before the `beforeDBMToBM` hook.
214
+ *
215
+ * To migrate away from compression:
216
+ * 1. Remove this config (or set `keys` to empty array)
217
+ * 2. Add `beforeDBMToBM: CommonDao.decompressLegacyRow` to your hooks to decompress legacy data on read
218
+ * 3. Once all data has been naturally rewritten without compression, remove the hook
218
219
  */
219
220
  compress?: {
220
221
  keys: (keyof DBM)[]
@@ -16,11 +16,13 @@ import {
16
16
  _stringMapEntries,
17
17
  _stringMapValues,
18
18
  _typeCast,
19
- type BaseDBEntity,
20
- type NonNegativeInteger,
21
- type ObjectWithId,
22
- type StringMap,
23
- type Unsaved,
19
+ } from '@naturalcycles/js-lib/types'
20
+ import type {
21
+ BaseDBEntity,
22
+ NonNegativeInteger,
23
+ ObjectWithId,
24
+ StringMap,
25
+ Unsaved,
24
26
  } from '@naturalcycles/js-lib/types'
25
27
  import { stringId } from '@naturalcycles/nodejs-lib'
26
28
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv'
@@ -53,9 +55,12 @@ import { CommonDaoTransaction } from './commonDaoTransaction.js'
53
55
  /**
54
56
  * Lowest common denominator API between supported Databases.
55
57
  *
56
- * DBM = Database model (how it's stored in DB)
57
58
  * BM = Backend model (optimized for API access)
59
+ * DBM = Database model (logical representation, before compression)
58
60
  * TM = Transport model (optimized to be sent over the wire)
61
+ *
62
+ * Note: When auto-compression is enabled, the physical storage format differs from DBM.
63
+ * Compression/decompression is handled transparently at the storage boundary.
59
64
  */
60
65
  export class CommonDao<
61
66
  BM extends BaseDBEntity,
@@ -85,6 +90,16 @@ export class CommonDao<
85
90
  } else {
86
91
  delete this.cfg.hooks!.createRandomId
87
92
  }
93
+
94
+ // If the auto-compression is enabled,
95
+ // then we need to ensure that the '__compressed' property is part of the index exclusion list.
96
+ if (this.cfg.compress?.keys) {
97
+ const current = this.cfg.excludeFromIndexes
98
+ this.cfg.excludeFromIndexes = current ? [...current] : []
99
+ if (!this.cfg.excludeFromIndexes.includes('__compressed' as any)) {
100
+ this.cfg.excludeFromIndexes.push('__compressed' as any)
101
+ }
102
+ }
88
103
  }
89
104
 
90
105
  // CREATE
@@ -139,7 +154,8 @@ export class CommonDao<
139
154
  private async loadByIds(ids: ID[], opt: CommonDaoReadOptions = {}): Promise<DBM[]> {
140
155
  if (!ids.length) return []
141
156
  const table = opt.table || this.cfg.table
142
- return await (opt.tx || this.cfg.db).getByIds<DBM>(table, ids, opt)
157
+ const rows = await (opt.tx || this.cfg.db).getByIds<DBM>(table, ids, opt)
158
+ return await this.storageRowsToDBMs(rows)
143
159
  }
144
160
 
145
161
  async getBy(by: keyof DBM, value: any, limit = 0, opt?: CommonDaoReadOptions): Promise<BM[]> {
@@ -198,8 +214,9 @@ export class CommonDao<
198
214
  ): Promise<RunQueryResult<BM>> {
199
215
  this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
200
216
  q.table = opt.table || q.table
201
- const { rows, ...queryResult } = await this.cfg.db.runQuery<DBM>(q, opt)
217
+ const { rows: rawRows, ...queryResult } = await this.cfg.db.runQuery<DBM>(q, opt)
202
218
  const isPartialQuery = !!q._selectedFieldNames
219
+ const rows = isPartialQuery ? rawRows : await this.storageRowsToDBMs(rawRows)
203
220
  const bms = isPartialQuery ? (rows as any[]) : await this.dbmsToBM(rows, opt)
204
221
  return {
205
222
  rows: bms,
@@ -218,8 +235,9 @@ export class CommonDao<
218
235
  ): Promise<RunQueryResult<DBM>> {
219
236
  this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
220
237
  q.table = opt.table || q.table
221
- const { rows, ...queryResult } = await this.cfg.db.runQuery<DBM>(q, opt)
238
+ const { rows: rawRows, ...queryResult } = await this.cfg.db.runQuery<DBM>(q, opt)
222
239
  const isPartialQuery = !!q._selectedFieldNames
240
+ const rows = isPartialQuery ? rawRows : await this.storageRowsToDBMs(rawRows)
223
241
  const dbms = isPartialQuery ? rows : await this.anyToDBMs(rows, opt)
224
242
  return { rows: dbms, ...queryResult }
225
243
  }
@@ -231,7 +249,13 @@ export class CommonDao<
231
249
  }
232
250
 
233
251
  streamQueryAsDBM(q: DBQuery<DBM>, opt: CommonDaoStreamOptions<DBM> = {}): Pipeline<DBM> {
234
- const pipeline = this.streamQueryRaw(q, opt)
252
+ this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
253
+ q.table = opt.table || q.table
254
+ let pipeline = this.cfg.db.streamQuery<DBM>(q, opt)
255
+
256
+ if (this.cfg.compress?.keys.length) {
257
+ pipeline = pipeline.map(async row => await this.storageRowToDBM(row))
258
+ }
235
259
 
236
260
  const isPartialQuery = !!q._selectedFieldNames
237
261
  if (isPartialQuery) return pipeline
@@ -243,7 +267,13 @@ export class CommonDao<
243
267
  }
244
268
 
245
269
  streamQuery(q: DBQuery<DBM>, opt: CommonDaoStreamOptions<BM> = {}): Pipeline<BM> {
246
- const pipeline = this.streamQueryRaw(q, opt)
270
+ this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
271
+ q.table = opt.table || q.table
272
+ let pipeline = this.cfg.db.streamQuery<DBM>(q, opt)
273
+
274
+ if (this.cfg.compress?.keys.length) {
275
+ pipeline = pipeline.map(async row => await this.storageRowToDBM(row))
276
+ }
247
277
 
248
278
  const isPartialQuery = !!q._selectedFieldNames
249
279
  if (isPartialQuery) return pipeline as any as Pipeline<BM>
@@ -254,12 +284,6 @@ export class CommonDao<
254
284
  return pipeline.map(async dbm => await this.dbmToBM(dbm, opt), { errorMode: opt.errorMode })
255
285
  }
256
286
 
257
- private streamQueryRaw(q: DBQuery<DBM>, opt: CommonDaoStreamOptions<any> = {}): Pipeline<DBM> {
258
- this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
259
- q.table = opt.table || q.table
260
- return this.cfg.db.streamQuery<DBM>(q, opt)
261
- }
262
-
263
287
  async queryIds(q: DBQuery<DBM>, opt: CommonDaoReadOptions = {}): Promise<ID[]> {
264
288
  this.validateQueryIndexes(q) // throws if query uses `excludeFromIndexes` property
265
289
  q.table = opt.table || q.table
@@ -447,7 +471,8 @@ export class CommonDao<
447
471
  const table = opt.table || this.cfg.table
448
472
  const saveOptions = this.prepareSaveOptions(opt)
449
473
 
450
- await (opt.tx || this.cfg.db).saveBatch(table, [dbm], saveOptions)
474
+ const row = await this.dbmToStorageRow(dbm)
475
+ await (opt.tx || this.cfg.db).saveBatch(table, [row], saveOptions)
451
476
 
452
477
  if (saveOptions.assignGeneratedIds) {
453
478
  bm.id = dbm.id
@@ -459,18 +484,19 @@ export class CommonDao<
459
484
  async saveAsDBM(dbm: Unsaved<DBM>, opt: CommonDaoSaveOptions<BM, DBM> = {}): Promise<DBM> {
460
485
  this.requireWriteAccess()
461
486
  this.assignIdCreatedUpdated(dbm, opt) // mutates
462
- const row = await this.anyToDBM(dbm, opt)
463
- this.cfg.hooks!.beforeSave?.(row)
487
+ const validDbm = await this.anyToDBM(dbm, opt)
488
+ this.cfg.hooks!.beforeSave?.(validDbm)
464
489
  const table = opt.table || this.cfg.table
465
490
  const saveOptions = this.prepareSaveOptions(opt)
466
491
 
492
+ const row = await this.dbmToStorageRow(validDbm)
467
493
  await (opt.tx || this.cfg.db).saveBatch(table, [row], saveOptions)
468
494
 
469
495
  if (saveOptions.assignGeneratedIds) {
470
- dbm.id = row.id
496
+ dbm.id = validDbm.id
471
497
  }
472
498
 
473
- return row
499
+ return validDbm
474
500
  }
475
501
 
476
502
  async saveBatch(bms: Unsaved<BM>[], opt: CommonDaoSaveBatchOptions<DBM> = {}): Promise<BM[]> {
@@ -484,7 +510,8 @@ export class CommonDao<
484
510
  const table = opt.table || this.cfg.table
485
511
  const saveOptions = this.prepareSaveOptions(opt)
486
512
 
487
- await (opt.tx || this.cfg.db).saveBatch(table, dbms, saveOptions)
513
+ const rows = await this.dbmsToStorageRows(dbms)
514
+ await (opt.tx || this.cfg.db).saveBatch(table, rows, saveOptions)
488
515
 
489
516
  if (saveOptions.assignGeneratedIds) {
490
517
  dbms.forEach((dbm, i) => (bms[i]!.id = dbm.id))
@@ -500,35 +527,49 @@ export class CommonDao<
500
527
  if (!dbms.length) return []
501
528
  this.requireWriteAccess()
502
529
  dbms.forEach(dbm => this.assignIdCreatedUpdated(dbm, opt))
503
- const rows = await this.anyToDBMs(dbms as DBM[], opt)
530
+ const validDbms = await this.anyToDBMs(dbms as DBM[], opt)
504
531
  if (this.cfg.hooks!.beforeSave) {
505
- rows.forEach(row => this.cfg.hooks!.beforeSave!(row))
532
+ validDbms.forEach(dbm => this.cfg.hooks!.beforeSave!(dbm))
506
533
  }
507
534
  const table = opt.table || this.cfg.table
508
535
  const saveOptions = this.prepareSaveOptions(opt)
509
536
 
537
+ const rows = await this.dbmsToStorageRows(validDbms)
510
538
  await (opt.tx || this.cfg.db).saveBatch(table, rows, saveOptions)
511
539
 
512
540
  if (saveOptions.assignGeneratedIds) {
513
- rows.forEach((row, i) => (dbms[i]!.id = row.id))
541
+ validDbms.forEach((dbm, i) => (dbms[i]!.id = dbm.id))
514
542
  }
515
543
 
516
- return rows
544
+ return validDbms
517
545
  }
518
546
 
519
- private prepareSaveOptions(opt: CommonDaoSaveOptions<BM, DBM>): CommonDBSaveOptions<DBM> {
547
+ private prepareSaveOptions(
548
+ opt: CommonDaoSaveOptions<BM, DBM>,
549
+ ): CommonDBSaveOptions<ObjectWithId> {
520
550
  let {
521
551
  saveMethod,
522
552
  assignGeneratedIds = this.cfg.assignGeneratedIds,
523
553
  excludeFromIndexes = this.cfg.excludeFromIndexes,
524
554
  } = opt
555
+
556
+ // If the user passed in custom `excludeFromIndexes` with the save() call,
557
+ // and the auto-compression is enabled,
558
+ // then we need to ensure that the '__compressed' property is part of the list.
559
+ if (this.cfg.compress?.keys) {
560
+ excludeFromIndexes ??= []
561
+ if (!excludeFromIndexes.includes('__compressed' as any)) {
562
+ excludeFromIndexes.push('__compressed' as any)
563
+ }
564
+ }
565
+
525
566
  if (this.cfg.immutable && !opt.allowMutability && !opt.saveMethod) {
526
567
  saveMethod = 'insert'
527
568
  }
528
569
 
529
570
  return {
530
571
  ...opt,
531
- excludeFromIndexes,
572
+ excludeFromIndexes: excludeFromIndexes as (keyof ObjectWithId)[],
532
573
  saveMethod,
533
574
  assignGeneratedIds,
534
575
  }
@@ -550,11 +591,7 @@ export class CommonDao<
550
591
  opt.skipValidation ??= true
551
592
  opt.errorMode ||= ErrorMode.SUPPRESS
552
593
 
553
- if (this.cfg.immutable && !opt.allowMutability && !opt.saveMethod) {
554
- opt = { ...opt, saveMethod: 'insert' }
555
- }
556
-
557
- const excludeFromIndexes = opt.excludeFromIndexes || this.cfg.excludeFromIndexes
594
+ const saveOptions = this.prepareSaveOptions(opt)
558
595
  const { beforeSave } = this.cfg.hooks!
559
596
 
560
597
  const { chunkSize = 500, chunkConcurrency = 32, errorMode } = opt
@@ -565,17 +602,14 @@ export class CommonDao<
565
602
  this.assignIdCreatedUpdated(bm, opt)
566
603
  const dbm = await this.bmToDBM(bm, opt)
567
604
  beforeSave?.(dbm)
568
- return dbm
605
+ return await this.dbmToStorageRow(dbm)
569
606
  },
570
607
  { errorMode },
571
608
  )
572
609
  .chunk(chunkSize)
573
610
  .map(
574
611
  async batch => {
575
- await this.cfg.db.saveBatch(table, batch, {
576
- ...opt,
577
- excludeFromIndexes,
578
- })
612
+ await this.cfg.db.saveBatch(table, batch, saveOptions)
579
613
  return batch
580
614
  },
581
615
  {
@@ -714,9 +748,6 @@ export class CommonDao<
714
748
  // const dbm = this.anyToDBM(_dbm, opt)
715
749
  const dbm: DBM = { ..._dbm, ...this.cfg.hooks!.parseNaturalId!(_dbm.id as ID) }
716
750
 
717
- // Decompress
718
- await this.decompress(dbm)
719
-
720
751
  // DBM > BM
721
752
  const bm = ((await this.cfg.hooks!.beforeDBMToBM?.(dbm)) || dbm) as Partial<BM>
722
753
 
@@ -743,9 +774,6 @@ export class CommonDao<
743
774
  // BM > DBM
744
775
  const dbm = ((await this.cfg.hooks!.beforeBMToDBM?.(bm)) || bm) as DBM
745
776
 
746
- // Compress
747
- if (this.cfg.compress) await this.compress(dbm)
748
-
749
777
  return dbm
750
778
  }
751
779
 
@@ -754,37 +782,85 @@ export class CommonDao<
754
782
  return await pMap(bms, async bm => await this.bmToDBM(bm, opt))
755
783
  }
756
784
 
785
+ // STORAGE LAYER (compression/decompression at DB boundary)
786
+ // These methods convert between DBM (logical model) and storage format (physical, possibly compressed).
787
+ // Public methods allow external code to bypass the DAO layer for direct DB access
788
+ // (e.g., cross-environment data copy).
789
+
790
+ /**
791
+ * Converts a DBM to storage format, applying compression if configured.
792
+ *
793
+ * Use this when you need to write directly to the database, bypassing the DAO save methods.
794
+ * The returned value is opaque and should only be passed to db.saveBatch() or similar.
795
+ *
796
+ * @example
797
+ * const storageRow = await dao.dbmToStorageRow(dbm)
798
+ * await db.saveBatch(table, [storageRow])
799
+ */
800
+ async dbmToStorageRow(dbm: DBM): Promise<ObjectWithId> {
801
+ if (!this.cfg.compress?.keys.length) return dbm
802
+ const row = { ...dbm }
803
+ await this.compress(row)
804
+ return row
805
+ }
806
+
807
+ /**
808
+ * Converts multiple DBMs to storage rows.
809
+ */
810
+ async dbmsToStorageRows(dbms: DBM[]): Promise<ObjectWithId[]> {
811
+ if (!this.cfg.compress?.keys.length) return dbms
812
+ return await pMap(dbms, async dbm => await this.dbmToStorageRow(dbm))
813
+ }
814
+
815
+ /**
816
+ * Converts a storage row back to a DBM, applying decompression if needed.
817
+ *
818
+ * Use this when you need to read directly from the database, bypassing the DAO load methods.
819
+ *
820
+ * @example
821
+ * const rows = await db.getByIds(table, ids)
822
+ * const dbms = await Promise.all(rows.map(row => dao.storageRowToDBM(row)))
823
+ */
824
+ async storageRowToDBM(row: ObjectWithId): Promise<DBM> {
825
+ if (!this.cfg.compress?.keys.length) return row as DBM
826
+ const dbm = { ...(row as DBM) }
827
+ await this.decompress(dbm)
828
+ return dbm
829
+ }
830
+
831
+ /**
832
+ * Converts multiple storage rows to DBMs.
833
+ */
834
+ async storageRowsToDBMs(rows: ObjectWithId[]): Promise<DBM[]> {
835
+ if (!this.cfg.compress?.keys.length) return rows as DBM[]
836
+ return await pMap(rows, async row => await this.storageRowToDBM(row))
837
+ }
838
+
757
839
  /**
758
840
  * Mutates `dbm`.
759
841
  */
760
- async compress(dbm: DBM): Promise<void> {
842
+ private async compress(dbm: DBM): Promise<void> {
761
843
  if (!this.cfg.compress?.keys.length) return // No compression requested
762
844
 
763
845
  const { keys } = this.cfg.compress
764
846
  const properties = _pick(dbm, keys)
765
- _assert(
766
- !('data' in dbm) || 'data' in properties,
767
- `Data (${dbm.id}) already has a "data" property. When using compression, this property must be included in the compression keys list.`,
768
- )
769
847
  const bufferString = JSON.stringify(properties)
770
- const data = await zstdCompress(bufferString)
848
+ const __compressed = await zstdCompress(bufferString)
771
849
  _omitWithUndefined(dbm as any, _objectKeys(properties), { mutate: true })
772
- Object.assign(dbm, { data })
850
+ Object.assign(dbm, { __compressed })
773
851
  }
774
852
 
775
853
  /**
776
854
  * Mutates `dbm`.
777
855
  */
778
- async decompress(dbm: DBM): Promise<void> {
856
+ private async decompress(dbm: DBM): Promise<void> {
779
857
  _typeCast<Compressed<DBM>>(dbm)
780
- if (!this.cfg.compress) return // Auto-compression not turned on
781
- if (!Buffer.isBuffer(dbm.data)) return // No compressed data
858
+ if (!Buffer.isBuffer(dbm.__compressed)) return // No compressed data
782
859
 
783
- // try-catch to avoid a `data` with Buffer which is not compressed, but legit data
784
860
  try {
785
- const bufferString = await decompressZstdOrInflateToString(dbm.data)
861
+ const bufferString = await decompressZstdOrInflateToString(dbm.__compressed)
786
862
  const properties = JSON.parse(bufferString)
787
- dbm.data = undefined
863
+ dbm.__compressed = undefined
788
864
  Object.assign(dbm, properties)
789
865
  } catch {}
790
866
  }
@@ -799,9 +875,6 @@ export class CommonDao<
799
875
 
800
876
  dbm = { ...dbm, ...this.cfg.hooks!.parseNaturalId!(dbm.id as ID) }
801
877
 
802
- // Decompress
803
- await this.decompress(dbm)
804
-
805
878
  // Validate/convert DBM
806
879
  // return this.validateAndConvert(dbm, this.cfg.dbmSchema, DBModelType.DBM, opt)
807
880
  return dbm
@@ -898,6 +971,75 @@ export class CommonDao<
898
971
  }
899
972
  }
900
973
 
974
+ /**
975
+ * Helper to decompress legacy compressed data when migrating away from auto-compression.
976
+ * Use as your `beforeDBMToBM` hook to decompress legacy rows on read.
977
+ *
978
+ * @example
979
+ * const dao = new CommonDao({
980
+ * hooks: {
981
+ * beforeDBMToBM: CommonDao.decompressLegacyRow,
982
+ * }
983
+ * })
984
+ *
985
+ * // Or within an existing hook:
986
+ * beforeDBMToBM: async (dbm) => {
987
+ * await CommonDao.decompressLegacyRow(dbm)
988
+ * // ... other transformations
989
+ * return dbm
990
+ * }
991
+ */
992
+ static async decompressLegacyRow<T extends ObjectWithId>(row: T): Promise<T> {
993
+ // Check both __compressed (current) and data (legacy) for backward compatibility
994
+ const compressed = (row as any).__compressed ?? (row as any).data
995
+ if (!Buffer.isBuffer(compressed)) return row
996
+
997
+ try {
998
+ const bufferString = await decompressZstdOrInflateToString(compressed)
999
+ const properties = JSON.parse(bufferString)
1000
+ ;(row as any).__compressed = undefined
1001
+ ;(row as any).data = undefined
1002
+ Object.assign(row, properties)
1003
+ } catch {
1004
+ // Decompression failed - field is not compressed, leave as-is
1005
+ }
1006
+
1007
+ return row
1008
+ }
1009
+
1010
+ /**
1011
+ * Temporary helper to migrate from the old `data` compressed property to the new `__compressed` property.
1012
+ * Use as your `beforeDBMToBM` hook during the migration period.
1013
+ *
1014
+ * Migration steps:
1015
+ * 1. Add `beforeDBMToBM: CommonDao.migrateCompressedDataProperty` to your hooks
1016
+ * 2. Deploy - old data (with `data` property) will be decompressed on read and recompressed to `__compressed` on write
1017
+ * 3. Once all data has been naturally rewritten, remove the hook
1018
+ *
1019
+ * @example
1020
+ * const dao = new CommonDao({
1021
+ * compress: { keys: ['field1', 'field2'] },
1022
+ * hooks: {
1023
+ * beforeDBMToBM: CommonDao.migrateCompressedDataProperty,
1024
+ * }
1025
+ * })
1026
+ */
1027
+ static async migrateCompressedDataProperty<T extends ObjectWithId>(row: T): Promise<T> {
1028
+ const data = (row as any).data
1029
+ if (!Buffer.isBuffer(data)) return row
1030
+
1031
+ try {
1032
+ const bufferString = await decompressZstdOrInflateToString(data)
1033
+ const properties = JSON.parse(bufferString)
1034
+ ;(row as any).data = undefined
1035
+ Object.assign(row, properties)
1036
+ } catch {
1037
+ // Decompression failed - data field is not compressed, leave as-is
1038
+ }
1039
+
1040
+ return row
1041
+ }
1042
+
901
1043
  /**
902
1044
  * Load rows (by their ids) from Multiple tables at once.
903
1045
  * An optimized way to load data, minimizing DB round-trips.
@@ -983,13 +1125,17 @@ export class CommonDao<
983
1125
  const { table } = dao.cfg
984
1126
  if ('id' in input) {
985
1127
  // Singular
986
- const dbm = dbmByTableById[table]![input.id]
1128
+ const row = dbmByTableById[table]![input.id]
1129
+ // Decompress before converting to BM
1130
+ const dbm = row ? await dao.storageRowToDBM(row) : undefined
987
1131
  bmsByProp[prop] = (await dao.dbmToBM(dbm, opt)) || null
988
1132
  } else {
989
1133
  // Plural
990
1134
  // We apply filtering, to be able to support multiple input props fetching from the same table.
991
1135
  // Without filtering - every prop will get ALL rows from that table.
992
- const dbms = input.ids.map(id => dbmByTableById[table]![id]).filter(_isTruthy)
1136
+ const rows = input.ids.map(id => dbmByTableById[table]![id]).filter(_isTruthy)
1137
+ // Decompress before converting to BM
1138
+ const dbms = await dao.storageRowsToDBMs(rows)
993
1139
  bmsByProp[prop] = await dao.dbmsToBM(dbms, opt)
994
1140
  }
995
1141
  })
@@ -1054,7 +1200,8 @@ export class CommonDao<
1054
1200
  dao.assignIdCreatedUpdated(row, opt)
1055
1201
  const dbm = await dao.bmToDBM(row, opt)
1056
1202
  dao.cfg.hooks!.beforeSave?.(dbm)
1057
- dbmsByTable[table].push(dbm)
1203
+ const storageRow = await dao.dbmToStorageRow(dbm)
1204
+ dbmsByTable[table].push(storageRow)
1058
1205
  } else {
1059
1206
  // Plural
1060
1207
  input.rows.forEach(bm => dao.assignIdCreatedUpdated(bm, opt))
@@ -1062,7 +1209,8 @@ export class CommonDao<
1062
1209
  if (dao.cfg.hooks!.beforeSave) {
1063
1210
  dbms.forEach(dbm => dao.cfg.hooks!.beforeSave!(dbm))
1064
1211
  }
1065
- dbmsByTable[table].push(...dbms)
1212
+ const storageRows = await dao.dbmsToStorageRows(dbms)
1213
+ dbmsByTable[table].push(...storageRows)
1066
1214
  }
1067
1215
  })
1068
1216
 
@@ -1197,4 +1345,4 @@ export type AnyDao = CommonDao<any>
1197
1345
  * Used internally during compression/decompression so that DBM instances can
1198
1346
  * carry their compressed payload alongside the original type shape.
1199
1347
  */
1200
- type Compressed<DBM> = DBM & { data?: Buffer }
1348
+ type Compressed<DBM> = DBM & { __compressed?: Buffer }
@@ -2,13 +2,8 @@ import { _isEmptyObject } from '@naturalcycles/js-lib'
2
2
  import { _assert } from '@naturalcycles/js-lib/error/assert.js'
3
3
  import type { CommonLogger } from '@naturalcycles/js-lib/log'
4
4
  import { _deepCopy, _sortObjectDeep } from '@naturalcycles/js-lib/object'
5
- import {
6
- _stringMapEntries,
7
- _stringMapValues,
8
- type AnyObjectWithId,
9
- type ObjectWithId,
10
- type StringMap,
11
- } from '@naturalcycles/js-lib/types'
5
+ import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types'
6
+ import type { AnyObjectWithId, ObjectWithId, StringMap } from '@naturalcycles/js-lib/types'
12
7
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv'
13
8
  import { generateJsonSchemaFromData } from '@naturalcycles/nodejs-lib/ajv'
14
9
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream'
@@ -1,7 +1,8 @@
1
1
  import { AppError } from '@naturalcycles/js-lib/error/error.util.js'
2
2
  import type { CommonLogger } from '@naturalcycles/js-lib/log'
3
3
  import { pMap } from '@naturalcycles/js-lib/promise/pMap.js'
4
- import { type Integer, type KeyValueTuple, SKIP } from '@naturalcycles/js-lib/types'
4
+ import { SKIP } from '@naturalcycles/js-lib/types'
5
+ import type { Integer, KeyValueTuple } from '@naturalcycles/js-lib/types'
5
6
  import type { Pipeline } from '@naturalcycles/nodejs-lib/stream'
6
7
  import {
7
8
  decompressZstdOrInflateToString,
@@ -8,11 +8,10 @@ import { _passthroughMapper } from '@naturalcycles/js-lib/types'
8
8
  import type { JsonSchema } from '@naturalcycles/nodejs-lib/ajv'
9
9
  import { boldWhite, dimWhite, grey, yellow } from '@naturalcycles/nodejs-lib/colors'
10
10
  import { fs2 } from '@naturalcycles/nodejs-lib/fs2'
11
- import {
12
- NDJsonStats,
13
- Pipeline,
14
- type TransformLogProgressOptions,
15
- type TransformMapOptions,
11
+ import { NDJsonStats, Pipeline } from '@naturalcycles/nodejs-lib/stream'
12
+ import type {
13
+ TransformLogProgressOptions,
14
+ TransformMapOptions,
16
15
  } from '@naturalcycles/nodejs-lib/stream'
17
16
  import type { CommonDB } from '../commondb/common.db.js'
18
17
  import type { CommonDBSaveOptions } from '../db.model.js'
@@ -1,6 +1,7 @@
1
1
  import { _range } from '@naturalcycles/js-lib/array/range.js'
2
2
  import type { BaseDBEntity, UnixTimestamp } from '@naturalcycles/js-lib/types'
3
- import { j, type JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv'
3
+ import { j } from '@naturalcycles/nodejs-lib/ajv'
4
+ import type { JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv'
4
5
 
5
6
  const MOCK_TS_2018_06_21 = 1529539200 as UnixTimestamp
6
7
 
@@ -1,15 +1,9 @@
1
1
  import type { ObjectWithId } from '@naturalcycles/js-lib/types'
2
- import {
3
- j,
4
- JsonSchemaAnyBuilder,
5
- type JsonSchemaObjectBuilder,
6
- } from '@naturalcycles/nodejs-lib/ajv'
2
+ import { j, JsonSchemaAnyBuilder } from '@naturalcycles/nodejs-lib/ajv'
3
+ import type { JsonSchemaObjectBuilder } from '@naturalcycles/nodejs-lib/ajv'
7
4
  import type { CommonDBOptions, CommonDBSaveOptions, DBTransaction } from '../db.model.js'
8
- import {
9
- type DBQueryFilter,
10
- dbQueryFilterOperatorValues,
11
- type DBQueryOrder,
12
- } from '../query/dbQuery.js'
5
+ import { dbQueryFilterOperatorValues } from '../query/dbQuery.js'
6
+ import type { DBQueryFilter, DBQueryOrder } from '../query/dbQuery.js'
13
7
 
14
8
  // oxlint-disable typescript/explicit-function-return-type
15
9