@relations-dil/relations 0.1.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/README.md ADDED
@@ -0,0 +1,824 @@
1
+ # relations-dil
2
+
3
+ **A data interface layer that doesn't care what's underneath it.**
4
+
5
+ Define your models once. Point them at a source at runtime. The same model description
6
+ drives a database in one service and a REST API in the next — because the shape of your
7
+ data doesn't change just because you changed where you put it.
8
+
9
+ A TypeScript port (usable from plain JavaScript) of [python-relations](https://github.com/relations-dil/python-relations).
10
+
11
+ ```bash
12
+ npm install @relations-dil/relations
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Contents
18
+
19
+ - [The idea](#the-idea)
20
+ - [Quick start](#quick-start)
21
+ - [Async: what changed from Python, and why](#async-what-changed-from-python-and-why)
22
+ - [TypeScript](#typescript)
23
+ - [Fields](#fields)
24
+ - [Querying](#querying)
25
+ - [Relations](#relations)
26
+ - [Titles](#titles)
27
+ - [Migrations](#migrations)
28
+ - [Models from data (YAML, JSON, anywhere)](#models-from-data-yaml-json-anywhere)
29
+ - [Writing your own source](#writing-your-own-source)
30
+ - [Reserved names](#reserved-names)
31
+ - [Differences from python-relations](#differences-from-python-relations)
32
+ - [API reference](#api-reference)
33
+
34
+ ---
35
+
36
+ ## The idea
37
+
38
+ Most ORMs bind a model to a backend. Relations doesn't. A model here is a description of
39
+ structure — fields, kinds, indexes, how it relates to other models — and nothing else.
40
+ Where that structure lives is decided at runtime by whichever source registered itself
41
+ under the model's `source` name.
42
+
43
+ That matters when you're building a service out of microservices. The service that owns
44
+ the data talks to a database. Everything else talks to that service's API. Both use the
45
+ *same model file*. You describe your data once, and you stop writing CRUD endpoints and
46
+ CRUD clients that say the same thing in three different dialects.
47
+
48
+ This package is the base layer: the abstract classes, plus a complete in-memory source so
49
+ you can run the whole thing today without picking a backend.
50
+
51
+ ---
52
+
53
+ ## Quick start
54
+
55
+ ```ts
56
+ import { Model, MockSource, OneToMany, int, str } from '@relations-dil/relations'
57
+
58
+ // A base to share the source across your models.
59
+ class Base extends Model {
60
+ static source = 'example'
61
+ }
62
+
63
+ class Unit extends Base {
64
+ static fields = { id: int, name: str }
65
+ }
66
+
67
+ class Test extends Base {
68
+ static fields = { id: int, unit_id: int, name: str }
69
+ }
70
+
71
+ // Relations are declared outside the models, so a model stays a plain description
72
+ // of its own shape.
73
+ new OneToMany(Unit, Test)
74
+
75
+ // This makes "example" an in-memory store. Swap this line to change backends.
76
+ new MockSource('example')
77
+
78
+ // Create a Unit named "yep".
79
+ await new Unit('yep').create()
80
+
81
+ // Retrieve it and read its id.
82
+ const yep = await Unit.one({ name: 'yep' }).retrieve()
83
+ yep.id // 1
84
+
85
+ // Create two more. The first non-auto field is filled positionally.
86
+ await new Unit([['people'], ['stuff']]).create()
87
+
88
+ // Retrieve one, change it, save it. update() resolves to the number of records changed.
89
+ const stuff = await Unit.one({ name: 'stuff' }).retrieve()
90
+ stuff.name = 'things'
91
+ await stuff.update() // 1
92
+
93
+ // Add a child through the relation.
94
+ const unit = await Unit.one(2).retrieve()
95
+ unit.test.add('moar')
96
+ await unit.update()
97
+
98
+ // Find tests whose parent unit is named "things", with an id above 0.
99
+ const tests = await Test.many({ unit__name: 'things', id__gt: 0 }).retrieve()
100
+ tests[0].name // 'moar'
101
+ ```
102
+
103
+ Field values are ordinary properties. `unit.name`, `unit.name = 'x'`, `units[0].name` all
104
+ work — a `Proxy` maps them onto the record underneath.
105
+
106
+ ---
107
+
108
+ ## Async: what changed from Python, and why
109
+
110
+ Python's relations is synchronous. JavaScript can't be: the runtime is single-threaded, so
111
+ anything that talks to a database, a socket, or a file hands back a `Promise` instead of
112
+ blocking. That has exactly one consequence for this library, and it's worth understanding
113
+ because everything else follows from it.
114
+
115
+ **Six methods touch the source. They are async. Everything else is not.**
116
+
117
+ | Async — awaits the source | Sync — builds criteria in memory |
118
+ | ------------------------- | -------------------------------- |
119
+ | `create()` | `one()` `many()` `bulk()` |
120
+ | `retrieve()` | `filter()` `sort()` `limit()` |
121
+ | `count()` | `set()` `add()` |
122
+ | `update()` | `export()` `query()` |
123
+ | `delete()` | field get and set |
124
+ | `titles()` | `define()` `thy()` |
125
+
126
+ So you chain synchronously and `await` once, at the end:
127
+
128
+ ```ts
129
+ const units = await Unit.many({ name__like: 'thing' }).sort('-name').limit(10).retrieve()
130
+ ```
131
+
132
+ **The one thing this costs.** Python does a lazy retrieve on attribute access —
133
+ `Unit.one(name="yep").id` quietly runs the query when you touch `.id`. JavaScript cannot:
134
+ a property getter is synchronous, and there is no `await` available inside one. So
135
+ retrieval is explicit here, at exactly one point:
136
+
137
+ ```ts
138
+ // Python
139
+ Unit.one(name="yep").id
140
+
141
+ // JavaScript
142
+ const unit = await Unit.one({ name: 'yep' }).retrieve()
143
+ unit.id
144
+ ```
145
+
146
+ Reading a field off a model that hasn't been retrieved throws a clear error rather than
147
+ returning nothing:
148
+
149
+ ```
150
+ ModelError: unit: not retrieved yet - await model.retrieve() before reading values
151
+ ```
152
+
153
+ The same applies to reaching a parent from a child, which is also a query:
154
+
155
+ ```ts
156
+ const test = await Test.one({ name: 'moar' }).retrieve()
157
+ const unit = await test.unit.retrieve()
158
+ unit.name
159
+ ```
160
+
161
+ **Doing things at the same time.** Independent queries should not wait on each other:
162
+
163
+ ```ts
164
+ // Sequential — two round trips, one after the other
165
+ const a = await Unit.one({ id: 1 }).retrieve()
166
+ const b = await Unit.one({ id: 2 }).retrieve()
167
+
168
+ // Concurrent — both in flight at once
169
+ const [a, b] = await Promise.all([
170
+ Unit.one({ id: 1 }).retrieve(),
171
+ Unit.one({ id: 2 }).retrieve()
172
+ ])
173
+ ```
174
+
175
+ **Errors.** `await` throws, so ordinary `try`/`catch` works:
176
+
177
+ ```ts
178
+ try {
179
+ await new Unit('yep').create()
180
+ } catch (error) {
181
+ if (error instanceof UniqueError) {
182
+ // the source rolled back
183
+ }
184
+ }
185
+ ```
186
+
187
+ ---
188
+
189
+ ## TypeScript
190
+
191
+ Field access goes through a `Proxy`, which the type system can't see into on its own.
192
+ `fields()` closes that gap — declare the fields once and the base class knows their types:
193
+
194
+ ```ts
195
+ import { fields, int, str, list } from '@relations-dil/relations'
196
+
197
+ class Unit extends fields({ id: int, name: str, tags: list }) {
198
+ static source = 'example'
199
+ }
200
+
201
+ const unit = await Unit.one({ name: 'yep' }).retrieve()
202
+
203
+ unit.name.toUpperCase() // string — checked
204
+ unit.nmae // error: property does not exist
205
+ unit.name = 5 // error: not a string
206
+ ```
207
+
208
+ In many mode, every field reads back as an array, and indexing gives you one model:
209
+
210
+ ```ts
211
+ const units = await Unit.many().retrieve()
212
+
213
+ units.name // string[]
214
+ units[0].name // string
215
+ ```
216
+
217
+ Pass a base class as the second argument to share configuration:
218
+
219
+ ```ts
220
+ class Base extends Model {
221
+ static source = 'example'
222
+ }
223
+
224
+ class Unit extends fields({ id: int, name: str }, Base) {}
225
+ ```
226
+
227
+ Relations are declared separately from the model, so add them with `declare` when you want
228
+ them typed. `declare` is erased at compile time and costs nothing at runtime:
229
+
230
+ ```ts
231
+ class Unit extends fields({ id: int, name: str }, Base) {
232
+ declare test: Test
233
+ }
234
+
235
+ class Test extends fields({ id: int, unit_id: int, name: str }, Base) {
236
+ declare unit: Unit
237
+ }
238
+
239
+ new OneToMany(Unit, Test)
240
+ ```
241
+
242
+ **You don't have to use any of this.** Plain `static fields = { ... }` on a `Model` works
243
+ identically at runtime; you just get `any` for field access. The package ships `.d.ts`
244
+ files either way, so JavaScript users get autocomplete on the library API.
245
+
246
+ ---
247
+
248
+ ## Fields
249
+
250
+ Python has `int`, `str` and `dict` as first-class values you can hand to a field.
251
+ JavaScript doesn't, so relations exports the same seven names as sentinels:
252
+
253
+ | Kind | Holds | Notes |
254
+ | ------- | --------------------- | ---------------------------------------------------------------- |
255
+ | `bool` | `true` / `false` | Casts the strings `"false"`, `"no"`, `"0"`, `""` to `false` |
256
+ | `int` | whole number | Truncates toward zero, like Python's `int()` |
257
+ | `float` | number | |
258
+ | `str` | string | |
259
+ | `set` | `Set` | Never null; exports as a sorted array, or in `options` order |
260
+ | `list` | array | Never null; defaults to `[]` |
261
+ | `dict` | plain object | Never null; defaults to `{}` |
262
+
263
+ ### Declaring them
264
+
265
+ ```ts
266
+ class Thing extends Model {
267
+ static source = 'example'
268
+ static fields = {
269
+ id: int, // a kind
270
+ name: str,
271
+ status: ['open', 'closed'], // options, first is the default
272
+ tags: new Set(['red', 'green', 'blue']), // a set field with options
273
+ created: () => Date.now(), // a function is a default factory
274
+ note: { kind: str, none: true, length: 40 } // the full form
275
+ }
276
+ }
277
+ ```
278
+
279
+ ### Field options
280
+
281
+ | Option | What it does |
282
+ | ------------ | ---------------------------------------------------------------------------- |
283
+ | `store` | Name in the source. Defaults to the field name. `false` means never stored. |
284
+ | `none` | Whether null is allowed. Inferred when not set. |
285
+ | `default` | A value, or a function returning one. |
286
+ | `options` | The complete set of allowed values. |
287
+ | `validation` | A regular expression, a pattern string, or a predicate. |
288
+ | `length` | Length of the value, for sources that care. |
289
+ | `readonly` | Whether the caller may write it. |
290
+ | `auto` | Whether the source generates it — an auto-increment id, say. |
291
+ | `refresh` | Reset to the default on update when untouched. Good for `updated_at`. |
292
+ | `inject` | Store this value inside another `list` or `dict` field, at a path. |
293
+ | `extract` | Pull interior values out into their own stored columns, so they can be indexed.|
294
+ | `attr` | How to flatten a class-kind value. Required when `kind` is a class. |
295
+ | `init` | How to rebuild a class-kind value. Defaults to `attr`. |
296
+ | `titles` | Which attributes of a class kind make up its title. Defaults to `attr`. |
297
+
298
+ ### Class kinds
299
+
300
+ A field can hold instances of your own class, as long as you say how to flatten it:
301
+
302
+ ```ts
303
+ class Point {
304
+ constructor(values) {
305
+ this.x = values.x
306
+ this.y = values.y
307
+ }
308
+ }
309
+
310
+ class Place extends Model {
311
+ static source = 'example'
312
+ static fields = {
313
+ id: int,
314
+ name: str,
315
+ at: { kind: Point, attr: ['x', 'y'] }
316
+ }
317
+ }
318
+
319
+ const place = new Place({ name: 'home', at: { x: 1, y: 2 } })
320
+ place.at instanceof Point // true
321
+ // stored as { x: 1, y: 2 }
322
+ ```
323
+
324
+ ### inject and extract
325
+
326
+ `inject` writes a field *into* another field, so a handful of loose values can share one
327
+ JSON column. `extract` copies a value *out* of a container into its own stored key, so a
328
+ backend can index it.
329
+
330
+ ```ts
331
+ static fields = {
332
+ id: int,
333
+ meta: { kind: dict, extract: ['owner__name'] }, // stored also as meta__owner__name
334
+ flag: { kind: bool, inject: 'meta__flags__0' } // stored inside meta
335
+ }
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Querying
341
+
342
+ `one()` expects exactly one record and throws if it finds none or several. `many()` takes
343
+ whatever it finds. Pass `false` to `retrieve()` to get `null` instead of an error:
344
+
345
+ ```ts
346
+ await Unit.one({ name: 'yep' }).retrieve() // throws if missing
347
+ await Unit.one({ name: 'yep' }).retrieve(false) // null if missing
348
+ await Unit.many({ name__like: 'ye' }).retrieve()
349
+ await Unit.many().count()
350
+ ```
351
+
352
+ ### Operators
353
+
354
+ Criteria are `field__operator`. No operator means `eq`. Prefix any operator with `not_` to
355
+ invert it.
356
+
357
+ | Operator | Meaning | Multiple values |
358
+ | -------- | -------------------------------- | --------------- |
359
+ | `eq` | equal | |
360
+ | `null` | is or isn't null | |
361
+ | `gt` | greater than | |
362
+ | `gte` | greater than or equal | |
363
+ | `lt` | less than | |
364
+ | `lte` | less than or equal | |
365
+ | `like` | contains, case-insensitive | |
366
+ | `start` | starts with, case-insensitive | |
367
+ | `end` | ends with, case-insensitive | |
368
+ | `in` | is one of | ✓ |
369
+ | `has` | contains all of | ✓ |
370
+ | `any` | contains at least one of | ✓ |
371
+ | `all` | contains exactly these | ✓ |
372
+
373
+ ```ts
374
+ await Unit.many({ id__gt: 3, name__not_like: 'test', status__in: ['open', 'held'] }).retrieve()
375
+ ```
376
+
377
+ ### Reaching inside containers
378
+
379
+ The same double-underscore notation reaches into `list` and `dict` fields, and into class
380
+ kinds through their `attr` mapping:
381
+
382
+ ```ts
383
+ await Thing.many({ meta__owner__name: 'gaf' }).retrieve()
384
+ await Thing.many({ tags__0: 'red' }).retrieve()
385
+ await Thing.many({ meta__count__gte: 5 }).retrieve()
386
+ ```
387
+
388
+ Extra underscores change how a place is read — this is the
389
+ [overscore](#overscore) notation, exported separately if you want it:
390
+
391
+ | Underscores | Following | Meaning | Example | Equivalent |
392
+ | ----------- | --------- | -------------------- | ---------- | ------------- |
393
+ | 2 | word | key | `a__b` | `['a']['b']` |
394
+ | 2 | number | index | `a__1` | `['a'][1]` |
395
+ | 3 | number | negative index | `a___2` | `['a'][-2]` |
396
+ | 4 | number | numeric key | `a____3` | `['a']['3']` |
397
+ | 5 | number | negative numeric key | `a_____4` | `['a']['-4']` |
398
+
399
+ ### Fuzzy matching
400
+
401
+ `like` as a criterion name searches every title field at once, following parent relations:
402
+
403
+ ```ts
404
+ await Unit.many({ like: 'thing' }).retrieve()
405
+ ```
406
+
407
+ ### Sorting and limiting
408
+
409
+ ```ts
410
+ await Unit.many().sort('-name', '+id').retrieve()
411
+ await Unit.many().limit(10).retrieve()
412
+ await Unit.many().limit({ page: 3, perPage: 25 }).retrieve()
413
+
414
+ const page = await Unit.many().limit(10).retrieve()
415
+ page.overflow // true when the limit was reached, so there may be more
416
+ ```
417
+
418
+ ### Mass updates and deletes
419
+
420
+ Set values on a `many()` and every matching record gets them:
421
+
422
+ ```ts
423
+ await Unit.many({ status: 'open' }).set({ status: 'closed' }).update() // number changed
424
+ await Unit.many({ status: 'closed' }).delete() // number removed
425
+ ```
426
+
427
+ ### Bulk inserts
428
+
429
+ For loading a lot of records without reading their ids back:
430
+
431
+ ```ts
432
+ const bulk = Unit.bulk(1000)
433
+
434
+ for (const row of rows) {
435
+ await bulk.queue(row.name) // flushes to the source every 1000
436
+ }
437
+
438
+ await bulk.create() // whatever's left
439
+ ```
440
+
441
+ ---
442
+
443
+ ## Relations
444
+
445
+ Relations are declared outside the models. That's deliberate: a model stays a plain
446
+ description of its own shape, so it can be reused in a service that doesn't know the
447
+ other side exists.
448
+
449
+ ```ts
450
+ new OneToMany(Unit, Test) // unit.test (many), test.unit (one)
451
+ new OneToOne(Test, Case) // test.case (one), case.test (one)
452
+ new ManyToMany(Sister, Brother, Tie) // sister.brother, brother.sister, through Tie
453
+ ```
454
+
455
+ The joining field is found by convention — `unit_id` on the child — and every part of it
456
+ can be spelled out instead:
457
+
458
+ ```ts
459
+ new OneToMany(Unit, Test, {
460
+ parentChildAttr: 'checks', // what the parent calls its children
461
+ childParentAttr: 'owner', // what the child calls its parent
462
+ parentId: 'uuid', // the field on the parent being pointed at
463
+ childParentRef: 'owner_uuid' // the field on the child doing the pointing
464
+ })
465
+ ```
466
+
467
+ ### Working through them
468
+
469
+ ```ts
470
+ // Create children with the parent, in one go.
471
+ const unit = new Unit('a')
472
+ unit.test.add('one').add('two')
473
+ await unit.create()
474
+
475
+ // Add to an existing parent.
476
+ const found = await Unit.one({ name: 'a' }).retrieve()
477
+ found.test.add('three')
478
+ await found.update()
479
+
480
+ // Filter by the other side. Relations does the intermediate query for you.
481
+ await Test.many({ unit__name: 'a' }).retrieve() // tests whose unit is named a
482
+ await Unit.many({ test__name: 'one' }).retrieve() // units having a test named one
483
+ ```
484
+
485
+ > **Note.** `relation.add()` on a relation you haven't read starts a fresh set to create,
486
+ > rather than quietly querying the source. When you want the existing records loaded
487
+ > alongside the new ones, `await parent.child.retrieve()` first.
488
+
489
+ ### Many to many
490
+
491
+ Both sides carry a `list` field of the other side's ids, and the tie is a real model, so
492
+ it migrates like anything else:
493
+
494
+ ```ts
495
+ class Sister extends Base { static fields = { id: int, name: str, brother_id: list } }
496
+ class Brother extends Base { static fields = { id: int, name: str, sister_id: list } }
497
+
498
+ class Tie extends Base {
499
+ static fields = { sister_id: int, brother_id: int }
500
+ static id = null
501
+ static unique = { sister_brother: ['sister_id', 'brother_id'] }
502
+ }
503
+
504
+ new ManyToMany(Sister, Brother, Tie)
505
+
506
+ await new Brother({ name: 'tom', sister_id: [1, 2] }).create()
507
+
508
+ await Brother.many({ sister_id__all: [1, 2] }).retrieve() // tied to both
509
+ await Brother.many({ sister_id__any: [2] }).retrieve() // tied to either
510
+ await Sister.many({ brother__name: 'tom' }).retrieve() // by the sibling's fields
511
+ ```
512
+
513
+ ---
514
+
515
+ ## Titles
516
+
517
+ A title is the human-readable label for a record, assembled from its title fields — and,
518
+ where a title field points at a parent, from that parent's title too.
519
+
520
+ ```ts
521
+ const titles = await Unit.many().titles()
522
+
523
+ titles.ids // [1, 2] — in retrieval order
524
+ titles.get(1) // ['yep']
525
+ titles.fields // ['name']
526
+ titles.format // formatting instructions, one per value
527
+ ```
528
+
529
+ Which fields make up a title is inferred (the leading `int` and `str` fields), or you can
530
+ say so:
531
+
532
+ ```ts
533
+ class Item extends Base {
534
+ static fields = { id: int, owner_id: int, name: str }
535
+ static titles = ['owner_id', 'name'] // owner_id resolves through the relation
536
+ }
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Migrations
542
+
543
+ Definitions are backend-neutral JSON. A source converts them into whatever it needs — DDL
544
+ for a database, nothing at all for an API — so one migration history drives every backend
545
+ your models are used with.
546
+
547
+ ```ts
548
+ import { Migrations, models } from '@relations-dil/relations'
549
+ import * as schema from './models.js'
550
+
551
+ const migrations = new Migrations('ddl')
552
+
553
+ // Snapshot the models. Writes ddl/definition.json, and a timestamped migration
554
+ // alongside the previous snapshot. Resolves to whether anything changed.
555
+ await migrations.generate(models(schema))
556
+
557
+ // Convert into a source's own form, under ddl/<source>/<kind>/
558
+ await migrations.convert('example')
559
+
560
+ // Apply everything not yet applied.
561
+ await migrations.apply('example')
562
+ ```
563
+
564
+ ### Renames
565
+
566
+ A removed field and an added field can be the same field under a new name, and nothing in
567
+ the definitions can tell you which. Python prompts on stdin. This takes a function, so the
568
+ same code works in a script, a test, or a CLI you write yourself:
569
+
570
+ ```ts
571
+ const migrations = new Migrations('ddl', {
572
+ renamer: (what, added, removed) => {
573
+ // what: "people fields", "models", "unit unique indexes", ...
574
+ // return { <removed name>: <added name> } for each rename
575
+ return removed.includes('nickname') && added.includes('handle')
576
+ ? { nickname: 'handle' }
577
+ : {}
578
+ }
579
+ })
580
+ ```
581
+
582
+ The default treats nothing as a rename, so unattended runs never block.
583
+
584
+ ---
585
+
586
+ ## Models from data (YAML, JSON, anywhere)
587
+
588
+ `identity.define()` turns a model class into a plain object. `modelsFrom()` turns it back
589
+ into working model classes. The round trip means a set of models can be written as data,
590
+ saved, shipped, and loaded somewhere that has never seen the code:
591
+
592
+ ```yaml
593
+ # app.yaml
594
+ models:
595
+ unit:
596
+ source: example
597
+ name: unit
598
+ id: id
599
+ unique: {name: [name]}
600
+ fields:
601
+ - {kind: int, name: id, store: id, auto: true}
602
+ - {kind: str, name: name, store: name, none: false}
603
+ test:
604
+ source: example
605
+ name: test
606
+ id: id
607
+ unique: {name: [name]}
608
+ fields:
609
+ - {kind: int, name: id, store: id, auto: true}
610
+ - {kind: int, name: unit_id, store: unit_id}
611
+ - {kind: str, name: name, store: name, none: false}
612
+
613
+ relations:
614
+ - {kind: OneToMany, parent: unit, child: test}
615
+ ```
616
+
617
+ ```ts
618
+ import { modelsFrom, MockSource } from '@relations-dil/relations'
619
+ import { parse } from 'yaml' // any YAML parser; relations has no opinion and no dependency
620
+
621
+ const app = parse(await readFile('app.yaml', 'utf8'))
622
+
623
+ new MockSource('example')
624
+
625
+ const { unit: Unit, test: Test } = modelsFrom(app.models, { relations: app.relations })
626
+
627
+ await new Unit('yep').create()
628
+ const found = await Unit.one({ name: 'yep' }).retrieve()
629
+ ```
630
+
631
+ And back the other way:
632
+
633
+ ```ts
634
+ import { definitionsOf } from '@relations-dil/relations'
635
+ import { stringify } from 'yaml'
636
+
637
+ await writeFile('app.yaml', stringify({ models: definitionsOf([Unit, Test]) }))
638
+ ```
639
+
640
+ The data your models hold is already plain too — `model.export()` and `JSON.stringify(model)`
641
+ give you objects keyed by field name, so an app *and* its data can both be a file you save
642
+ and load.
643
+
644
+ ---
645
+
646
+ ## Writing your own source
647
+
648
+ Subclass `Source`, name it, and models pointing at that name will use it. Registration
649
+ happens in the constructor.
650
+
651
+ ```ts
652
+ import { Source } from '@relations-dil/relations'
653
+
654
+ class PostgresSource extends Source {
655
+ static KIND = 'postgresql'
656
+
657
+ constructor(name, options) {
658
+ super(name, options)
659
+ this.pool = options.pool
660
+ }
661
+
662
+ // Called once per model, to prepare its fields for this backend.
663
+ init(model) {
664
+ super.init(model)
665
+ }
666
+
667
+ async create(model) {
668
+ await super.create(model) // checks bulk/tie constraints
669
+
670
+ for (const creating of model._each('create')) {
671
+ const values = creating._record.create({})
672
+ const { rows } = await this.pool.query(/* ... */, Object.values(values))
673
+ if (model._id) creating[model._id] = rows[0][model._id]
674
+ creating._action = 'update'
675
+ creating._record._action = 'update'
676
+ }
677
+
678
+ model._action = 'update'
679
+ return model
680
+ }
681
+
682
+ async retrieve(model, verify = true) {
683
+ await super.retrieve(model, verify) // resolves many-to-many criteria
684
+ // ...
685
+ }
686
+
687
+ async count(model) { await super.count(model); /* ... */ }
688
+ async update(model) { /* ... */ }
689
+ async delete(model) { /* ... */ }
690
+ async titles(model) { await super.titles(model); /* ... */ }
691
+ }
692
+
693
+ new PostgresSource('example', { pool })
694
+ ```
695
+
696
+ The base class does the parts that are the same everywhere: resolving many-to-many
697
+ criteria into plain id filters (`Source.collateTies`), reading and writing tie records
698
+ (`Source.retrieveTies`, `Source.createTies`, `Source.deleteTies`), and walking records and
699
+ fields for definitions and migrations. `MockSource` in `src/mock.ts` is a complete,
700
+ readable implementation — about 500 lines — and is the best reference.
701
+
702
+ ---
703
+
704
+ ## Reserved names
705
+
706
+ A field can't be named the same as something a model already answers to, because both
707
+ would be reached as `model.<name>`. If your column really is called one of these, use
708
+ `store`:
709
+
710
+ ```ts
711
+ static fields = {
712
+ id: int,
713
+ count: { kind: int, store: 'count', name: 'total' } // ...or just name it something else
714
+ }
715
+ ```
716
+
717
+ The reserved list is exported as `RESERVED`:
718
+
719
+ `action`, `add`, `append`, `bulk`, `catch`, `constructor`, `count`, `create`, `define`,
720
+ `delete`, `export`, `filter`, `finally`, `insert`, `keys`, `like`, `limit`, `many`,
721
+ `match`, `models`, `one`, `overflow`, `prepare`, `query`, `queue`, `read`, `retrieve`,
722
+ `satisfy`, `set`, `size`, `sort`, `then`, `thy`, `titles`, `toJSON`, `update`, `write`
723
+
724
+ Field names also can't contain `__` (that's the path separator) or start with `_`.
725
+
726
+ ---
727
+
728
+ ## Differences from python-relations
729
+
730
+ The port is verified against the Python test suite — 340 tests, ported assertion by
731
+ assertion. Where behaviour differs, it's for a reason:
732
+
733
+ | | Python | Here | Why |
734
+ | --- | --- | --- | --- |
735
+ | **Retrieval** | Lazy, on attribute access | Explicit `await …retrieve()` | A JS property getter can't await |
736
+ | **CRUD** | Synchronous | `async` | JS I/O is non-blocking, always |
737
+ | **Class config** | `SOURCE`, `NAME`, `TITLE`, `ID`… | `static source`, `store`, `title`, `id`… | `static name` collides with `Function.name`; `store` matches the field vocabulary |
738
+ | **Source methods** | `create_field`, `has_ties` | `createField`, `hasTies` | camelCase |
739
+ | **Migration renames** | Prompts on stdin | A `renamer` function | Works unattended, and in tests |
740
+ | **`add()` on an unread relation** | Retrieves first | Starts a fresh set | The retrieve would have to be awaited |
741
+ | **`bool` casting** | `bool("false")` is `true` | `"false"`, `"no"`, `"0"`, `""` are `false` | Sane for HTTP params; Python's is a wart |
742
+ | **`int` default of `false`** | Allowed (`bool` subclasses `int`) | Rejected | JS has no such relationship |
743
+ | **`error.message`** | Bare text; name added by `__str__` | Name included; bare text on `.detail` | `assert.throws` and stack traces read `.message` |
744
+ | **Sorting on several keys** | Only the first key was used | All keys, in order | Python bug |
745
+ | **`titles` as a field name** | Allowed (typo in the reserved list) | Reserved | It's a real method |
746
+
747
+ The double-underscore criteria notation, the field options, the relation conventions, the
748
+ definition and migration JSON format, and the `overscore` path notation are all identical,
749
+ so definitions written by either implementation are readable by the other.
750
+
751
+ ---
752
+
753
+ ## API reference
754
+
755
+ ### Models
756
+
757
+ | | |
758
+ | --- | --- |
759
+ | `class X extends Model` | Declare a model. `static source`, `static fields`, and the config statics below. |
760
+ | `fields(spec, base?)` | A typed base class. See [TypeScript](#typescript). |
761
+ | `X.thy()` | Everything knowable about the model without an instance. |
762
+ | `X.define()` | The model's definition, as its source wants it. |
763
+ | `X.one(...)` / `X.many(...)` / `X.bulk(size?)` | Start a query. |
764
+ | `new X(...)` | Start a create. A trailing plain object is named values; anything before it is positional. An array of arrays or objects creates many. |
765
+
766
+ Config statics: `source`, `store`, `title`, `id`, `titles`, `list`, `unique`, `index`,
767
+ `order`, `chunk`.
768
+
769
+ Instance: `filter()`, `sort()`, `limit()`, `set()`, `add()`, `queue()`, `export()`,
770
+ `toJSON()`, `keys()`, `size`, `models`, `overflow`, `query()`, and the six async methods
771
+ `create()`, `retrieve()`, `count()`, `update()`, `delete()`, `titles()`.
772
+
773
+ ### Kinds
774
+
775
+ `bool`, `int`, `float`, `str`, `set`, `list`, `dict` — plus any class, with `attr`.
776
+
777
+ ### Relations
778
+
779
+ `OneToMany`, `OneToOne`, `ManyToMany`, and the `Relation` base.
780
+
781
+ ### Sources
782
+
783
+ `Source` (subclass this), `MockSource` (in-memory, complete), `register()`, `source()`,
784
+ `unregister()`, `clear()`, `SOURCES`.
785
+
786
+ ### Migrations and data
787
+
788
+ `Migrations`, `models(module)`, `modelFrom()`, `modelsFrom()`, `definitionsOf()`.
789
+
790
+ ### Errors
791
+
792
+ `FieldError`, `RecordError`, `ModelError`, `UniqueError`, `MigrationsError`,
793
+ `OverscoreError`. All carry the thing that went wrong, not just a message.
794
+
795
+ ### overscore
796
+
797
+ The double-underscore path notation, usable on its own:
798
+
799
+ ```ts
800
+ import * as overscore from '@relations-dil/relations/overscore'
801
+
802
+ overscore.get(data, 'things__a__b__0____1')
803
+ overscore.set(data, 'things__a__b__0____1', 'yep')
804
+ overscore.has(data, 'things__a')
805
+ overscore.parse('a__0___1') // ['a', 0, -1]
806
+ overscore.compile(['a', 0, -1]) // 'a__0___1'
807
+ ```
808
+
809
+ ---
810
+
811
+ ## Development
812
+
813
+ ```bash
814
+ npm install
815
+ npm run build # compile to dist/
816
+ npm test # compile and run the full suite
817
+ npm run typecheck # types only, no output
818
+ ```
819
+
820
+ No runtime dependencies.
821
+
822
+ ## License
823
+
824
+ MIT