@proseql/core 0.2.3 → 0.2.4
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/README.md +940 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
# @proseql/core
|
|
2
|
+
|
|
3
|
+
Runtime-agnostic in-memory database with type-safe queries, relationships, and Effect integration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @proseql/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Effect, Schema } from "effect"
|
|
15
|
+
import { createEffectDatabase } from "@proseql/core"
|
|
16
|
+
|
|
17
|
+
const BookSchema = Schema.Struct({
|
|
18
|
+
id: Schema.String,
|
|
19
|
+
title: Schema.String,
|
|
20
|
+
author: Schema.String,
|
|
21
|
+
year: Schema.Number,
|
|
22
|
+
genre: Schema.String,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const config = {
|
|
26
|
+
books: {
|
|
27
|
+
schema: BookSchema,
|
|
28
|
+
relationships: {},
|
|
29
|
+
},
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const program = Effect.gen(function* () {
|
|
33
|
+
const db = yield* createEffectDatabase(config, {
|
|
34
|
+
books: [
|
|
35
|
+
{ id: "1", title: "Dune", author: "Frank Herbert", year: 1965, genre: "sci-fi" },
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// query
|
|
40
|
+
const scifi = await db.books.query({
|
|
41
|
+
where: { genre: "sci-fi" },
|
|
42
|
+
}).runPromise
|
|
43
|
+
|
|
44
|
+
// create
|
|
45
|
+
const book = await db.books.create({
|
|
46
|
+
title: "Neuromancer",
|
|
47
|
+
author: "William Gibson",
|
|
48
|
+
year: 1984,
|
|
49
|
+
genre: "sci-fi",
|
|
50
|
+
}).runPromise
|
|
51
|
+
|
|
52
|
+
// update
|
|
53
|
+
await db.books.update("1", { genre: "classic" }).runPromise
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
await Effect.runPromise(program)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Schema Definition
|
|
60
|
+
|
|
61
|
+
Schemas use Effect's `Schema.Struct` for type-safe validation:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { Schema } from "effect"
|
|
65
|
+
|
|
66
|
+
const BookSchema = Schema.Struct({
|
|
67
|
+
id: Schema.String,
|
|
68
|
+
title: Schema.String,
|
|
69
|
+
author: Schema.String,
|
|
70
|
+
year: Schema.Number,
|
|
71
|
+
genre: Schema.String,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const AuthorSchema = Schema.Struct({
|
|
75
|
+
id: Schema.String,
|
|
76
|
+
name: Schema.String,
|
|
77
|
+
birthYear: Schema.Number,
|
|
78
|
+
country: Schema.String,
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Nested Data
|
|
83
|
+
|
|
84
|
+
Schemas can contain nested objects. ProseQL supports them everywhere — filtering, sorting, updates, aggregation, indexing, search, and computed fields.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const BookSchema = Schema.Struct({
|
|
88
|
+
id: Schema.String,
|
|
89
|
+
title: Schema.String,
|
|
90
|
+
genre: Schema.String,
|
|
91
|
+
metadata: Schema.Struct({
|
|
92
|
+
views: Schema.Number,
|
|
93
|
+
rating: Schema.Number,
|
|
94
|
+
tags: Schema.Array(Schema.String),
|
|
95
|
+
description: Schema.String,
|
|
96
|
+
}),
|
|
97
|
+
author: Schema.Struct({
|
|
98
|
+
name: Schema.String,
|
|
99
|
+
country: Schema.String,
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Two ways to reference nested fields:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// shape-mirroring — mirrors the object structure
|
|
108
|
+
await db.books.query({ where: { metadata: { rating: 5 } } }).runPromise
|
|
109
|
+
|
|
110
|
+
// dot-notation — flat string path
|
|
111
|
+
await db.books.query({ where: { "metadata.rating": 5 } }).runPromise
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Both are equivalent. Use whichever reads better in context.
|
|
115
|
+
|
|
116
|
+
## CRUD
|
|
117
|
+
|
|
118
|
+
Type-safe operations with `.runPromise` for convenience.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// create one
|
|
122
|
+
const book = await db.books.create({
|
|
123
|
+
title: "The Dispossessed",
|
|
124
|
+
author: "Ursula K. Le Guin",
|
|
125
|
+
year: 1974,
|
|
126
|
+
genre: "sci-fi",
|
|
127
|
+
}).runPromise
|
|
128
|
+
|
|
129
|
+
// create a bunch
|
|
130
|
+
const batch = await db.books.createMany([
|
|
131
|
+
{ title: "Snow Crash", author: "Neal Stephenson", year: 1992, genre: "sci-fi" },
|
|
132
|
+
{ title: "Parable of the Sower", author: "Octavia Butler", year: 1993, genre: "sci-fi" },
|
|
133
|
+
]).runPromise
|
|
134
|
+
|
|
135
|
+
// find by ID — O(1), not a scan
|
|
136
|
+
const found = await db.books.findById("1").runPromise
|
|
137
|
+
|
|
138
|
+
// update
|
|
139
|
+
await db.books.update("1", { genre: "prophetic" }).runPromise
|
|
140
|
+
|
|
141
|
+
// upsert — create if missing, update if found
|
|
142
|
+
const result = await db.books.upsert({
|
|
143
|
+
where: { id: "42" },
|
|
144
|
+
create: { title: "Hitchhiker's Guide", author: "Douglas Adams", year: 1979, genre: "comedy" },
|
|
145
|
+
update: { genre: "documentary" },
|
|
146
|
+
}).runPromise
|
|
147
|
+
|
|
148
|
+
// update many by predicate
|
|
149
|
+
await db.books.updateMany(
|
|
150
|
+
(book) => book.genre === "sci-fi",
|
|
151
|
+
{ genre: "speculative fiction" },
|
|
152
|
+
).runPromise
|
|
153
|
+
|
|
154
|
+
// upsert many
|
|
155
|
+
await db.books.upsertMany([
|
|
156
|
+
{ where: { id: "1" }, create: { title: "Dune", author: "Frank Herbert", year: 1965, genre: "sci-fi" }, update: { genre: "classic" } },
|
|
157
|
+
{ where: { id: "99" }, create: { title: "New Book", author: "New Author", year: 2024, genre: "new" }, update: { genre: "updated" } },
|
|
158
|
+
]).runPromise
|
|
159
|
+
|
|
160
|
+
// delete
|
|
161
|
+
await db.books.delete("1").runPromise
|
|
162
|
+
|
|
163
|
+
// delete by predicate
|
|
164
|
+
await db.books.deleteMany(
|
|
165
|
+
(book) => book.year < 1970,
|
|
166
|
+
).runPromise
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Update Operators
|
|
170
|
+
|
|
171
|
+
Atomic, type-safe mutations.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// increment/decrement numbers
|
|
175
|
+
await db.books.update("1", { year: { $increment: 1 } }).runPromise
|
|
176
|
+
await db.books.update("1", { year: { $decrement: 5 } }).runPromise
|
|
177
|
+
await db.books.update("1", { year: { $multiply: 2 } }).runPromise
|
|
178
|
+
|
|
179
|
+
// string append/prepend
|
|
180
|
+
await db.books.update("1", { title: { $append: " (Revised)" } }).runPromise
|
|
181
|
+
await db.books.update("1", { title: { $prepend: "The " } }).runPromise
|
|
182
|
+
|
|
183
|
+
// array operations
|
|
184
|
+
await db.books.update("1", { tags: { $append: "classic" } }).runPromise
|
|
185
|
+
await db.books.update("1", { tags: { $prepend: "must-read" } }).runPromise
|
|
186
|
+
await db.books.update("1", { tags: { $remove: "draft" } }).runPromise
|
|
187
|
+
|
|
188
|
+
// toggle booleans
|
|
189
|
+
await db.books.update("1", { inStock: { $toggle: true } }).runPromise
|
|
190
|
+
|
|
191
|
+
// explicit set (same as plain value, but composable with other operators)
|
|
192
|
+
await db.books.update("1", { genre: { $set: "masterpiece" } }).runPromise
|
|
193
|
+
|
|
194
|
+
// nested updates — deep merge preserves sibling fields
|
|
195
|
+
await db.books.update("1", { metadata: { views: 500 } }).runPromise
|
|
196
|
+
// → metadata.views = 500, metadata.rating/tags/description unchanged
|
|
197
|
+
|
|
198
|
+
// nested operators
|
|
199
|
+
await db.books.update("1", { metadata: { views: { $increment: 100 } } }).runPromise
|
|
200
|
+
|
|
201
|
+
// update multiple nested paths at once
|
|
202
|
+
await db.books.update("1", {
|
|
203
|
+
metadata: { rating: 5, views: { $increment: 200 } },
|
|
204
|
+
author: { country: "CA" },
|
|
205
|
+
}).runPromise
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Operator | Works On | What It Does |
|
|
209
|
+
|----------|----------|-------------|
|
|
210
|
+
| `$set` | everything | Explicit set (equivalent to plain value) |
|
|
211
|
+
| `$increment` | numbers | Add to current value |
|
|
212
|
+
| `$decrement` | numbers | Subtract from current value |
|
|
213
|
+
| `$multiply` | numbers | Multiply current value |
|
|
214
|
+
| `$append` | strings, arrays | Append to end |
|
|
215
|
+
| `$prepend` | strings, arrays | Prepend to beginning |
|
|
216
|
+
| `$remove` | arrays | Remove matching element(s) |
|
|
217
|
+
| `$toggle` | booleans | Flip the value |
|
|
218
|
+
|
|
219
|
+
## Querying
|
|
220
|
+
|
|
221
|
+
### Filtering
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const results = await db.books.query({
|
|
225
|
+
where: {
|
|
226
|
+
year: { $gte: 1960, $lt: 1990 },
|
|
227
|
+
genre: { $in: ["sci-fi", "fantasy"] },
|
|
228
|
+
title: { $contains: "Dark" },
|
|
229
|
+
},
|
|
230
|
+
}).runPromise
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| Operator | Works On | What It Does |
|
|
234
|
+
|----------|----------|-------------|
|
|
235
|
+
| `$eq` | everything | Equals |
|
|
236
|
+
| `$ne` | everything | Not equals |
|
|
237
|
+
| `$in` | everything | In list |
|
|
238
|
+
| `$nin` | everything | Not in list |
|
|
239
|
+
| `$gt` `$gte` `$lt` `$lte` | numbers, strings | Comparisons |
|
|
240
|
+
| `$startsWith` `$endsWith` `$contains` | strings | String matching |
|
|
241
|
+
| `$search` | strings | Token-based text search (see [Full-Text Search](#full-text-search)) |
|
|
242
|
+
| `$contains` `$all` `$size` | arrays | Array matching |
|
|
243
|
+
| `$or` | clauses | Match **any** of the given conditions |
|
|
244
|
+
| `$and` | clauses | Match **all** of the given conditions |
|
|
245
|
+
| `$not` | clause | Negate a condition |
|
|
246
|
+
|
|
247
|
+
Nested fields work with any operator — use shape-mirroring or dot-notation:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// shape-mirroring
|
|
251
|
+
const popular = await db.books.query({
|
|
252
|
+
where: { metadata: { views: { $gt: 700 } } },
|
|
253
|
+
}).runPromise
|
|
254
|
+
|
|
255
|
+
// dot-notation
|
|
256
|
+
const highlyRated = await db.books.query({
|
|
257
|
+
where: { "metadata.rating": { $gte: 4 } },
|
|
258
|
+
}).runPromise
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Or just pass a value for exact match:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const scifi = await db.books.query({ where: { genre: "sci-fi" } }).runPromise
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Logical Operators
|
|
268
|
+
|
|
269
|
+
Combine conditions with `$or`, `$and`, and `$not`:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// books that are either sci-fi OR published before 1970
|
|
273
|
+
const results = await db.books.query({
|
|
274
|
+
where: {
|
|
275
|
+
$or: [
|
|
276
|
+
{ genre: "sci-fi" },
|
|
277
|
+
{ year: { $lt: 1970 } },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
}).runPromise
|
|
281
|
+
|
|
282
|
+
// NOT fantasy
|
|
283
|
+
const notFantasy = await db.books.query({
|
|
284
|
+
where: {
|
|
285
|
+
$not: { genre: "fantasy" },
|
|
286
|
+
},
|
|
287
|
+
}).runPromise
|
|
288
|
+
|
|
289
|
+
// combine with field-level filters
|
|
290
|
+
const complex = await db.books.query({
|
|
291
|
+
where: {
|
|
292
|
+
$and: [
|
|
293
|
+
{ year: { $gte: 1960 } },
|
|
294
|
+
{ $or: [{ genre: "sci-fi" }, { genre: "fantasy" }] },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
}).runPromise
|
|
298
|
+
|
|
299
|
+
// logical operators work with nested fields too
|
|
300
|
+
const featured = await db.books.query({
|
|
301
|
+
where: {
|
|
302
|
+
$or: [
|
|
303
|
+
{ metadata: { rating: 5 } },
|
|
304
|
+
{ author: { country: "UK" } },
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
}).runPromise
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Sorting
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
const sorted = await db.books.query({
|
|
314
|
+
sort: { year: "desc", title: "asc" },
|
|
315
|
+
}).runPromise
|
|
316
|
+
|
|
317
|
+
// sort by nested fields using dot-notation
|
|
318
|
+
const mostViewed = await db.books.query({
|
|
319
|
+
sort: { "metadata.views": "desc" },
|
|
320
|
+
}).runPromise
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Field Selection
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
const titles = await db.books.query({
|
|
327
|
+
select: ["title", "author"],
|
|
328
|
+
}).runPromise
|
|
329
|
+
// → [{ title: "Dune", author: "Frank Herbert" }, ...]
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Pagination
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
// offset-based (the simple kind)
|
|
336
|
+
const page = await db.books.query({
|
|
337
|
+
sort: { title: "asc" },
|
|
338
|
+
limit: 10,
|
|
339
|
+
offset: 20,
|
|
340
|
+
}).runPromise
|
|
341
|
+
|
|
342
|
+
// cursor-based (the stable kind — inserts and deletes don't break it)
|
|
343
|
+
const page1 = await db.books.query({
|
|
344
|
+
sort: { title: "asc" },
|
|
345
|
+
cursor: { key: "title", limit: 10 },
|
|
346
|
+
}).runPromise
|
|
347
|
+
// page1.pageInfo.endCursor → "Neuromancer"
|
|
348
|
+
// page1.pageInfo.hasNextPage → true
|
|
349
|
+
|
|
350
|
+
const page2 = await db.books.query({
|
|
351
|
+
sort: { title: "asc" },
|
|
352
|
+
cursor: { key: "title", after: page1.pageInfo.endCursor, limit: 10 },
|
|
353
|
+
}).runPromise
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Aggregation
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
const stats = await db.books.aggregate({
|
|
360
|
+
count: true,
|
|
361
|
+
sum: "pages",
|
|
362
|
+
min: "year",
|
|
363
|
+
max: "year",
|
|
364
|
+
avg: "year",
|
|
365
|
+
}).runPromise
|
|
366
|
+
// → { count: 42, sum: { pages: 12840 }, min: { year: 1818 }, max: { year: 2024 }, avg: { year: 1973.5 } }
|
|
367
|
+
|
|
368
|
+
// by genre
|
|
369
|
+
const genres = await db.books.aggregate({
|
|
370
|
+
groupBy: "genre",
|
|
371
|
+
count: true,
|
|
372
|
+
}).runPromise
|
|
373
|
+
// → [
|
|
374
|
+
// { group: { genre: "sci-fi" }, count: 23 },
|
|
375
|
+
// { group: { genre: "fantasy" }, count: 12 },
|
|
376
|
+
// { group: { genre: "literary horror" }, count: 7 },
|
|
377
|
+
// ]
|
|
378
|
+
|
|
379
|
+
// filtered
|
|
380
|
+
const modern = await db.books.aggregate({
|
|
381
|
+
where: { year: { $gte: 2000 } },
|
|
382
|
+
count: true,
|
|
383
|
+
}).runPromise
|
|
384
|
+
|
|
385
|
+
// aggregate nested fields using dot-notation
|
|
386
|
+
const nested = await db.books.aggregate({
|
|
387
|
+
where: { metadata: { rating: { $gte: 4 } } },
|
|
388
|
+
count: true,
|
|
389
|
+
sum: "metadata.views",
|
|
390
|
+
avg: "metadata.rating",
|
|
391
|
+
}).runPromise
|
|
392
|
+
// → { count: 4, sum: { "metadata.views": 3600 }, avg: { "metadata.rating": 4.5 } }
|
|
393
|
+
|
|
394
|
+
// group by nested field
|
|
395
|
+
const byCountry = await db.books.aggregate({
|
|
396
|
+
groupBy: "author.country",
|
|
397
|
+
count: true,
|
|
398
|
+
}).runPromise
|
|
399
|
+
// → [
|
|
400
|
+
// { group: { "author.country": "USA" }, count: 3 },
|
|
401
|
+
// { group: { "author.country": "UK" }, count: 2 },
|
|
402
|
+
// ]
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Full-Text Search
|
|
406
|
+
|
|
407
|
+
Search text fields with token-based matching. Results are ranked by relevance.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// field-level search
|
|
411
|
+
const results = await db.books.query({
|
|
412
|
+
where: { title: { $search: "left hand" } },
|
|
413
|
+
}).runPromise
|
|
414
|
+
|
|
415
|
+
// multi-field search — terms can span across fields
|
|
416
|
+
const results = await db.books.query({
|
|
417
|
+
where: {
|
|
418
|
+
$search: { query: "herbert dune", fields: ["title", "author"] },
|
|
419
|
+
},
|
|
420
|
+
}).runPromise
|
|
421
|
+
|
|
422
|
+
// search all string fields (omit fields)
|
|
423
|
+
const results = await db.books.query({
|
|
424
|
+
where: {
|
|
425
|
+
$search: { query: "cyberpunk" },
|
|
426
|
+
},
|
|
427
|
+
}).runPromise
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Search nested fields by specifying dot-paths:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
const results = await db.books.query({
|
|
434
|
+
where: {
|
|
435
|
+
$search: { query: "cyberpunk", fields: ["metadata.description"] },
|
|
436
|
+
},
|
|
437
|
+
}).runPromise
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Add a `searchIndex` for faster lookups on large collections:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
const config = {
|
|
444
|
+
books: {
|
|
445
|
+
schema: BookSchema,
|
|
446
|
+
searchIndex: ["title", "metadata.description", "author.name"],
|
|
447
|
+
relationships: {},
|
|
448
|
+
},
|
|
449
|
+
} as const
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Without a search index, `$search` scans all entities (still works, just slower). With one, it hits the inverted index for O(tokens) candidate lookup.
|
|
453
|
+
|
|
454
|
+
## Computed Fields
|
|
455
|
+
|
|
456
|
+
Derived values that exist only at query time. Never persisted, zero overhead when not selected.
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
const config = {
|
|
460
|
+
books: {
|
|
461
|
+
schema: BookSchema,
|
|
462
|
+
computed: {
|
|
463
|
+
displayName: (book) => `${book.title} (${book.year})`,
|
|
464
|
+
isClassic: (book) => book.year < 1980,
|
|
465
|
+
// computed fields can read nested data
|
|
466
|
+
viewCount: (book) => book.metadata.views,
|
|
467
|
+
isHighlyRated: (book) => book.metadata.rating >= 4,
|
|
468
|
+
summary: (book) => `${book.title} by ${book.author.name} (${book.metadata.rating}/5)`,
|
|
469
|
+
},
|
|
470
|
+
relationships: {},
|
|
471
|
+
},
|
|
472
|
+
} as const
|
|
473
|
+
|
|
474
|
+
const program = Effect.gen(function* () {
|
|
475
|
+
const db = yield* createEffectDatabase(config, { books: initialBooks })
|
|
476
|
+
|
|
477
|
+
// computed fields appear in query results automatically
|
|
478
|
+
const books = await db.books.query().runPromise
|
|
479
|
+
// → [{ title: "Dune", year: 1965, displayName: "Dune (1965)", isClassic: true, ... }]
|
|
480
|
+
|
|
481
|
+
// filter on computed fields
|
|
482
|
+
const classics = await db.books.query({
|
|
483
|
+
where: { isClassic: true },
|
|
484
|
+
}).runPromise
|
|
485
|
+
|
|
486
|
+
// select specific fields (including computed)
|
|
487
|
+
const labels = await db.books.query({
|
|
488
|
+
select: ["displayName"],
|
|
489
|
+
}).runPromise
|
|
490
|
+
// → [{ displayName: "Dune (1965)" }, ...]
|
|
491
|
+
|
|
492
|
+
// sort by computed fields
|
|
493
|
+
const sorted = await db.books.query({
|
|
494
|
+
sort: { displayName: "asc" },
|
|
495
|
+
}).runPromise
|
|
496
|
+
})
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## Relationships
|
|
500
|
+
|
|
501
|
+
Books have authors. Authors write books. ProseQL gets it.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
const config = {
|
|
505
|
+
books: {
|
|
506
|
+
schema: BookSchema,
|
|
507
|
+
relationships: {
|
|
508
|
+
author: { type: "ref" as const, target: "authors" as const, foreignKey: "authorId" },
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
authors: {
|
|
512
|
+
schema: AuthorSchema,
|
|
513
|
+
relationships: {
|
|
514
|
+
books: { type: "inverse" as const, target: "books" as const, foreignKey: "authorId" },
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
} as const
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Populate related data in queries:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
const booksWithAuthors = await db.books.query({
|
|
524
|
+
populate: { author: true },
|
|
525
|
+
}).runPromise
|
|
526
|
+
// → [{ title: "Dune", author: { name: "Frank Herbert", ... } }, ...]
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Foreign keys are enforced. Try referencing a ghost author:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
await db.books.create({
|
|
533
|
+
title: "Mystery Novel",
|
|
534
|
+
authorId: "definitely-not-real",
|
|
535
|
+
}).runPromise
|
|
536
|
+
// → ForeignKeyError. Nice try.
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Indexing
|
|
540
|
+
|
|
541
|
+
Full scans are for people with time to kill. Declare indexes for O(1) lookups.
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
const config = {
|
|
545
|
+
books: {
|
|
546
|
+
schema: BookSchema,
|
|
547
|
+
indexes: ["genre", "authorId", ["genre", "year"]],
|
|
548
|
+
relationships: {},
|
|
549
|
+
},
|
|
550
|
+
} as const
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Nested fields use dot-notation in index declarations:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
const config = {
|
|
557
|
+
books: {
|
|
558
|
+
schema: BookSchema,
|
|
559
|
+
indexes: ["metadata.views", "metadata.rating", "author.country"],
|
|
560
|
+
relationships: {},
|
|
561
|
+
},
|
|
562
|
+
} as const
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Indexes are maintained automatically. Queries on indexed fields just... go fast.
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
// hits the genre index
|
|
569
|
+
const scifi = await db.books.query({
|
|
570
|
+
where: { genre: "sci-fi" },
|
|
571
|
+
}).runPromise
|
|
572
|
+
|
|
573
|
+
// hits the compound [genre, year] index
|
|
574
|
+
const recent = await db.books.query({
|
|
575
|
+
where: { genre: "sci-fi", year: 2024 },
|
|
576
|
+
}).runPromise
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Reactive Queries
|
|
580
|
+
|
|
581
|
+
Subscribe to live query results. Mutations automatically push updates through the stream.
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
import { Effect, Stream, Scope } from "effect"
|
|
585
|
+
|
|
586
|
+
// watch a filtered query — emits current results, then re-emits on every change
|
|
587
|
+
const program = Effect.gen(function* () {
|
|
588
|
+
const db = yield* createEffectDatabase(config, initialData)
|
|
589
|
+
|
|
590
|
+
const stream = yield* db.books.watch({
|
|
591
|
+
where: { genre: "sci-fi" },
|
|
592
|
+
sort: { year: "desc" },
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// process each emission
|
|
596
|
+
yield* Stream.runForEach(stream, (books) =>
|
|
597
|
+
Effect.sync(() => console.log("Current sci-fi:", books.length))
|
|
598
|
+
)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
// run with a scope (stream cleans up automatically when scope closes)
|
|
602
|
+
await Effect.runPromise(Effect.scoped(program))
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Watch a single entity by ID:
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
const program = Effect.gen(function* () {
|
|
609
|
+
const db = yield* createEffectDatabase(config, initialData)
|
|
610
|
+
|
|
611
|
+
const stream = yield* db.books.watchById("1")
|
|
612
|
+
|
|
613
|
+
// emits the entity (or null if it doesn't exist)
|
|
614
|
+
// re-emits on update, emits null on deletion
|
|
615
|
+
yield* Stream.runForEach(stream, (book) =>
|
|
616
|
+
Effect.sync(() => {
|
|
617
|
+
if (book) console.log("Book updated:", book.title)
|
|
618
|
+
else console.log("Book was deleted")
|
|
619
|
+
})
|
|
620
|
+
)
|
|
621
|
+
})
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Streams are debounced and deduplicated automatically — rapid mutations produce at most one emission after the debounce interval settles. Nested field changes trigger emissions too — updating `metadata.views` on a watched entity re-emits the stream.
|
|
625
|
+
|
|
626
|
+
## Lifecycle Hooks
|
|
627
|
+
|
|
628
|
+
Run logic before or after mutations. Normalize data, enforce rules, log things, live your best life.
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import { Effect } from "effect"
|
|
632
|
+
import { HookError } from "@proseql/core"
|
|
633
|
+
|
|
634
|
+
const config = {
|
|
635
|
+
books: {
|
|
636
|
+
schema: BookSchema,
|
|
637
|
+
hooks: {
|
|
638
|
+
beforeCreate: (ctx) =>
|
|
639
|
+
Effect.succeed({
|
|
640
|
+
...ctx.data,
|
|
641
|
+
title: ctx.data.title.trim(),
|
|
642
|
+
createdAt: new Date().toISOString(),
|
|
643
|
+
}),
|
|
644
|
+
|
|
645
|
+
afterCreate: (ctx) =>
|
|
646
|
+
Effect.sync(() => console.log(`New book: "${ctx.entity.title}"`)),
|
|
647
|
+
|
|
648
|
+
beforeUpdate: (ctx) =>
|
|
649
|
+
Effect.succeed({
|
|
650
|
+
...ctx.changes,
|
|
651
|
+
updatedAt: new Date().toISOString(),
|
|
652
|
+
}),
|
|
653
|
+
|
|
654
|
+
onChange: (ctx) =>
|
|
655
|
+
Effect.sync(() => console.log(`${ctx.operation} on books`)),
|
|
656
|
+
},
|
|
657
|
+
relationships: {},
|
|
658
|
+
},
|
|
659
|
+
} as const
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
Hooks can reject operations:
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
beforeCreate: (ctx) =>
|
|
666
|
+
ctx.data.year > new Date().getFullYear()
|
|
667
|
+
? Effect.fail(new HookError({
|
|
668
|
+
hook: "beforeCreate",
|
|
669
|
+
collection: "books",
|
|
670
|
+
operation: "create",
|
|
671
|
+
reason: "We don't accept books from the future",
|
|
672
|
+
message: "We don't accept books from the future",
|
|
673
|
+
}))
|
|
674
|
+
: Effect.succeed(ctx.data),
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
## Schema Migrations
|
|
678
|
+
|
|
679
|
+
Schemas change. Data shouldn't break. Migrations run automatically on load.
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
import type { Migration } from "@proseql/core"
|
|
683
|
+
|
|
684
|
+
const migrations: ReadonlyArray<Migration> = [
|
|
685
|
+
{
|
|
686
|
+
from: 0,
|
|
687
|
+
to: 1,
|
|
688
|
+
// v1 added a "genre" field
|
|
689
|
+
transform: (book) => ({
|
|
690
|
+
...book,
|
|
691
|
+
genre: book.genre ?? "uncategorized",
|
|
692
|
+
}),
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
from: 1,
|
|
696
|
+
to: 2,
|
|
697
|
+
// v2 split "author" string into "authorFirst" and "authorLast"
|
|
698
|
+
transform: (book) => ({
|
|
699
|
+
...book,
|
|
700
|
+
authorFirst: book.author?.split(" ")[0] ?? "",
|
|
701
|
+
authorLast: book.author?.split(" ").slice(1).join(" ") ?? "",
|
|
702
|
+
author: undefined,
|
|
703
|
+
}),
|
|
704
|
+
},
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
const config = {
|
|
708
|
+
books: {
|
|
709
|
+
schema: BookSchemaV2,
|
|
710
|
+
version: 2,
|
|
711
|
+
migrations,
|
|
712
|
+
relationships: {},
|
|
713
|
+
},
|
|
714
|
+
} as const
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Data at version 0? Runs 0 → 1 → 2, validates, continues. Data already at version 2? Loaded normally. Migration fails? Original data untouched.
|
|
718
|
+
|
|
719
|
+
## Transactions
|
|
720
|
+
|
|
721
|
+
All or nothing. If any operation fails, everything rolls back.
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
await db.$transaction(async (tx) => {
|
|
725
|
+
const author = await tx.authors.create({
|
|
726
|
+
name: "Becky Chambers",
|
|
727
|
+
}).runPromise
|
|
728
|
+
|
|
729
|
+
await tx.books.createMany([
|
|
730
|
+
{ title: "The Long Way to a Small, Angry Planet", authorId: author.id, year: 2014, genre: "sci-fi" },
|
|
731
|
+
{ title: "A Closed and Common Orbit", authorId: author.id, year: 2016, genre: "sci-fi" },
|
|
732
|
+
{ title: "Record of a Spaceborn Few", authorId: author.id, year: 2018, genre: "sci-fi" },
|
|
733
|
+
]).runPromise
|
|
734
|
+
|
|
735
|
+
// if anything above throws, none of it happened
|
|
736
|
+
})
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
## Unique Constraints
|
|
740
|
+
|
|
741
|
+
Some things should only exist once.
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
const config = {
|
|
745
|
+
books: {
|
|
746
|
+
schema: BookSchema,
|
|
747
|
+
uniqueFields: ["isbn"],
|
|
748
|
+
relationships: {},
|
|
749
|
+
},
|
|
750
|
+
reviews: {
|
|
751
|
+
schema: ReviewSchema,
|
|
752
|
+
uniqueFields: [["userId", "bookId"]], // one review per user per book
|
|
753
|
+
relationships: {},
|
|
754
|
+
},
|
|
755
|
+
} as const
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
await db.books.create({ title: "Dune", isbn: "978-0441172719", ... }).runPromise
|
|
760
|
+
await db.books.create({ title: "Dune (but again)", isbn: "978-0441172719", ... }).runPromise
|
|
761
|
+
// → UniqueConstraintError. There can be only one.
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
## Plugin System
|
|
765
|
+
|
|
766
|
+
Extend ProseQL with custom codecs, operators, ID generators, and global hooks.
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
import type { ProseQLPlugin } from "@proseql/core"
|
|
770
|
+
|
|
771
|
+
const regexPlugin: ProseQLPlugin = {
|
|
772
|
+
name: "regex-search",
|
|
773
|
+
operators: [{
|
|
774
|
+
name: "$regex",
|
|
775
|
+
types: ["string"],
|
|
776
|
+
evaluate: (value, pattern) =>
|
|
777
|
+
typeof value === "string" && new RegExp(pattern as string).test(value),
|
|
778
|
+
}],
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const snowflakePlugin: ProseQLPlugin = {
|
|
782
|
+
name: "snowflake-ids",
|
|
783
|
+
idGenerators: [{
|
|
784
|
+
name: "snowflake",
|
|
785
|
+
generate: () => generateSnowflakeId(),
|
|
786
|
+
}],
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const program = Effect.gen(function* () {
|
|
790
|
+
const db = yield* createEffectDatabase(config, initialData, {
|
|
791
|
+
plugins: [regexPlugin, snowflakePlugin],
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
// use the custom operator in queries
|
|
795
|
+
const matches = await db.books.query({
|
|
796
|
+
where: { title: { $regex: "^The.*" } },
|
|
797
|
+
}).runPromise
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
// reference the custom ID generator in collection config
|
|
801
|
+
const config = {
|
|
802
|
+
books: {
|
|
803
|
+
schema: BookSchema,
|
|
804
|
+
idGenerator: "snowflake", // uses the plugin's generator
|
|
805
|
+
relationships: {},
|
|
806
|
+
},
|
|
807
|
+
} as const
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Plugins can also contribute format codecs and global lifecycle hooks that run across all collections.
|
|
811
|
+
|
|
812
|
+
## Error Handling
|
|
813
|
+
|
|
814
|
+
Every error is tagged. Catch exactly what you want.
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
import { Effect } from "effect"
|
|
818
|
+
import { NotFoundError } from "@proseql/core"
|
|
819
|
+
|
|
820
|
+
const result = await Effect.runPromise(
|
|
821
|
+
db.books.findById("nope").pipe(
|
|
822
|
+
Effect.catchTag("NotFoundError", () =>
|
|
823
|
+
Effect.succeed({ title: "Book not found", suggestion: "Try the library?" }),
|
|
824
|
+
),
|
|
825
|
+
),
|
|
826
|
+
)
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Or use try/catch if that's more your speed:
|
|
830
|
+
|
|
831
|
+
```ts
|
|
832
|
+
try {
|
|
833
|
+
await db.books.findById("nope").runPromise
|
|
834
|
+
} catch (err) {
|
|
835
|
+
if (err instanceof NotFoundError) {
|
|
836
|
+
console.log("Have you tried the library?")
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
| Error | When |
|
|
842
|
+
|-------|------|
|
|
843
|
+
| `NotFoundError` | ID doesn't exist |
|
|
844
|
+
| `ValidationError` | Schema says no |
|
|
845
|
+
| `DuplicateKeyError` | ID already taken |
|
|
846
|
+
| `UniqueConstraintError` | Unique field collision |
|
|
847
|
+
| `ForeignKeyError` | Referenced entity is a ghost |
|
|
848
|
+
| `HookError` | Lifecycle hook rejected it |
|
|
849
|
+
| `TransactionError` | Transaction couldn't begin/commit/rollback |
|
|
850
|
+
| `StorageError` | Storage adapter trouble |
|
|
851
|
+
| `SerializationError` | Couldn't encode/decode |
|
|
852
|
+
| `MigrationError` | Migration went sideways |
|
|
853
|
+
| `PluginError` | Plugin validation or conflict |
|
|
854
|
+
|
|
855
|
+
## ID Generation
|
|
856
|
+
|
|
857
|
+
Pick a strategy. Or don't — we'll generate one for you.
|
|
858
|
+
|
|
859
|
+
```ts
|
|
860
|
+
import {
|
|
861
|
+
generateUUID,
|
|
862
|
+
generateNanoId,
|
|
863
|
+
generateULID,
|
|
864
|
+
generateTimestampId,
|
|
865
|
+
generatePrefixedId,
|
|
866
|
+
generateTypedId,
|
|
867
|
+
} from "@proseql/core"
|
|
868
|
+
|
|
869
|
+
generateUUID() // "550e8400-e29b-41d4-a716-446655440000"
|
|
870
|
+
generateNanoId() // "V1StGXR8_Z5jdHi6B-myT"
|
|
871
|
+
generateULID() // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
872
|
+
generateTimestampId() // "1704067200000-a3f2-0001"
|
|
873
|
+
generatePrefixedId("book") // "book_1704067200000-a3f2-0001"
|
|
874
|
+
generateTypedId("book") // "book_V1StGXR8_Z5jdHi6B-myT"
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
## Serialization
|
|
878
|
+
|
|
879
|
+
The core package includes all serialization codecs, which are runtime-agnostic:
|
|
880
|
+
|
|
881
|
+
| Format | Extension | Codec |
|
|
882
|
+
|--------|-----------|-------|
|
|
883
|
+
| JSON | `.json` | `jsonCodec()` |
|
|
884
|
+
| JSONL | `.jsonl` | `jsonlCodec()` |
|
|
885
|
+
| YAML | `.yaml` | `yamlCodec()` |
|
|
886
|
+
| JSON5 | `.json5` | `json5Codec()` |
|
|
887
|
+
| JSONC | `.jsonc` | `jsoncCodec()` |
|
|
888
|
+
| TOML | `.toml` | `tomlCodec()` |
|
|
889
|
+
| TOON | `.toon` | `toonCodec()` |
|
|
890
|
+
| Hjson | `.hjson` | `hjsonCodec()` |
|
|
891
|
+
| Prose | `.prose` | `proseCodec()` |
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
import {
|
|
895
|
+
makeSerializerLayer,
|
|
896
|
+
jsonCodec,
|
|
897
|
+
yamlCodec,
|
|
898
|
+
tomlCodec,
|
|
899
|
+
AllTextFormatsLayer,
|
|
900
|
+
} from "@proseql/core"
|
|
901
|
+
|
|
902
|
+
// pick and choose
|
|
903
|
+
makeSerializerLayer([jsonCodec(), yamlCodec(), tomlCodec()])
|
|
904
|
+
|
|
905
|
+
// or take them all (except prose, which must be registered explicitly)
|
|
906
|
+
AllTextFormatsLayer
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
### Prose Format
|
|
910
|
+
|
|
911
|
+
Prose is a human-readable format where data looks like English sentences:
|
|
912
|
+
|
|
913
|
+
```ts
|
|
914
|
+
import { proseCodec } from "@proseql/core"
|
|
915
|
+
|
|
916
|
+
// explicit template
|
|
917
|
+
proseCodec({ template: '[{id}] "{title}" by {author} ({year}) — {genre}' })
|
|
918
|
+
|
|
919
|
+
// or let it learn from the @prose directive in the file
|
|
920
|
+
proseCodec()
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
On disk, prose files look like this:
|
|
924
|
+
|
|
925
|
+
```
|
|
926
|
+
@prose [{id}] "{title}" by {author} ({year}) — {genre}
|
|
927
|
+
|
|
928
|
+
[1] "Dune" by Frank Herbert (1965) — sci-fi
|
|
929
|
+
[2] "Neuromancer" by William Gibson (1984) — sci-fi
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
## Persistence
|
|
933
|
+
|
|
934
|
+
For file persistence on Node.js, see [`@proseql/node`](https://www.npmjs.com/package/@proseql/node).
|
|
935
|
+
|
|
936
|
+
For browser storage (localStorage, sessionStorage, IndexedDB), see [`@proseql/browser`](https://www.npmjs.com/package/@proseql/browser).
|
|
937
|
+
|
|
938
|
+
## License
|
|
939
|
+
|
|
940
|
+
MIT
|