@relations-dil/relations 0.2.1 → 0.2.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here, newest first. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.2.3] - 2026-10-18
9
+
10
+ Parity with python-relations 0.6.20, the four releases since 0.6.16, and the release tooling the Python repos have.
11
+
12
+ ### Added
13
+ - `Model.pre(action, ...args)` and `Model.post(action, result, ...args)`, empty hooks run around every source action (`create`, `count`, `retrieve`, `titles`, `update`, `delete`). Override `pre` to deny (throw) or rewrite (`this.filter(...)`), bulk calls included; override `post` to transform the result, it must return it. Both can be `async` and chain through `super`. (python-relations 0.6.17)
14
+ - String ids (slugs): an id that isn't an `int` is supplied by the caller instead of generated by the source. Such an id is not `auto`, can't be null and is `readonly`, and unless set otherwise it is the model's only title, has no extra unique index (it is the key) and is the default order. `MockSource` (and so `LocalSource`) keys its data by the supplied id and throws a `UniqueError` for a missing or repeated one. (0.6.18)
15
+ - `readonly` is enforced, it was declared and never read: a readonly field is written on create, skipped by `update` and mass updates, and an edited value snaps back to what is stored so `WHERE` clauses still find the record. (0.6.18)
16
+ - `RelationsError`, the base of every error relations throws, so one `catch` gets them all. `FieldError`, `ModelError`, `RecordError`, `MigrationsError` and `SourceError` extend it. (0.6.19)
17
+ - `NotFoundError` ("none retrieved") and `MultipleError` ("more than one retrieved"), both `ModelError`s with the same messages as before, thrown by `MockSource` and `RestSource` in place of a plain `ModelError`. `UniqueError` was already there. (0.6.19)
18
+ - Release tooling, as in the Python repos: one script in `scripts/` per make target. `make new` (next version, branch, `VERSION`, `package.json`, changelog section), `make release-check` (where the release is up to and what would stop it), `make release` (from `main`: the checks, npm, a clean install from the registry, the tag; it does only what's left, so it can be run again), `make verify` (the clean install), and `make test-release`, which tests the scripts against a throwaway repo and a fake registry (109 assertions).
19
+ - A `release` stage in the `Jenkinsfile`, on `main` only: a merged `VERSION` that isn't released yet is published and tagged. It needs `~/.npmrc` with a publish token on the Jenkins host; a `VERSION` that is already out is a no-op.
20
+ - `make` on its own lists the targets (`make help`), each has a description, and `make sync` does what `make version` used to.
21
+
22
+ ### Changed
23
+ - A model whose name has a `.` in it throws a `ModelError`: ids built from a model's name (relations-unifist's `seq.model.source.unum`) use the dot to separate their parts. (0.6.20)
24
+ - Models whose id field isn't an `int` get the string id defaults above, so their default titles, unique index and order change; set `titles` and `unique` to keep the old ones.
25
+ - `make version` sets `VERSION` and syncs `package.json` and the lockfile (`V=+0.0.1|+0.1|+1|x.y.z`, `+0.0.1` by default), like the Python repos. What it did before is `make sync`.
26
+ - `make tag` skips a tag that exists instead of hiding every failure, and every `docker run` in the Makefile uses `--rm`.
27
+ - `make pack` and `make publish` build from a copy, with only what the package needs mounted read-only, so nothing is written into the working tree.
28
+
29
+ ### Fixed
30
+ - `CHANGELOG.md` is in the published package, `files` listed it but the build never had it (39 files in 0.2.2, 40 now).
31
+
32
+ ## [0.2.2] - 2026-10-06
33
+
34
+ Parity with python-relations 0.6.16: an optional parent whose id is stored in a dict field of the child.
35
+
36
+ ### Added
37
+ - `childInject` on `OneTo` (and so `OneToMany` and `OneToOne`): `new OneToMany(Unum, Entity, { childInject: 'what' })` adds a nullable key field to the child, `unum_id`, stored inside the child's `what` dict at `what__relations__unum__id`, so a model that's shared between apps can take on a parent without being edited or getting a column. The dict field has to be named, it isn't touched (no `extract`), and several parents can use the same one. It throws a `ModelError` if the key field already exists, the named field is missing, or it isn't a dict.
38
+ - The key is named `<model>_<id>` when the parent and child share a source, else `<parent source>_<model>_<id>` (the source lowercased, `-` as `_`), like `bucket_app_pet_id`. `childParentRef` still overrides it.
39
+ - `DNS` and `SourceError`: source names have to be dns labels (letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen), so they're safe to use in field names. `register` throws a `SourceError` for any other name, and `OneTo` checks the parent's source when the two sources differ.
40
+
41
+ ### Changed
42
+ - An empty key means no parent: `_relate` returns `null` for a parent when its key is `null`, instead of a placeholder parent that matches nothing. Setting the key later still loads the parent.
43
+ - `Record.retrieve` checks an injected field where it's stored, in the field it lives inside, so filtering by an injected key works.
44
+ - `Field.write` doesn't store a `null` for an injected field whose path is only dict keys, it leaves the key out (or removes it), since a missing key reads back as `null` and storage has no JSON nulls to trip over. Paths through a list still set the `null`.
45
+
46
+ ## [0.2.1] - 2026-10-02
47
+
48
+ ### Added
49
+ - The example is laid out as a list of apps: `app.js` lists them (name, title, models file, source, seed), `models/<app>.js` holds each app's models and relations (source-agnostic; the source is named for the app), and `js/unum.js` is the host that builds the apps, makes their sources, seeds an empty one and starts the screens. Screens are routed by app, `/<app>/<model>` (`#/example/unit`), with an app page at `/<app>`, a nav of apps with their models in dropdowns and a home page listing the apps, so two apps can each have a model of the same name; the access function addresses models as `api/<app>/<model>`, and routes, route names and controllers are keyed `<app>_<model>`. `make dotroute` loads the models file with a script tag; `make single` inlines `app.js` and every `models/*.js`.
50
+ - Single-file pages keep **every app's data** in one file next to the page: `app.html` loads `data.js` (a plain `<script src>`, which works from `file://`) on open, and **Save to data.js** (or Cmd/Ctrl-S) writes it back, in place after one pick in Chromium (File System Access API) or as a download elsewhere. `localStorage` stays the working copy, one key per app (`relations:<app>`); one revision covers the whole file and decides whether the file or the browser is newer, and the page asks when both changed. Apps in the file that aren't running here are kept on save. A chip shows saved or unsaved, and leaving with unsaved changes asks first. Code in `example/dotroute/www/js/persist.js`; `make check-single` runs 14 jsdom checks of the rules.
51
+ - `RestSource` (`@relations-dil/relations/rest`, also exported from the index): a source that uses a relations-restx API as its backend, a port of python-relations-rest. It takes `url`, and optionally `fetch`, `headers`, `credentials` and `request`, and works in the browser or in Node. Reads go as `POST /<endpoint>` with a `{"filter": ...}` body (which restx treats as a GET), since browsers can't send a body with a GET. Errors from the API throw a `ModelError` with its message.
52
+ - `test/restx-fake.ts`: an in-process relations-restx API (a `fetch` over MockSource-backed models) the `RestSource` tests run against, with no network.
53
+
54
+ ## [0.2.0] - 2026-10-02
55
+
56
+ ### Added
57
+ - A `VERSION` file as the one place the version is set. The Makefile reads it, and `make version`, `make pack` and `make publish` sync `package.json` and the lockfile to it, so what's published always matches.
58
+ - `LocalSource` (`@relations-dil/relations/local`, also exported from the index): a source that keeps its data in `localStorage` (or any `storage` you pass), loading when it's made and saving after every successful write. `MockSource` stays purely in memory.
59
+ - `make local`: builds the single file and opens it in your browser (`open`, or `xdg-open` where there's no `open`).
60
+ - `example/single/` and `make single`: builds the doTRoute app into one self-contained `app.html` (library, UIkit, jQuery, doT, doTRoute, templates and app all inlined) that opens from disk with no server and keeps its data in `localStorage`. The example app picks `LocalSource` when the page sets `STORAGE_KEY`, and only seeds an empty store; `DRApp.load` reads templates embedded in the page when there are any.
61
+ - `make shell` mounts `~/.npmrc` too, so npm commands run as you, but only when the file exists.
62
+
63
+ ### Changed
64
+ - File access in `MockSource` and `Migrations` is loaded lazily and `node:path` is no longer used (a small `joinPath` replaces it), so the library bundles for the browser with no stub and no Node polyfills; only the file-based features need Node when called. `make dotroute` no longer needs a stub file, and `esbuild` is now a dev dependency.
65
+
66
+ ## [0.1.0] - 2026-10-02
67
+
68
+ ### Added
69
+ - Package metadata for npm: `repository`, `homepage` and `bugs`.
70
+ - Initial TypeScript port (usable from plain JavaScript) of python-relations (npm package `@relations-dil/relations`): Model, Field, Record, Relation, Source, Migrations, Titles, MockSource, overscore, and models-from-data.
71
+ - Docker-based workflow (`Dockerfile`, `Makefile`, `Jenkinsfile`) mirroring the Python repos: `make build|test|lint|setup|shell|debug|example|pack|publish|tag`. Nothing needs installing locally beyond Docker and make.
72
+ - `package.json` scripts (`build`, `test`, `coverage`, `lint`, `typecheck`, `example`) as the canonical commands; the Makefile wraps them, so `npm` works too for anyone with Node.
73
+ - ESLint (flat config, 140-column limit) and TypeScript typecheck as `lint`.
74
+ - Coverage tests (`test/coverage.test.ts`, `test/coverage-isolated.test.ts`) covering the edge cases and error paths the ported suites missed, bringing `make test` to 100% line, branch and function coverage. The isolated file runs in its own process so it can see `MigrationsRef` before `migrations.ts` replaces its stub.
75
+ - `example/dotroute/`: a playable doTRoute app that follows the layout of the other apps' `gui/www`: an empty `index.html`, one `.html` file per view (header, footer, home, form, fields, list, create, retrieve, update), the generic `js/relations.js` (Base and Model controllers, the same as the other apps' apart from `await`), and a small `js/service.js` for the app's routes. Every data call goes through one access function, `DRApp.rest(type, url, data)`, in `js/api.js`: `DRApp.http` is the other apps' request to a relations-restx API, and `DRApp.local` answers the same API from relations models running in the page, with the restx endpoints, request bodies, response shapes (`api/model`, `OPTIONS` fields with a parent's `options` and `titles`, list `formats` and `overflow`, `{updated}`/`{deleted}`) and status codes, including the alert on failure. Moving to a real backend is `DRApp.rest = DRApp.http` in `service.js`. Foreign keys render as a select of the parent's titles and display as the parent's title in lists. `make dotroute` bundles the library for the browser with esbuild (an IIFE exposing global `Relations`, with the `node:` file imports stubbed out) and serves it on http://localhost:8483 (`make dotroute PORT=9000` to change it; 8483 is ASCII T and S).
76
+
77
+ ### Changed
78
+ - The published package no longer includes `.map` files (source maps and declaration maps): `src/` isn't shipped, so they pointed at nothing. They're still built locally.
79
+ - `example/dotroute/` uses the published `@unum-pillars/uikit` theme (UIkit 3 with the `uu-*` palette) instead of a vendored UIkit 2. It's a dev dependency, and `make dotroute` copies `dist/` into `example/dotroute/www/vendor/` (gitignored). The ported views use UIkit 3 classes (`uk-navbar-container`, `uk-input`, `uk-alert-danger`, ...), with the navbar built the UIkit 3 way (a full-width bar with a container inside it) and UIkit 3's own centered container width; the other apps' wide-container override in `relations.css` was UIkit 2 CSS and isn't carried over.
80
+ - Removed provably unreachable code found while reaching 100% coverage, with no behaviour change: the `UNDEFINE` filter in `Field.define()` (no definable attribute was ever in it), the unreachable `return 0` in `count()` in `field.ts`, a duplicated no-records check in `Model._item`, and `this._proxy ?? this` / `?.` / `?? 0` fallbacks in `model.ts` and `mock.ts` that could never fire.
81
+ - Tests run directly from `.ts` via `tsx` (no compiled `dist-test/` step).
82
+ - Repo is named `typescript-relations` (Docker image too); the npm package is `@relations-dil/relations`, under the `relations-dil` npm org.
83
+ - `files` in `package.json` references `LICENSE` (repo copy) instead of `LICENSE.txt`.
84
+
85
+ ### Fixed
86
+ - Four lint violations in the ported source: a long line in `field.ts`, a `Function` type in `model.ts`, and two bare expressions in `ported-model.test.ts`.
package/README.md CHANGED
@@ -24,6 +24,8 @@ npm install @relations-dil/relations
24
24
  - [Querying](#querying)
25
25
  - [Relations](#relations)
26
26
  - [Titles](#titles)
27
+ - [Ids you supply](#ids-you-supply)
28
+ - [Hooks](#hooks)
27
29
  - [Migrations](#migrations)
28
30
  - [Models from data (YAML, JSON, anywhere)](#models-from-data-yaml-json-anywhere)
29
31
  - [Writing your own source](#writing-your-own-source)
@@ -286,7 +288,7 @@ class Thing extends Model {
286
288
  | `options` | The complete set of allowed values. |
287
289
  | `validation` | A regular expression, a pattern string, or a predicate. |
288
290
  | `length` | Length of the value, for sources that care. |
289
- | `readonly` | Whether the caller may write it. |
291
+ | `readonly` | Written on create, never on update. An edited value snaps back to what's stored. |
290
292
  | `auto` | Whether the source generates it — an auto-increment id, say. |
291
293
  | `refresh` | Reset to the default on update when untouched. Good for `updated_at`. |
292
294
  | `inject` | Store this value inside another `list` or `dict` field, at a path. |
@@ -335,6 +337,17 @@ static fields = {
335
337
  }
336
338
  ```
337
339
 
340
+ A relation can do the injecting for you, so a shared model can take on an optional parent
341
+ without being edited or getting a column of its own. Name the dict field to store it in:
342
+
343
+ ```ts
344
+ new OneToMany(Unum, Entity, { childInject: 'what' })
345
+ // adds entity.unum_id, stored at what.relations.unum.id; entity.unum is null when there isn't one
346
+ ```
347
+
348
+ The key is named `<model>_<id>`, or `<parent source>_<model>_<id>` when the parent lives in a
349
+ different source, which has to be a dns label (letters, digits and hyphens).
350
+
338
351
  ---
339
352
 
340
353
  ## Querying
@@ -538,6 +551,51 @@ class Item extends Base {
538
551
 
539
552
  ---
540
553
 
554
+ ## Ids you supply
555
+
556
+ Only an `int` id is generated by the source. An id of any other kind, a `str` slug, is supplied by
557
+ the caller: it's not `auto`, can't be null, and is `readonly`, so it's written when the record is
558
+ created and never changed. Unless you say otherwise it's the model's only title, there's no extra
559
+ unique index (it is the key), and it's the default order.
560
+
561
+ ```ts
562
+ class Tag extends fields({ id: str, label: str }) {
563
+ static source = 'example'
564
+ }
565
+
566
+ await new Tag('fine', 'Fine').create()
567
+
568
+ const tag = await Tag.one('fine').retrieve()
569
+ tag.id = 'renamed' // snaps back to 'fine' on update
570
+ ```
571
+
572
+ A missing or repeated id throws a `UniqueError`. A model's name can't have a `.` in it, ids built
573
+ from a name (`seq.model.source.unum`) use the dot to separate their parts.
574
+
575
+ ---
576
+
577
+ ## Hooks
578
+
579
+ `pre(action, ...args)` and `post(action, result, ...args)` run around every source action:
580
+ `create`, `count`, `retrieve`, `titles`, `update` and `delete`. Bulk calls come through too, as a
581
+ model in many mode. Override `pre` to deny (throw) or rewrite (`this.filter(...)`) before the
582
+ source runs, and `post` to transform what it returned. `post` must return the result. Both can be
583
+ `async`, and mixins chain through `super`.
584
+
585
+ ```ts
586
+ class Mine extends fields({ id: int, name: str, owner: str }) {
587
+ static source = 'example'
588
+
589
+ pre(action: string) {
590
+ if (action !== 'create') {
591
+ this.filter({ owner: currentUser() }) // every read, update and delete is scoped
592
+ }
593
+ }
594
+ }
595
+ ```
596
+
597
+ ---
598
+
541
599
  ## Migrations
542
600
 
543
601
  Definitions are backend-neutral JSON. A source converts them into whatever it needs — DDL
@@ -740,6 +798,7 @@ assertion. Where behaviour differs, it's for a reason:
740
798
  | **`add()` on an unread relation** | Retrieves first | Starts a fresh set | The retrieve would have to be awaited |
741
799
  | **`bool` casting** | `bool("false")` is `true` | `"false"`, `"no"`, `"0"`, `""` are `false` | Sane for HTTP params; Python's is a wart |
742
800
  | **`int` default of `false`** | Allowed (`bool` subclasses `int`) | Rejected | JS has no such relationship |
801
+ | **Overriding `pre` / `post`** | A method in the class body that shadows one of `Model`'s is left alone, not made a field | Fields are declared in `static fields`, so a method is always a method | Nothing to guard against |
743
802
  | **`error.message`** | Bare text; name added by `__str__` | Name included; bare text on `.detail` | `assert.throws` and stack traces read `.message` |
744
803
  | **Sorting on several keys** | Only the first key was used | All keys, in order | Python bug |
745
804
  | **`titles` as a field name** | Allowed (typo in the reserved list) | Reserved | It's a real method |
@@ -767,8 +826,8 @@ Config statics: `source`, `store`, `title`, `id`, `titles`, `list`, `unique`, `i
767
826
  `order`, `chunk`.
768
827
 
769
828
  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()`.
829
+ `toJSON()`, `keys()`, `size`, `models`, `overflow`, `query()`, the hooks `pre()` and `post()`, and
830
+ the six async methods `create()`, `retrieve()`, `count()`, `update()`, `delete()`, `titles()`.
772
831
 
773
832
  ### Kinds
774
833
 
@@ -846,8 +905,11 @@ that many and sets `overflow` when it got that many.
846
905
 
847
906
  ### Errors
848
907
 
849
- `FieldError`, `RecordError`, `ModelError`, `UniqueError`, `MigrationsError`,
850
- `OverscoreError`. All carry the thing that went wrong, not just a message.
908
+ `RelationsError` is the base of every error relations throws, so one `catch` gets them all.
909
+ `FieldError`, `RecordError`, `ModelError`, `MigrationsError` and `SourceError` extend it, and
910
+ `UniqueError`, `NotFoundError` ("none retrieved") and `MultipleError` ("more than one retrieved")
911
+ are `ModelError`s, so what caught a `ModelError` still does. `OverscoreError` is its own.
912
+ All carry the thing that went wrong, not just a message.
851
913
 
852
914
  ### overscore
853
915
 
@@ -874,6 +936,25 @@ npm test # compile and run the full suite
874
936
  npm run typecheck # types only, no output
875
937
  ```
876
938
 
939
+ Nothing needs installing but Docker and make. `make` on its own lists every target, and the
940
+ usual ones are `make build`, `make test` (100% coverage), `make lint` and `make setup`.
941
+
942
+ ### Releasing
943
+
944
+ `VERSION` is the one place the version is set. The work is in `scripts/`, one script per target.
945
+
946
+ ```bash
947
+ make new # next version: branch gaf3/<version>, VERSION, package.json, changelog
948
+ make new V=+0.1 B=thing # a bigger bump (+0.0.1, +0.1, +1 or x.y.z) and a branch name
949
+ make release-check # where the release is up to, and what would stop it
950
+ make release # from main: the checks, npm, a clean install from it, the tag
951
+ make test-release # tests the scripts against a throwaway repo and a fake registry
952
+ ```
953
+
954
+ `make release` does only what's left, so it can be run again after a failure, and once the version
955
+ is out and tagged it does nothing. It needs `~/.npmrc` with a token that can publish. The Jenkins
956
+ build runs it on `main`, so merging a pull request with an unreleased `VERSION` releases it.
957
+
877
958
  No runtime dependencies.
878
959
 
879
960
  ## License
package/dist/errors.d.ts CHANGED
@@ -2,18 +2,22 @@
2
2
  * Error classes. Each one carries the thing that went wrong, not just a message,
3
3
  * so callers can branch on the field or model involved.
4
4
  */
5
+ /** The base of every error relations throws, so one catch gets them all. */
6
+ export declare class RelationsError extends Error {
7
+ constructor(message: string);
8
+ }
5
9
  /** Something invalid about a field's definition, value, or criteria. */
6
- export declare class FieldError extends Error {
10
+ export declare class FieldError extends RelationsError {
7
11
  field: any;
8
12
  constructor(field: any, message: string);
9
13
  }
10
14
  /** Something invalid about a record - usually an unknown field name. */
11
- export declare class RecordError extends Error {
15
+ export declare class RecordError extends RelationsError {
12
16
  record: any;
13
17
  constructor(record: any, message: string);
14
18
  }
15
19
  /** Something invalid about a model, its identity, or the action being attempted. */
16
- export declare class ModelError extends Error {
20
+ export declare class ModelError extends RelationsError {
17
21
  /** The model the error is about. */
18
22
  model: any;
19
23
  /** The message on its own, without the model name in front. */
@@ -24,8 +28,20 @@ export declare class ModelError extends Error {
24
28
  export declare class UniqueError extends ModelError {
25
29
  constructor(model: any, message: string);
26
30
  }
31
+ /** A record that was asked for as one, to read, update or delete, isn't there. */
32
+ export declare class NotFoundError extends ModelError {
33
+ constructor(model: any, message: string);
34
+ }
35
+ /** More than one record where there should be only one. */
36
+ export declare class MultipleError extends ModelError {
37
+ constructor(model: any, message: string);
38
+ }
39
+ /** A source can't be registered, usually because its name isn't a dns label. */
40
+ export declare class SourceError extends RelationsError {
41
+ constructor(message: string);
42
+ }
27
43
  /** Something went wrong diffing or applying migrations. */
28
- export declare class MigrationsError extends Error {
44
+ export declare class MigrationsError extends RelationsError {
29
45
  constructor(message: string);
30
46
  }
31
47
  //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js CHANGED
@@ -2,8 +2,15 @@
2
2
  * Error classes. Each one carries the thing that went wrong, not just a message,
3
3
  * so callers can branch on the field or model involved.
4
4
  */
5
+ /** The base of every error relations throws, so one catch gets them all. */
6
+ export class RelationsError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'RelationsError';
10
+ }
11
+ }
5
12
  /** Something invalid about a field's definition, value, or criteria. */
6
- export class FieldError extends Error {
13
+ export class FieldError extends RelationsError {
7
14
  field;
8
15
  constructor(field, message) {
9
16
  super(message);
@@ -12,7 +19,7 @@ export class FieldError extends Error {
12
19
  }
13
20
  }
14
21
  /** Something invalid about a record - usually an unknown field name. */
15
- export class RecordError extends Error {
22
+ export class RecordError extends RelationsError {
16
23
  record;
17
24
  constructor(record, message) {
18
25
  super(message);
@@ -21,7 +28,7 @@ export class RecordError extends Error {
21
28
  }
22
29
  }
23
30
  /** Something invalid about a model, its identity, or the action being attempted. */
24
- export class ModelError extends Error {
31
+ export class ModelError extends RelationsError {
25
32
  /** The model the error is about. */
26
33
  model;
27
34
  /** The message on its own, without the model name in front. */
@@ -40,8 +47,29 @@ export class UniqueError extends ModelError {
40
47
  this.name = 'UniqueError';
41
48
  }
42
49
  }
50
+ /** A record that was asked for as one, to read, update or delete, isn't there. */
51
+ export class NotFoundError extends ModelError {
52
+ constructor(model, message) {
53
+ super(model, message);
54
+ this.name = 'NotFoundError';
55
+ }
56
+ }
57
+ /** More than one record where there should be only one. */
58
+ export class MultipleError extends ModelError {
59
+ constructor(model, message) {
60
+ super(model, message);
61
+ this.name = 'MultipleError';
62
+ }
63
+ }
64
+ /** A source can't be registered, usually because its name isn't a dns label. */
65
+ export class SourceError extends RelationsError {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = 'SourceError';
69
+ }
70
+ }
43
71
  /** Something went wrong diffing or applying migrations. */
44
- export class MigrationsError extends Error {
72
+ export class MigrationsError extends RelationsError {
45
73
  constructor(message) {
46
74
  super(message);
47
75
  this.name = 'MigrationsError';
package/dist/field.js CHANGED
@@ -491,7 +491,23 @@ export class Field {
491
491
  write(values) {
492
492
  const value = this.export();
493
493
  if (this.inject) {
494
- overscore.set(values, this.inject.split('__').slice(1).join('__'), value);
494
+ const path = this.inject.split('__').slice(1).join('__');
495
+ if (value === null || value === undefined) {
496
+ // A null isn't stored when it's only dict keys: a missing key reads back as null,
497
+ // and storage has no nulls to trip over.
498
+ const keys = overscore.parse(path);
499
+ let container = values;
500
+ for (const key of keys.slice(0, -1)) {
501
+ container =
502
+ overscore.isDict(container) && typeof key === 'string' ? (key in container ? container[key] : {}) : null;
503
+ }
504
+ const last = keys[keys.length - 1];
505
+ if (overscore.isDict(container) && typeof last === 'string') {
506
+ delete container[last];
507
+ return;
508
+ }
509
+ }
510
+ overscore.set(values, path, value);
495
511
  }
496
512
  else if (this.store) {
497
513
  values[this.store] = value;
@@ -627,6 +643,14 @@ export class Field {
627
643
  }
628
644
  /** Write this field's value for an update, if it changed. */
629
645
  update(values) {
646
+ // A readonly field isn't written, and an edit snaps back to what's stored.
647
+ if (this.readonly) {
648
+ if (this.original !== null && this.delta()) {
649
+ this.value = this.original;
650
+ this.changed = false;
651
+ }
652
+ return;
653
+ }
630
654
  if (this.refresh && !this.delta()) {
631
655
  this.value = typeof this.default === 'function' ? this.default() : this.default;
632
656
  }
@@ -637,6 +661,9 @@ export class Field {
637
661
  }
638
662
  /** Write this field's value for a mass update, if it was set. */
639
663
  mass(values) {
664
+ if (this.readonly) {
665
+ return;
666
+ }
640
667
  if (this.refresh && !this.changed) {
641
668
  this.value = typeof this.default === 'function' ? this.default() : this.default;
642
669
  }
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  */
32
32
  import * as overscore from './overscore.js';
33
33
  export { overscore };
34
- export { FieldError, MigrationsError, ModelError, RecordError, UniqueError } from './errors.js';
34
+ export { FieldError, MigrationsError, ModelError, MultipleError, NotFoundError, RecordError, RelationsError, SourceError, UniqueError } from './errors.js';
35
35
  export { OverscoreError } from './overscore.js';
36
36
  export { bool, builtin, container, dict, float, int, kindName, kindOf, list, scalar, set, str, CONTAINERS, KINDS, SCALARS, type FieldKind, type Kind, type KindClass } from './kinds.js';
37
37
  export { Field, OPERATORS, RESERVED, type Attr, type FieldOptions, type Init, type Validation } from './field.js';
@@ -46,7 +46,7 @@ export { LocalSource, type StorageLike } from './local.js';
46
46
  export { RestSource, type FetchInit, type FetchLike, type FetchResponse } from './rest.js';
47
47
  export { fields, type Listed, type Typed, type TypedClass, type ValueOfKind, type ValueOfSpec, type Values } from './typed.js';
48
48
  export { definitionsOf, modelFrom, modelsFrom, type Built, type Definition, type RelationDefinition } from './dynamic.js';
49
- export { SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
49
+ export { DNS, SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
50
50
  import type { ModelClass } from './model.js';
51
51
  /**
52
52
  * Every model class exported by a module, for handing to migrations.
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@
31
31
  */
32
32
  import * as overscore from './overscore.js';
33
33
  export { overscore };
34
- export { FieldError, MigrationsError, ModelError, RecordError, UniqueError } from './errors.js';
34
+ export { FieldError, MigrationsError, ModelError, MultipleError, NotFoundError, RecordError, RelationsError, SourceError, UniqueError } from './errors.js';
35
35
  export { OverscoreError } from './overscore.js';
36
36
  export { bool, builtin, container, dict, float, int, kindName, kindOf, list, scalar, set, str, CONTAINERS, KINDS, SCALARS } from './kinds.js';
37
37
  export { Field, OPERATORS, RESERVED } from './field.js';
@@ -46,7 +46,7 @@ export { LocalSource } from './local.js';
46
46
  export { RestSource } from './rest.js';
47
47
  export { fields } from './typed.js';
48
48
  export { definitionsOf, modelFrom, modelsFrom } from './dynamic.js';
49
- export { SOURCES, clear, register, source, unregister } from './registry.js';
49
+ export { DNS, SOURCES, clear, register, source, unregister } from './registry.js';
50
50
  import { Model as ModelBase } from './model.js';
51
51
  /**
52
52
  * Every model class exported by a module, for handing to migrations.
package/dist/mock.d.ts CHANGED
@@ -35,9 +35,9 @@ export declare class MockSource extends Source {
35
35
  /** Last id handed out, keyed by model name. */
36
36
  ids: globalThis.Record<string, number>;
37
37
  /** Records keyed by id, keyed by model name. */
38
- data: globalThis.Record<string, Map<number, Stored>>;
38
+ data: globalThis.Record<string, Map<number | string, Stored>>;
39
39
  /** Unique index values keyed by id, keyed by index, keyed by model name. */
40
- unique: globalThis.Record<string, globalThis.Record<string, Map<number, string>>>;
40
+ unique: globalThis.Record<string, globalThis.Record<string, Map<number | string, string>>>;
41
41
  /** Migrations applied so far, or null when nothing has been loaded. */
42
42
  migrations: string[] | null;
43
43
  /** Snapshot taken while a write is in flight, so a failure can roll back. */
@@ -56,7 +56,7 @@ export declare class MockSource extends Source {
56
56
  /** Copy extracted paths out of container fields into their own stored keys. */
57
57
  static extract(model: Model, values: Stored): Stored;
58
58
  /** Check every unique index, recording the new value or throwing. */
59
- uniques(model: Model, values: Stored, id: number): void;
59
+ uniques(model: Model, values: Stored, id: number | string): void;
60
60
  /** Run a write with a snapshot in place, restoring it if the write fails. */
61
61
  private rolling;
62
62
  createQuery(model: Model): Query;
package/dist/mock.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * ```
12
12
  */
13
13
  import * as overscore from './overscore.js';
14
- import { ModelError, UniqueError } from './errors.js';
14
+ import { ModelError, MultipleError, NotFoundError, UniqueError } from './errors.js';
15
15
  import { Source } from './source.js';
16
16
  import { Titles } from './titles.js';
17
17
  import { clone, joinPath, stable } from './util.js';
@@ -162,12 +162,21 @@ export class MockSource extends Source {
162
162
  for (const creating of model._each('create')) {
163
163
  const values = creating._record.create({});
164
164
  this.ids[name] += 1;
165
- this.uniques(model, values, this.ids[name]);
166
- if (model._id !== null && (values[model._id] ?? null) === null) {
167
- values[model._fields.field(model._id).store] = this.ids[name];
168
- creating[model._id] = this.ids[name];
165
+ const idField = model._id !== null ? model._fields.field(model._id) : null;
166
+ let key = this.ids[name];
167
+ // Ids the caller supplies (slugs) are the keys, else it's the next number.
168
+ if (idField !== null && !idField.auto) {
169
+ key = values[idField.store];
170
+ if (key === null || key === undefined || this.data[name].has(key)) {
171
+ throw new UniqueError(model, `value ${JSON.stringify(key ?? null)} violates unique ${model._id}`);
172
+ }
173
+ }
174
+ this.uniques(model, values, key);
175
+ if (idField !== null && idField.auto && (values[model._id] ?? null) === null) {
176
+ values[idField.store] = key;
177
+ creating[model._id] = key;
169
178
  }
170
- this.data[name].set(this.ids[name], MockSource.extract(creating, values));
179
+ this.data[name].set(key, MockSource.extract(creating, values));
171
180
  if (!model._bulk) {
172
181
  if (model._id) {
173
182
  await Source.createTies(creating);
@@ -256,12 +265,12 @@ export class MockSource extends Source {
256
265
  }
257
266
  }
258
267
  if (model._mode === 'one' && matches.length > 1) {
259
- throw new ModelError(model, 'more than one retrieved');
268
+ throw new MultipleError(model, 'more than one retrieved');
260
269
  }
261
270
  if (model._mode === 'one' && model._role !== 'child') {
262
271
  if (matches.length < 1) {
263
272
  if (verify) {
264
- throw new ModelError(model, 'none retrieved');
273
+ throw new NotFoundError(model, 'none retrieved');
265
274
  }
266
275
  return null;
267
276
  }
package/dist/model.d.ts CHANGED
@@ -253,6 +253,19 @@ export declare class Model extends ModelIdentity {
253
253
  toJSON(): any;
254
254
  /** The definition of this model, for schemas and migrations. */
255
255
  static define(...args: any[]): any;
256
+ /**
257
+ * Called before every source action (create, count, retrieve, titles, update, delete).
258
+ * Override to deny (throw) or rewrite (`this.filter(...)`) before the source runs.
259
+ * Bulk calls come through here too, as a model in many mode.
260
+ */
261
+ pre(action: string, ...args: any[]): void;
262
+ /**
263
+ * Called after every source action with what the source returned. Override to
264
+ * transform the result. Must return the result.
265
+ */
266
+ post(action: string, result: any, ...args: any[]): any;
267
+ /** Runs `pre`, the source action, then `post`. */
268
+ _hook(action: 'create' | 'count' | 'retrieve' | 'titles' | 'update' | 'delete', ...args: any[]): Promise<any>;
256
269
  /** Write this model's records to the source. */
257
270
  create(...args: any[]): Promise<this>;
258
271
  /** How many records match. */
package/dist/model.js CHANGED
@@ -356,6 +356,10 @@ export class Model extends ModelIdentity {
356
356
  const self = target ?? new ModelIdentity();
357
357
  self.TITLE = cls.title ?? cls.name;
358
358
  self.NAME = cls.store ?? underscore(self.TITLE);
359
+ // Ids built from a model's name separate their parts with a dot.
360
+ if (self.NAME.includes('.')) {
361
+ throw new ModelError(self, "model name cannot contain '.'");
362
+ }
359
363
  // Build the fields.
360
364
  const fields = new Record();
361
365
  for (const [name, spec] of Object.entries(cls.fields ?? {})) {
@@ -367,8 +371,29 @@ export class Model extends ModelIdentity {
367
371
  // Which field is the id.
368
372
  const id = cls.id === undefined ? 0 : cls.id;
369
373
  self._id = id === null ? null : self._fieldName(id);
374
+ // Only int ids are generated by the source (it marks them auto). Any other kind, a slug,
375
+ // is supplied by the caller, so it's required and written once.
376
+ let supplied = false;
377
+ if (self._id !== null) {
378
+ const field = self._fields.field(self._id);
379
+ if (field.auto === null && field.kind !== int) {
380
+ field.auto = false;
381
+ }
382
+ supplied = field.auto === false && field.kind !== int;
383
+ if (supplied) {
384
+ if (field._none === null) {
385
+ field.none = false;
386
+ }
387
+ if (field.readonly === null) {
388
+ field.readonly = true;
389
+ }
390
+ }
391
+ }
370
392
  // Which fields make up a title.
371
393
  let titles = cls.titles;
394
+ if (!titles && supplied) {
395
+ titles = [self._id];
396
+ }
372
397
  if (!titles) {
373
398
  const found = [];
374
399
  for (const field of self._fields._order) {
@@ -410,7 +435,10 @@ export class Model extends ModelIdentity {
410
435
  }
411
436
  // Unique indexes: unset means the titles, explicitly empty means none.
412
437
  let unique = cls.unique;
413
- if (unique === undefined) {
438
+ if (unique === undefined && supplied) {
439
+ unique = {};
440
+ }
441
+ else if (unique === undefined) {
414
442
  unique = self._titles;
415
443
  }
416
444
  else if (!unique || (Array.isArray(unique) && !unique.length)) {
@@ -467,6 +495,9 @@ export class Model extends ModelIdentity {
467
495
  else if (cls.order === undefined && Object.keys(self._unique).length === 1) {
468
496
  self._order = self._ordering(Object.values(self._unique)[0]);
469
497
  }
498
+ else if (cls.order === undefined && supplied) {
499
+ self._order = self._ordering([self._id]);
500
+ }
470
501
  else {
471
502
  self._order = [];
472
503
  }
@@ -545,12 +576,17 @@ export class Model extends ModelIdentity {
545
576
  if (name in this.PARENTS) {
546
577
  const relation = this.PARENTS[name];
547
578
  if (!this._parents[name]) {
548
- this._parents[name] =
549
- this._action === 'retrieve'
550
- ? relation.Parent.many().limit(this._chunk)
551
- : construct(relation.Parent, {
552
- child: { [relation.parentId]: this._item(relation.childParentRef) }
553
- });
579
+ if (this._action === 'retrieve') {
580
+ this._parents[name] = relation.Parent.many().limit(this._chunk);
581
+ }
582
+ else if (this._item(relation.childParentRef) === null) {
583
+ return null; // No key means no parent, nothing to cache
584
+ }
585
+ else {
586
+ this._parents[name] = construct(relation.Parent, {
587
+ child: { [relation.parentId]: this._item(relation.childParentRef) }
588
+ });
589
+ }
554
590
  }
555
591
  return this._parents[name];
556
592
  }
@@ -1012,12 +1048,32 @@ export class Model extends ModelIdentity {
1012
1048
  const cls = this;
1013
1049
  return need(cls.source, cls).define(cls.thy().define(), ...args);
1014
1050
  }
1051
+ /**
1052
+ * Called before every source action (create, count, retrieve, titles, update, delete).
1053
+ * Override to deny (throw) or rewrite (`this.filter(...)`) before the source runs.
1054
+ * Bulk calls come through here too, as a model in many mode.
1055
+ */
1056
+ pre(action, ...args) { }
1057
+ /**
1058
+ * Called after every source action with what the source returned. Override to
1059
+ * transform the result. Must return the result.
1060
+ */
1061
+ post(action, result, ...args) {
1062
+ return result;
1063
+ }
1064
+ /** Runs `pre`, the source action, then `post`. */
1065
+ async _hook(action, ...args) {
1066
+ const model = this._proxy;
1067
+ await model.pre(action, ...args);
1068
+ const result = await need(this.SOURCE, this)[action](model, ...args);
1069
+ return model.post(action, result, ...args);
1070
+ }
1015
1071
  /** Write this model's records to the source. */
1016
1072
  async create(...args) {
1017
1073
  if (!['create', 'update'].includes(this._action)) {
1018
1074
  throw new ModelError(this, `cannot create during ${this._action}`);
1019
1075
  }
1020
- await need(this.SOURCE, this).create(this._proxy, ...args);
1076
+ await this._hook('create', ...args);
1021
1077
  return this;
1022
1078
  }
1023
1079
  /** How many records match. */
@@ -1025,7 +1081,7 @@ export class Model extends ModelIdentity {
1025
1081
  if (!['update', 'retrieve'].includes(this._action)) {
1026
1082
  throw new ModelError(this, `cannot count during ${this._action}`);
1027
1083
  }
1028
- return need(this.SOURCE, this).count(this._proxy, ...args);
1084
+ return this._hook('count', ...args);
1029
1085
  }
1030
1086
  /**
1031
1087
  * Read this model's records out of the source. Pass `false` to get `null` instead of
@@ -1035,7 +1091,7 @@ export class Model extends ModelIdentity {
1035
1091
  if (this._action !== 'retrieve') {
1036
1092
  throw new ModelError(this, `cannot retrieve during ${this._action}`);
1037
1093
  }
1038
- const retrieved = await need(this.SOURCE, this).retrieve(this._proxy, verify, ...args);
1094
+ const retrieved = await this._hook('retrieve', verify, ...args);
1039
1095
  return retrieved === null ? null : this;
1040
1096
  }
1041
1097
  /** Titles for the matching records, keyed by id. */
@@ -1043,14 +1099,14 @@ export class Model extends ModelIdentity {
1043
1099
  if (!['update', 'retrieve'].includes(this._action)) {
1044
1100
  throw new ModelError(this, `cannot titles during ${this._action}`);
1045
1101
  }
1046
- return need(this.SOURCE, this).titles(this._proxy, ...args);
1102
+ return this._hook('titles', ...args);
1047
1103
  }
1048
1104
  /** Write changes back to the source. Resolves to how many records changed. */
1049
1105
  async update(...args) {
1050
1106
  if (!['update', 'retrieve'].includes(this._action)) {
1051
1107
  throw new ModelError(this, `cannot update during ${this._action}`);
1052
1108
  }
1053
- return need(this.SOURCE, this).update(this._proxy, ...args);
1109
+ return this._hook('update', ...args);
1054
1110
  }
1055
1111
  /** Remove records from the source. Resolves to how many were removed. */
1056
1112
  async delete(...args) {
@@ -1060,7 +1116,7 @@ export class Model extends ModelIdentity {
1060
1116
  if (this._action === 'retrieve' && this._mode === 'one') {
1061
1117
  await this.retrieve();
1062
1118
  }
1063
- return need(this.SOURCE, this).delete(this._proxy, ...args);
1119
+ return this._hook('delete', ...args);
1064
1120
  }
1065
1121
  /** The query a source would run for an action, without running it. */
1066
1122
  query(action, ...args) {
package/dist/record.js CHANGED
@@ -161,7 +161,14 @@ export class Record {
161
161
  /** Whether a stored record satisfies every field's criteria. */
162
162
  retrieve(values) {
163
163
  for (const field of this._order) {
164
- if (!field.retrieve(values)) {
164
+ if (field.inject) {
165
+ // An injected field is checked where it's stored, in the field it lives inside.
166
+ const injected = overscore.get(values[this.injected(field)] ?? {}, field.inject.split('__').slice(1).join('__'));
167
+ if (!field.retrieve({ [field.store]: injected })) {
168
+ return false;
169
+ }
170
+ }
171
+ else if (!field.retrieve(values)) {
165
172
  return false;
166
173
  }
167
174
  }
@@ -6,6 +6,11 @@
6
6
  * the whole point of relations: the same model runs against a database in one service
7
7
  * and against a REST API in another.
8
8
  */
9
+ /**
10
+ * A dns label, which is what a source's name has to be so it's safe to use in other names:
11
+ * letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen.
12
+ */
13
+ export declare const DNS: RegExp;
9
14
  /** Anything registered as a source. Kept loose to avoid a circular import. */
10
15
  export interface Registered {
11
16
  name: string;
@@ -13,7 +18,7 @@ export interface Registered {
13
18
  }
14
19
  /** Every registered source, keyed by name. */
15
20
  export declare const SOURCES: Map<string, Registered>;
16
- /** Register a source under its name, replacing any source already there. */
21
+ /** Register a source under its name, replacing any source already there. The name has to be a dns label. */
17
22
  export declare function register(source: Registered): void;
18
23
  /** The source registered under a name, or `undefined`. */
19
24
  export declare function source(name: string | null | undefined): Registered | undefined;
package/dist/registry.js CHANGED
@@ -6,10 +6,19 @@
6
6
  * the whole point of relations: the same model runs against a database in one service
7
7
  * and against a REST API in another.
8
8
  */
9
+ import { SourceError } from './errors.js';
10
+ /**
11
+ * A dns label, which is what a source's name has to be so it's safe to use in other names:
12
+ * letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen.
13
+ */
14
+ export const DNS = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
9
15
  /** Every registered source, keyed by name. */
10
16
  export const SOURCES = new Map();
11
- /** Register a source under its name, replacing any source already there. */
17
+ /** Register a source under its name, replacing any source already there. The name has to be a dns label. */
12
18
  export function register(source) {
19
+ if (typeof source.name !== 'string' || !DNS.test(source.name)) {
20
+ throw new SourceError(`source ${source.name} is not dns compliant`);
21
+ }
13
22
  SOURCES.set(source.name, source);
14
23
  }
15
24
  /** The source registered under a name, or `undefined`. */
@@ -32,6 +32,11 @@ export interface OneToOptions {
32
32
  parentId?: string | number;
33
33
  /** Field on the child that points at the parent. Inferred by convention. */
34
34
  childParentRef?: string | number;
35
+ /**
36
+ * A dict field on the child to store the parent id in, so the child needs no field of its own
37
+ * for it. The relation adds that field, at `<childInject>__relations__<parent>__<id>`.
38
+ */
39
+ childInject?: string;
35
40
  }
36
41
  /** One parent record to one or many child records. */
37
42
  export declare class OneTo extends Relation {
@@ -49,6 +54,8 @@ export declare class OneTo extends Relation {
49
54
  childParentRef: string;
50
55
  /** Attribute on the child that reaches the parent. */
51
56
  childParentAttr: string;
57
+ /** The dict field in the child the parent id is stored in, if it's not a field of its own. */
58
+ childInject: string | null;
52
59
  /** Whether the child side holds one record or many. */
53
60
  MODE: 'one' | 'many';
54
61
  constructor(Parent: ModelClass, Child: ModelClass, options?: OneToOptions);
package/dist/relation.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * ```
13
13
  */
14
14
  import { ModelError } from './errors.js';
15
+ import { Field } from './field.js';
16
+ import { dict } from './kinds.js';
17
+ import { DNS } from './registry.js';
15
18
  /** Base for every relation, holding the convention used to find the joining field. */
16
19
  export class Relation {
17
20
  /** Whether the two sides can share an id field name. */
@@ -55,6 +58,8 @@ export class OneTo extends Relation {
55
58
  childParentRef;
56
59
  /** Attribute on the child that reaches the parent. */
57
60
  childParentAttr;
61
+ /** The dict field in the child the parent id is stored in, if it's not a field of its own. */
62
+ childInject;
58
63
  /** Whether the child side holds one record or many. */
59
64
  MODE;
60
65
  constructor(Parent, Child, options = {}) {
@@ -63,14 +68,48 @@ export class OneTo extends Relation {
63
68
  this.Parent = Parent;
64
69
  this.Child = Child;
65
70
  this.MODE = kind.MODE;
71
+ this.childInject = options.childInject ?? null;
66
72
  const parent = Parent.thy();
67
- const child = Child.thy();
73
+ let child = Child.thy();
68
74
  this.parentId = parent._fieldName(options.parentId ?? parent._id);
69
75
  this.parentChildAttr = options.parentChildAttr ?? child.NAME;
76
+ let childParentRef = options.childParentRef;
77
+ // If asked, add the child field for the parent id, stored in a dict field of the child.
78
+ if (this.childInject !== null) {
79
+ const stored = child._fields._names.get(this.childInject);
80
+ if (stored === undefined) {
81
+ throw new ModelError(child, `cannot find field ${this.childInject} in ${child.NAME}`);
82
+ }
83
+ if (stored.kind !== dict) {
84
+ throw new ModelError(child, `field ${this.childInject} not a dict in ${child.NAME}`);
85
+ }
86
+ // Same source is just the model name, else prefix the source of the parent (a dns label).
87
+ let named = parent.NAME;
88
+ if (parent.SOURCE !== child.SOURCE) {
89
+ if (typeof parent.SOURCE !== 'string' || !DNS.test(parent.SOURCE)) {
90
+ throw new ModelError(parent, `source ${parent.SOURCE} is not dns compliant`);
91
+ }
92
+ named = `${parent.SOURCE.toLowerCase().replace(/-/g, '_')}_${parent.NAME}`;
93
+ }
94
+ const ref = childParentRef !== undefined ? String(childParentRef) : `${named}_${this.parentId}`;
95
+ if (child._fields.has(ref)) {
96
+ throw new ModelError(child, `field ${ref} already exists in ${child.NAME}`);
97
+ }
98
+ const kindOfParent = parent._fields._names.get(this.parentId).kind;
99
+ Child.fields = {
100
+ ...Child.fields,
101
+ [ref]: new Field(kindOfParent, {
102
+ inject: `${this.childInject}__relations__${named}__${this.parentId}`,
103
+ none: true
104
+ })
105
+ };
106
+ childParentRef = ref;
107
+ child = Child.thy();
108
+ }
70
109
  this.childParentAttr = options.childParentAttr ?? parent.NAME;
71
110
  this.childParentRef =
72
- options.childParentRef !== undefined
73
- ? child._fieldName(options.childParentRef)
111
+ childParentRef !== undefined
112
+ ? child._fieldName(childParentRef)
74
113
  : Relation.relativeField(parent, child, kind.SAME);
75
114
  this.Parent._child(this);
76
115
  this.Child._parent(this);
package/dist/rest.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * Browsers can't send a body with a GET, so reads are sent as `POST /<endpoint>` with a
14
14
  * `{"filter": ...}` body, which relations-restx treats exactly like a GET.
15
15
  */
16
- import { ModelError } from './errors.js';
16
+ import { ModelError, MultipleError, NotFoundError } from './errors.js';
17
17
  import { Source } from './source.js';
18
18
  import { Titles } from './titles.js';
19
19
  import { sorted } from './util.js';
@@ -176,12 +176,12 @@ export class RestSource extends Source {
176
176
  }
177
177
  const matches = await this.result(model, model.PLURAL, await this.send('POST', model.ENDPOINT, body));
178
178
  if (model._mode === 'one' && matches.length > 1) {
179
- throw new ModelError(model, 'more than one retrieved');
179
+ throw new MultipleError(model, 'more than one retrieved');
180
180
  }
181
181
  if (model._mode === 'one' && model._role !== 'child') {
182
182
  if (matches.length < 1) {
183
183
  if (verify) {
184
- throw new ModelError(model, 'none retrieved');
184
+ throw new NotFoundError(model, 'none retrieved');
185
185
  }
186
186
  return null;
187
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relations-dil/relations",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "A simple, flexible DIL (data interface layer). Define models once, independent of backend; set the source at runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {