@relations-dil/relations 0.1.0 → 0.2.0
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 +19 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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/util.d.ts +2 -0
- package/dist/util.js +4 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -780,8 +780,25 @@ 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`), `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.
|
|
785
802
|
|
|
786
803
|
### Migrations and data
|
|
787
804
|
|
package/dist/index.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ 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';
|
|
45
46
|
export { fields, type Listed, type Typed, type TypedClass, type ValueOfKind, type ValueOfSpec, type Values } from './typed.js';
|
|
46
47
|
export { definitionsOf, modelFrom, modelsFrom, type Built, type Definition, type RelationDefinition } from './dynamic.js';
|
|
47
48
|
export { SOURCES, clear, register, source, unregister, type Registered } from './registry.js';
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ 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';
|
|
45
46
|
export { fields } from './typed.js';
|
|
46
47
|
export { definitionsOf, modelFrom, modelsFrom } from './dynamic.js';
|
|
47
48
|
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/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.
|
|
3
|
+
"version": "0.2.0",
|
|
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,10 @@
|
|
|
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
|
+
},
|
|
34
38
|
"./overscore": {
|
|
35
39
|
"types": "./dist/overscore.d.ts",
|
|
36
40
|
"default": "./dist/overscore.js"
|
|
@@ -60,6 +64,7 @@
|
|
|
60
64
|
"@eslint/js": "^9.39.5",
|
|
61
65
|
"@types/node": "^22.0.0",
|
|
62
66
|
"@unum-pillars/uikit": "^0.1.0",
|
|
67
|
+
"esbuild": "^0.25.12",
|
|
63
68
|
"eslint": "^9.39.5",
|
|
64
69
|
"tsx": "^4.23.15",
|
|
65
70
|
"typescript": "^5.6.0",
|