@relations-dil/relations 0.1.0 → 0.2.1
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 +59 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/local.d.ts +42 -0
- package/dist/local.js +89 -0
- package/dist/migrations.js +12 -11
- package/dist/mock.js +9 -6
- package/dist/rest.d.ts +91 -0
- package/dist/rest.js +306 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +4 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -780,8 +780,65 @@ Instance: `filter()`, `sort()`, `limit()`, `set()`, `add()`, `queue()`, `export(
|
|
|
780
780
|
|
|
781
781
|
### Sources
|
|
782
782
|
|
|
783
|
-
`Source` (subclass this), `MockSource` (in-memory, complete), `
|
|
784
|
-
`unregister()`, `clear()`, `SOURCES`.
|
|
783
|
+
`Source` (subclass this), `MockSource` (in-memory, complete), `LocalSource` (the same, kept in the
|
|
784
|
+
browser's `localStorage`), `RestSource` (a relations-restx API as the backend), `register()`, `source()`, `unregister()`, `clear()`, `SOURCES`.
|
|
785
|
+
|
|
786
|
+
#### LocalSource
|
|
787
|
+
|
|
788
|
+
For pages that need to remember things with no server: it behaves like any other source, but the
|
|
789
|
+
data survives a reload.
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
import { LocalSource } from '@relations-dil/relations/local'
|
|
793
|
+
|
|
794
|
+
new LocalSource('example', { key: 'my-app' })
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
It loads once when it's made and saves after every write that succeeds, as plain JSON under `key`
|
|
798
|
+
(default `relations:<name>`). Pass `storage` to use something other than `localStorage`. Two tabs
|
|
799
|
+
open on the same key don't see each other's changes; whichever saves last wins, and `reset()`
|
|
800
|
+
forgets everything. If what's in storage isn't valid JSON it refuses to start, rather than
|
|
801
|
+
overwrite it.
|
|
802
|
+
|
|
803
|
+
#### RestSource
|
|
804
|
+
|
|
805
|
+
For models whose data lives behind a [relations-restx](https://github.com/relations-dil/python-relations-restx)
|
|
806
|
+
API: swap `MockSource` or `LocalSource` for it at runtime and the same models read and write the
|
|
807
|
+
remote service. It's a port of python-relations-rest, and it runs in the browser or in Node.
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
import { RestSource } from '@relations-dil/relations/rest'
|
|
811
|
+
|
|
812
|
+
new RestSource('example', { url: 'https://api.example.com' })
|
|
813
|
+
|
|
814
|
+
const unit = await Unit.one({ name: 'yep' }).retrieve() // POST https://api.example.com/unit
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Options:
|
|
818
|
+
|
|
819
|
+
- `url` (required): where the API lives. `''` is fine for the same origin.
|
|
820
|
+
- `fetch`: anything shaped like `fetch`. Defaults to `globalThis.fetch`, so pass one in on older
|
|
821
|
+
Node or to stand in for the network in tests.
|
|
822
|
+
- `headers`: sent on every request, e.g. `{ Authorization: 'Bearer ...' }`. Requests have a JSON
|
|
823
|
+
body and `Content-Type: application/json`; a header of your own wins.
|
|
824
|
+
- `credentials`: passed through to fetch, e.g. `'include'` to send cookies cross-origin.
|
|
825
|
+
- `request`: anything else to hand fetch on every request, like `mode` or `cache`.
|
|
826
|
+
|
|
827
|
+
A model's `SINGULAR`, `PLURAL` and `ENDPOINT` (defaulting to its name, the name plus `s`, and its
|
|
828
|
+
name) say how it's addressed, just as in the Python client. The endpoint is `/<ENDPOINT>`, and by
|
|
829
|
+
id `/<ENDPOINT>/<id>`.
|
|
830
|
+
|
|
831
|
+
Reads are sent as `POST /<endpoint>` with a `{"filter": ...}` body, not GET. Browsers can't send a
|
|
832
|
+
body with a GET, and relations-restx treats a POST that carries a `filter` exactly like a GET, so
|
|
833
|
+
`filter`, `sort`, `limit` and `count` all work. Creates are `POST {plural: [...]}`, updates by id
|
|
834
|
+
are `PATCH /<endpoint>/<id>` with `{singular: {...}}`, mass updates are `PATCH /<endpoint>` with
|
|
835
|
+
`{filter, plural: {...}}`, and deletes are `DELETE /<endpoint>` with `{filter}`.
|
|
836
|
+
|
|
837
|
+
A response of 400 or more throws a `ModelError` carrying the API's `message` (or `API Error`). An
|
|
838
|
+
`overflow` in a response is carried onto the model. Sets go over as sorted lists. Like the Python
|
|
839
|
+
client, it never asks the API for more than the model's `limit()`, but restx always applies one
|
|
840
|
+
itself (the server model's `CHUNK`, 100 unless set), so a retrieve with no `limit()` returns at most
|
|
841
|
+
that many and sets `overflow` when it got that many.
|
|
785
842
|
|
|
786
843
|
### Migrations and data
|
|
787
844
|
|
package/dist/index.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export { Source, lookup, type Query } from './source.js';
|
|
|
42
42
|
export { Titles } from './titles.js';
|
|
43
43
|
export { Migrations, pretty, type Renamer } from './migrations.js';
|
|
44
44
|
export { MockQuery, MockSource } from './mock.js';
|
|
45
|
+
export { LocalSource, type StorageLike } from './local.js';
|
|
46
|
+
export { RestSource, type FetchInit, type FetchLike, type FetchResponse } from './rest.js';
|
|
45
47
|
export { fields, type Listed, type Typed, type TypedClass, type ValueOfKind, type ValueOfSpec, type Values } from './typed.js';
|
|
46
48
|
export { definitionsOf, modelFrom, modelsFrom, type Built, type Definition, type RelationDefinition } from './dynamic.js';
|
|
47
49
|
export { SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,8 @@ export { Source, lookup } from './source.js';
|
|
|
42
42
|
export { Titles } from './titles.js';
|
|
43
43
|
export { Migrations, pretty } from './migrations.js';
|
|
44
44
|
export { MockQuery, MockSource } from './mock.js';
|
|
45
|
+
export { LocalSource } from './local.js';
|
|
46
|
+
export { RestSource } from './rest.js';
|
|
45
47
|
export { fields } from './typed.js';
|
|
46
48
|
export { definitionsOf, modelFrom, modelsFrom } from './dynamic.js';
|
|
47
49
|
export { SOURCES, clear, register, source, unregister } from './registry.js';
|
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source that keeps everything in the browser's localStorage.
|
|
3
|
+
*
|
|
4
|
+
* Same behaviour as every other source - unique indexes, auto ids, relations, titles - but the
|
|
5
|
+
* data survives a page reload, with no server and nothing to install. Reach for it for apps that
|
|
6
|
+
* are a single file you open from disk, or any page that just needs to remember things.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { LocalSource } from '@relations-dil/relations/local'
|
|
10
|
+
*
|
|
11
|
+
* new LocalSource('example', { key: 'my-app' })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* It loads once, when it's made, and saves after every write that succeeds. Two tabs open on the
|
|
15
|
+
* same key don't see each other's changes: whichever saves last wins.
|
|
16
|
+
*/
|
|
17
|
+
import { MockSource } from './mock.js';
|
|
18
|
+
import type { Model } from './model.js';
|
|
19
|
+
/** The slice of the Web Storage API that's used, so tests (and other stores) can stand in. */
|
|
20
|
+
export interface StorageLike {
|
|
21
|
+
getItem(key: string): string | null;
|
|
22
|
+
setItem(key: string, value: string): void;
|
|
23
|
+
removeItem(key: string): void;
|
|
24
|
+
}
|
|
25
|
+
/** A source whose data lives in localStorage. */
|
|
26
|
+
export declare class LocalSource extends MockSource {
|
|
27
|
+
/** The localStorage key everything is kept under. */
|
|
28
|
+
key: string;
|
|
29
|
+
/** Where it's kept. Defaults to the page's localStorage. */
|
|
30
|
+
storage: StorageLike;
|
|
31
|
+
constructor(name: string, options?: globalThis.Record<string, any>);
|
|
32
|
+
/** Read what was saved last time, if anything. */
|
|
33
|
+
restore(): void;
|
|
34
|
+
/** Write everything out. Maps become lists of [id, value] pairs. */
|
|
35
|
+
save(): void;
|
|
36
|
+
/** Forget everything, here and in storage. The models stay registered. */
|
|
37
|
+
reset(): void;
|
|
38
|
+
create(model: Model): Promise<Model>;
|
|
39
|
+
update(model: Model): Promise<number>;
|
|
40
|
+
delete(model: Model): Promise<number>;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=local.d.ts.map
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source that keeps everything in the browser's localStorage.
|
|
3
|
+
*
|
|
4
|
+
* Same behaviour as every other source - unique indexes, auto ids, relations, titles - but the
|
|
5
|
+
* data survives a page reload, with no server and nothing to install. Reach for it for apps that
|
|
6
|
+
* are a single file you open from disk, or any page that just needs to remember things.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { LocalSource } from '@relations-dil/relations/local'
|
|
10
|
+
*
|
|
11
|
+
* new LocalSource('example', { key: 'my-app' })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* It loads once, when it's made, and saves after every write that succeeds. Two tabs open on the
|
|
15
|
+
* same key don't see each other's changes: whichever saves last wins.
|
|
16
|
+
*/
|
|
17
|
+
import { MockSource } from './mock.js';
|
|
18
|
+
/** A source whose data lives in localStorage. */
|
|
19
|
+
export class LocalSource extends MockSource {
|
|
20
|
+
constructor(name, options = {}) {
|
|
21
|
+
super(name, options);
|
|
22
|
+
const storage = options.storage ?? globalThis.localStorage;
|
|
23
|
+
if (storage === undefined) {
|
|
24
|
+
throw new Error(`${name}: no localStorage here - pass options.storage`);
|
|
25
|
+
}
|
|
26
|
+
this.key = options.key ?? `relations:${name}`;
|
|
27
|
+
this.storage = storage;
|
|
28
|
+
this.restore();
|
|
29
|
+
}
|
|
30
|
+
/** Read what was saved last time, if anything. */
|
|
31
|
+
restore() {
|
|
32
|
+
const saved = this.storage.getItem(this.key);
|
|
33
|
+
if (saved === null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(saved);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Carrying on would overwrite whatever's there at the next save.
|
|
42
|
+
throw new Error(`${this.name}: ${this.key} in storage isn't valid JSON`);
|
|
43
|
+
}
|
|
44
|
+
this.ids = parsed.ids;
|
|
45
|
+
this.data = Object.fromEntries(Object.entries(parsed.data).map(([name, rows]) => [name, new Map(rows)]));
|
|
46
|
+
this.unique = Object.fromEntries(Object.entries(parsed.unique).map(([name, indexes]) => [
|
|
47
|
+
name,
|
|
48
|
+
Object.fromEntries(Object.entries(indexes).map(([index, rows]) => [index, new Map(rows)]))
|
|
49
|
+
]));
|
|
50
|
+
}
|
|
51
|
+
/** Write everything out. Maps become lists of [id, value] pairs. */
|
|
52
|
+
save() {
|
|
53
|
+
this.storage.setItem(this.key, JSON.stringify({
|
|
54
|
+
ids: this.ids,
|
|
55
|
+
data: Object.fromEntries(Object.entries(this.data).map(([name, records]) => [name, [...records]])),
|
|
56
|
+
unique: Object.fromEntries(Object.entries(this.unique).map(([name, indexes]) => [
|
|
57
|
+
name,
|
|
58
|
+
Object.fromEntries(Object.entries(indexes).map(([index, values]) => [index, [...values]]))
|
|
59
|
+
]))
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
/** Forget everything, here and in storage. The models stay registered. */
|
|
63
|
+
reset() {
|
|
64
|
+
this.storage.removeItem(this.key);
|
|
65
|
+
this.ids = Object.fromEntries(Object.keys(this.ids).map((name) => [name, 0]));
|
|
66
|
+
this.data = Object.fromEntries(Object.keys(this.data).map((name) => [name, new Map()]));
|
|
67
|
+
this.unique = Object.fromEntries(Object.entries(this.unique).map(([name, indexes]) => [
|
|
68
|
+
name,
|
|
69
|
+
Object.fromEntries(Object.keys(indexes).map((index) => [index, new Map()]))
|
|
70
|
+
]));
|
|
71
|
+
}
|
|
72
|
+
async create(model) {
|
|
73
|
+
const created = await super.create(model);
|
|
74
|
+
this.save();
|
|
75
|
+
return created;
|
|
76
|
+
}
|
|
77
|
+
async update(model) {
|
|
78
|
+
const updated = await super.update(model);
|
|
79
|
+
this.save();
|
|
80
|
+
return updated;
|
|
81
|
+
}
|
|
82
|
+
async delete(model) {
|
|
83
|
+
const deleted = await super.delete(model);
|
|
84
|
+
this.save();
|
|
85
|
+
return deleted;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
LocalSource.KIND = 'local';
|
|
89
|
+
//# sourceMappingURL=local.js.map
|
package/dist/migrations.js
CHANGED
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* DDL for a database, nothing at all for an API - so the same migration history drives
|
|
6
6
|
* every backend a set of models is used with.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdir, readdir, readFile, rename as renameFile, writeFile } from 'node:fs/promises';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
8
|
import { MigrationsError } from './errors.js';
|
|
11
9
|
import { source as registered } from './registry.js';
|
|
12
|
-
import { equal } from './util.js';
|
|
10
|
+
import { equal, joinPath } from './util.js';
|
|
13
11
|
import { MigrationsRef } from './model.js';
|
|
14
12
|
const noRenames = () => ({});
|
|
15
13
|
/** Diffs model definitions and drives a source through the resulting changes. */
|
|
@@ -24,8 +22,9 @@ export class Migrations {
|
|
|
24
22
|
}
|
|
25
23
|
/** The definition as of the last generate, or an empty one. */
|
|
26
24
|
async current() {
|
|
25
|
+
const { readFile } = await import('node:fs/promises');
|
|
27
26
|
try {
|
|
28
|
-
return JSON.parse(await readFile(
|
|
27
|
+
return JSON.parse(await readFile(joinPath(this.directory, 'definition.json'), 'utf8'));
|
|
29
28
|
}
|
|
30
29
|
catch (error) {
|
|
31
30
|
if (error.code === 'ENOENT') {
|
|
@@ -191,6 +190,7 @@ export class Migrations {
|
|
|
191
190
|
* Resolves to whether anything was written.
|
|
192
191
|
*/
|
|
193
192
|
async generate(models, stamp) {
|
|
193
|
+
const { mkdir, rename: renameFile, writeFile } = await import('node:fs/promises');
|
|
194
194
|
await mkdir(this.directory, { recursive: true });
|
|
195
195
|
const current = await this.current();
|
|
196
196
|
const define = Migrations.define(models);
|
|
@@ -200,10 +200,10 @@ export class Migrations {
|
|
|
200
200
|
}
|
|
201
201
|
const migration = Migrations.models(current, define, this.renamer);
|
|
202
202
|
const at = stamp ?? stamped();
|
|
203
|
-
await renameFile(
|
|
204
|
-
await writeFile(
|
|
203
|
+
await renameFile(joinPath(this.directory, 'definition.json'), joinPath(this.directory, `definition-${at}.json`));
|
|
204
|
+
await writeFile(joinPath(this.directory, `migration-${at}.json`), pretty(migration));
|
|
205
205
|
}
|
|
206
|
-
await writeFile(
|
|
206
|
+
await writeFile(joinPath(this.directory, 'definition.json'), pretty(define));
|
|
207
207
|
return true;
|
|
208
208
|
}
|
|
209
209
|
/** Where a source's converted files live. */
|
|
@@ -212,7 +212,7 @@ export class Migrations {
|
|
|
212
212
|
if (source === undefined) {
|
|
213
213
|
throw new MigrationsError(`no source registered as '${name}'`);
|
|
214
214
|
}
|
|
215
|
-
return
|
|
215
|
+
return joinPath(this.directory, source.name, String(source.KIND));
|
|
216
216
|
}
|
|
217
217
|
/** Convert every definition and migration into a source's own form. */
|
|
218
218
|
async convert(name) {
|
|
@@ -220,6 +220,7 @@ export class Migrations {
|
|
|
220
220
|
if (source === undefined) {
|
|
221
221
|
throw new MigrationsError(`no source registered as '${name}'`);
|
|
222
222
|
}
|
|
223
|
+
const { mkdir, readdir } = await import('node:fs/promises');
|
|
223
224
|
const path = this.sourcePath(name);
|
|
224
225
|
await mkdir(path, { recursive: true });
|
|
225
226
|
for (const file of (await readdir(this.directory)).sort()) {
|
|
@@ -227,10 +228,10 @@ export class Migrations {
|
|
|
227
228
|
continue;
|
|
228
229
|
}
|
|
229
230
|
if (file.startsWith('definition')) {
|
|
230
|
-
await source.definition(
|
|
231
|
+
await source.definition(joinPath(this.directory, file), path);
|
|
231
232
|
}
|
|
232
233
|
else if (file.startsWith('migration')) {
|
|
233
|
-
await source.migration(
|
|
234
|
+
await source.migration(joinPath(this.directory, file), path);
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
}
|
|
@@ -248,7 +249,7 @@ export class Migrations {
|
|
|
248
249
|
if (source === undefined) {
|
|
249
250
|
throw new MigrationsError(`no source registered as '${name}'`);
|
|
250
251
|
}
|
|
251
|
-
return source.load(
|
|
252
|
+
return source.load(joinPath(this.sourcePath(name), fileName));
|
|
252
253
|
}
|
|
253
254
|
/** Bring a source up to date. Resolves to whether anything was applied. */
|
|
254
255
|
async apply(name) {
|
package/dist/mock.js
CHANGED
|
@@ -10,13 +10,11 @@
|
|
|
10
10
|
* new MockSource('example')
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
13
|
import * as overscore from './overscore.js';
|
|
16
14
|
import { ModelError, UniqueError } from './errors.js';
|
|
17
15
|
import { Source } from './source.js';
|
|
18
16
|
import { Titles } from './titles.js';
|
|
19
|
-
import { clone, stable } from './util.js';
|
|
17
|
+
import { clone, joinPath, stable } from './util.js';
|
|
20
18
|
import { construct } from './model.js';
|
|
21
19
|
/** A stand-in for a real source's query object. */
|
|
22
20
|
export class MockQuery {
|
|
@@ -379,6 +377,7 @@ export class MockSource extends Source {
|
|
|
379
377
|
}
|
|
380
378
|
// ------------------------------------------------------------------ files
|
|
381
379
|
async definition(filePath, sourcePath) {
|
|
380
|
+
const { readFile } = await import('node:fs/promises');
|
|
382
381
|
const definitions = [];
|
|
383
382
|
const definition = JSON.parse(await readFile(filePath, 'utf8'));
|
|
384
383
|
for (const name of Object.keys(definition).sort()) {
|
|
@@ -391,6 +390,7 @@ export class MockSource extends Source {
|
|
|
391
390
|
}
|
|
392
391
|
}
|
|
393
392
|
async migration(filePath, sourcePath) {
|
|
393
|
+
const { readFile } = await import('node:fs/promises');
|
|
394
394
|
const migrations = [];
|
|
395
395
|
const migration = JSON.parse(await readFile(filePath, 'utf8'));
|
|
396
396
|
for (const add of Object.keys(migration.add ?? {}).sort()) {
|
|
@@ -456,9 +456,11 @@ export class MockSource extends Source {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
async load(loadPath) {
|
|
459
|
+
const { readFile } = await import('node:fs/promises');
|
|
459
460
|
await this.execute(JSON.parse(await readFile(loadPath, 'utf8')));
|
|
460
461
|
}
|
|
461
462
|
async list(sourcePath) {
|
|
463
|
+
const { readdir } = await import('node:fs/promises');
|
|
462
464
|
const migrations = {};
|
|
463
465
|
for (const fileName of await readdir(sourcePath)) {
|
|
464
466
|
if (!fileName.endsWith('.json') || !fileName.includes('-')) {
|
|
@@ -474,19 +476,20 @@ export class MockSource extends Source {
|
|
|
474
476
|
return migrations;
|
|
475
477
|
}
|
|
476
478
|
async migrate(sourcePath) {
|
|
479
|
+
const { readdir } = await import('node:fs/promises');
|
|
477
480
|
let migrated = false;
|
|
478
481
|
const paths = (await readdir(sourcePath))
|
|
479
482
|
.filter((file) => file.startsWith('migration-') && file.endsWith('.json'))
|
|
480
483
|
.sort();
|
|
481
484
|
if (this.migrations === null) {
|
|
482
485
|
this.migrations = [];
|
|
483
|
-
await this.load(
|
|
486
|
+
await this.load(joinPath(sourcePath, 'definition.json'));
|
|
484
487
|
migrated = true;
|
|
485
488
|
}
|
|
486
489
|
else {
|
|
487
490
|
for (const path of paths) {
|
|
488
491
|
if (!this.migrations.includes(stampOf(path))) {
|
|
489
|
-
await this.load(
|
|
492
|
+
await this.load(joinPath(sourcePath, path));
|
|
490
493
|
migrated = true;
|
|
491
494
|
}
|
|
492
495
|
}
|
|
@@ -506,6 +509,6 @@ function stampOf(path) {
|
|
|
506
509
|
async function write(sourcePath, filePath, contents) {
|
|
507
510
|
const { writeFile } = await import('node:fs/promises');
|
|
508
511
|
const fileName = filePath.split('/').pop().split('.')[0];
|
|
509
|
-
await writeFile(
|
|
512
|
+
await writeFile(joinPath(sourcePath, `${fileName}.json`), `${JSON.stringify(contents)}\n`);
|
|
510
513
|
}
|
|
511
514
|
//# sourceMappingURL=mock.js.map
|
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/dist/util.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export declare function sorted<T>(values: Iterable<T>): T[];
|
|
|
13
13
|
export declare function compare(first: any, second: any): number;
|
|
14
14
|
/** Turn CamelCase into under_scored. */
|
|
15
15
|
export declare function underscore(name: string): string;
|
|
16
|
+
/** Join path pieces with `/`, collapsing repeats. All the file paths here need, without `node:path`. */
|
|
17
|
+
export declare function joinPath(...parts: string[]): string;
|
|
16
18
|
//# sourceMappingURL=util.d.ts.map
|
package/dist/util.js
CHANGED
|
@@ -114,4 +114,8 @@ export function underscore(name) {
|
|
|
114
114
|
}
|
|
115
115
|
return parts.join('');
|
|
116
116
|
}
|
|
117
|
+
/** Join path pieces with `/`, collapsing repeats. All the file paths here need, without `node:path`. */
|
|
118
|
+
export function joinPath(...parts) {
|
|
119
|
+
return parts.join('/').replace(/\/{2,}/g, '/');
|
|
120
|
+
}
|
|
117
121
|
//# sourceMappingURL=util.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relations-dil/relations",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
"types": "./dist/mock.d.ts",
|
|
32
32
|
"default": "./dist/mock.js"
|
|
33
33
|
},
|
|
34
|
+
"./local": {
|
|
35
|
+
"types": "./dist/local.d.ts",
|
|
36
|
+
"default": "./dist/local.js"
|
|
37
|
+
},
|
|
38
|
+
"./rest": {
|
|
39
|
+
"types": "./dist/rest.d.ts",
|
|
40
|
+
"default": "./dist/rest.js"
|
|
41
|
+
},
|
|
34
42
|
"./overscore": {
|
|
35
43
|
"types": "./dist/overscore.d.ts",
|
|
36
44
|
"default": "./dist/overscore.js"
|
|
@@ -60,7 +68,9 @@
|
|
|
60
68
|
"@eslint/js": "^9.39.5",
|
|
61
69
|
"@types/node": "^22.0.0",
|
|
62
70
|
"@unum-pillars/uikit": "^0.1.0",
|
|
71
|
+
"esbuild": "^0.25.12",
|
|
63
72
|
"eslint": "^9.39.5",
|
|
73
|
+
"jsdom": "^24.1.3",
|
|
64
74
|
"tsx": "^4.23.15",
|
|
65
75
|
"typescript": "^5.6.0",
|
|
66
76
|
"typescript-eslint": "^8.71.0"
|