@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.
Files changed (2) hide show
  1. package/README.md +940 -0
  2. 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proseql/core",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Type-safe in-memory database that persists to plain text files (JSON, YAML, TOML), built on Effect",
5
5
  "type": "module",
6
6
  "scripts": {