@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 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), `register()`, `source()`,
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';
@@ -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
@@ -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(join(this.directory, 'definition.json'), 'utf8'));
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(join(this.directory, 'definition.json'), join(this.directory, `definition-${at}.json`));
204
- await writeFile(join(this.directory, `migration-${at}.json`), pretty(migration));
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(join(this.directory, 'definition.json'), pretty(define));
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 join(this.directory, source.name, String(source.KIND));
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(join(this.directory, file), path);
231
+ await source.definition(joinPath(this.directory, file), path);
231
232
  }
232
233
  else if (file.startsWith('migration')) {
233
- await source.migration(join(this.directory, file), path);
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(join(this.sourcePath(name), fileName));
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(join(sourcePath, 'definition.json'));
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(join(sourcePath, path));
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(join(sourcePath, `${fileName}.json`), `${JSON.stringify(contents)}\n`);
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.1.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",