Build createORM(models) — a tiny in-memory database with one delegate per model, each exposing basic CRUD. This mirrors the shape of a real ORM like Prisma: you call db.user.create(...), db.post.findMany(...), and each model is reached through its own object hanging off db. There is no SQL and no disk — records are plain JavaScript objects held in arrays in memory.
// models: string[] | Record<string, object>
// Either a list of model names (['user', 'post']) or a definition
// object ({ user: {}, post: {} }) — you support both.
//
// createORM(models) returns an object with ONE delegate per model.
// Each delegate exposes:
// create(data) → stores data with a fresh auto-increment id; returns the record.
// findMany() → every record (an array).
// findMany({ where }) → records where each `where` field equals the record's field (AND).
// findById(id) → the matching record, or null.
// update(id, patch) → shallow-merges patch into the record; returns it, or null if missing.
// delete(id) → removes the record; returns it, or null if missing.
// count() → how many records the model holds.
function createORM(models): Record<string, Delegate>;
const db = createORM(['user', 'post']);
const ada = db.user.create({ name: 'Ada', city: 'London' });
// → { id: 1, name: 'Ada', city: 'London' } (id assigned for you)
db.user.create({ name: 'Grace', city: 'London' }); // → { id: 2, ... }
db.user.findMany({ where: { city: 'London' } });
// → [ { id: 1, name: 'Ada', ... }, { id: 2, name: 'Grace', ... } ]
db.user.update(1, { city: 'Paris' });
// → { id: 1, name: 'Ada', city: 'Paris' } (merged, not replaced)
// ids are per-model: creating users does not advance the post counter.
const db = createORM(['user', 'post']);
db.user.create({ name: 'Ada' }); // → id 1
db.post.create({ title: 'Hello' }); // → id 1 (its own counter)
db.post.count(); // → 1
db.user.count(); // → 1
createORM(['user', 'post']) returns { user, post }, where each value is an object with the seven methods above. Accept both the array form and the { user: {}, post: {} } object form.id: 1, the next id: 2, and so on. Each model counts independently — user ids and post ids do not share a sequence.where is equality only. A record matches when every field in where is strictly equal (===) to that field on the record. Multiple fields are combined with AND. No where (or findMany() with no argument) returns everything.update merges, it doesn't replace. update(1, { city: 'Paris' }) changes only city and leaves the other fields intact. Updating or deleting a missing id returns null rather than throwing.gt, lt, in, contains), no sorting, no field selection, no relations. Equality where and plain CRUD only.You'll build a factory that hands back one small object per model — a delegate — and each delegate owns a private array of records plus its own id counter.
Think of a real ORM like Prisma. You write db.user.create({ name: 'Ada' }) and db.post.findMany(), and the library quietly assigns each new row an id, remembers it, and lets you query it back later. We're building the same shape in memory: no SQL, no disk, just plain objects in arrays. createORM(['user', 'post']) returns { user, post }, and each of those is an object with seven methods — create, findMany, findById, update, delete, count. The only real bookkeeping is two things per model: the list of records, and the next id to hand out. Everything else is reading or writing that list.
A delegate is a closure. When you create it, you capture two private variables — rows (an array of records) and nextId (an integer that only ever climbs) — and you return an object whose methods read and write those captured variables. create pushes a record and bumps nextId. findMany filters rows. update finds a record and merges into it. Nothing leaves the closure except copies of records. The single most important decision in the whole problem is where you declare rows and nextId: they must be born fresh for each model, so user and post get their own. Get that placement wrong and the two models silently share one array.
The natural instinct is to loop over the model names and build a delegate for each. But it's easy to declare the storage in the wrong place — outside the loop, so it's created once and shared:
function createORM(modelNames) {
const rows = []; // declared ONCE, before the loop
let nextId = 1;
const db = {};
for (const name of modelNames) {
db[name] = {
create(data) {
const record = { id: nextId++, ...data };
rows.push(record);
return record;
},
findMany() {
return rows;
},
count() {
return rows.length;
},
// ...findById, update, delete elided
};
}
return db;
}
This compiles and even passes a single-model test, so it looks right. But every delegate closes over the same rows and the same nextId. The moment you use two models, they bleed together: db.user.create({ name: 'Ada' }) and db.post.create({ title: 'Hi' }) both push into the one shared array, so db.post.count() returns 2 and the ids interleave (user gets 1, post gets 2) instead of each starting at 1. The models aren't isolated at all.
function createORM(modelDefs) {
// Accept either a list of names (['user', 'post']) or a definition object
// ({ user: {}, post: {} }). Normalize both to an array of model names so the
// rest of the factory never has to care which form the caller used.
const modelNames = Array.isArray(modelDefs)
? modelDefs
: Object.keys(modelDefs);
const db = {};
for (const name of modelNames) {
// Each delegate closes over its OWN rows array and id counter. Because these
// live in the loop body, every model gets a fresh, isolated pair — creating
// a user can never touch posts, and the counters advance independently.
const rows = [];
let nextId = 1;
// True only when every field in `where` equals the record's field. An empty
// where (or no where) means "match everything", so findMany() returns all.
const matches = (record, where) =>
Object.keys(where).every((field) => record[field] === where[field]);
db[name] = {
create(data) {
const record = { id: nextId, ...data };
nextId += 1;
rows.push(record);
// Return a copy so the caller can't mutate our stored row by reference.
return { ...record };
},
findMany(args = {}) {
const where = args.where ?? {};
// Filter by equality, then hand back copies in a fresh array. Mutating
// the returned array or its records can't corrupt the store.
return rows
.filter((record) => matches(record, where))
.map((record) => ({ ...record }));
},
findById(id) {
const record = rows.find((r) => r.id === id);
return record ? { ...record } : null;
},
update(id, patch) {
const record = rows.find((r) => r.id === id);
if (!record) return null;
// Shallow-merge: patch fields overwrite existing ones; id is preserved
// because we never let it be patched away (id isn't part of `patch`).
Object.assign(record, patch);
return { ...record };
},
delete(id) {
const index = rows.findIndex((r) => r.id === id);
if (index === -1) return null;
// splice returns an array of removed items; we removed exactly one.
const [removed] = rows.splice(index, 1);
return removed;
},
count() {
return rows.length;
},
};
}
return db;
}
module.exports = { createORM };
Three shifts carry the fix. First, const rows = [] and let nextId = 1 move inside the loop, so each model captures its own pair — that single move is what makes the delegates isolated. Second, the two input shapes (a name list or a { user: {} } object) are normalized to an array up front with Array.isArray, so the loop never has to branch again. Third, every method that returns a record returns a copy ({ ...record }) and findMany returns a fresh array, so a caller poking at the result can't reach in and corrupt the store.
Trace four calls on a single delegate, watching rows and nextId move.
const db = createORM(['user']);
// db.user starts with rows = [], nextId = 1
db.user.create({ name: 'Ada' })
→ record = { id: 1, name: 'Ada' }; nextId becomes 2; pushed.
→ rows = [ { id: 1, name: 'Ada' } ]
→ returns { id: 1, name: 'Ada' }
db.user.create({ name: 'Linus' })
→ record = { id: 2, name: 'Linus' }; nextId becomes 3; pushed.
→ rows = [ { id: 1, name: 'Ada' }, { id: 2, name: 'Linus' } ]
db.user.update(1, { city: 'Paris' })
→ find id 1, Object.assign merges city in.
→ rows = [ { id: 1, name: 'Ada', city: 'Paris' }, { id: 2, name: 'Linus' } ]
→ returns { id: 1, name: 'Ada', city: 'Paris' }
db.user.delete(2)
→ findIndex(id === 2) is 1; splice removes that one record.
→ rows = [ { id: 1, name: 'Ada', city: 'Paris' } ]
→ returns { id: 2, name: 'Linus' }
The detail worth pausing on: after deleting id 2, nextId is still 3. Deleting a record never rewinds the counter, so the next create would hand out id 3, not reuse 2. Ids stay unique for the model's whole lifetime.
Now the where filter. findMany({ where: { city: 'London', active: true } }) calls matches on every record. matches walks the keys of where — city, then active — and a record survives only if it clears both with ===. A record in London but inactive fails on active; a record active but in Paris fails on city. Because we iterate where's keys, an empty where runs [].every(...), which is true for every record — that's why findMany() with no argument returns everything.
rows outside the loop. This is the headline bug. const rows = [] above the for loop is captured once and shared by every delegate, so db.post.count() sees the users too and the ids interleave. Move both rows and nextId inside the loop body so each model captures a fresh pair. If you console.log a post.count() of 2 after only creating users, this is why.return record (instead of { ...record }) hands the caller the actual object in your rows array. They can then write result.id = 999 and silently corrupt your store, and a later findById returns the mutated record. Return a shallow copy from create, findById, and update; return a fresh array from findMany.update(99, ...) and delete(99) on an id that isn't there must return null, not throw. Guard with if (!record) return null (update) and if (index === -1) return null (delete) before touching anything — and don't accidentally advance nextId or mutate rows on the miss.update replace instead of merge. update(1, { city: 'Paris' }) should change only city. If you write rows[i] = { id, ...patch } you wipe name and every other field. Object.assign(record, patch) merges patch onto the existing record, so untouched fields survive. (And because patch normally has no id, the id is preserved automatically.)nextId = rows.length + 1, but that reuses numbers: delete id 2 from a 3-row model and the next create would collide on an existing id. Keep a standalone nextId that only ever increments, independent of how many rows currently exist.where filtering everything out. If you implement matching as "the record equals the where object" you'll drop every row when where is {}. Iterate the keys of where with .every(...) instead: zero keys means zero conditions, and [].every(...) is true, so findMany({ where: {} }) and findMany() both return all records.where values be objects like { age: { gt: 18 } } or { id: { in: [1, 2, 3] } }. You'd detect when a where value is an operator object and dispatch to a comparator (gt, lt, in, contains) instead of ===. That's the sibling problem mini-orm-ii; keep this version equality-only.findMany({ orderBy: { age: 'desc' }, take: 10, skip: 20 }) would sort the filtered rows by a field and slice a window out. A stable sort plus an array slice covers it, but it layers another concern onto the read path — also mini-orm-ii territory.findMany({ select: { name: true } }) returns partial records, and include would stitch related models together (a post's author). Selection means projecting each record down to chosen keys; relations mean one delegate reaching into another by a foreign-key id. That's mini-orm-iii; here, every read returns the whole record and models stay independent.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build createORM(models) — a tiny in-memory database with one delegate per model, each exposing basic CRUD. This mirrors the shape of a real ORM like Prisma: you call db.user.create(...), db.post.findMany(...), and each model is reached through its own object hanging off db. There is no SQL and no disk — records are plain JavaScript objects held in arrays in memory.
// models: string[] | Record<string, object>
// Either a list of model names (['user', 'post']) or a definition
// object ({ user: {}, post: {} }) — you support both.
//
// createORM(models) returns an object with ONE delegate per model.
// Each delegate exposes:
// create(data) → stores data with a fresh auto-increment id; returns the record.
// findMany() → every record (an array).
// findMany({ where }) → records where each `where` field equals the record's field (AND).
// findById(id) → the matching record, or null.
// update(id, patch) → shallow-merges patch into the record; returns it, or null if missing.
// delete(id) → removes the record; returns it, or null if missing.
// count() → how many records the model holds.
function createORM(models): Record<string, Delegate>;
const db = createORM(['user', 'post']);
const ada = db.user.create({ name: 'Ada', city: 'London' });
// → { id: 1, name: 'Ada', city: 'London' } (id assigned for you)
db.user.create({ name: 'Grace', city: 'London' }); // → { id: 2, ... }
db.user.findMany({ where: { city: 'London' } });
// → [ { id: 1, name: 'Ada', ... }, { id: 2, name: 'Grace', ... } ]
db.user.update(1, { city: 'Paris' });
// → { id: 1, name: 'Ada', city: 'Paris' } (merged, not replaced)
// ids are per-model: creating users does not advance the post counter.
const db = createORM(['user', 'post']);
db.user.create({ name: 'Ada' }); // → id 1
db.post.create({ title: 'Hello' }); // → id 1 (its own counter)
db.post.count(); // → 1
db.user.count(); // → 1
createORM(['user', 'post']) returns { user, post }, where each value is an object with the seven methods above. Accept both the array form and the { user: {}, post: {} } object form.id: 1, the next id: 2, and so on. Each model counts independently — user ids and post ids do not share a sequence.where is equality only. A record matches when every field in where is strictly equal (===) to that field on the record. Multiple fields are combined with AND. No where (or findMany() with no argument) returns everything.update merges, it doesn't replace. update(1, { city: 'Paris' }) changes only city and leaves the other fields intact. Updating or deleting a missing id returns null rather than throwing.gt, lt, in, contains), no sorting, no field selection, no relations. Equality where and plain CRUD only.You'll build a factory that hands back one small object per model — a delegate — and each delegate owns a private array of records plus its own id counter.
Think of a real ORM like Prisma. You write db.user.create({ name: 'Ada' }) and db.post.findMany(), and the library quietly assigns each new row an id, remembers it, and lets you query it back later. We're building the same shape in memory: no SQL, no disk, just plain objects in arrays. createORM(['user', 'post']) returns { user, post }, and each of those is an object with seven methods — create, findMany, findById, update, delete, count. The only real bookkeeping is two things per model: the list of records, and the next id to hand out. Everything else is reading or writing that list.
A delegate is a closure. When you create it, you capture two private variables — rows (an array of records) and nextId (an integer that only ever climbs) — and you return an object whose methods read and write those captured variables. create pushes a record and bumps nextId. findMany filters rows. update finds a record and merges into it. Nothing leaves the closure except copies of records. The single most important decision in the whole problem is where you declare rows and nextId: they must be born fresh for each model, so user and post get their own. Get that placement wrong and the two models silently share one array.
The natural instinct is to loop over the model names and build a delegate for each. But it's easy to declare the storage in the wrong place — outside the loop, so it's created once and shared:
function createORM(modelNames) {
const rows = []; // declared ONCE, before the loop
let nextId = 1;
const db = {};
for (const name of modelNames) {
db[name] = {
create(data) {
const record = { id: nextId++, ...data };
rows.push(record);
return record;
},
findMany() {
return rows;
},
count() {
return rows.length;
},
// ...findById, update, delete elided
};
}
return db;
}
This compiles and even passes a single-model test, so it looks right. But every delegate closes over the same rows and the same nextId. The moment you use two models, they bleed together: db.user.create({ name: 'Ada' }) and db.post.create({ title: 'Hi' }) both push into the one shared array, so db.post.count() returns 2 and the ids interleave (user gets 1, post gets 2) instead of each starting at 1. The models aren't isolated at all.
function createORM(modelDefs) {
// Accept either a list of names (['user', 'post']) or a definition object
// ({ user: {}, post: {} }). Normalize both to an array of model names so the
// rest of the factory never has to care which form the caller used.
const modelNames = Array.isArray(modelDefs)
? modelDefs
: Object.keys(modelDefs);
const db = {};
for (const name of modelNames) {
// Each delegate closes over its OWN rows array and id counter. Because these
// live in the loop body, every model gets a fresh, isolated pair — creating
// a user can never touch posts, and the counters advance independently.
const rows = [];
let nextId = 1;
// True only when every field in `where` equals the record's field. An empty
// where (or no where) means "match everything", so findMany() returns all.
const matches = (record, where) =>
Object.keys(where).every((field) => record[field] === where[field]);
db[name] = {
create(data) {
const record = { id: nextId, ...data };
nextId += 1;
rows.push(record);
// Return a copy so the caller can't mutate our stored row by reference.
return { ...record };
},
findMany(args = {}) {
const where = args.where ?? {};
// Filter by equality, then hand back copies in a fresh array. Mutating
// the returned array or its records can't corrupt the store.
return rows
.filter((record) => matches(record, where))
.map((record) => ({ ...record }));
},
findById(id) {
const record = rows.find((r) => r.id === id);
return record ? { ...record } : null;
},
update(id, patch) {
const record = rows.find((r) => r.id === id);
if (!record) return null;
// Shallow-merge: patch fields overwrite existing ones; id is preserved
// because we never let it be patched away (id isn't part of `patch`).
Object.assign(record, patch);
return { ...record };
},
delete(id) {
const index = rows.findIndex((r) => r.id === id);
if (index === -1) return null;
// splice returns an array of removed items; we removed exactly one.
const [removed] = rows.splice(index, 1);
return removed;
},
count() {
return rows.length;
},
};
}
return db;
}
module.exports = { createORM };
Three shifts carry the fix. First, const rows = [] and let nextId = 1 move inside the loop, so each model captures its own pair — that single move is what makes the delegates isolated. Second, the two input shapes (a name list or a { user: {} } object) are normalized to an array up front with Array.isArray, so the loop never has to branch again. Third, every method that returns a record returns a copy ({ ...record }) and findMany returns a fresh array, so a caller poking at the result can't reach in and corrupt the store.
Trace four calls on a single delegate, watching rows and nextId move.
const db = createORM(['user']);
// db.user starts with rows = [], nextId = 1
db.user.create({ name: 'Ada' })
→ record = { id: 1, name: 'Ada' }; nextId becomes 2; pushed.
→ rows = [ { id: 1, name: 'Ada' } ]
→ returns { id: 1, name: 'Ada' }
db.user.create({ name: 'Linus' })
→ record = { id: 2, name: 'Linus' }; nextId becomes 3; pushed.
→ rows = [ { id: 1, name: 'Ada' }, { id: 2, name: 'Linus' } ]
db.user.update(1, { city: 'Paris' })
→ find id 1, Object.assign merges city in.
→ rows = [ { id: 1, name: 'Ada', city: 'Paris' }, { id: 2, name: 'Linus' } ]
→ returns { id: 1, name: 'Ada', city: 'Paris' }
db.user.delete(2)
→ findIndex(id === 2) is 1; splice removes that one record.
→ rows = [ { id: 1, name: 'Ada', city: 'Paris' } ]
→ returns { id: 2, name: 'Linus' }
The detail worth pausing on: after deleting id 2, nextId is still 3. Deleting a record never rewinds the counter, so the next create would hand out id 3, not reuse 2. Ids stay unique for the model's whole lifetime.
Now the where filter. findMany({ where: { city: 'London', active: true } }) calls matches on every record. matches walks the keys of where — city, then active — and a record survives only if it clears both with ===. A record in London but inactive fails on active; a record active but in Paris fails on city. Because we iterate where's keys, an empty where runs [].every(...), which is true for every record — that's why findMany() with no argument returns everything.
rows outside the loop. This is the headline bug. const rows = [] above the for loop is captured once and shared by every delegate, so db.post.count() sees the users too and the ids interleave. Move both rows and nextId inside the loop body so each model captures a fresh pair. If you console.log a post.count() of 2 after only creating users, this is why.return record (instead of { ...record }) hands the caller the actual object in your rows array. They can then write result.id = 999 and silently corrupt your store, and a later findById returns the mutated record. Return a shallow copy from create, findById, and update; return a fresh array from findMany.update(99, ...) and delete(99) on an id that isn't there must return null, not throw. Guard with if (!record) return null (update) and if (index === -1) return null (delete) before touching anything — and don't accidentally advance nextId or mutate rows on the miss.update replace instead of merge. update(1, { city: 'Paris' }) should change only city. If you write rows[i] = { id, ...patch } you wipe name and every other field. Object.assign(record, patch) merges patch onto the existing record, so untouched fields survive. (And because patch normally has no id, the id is preserved automatically.)nextId = rows.length + 1, but that reuses numbers: delete id 2 from a 3-row model and the next create would collide on an existing id. Keep a standalone nextId that only ever increments, independent of how many rows currently exist.where filtering everything out. If you implement matching as "the record equals the where object" you'll drop every row when where is {}. Iterate the keys of where with .every(...) instead: zero keys means zero conditions, and [].every(...) is true, so findMany({ where: {} }) and findMany() both return all records.where values be objects like { age: { gt: 18 } } or { id: { in: [1, 2, 3] } }. You'd detect when a where value is an operator object and dispatch to a comparator (gt, lt, in, contains) instead of ===. That's the sibling problem mini-orm-ii; keep this version equality-only.findMany({ orderBy: { age: 'desc' }, take: 10, skip: 20 }) would sort the filtered rows by a field and slice a window out. A stable sort plus an array slice covers it, but it layers another concern onto the read path — also mini-orm-ii territory.findMany({ select: { name: true } }) returns partial records, and include would stitch related models together (a post's author). Selection means projecting each record down to chosen keys; relations mean one delegate reaching into another by a foreign-key id. That's mini-orm-iii; here, every read returns the whole record and models stay independent.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.