@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 +21 -0
- package/README.md +398 -2
- package/dist/index.d.ts +267 -0
- package/dist/index.js +852 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -3
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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@mvd/memrel)
|
|
10
|
+
[](https://github.com/meesvandongen/memrel/actions/workflows/ci.yml)
|
|
11
|
+
[](https://bundlejs.com/?q=@mvd/memrel)
|
|
12
|
+
[](https://github.com/meesvandongen/memrel/blob/main/packages/memrel/src/types.ts)
|
|
13
|
+
[](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
|