cry-db
v2.5.0
Published
database access with mongo
Readme
cry-db
A MongoDB wrapper library providing a high-level API for database operations with built-in support for revisions, soft-delete, archiving, blocking, auditing, and real-time publish events.
Architecture
Base Connection management, ObjectId utilities, timestamps
└─ Db Database selection, collection/index management
└─ Mongo Full CRUD, soft-delete, archive, block, audit, publish events
Repo Convenience wrapper around Mongo bound to a single collectionMongo— operates on explicit(collection, query, ...)argumentsRepo<T>— wraps aMongoinstance with a fixed collection, so every call omits the collection parameter
Quick Start
import { Repo, Mongo } from 'cry-db';
// Repo — single-collection convenience class
const users = new Repo('users', 'mydb');
// Mongo — multi-collection class
const mongo = new Mongo('mydb');Connection uses MONGO_URL env var (default mongodb://127.0.0.1:27017) and MONGO_DB env var for the database name.
Record Lifecycle
Documents in cry-db have three visibility states controlled by metadata flags. Each state is opt-in and independent — a record can be in any combination of states simultaneously.
| State | Flag | Opt-in | Filtered from queries | Reversible |
|-----------|------------|--------------------------|------------------------------|------------|
| Active | (none) | — | No | — |
| Deleted | _deleted | useSoftDelete(true) | Yes, unless returnDeleted | No (use hardDelete to purge) |
| Archived | _archived| useArchive(true) | Yes, unless returnArchived | Yes (unarchiveOne) |
| Blocked | _blocked | — | No (always returned) | Yes (unblockOne) |
Query filtering rules:
- When soft-delete is enabled, all query methods (
find,findOne,findById,findByIds,count,findNewer,findNewerMany,findByIdsInManyCollections) automatically add{ _deleted: { $exists: false } }to the query. Pass{ returnDeleted: true }inQueryOptsto override. - When archive is enabled, the same query methods automatically add
{ _archived: { $exists: false } }. Pass{ returnArchived: true }to override. - Both filters apply independently. A record that is both deleted and archived is hidden by both filters — you need both
returnDeletedandreturnArchivedto see it. findAllbypasses both filters entirely.
CRUD Operations
Insert
let user = await users.insert({ name: 'Alice', age: 30 });
// user._id, user._rev, user._ts are set automatically when revisions are enabled
let many = await users.insertMany([{ name: 'Bob' }, { name: 'Carol' }]);Query
await users.find({ age: { $gte: 25 } });
await users.find({ age: { $gte: 25 } }, { sort: { name: 1 }, limit: 10, skip: 0 });
await users.findOne({ name: 'Alice' });
await users.findById(id);
await users.findByIds([id1, id2]);
await users.findAll(query); // bypasses soft-delete/archive filters
await users.count({ age: { $gte: 25 } });Update
await users.updateOne({ name: 'Alice' }, { $set: { age: 31 } });
await users.update({ active: true }, { $inc: { loginCount: 1 } }); // updateMany
await users.save({ $set: { age: 31 } }, userId);
await users.upsert({ email: '[email protected]' }, { $set: { name: 'Alice' } });Bracket-by-_id array paths
Update array elements by their _id instead of by position. The path
arr[<id>] is shorthand for "the element of arr whose _id is <id>",
resolved server-side against the live document — no race window where a
concurrent reorder, insert, or delete shifts indices between client read
and server write.
Supported in every write method: updateOne, save, update, upsert,
upsertBatch, and updateCollections. Top-level shorthand (no $set
wrapper) is normalized into $set automatically; key: undefined becomes
$unset as usual.
Syntax
| Form | Quoted variants |
|---|---|
| arr[<id>] | arr['<id>'], arr["<id>"] |
Quoted and unquoted forms are equivalent. Use quotes when an _id contains
characters that could conflict with dot-notation (rare).
Four operations
// 1. Update sub-field of element with _id === 'p2'
// (translated to $set + arrayFilters [{ f0._id: 'p2' }])
await items.updateOne(
{ _id: 'r1' },
{ 'postavke[p2].kolicina': 99 },
);
// 2. Unset sub-field of element with _id === 'p2'
// (key: undefined → $unset → mongo $unset on the matched element)
await items.updateOne(
{ _id: 'r1' },
{ 'postavke[p2].navodilo': undefined },
);
// 3. Insert / replace an element by _id (terminal bracket + array value)
// Idempotent — re-sending the same _id replaces in place, no duplicates.
// Each element MUST carry its own `_id`.
await items.updateOne(
{ _id: 'r1' },
{ "postavke['p0']": [{ _id: 'p0', value: 0 }] },
);
// 4. Remove an element by _id (terminal bracket + undefined)
// Translated to $pull (or merged into the same pipeline as inserts above).
await items.updateOne(
{ _id: 'r1' },
{ "postavke['p1']": undefined },
);Single-element insert/remove uses an array of one element on the value side (form 3). Multiple terminal-bracket inserts against the same parent field are merged into one atomic stage.
Combining operations
Sub-field updates (form 1/2) and terminal-bracket inserts/removes (form 3/4) can both appear in a single update with one exception:
| In the same update | Result |
|---|---|
| Sub-field updates only (1, 2) | Single doc update with arrayFilters. |
| Terminal removes only (4) | $pull added to the doc; arrayFilters still allowed for any sub-field updates. |
| Terminal inserts (3), with or without removes (4) | Aggregation pipeline with a unified $filter + $concatArrays stage per parent field. Inserts are idempotent (re-inserting the same _id replaces the element). |
| Terminal inserts and sub-field updates on the same parent array | Single pipeline: $filter + $concatArrays produces the post-insert array, then a per-_id $switch + $mergeObjects overlay applies the sub-field updates LAST. Same-id insert + sub-field update yields an element with the insert as its base and the sub-field update overriding matching fields. |
Nested brackets (multi-level)
Bracket paths can chain to arbitrary depth — arr[A].sub[B].field,
arr[A].sub[B], arr[A].sub[B] = undefined, and so on are all
supported. Pure sub-field nested updates take the arrayFilters fast
path (mongo's native nested positional support). When combined with a
terminal-bracket insert/remove anywhere in the update, the library
switches to pipeline form and composes the nested transform recursively:
each bracket level becomes a $filter + $concatArrays + $map/$switch
stage applied inside the parent element's overlay.
// ✓ Pure nested sub-field — arrayFilters path
await items.updateOne({ _id: 'r1' }, {
'terapije[t1].postavke[sp2].kolicina': 99,
});
// ✓ Nested sub-field + nested terminal insert (different ids) — pipeline path
await items.updateOne({ _id: 'r1' }, {
'terapije[t1].postavke[sp2].kolicina': 99,
"terapije[t1].postavke[sp3]": [{ _id: 'sp3', kolicina: 5 }],
});
// ✓ Combined with top-level operations
await items.updateOne({ _id: 'r1' }, {
'terapije[t1].postavke[sp2].kolicina': 99,
"terapije[t1].postavke[sp3]": [{ _id: 'sp3', kolicina: 5 }],
"terapije[t2]": undefined, // delete outer t2
"terapije[t9]": [{ _id: 't9', postavke: [] }], // insert new outer t9
});Order of effects within one update:
$filterremoves elements whose_idis in anyremoveIdsfor that level.$concatArraysappends terminal-bracket-inserted elements at the end.$map + $switchapplies sub-field overlays in-place on matching ids.- Nested array transforms inside the overlay are wrapped in
$cond: $isArray(<path>)— when the operation is removes-only on a missing nested array, the field is NOT created. (Matches mongo's native "$pull on missing field = no-op" semantic.)
Same-id overlap: when a nested "new base" operation and a nested
sub-field update target the SAME element (same _id at the inner
level), both apply — the new-base value is set first, sub-field
overlay applies on top. A kind: 'overlap' warning is emitted so the
unusual overlap is visible to the caller.
Two flavors trigger the warning:
// Flavor 1: terminal-array insert + sub-field on same id.
await items.updateOne({ _id: 'r1' }, {
'terapije[t1].postavke[sp1].kolicina': 99,
"terapije[t1].postavke[sp1]": [{ _id: 'sp1', q: 5 }],
});
// final sp1: { _id: 'sp1', q: 5, kolicina: 99 }
// — terminal value's `q` kept, sub-field's `kolicina` overlaid.
// — original sp1 fields (not in terminal value) are dropped.
// Flavor 2: whole-element replace (object value, not array) + sub-field.
await items.updateOne({ _id: 'r1' }, {
'terapije[t1].postavke[sp1].kolicina': 99,
"terapije[t1].postavke[sp1]": { _id: 'sp1', q: 5 },
});
// final sp1: identical to Flavor 1 above.Both flavors emit result.results.warnings[0].error matching
/same-id|overlap/i.
The top-level same-id case is the same semantic (insert/replace +
sub-field on the same outer _id) but is silent — it's a documented
merge pattern, not an overlap warning.
Delete
await users.deleteOne({ name: 'Alice' }); // soft-delete when enabled, else hard delete
await users.delete({ inactive: true }); // bulk soft-delete / hard delete
await users.hardDeleteOne(id); // always physically removes
await users.hardDelete({}); // physically remove all matchingBatch Sync
upsertBatch for single-collection batches:
await users.upsertBatch({
upsert: [{ _id: id1, name: 'Alice' }, { _id: id2, name: 'Bob' }],
delete: [id3],
});updateCollections for multi-collection batches with per-record
error/warning reporting:
const results = await mongo.updateCollections([
[{
collection: 'users',
batch: {
updates: [
{ _id: id1, _rev: 4, update: { name: 'Alice' } },
{ _id: id2, _rev: 1, update: { name: 'Bob' } },
],
deletes: [{ _id: id3, _rev: 2 }],
},
}],
]);Each updates/deletes entry carries _rev — the revision the client's
change was computed against. The server uses it to detect concurrent
external writes and to surface server-resolved fields (see mustRefresh
below). Do not put _rev inside update (it is stripped). Omitting
_rev is treated as 0, which forces a full-record refresh — the
old-client fallback.
Partial-failure semantics: per-record failures inside updates or
deletes are caught, surfaced in result.results.errors, and do
not stop the rest of the batch. Each findOneAndUpdate /
findOneAndDelete runs in its own try/catch, so a single
Document not found, validation error, or write conflict only affects
that record.
Caveat: a few code paths run outside the per-record try/catch and
will still throw out of the call — abort the rest of the batch:
- connection failure on the initial
connect() - sequence pre-processing (
_processSequenceFieldForMany), which runs once per(collection, batch)before the per-record loop - audit writes and publish emits, which run after the per-record loop
In practice: per-record validation / "not found" / write-conflict failures
land in errors and the batch keeps going. Infrastructure failures
(connection lost, audit collection unreachable, …) propagate as a thrown
exception.
results[0].results.inserted // DbEntity[] — records actually upserted as new
results[0].results.updated // DbEntity[] — records actually updated
results[0].results.deleted // DbEntity[] — records actually deleted
results[0].results.mustRefresh // (DbEntity & full record)[] — see below
results[0].results.errors // { _id, error }[] — per-record failures (omitted if none)
results[0].results.warnings // { _id, error }[] — non-fatal issues (e.g. dropped
// NESTED-bracket paths; omitted if none)errors and warnings are arrays — one entry per affected record.
Both fields are omitted when empty. The call itself does not throw on
per-record failures; check .errors.length to detect them.
mustRefresh — records the client must re-adopt
The inserted/updated/deleted arrays carry only {_id,_ts,_rev}, so a
client that relied on them alone never learned what cry-db changed beyond
the diff it sent. mustRefresh closes that: it holds the full, sanitized
post-write record for any entry where the client's local copy is now
stale. A record lands in mustRefresh when:
- cry-db resolved a field the client sent as a placeholder —
SEQ_*(auto-increment) or__hashed__*(hashing); or - the stored
_revadvanced beyond the client's base_revby more than this write applied — i.e. another actor modified the record first (conflict); or - the request omitted
_rev(old-client fallback — always refresh).
Contract:
- Every
mustRefresh._idalso appears ininserted/updated/deleted, so dirty-clearing logic that keys off those arrays is unaffected — treatmustRefreshas supplementary. - Records are full and sanitized:
__hashed__*values are never included (the client adopting the record drops any local plaintext), andremoveFieldsAlways/findNewerRemoveFieldsare applied as on reads. - The client should replace its local record with the
mustRefreshentry, then run its own dirty-merge — applymustRefreshbefore the merge so unsynced local edits to other fields are reconciled rather than clobbered. - Conflict detection requires
useRevisions(). Field-resolution refresh (SEQ/hash) works regardless of revisions.
Consumers (e.g.
cry-synced-db-client): send_revper update/delete and applymustRefreshon the response. TheapplyObjDiff/computeObjDiffhelpers (also exported from this package) can drive the local replace.
Features
Revisions
When enabled, every write increments _rev and updates the _ts (Timestamp) field.
users.useRevisions(true);Soft Delete
When enabled, deleteOne / delete set _deleted: Date instead of physically removing the document. Query operations automatically exclude deleted records. Deleted records cannot be "undeleted" — use hardDelete to purge them permanently.
users.useSoftDelete(true);
await users.deleteOne({ name: 'Alice' }); // sets _deleted: Date, increments _rev
await users.delete({ inactive: true }); // bulk soft-delete
await users.find({}); // excludes deleted
await users.find({}, { returnDeleted: true }); // includes deleted
await users.hardDelete({ inactive: true }); // physically removes (bypasses soft-delete)Archive
When enabled, query operations automatically exclude records with _archived set. Unlike soft-delete, archiving is fully reversible.
users.useArchive(true);
// Archive by query
await users.archiveOne({ name: 'Alice' }); // sets _archived: Date, increments _rev
await users.unarchiveOne({ name: 'Alice' }); // removes _archived, increments _rev
// Archive by id
await users.archiveOneById(id); // archive single record by _id
await users.unarchiveOneById(id); // unarchive single record by _id
// Archive multiple by ids
await users.archiveManyByIds([id1, id2, id3]); // returns array of archived docs (skips already archived)
await users.unarchiveManyByIds([id1, id2, id3]); // returns array of unarchived docs (skips non-archived)
// Query behavior
await users.find({}); // excludes archived
await users.find({}, { returnArchived: true }); // includes archivedArchive and soft-delete are independent — a record can be both archived and deleted. The returnArchived and returnDeleted options control each filter separately.
Block
Block/unblock sets or removes the _blocked field. Blocked records are not filtered from queries (unlike deleted/archived).
await users.blockOne({ name: 'Alice' });
await users.unblockOne({ name: 'Alice' });Sequences
Auto-incrementing field values managed atomically via a dedicated _sequences collection. Use the special string directives 'SEQ_NEXT' and 'SEQ_LAST' as field values during insert.
| Directive | Behavior |
|-----------|----------|
| 'SEQ_NEXT' | Increment the sequence counter and use the new value |
| 'SEQ_LAST' | Use the current sequence value without incrementing |
// Single insert — orderNo gets the next sequence value
await users.insert({ name: 'Alice', orderNo: 'SEQ_NEXT' });
// => { name: 'Alice', orderNo: 1, _id: ... }
await users.insert({ name: 'Bob', orderNo: 'SEQ_NEXT' });
// => { name: 'Bob', orderNo: 2, _id: ... }
// SEQ_LAST returns the current value (no increment)
await users.insert({ name: 'Carol', orderNo: 'SEQ_LAST' });
// => { name: 'Carol', orderNo: 2, _id: ... }Sequences are per-collection per-field. On first use, the sequence auto-seeds from the maximum existing value in the collection. If the collection is emptied, the sequence resets to 0.
Batch inserts are optimized — a range of sequence numbers is reserved atomically in a single operation, then distributed across the batch:
await users.insertMany([
{ name: 'A', orderNo: 'SEQ_NEXT' }, // orderNo: 3
{ name: 'B', orderNo: 'SEQ_LAST' }, // orderNo: 3 (current value after A)
{ name: 'C', orderNo: 'SEQ_NEXT' }, // orderNo: 4
]);Multiple fields can use independent sequences in the same document:
await users.insert({ invoiceNo: 'SEQ_NEXT', lineNo: 'SEQ_NEXT' });To reset a collection's sync sequence:
await users.resetCollectionSync();Auditing
Records changes to a separate audit log collection. Audited operations: insert, insertMany, update, updateOne, upsert, save, deleteOne, delete, blockOne, unblockOne, and updateCollections.
users.useAuditing(true);
users.auditToCollection('dblog');
users.auditCollection(); // enable auditing for this repo's collection
await users.dbLogGet(entityId); // retrieve audit log for an entity
await users.dbLogPurge(entityId); // purge audit logPublish Events
Real-time events emitted on insert/update/delete for data synchronization.
users.emitPublishEvents(true); // full document payloads
users.emitPublishRevEvents(true); // lightweight revision-only payloads
users.on('publish', (channel, data) => { /* ... */ });
users.on('publishRev', (channel, data) => { /* ... */ });Update payload shape: data and rawUpdate
For update / updateMany events (and per-item updates inside batch
events), the publish payload carries two complementary fields:
| Field | Contents | Mongo operators? |
|---|---|---|
| data | Clean (sub-)document with the de-bracketed result. Top-level scalar updates produce a flat key/value pair; bracket-by-_id paths produce the full post-update parent array (no diff, no positional placeholders, no $[…]). Nothing is synthesized — values are sliced straight from the post-update document. | Never — no $set, $unset, $pull, $[fN], no flat dotted/bracket keys. |
| rawUpdate | The user's update spec exactly as it came in (top-level shorthand or $set/$unset form, bracket paths preserved). For inspection / replay / audit. | Allowed — that's the point. |
// Sub-field update via bracket-by-_id
await items.updateOne({ _id: 'r1' }, { 'postavke[p2].kolicina': 99 });
// Publish event:
{
operation: 'update',
db: 'mydb',
collection: 'items',
data: {
_id: 'r1',
postavke: [ // FULL post-update array
{ _id: 'p1', kolicina: 1 },
{ _id: 'p2', kolicina: 99 },
{ _id: 'p3', kolicina: 3 },
],
},
rawUpdate: { 'postavke[p2].kolicina': 99 },
}data only contains fields that were touched by the update (plus _id,
plus _rev/_ts when revisions are enabled). Sibling fields the update
did not touch never leak in. For terminal-bracket inserts/removes
(arr[id]: [els] / arr[id]: undefined) the parent array is likewise
emitted in full.
For updateMany the data field is { n: number, ok: boolean } and
rawUpdate is not included — mongo's updateMany doesn't return the
list of affected _id-s, so the spec on its own is not actionable.
Subscribers refetch by timestamp via findNewer* (the matching
publishRev event always sets enquireLastTs: true).
For batch events (upsertBatch / updateCollections), each item in
payload.data carries its own data (clean) and rawUpdate (raw form).
Transactions
await users.startTransaction();
// ... operations ...
await users.commitTransaction();
// or
await users.abortTransaction();
// or callback style:
await users.withTransaction(async (client, session) => {
// ... operations ...
});Entity Metadata
Every document can have these system fields (managed automatically when the respective feature is enabled):
| Field | Type | Description |
|------------|-----------|-----------------------------------------------|
| _id | ObjectId | Document identifier |
| _rev | number | Revision counter (incremented on each write) |
| _ts | Timestamp | Server timestamp (set on each write) |
| _deleted | Date | Soft-delete timestamp |
| _archived| Date | Archive timestamp |
| _blocked | Date | Block timestamp |
QueryOpts
Options passed to query methods:
| Option | Type | Description |
|-------------------|----------------------|----------------------------------------------|
| project | Projection | Fields to include/exclude |
| sort | Record<string, 1|-1> | Sort specification |
| limit | number | Max documents to return |
| skip | number | Documents to skip |
| collation | CollationOptions | MongoDB collation options |
| readPreference | ReadPreference | Read preference |
| returnDeleted | boolean | Include soft-deleted records |
| returnArchived | boolean | Include archived records |
Additional Methods
users.distinct<string>('status'); // distinct field values (sorted)
users.findNewer(timestamp, query, opts); // find records newer than timestamp
mongo.findNewerMany([{ collection, timestamp }]); // batch findNewer across collections
mongo.findNewerMany([{ collection, timestamp, specId }]); // specId optional — disambiguates duplicate-collection specs
mongo.findByIdsInManyCollections([{ collection, ids }]); // batch findByIds across collections
users.isUnique('email', '[email protected]', excludeId); // uniqueness check
users.aggregate(pipeline); // aggregation pipeline
users.createIndex('name_1', { name: 1 }); // create index
users.indexes(); // list indexes
users.findNewerRemoveFields(); // get current list of stripped fields
users.findNewerRemoveFields('_ts,_rev'); // set fields to strip (comma-separated string)
users.findNewerRemoveFields(['_ts', '_rev']); // set fields to strip (array)
users.removeFieldsAlways(); // get current list of always-stripped fields
users.removeFieldsAlways('secret,internal'); // set fields stripped from ALL find* and publish
users.removeFieldsAlways(['secret', 'internal']); // set fields stripped from ALL find* and publishDiff Utilities
Pure, mongo-independent helpers for computing and replaying document diffs,
exported from the package root. They are the server-side counterpart of the
cry-synced-db-client diff machinery (computeObjDiff / applyObjDiff).
import { computeObjDiff, applyObjDiff } from 'cry-db';
// Minimal MongoDB-style dot/bracket paths between two documents (only keys
// present in `update`). Arrays of `_id`-keyed objects diff element-wise via
// `arr[<id>].field`, with composition changes as `arr[<id>] = [el]` /
// `arr[<id>] = undefined`. `_ts`/`_rev`/`_csq` are never emitted.
const diff = computeObjDiff(existing, update);
// → { "koraki[k1].diag": "new", "koraki[k2]": [{ _id: "k2", ... }] }
// Replay a diff onto a base document the way a server-side `$set`+`$unset`
// would. Returns a fresh object (base is cloned, never mutated). Bracket
// paths whose parent array is missing are materialized; unrepresentable
// multi-bracket paths are dropped (logged unless the root field starts `_`).
const merged = applyObjDiff(base, diff, fallbackId, collection);Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| MONGO_URL | mongodb://127.0.0.1:27017 | MongoDB connection URL |
| MONGO_DB | — | Default database name |
| AUDIT_COLLECTIONS | — | Comma-separated list of collections to audit |
| REMOVE_FIELDS_FOR_FINDNEWER | — | Comma-separated fields stripped from findNewer* results and publish events (also configurable at runtime via findNewerRemoveFields()) |
| REMOVE_FIELDS_ALWAYS | — | Comma-separated fields stripped from all find* results and publish events (also configurable at runtime via removeFieldsAlways()) |
Testing
npm testRequires a running MongoDB instance on localhost.
