@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 ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here, newest first. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.2.3] - 2026-10-18
9
+
10
+ Parity with python-relations 0.6.20, the four releases since 0.6.16, and the release tooling the Python repos have.
11
+
12
+ ### Added
13
+ - `Model.pre(action, ...args)` and `Model.post(action, result, ...args)`, empty hooks run around every source action (`create`, `count`, `retrieve`, `titles`, `update`, `delete`). Override `pre` to deny (throw) or rewrite (`this.filter(...)`), bulk calls included; override `post` to transform the result, it must return it. Both can be `async` and chain through `super`. (python-relations 0.6.17)
14
+ - String ids (slugs): an id that isn't an `int` is supplied by the caller instead of generated by the source. Such an id is not `auto`, can't be null and is `readonly`, and unless set otherwise it is the model's only title, has no extra unique index (it is the key) and is the default order. `MockSource` (and so `LocalSource`) keys its data by the supplied id and throws a `UniqueError` for a missing or repeated one. (0.6.18)
15
+ - `readonly` is enforced, it was declared and never read: a readonly field is written on create, skipped by `update` and mass updates, and an edited value snaps back to what is stored so `WHERE` clauses still find the record. (0.6.18)
16
+ - `RelationsError`, the base of every error relations throws, so one `catch` gets them all. `FieldError`, `ModelError`, `RecordError`, `MigrationsError` and `SourceError` extend it. (0.6.19)
17
+ - `NotFoundError` ("none retrieved") and `MultipleError` ("more than one retrieved"), both `ModelError`s with the same messages as before, thrown by `MockSource` and `RestSource` in place of a plain `ModelError`. `UniqueError` was already there. (0.6.19)
18
+ - Release tooling, as in the Python repos: one script in `scripts/` per make target. `make new` (next version, branch, `VERSION`, `package.json`, changelog section), `make release-check` (where the release is up to and what would stop it), `make release` (from `main`: the checks, npm, a clean install from the registry, the tag; it does only what's left, so it can be run again), `make verify` (the clean install), and `make test-release`, which tests the scripts against a throwaway repo and a fake registry (109 assertions).
19
+ - A `release` stage in the `Jenkinsfile`, on `main` only: a merged `VERSION` that isn't released yet is published and tagged. It needs `~/.npmrc` with a publish token on the Jenkins host; a `VERSION` that is already out is a no-op.
20
+ - `make` on its own lists the targets (`make help`), each has a description, and `make sync` does what `make version` used to.
21
+
22
+ ### Changed
23
+ - A model whose name has a `.` in it throws a `ModelError`: ids built from a model's name (relations-unifist's `seq.model.source.unum`) use the dot to separate their parts. (0.6.20)
24
+ - Models whose id field isn't an `int` get the string id defaults above, so their default titles, unique index and order change; set `titles` and `unique` to keep the old ones.
25
+ - `make version` sets `VERSION` and syncs `package.json` and the lockfile (`V=+0.0.1|+0.1|+1|x.y.z`, `+0.0.1` by default), like the Python repos. What it did before is `make sync`.
26
+ - `make tag` skips a tag that exists instead of hiding every failure, and every `docker run` in the Makefile uses `--rm`.
27
+ - `make pack` and `make publish` build from a copy, with only what the package needs mounted read-only, so nothing is written into the working tree.
28
+
29
+ ### Fixed
30
+ - `CHANGELOG.md` is in the published package, `files` listed it but the build never had it (39 files in 0.2.2, 40 now).
31
+
32
+ ## [0.2.2] - 2026-10-06
33
+
34
+ Parity with python-relations 0.6.16: an optional parent whose id is stored in a dict field of the child.
35
+
36
+ ### Added
37
+ - `childInject` on `OneTo` (and so `OneToMany` and `OneToOne`): `new OneToMany(Unum, Entity, { childInject: 'what' })` adds a nullable key field to the child, `unum_id`, stored inside the child's `what` dict at `what__relations__unum__id`, so a model that's shared between apps can take on a parent without being edited or getting a column. The dict field has to be named, it isn't touched (no `extract`), and several parents can use the same one. It throws a `ModelError` if the key field already exists, the named field is missing, or it isn't a dict.
38
+ - The key is named `<model>_<id>` when the parent and child share a source, else `<parent source>_<model>_<id>` (the source lowercased, `-` as `_`), like `bucket_app_pet_id`. `childParentRef` still overrides it.
39
+ - `DNS` and `SourceError`: source names have to be dns labels (letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen), so they're safe to use in field names. `register` throws a `SourceError` for any other name, and `OneTo` checks the parent's source when the two sources differ.
40
+
41
+ ### Changed
42
+ - An empty key means no parent: `_relate` returns `null` for a parent when its key is `null`, instead of a placeholder parent that matches nothing. Setting the key later still loads the parent.
43
+ - `Record.retrieve` checks an injected field where it's stored, in the field it lives inside, so filtering by an injected key works.
44
+ - `Field.write` doesn't store a `null` for an injected field whose path is only dict keys, it leaves the key out (or removes it), since a missing key reads back as `null` and storage has no JSON nulls to trip over. Paths through a list still set the `null`.
45
+
46
+ ## [0.2.1] - 2026-10-02
47
+
48
+ ### Added
49
+ - The example is laid out as a list of apps: `app.js` lists them (name, title, models file, source, seed), `models/<app>.js` holds each app's models and relations (source-agnostic; the source is named for the app), and `js/unum.js` is the host that builds the apps, makes their sources, seeds an empty one and starts the screens. Screens are routed by app, `/<app>/<model>` (`#/example/unit`), with an app page at `/<app>`, a nav of apps with their models in dropdowns and a home page listing the apps, so two apps can each have a model of the same name; the access function addresses models as `api/<app>/<model>`, and routes, route names and controllers are keyed `<app>_<model>`. `make dotroute` loads the models file with a script tag; `make single` inlines `app.js` and every `models/*.js`.
50
+ - Single-file pages keep **every app's data** in one file next to the page: `app.html` loads `data.js` (a plain `<script src>`, which works from `file://`) on open, and **Save to data.js** (or Cmd/Ctrl-S) writes it back, in place after one pick in Chromium (File System Access API) or as a download elsewhere. `localStorage` stays the working copy, one key per app (`relations:<app>`); one revision covers the whole file and decides whether the file or the browser is newer, and the page asks when both changed. Apps in the file that aren't running here are kept on save. A chip shows saved or unsaved, and leaving with unsaved changes asks first. Code in `example/dotroute/www/js/persist.js`; `make check-single` runs 14 jsdom checks of the rules.
51
+ - `RestSource` (`@relations-dil/relations/rest`, also exported from the index): a source that uses a relations-restx API as its backend, a port of python-relations-rest. It takes `url`, and optionally `fetch`, `headers`, `credentials` and `request`, and works in the browser or in Node. Reads go as `POST /<endpoint>` with a `{"filter": ...}` body (which restx treats as a GET), since browsers can't send a body with a GET. Errors from the API throw a `ModelError` with its message.
52
+ - `test/restx-fake.ts`: an in-process relations-restx API (a `fetch` over MockSource-backed models) the `RestSource` tests run against, with no network.
53
+
54
+ ## [0.2.0] - 2026-10-02
55
+
56
+ ### Added
57
+ - A `VERSION` file as the one place the version is set. The Makefile reads it, and `make version`, `make pack` and `make publish` sync `package.json` and the lockfile to it, so what's published always matches.
58
+ - `LocalSource` (`@relations-dil/relations/local`, also exported from the index): a source that keeps its data in `localStorage` (or any `storage` you pass), loading when it's made and saving after every successful write. `MockSource` stays purely in memory.
59
+ - `make local`: builds the single file and opens it in your browser (`open`, or `xdg-open` where there's no `open`).
60
+ - `example/single/` and `make single`: builds the doTRoute app into one self-contained `app.html` (library, UIkit, jQuery, doT, doTRoute, templates and app all inlined) that opens from disk with no server and keeps its data in `localStorage`. The example app picks `LocalSource` when the page sets `STORAGE_KEY`, and only seeds an empty store; `DRApp.load` reads templates embedded in the page when there are any.
61
+ - `make shell` mounts `~/.npmrc` too, so npm commands run as you, but only when the file exists.
62
+
63
+ ### Changed
64
+ - File access in `MockSource` and `Migrations` is loaded lazily and `node:path` is no longer used (a small `joinPath` replaces it), so the library bundles for the browser with no stub and no Node polyfills; only the file-based features need Node when called. `make dotroute` no longer needs a stub file, and `esbuild` is now a dev dependency.
65
+
66
+ ## [0.1.0] - 2026-10-02
67
+
68
+ ### Added
69
+ - Package metadata for npm: `repository`, `homepage` and `bugs`.
70
+ - Initial TypeScript port (usable from plain JavaScript) of python-relations (npm package `@relations-dil/relations`): Model, Field, Record, Relation, Source, Migrations, Titles, MockSource, overscore, and models-from-data.
71
+ - Docker-based workflow (`Dockerfile`, `Makefile`, `Jenkinsfile`) mirroring the Python repos: `make build|test|lint|setup|shell|debug|example|pack|publish|tag`. Nothing needs installing locally beyond Docker and make.
72
+ - `package.json` scripts (`build`, `test`, `coverage`, `lint`, `typecheck`, `example`) as the canonical commands; the Makefile wraps them, so `npm` works too for anyone with Node.
73
+ - ESLint (flat config, 140-column limit) and TypeScript typecheck as `lint`.
74
+ - Coverage tests (`test/coverage.test.ts`, `test/coverage-isolated.test.ts`) covering the edge cases and error paths the ported suites missed, bringing `make test` to 100% line, branch and function coverage. The isolated file runs in its own process so it can see `MigrationsRef` before `migrations.ts` replaces its stub.
75
+ - `example/dotroute/`: a playable doTRoute app that follows the layout of the other apps' `gui/www`: an empty `index.html`, one `.html` file per view (header, footer, home, form, fields, list, create, retrieve, update), the generic `js/relations.js` (Base and Model controllers, the same as the other apps' apart from `await`), and a small `js/service.js` for the app's routes. Every data call goes through one access function, `DRApp.rest(type, url, data)`, in `js/api.js`: `DRApp.http` is the other apps' request to a relations-restx API, and `DRApp.local` answers the same API from relations models running in the page, with the restx endpoints, request bodies, response shapes (`api/model`, `OPTIONS` fields with a parent's `options` and `titles`, list `formats` and `overflow`, `{updated}`/`{deleted}`) and status codes, including the alert on failure. Moving to a real backend is `DRApp.rest = DRApp.http` in `service.js`. Foreign keys render as a select of the parent's titles and display as the parent's title in lists. `make dotroute` bundles the library for the browser with esbuild (an IIFE exposing global `Relations`, with the `node:` file imports stubbed out) and serves it on http://localhost:8483 (`make dotroute PORT=9000` to change it; 8483 is ASCII T and S).
76
+
77
+ ### Changed
78
+ - The published package no longer includes `.map` files (source maps and declaration maps): `src/` isn't shipped, so they pointed at nothing. They're still built locally.
79
+ - `example/dotroute/` uses the published `@unum-pillars/uikit` theme (UIkit 3 with the `uu-*` palette) instead of a vendored UIkit 2. It's a dev dependency, and `make dotroute` copies `dist/` into `example/dotroute/www/vendor/` (gitignored). The ported views use UIkit 3 classes (`uk-navbar-container`, `uk-input`, `uk-alert-danger`, ...), with the navbar built the UIkit 3 way (a full-width bar with a container inside it) and UIkit 3's own centered container width; the other apps' wide-container override in `relations.css` was UIkit 2 CSS and isn't carried over.
80
+ - Removed provably unreachable code found while reaching 100% coverage, with no behaviour change: the `UNDEFINE` filter in `Field.define()` (no definable attribute was ever in it), the unreachable `return 0` in `count()` in `field.ts`, a duplicated no-records check in `Model._item`, and `this._proxy ?? this` / `?.` / `?? 0` fallbacks in `model.ts` and `mock.ts` that could never fire.
81
+ - Tests run directly from `.ts` via `tsx` (no compiled `dist-test/` step).
82
+ - Repo is named `typescript-relations` (Docker image too); the npm package is `@relations-dil/relations`, under the `relations-dil` npm org.
83
+ - `files` in `package.json` references `LICENSE` (repo copy) instead of `LICENSE.txt`.
84
+
85
+ ### Fixed
86
+ - Four lint violations in the ported source: a long line in `field.ts`, a `Function` type in `model.ts`, and two bare expressions in `ported-model.test.ts`.
package/README.md CHANGED
@@ -24,6 +24,8 @@ npm install @relations-dil/relations
24
24
  - [Querying](#querying)
25
25
  - [Relations](#relations)
26
26
  - [Titles](#titles)
27
+ - [Ids you supply](#ids-you-supply)
28
+ - [Hooks](#hooks)
27
29
  - [Migrations](#migrations)
28
30
  - [Models from data (YAML, JSON, anywhere)](#models-from-data-yaml-json-anywhere)
29
31
  - [Writing your own source](#writing-your-own-source)
@@ -286,7 +288,7 @@ class Thing extends Model {
286
288
  | `options` | The complete set of allowed values. |
287
289
  | `validation` | A regular expression, a pattern string, or a predicate. |
288
290
  | `length` | Length of the value, for sources that care. |
289
- | `readonly` | Whether the caller may write it. |
291
+ | `readonly` | Written on create, never on update. An edited value snaps back to what's stored. |
290
292
  | `auto` | Whether the source generates it — an auto-increment id, say. |
291
293
  | `refresh` | Reset to the default on update when untouched. Good for `updated_at`. |
292
294
  | `inject` | Store this value inside another `list` or `dict` field, at a path. |
@@ -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()`, and the six async methods
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
- `FieldError`, `RecordError`, `ModelError`, `UniqueError`, `MigrationsError`,
861
- `OverscoreError`. All carry the thing that went wrong, not just a message.
908
+ `RelationsError` is the base of every error relations throws, so one `catch` gets them all.
909
+ `FieldError`, `RecordError`, `ModelError`, `MigrationsError` and `SourceError` extend it, and
910
+ `UniqueError`, `NotFoundError` ("none retrieved") and `MultipleError` ("more than one retrieved")
911
+ are `ModelError`s, so what caught a `ModelError` still does. `OverscoreError` is its own.
912
+ All carry the thing that went wrong, not just a message.
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 Error {
10
+ export declare class FieldError extends RelationsError {
7
11
  field: any;
8
12
  constructor(field: any, message: string);
9
13
  }
10
14
  /** Something invalid about a record - usually an unknown field name. */
11
- export declare class RecordError extends Error {
15
+ export declare class RecordError extends RelationsError {
12
16
  record: any;
13
17
  constructor(record: any, message: string);
14
18
  }
15
19
  /** Something invalid about a model, its identity, or the action being attempted. */
16
- export declare class ModelError extends Error {
20
+ export declare class ModelError extends RelationsError {
17
21
  /** The model the error is about. */
18
22
  model: any;
19
23
  /** The message on its own, without the model name in front. */
@@ -24,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 Error {
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 Error {
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 Error {
13
+ export class FieldError extends RelationsError {
7
14
  field;
8
15
  constructor(field, message) {
9
16
  super(message);
@@ -12,7 +19,7 @@ export class FieldError extends Error {
12
19
  }
13
20
  }
14
21
  /** Something invalid about a record - usually an unknown field name. */
15
- export class RecordError extends Error {
22
+ export class RecordError extends RelationsError {
16
23
  record;
17
24
  constructor(record, message) {
18
25
  super(message);
@@ -21,7 +28,7 @@ export class RecordError extends Error {
21
28
  }
22
29
  }
23
30
  /** Something invalid about a model, its identity, or the action being attempted. */
24
- export class ModelError extends Error {
31
+ export class ModelError extends RelationsError {
25
32
  /** The model the error is about. */
26
33
  model;
27
34
  /** The message on its own, without the model name in front. */
@@ -40,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 Error {
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 Error {
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
- this.uniques(model, values, this.ids[name]);
166
- if (model._id !== null && (values[model._id] ?? null) === null) {
167
- values[model._fields.field(model._id).store] = this.ids[name];
168
- creating[model._id] = this.ids[name];
165
+ const idField = model._id !== null ? model._fields.field(model._id) : null;
166
+ let key = this.ids[name];
167
+ // Ids the caller supplies (slugs) are the keys, else it's the next number.
168
+ if (idField !== null && !idField.auto) {
169
+ key = values[idField.store];
170
+ if (key === null || key === undefined || this.data[name].has(key)) {
171
+ throw new UniqueError(model, `value ${JSON.stringify(key ?? null)} violates unique ${model._id}`);
172
+ }
173
+ }
174
+ this.uniques(model, values, key);
175
+ if (idField !== null && idField.auto && (values[model._id] ?? null) === null) {
176
+ values[idField.store] = key;
177
+ creating[model._id] = key;
169
178
  }
170
- this.data[name].set(this.ids[name], MockSource.extract(creating, values));
179
+ this.data[name].set(key, MockSource.extract(creating, values));
171
180
  if (!model._bulk) {
172
181
  if (model._id) {
173
182
  await Source.createTies(creating);
@@ -256,12 +265,12 @@ export class MockSource extends Source {
256
265
  }
257
266
  }
258
267
  if (model._mode === 'one' && matches.length > 1) {
259
- throw new ModelError(model, 'more than one retrieved');
268
+ throw new MultipleError(model, 'more than one retrieved');
260
269
  }
261
270
  if (model._mode === 'one' && model._role !== 'child') {
262
271
  if (matches.length < 1) {
263
272
  if (verify) {
264
- throw new ModelError(model, 'none retrieved');
273
+ throw new NotFoundError(model, 'none retrieved');
265
274
  }
266
275
  return null;
267
276
  }
package/dist/model.d.ts CHANGED
@@ -253,6 +253,19 @@ export declare class Model extends ModelIdentity {
253
253
  toJSON(): any;
254
254
  /** The definition of this model, for schemas and migrations. */
255
255
  static define(...args: any[]): any;
256
+ /**
257
+ * Called before every source action (create, count, retrieve, titles, update, delete).
258
+ * Override to deny (throw) or rewrite (`this.filter(...)`) before the source runs.
259
+ * Bulk calls come through here too, as a model in many mode.
260
+ */
261
+ pre(action: string, ...args: any[]): void;
262
+ /**
263
+ * Called after every source action with what the source returned. Override to
264
+ * transform the result. Must return the result.
265
+ */
266
+ post(action: string, result: any, ...args: any[]): any;
267
+ /** Runs `pre`, the source action, then `post`. */
268
+ _hook(action: 'create' | 'count' | 'retrieve' | 'titles' | 'update' | 'delete', ...args: any[]): Promise<any>;
256
269
  /** Write this model's records to the source. */
257
270
  create(...args: any[]): Promise<this>;
258
271
  /** How many records match. */
package/dist/model.js CHANGED
@@ -356,6 +356,10 @@ export class Model extends ModelIdentity {
356
356
  const self = target ?? new ModelIdentity();
357
357
  self.TITLE = cls.title ?? cls.name;
358
358
  self.NAME = cls.store ?? underscore(self.TITLE);
359
+ // Ids built from a model's name separate their parts with a dot.
360
+ if (self.NAME.includes('.')) {
361
+ throw new ModelError(self, "model name cannot contain '.'");
362
+ }
359
363
  // Build the fields.
360
364
  const fields = new Record();
361
365
  for (const [name, spec] of Object.entries(cls.fields ?? {})) {
@@ -367,8 +371,29 @@ export class Model extends ModelIdentity {
367
371
  // Which field is the id.
368
372
  const id = cls.id === undefined ? 0 : cls.id;
369
373
  self._id = id === null ? null : self._fieldName(id);
374
+ // Only int ids are generated by the source (it marks them auto). Any other kind, a slug,
375
+ // is supplied by the caller, so it's required and written once.
376
+ let supplied = false;
377
+ if (self._id !== null) {
378
+ const field = self._fields.field(self._id);
379
+ if (field.auto === null && field.kind !== int) {
380
+ field.auto = false;
381
+ }
382
+ supplied = field.auto === false && field.kind !== int;
383
+ if (supplied) {
384
+ if (field._none === null) {
385
+ field.none = false;
386
+ }
387
+ if (field.readonly === null) {
388
+ field.readonly = true;
389
+ }
390
+ }
391
+ }
370
392
  // Which fields make up a title.
371
393
  let titles = cls.titles;
394
+ if (!titles && supplied) {
395
+ titles = [self._id];
396
+ }
372
397
  if (!titles) {
373
398
  const found = [];
374
399
  for (const field of self._fields._order) {
@@ -410,7 +435,10 @@ export class Model extends ModelIdentity {
410
435
  }
411
436
  // Unique indexes: unset means the titles, explicitly empty means none.
412
437
  let unique = cls.unique;
413
- if (unique === undefined) {
438
+ if (unique === undefined && supplied) {
439
+ unique = {};
440
+ }
441
+ else if (unique === undefined) {
414
442
  unique = self._titles;
415
443
  }
416
444
  else if (!unique || (Array.isArray(unique) && !unique.length)) {
@@ -467,6 +495,9 @@ export class Model extends ModelIdentity {
467
495
  else if (cls.order === undefined && Object.keys(self._unique).length === 1) {
468
496
  self._order = self._ordering(Object.values(self._unique)[0]);
469
497
  }
498
+ else if (cls.order === undefined && supplied) {
499
+ self._order = self._ordering([self._id]);
500
+ }
470
501
  else {
471
502
  self._order = [];
472
503
  }
@@ -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 need(this.SOURCE, this).create(this._proxy, ...args);
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 need(this.SOURCE, this).count(this._proxy, ...args);
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 need(this.SOURCE, this).retrieve(this._proxy, verify, ...args);
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 need(this.SOURCE, this).titles(this._proxy, ...args);
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 need(this.SOURCE, this).update(this._proxy, ...args);
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 need(this.SOURCE, this).delete(this._proxy, ...args);
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 ModelError(model, 'more than one retrieved');
179
+ throw new MultipleError(model, 'more than one retrieved');
180
180
  }
181
181
  if (model._mode === 'one' && model._role !== 'child') {
182
182
  if (matches.length < 1) {
183
183
  if (verify) {
184
- throw new ModelError(model, 'none retrieved');
184
+ throw new NotFoundError(model, 'none retrieved');
185
185
  }
186
186
  return null;
187
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relations-dil/relations",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "A simple, flexible DIL (data interface layer). Define models once, independent of backend; set the source at runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {