Package Exports
- vitreousdatabase
- vitreousdatabase/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (vitreousdatabase) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
VitreousDataBase
A lightweight, file-backed non-relational database for Node.js. No external dependencies — data is stored as a JSON file on disk, with schema validation, constraints, and nested object support built in.
Requirements
- Node.js >= 18.0.0
Installation
Copy the module into your project or install it locally:
# from a local path
npm install ./path/to/VitreousDataBaseThen require it:
const { Database } = require('vitreousdatabase');Quick start
const { Database } = require('vitreousdatabase');
async function main() {
// Opens (or creates) the database file
const db = await Database.create('./mydata.json');
// 1. Define the schema for an entity
await db.entityManager.createEntity('users', {
type: 'table',
id: ['id'],
values: ['id', 'username', 'email'],
notnullable: ['username'],
unique: ['email'],
});
// 2. Insert a record
const user = await db.recordManager.insert('users', {
id: 1,
username: 'alice',
email: 'alice@example.com',
});
console.log(user); // { id: 1, username: 'alice', email: 'alice@example.com' }
// 3. Find by id
const found = await db.recordManager.findByIdSingle('users', 1);
console.log(found.username); // 'alice'
// 4. Update
await db.recordManager.update('users', { id: 1 }, { username: 'alice_b' });
// 5. Delete
await db.recordManager.deleteRecord('users', { id: 1 });
}
main();The file mydata.json is created automatically if it does not exist.
Concepts
Database file
All data is stored in a single JSON file:
{
"entitiesConfiguration": { },
"entities": { }
}entitiesConfiguration— the schema registry: one entry per entity, describing its fields and constraints.entities— the data storage: one array pertableentity, each element is a record.
Entity types
| Type | Description |
|---|---|
"table" |
A standalone collection of records. Supports insert, find, update, delete. |
"object" |
A reusable nested structure. Cannot be inserted directly — used only as a field inside a table entity. |
Entity configuration fields
| Field | Required | Description |
|---|---|---|
type |
yes | "table" or "object" |
values |
yes | All field names the entity is allowed to have |
id |
yes (for "table") |
Field names that identify a record (for lookups). Auto-added to notnullable. Uniqueness is enforced as a composite tuple, not per-field. At least one required for table entities. |
notnullable |
no | Fields that cannot be null or undefined when saving |
unique |
no | Fields whose value must be unique across all records |
nested |
no | Fields whose value is a nested object (must match a registered "object" entity) |
Note:
idfields are immutable after insert — they cannot be changed viaupdate().
Schema management
createEntity(name, config)
Registers a new entity. Object-type entities must be created before any table that references them in nested.
// Register the nested type first
await db.entityManager.createEntity('address', {
type: 'object',
values: ['street', 'city', 'zip'],
notnullable: ['city'],
});
// Then register the table that uses it
await db.entityManager.createEntity('customers', {
type: 'table',
id: ['id'],
values: ['id', 'name', 'email', 'address'],
notnullable: ['name'],
unique: ['email'],
nested: ['address'], // 'address' must already exist as type "object"
});getEntity(name)
Returns the configuration object for an entity.
const config = await db.entityManager.getEntity('customers');
console.log(config.values); // ['id', 'name', 'email', 'address']listEntities(type?)
Returns an array of entity names, optionally filtered by type.
const tables = await db.entityManager.listEntities('table');
const objects = await db.entityManager.listEntities('object');
const all = await db.entityManager.listEntities();deleteEntity(name)
Removes an entity and, for table entities, all its records.
await db.entityManager.deleteEntity('customers');Deleting an
"object"entity that is still referenced by atablethrowsEntityInUseError.
CRUD operations
insert(entityName, record)
Inserts a new record. All validation rules are applied.
const order = await db.recordManager.insert('orders', {
orderId: 101,
customerId: 1,
total: 59.90,
});findById(entityName, idObject)
Looks up a record using an id object. Works for both single and composite ids. Key order does not matter.
// Single id
const customer = await db.recordManager.findById('customers', { id: 1 });
// Composite id
const line = await db.recordManager.findById('orderLines', { orderId: 101, lineId: 3 });Returns the record, or null if not found.
idObjectmust contain all declaredidfields — throwsInvalidIdErrorif any are missing or if it contains a non-id key.
findByIdSingle(entityName, value)
Convenience shorthand for entities with exactly one id field.
const customer = await db.recordManager.findByIdSingle('customers', 1);Returns null if no record matches. Throws InvalidIdError if the entity has a composite id. Throws EntityTypeError if called on an "object" entity.
findAll(entityName)
Returns all records for an entity. Throws EntityTypeError if called on an "object" entity.
const allCustomers = await db.recordManager.findAll('customers');findWhere(entityName, predicate)
Filters records. Accepts either a function or a plain object.
// Function predicate — full power, supports nested access
const rich = await db.recordManager.findWhere('customers', r => r.total > 1000);
const milanese = await db.recordManager.findWhere('customers', r => r.address?.city === 'Milano');
// Plain object — top-level strict equality only
const alices = await db.recordManager.findWhere('customers', { name: 'Alice' });Throws EntityTypeError if called on an "object" entity.
Nested field matching (e.g.
{ address: { city: 'Milano' } }) is not supported via plain object — use a function predicate instead.
update(entityName, idObject, updates)
Deep-merges updates into the existing record. Returns the updated record.
const updated = await db.recordManager.update('customers', { id: 1 }, {
email: 'alice_new@example.com',
});Nested object fields are merged recursively — only the provided keys are overwritten, the rest are preserved:
// Before: { id: 1, address: { street: 'Via Roma 1', city: 'Milano', zip: '20100' } }
await db.recordManager.update('customers', { id: 1 }, {
address: { city: 'Torino' },
});
// After: { id: 1, address: { street: 'Via Roma 1', city: 'Torino', zip: '20100' } }idfields cannot be updated — throwsInvalidIdError.idObjectmust contain all declaredidfields — throwsInvalidIdErrorif any are missing or if it contains a non-id key.- Throws
RecordNotFoundErrorif no record matchesidObject. - All validation rules (notnullable, unique, unknown fields) apply to the merged result. Unknown fields in
updatesare caught by the unknown-field check and throwUnknownFieldError.
deleteRecord(entityName, idObject)
Removes a record and returns it.
const removed = await db.recordManager.deleteRecord('customers', { id: 1 });idObjectmust contain all declaredidfields — throwsInvalidIdErrorif any are missing or if it contains a non-id key.- Throws
RecordNotFoundErrorif no record matchesidObject.
Nested objects
Fields declared in nested must be plain objects. Their structure is validated against the matching "object" entity configuration.
Convention: the field name listed in
nestedmust exactly match the name of the registered"object"entity. For example, a field named"location"must be backed by an entity also called"location".
await db.entityManager.createEntity('location', {
type: 'object',
values: ['lat', 'lng'],
notnullable: ['lat', 'lng'],
});
await db.entityManager.createEntity('stores', {
type: 'table',
id: ['storeId'],
values: ['storeId', 'name', 'location'],
nested: ['location'], // field name 'location' → validated against the 'location' object entity
});
await db.recordManager.insert('stores', {
storeId: 'S01',
name: 'Central Store',
location: { lat: 45.46, lng: 9.19 },
});Nested objects:
- Are validated for unknown fields and
notnullableconstraints. - Are not subject to
uniquechecks (deep equality is not supported). - Cannot be used as
idfields. - Can themselves contain further nested objects (multi-level nesting is supported).
- Setting a nested field to
nullis valid for non-notnullablefields and explicitly clears it.
Naming constraint: because the field name must match the
"object"entity name, it is not possible to have two fields of the same nested type within the same entity. For example, you cannot have bothbillingAddressandshippingAddressbacked by a single"address"entity — each would require its own separately named"object"entity (e.g."billingAddress"and"shippingAddress").
Update limitation:
update()deep-merges nested objects but cannot remove individual keys from a nested object. Setting a key tonullleaves it present asnull(which may violatenotnullable). To replace a nested object entirely, set the whole field to a new object; to clear it, set the field tonull(only valid if the field is notnotnullable).
Composite ids
When an entity has more than one id field, use findById with an object.
Composite id uniqueness is enforced as a tuple: only the full combination of id field values must be unique. Different records may share the value of individual id fields as long as the full combination differs.
await db.entityManager.createEntity('orderLines', {
type: 'table',
id: ['orderId', 'lineId'],
values: ['orderId', 'lineId', 'productId', 'qty'],
});
await db.recordManager.insert('orderLines', { orderId: 1, lineId: 1, productId: 'P01', qty: 2 });
await db.recordManager.insert('orderLines', { orderId: 1, lineId: 2, productId: 'P02', qty: 1 });
// orderId: 1 appears in both records — valid because (orderId, lineId) tuples are distinct
const line = await db.recordManager.findById('orderLines', { orderId: 1, lineId: 2 });
// key order does not matter: { lineId: 2, orderId: 1 } works tooEager mode
By default every read operation loads the file from disk and every write saves it immediately. For write-heavy scenarios within a single process, enable eager mode to keep everything in memory and flush manually.
const db = await Database.create('./mydata.json', { eager: true });
// All operations hit the in-memory cache — no disk I/O
await db.recordManager.insert('logs', { id: 1, msg: 'start' });
await db.recordManager.insert('logs', { id: 2, msg: 'end' });
// Persist to disk when ready
await db.flush();
// Or close (flushes automatically)
await db.close();
// Calling close() a second time is a safe no-op — it returns immediately without flushing or throwing.Warning: Neither mode is safe when multiple processes share the same file. There is no cross-process file locking. The in-process mutex (
_enqueue) only serializes operations within a single process. In eager mode data races can cause silent overwrites; in default mode, concurrent read-modify-write cycles between processes can still interleave and lose writes. Use an external coordination mechanism (e.g. a dedicated server process) in multi-process environments.
Eager mode data loss: the emergency sync flush on
process.on('exit')is not invoked onSIGKILL(kill -9), OOM termination, orSIGTERMwithout an explicit handler. Calldb.close()ordb.flush()before your process exits to guarantee data is written. Alternatively, register your ownSIGTERM/SIGINThandlers that calldb.flush()before exiting.
Error handling
All errors extend VitreousError. Import specific classes to handle them precisely.
const {
Database,
VitreousError,
EntityNotFoundError,
UniqueConstraintError,
NullConstraintError,
FileAccessError,
} = require('vitreousdatabase');
try {
await db.recordManager.insert('users', { id: 1, username: null });
} catch (e) {
if (e instanceof NullConstraintError) {
console.error(`Null value rejected — field: ${e.fieldName}`);
} else if (e instanceof UniqueConstraintError) {
console.error(`Duplicate value rejected — field: ${e.fieldName}, value: ${e.value}`);
} else if (e instanceof VitreousError) {
console.error(`Database error: ${e.message}`);
} else {
throw e;
}
}Full error reference
| Class | When thrown | Extra properties |
|---|---|---|
FileAccessError |
File path inaccessible, JSON is corrupt, or operation called after close() |
filePath, reason |
EntityNotFoundError |
Entity name not in entitiesConfiguration |
entityName |
EntityAlreadyExistsError |
createEntity called with an existing name |
entityName |
EntityTypeError |
Operation requires "table" but got "object" (or vice versa) |
entityName, expected, actual |
EntityInUseError |
Deleting an "object" entity still referenced by a table |
entityName, referencedBy |
UnknownFieldError |
Record contains a field not listed in values |
entityName, fieldName |
NullConstraintError |
A notnullable field is null or undefined |
entityName, fieldName |
UniqueConstraintError |
A unique field value already exists in the data |
entityName, fieldName, value |
NestedTypeError |
A nested field received a non-object value |
entityName, fieldName |
InvalidIdError |
id field is also nested; object entity has id; findByIdSingle on composite id; idObject contains a non-id key or is empty |
entityName, reason |
CircularReferenceError |
Nested chain forms a cycle (including self-reference) | entityName, cycle |
RecordNotFoundError |
update or deleteRecord found no record matching idObject |
entityName, idObject |
Complete example
Below is a self-contained script that models a small shop with customers, addresses, and orders.
const { Database, UniqueConstraintError } = require('vitreousdatabase');
async function main() {
const db = await Database.create('./shop.json');
// --- Schema ---
await db.entityManager.createEntity('address', {
type: 'object',
values: ['street', 'city', 'zip'],
notnullable: ['city'],
});
await db.entityManager.createEntity('customers', {
type: 'table',
id: ['id'],
values: ['id', 'name', 'email', 'address'],
notnullable: ['name'],
unique: ['email'],
nested: ['address'],
});
await db.entityManager.createEntity('orders', {
type: 'table',
id: ['orderId'],
values: ['orderId', 'customerId', 'total', 'status'],
notnullable: ['customerId', 'total'],
});
// --- Insert ---
await db.recordManager.insert('customers', {
id: 1,
name: 'Alice',
email: 'alice@example.com',
address: { street: 'Via Roma 1', city: 'Milano', zip: '20100' },
});
await db.recordManager.insert('customers', {
id: 2,
name: 'Bob',
email: 'bob@example.com',
});
await db.recordManager.insert('orders', { orderId: 101, customerId: 1, total: 49.99, status: 'pending' });
await db.recordManager.insert('orders', { orderId: 102, customerId: 1, total: 19.00, status: 'shipped' });
await db.recordManager.insert('orders', { orderId: 103, customerId: 2, total: 99.50, status: 'pending' });
// --- Query ---
const alice = await db.recordManager.findByIdSingle('customers', 1);
console.log(`Customer: ${alice.name} — city: ${alice.address?.city}`);
const aliceOrders = await db.recordManager.findWhere('orders', { customerId: 1 });
console.log(`Alice has ${aliceOrders.length} orders`);
const pendingOrders = await db.recordManager.findWhere('orders', o => o.status === 'pending');
console.log(`Pending orders: ${pendingOrders.length}`);
// --- Update ---
await db.recordManager.update('orders', { orderId: 101 }, { status: 'shipped' });
// --- Unique constraint ---
try {
await db.recordManager.insert('customers', { id: 3, name: 'Eve', email: 'alice@example.com' });
} catch (e) {
if (e instanceof UniqueConstraintError) {
console.log(`Rejected: ${e.message}`);
}
}
// --- Delete ---
await db.recordManager.deleteRecord('orders', { orderId: 103 });
console.log(`Orders remaining: ${(await db.recordManager.findAll('orders')).length}`);
}
main().catch(console.error);Running the tests
node --test test/*.test.jsOr using the npm script:
npm testThe test suite includes:
test/validator.test.js— unit tests for Validator.jstest/database.test.js— Database init and eager modetest/entity.test.js— EntityManager integrationtest/record.test.js— RecordManager integrationtest/bugs.test.js— regression tests for all BUGS.md fixestest/edge_cases.test.js— boundary and edge case coveragetest/persistence.test.js— persistence and error property checkstest/integration.test.js— end-to-end scenariostest/readme.test.js— verifies README examples work correctly
Known limitations
No schema migration. Once an entity is created, its schema is immutable. There is no
updateEntitymethod. Changing field names, types, or constraints requires deleting and recreating the entity, which also destroys all its records. Plan your schema carefully before inserting data.No referential integrity across table entities. VitreousDataBase has no concept of foreign keys between table entities. Deleting a
customersrecord leaves allordersrecords with a danglingcustomerIdintact and undetectable. Cross-table consistency must be maintained by the application.JSON-only values. All field values must be JSON-serializable. Values like
Date,RegExp,Map,Set, andundefinedare not rejected at insert time but are silently corrupted by theJSON.parse(JSON.stringify(...))round-trip:Datebecomes an ISO string,RegExp/Map/Setbecome{}, andundefinedfields are dropped. Use only plain JSON types: strings, numbers, booleans,null, plain objects, and arrays.No composite
uniqueconstraints. Theuniquefield in the entity config applies per-field only. There is no way to declare that a combination of non-id fields must be unique (e.g.categoryId + slug). If you need composite uniqueness, include those fields inid(which enforces composite tuple uniqueness) or enforce the constraint in application code.