@phpdot/statedb
v1.0.0
Published
Lightweight, zero-dependency reactive database for JavaScript and TypeScript. MongoDB-style queries, update operators, indexes, schema validation, and a built-in watcher system for real-time UI reactivity. Optional localStorage/sessionStorage persistence.
Maintainers
Readme
StateDB
Lightweight, zero-dependency reactive database for JavaScript and TypeScript. MongoDB-style queries, update operators, indexes, schema validation, and a built-in watcher system for real-time UI reactivity. Optional localStorage/sessionStorage persistence.
Built for developers who think in collections, queries, and CRUD — not observables, reducers, or stores.
Why StateDB?
- You need client-side data with a familiar query syntax (MongoDB-style)
- You want reactive UI updates without React/Vue/Angular
- You want to drop in a single
<script>tag and start working - You don't want to learn RxJS, Redux, or a new state management paradigm
- You're building with Livewire, HTMX, Alpine, or vanilla JS and need structured client-side state
Install
npm install @phpdot/statedbCDN
<!-- Minified (~20KB) -->
<script src="https://unpkg.com/@phpdot/statedb/dist/index.min.js"></script>
<!-- Non-minified (readable) -->
<script src="https://unpkg.com/@phpdot/statedb/dist/index.js"></script>
<script>
const db = new StateDB('my-app');
</script>ESM / CJS
// ESM
import { StateDB } from '@phpdot/statedb';
// CJS
const { StateDB } = require('@phpdot/statedb');Quick Start
import { StateDB } from '@phpdot/statedb';
// Create a database and collection
const db = new StateDB('mydb');
const users = db.createCollection('users');
// Insert documents
users.insertOne({ name: 'Omar', age: 30, role: 'admin' });
users.insertMany([
{ name: 'Ali', age: 25, role: 'user' },
{ name: 'Sara', age: 28, role: 'admin' },
]);
// Query with MongoDB operators
const admins = users.find({ role: 'admin' }).sort({ age: -1 }).toArray();
// [{ name: 'Omar', age: 30, ... }, { name: 'Sara', age: 28, ... }]
// Update with operators
users.updateOne({ name: 'Ali' }, { $set: { role: 'admin' }, $inc: { age: 1 } });
// React to changes
users.watch((event, docs) => {
console.log(event, docs); // 'update', [{ name: 'Ali', ... }]
});
// Delete
users.deleteMany({ role: 'user' });Features
CRUD Operations
const users = db.createCollection('users');
// Insert
const doc = users.insertOne({ name: 'Omar', age: 30 });
// → { __inc: 1, name: 'Omar', age: 30 }
const docs = users.insertMany([{ name: 'Ali' }, { name: 'Sara' }]);
// Find
users.find().toArray(); // all documents
users.find({ role: 'admin' }).toArray(); // with query
users.findOne({ name: 'Omar' }); // single document
users.findOne({ __inc: 1 }); // by ID (O(1) lookup)
// Helpers
users.count({ role: 'admin' }); // count matching
users.exists({ name: 'Omar' }); // true/false
users.first(); // first document
users.last(); // last document
// Update
users.updateOne({ name: 'Omar' }, { $set: { age: 31 } });
users.updateMany({ role: 'user' }, { $set: { active: true } });
users.replaceOne({ name: 'Omar' }, { name: 'Omar H', age: 31 });
users.upsertOne({ email: '[email protected]' }, { name: 'New', email: '[email protected]' });
// Delete
users.deleteOne({ name: 'Omar' });
users.deleteMany({ active: false });
users.drop(); // remove all documentsEvery document gets an auto-incrementing __inc field used as the primary key. This is never reused, even after deletes.
Query Operators
Full MongoDB-style query syntax with dot notation for nested fields.
| Comparison | Logical | Array | Element | Evaluation |
|-----------|---------|-------|---------|------------|
| $eq $ne | $and | $all | $exists | $regex |
| $gt $gte | $or | $elemMatch | $type | |
| $lt $lte | $not | $size | | |
| $in $nin | $nor | | | |
// Comparison
users.find({ age: { $gt: 25, $lte: 35 } });
// Logical
users.find({ $or: [{ role: 'admin' }, { age: { $gte: 30 } }] });
users.find({ $and: [{ active: true }, { $not: { role: 'guest' } }] });
// Array
users.find({ tags: { $all: ['js', 'ts'] } });
users.find({ scores: { $elemMatch: { $gte: 90 } } });
users.find({ tags: { $size: 3 } });
// Element
users.find({ email: { $exists: true } });
users.find({ age: { $type: 'number' } });
// Regex
users.find({ name: { $regex: /^om/i } });
// Inclusion
users.find({ status: { $in: ['active', 'pending'] } });
users.find({ role: { $nin: ['banned'] } });
// Dot notation (nested fields)
users.find({ 'address.city': 'Dubai' });
users.find({ 'profile.settings.theme': 'dark' });Update Operators
| Field | Numeric | Array |
|-------|---------|-------|
| $set $unset | $inc $mul | $push $pop |
| $rename | $min $max | $pull $pullAll |
| $currentDate | | $addToSet |
// Set fields (supports dot notation)
users.updateOne({ name: 'Omar' }, {
$set: { 'address.city': 'Dubai', active: true },
});
// Increment / multiply
users.updateOne({ __inc: 1 }, { $inc: { loginCount: 1 } });
users.updateOne({ __inc: 1 }, { $mul: { price: 1.1 } });
// Min / max (only updates if new value is lower/higher)
users.updateOne({ __inc: 1 }, { $min: { lowest: 5 }, $max: { highest: 100 } });
// Remove fields
users.updateOne({ __inc: 1 }, { $unset: { tempField: '' } });
// Rename fields
users.updateOne({ __inc: 1 }, { $rename: { oldName: 'newName' } });
// Set current date
users.updateOne({ __inc: 1 }, { $currentDate: { updatedAt: true } });
// Array: push
users.updateOne({ __inc: 1 }, { $push: { tags: 'new' } });
users.updateOne({ __inc: 1 }, {
$push: { tags: { $each: ['a', 'b'], $position: 0 } },
});
// Array: add unique
users.updateOne({ __inc: 1 }, { $addToSet: { tags: 'unique' } });
users.updateOne({ __inc: 1 }, { $addToSet: { tags: { $each: ['a', 'b'] } } });
// Array: remove
users.updateOne({ __inc: 1 }, { $pull: { tags: 'old' } });
users.updateOne({ __inc: 1 }, { $pullAll: { tags: ['a', 'b'] } });
users.updateOne({ __inc: 1 }, { $pop: { tags: 1 } }); // last
users.updateOne({ __inc: 1 }, { $pop: { tags: -1 } }); // first
// Simple merge (no operators — merges fields directly)
users.updateOne({ __inc: 1 }, { name: 'Omar H', age: 31 });Chainable Queries
users.find({ active: true })
.sort({ age: -1 }) // sort descending
.skip(10) // pagination offset
.limit(5) // max results
.project({ name: 1, age: 1 }) // include only these fields
.toArray(); // execute and return array
// Iteration helpers
users.find().forEach((doc) => console.log(doc));
users.find().map((doc) => doc.name); // ['Omar', 'Ali', ...]
users.find().filter((doc) => doc.age > 25);
users.find().count();
users.find().first();
users.find().last();Projection
// Include mode — only return specified fields (+ __inc always included)
users.find({}, { name: 1, email: 1 }).toArray();
// [{ __inc: 1, name: 'Omar', email: '[email protected]' }]
// Exclude mode — return all fields except specified
users.find({}, { password: 0, secret: 0 }).toArray();
// Works with dot notation
users.find({}, { 'address.city': 1 }).toArray();Watchers (Reactivity)
Subscribe to data changes. Watchers fire synchronously after every mutation — ideal for re-rendering UI.
// Watch all changes
const id = users.watch((event, docs, prevDocs) => {
// event: 'insert' | 'update' | 'delete' | 'drop'
// docs: affected documents (current state)
// prevDocs: previous state (null for insert/drop)
console.log(event, docs);
});
// Watch specific operations only
users.watch(callback, { ops: ['insert', 'delete'] });
// Stop watching
users.unwatch(id); // remove specific watcher
users.unwatch(); // remove all watchersReal-world example: reactive UI
<ul id="user-list"></ul>
<script>
const db = new StateDB('app');
const users = db.createCollection('users');
// Re-render whenever data changes
users.watch(function() {
var list = document.getElementById('user-list');
list.textContent = '';
users.find().sort({ name: 1 }).toArray().forEach(function(u) {
var li = document.createElement('li');
li.textContent = u.name + ' (' + u.age + ')';
list.appendChild(li);
});
});
// These automatically trigger the watcher
users.insertOne({ name: 'Omar', age: 30 });
users.updateOne({ name: 'Omar' }, { $inc: { age: 1 } });
</script>Schema Validation
Define schemas when creating collections. Documents are validated on every insert and update.
const users = db.createCollection('users', {
schema: {
name: { type: String, required: true, minLength: 2, maxLength: 50 },
email: { type: String, required: true, match: /^.+@.+\..+$/ },
age: { type: Number, min: 0, max: 150 },
role: { type: String, enum: ['admin', 'user', 'guest'], default: 'user' },
tags: { type: Array, of: String },
bio: { type: String },
active:{ type: Boolean, default: true },
joined:{ type: Date, default: () => new Date() },
},
});Schema rules
| Rule | Type | Description |
|------|------|-------------|
| type | String Number Boolean Date Array Object | Type checking |
| required | boolean | Must be present on insert |
| default | value or () => value | Applied on insert if field is missing |
| min / max | number | For Number fields |
| minLength / maxLength | number | For String fields |
| enum | array | Allowed values |
| match | RegExp | Pattern match for String fields |
| of | type constructor | Element type for Array fields |
// Defaults are applied automatically
users.insertOne({ name: 'Omar', email: '[email protected]' });
// → { name: 'Omar', email: '[email protected]', role: 'user', active: true, joined: Date, __inc: 1 }
// Validation errors throw
users.insertOne({ name: 'A' });
// Error: Validation error: name must be at least 2 characters
users.insertOne({ age: 'not a number' });
// Error: Validation error: age must be of type NumberCapped collections
Automatically remove the oldest documents when the collection reaches a maximum size. Useful for logs, recent activity, or chat messages.
const logs = db.createCollection('logs', {
schema: {},
capped: true,
max: 1000,
});
// When the 1001st doc is inserted, the oldest is removed automaticallyIndexes
Create indexes for fast lookups. Queries automatically use the best available index.
// Single field index
users.createIndex('email', { unique: true });
// Compound index (multiple fields)
users.createIndex({ country: 1, city: 1 });
// Queries use indexes automatically
users.findOne({ email: '[email protected]' }); // IXSCAN (fast)
users.find({ country: 'UAE', city: 'Dubai' }); // uses compound index
users.find({ country: 'UAE' }); // uses compound index (prefix match)
users.find({ city: 'Dubai' }); // COLLSCAN (city is not the prefix)
// Manage indexes
users.getIndexes(); // ['__inc', 'email', 'country_city']
users.dropIndex('email');Explain
Inspect how a query is executed — whether it uses an index or a full collection scan.
const explain = users.find({ email: '[email protected]' }).explain();
console.log(explain.queryPlanner.winningPlan);
// { stage: 'FETCH', inputStage: { stage: 'IXSCAN', indexName: 'email' } }
console.log(explain.executionStats);
// { nReturned: 1, totalDocsExamined: 1, indexUsed: true, executionTimeMs: 0.02 }Hooks (Middleware)
Run functions before or after operations. Pre-hooks can modify data or abort the operation.
// Add timestamps automatically
users.pre('insert', (doc) => {
doc.createdAt = new Date();
doc.updatedAt = new Date();
});
users.pre('update', (query, update) => {
update.$set = update.$set || {};
update.$set.updatedAt = new Date();
});
// Abort an operation (return false)
users.pre('delete', (query) => {
const doc = users.findOne(query);
if (doc.role === 'admin') return false; // prevent deleting admins
});
// Post-hooks for side effects
users.post('insert', (doc) => {
console.log('New user:', doc.name);
});
users.post('update', (result, query, update) => {
console.log(`Modified ${result.modifiedCount} documents`);
});
users.post('delete', (result, query) => {
console.log(`Deleted ${result.deletedCount} documents`);
});
// Remove hooks
users.removePre('insert'); // remove all pre-insert hooks
users.removePre('insert', specificFn); // remove specific hook
users.removePost('update');Persistence
Optionally save data to localStorage or sessionStorage. Data auto-saves on every mutation (debounced) and auto-loads on initialization.
const db = new StateDB('mydb', {
persistent: true,
storage: 'local', // 'local' (default) or 'session'
debounce: 100, // ms between saves (default: 100)
});
// Data auto-saves after mutations
// Data auto-loads when StateDB is created
// Manual controls
db.save(); // force save now
db.load(); // force reload from storage
db.flush(); // clear storage completelyExport / Import
Move data in and out of StateDB.
// Export — clean data without __inc (for APIs, downloads)
const data = db.export();
// { users: [{ name: 'Omar', age: 30 }, { name: 'Ali', age: 25 }] }
// Dump — full data with __inc and counters (for backups)
const backup = db.dump();
// { users: { documents: [{ __inc: 1, name: 'Omar', ... }], inc: 3 } }
// Import clean data (assigns new __inc values)
db.import({ users: [{ name: 'Omar' }, { name: 'Ali' }] });
// Restore from dump (preserves __inc values)
db.restore(backup);Browser Usage (IIFE)
StateDB works anywhere JavaScript runs. For browsers without a build tool, use the IIFE bundle:
<script src="https://unpkg.com/@phpdot/statedb/dist/index.min.js"></script>
<script>
var db = new StateDB('app');
var todos = db.createCollection('todos');
// Reactive rendering
todos.watch(function() {
var list = document.getElementById('list');
list.textContent = '';
todos.find().sort({ __inc: -1 }).toArray().forEach(function(t) {
var li = document.createElement('li');
li.textContent = t.text;
list.appendChild(li);
});
});
todos.insertOne({ text: 'Hello StateDB', done: false });
</script>All exports are available on the global StateDB object:
var db = new StateDB('app'); // main class
StateDB.Collection; // Collection class
StateDB.Query; // Query engine
StateDB.Schema; // Schema class
StateDB.Index; // Index class
StateDB.Watcher; // Watcher classPerformance
Benchmarked on Node v22 with 10,000 documents. Run npm run bench to reproduce.
Insert
| Operation | Time | Throughput |
|-----------|------|------------|
| insertOne x10,000 | 3.2ms | 3.1M ops/s |
| insertMany x10,000 | 1.0ms | 10.4M docs/s |
Find
| Operation | Time (1,000 queries) | Per query |
|-----------|---------------------|-----------|
| find({}).toArray() | 0.09ms | ~0.09us |
| find({ role: "admin" }) | 22ms | ~22us |
| find({ age: { $gt: 40 } }) | 53ms | ~53us |
| find({ $and + $or }) | 167ms | ~167us |
| find().sort().skip().limit() | 854ms | ~854us |
Find One
| Operation | Time (5,000 lookups) | Per lookup |
|-----------|---------------------|------------|
| findOne({ __inc }) primary key | 0.05ms | ~0.01us |
| findOne({ email }) full scan | 160ms | ~32us |
| findOne({ email }) indexed | 1.1ms | ~0.22us |
| Index speedup | | 145x |
Update
| Operation | Time (1,000 updates) | Per update |
|-----------|---------------------|-----------|
| updateOne $set (by ID) | 0.3ms | ~0.3us |
| updateOne $inc (by ID) | 0.5ms | ~0.5us |
| updateMany (~2K docs) x10 | 4.0ms | ~0.4ms/batch |
Delete
| Operation | Time |
|-----------|------|
| deleteOne x5,000 (one by one) | 2.5ms |
| deleteMany (4,000 matched) | 0.8ms |
Watchers
| Scenario | Time |
|----------|------|
| insertOne x5,000 + 1 watcher | 2.2ms |
| updateOne x1,000 + 10 watchers | 1.1ms |
Schema
| Scenario | Time (10,000 inserts) | Overhead | |----------|----------------------|----------| | Without schema | 1.9ms | - | | With schema (4 validated fields) | 7.7ms | ~4x |
API Reference
StateDB
new StateDB(name?: string, options?: StateDBOptions)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| persistent | boolean | false | Enable auto-save to storage |
| storage | 'local' \| 'session' | 'local' | Storage backend |
| debounce | number | 100 | Save debounce in ms |
| Method | Returns | Description |
|--------|---------|-------------|
| createCollection(name, options?) | Collection | Create or get collection |
| getCollection(name) | Collection \| null | Get existing collection |
| listCollections() | string[] | List collection names |
| dropCollection(name) | boolean | Drop a collection |
| drop() | boolean | Drop all collections |
| save() | boolean | Force save to storage |
| load() | boolean | Force load from storage |
| flush() | boolean | Clear storage |
| export() | ExportData | Export clean data (no __inc) |
| dump() | DumpData | Export with metadata |
| import(data) | boolean | Import clean data |
| restore(dump) | boolean | Restore from dump |
Collection
db.createCollection(name: string, options?: CollectionOptions)| Option | Type | Description |
|--------|------|-------------|
| schema | SchemaDefinition | Field validation rules |
| capped | boolean | Enable capped collection |
| max | number | Max documents (capped only) |
CRUD
| Method | Returns | Description |
|--------|---------|-------------|
| insertOne(doc) | Document \| null | Insert one document |
| insertMany(docs) | Document[] | Insert multiple documents |
| find(query?, projection?) | QueryResult | Find documents |
| findOne(query?) | Document \| null | Find one document |
| count(query?) | number | Count matching documents |
| exists(query?) | boolean | Check if any match exists |
| first() | Document \| null | First document in collection |
| last() | Document \| null | Last document in collection |
| updateOne(query, update) | UpdateResult | Update first match |
| updateMany(query, update) | UpdateResult | Update all matches |
| replaceOne(query, replacement) | UpdateResult | Replace entire document |
| upsertOne(query, doc) | UpdateResult | Update or insert |
| deleteOne(query) | DeleteResult | Delete first match |
| deleteMany(query) | DeleteResult | Delete all matches |
| drop() | DeleteResult | Remove all documents |
Indexes
| Method | Returns | Description |
|--------|---------|-------------|
| createIndex(fields, options?) | string | Create index (returns name) |
| dropIndex(name) | boolean | Remove an index |
| getIndexes() | string[] | List all index names |
Watchers
| Method | Returns | Description |
|--------|---------|-------------|
| watch(callback, options?) | number | Subscribe to changes (returns ID) |
| unwatch(id?) | void | Unsubscribe (by ID, or all) |
Hooks
| Method | Returns | Description |
|--------|---------|-------------|
| pre(op, callback) | this | Add pre-hook (return false to abort) |
| post(op, callback) | this | Add post-hook |
| removePre(op, callback?) | this | Remove pre-hook(s) |
| removePost(op, callback?) | this | Remove post-hook(s) |
QueryResult
Returned by find(). All methods except toArray(), map(), filter(), count(), first(), last(), and explain() are chainable.
| Method | Returns | Description |
|--------|---------|-------------|
| sort(spec) | this | Sort: { field: 1 } (asc) or { field: -1 } (desc) |
| skip(n) | this | Skip first n results |
| limit(n) | this | Limit to n results |
| project(spec) | this | Include ({ name: 1 }) or exclude ({ password: 0 }) fields |
| toArray() | Document[] | Execute and return results |
| forEach(fn) | this | Iterate over results |
| map(fn) | any[] | Map results |
| filter(fn) | Document[] | Filter results |
| count() | number | Number of results |
| first() | Document \| null | First result |
| last() | Document \| null | Last result |
| explain() | ExplainResult | Query execution details |
TypeScript Types
All types are exported for use in your code:
import type {
Document,
QueryFilter,
QueryOperators,
UpdateSpec,
UpdateResult,
DeleteResult,
SchemaDefinition,
CollectionOptions,
StateDBOptions,
WatcherEvent,
WatcherCallback,
ProjectionSpec,
SortSpec,
ExplainResult,
} from '@phpdot/statedb';Examples
The examples/ folder contains working demos:
basic.html— Minimal getting-started exampletodo-demo.html— Todo app with watchers and live operation logapi-demo.html— Index and query explain demo with external API data
Run locally:
npx serve .
# Open http://localhost:3000/examples/todo-demo.htmlDevelopment
# Install
npm install
# Build (ESM + CJS + IIFE + types)
npm run build
# Run tests (133 tests)
npm test
# Run benchmarks
npm run bench
# Watch mode
npm run devProject Structure
src/
index.ts Entry point (exports)
browser.ts IIFE entry (attaches classes to StateDB global)
StateDB.ts Database class, persistence
Collection.ts Collection class, CRUD delegation
Query.ts Query engine, compiled matchers
DbIndex.ts Index (single + compound, unique)
Schema.ts Schema validation
Watcher.ts Reactive event system
types.ts TypeScript interfaces
operations/
insert.ts insertOne, insertMany
find.ts find, findOne, QueryResult
update.ts updateOne, updateMany, operators
delete.ts deleteOne, deleteMany, drop
tests/ Vitest test files (133 tests)
benchmarks/ Performance benchmarks
examples/ HTML demos
dist/ Build output
index.mjs ESM
index.cjs CJS
index.js IIFE (non-minified)
index.min.js IIFE (minified, ~20KB)
index.d.ts TypeScript declarations
index.d.mts TypeScript declarations (ESM)License
MIT - Omar Hamdan
