@mvd/memrel 0.0.0-stage → 0.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mees van Dongen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,399 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # memrel
4
+
5
+ **A tiny, synchronous, in-memory relational store for typed mock data.**
6
+
7
+ Composite keys · foreign keys · cascades · transactions · snapshots: typed end to end, with zero dependencies.
8
+
9
+ [![npm](https://img.shields.io/npm/v/@mvd/memrel?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mvd/memrel)
10
+ [![CI](https://github.com/meesvandongen/memrel/actions/workflows/ci.yml/badge.svg)](https://github.com/meesvandongen/memrel/actions/workflows/ci.yml)
11
+ [![bundle size](https://img.shields.io/bundlejs/size/@mvd/memrel)](https://bundlejs.com/?q=@mvd/memrel)
12
+ [![types](https://img.shields.io/npm/types/@mvd/memrel)](https://github.com/meesvandongen/memrel/blob/main/packages/memrel/src/types.ts)
13
+ [![license](https://img.shields.io/npm/l/@mvd/memrel)](https://github.com/meesvandongen/memrel/blob/main/LICENSE)
14
+
15
+ **[▶ Try it in the playground](https://meesvandongen.github.io/memrel/)**
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ Mock APIs and test fixtures quickly turn into a tangle of arrays, `find` calls and hand-written "delete the
22
+ user's posts too" logic. **memrel** gives you a small relational store instead: you describe keys and
23
+ relations once, and it keeps your mock data consistent. That means no duplicate keys, no dangling
24
+ references, and deletes that cascade the way your real database does.
25
+
26
+ It is **not an ORM**: there is no database underneath to map onto. Rows are plain objects, results are plain
27
+ arrays, and every lookup is a `Map` hit.
28
+
29
+ <a href="https://meesvandongen.github.io/memrel/"><img src="https://raw.githubusercontent.com/meesvandongen/memrel/main/docs/playground.png" alt="The memrel playground: deleting a user cascades through memberships and permissions, nulls a reviewer, and highlights every changed row" /></a>
30
+
31
+ ```ts
32
+ import { createDb } from '@mvd/memrel';
33
+
34
+ const db = createDb<{ user: User; post: Post }>()({
35
+ user: { key: ['id'], unique: [['email']] },
36
+ post: {
37
+ key: ['id'],
38
+ refs: {
39
+ author: { fields: ['authorId'], to: 'user', onDelete: 'cascade', inverse: 'posts' },
40
+ },
41
+ },
42
+ });
43
+
44
+ db.load({
45
+ user: [{ id: 'u1', email: 'ann@example.com', name: 'Ann' }],
46
+ post: [{ id: 'p1', authorId: 'u1', title: 'Hello' }],
47
+ });
48
+
49
+ db.user.rel({ id: 'u1' }, 'posts'); // [{ id: 'p1', authorId: 'u1', title: 'Hello' }]
50
+ db.post.insert({ id: 'p2', authorId: 'nobody', title: '?' });
51
+ // 💥 MemrelError: post ["p2"]: post.author references missing user ["nobody"]
52
+ db.user.delete({ id: 'u1' }); // { deleted: { user: [...], post: [...] }, nulled: {} }
53
+ ```
54
+
55
+ ## Highlights
56
+
57
+ | | |
58
+ | ---------------------------------- | ----------------------------------------------------------------------------------------------------- |
59
+ | 🧩 **Bring your own row types** | Plain TypeScript types from anywhere: `z.infer`, openapi-typescript or hand-written. |
60
+ | 🔑 **Composite keys** | Composite primary keys, composite foreign keys, and references to `unique` keys such as emails or slugs. |
61
+ | 🔗 **Referential integrity** | `cascade`, `restrict` and `setNull`, on delete *and* on key change. |
62
+ | 🛡️ **Typed config** | Wrong field names, mismatched key types and non-nullable `setNull` fields are compile errors. |
63
+ | ⚡ **Fast and synchronous** | Every key lookup, foreign key lookup and cascade step is a `Map` lookup, never a scan. |
64
+ | ↩️ **Transactions and snapshots** | Every write is atomic. Roll back any block, or restore a seeded state before each test. |
65
+ | 📦 **Plain data** | Rows are plain objects and results are plain arrays, so they serialize straight into mock responses. |
66
+ | 🪶 **Small** | Zero dependencies, ESM, roughly 8 kB gzipped. |
67
+
68
+ ## Install
69
+
70
+ ```sh
71
+ npm install -D @mvd/memrel
72
+ # or
73
+ pnpm add -D @mvd/memrel
74
+ ```
75
+
76
+ Requires TypeScript 5 or newer for the type-level checks.
77
+
78
+ ## Defining a schema
79
+
80
+ `createDb` takes your row types first, then a small runtime config for keys and relations. The double call is
81
+ deliberate: TypeScript can't infer some type parameters while you pass others explicitly, so the first call
82
+ fixes the row types and the second infers the exact config.
83
+
84
+ ```ts
85
+ type User = { id: string; email: string; name: string };
86
+ type Org = { id: number; name: string };
87
+ type OrgUser = { orgId: number; userId: string; role: 'admin' | 'member' };
88
+ type OrgUserPermission = { memberOrgId: number; memberUserId: string; permission: string };
89
+ type Post = { id: string; authorId: string; title: string };
90
+
91
+ const db = createDb<{
92
+ user: User;
93
+ org: Org;
94
+ orgUser: OrgUser;
95
+ orgUserPermission: OrgUserPermission;
96
+ post: Post;
97
+ }>()({
98
+ user: { key: ['id'], unique: [['email']] }, // a unique key besides the primary key
99
+ org: { key: ['id'] },
100
+
101
+ orgUser: {
102
+ key: ['orgId', 'userId'], // composite primary key
103
+ refs: {
104
+ org: { fields: ['orgId'], to: 'org', onDelete: 'cascade', onUpdate: 'cascade', inverse: 'members' },
105
+ user: { fields: ['userId'], to: 'user', onDelete: 'cascade', onUpdate: 'cascade', inverse: 'memberships' },
106
+ },
107
+ },
108
+
109
+ orgUserPermission: {
110
+ key: ['memberOrgId', 'memberUserId', 'permission'],
111
+ refs: {
112
+ membership: { // composite foreign key
113
+ fields: ['memberOrgId', 'memberUserId'],
114
+ to: 'orgUser',
115
+ references: ['orgId', 'userId'], // pairs with `fields` by position
116
+ onDelete: 'cascade',
117
+ onUpdate: 'cascade',
118
+ inverse: 'permissions',
119
+ },
120
+ },
121
+ },
122
+
123
+ post: {
124
+ key: ['id'],
125
+ indexes: [['title']], // an extra equality index for `where`
126
+ refs: {
127
+ author: { fields: ['authorId'], to: 'user', onDelete: 'restrict', inverse: 'posts' },
128
+ },
129
+ },
130
+ });
131
+ ```
132
+
133
+ <details>
134
+ <summary><b>Config reference</b></summary>
135
+
136
+ **Per collection**
137
+
138
+ | Option | Required | Meaning |
139
+ | --------- | -------- | ---------------------------------------------------------------------------------------- |
140
+ | `key` | yes | Primary key fields, in order: one field or several (composite). |
141
+ | `unique` | no | Unique keys besides the primary key, e.g. `[['email'], ['orgId', 'slug']]`. |
142
+ | `refs` | no | Named outgoing references (foreign keys). |
143
+ | `indexes` | no | Extra equality indexes. Foreign keys are always indexed, so they never need declaring. |
144
+
145
+ **Per ref**
146
+
147
+ | Option | Required | Meaning |
148
+ | --------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------- |
149
+ | `fields` | yes | The child's foreign key fields, in order. |
150
+ | `to` | yes | Target collection. |
151
+ | `references` | for composite keys; else the target's `key` | Target fields; `fields[i]` pairs with `references[i]`. Must be the target's primary key or one of its `unique` keys. |
152
+ | `onDelete` | yes | `cascade`, `restrict` or `setNull`, applied when the target is deleted. |
153
+ | `onUpdate` | no, defaults to `restrict` | The same rules, applied when the referenced fields change. |
154
+ | `setNullFields` | no, defaults to all of `fields` | The fields `setNull` clears, like Postgres' `SET NULL (col)`. Useful for shared tenant columns. |
155
+ | `inverse` | no | Name of the reverse relation on the target, used by `rel`. |
156
+
157
+ Mapping is by position, never by name. A foreign key whose fields are all null references nothing; one that
158
+ is only partly null is an error (like SQL's `MATCH FULL`).
159
+
160
+ </details>
161
+
162
+ ### Mistakes are compile errors
163
+
164
+ The config is checked against your row types, and errors point at the offending property with a readable
165
+ message:
166
+
167
+ ```ts
168
+ author: { fields: ['authorId'], to: 'user', references: ['name'], onDelete: 'cascade' }
169
+ // ~~~~~~~~~~~~~~~~~~~~~
170
+ // memrel: `references` must be the target's `key` or one of its `unique` keys
171
+
172
+ author: { fields: ['authorId'], to: 'user', onDelete: 'setNull' }
173
+ // ~~~~~~~~~
174
+ // memrel: setNull requires nullable fields, but 'authorId' is not nullable
175
+
176
+ membership: { fields: ['memberOrgId'], to: 'orgUser', onDelete: 'cascade' }
177
+ // memrel: composite foreign keys need explicit `references`
178
+ ```
179
+
180
+ The types check that:
181
+
182
+ - every field list only names fields of its row type, and `to` names an existing collection;
183
+ - `fields` and `references` have the same length, and their types match position by position, so
184
+ `orgId: number` can't point at `user.id: string`;
185
+ - `references` names the target's primary key or one of its unique keys;
186
+ - `setNull` is only used on nullable fields, and unknown options (`invers: ...`) are rejected;
187
+ - `get` requires the complete key, so half a composite key doesn't compile;
188
+ - relation names in `rel` are typed per collection, and so are their results.
189
+
190
+ Everything about the actual data (duplicate keys, dangling references, the outcome of cascades) is checked at
191
+ runtime, and failures throw a `MemrelError` with a stable `code`.
192
+
193
+ ## Reading
194
+
195
+ There are three read methods. Every result is a plain value: a row, `undefined`, `null`, or a readonly
196
+ array.
197
+
198
+ ```ts
199
+ db.user.get({ id: 'u1' }); // Readonly<User> | undefined
200
+ db.orgUser.get({ orgId: 1, userId: 'u1' }); // the full composite key is required
201
+
202
+ db.post.where({ authorId: 'u1' }); // readonly Readonly<Post>[]
203
+ db.post.where({ authorId: { in: ['u1', 'u2'] } });
204
+ db.post.where({}); // every row
205
+
206
+ db.post.rel(post, 'author'); // Readonly<User>
207
+ db.user.rel(user, 'posts'); // readonly Readonly<Post>[]
208
+ db.orgUser.rel({ orgId: 1, userId: 'u1' }, 'permissions'); // through the composite foreign key
209
+ ```
210
+
211
+ - **`get(key)`** is a single map lookup.
212
+ - **`where(conditions)`** supports equality and `in`. It uses the primary key, a unique key, a foreign key or
213
+ a declared index when the conditions cover one, picking the smallest bucket, and scans otherwise. Conditions
214
+ that are `undefined` are ignored, which suits optional query parameters.
215
+ - **`rel(rowOrKey, name)`** follows a relation. A forward ref returns the target row, or `null` when the
216
+ foreign key is null. An inverse returns the referencing rows, or a single row (or `undefined`) when the
217
+ foreign key is also the child's primary or unique key.
218
+
219
+ **Results come back in insertion order.** An updated row keeps its place, even when its key changes, so mock
220
+ pagination and snapshot tests stay stable.
221
+
222
+ Everything else is plain code. Filtering, sorting, pagination and counting use array methods, and `rel` costs
223
+ the same as a built-in would, because it hits the reverse index:
224
+
225
+ ```ts
226
+ db.post.where({ authorId: 'u1' }).filter((p) => p.title.includes('zod')).slice(0, 10);
227
+ db.user.where({}).filter((u) => db.user.rel(u, 'posts').length > 0);
228
+
229
+ const toUserResponse = (u: User) => ({
230
+ ...u,
231
+ postCount: db.user.rel(u, 'posts').length,
232
+ orgIds: db.user.rel(u, 'memberships').map((m) => m.orgId),
233
+ });
234
+ ```
235
+
236
+ ## Writing
237
+
238
+ Every write keeps the store consistent, and every write is atomic: if anything fails, nothing changes.
239
+
240
+ ### Insert and load
241
+
242
+ ```ts
243
+ db.user.insert({ id: 'u1', email: 'ann@example.com', name: 'Ann' }); // returns the stored row
244
+ db.post.insert([p1, p2]); // a batch
245
+ db.user.insert(row, { onConflict: 'replace' }); // upsert, with PUT semantics
246
+ db.user.insert(rows, { onConflict: 'ignore' }); // keep existing rows, add new ones
247
+
248
+ db.load({ user: [...], org: [...], orgUser: [...], post: [...] }); // across collections, in any order
249
+ ```
250
+
251
+ References are checked once the whole batch is in, so rows in a batch can reference each other in any order,
252
+ and `load` takes exactly the shape of a JSON fixture file. Unique conflicts always throw; `onConflict` only
253
+ concerns the primary key.
254
+
255
+ ### Update
256
+
257
+ ```ts
258
+ db.post.update({ id: 'p1' }, { title: 'New title' }); // a patch
259
+ db.post.update({ id: 'p1' }, (p) => ({ title: p.title.trim() })); // derived from the current row
260
+ db.post.update(db.post.where({ authorId: 'u1' }), { authorId: 'u2' }); // several keys, one patch
261
+ ```
262
+
263
+ Any field can change, **keys included**. When a referenced value changes, each ref pointing at it applies its
264
+ `onUpdate` rule, and cascades chain: renaming `user.id` rewrites `orgUser.userId`, which changes `orgUser`'s
265
+ primary key, which in turn rewrites `orgUserPermission.memberUserId`. Rows are replaced, never mutated, so rows
266
+ and arrays you got earlier stay unchanged.
267
+
268
+ ### Delete and remove
269
+
270
+ ```ts
271
+ db.user.delete({ id: 'u1' });
272
+ db.post.delete(db.post.where({ authorId: 'u1' })); // rows contain their keys
273
+ db.remove({ user: [{ id: 'u1' }], post: [{ id: 'p1' }] }); // across collections, as one plan
274
+ ```
275
+
276
+ Deletes are planned in full before anything changes. `cascade` adds referencing rows to the plan, `setNull`
277
+ clears their foreign keys, and `restrict` is checked **against the final plan**. That's what lets "delete this
278
+ user and all their posts" succeed in one call, even though `post.author` is `restrict`:
279
+
280
+ ```ts
281
+ db.user.delete({ id: 'u1' });
282
+ // 💥 MemrelError: Cannot delete user ["u1"]: restricted by 2 rows in post.author
283
+
284
+ db.remove({ user: [{ id: 'u1' }], post: db.post.where({ authorId: 'u1' }) });
285
+ // ✅ { deleted: { user: [...], post: [...], orgUser: [...], orgUserPermission: [...] }, nulled: {} }
286
+ ```
287
+
288
+ The result holds the removed rows, ready for test assertions and mock `DELETE` handlers.
289
+
290
+ ## Transactions and snapshots
291
+
292
+ ```ts
293
+ db.transaction(() => {
294
+ db.user.insert(newUser);
295
+ db.post.update({ id: 'p1' }, { authorId: newUser.id });
296
+ }); // any throw rolls back everything inside; nested transactions roll back to where they started
297
+ ```
298
+
299
+ Seed once, and restore before each test:
300
+
301
+ ```ts
302
+ const db = createMyDb();
303
+ db.load(fixtures);
304
+ const seeded = db.snapshot();
305
+
306
+ beforeEach(() => db.restore(seeded));
307
+ ```
308
+
309
+ Snapshots share rows (rows are never mutated) and copy the indexes, which takes a few milliseconds at mock
310
+ sizes.
311
+
312
+ ## Recipe: an MSW handler
313
+
314
+ ```ts
315
+ http.get('/api/users/:id', ({ params }) => {
316
+ const user = db.user.get({ id: params.id as string });
317
+ if (!user) return new HttpResponse(null, { status: 404 });
318
+ return HttpResponse.json({ ...user, posts: db.user.rel(user, 'posts') });
319
+ });
320
+
321
+ http.delete('/api/orgs/:id', ({ params }) => {
322
+ const { deleted } = db.org.delete({ id: Number(params.id) });
323
+ return HttpResponse.json({ removedMemberships: deleted.orgUser?.length ?? 0 });
324
+ });
325
+ ```
326
+
327
+ ## Errors
328
+
329
+ Everything memrel throws on purpose is a `MemrelError` with a `code`:
330
+
331
+ | Code | When |
332
+ | ----------------------- | ------------------------------------------------------------------------ |
333
+ | `INVALID_SCHEMA` | The config passed to `createDb` is inconsistent. |
334
+ | `DUPLICATE_KEY` | A primary key is already taken. |
335
+ | `UNIQUE_VIOLATION` | A `unique` key is already taken. |
336
+ | `FOREIGN_KEY_VIOLATION` | A reference points at a missing row, or is partly null. |
337
+ | `RESTRICT_VIOLATION` | A delete or key change is blocked by a `restrict` rule. |
338
+ | `NOT_FOUND` | A key passed to `update`, `delete` or `rel` doesn't exist. |
339
+ | `INVALID_ARGUMENT` | An unknown relation name, an async transaction callback, and the like. |
340
+
341
+ ## Performance
342
+
343
+ `pnpm bench` on a laptop-class machine, with 100k posts, 100k comments and 10k users:
344
+
345
+ | Operation | Time |
346
+ | ------------------------------------------- | ---------- |
347
+ | `get` | ~0.4 µs |
348
+ | `rel`, inverse with 10 rows | ~3 µs |
349
+ | `where` on a foreign key, 10 rows | ~10 µs |
350
+ | `insert` / `update` one row | ~5–10 µs |
351
+ | cascading delete of a user, 10 posts, 10 comments | ~0.1 ms |
352
+ | `snapshot` / `restore` of 210k rows | ~0.1 s |
353
+
354
+ ## What memrel is not
355
+
356
+ memrel deliberately leaves out runtime validation (the types are the guard, so rows from `z.infer` must
357
+ already be in their final shape), a query language or `include` API, persistence, lazy relation accessors,
358
+ frozen rows, and factories. Each of these is either plain code on top or a better fit for a separate package.
359
+ The guiding rule: **a method exists only if it uses an index, or enforces something at the type level that
360
+ plain code can't.**
361
+
362
+ ## Development
363
+
364
+ This repository is a pnpm workspace built on the [Vite+](https://viteplus.dev) toolchain: Oxfmt and Oxlint
365
+ (with type-aware checks) through `vp check`, Vitest through `vp test`, and tsdown through `vp pack`.
366
+
367
+ ```
368
+ .
369
+ ├── packages/memrel the @mvd/memrel package: src, tests, benchmarks
370
+ ├── apps/playground the interactive playground, deployed to GitHub Pages
371
+ ├── docs/design.md the implementation design
372
+ ├── .changeset/ pending release notes
373
+ ├── vite.config.ts shared lint, format and task-runner config
374
+ └── pnpm-workspace.yaml workspace packages and the dependency catalog
375
+ ```
376
+
377
+ ```sh
378
+ pnpm install
379
+ pnpm dev # the playground, served from the library source
380
+ pnpm check # vp check: format, lint and type check
381
+ pnpm test # vp test in every package: runtime and type tests
382
+ pnpm build # vp pack in every package
383
+ pnpm bench # tinybench, 10k and 100k rows
384
+ pnpm ready # check, test and build, as CI runs them
385
+ pnpm changeset # describe a change for the next release
386
+ ```
387
+
388
+ ### Releasing
389
+
390
+ Releases are managed with [Changesets](https://github.com/changesets/changesets). Every pull request that changes
391
+ `@mvd/memrel` adds a changeset (`pnpm changeset`) saying whether it is a patch, minor or major change and what
392
+ changed. On `main`, the release workflow collects pending changesets into a "version packages" pull request that
393
+ bumps the version and updates `CHANGELOG.md`. Merging that pull request publishes to npm, tags the release and
394
+ creates a GitHub release. Publishing uses npm [trusted publishing](https://docs.npmjs.com/trusted-publishers)
395
+ (OIDC), so there is no npm token, and every release carries provenance.
396
+
397
+ ## License
398
+
399
+ [MIT](https://github.com/meesvandongen/memrel/blob/main/LICENSE) © Mees van Dongen