@relations-dil/relations 0.2.2 → 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 +75 -5
- package/dist/errors.d.ts +17 -5
- package/dist/errors.js +26 -5
- package/dist/field.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mock.d.ts +3 -3
- package/dist/mock.js +17 -8
- package/dist/model.d.ts +13 -0
- package/dist/model.js +58 -7
- 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. |
|
|
@@ -549,6 +551,51 @@ class Item extends Base {
|
|
|
549
551
|
|
|
550
552
|
---
|
|
551
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
|
+
|
|
552
599
|
## Migrations
|
|
553
600
|
|
|
554
601
|
Definitions are backend-neutral JSON. A source converts them into whatever it needs — DDL
|
|
@@ -751,6 +798,7 @@ assertion. Where behaviour differs, it's for a reason:
|
|
|
751
798
|
| **`add()` on an unread relation** | Retrieves first | Starts a fresh set | The retrieve would have to be awaited |
|
|
752
799
|
| **`bool` casting** | `bool("false")` is `true` | `"false"`, `"no"`, `"0"`, `""` are `false` | Sane for HTTP params; Python's is a wart |
|
|
753
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 |
|
|
754
802
|
| **`error.message`** | Bare text; name added by `__str__` | Name included; bare text on `.detail` | `assert.throws` and stack traces read `.message` |
|
|
755
803
|
| **Sorting on several keys** | Only the first key was used | All keys, in order | Python bug |
|
|
756
804
|
| **`titles` as a field name** | Allowed (typo in the reserved list) | Reserved | It's a real method |
|
|
@@ -778,8 +826,8 @@ Config statics: `source`, `store`, `title`, `id`, `titles`, `list`, `unique`, `i
|
|
|
778
826
|
`order`, `chunk`.
|
|
779
827
|
|
|
780
828
|
Instance: `filter()`, `sort()`, `limit()`, `set()`, `add()`, `queue()`, `export()`,
|
|
781
|
-
`toJSON()`, `keys()`, `size`, `models`, `overflow`, `query()`,
|
|
782
|
-
`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()`.
|
|
783
831
|
|
|
784
832
|
### Kinds
|
|
785
833
|
|
|
@@ -857,8 +905,11 @@ that many and sets `overflow` when it got that many.
|
|
|
857
905
|
|
|
858
906
|
### Errors
|
|
859
907
|
|
|
860
|
-
`
|
|
861
|
-
`
|
|
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.
|
|
862
913
|
|
|
863
914
|
### overscore
|
|
864
915
|
|
|
@@ -885,6 +936,25 @@ npm test # compile and run the full suite
|
|
|
885
936
|
npm run typecheck # types only, no output
|
|
886
937
|
```
|
|
887
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
|
+
|
|
888
958
|
No runtime dependencies.
|
|
889
959
|
|
|
890
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,12 +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
|
+
}
|
|
27
39
|
/** A source can't be registered, usually because its name isn't a dns label. */
|
|
28
|
-
export declare class SourceError extends
|
|
40
|
+
export declare class SourceError extends RelationsError {
|
|
29
41
|
constructor(message: string);
|
|
30
42
|
}
|
|
31
43
|
/** Something went wrong diffing or applying migrations. */
|
|
32
|
-
export declare class MigrationsError extends
|
|
44
|
+
export declare class MigrationsError extends RelationsError {
|
|
33
45
|
constructor(message: string);
|
|
34
46
|
}
|
|
35
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,15 +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
|
+
}
|
|
43
64
|
/** A source can't be registered, usually because its name isn't a dns label. */
|
|
44
|
-
export class SourceError extends
|
|
65
|
+
export class SourceError extends RelationsError {
|
|
45
66
|
constructor(message) {
|
|
46
67
|
super(message);
|
|
47
68
|
this.name = 'SourceError';
|
|
48
69
|
}
|
|
49
70
|
}
|
|
50
71
|
/** Something went wrong diffing or applying migrations. */
|
|
51
|
-
export class MigrationsError extends
|
|
72
|
+
export class MigrationsError extends RelationsError {
|
|
52
73
|
constructor(message) {
|
|
53
74
|
super(message);
|
|
54
75
|
this.name = 'MigrationsError';
|
package/dist/field.js
CHANGED
|
@@ -643,6 +643,14 @@ export class Field {
|
|
|
643
643
|
}
|
|
644
644
|
/** Write this field's value for an update, if it changed. */
|
|
645
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
|
+
}
|
|
646
654
|
if (this.refresh && !this.delta()) {
|
|
647
655
|
this.value = typeof this.default === 'function' ? this.default() : this.default;
|
|
648
656
|
}
|
|
@@ -653,6 +661,9 @@ export class Field {
|
|
|
653
661
|
}
|
|
654
662
|
/** Write this field's value for a mass update, if it was set. */
|
|
655
663
|
mass(values) {
|
|
664
|
+
if (this.readonly) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
656
667
|
if (this.refresh && !this.changed) {
|
|
657
668
|
this.value = typeof this.default === 'function' ? this.default() : this.default;
|
|
658
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, SourceError, 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';
|
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, SourceError, 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';
|
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
|
}
|
|
@@ -1017,12 +1048,32 @@ export class Model extends ModelIdentity {
|
|
|
1017
1048
|
const cls = this;
|
|
1018
1049
|
return need(cls.source, cls).define(cls.thy().define(), ...args);
|
|
1019
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
|
+
}
|
|
1020
1071
|
/** Write this model's records to the source. */
|
|
1021
1072
|
async create(...args) {
|
|
1022
1073
|
if (!['create', 'update'].includes(this._action)) {
|
|
1023
1074
|
throw new ModelError(this, `cannot create during ${this._action}`);
|
|
1024
1075
|
}
|
|
1025
|
-
await
|
|
1076
|
+
await this._hook('create', ...args);
|
|
1026
1077
|
return this;
|
|
1027
1078
|
}
|
|
1028
1079
|
/** How many records match. */
|
|
@@ -1030,7 +1081,7 @@ export class Model extends ModelIdentity {
|
|
|
1030
1081
|
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1031
1082
|
throw new ModelError(this, `cannot count during ${this._action}`);
|
|
1032
1083
|
}
|
|
1033
|
-
return
|
|
1084
|
+
return this._hook('count', ...args);
|
|
1034
1085
|
}
|
|
1035
1086
|
/**
|
|
1036
1087
|
* Read this model's records out of the source. Pass `false` to get `null` instead of
|
|
@@ -1040,7 +1091,7 @@ export class Model extends ModelIdentity {
|
|
|
1040
1091
|
if (this._action !== 'retrieve') {
|
|
1041
1092
|
throw new ModelError(this, `cannot retrieve during ${this._action}`);
|
|
1042
1093
|
}
|
|
1043
|
-
const retrieved = await
|
|
1094
|
+
const retrieved = await this._hook('retrieve', verify, ...args);
|
|
1044
1095
|
return retrieved === null ? null : this;
|
|
1045
1096
|
}
|
|
1046
1097
|
/** Titles for the matching records, keyed by id. */
|
|
@@ -1048,14 +1099,14 @@ export class Model extends ModelIdentity {
|
|
|
1048
1099
|
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1049
1100
|
throw new ModelError(this, `cannot titles during ${this._action}`);
|
|
1050
1101
|
}
|
|
1051
|
-
return
|
|
1102
|
+
return this._hook('titles', ...args);
|
|
1052
1103
|
}
|
|
1053
1104
|
/** Write changes back to the source. Resolves to how many records changed. */
|
|
1054
1105
|
async update(...args) {
|
|
1055
1106
|
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1056
1107
|
throw new ModelError(this, `cannot update during ${this._action}`);
|
|
1057
1108
|
}
|
|
1058
|
-
return
|
|
1109
|
+
return this._hook('update', ...args);
|
|
1059
1110
|
}
|
|
1060
1111
|
/** Remove records from the source. Resolves to how many were removed. */
|
|
1061
1112
|
async delete(...args) {
|
|
@@ -1065,7 +1116,7 @@ export class Model extends ModelIdentity {
|
|
|
1065
1116
|
if (this._action === 'retrieve' && this._mode === 'one') {
|
|
1066
1117
|
await this.retrieve();
|
|
1067
1118
|
}
|
|
1068
|
-
return
|
|
1119
|
+
return this._hook('delete', ...args);
|
|
1069
1120
|
}
|
|
1070
1121
|
/** The query a source would run for an action, without running it. */
|
|
1071
1122
|
query(action, ...args) {
|
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