@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 +86 -0
- package/README.md +86 -5
- package/dist/errors.d.ts +20 -4
- package/dist/errors.js +32 -4
- package/dist/field.js +28 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mock.d.ts +3 -3
- package/dist/mock.js +17 -8
- package/dist/model.d.ts +13 -0
- package/dist/model.js +69 -13
- package/dist/record.js +8 -1
- package/dist/registry.d.ts +6 -1
- package/dist/registry.js +10 -1
- package/dist/relation.d.ts +7 -0
- package/dist/relation.js +42 -3
- package/dist/rest.js +3 -3
- package/package.json +1 -1
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` |
|
|
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()`,
|
|
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
|
-
`
|
|
850
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
549
|
-
this.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/relation.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
73
|
-
? child._fieldName(
|
|
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
|
|
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
|
|
184
|
+
throw new NotFoundError(model, 'none retrieved');
|
|
185
185
|
}
|
|
186
186
|
return null;
|
|
187
187
|
}
|
package/package.json
CHANGED