@relations-dil/relations 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +7 -0
- package/dist/field.js +17 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/model.js +11 -6
- 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.d.ts +91 -0
- package/dist/rest.js +306 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -335,6 +335,17 @@ static fields = {
|
|
|
335
335
|
}
|
|
336
336
|
```
|
|
337
337
|
|
|
338
|
+
A relation can do the injecting for you, so a shared model can take on an optional parent
|
|
339
|
+
without being edited or getting a column of its own. Name the dict field to store it in:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
new OneToMany(Unum, Entity, { childInject: 'what' })
|
|
343
|
+
// adds entity.unum_id, stored at what.relations.unum.id; entity.unum is null when there isn't one
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The key is named `<model>_<id>`, or `<parent source>_<model>_<id>` when the parent lives in a
|
|
347
|
+
different source, which has to be a dns label (letters, digits and hyphens).
|
|
348
|
+
|
|
338
349
|
---
|
|
339
350
|
|
|
340
351
|
## Querying
|
|
@@ -781,7 +792,7 @@ Instance: `filter()`, `sort()`, `limit()`, `set()`, `add()`, `queue()`, `export(
|
|
|
781
792
|
### Sources
|
|
782
793
|
|
|
783
794
|
`Source` (subclass this), `MockSource` (in-memory, complete), `LocalSource` (the same, kept in the
|
|
784
|
-
browser's `localStorage`), `register()`, `source()`, `unregister()`, `clear()`, `SOURCES`.
|
|
795
|
+
browser's `localStorage`), `RestSource` (a relations-restx API as the backend), `register()`, `source()`, `unregister()`, `clear()`, `SOURCES`.
|
|
785
796
|
|
|
786
797
|
#### LocalSource
|
|
787
798
|
|
|
@@ -800,6 +811,46 @@ open on the same key don't see each other's changes; whichever saves last wins,
|
|
|
800
811
|
forgets everything. If what's in storage isn't valid JSON it refuses to start, rather than
|
|
801
812
|
overwrite it.
|
|
802
813
|
|
|
814
|
+
#### RestSource
|
|
815
|
+
|
|
816
|
+
For models whose data lives behind a [relations-restx](https://github.com/relations-dil/python-relations-restx)
|
|
817
|
+
API: swap `MockSource` or `LocalSource` for it at runtime and the same models read and write the
|
|
818
|
+
remote service. It's a port of python-relations-rest, and it runs in the browser or in Node.
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
import { RestSource } from '@relations-dil/relations/rest'
|
|
822
|
+
|
|
823
|
+
new RestSource('example', { url: 'https://api.example.com' })
|
|
824
|
+
|
|
825
|
+
const unit = await Unit.one({ name: 'yep' }).retrieve() // POST https://api.example.com/unit
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Options:
|
|
829
|
+
|
|
830
|
+
- `url` (required): where the API lives. `''` is fine for the same origin.
|
|
831
|
+
- `fetch`: anything shaped like `fetch`. Defaults to `globalThis.fetch`, so pass one in on older
|
|
832
|
+
Node or to stand in for the network in tests.
|
|
833
|
+
- `headers`: sent on every request, e.g. `{ Authorization: 'Bearer ...' }`. Requests have a JSON
|
|
834
|
+
body and `Content-Type: application/json`; a header of your own wins.
|
|
835
|
+
- `credentials`: passed through to fetch, e.g. `'include'` to send cookies cross-origin.
|
|
836
|
+
- `request`: anything else to hand fetch on every request, like `mode` or `cache`.
|
|
837
|
+
|
|
838
|
+
A model's `SINGULAR`, `PLURAL` and `ENDPOINT` (defaulting to its name, the name plus `s`, and its
|
|
839
|
+
name) say how it's addressed, just as in the Python client. The endpoint is `/<ENDPOINT>`, and by
|
|
840
|
+
id `/<ENDPOINT>/<id>`.
|
|
841
|
+
|
|
842
|
+
Reads are sent as `POST /<endpoint>` with a `{"filter": ...}` body, not GET. Browsers can't send a
|
|
843
|
+
body with a GET, and relations-restx treats a POST that carries a `filter` exactly like a GET, so
|
|
844
|
+
`filter`, `sort`, `limit` and `count` all work. Creates are `POST {plural: [...]}`, updates by id
|
|
845
|
+
are `PATCH /<endpoint>/<id>` with `{singular: {...}}`, mass updates are `PATCH /<endpoint>` with
|
|
846
|
+
`{filter, plural: {...}}`, and deletes are `DELETE /<endpoint>` with `{filter}`.
|
|
847
|
+
|
|
848
|
+
A response of 400 or more throws a `ModelError` carrying the API's `message` (or `API Error`). An
|
|
849
|
+
`overflow` in a response is carried onto the model. Sets go over as sorted lists. Like the Python
|
|
850
|
+
client, it never asks the API for more than the model's `limit()`, but restx always applies one
|
|
851
|
+
itself (the server model's `CHUNK`, 100 unless set), so a retrieve with no `limit()` returns at most
|
|
852
|
+
that many and sets `overflow` when it got that many.
|
|
853
|
+
|
|
803
854
|
### Migrations and data
|
|
804
855
|
|
|
805
856
|
`Migrations`, `models(module)`, `modelFrom()`, `modelsFrom()`, `definitionsOf()`.
|
package/dist/errors.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare class ModelError extends Error {
|
|
|
24
24
|
export declare class UniqueError extends ModelError {
|
|
25
25
|
constructor(model: any, message: string);
|
|
26
26
|
}
|
|
27
|
+
/** A source can't be registered, usually because its name isn't a dns label. */
|
|
28
|
+
export declare class SourceError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
27
31
|
/** Something went wrong diffing or applying migrations. */
|
|
28
32
|
export declare class MigrationsError extends Error {
|
|
29
33
|
constructor(message: string);
|
package/dist/errors.js
CHANGED
|
@@ -40,6 +40,13 @@ export class UniqueError extends ModelError {
|
|
|
40
40
|
this.name = 'UniqueError';
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/** A source can't be registered, usually because its name isn't a dns label. */
|
|
44
|
+
export class SourceError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'SourceError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
43
50
|
/** Something went wrong diffing or applying migrations. */
|
|
44
51
|
export class MigrationsError extends Error {
|
|
45
52
|
constructor(message) {
|
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;
|
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, RecordError, 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';
|
|
@@ -43,9 +43,10 @@ export { Titles } from './titles.js';
|
|
|
43
43
|
export { Migrations, pretty, type Renamer } from './migrations.js';
|
|
44
44
|
export { MockQuery, MockSource } from './mock.js';
|
|
45
45
|
export { LocalSource, type StorageLike } from './local.js';
|
|
46
|
+
export { RestSource, type FetchInit, type FetchLike, type FetchResponse } from './rest.js';
|
|
46
47
|
export { fields, type Listed, type Typed, type TypedClass, type ValueOfKind, type ValueOfSpec, type Values } from './typed.js';
|
|
47
48
|
export { definitionsOf, modelFrom, modelsFrom, type Built, type Definition, type RelationDefinition } from './dynamic.js';
|
|
48
|
-
export { SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
|
|
49
|
+
export { DNS, SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
|
|
49
50
|
import type { ModelClass } from './model.js';
|
|
50
51
|
/**
|
|
51
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, RecordError, 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';
|
|
@@ -43,9 +43,10 @@ export { Titles } from './titles.js';
|
|
|
43
43
|
export { Migrations, pretty } from './migrations.js';
|
|
44
44
|
export { MockQuery, MockSource } from './mock.js';
|
|
45
45
|
export { LocalSource } from './local.js';
|
|
46
|
+
export { RestSource } from './rest.js';
|
|
46
47
|
export { fields } from './typed.js';
|
|
47
48
|
export { definitionsOf, modelFrom, modelsFrom } from './dynamic.js';
|
|
48
|
-
export { SOURCES, clear, register, source, unregister } from './registry.js';
|
|
49
|
+
export { DNS, SOURCES, clear, register, source, unregister } from './registry.js';
|
|
49
50
|
import { Model as ModelBase } from './model.js';
|
|
50
51
|
/**
|
|
51
52
|
* Every model class exported by a module, for handing to migrations.
|
package/dist/model.js
CHANGED
|
@@ -545,12 +545,17 @@ export class Model extends ModelIdentity {
|
|
|
545
545
|
if (name in this.PARENTS) {
|
|
546
546
|
const relation = this.PARENTS[name];
|
|
547
547
|
if (!this._parents[name]) {
|
|
548
|
-
this.
|
|
549
|
-
this.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
548
|
+
if (this._action === 'retrieve') {
|
|
549
|
+
this._parents[name] = relation.Parent.many().limit(this._chunk);
|
|
550
|
+
}
|
|
551
|
+
else if (this._item(relation.childParentRef) === null) {
|
|
552
|
+
return null; // No key means no parent, nothing to cache
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this._parents[name] = construct(relation.Parent, {
|
|
556
|
+
child: { [relation.parentId]: this._item(relation.childParentRef) }
|
|
557
|
+
});
|
|
558
|
+
}
|
|
554
559
|
}
|
|
555
560
|
return this._parents[name];
|
|
556
561
|
}
|
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.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source whose backend is a relations-restx API.
|
|
3
|
+
*
|
|
4
|
+
* The models stay exactly as they were; only the source they name changes. Swap `MockSource`
|
|
5
|
+
* or `LocalSource` for this one and the same code reads and writes a remote service.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { RestSource } from '@relations-dil/relations/rest'
|
|
9
|
+
*
|
|
10
|
+
* new RestSource('example', { url: 'https://api.example.com' })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Browsers can't send a body with a GET, so reads are sent as `POST /<endpoint>` with a
|
|
14
|
+
* `{"filter": ...}` body, which relations-restx treats exactly like a GET.
|
|
15
|
+
*/
|
|
16
|
+
import { Source } from './source.js';
|
|
17
|
+
import { Titles } from './titles.js';
|
|
18
|
+
import { type Model, type ModelIdentity } from './model.js';
|
|
19
|
+
import type { Field } from './field.js';
|
|
20
|
+
/** The part of a fetch response that's used, so anything fetch-shaped can stand in. */
|
|
21
|
+
export interface FetchResponse {
|
|
22
|
+
status: number;
|
|
23
|
+
json(): Promise<any>;
|
|
24
|
+
}
|
|
25
|
+
/** The part of fetch's init that's used. */
|
|
26
|
+
export interface FetchInit {
|
|
27
|
+
method: string;
|
|
28
|
+
headers: globalThis.Record<string, string>;
|
|
29
|
+
body?: string;
|
|
30
|
+
credentials?: string;
|
|
31
|
+
[option: string]: any;
|
|
32
|
+
}
|
|
33
|
+
/** Anything shaped like `fetch`. */
|
|
34
|
+
export type FetchLike = (url: string, init: FetchInit) => Promise<FetchResponse>;
|
|
35
|
+
type Values = globalThis.Record<string, any>;
|
|
36
|
+
/** A source that uses a relations-restx API as its backend. */
|
|
37
|
+
export declare class RestSource extends Source {
|
|
38
|
+
/** Where the API lives, without a trailing slash. */
|
|
39
|
+
url: string;
|
|
40
|
+
/** The fetch every request goes through. */
|
|
41
|
+
fetch: FetchLike;
|
|
42
|
+
/** Headers sent with every request. */
|
|
43
|
+
headers: globalThis.Record<string, string>;
|
|
44
|
+
/** Passed to fetch as `credentials`, e.g. `'include'` to send cookies. */
|
|
45
|
+
credentials: string | undefined;
|
|
46
|
+
/** Anything else to hand fetch on every request, like `mode` or `cache`. */
|
|
47
|
+
request: globalThis.Record<string, any>;
|
|
48
|
+
constructor(name: string, options?: globalThis.Record<string, any>);
|
|
49
|
+
/** Send a request, always with a JSON body, and hand back the response. */
|
|
50
|
+
send(method: string, path: string, body: any): Promise<FetchResponse>;
|
|
51
|
+
/** Checks a response and returns the result. */
|
|
52
|
+
result(model: Model, key: string, response: FetchResponse): Promise<any>;
|
|
53
|
+
/** Init the model. */
|
|
54
|
+
init(model: ModelIdentity): void;
|
|
55
|
+
/** Updates values with the field's that changed. */
|
|
56
|
+
createField(field: Field, values: Values): void;
|
|
57
|
+
/** Executes the create. */
|
|
58
|
+
create(model: Model): Promise<Model>;
|
|
59
|
+
/** Adds criteria to the filter. */
|
|
60
|
+
retrieveField(field: Field, criteria: Values): void;
|
|
61
|
+
/**
|
|
62
|
+
* Adds sibling-attribute filters (model._ties, e.g. bro__name: 'Tom') to the filter so the
|
|
63
|
+
* remote API resolves them through many(); the tie field lives on the model, not the record.
|
|
64
|
+
*/
|
|
65
|
+
static filterTies(model: Model, criteria: Values): void;
|
|
66
|
+
/** Everything that narrows the model down, as the API wants it. */
|
|
67
|
+
filter(model: Model): Promise<Values>;
|
|
68
|
+
/** Executes the count. */
|
|
69
|
+
count(model: Model): Promise<number>;
|
|
70
|
+
/** Executes the retrieve. */
|
|
71
|
+
retrieve(model: Model, verify?: boolean): Promise<Model | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Loads the tie ids the API already resolved onto the model.
|
|
74
|
+
*
|
|
75
|
+
* Other sources query the tie table; the API has no tie endpoint but returns the
|
|
76
|
+
* resolved ids inline, so they're read straight off the response.
|
|
77
|
+
*/
|
|
78
|
+
retrieveTies(model: Model, read: Values): void;
|
|
79
|
+
/** Creates the titles structure. */
|
|
80
|
+
titles(model: Model): Promise<Titles>;
|
|
81
|
+
/** Updates values with the field's that changed. */
|
|
82
|
+
updateField(field: Field, values: Values): void;
|
|
83
|
+
/** Mass values with the field's that changed. */
|
|
84
|
+
fieldMass(field: Field, values: Values): void;
|
|
85
|
+
/** Executes the update. */
|
|
86
|
+
update(model: Model): Promise<number>;
|
|
87
|
+
/** Executes the delete. */
|
|
88
|
+
delete(model: Model): Promise<number>;
|
|
89
|
+
}
|
|
90
|
+
export {};
|
|
91
|
+
//# sourceMappingURL=rest.d.ts.map
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source whose backend is a relations-restx API.
|
|
3
|
+
*
|
|
4
|
+
* The models stay exactly as they were; only the source they name changes. Swap `MockSource`
|
|
5
|
+
* or `LocalSource` for this one and the same code reads and writes a remote service.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { RestSource } from '@relations-dil/relations/rest'
|
|
9
|
+
*
|
|
10
|
+
* new RestSource('example', { url: 'https://api.example.com' })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Browsers can't send a body with a GET, so reads are sent as `POST /<endpoint>` with a
|
|
14
|
+
* `{"filter": ...}` body, which relations-restx treats exactly like a GET.
|
|
15
|
+
*/
|
|
16
|
+
import { ModelError } from './errors.js';
|
|
17
|
+
import { Source } from './source.js';
|
|
18
|
+
import { Titles } from './titles.js';
|
|
19
|
+
import { sorted } from './util.js';
|
|
20
|
+
import { construct } from './model.js';
|
|
21
|
+
/** A set goes over the wire as a sorted list. */
|
|
22
|
+
function wire(value) {
|
|
23
|
+
return value instanceof Set ? sorted(value) : value;
|
|
24
|
+
}
|
|
25
|
+
/** A source that uses a relations-restx API as its backend. */
|
|
26
|
+
export class RestSource extends Source {
|
|
27
|
+
constructor(name, options = {}) {
|
|
28
|
+
if (typeof options.url !== 'string') {
|
|
29
|
+
throw new Error(`${name}: no url - pass options.url`);
|
|
30
|
+
}
|
|
31
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
32
|
+
if (typeof fetcher !== 'function') {
|
|
33
|
+
throw new Error(`${name}: no fetch here - pass options.fetch`);
|
|
34
|
+
}
|
|
35
|
+
super(name, options);
|
|
36
|
+
// Called bare, so a browser's own fetch doesn't complain about what it was called on.
|
|
37
|
+
this.fetch = (url, init) => fetcher(url, init);
|
|
38
|
+
this.url = options.url.replace(/\/+$/, '');
|
|
39
|
+
this.headers = { ...options.headers };
|
|
40
|
+
this.credentials = options.credentials;
|
|
41
|
+
this.request = { ...options.request };
|
|
42
|
+
}
|
|
43
|
+
/** Send a request, always with a JSON body, and hand back the response. */
|
|
44
|
+
async send(method, path, body) {
|
|
45
|
+
const init = {
|
|
46
|
+
...this.request,
|
|
47
|
+
method,
|
|
48
|
+
headers: { 'Content-Type': 'application/json', ...this.headers },
|
|
49
|
+
body: JSON.stringify(body)
|
|
50
|
+
};
|
|
51
|
+
if (this.credentials !== undefined) {
|
|
52
|
+
init.credentials = this.credentials;
|
|
53
|
+
}
|
|
54
|
+
return this.fetch(`${this.url}/${path}`, init);
|
|
55
|
+
}
|
|
56
|
+
/** Checks a response and returns the result. */
|
|
57
|
+
async result(model, key, response) {
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = await response.json();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
body = null;
|
|
64
|
+
}
|
|
65
|
+
if (response.status >= 400) {
|
|
66
|
+
throw new ModelError(model, body?.message ?? 'API Error');
|
|
67
|
+
}
|
|
68
|
+
if (body === null || typeof body !== 'object') {
|
|
69
|
+
throw new ModelError(model, 'API Error');
|
|
70
|
+
}
|
|
71
|
+
if ('overflow' in body) {
|
|
72
|
+
model.overflow = model.overflow || body.overflow;
|
|
73
|
+
}
|
|
74
|
+
return body[key];
|
|
75
|
+
}
|
|
76
|
+
/** Init the model. */
|
|
77
|
+
init(model) {
|
|
78
|
+
this.recordInit(model._fields);
|
|
79
|
+
model.SINGULAR = model.SINGULAR ?? model.NAME;
|
|
80
|
+
model.PLURAL = model.PLURAL ?? `${model.SINGULAR}s`;
|
|
81
|
+
model.ENDPOINT = model.ENDPOINT ?? model.SINGULAR;
|
|
82
|
+
if (model._id !== null) {
|
|
83
|
+
const field = model._fields.field(model._id);
|
|
84
|
+
if (field.auto === null) {
|
|
85
|
+
field.auto = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ------------------------------------------------------------------ create
|
|
90
|
+
/** Updates values with the field's that changed. */
|
|
91
|
+
createField(field, values) {
|
|
92
|
+
if (!field.auto) {
|
|
93
|
+
values[field.name] = field.export();
|
|
94
|
+
field.original = field.export();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Executes the create. */
|
|
98
|
+
async create(model) {
|
|
99
|
+
const models = model._each('create');
|
|
100
|
+
const values = [];
|
|
101
|
+
for (const creating of models) {
|
|
102
|
+
const record = {};
|
|
103
|
+
this.createRecord(creating._record, record);
|
|
104
|
+
values.push(record);
|
|
105
|
+
}
|
|
106
|
+
const records = await this.result(model, model.PLURAL, await this.send('POST', model.ENDPOINT, { [model.PLURAL]: values }));
|
|
107
|
+
for (const [index, creating] of models.entries()) {
|
|
108
|
+
if (model._id !== null && model._fields.field(model._id).auto) {
|
|
109
|
+
;
|
|
110
|
+
creating[model._id] = records[index][model._id];
|
|
111
|
+
}
|
|
112
|
+
if (!model._bulk) {
|
|
113
|
+
for (const attr of Object.keys(creating.CHILDREN)) {
|
|
114
|
+
const child = creating._children[attr];
|
|
115
|
+
if (child) {
|
|
116
|
+
await child.create();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
creating._action = 'update';
|
|
121
|
+
creating._record._action = 'update';
|
|
122
|
+
}
|
|
123
|
+
if (model._bulk) {
|
|
124
|
+
model._models = [];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
model._action = 'update';
|
|
128
|
+
}
|
|
129
|
+
return model;
|
|
130
|
+
}
|
|
131
|
+
// ------------------------------------------------------------------ retrieve
|
|
132
|
+
/** Adds criteria to the filter. */
|
|
133
|
+
retrieveField(field, criteria) {
|
|
134
|
+
for (const [operator, value] of Object.entries(field.criteria ?? {})) {
|
|
135
|
+
criteria[`${field.name}__${operator}`] = wire(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Adds sibling-attribute filters (model._ties, e.g. bro__name: 'Tom') to the filter so the
|
|
140
|
+
* remote API resolves them through many(); the tie field lives on the model, not the record.
|
|
141
|
+
*/
|
|
142
|
+
static filterTies(model, criteria) {
|
|
143
|
+
for (const [name, attrs] of Object.entries(model._ties)) {
|
|
144
|
+
for (const [predicate, value] of Object.entries(attrs)) {
|
|
145
|
+
criteria[`${name}__${predicate}`] = wire(value);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Everything that narrows the model down, as the API wants it. */
|
|
150
|
+
async filter(model) {
|
|
151
|
+
await model._collate();
|
|
152
|
+
const criteria = {};
|
|
153
|
+
this.retrieveRecord(model._record, criteria);
|
|
154
|
+
RestSource.filterTies(model, criteria);
|
|
155
|
+
if (model._like !== null) {
|
|
156
|
+
criteria.like = model._like;
|
|
157
|
+
}
|
|
158
|
+
return criteria;
|
|
159
|
+
}
|
|
160
|
+
/** Executes the count. */
|
|
161
|
+
async count(model) {
|
|
162
|
+
const body = { filter: await this.filter(model), count: true };
|
|
163
|
+
return this.result(model, model.PLURAL, await this.send('POST', model.ENDPOINT, body));
|
|
164
|
+
}
|
|
165
|
+
/** Executes the retrieve. */
|
|
166
|
+
async retrieve(model, verify = true) {
|
|
167
|
+
const body = { filter: await this.filter(model) };
|
|
168
|
+
if (model._sort?.length) {
|
|
169
|
+
body.sort = model._sort;
|
|
170
|
+
}
|
|
171
|
+
if (model._limit !== null) {
|
|
172
|
+
body.limit = { per_page: model._limit };
|
|
173
|
+
if (model._offset) {
|
|
174
|
+
body.limit.start = model._offset;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const matches = await this.result(model, model.PLURAL, await this.send('POST', model.ENDPOINT, body));
|
|
178
|
+
if (model._mode === 'one' && matches.length > 1) {
|
|
179
|
+
throw new ModelError(model, 'more than one retrieved');
|
|
180
|
+
}
|
|
181
|
+
if (model._mode === 'one' && model._role !== 'child') {
|
|
182
|
+
if (matches.length < 1) {
|
|
183
|
+
if (verify) {
|
|
184
|
+
throw new ModelError(model, 'none retrieved');
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
model._record = model._build('update', { read: matches[0] });
|
|
189
|
+
model._action = 'update';
|
|
190
|
+
this.retrieveTies(model, matches[0]);
|
|
191
|
+
return model;
|
|
192
|
+
}
|
|
193
|
+
model._models = [];
|
|
194
|
+
for (const match of matches) {
|
|
195
|
+
const retrieved = construct(model.constructor, { read: match });
|
|
196
|
+
this.retrieveTies(retrieved, match);
|
|
197
|
+
model._models.push(retrieved);
|
|
198
|
+
}
|
|
199
|
+
model._record = null;
|
|
200
|
+
model._action = 'update';
|
|
201
|
+
return model;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Loads the tie ids the API already resolved onto the model.
|
|
205
|
+
*
|
|
206
|
+
* Other sources query the tie table; the API has no tie endpoint but returns the
|
|
207
|
+
* resolved ids inline, so they're read straight off the response.
|
|
208
|
+
*/
|
|
209
|
+
retrieveTies(model, read) {
|
|
210
|
+
for (const relation of Object.values(model.SISTERS)) {
|
|
211
|
+
if (relation.brotherSisterRef in read) {
|
|
212
|
+
;
|
|
213
|
+
model[relation.brotherSisterRef] = read[relation.brotherSisterRef];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const relation of Object.values(model.BROTHERS)) {
|
|
217
|
+
if (relation.sisterBrotherRef in read) {
|
|
218
|
+
;
|
|
219
|
+
model[relation.sisterBrotherRef] = read[relation.sisterBrotherRef];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ------------------------------------------------------------------ titles
|
|
224
|
+
/** Creates the titles structure. */
|
|
225
|
+
async titles(model) {
|
|
226
|
+
if (model._action === 'retrieve') {
|
|
227
|
+
await this.retrieve(model);
|
|
228
|
+
}
|
|
229
|
+
const titles = await Titles.build(model);
|
|
230
|
+
for (const titling of model._each()) {
|
|
231
|
+
titles.add(titling);
|
|
232
|
+
}
|
|
233
|
+
return titles;
|
|
234
|
+
}
|
|
235
|
+
// ------------------------------------------------------------------ update
|
|
236
|
+
/** Updates values with the field's that changed. */
|
|
237
|
+
updateField(field, values) {
|
|
238
|
+
if (!field.auto && field.delta()) {
|
|
239
|
+
field.original = field.export();
|
|
240
|
+
values[field.name] = field.original;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Mass values with the field's that changed. */
|
|
244
|
+
fieldMass(field, values) {
|
|
245
|
+
if (!field.auto && field.changed) {
|
|
246
|
+
values[field.name] = field.export();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Executes the update. */
|
|
250
|
+
async update(model) {
|
|
251
|
+
let updated = 0;
|
|
252
|
+
if (model._action === 'retrieve' && model._record._action === 'update') {
|
|
253
|
+
// The model is still retrieving but has values set: change everything it matches.
|
|
254
|
+
const criteria = await this.filter(model);
|
|
255
|
+
const values = {};
|
|
256
|
+
this.recordMass(model._record, values);
|
|
257
|
+
updated += await this.result(model, 'updated', await this.send('PATCH', model.ENDPOINT, { filter: criteria, [model.PLURAL]: values }));
|
|
258
|
+
}
|
|
259
|
+
else if (model._id) {
|
|
260
|
+
for (const updating of model._each('update')) {
|
|
261
|
+
const values = {};
|
|
262
|
+
this.updateRecord(updating._record, values);
|
|
263
|
+
updated += await this.result(updating, 'updated', await this.send('PATCH', `${model.ENDPOINT}/${encodeURIComponent(updating[model._id])}`, {
|
|
264
|
+
[model.SINGULAR]: values
|
|
265
|
+
}));
|
|
266
|
+
for (const attr of Object.keys(updating.CHILDREN)) {
|
|
267
|
+
const child = updating._children[attr];
|
|
268
|
+
if (child) {
|
|
269
|
+
await child.create();
|
|
270
|
+
await child.update();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
throw new ModelError(model, 'nothing to update from');
|
|
277
|
+
}
|
|
278
|
+
return updated;
|
|
279
|
+
}
|
|
280
|
+
// ------------------------------------------------------------------ delete
|
|
281
|
+
/** Executes the delete. */
|
|
282
|
+
async delete(model) {
|
|
283
|
+
let criteria = {};
|
|
284
|
+
let deleting = [];
|
|
285
|
+
if (model._action === 'retrieve') {
|
|
286
|
+
criteria = await this.filter(model);
|
|
287
|
+
}
|
|
288
|
+
else if (model._id) {
|
|
289
|
+
deleting = model._each();
|
|
290
|
+
criteria[`${model._id}__in`] = deleting.map((each) => each[model._id]);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
throw new ModelError(model, 'nothing to delete from');
|
|
294
|
+
}
|
|
295
|
+
const deleted = await this.result(model, 'deleted', await this.send('DELETE', model.ENDPOINT, { filter: criteria }));
|
|
296
|
+
for (const each of deleting) {
|
|
297
|
+
each._action = 'create';
|
|
298
|
+
}
|
|
299
|
+
if (model._action !== 'retrieve') {
|
|
300
|
+
model._action = 'create';
|
|
301
|
+
}
|
|
302
|
+
return deleted;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
RestSource.KIND = 'rest';
|
|
306
|
+
//# sourceMappingURL=rest.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relations-dil/relations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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": {
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"types": "./dist/local.d.ts",
|
|
36
36
|
"default": "./dist/local.js"
|
|
37
37
|
},
|
|
38
|
+
"./rest": {
|
|
39
|
+
"types": "./dist/rest.d.ts",
|
|
40
|
+
"default": "./dist/rest.js"
|
|
41
|
+
},
|
|
38
42
|
"./overscore": {
|
|
39
43
|
"types": "./dist/overscore.d.ts",
|
|
40
44
|
"default": "./dist/overscore.js"
|
|
@@ -66,6 +70,7 @@
|
|
|
66
70
|
"@unum-pillars/uikit": "^0.1.0",
|
|
67
71
|
"esbuild": "^0.25.12",
|
|
68
72
|
"eslint": "^9.39.5",
|
|
73
|
+
"jsdom": "^24.1.3",
|
|
69
74
|
"tsx": "^4.23.15",
|
|
70
75
|
"typescript": "^5.6.0",
|
|
71
76
|
"typescript-eslint": "^8.71.0"
|