@relations-dil/relations 0.1.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/LICENSE +21 -0
- package/README.md +824 -0
- package/dist/dynamic.d.ts +122 -0
- package/dist/dynamic.js +164 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +50 -0
- package/dist/field.d.ts +157 -0
- package/dist/field.js +660 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +75 -0
- package/dist/kinds.d.ts +99 -0
- package/dist/kinds.js +169 -0
- package/dist/migrations.d.ts +62 -0
- package/dist/migrations.js +293 -0
- package/dist/mock.d.ts +88 -0
- package/dist/mock.js +511 -0
- package/dist/model.d.ts +277 -0
- package/dist/model.js +1159 -0
- package/dist/overscore.d.ts +66 -0
- package/dist/overscore.js +226 -0
- package/dist/record.d.ts +58 -0
- package/dist/record.js +246 -0
- package/dist/registry.d.ts +24 -0
- package/dist/registry.js +27 -0
- package/dist/relation.d.ts +114 -0
- package/dist/relation.js +145 -0
- package/dist/source.d.ts +147 -0
- package/dist/source.js +409 -0
- package/dist/titles.d.ts +43 -0
- package/dist/titles.js +115 -0
- package/dist/typed.d.ts +99 -0
- package/dist/typed.js +44 -0
- package/dist/util.d.ts +16 -0
- package/dist/util.js +117 -0
- package/package.json +68 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations: diffing model definitions over time, and handing the diffs to a source.
|
|
3
|
+
*
|
|
4
|
+
* Definitions are backend-neutral JSON. A source converts them into whatever it needs -
|
|
5
|
+
* DDL for a database, nothing at all for an API - so the same migration history drives
|
|
6
|
+
* every backend a set of models is used with.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readdir, readFile, rename as renameFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { MigrationsError } from './errors.js';
|
|
11
|
+
import { source as registered } from './registry.js';
|
|
12
|
+
import { equal } from './util.js';
|
|
13
|
+
import { MigrationsRef } from './model.js';
|
|
14
|
+
const noRenames = () => ({});
|
|
15
|
+
/** Diffs model definitions and drives a source through the resulting changes. */
|
|
16
|
+
export class Migrations {
|
|
17
|
+
/** Directory the definition and migration files live in. */
|
|
18
|
+
directory;
|
|
19
|
+
/** How renames are decided. Defaults to treating nothing as a rename. */
|
|
20
|
+
renamer;
|
|
21
|
+
constructor(directory = 'ddl', options = {}) {
|
|
22
|
+
this.directory = directory;
|
|
23
|
+
this.renamer = options.renamer ?? noRenames;
|
|
24
|
+
}
|
|
25
|
+
/** The definition as of the last generate, or an empty one. */
|
|
26
|
+
async current() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(join(this.directory, 'definition.json'), 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Definitions for a set of model classes, keyed by model name. */
|
|
38
|
+
static define(models) {
|
|
39
|
+
const definitions = {};
|
|
40
|
+
for (const model of models) {
|
|
41
|
+
const definition = model.thy().define();
|
|
42
|
+
definitions[definition.name] = definition;
|
|
43
|
+
}
|
|
44
|
+
return definitions;
|
|
45
|
+
}
|
|
46
|
+
/** Work out which removals are renames, and take those out of adds and removes. */
|
|
47
|
+
static rename(renamer, name, adds, removes) {
|
|
48
|
+
if (!adds.length || !removes.length) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
const renames = renamer(name, [...adds], [...removes]);
|
|
52
|
+
for (const [remove, add] of Object.entries(renames)) {
|
|
53
|
+
const removeAt = removes.indexOf(remove);
|
|
54
|
+
const addAt = adds.indexOf(add);
|
|
55
|
+
if (removeAt === -1 || addAt === -1) {
|
|
56
|
+
throw new MigrationsError(`${name} rename ${remove} to ${add} is not in the changes`);
|
|
57
|
+
}
|
|
58
|
+
removes.splice(removeAt, 1);
|
|
59
|
+
adds.splice(addAt, 1);
|
|
60
|
+
}
|
|
61
|
+
return renames;
|
|
62
|
+
}
|
|
63
|
+
/** A field definition by name. */
|
|
64
|
+
static lookup(name, fields) {
|
|
65
|
+
for (const field of fields) {
|
|
66
|
+
if (name === field.name) {
|
|
67
|
+
return field;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** What changed between two field definitions. */
|
|
73
|
+
static field(current, define) {
|
|
74
|
+
const migration = {};
|
|
75
|
+
for (const attr of new Set([...Object.keys(current), ...Object.keys(define)])) {
|
|
76
|
+
if (!equal(current[attr], define[attr])) {
|
|
77
|
+
migration[attr] = define[attr] ?? null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return migration;
|
|
81
|
+
}
|
|
82
|
+
/** What changed between two sets of field definitions. */
|
|
83
|
+
static fields(renamer, model, current, define) {
|
|
84
|
+
const migration = {};
|
|
85
|
+
const currentNames = current.map((field) => field.name);
|
|
86
|
+
const defineNames = define.map((field) => field.name);
|
|
87
|
+
const add = defineNames.filter((name) => !currentNames.includes(name));
|
|
88
|
+
const remove = currentNames.filter((name) => !defineNames.includes(name));
|
|
89
|
+
const renames = Migrations.rename(renamer, `${model} fields`, add, remove);
|
|
90
|
+
if (add.length) {
|
|
91
|
+
migration.add = define.filter((field) => add.includes(field.name));
|
|
92
|
+
}
|
|
93
|
+
if (remove.length) {
|
|
94
|
+
migration.remove = remove;
|
|
95
|
+
}
|
|
96
|
+
const change = {};
|
|
97
|
+
for (const currentField of current) {
|
|
98
|
+
const defineField = Migrations.lookup(renames[currentField.name] ?? currentField.name, define);
|
|
99
|
+
if (defineField !== null && !equal(currentField, defineField)) {
|
|
100
|
+
change[currentField.name] = Migrations.field(currentField, defineField);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (Object.keys(change).length) {
|
|
104
|
+
migration.change = change;
|
|
105
|
+
}
|
|
106
|
+
return migration;
|
|
107
|
+
}
|
|
108
|
+
/** What changed between two sets of indexes. */
|
|
109
|
+
static indexes(renamer, model, kind, current, define) {
|
|
110
|
+
const migration = {};
|
|
111
|
+
const add = Object.keys(define)
|
|
112
|
+
.sort()
|
|
113
|
+
.filter((name) => !(name in current));
|
|
114
|
+
const remove = Object.keys(current)
|
|
115
|
+
.sort()
|
|
116
|
+
.filter((name) => !(name in define));
|
|
117
|
+
const renames = Migrations.rename(renamer, `${model} ${kind}`, add, remove);
|
|
118
|
+
if (add.length) {
|
|
119
|
+
migration.add = Object.fromEntries(add.map((name) => [name, define[name]]));
|
|
120
|
+
}
|
|
121
|
+
if (remove.length) {
|
|
122
|
+
migration.remove = remove;
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(renames).length) {
|
|
125
|
+
for (const [currentName, defineName] of Object.entries(renames)) {
|
|
126
|
+
if (!equal(current[currentName], define[defineName])) {
|
|
127
|
+
throw new MigrationsError(`${model} ${kind} ${currentName} and ${defineName} must have same fields to rename`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
migration.rename = renames;
|
|
131
|
+
}
|
|
132
|
+
return migration;
|
|
133
|
+
}
|
|
134
|
+
/** What changed between two model definitions. */
|
|
135
|
+
static model(current, define, renamer = noRenames) {
|
|
136
|
+
const model = current.name !== define.name ? `${current.name}/${define.name}` : current.name;
|
|
137
|
+
const migration = {};
|
|
138
|
+
const attrs = new Set(['name', 'title', 'id', ...Object.keys(current), ...Object.keys(define)]);
|
|
139
|
+
for (const attr of attrs) {
|
|
140
|
+
if (['fields', 'index', 'unique'].includes(attr)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!equal(current[attr], define[attr])) {
|
|
144
|
+
migration[attr] = define[attr] ?? null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!equal(current.fields, define.fields)) {
|
|
148
|
+
migration.fields = Migrations.fields(renamer, model, current.fields, define.fields);
|
|
149
|
+
}
|
|
150
|
+
for (const attr of ['unique', 'index']) {
|
|
151
|
+
const kind = attr === 'unique' ? 'unique indexes' : 'indexes';
|
|
152
|
+
if (!equal(current[attr], define[attr])) {
|
|
153
|
+
migration[attr] = Migrations.indexes(renamer, model, kind, current[attr] ?? {}, define[attr] ?? {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return migration;
|
|
157
|
+
}
|
|
158
|
+
/** What changed across a whole set of models. */
|
|
159
|
+
static models(current, define, renamer = noRenames) {
|
|
160
|
+
const migration = {};
|
|
161
|
+
const add = Object.keys(define)
|
|
162
|
+
.sort()
|
|
163
|
+
.filter((name) => !(name in current));
|
|
164
|
+
const remove = Object.keys(current)
|
|
165
|
+
.sort()
|
|
166
|
+
.filter((name) => !(name in define));
|
|
167
|
+
const renames = Migrations.rename(renamer, 'models', add, remove);
|
|
168
|
+
if (add.length) {
|
|
169
|
+
migration.add = Object.fromEntries(add.map((name) => [name, define[name]]));
|
|
170
|
+
}
|
|
171
|
+
if (remove.length) {
|
|
172
|
+
migration.remove = Object.fromEntries(remove.map((name) => [name, current[name]]));
|
|
173
|
+
}
|
|
174
|
+
const change = {};
|
|
175
|
+
for (const name of Object.keys(current)) {
|
|
176
|
+
const defineModel = define[renames[name] ?? name];
|
|
177
|
+
if (defineModel !== undefined && !equal(current[name], defineModel)) {
|
|
178
|
+
change[name] = {
|
|
179
|
+
definition: current[name],
|
|
180
|
+
migration: Migrations.model(current[name], defineModel, renamer)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (Object.keys(change).length) {
|
|
185
|
+
migration.change = change;
|
|
186
|
+
}
|
|
187
|
+
return migration;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Snapshot the current models, writing a migration alongside the previous snapshot.
|
|
191
|
+
* Resolves to whether anything was written.
|
|
192
|
+
*/
|
|
193
|
+
async generate(models, stamp) {
|
|
194
|
+
await mkdir(this.directory, { recursive: true });
|
|
195
|
+
const current = await this.current();
|
|
196
|
+
const define = Migrations.define(models);
|
|
197
|
+
if (Object.keys(current).length) {
|
|
198
|
+
if (equal(current, define)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const migration = Migrations.models(current, define, this.renamer);
|
|
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));
|
|
205
|
+
}
|
|
206
|
+
await writeFile(join(this.directory, 'definition.json'), pretty(define));
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
/** Where a source's converted files live. */
|
|
210
|
+
sourcePath(name) {
|
|
211
|
+
const source = registered(name);
|
|
212
|
+
if (source === undefined) {
|
|
213
|
+
throw new MigrationsError(`no source registered as '${name}'`);
|
|
214
|
+
}
|
|
215
|
+
return join(this.directory, source.name, String(source.KIND));
|
|
216
|
+
}
|
|
217
|
+
/** Convert every definition and migration into a source's own form. */
|
|
218
|
+
async convert(name) {
|
|
219
|
+
const source = registered(name);
|
|
220
|
+
if (source === undefined) {
|
|
221
|
+
throw new MigrationsError(`no source registered as '${name}'`);
|
|
222
|
+
}
|
|
223
|
+
const path = this.sourcePath(name);
|
|
224
|
+
await mkdir(path, { recursive: true });
|
|
225
|
+
for (const file of (await readdir(this.directory)).sort()) {
|
|
226
|
+
if (!file.endsWith('.json')) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (file.startsWith('definition')) {
|
|
230
|
+
await source.definition(join(this.directory, file), path);
|
|
231
|
+
}
|
|
232
|
+
else if (file.startsWith('migration')) {
|
|
233
|
+
await source.migration(join(this.directory, file), path);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** The migration pairs available for a source. */
|
|
238
|
+
async list(name) {
|
|
239
|
+
const source = registered(name);
|
|
240
|
+
if (source === undefined) {
|
|
241
|
+
throw new MigrationsError(`no source registered as '${name}'`);
|
|
242
|
+
}
|
|
243
|
+
return source.list(this.sourcePath(name));
|
|
244
|
+
}
|
|
245
|
+
/** Load one converted file into a source. */
|
|
246
|
+
async load(name, fileName) {
|
|
247
|
+
const source = registered(name);
|
|
248
|
+
if (source === undefined) {
|
|
249
|
+
throw new MigrationsError(`no source registered as '${name}'`);
|
|
250
|
+
}
|
|
251
|
+
return source.load(join(this.sourcePath(name), fileName));
|
|
252
|
+
}
|
|
253
|
+
/** Bring a source up to date. Resolves to whether anything was applied. */
|
|
254
|
+
async apply(name) {
|
|
255
|
+
const source = registered(name);
|
|
256
|
+
if (source === undefined) {
|
|
257
|
+
throw new MigrationsError(`no source registered as '${name}'`);
|
|
258
|
+
}
|
|
259
|
+
return source.migrate(this.sourcePath(name));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Sorted-key JSON, so definitions diff cleanly in version control. */
|
|
263
|
+
export function pretty(value) {
|
|
264
|
+
return `${JSON.stringify(value, (_key, item) => ordered(item), 4)}\n`;
|
|
265
|
+
}
|
|
266
|
+
function ordered(value) {
|
|
267
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
const sorted = {};
|
|
274
|
+
for (const key of Object.keys(value).sort()) {
|
|
275
|
+
sorted[key] = value[key];
|
|
276
|
+
}
|
|
277
|
+
return sorted;
|
|
278
|
+
}
|
|
279
|
+
function stamped(at = new Date()) {
|
|
280
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
281
|
+
return [
|
|
282
|
+
at.getFullYear(),
|
|
283
|
+
pad(at.getMonth() + 1),
|
|
284
|
+
pad(at.getDate()),
|
|
285
|
+
pad(at.getHours()),
|
|
286
|
+
pad(at.getMinutes()),
|
|
287
|
+
pad(at.getSeconds()),
|
|
288
|
+
pad(at.getMilliseconds() * 1000, 6)
|
|
289
|
+
].join('-');
|
|
290
|
+
}
|
|
291
|
+
// Let a model identity diff itself without importing this module.
|
|
292
|
+
MigrationsRef.model = (current, define) => Migrations.model(current, define);
|
|
293
|
+
//# sourceMappingURL=migrations.js.map
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory source.
|
|
3
|
+
*
|
|
4
|
+
* Everything relations can do, done against plain objects in this process. Use it in
|
|
5
|
+
* tests, in examples, and to feel out a model's shape before you pick a backend.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { MockSource } from '@relations-dil/relations/mock'
|
|
9
|
+
*
|
|
10
|
+
* new MockSource('example')
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import { Source, type Query } from './source.js';
|
|
14
|
+
import { Titles } from './titles.js';
|
|
15
|
+
import { type Model, type ModelIdentity } from './model.js';
|
|
16
|
+
/** A stand-in for a real source's query object. */
|
|
17
|
+
export declare class MockQuery implements Query {
|
|
18
|
+
/** What the query would do. */
|
|
19
|
+
action: string;
|
|
20
|
+
/** The model it was built for. */
|
|
21
|
+
model: Model | null;
|
|
22
|
+
constructor(action: string);
|
|
23
|
+
/** Attach the model, the way a real query builder would. */
|
|
24
|
+
bind(model: Model): this;
|
|
25
|
+
}
|
|
26
|
+
type Stored = globalThis.Record<string, any>;
|
|
27
|
+
/** A complete source that keeps everything in memory. */
|
|
28
|
+
export declare class MockSource extends Source {
|
|
29
|
+
static KIND: string;
|
|
30
|
+
/** Query classes, so tests can assert on what would run. */
|
|
31
|
+
SELECT: typeof MockQuery;
|
|
32
|
+
INSERT: typeof MockQuery;
|
|
33
|
+
UPDATE: typeof MockQuery;
|
|
34
|
+
DELETE: typeof MockQuery;
|
|
35
|
+
/** Last id handed out, keyed by model name. */
|
|
36
|
+
ids: globalThis.Record<string, number>;
|
|
37
|
+
/** Records keyed by id, keyed by model name. */
|
|
38
|
+
data: globalThis.Record<string, Map<number, Stored>>;
|
|
39
|
+
/** Unique index values keyed by id, keyed by index, keyed by model name. */
|
|
40
|
+
unique: globalThis.Record<string, globalThis.Record<string, Map<number, string>>>;
|
|
41
|
+
/** Migrations applied so far, or null when nothing has been loaded. */
|
|
42
|
+
migrations: string[] | null;
|
|
43
|
+
/** Snapshot taken while a write is in flight, so a failure can roll back. */
|
|
44
|
+
transaction: [typeof this.ids, typeof this.data, typeof this.unique] | null;
|
|
45
|
+
constructor(name: string, options?: globalThis.Record<string, any>);
|
|
46
|
+
/** Make room for a model, and give it an auto id if it has an id field. */
|
|
47
|
+
init(model: ModelIdentity): void;
|
|
48
|
+
fieldDefine(field: Stored, definitions: Stored[]): void;
|
|
49
|
+
define(model: Stored): Stored[];
|
|
50
|
+
fieldAdd(migration: Stored, migrations: Stored[]): void;
|
|
51
|
+
fieldRemove(definition: Stored, migrations: Stored[]): void;
|
|
52
|
+
fieldChange(definition: Stored, migration: Stored, migrations: Stored[]): void;
|
|
53
|
+
modelAdd(definition: Stored): Stored[];
|
|
54
|
+
modelRemove(definition: Stored): Stored[];
|
|
55
|
+
modelChange(definition: Stored, migration: Stored): Stored[];
|
|
56
|
+
/** Copy extracted paths out of container fields into their own stored keys. */
|
|
57
|
+
static extract(model: Model, values: Stored): Stored;
|
|
58
|
+
/** Check every unique index, recording the new value or throwing. */
|
|
59
|
+
uniques(model: Model, values: Stored, id: number): void;
|
|
60
|
+
/** Run a write with a snapshot in place, restoring it if the write fails. */
|
|
61
|
+
private rolling;
|
|
62
|
+
createQuery(model: Model): Query;
|
|
63
|
+
create(model: Model): Promise<Model>;
|
|
64
|
+
/** The records matching a fuzzy `like`, including matches through parent titles. */
|
|
65
|
+
modelLike(model: Model): Promise<Stored[]>;
|
|
66
|
+
/** Apply the model's sort, or its default order. */
|
|
67
|
+
static modelSort(model: Model): void;
|
|
68
|
+
/** Apply the model's limit, flagging overflow when it was reached. */
|
|
69
|
+
static modelLimit(model: Model): void;
|
|
70
|
+
countQuery(model: Model): Query;
|
|
71
|
+
count(model: Model): Promise<number>;
|
|
72
|
+
retrieveQuery(model: Model): Query;
|
|
73
|
+
retrieve(model: Model, verify?: boolean): Promise<Model | null>;
|
|
74
|
+
titlesQuery(model: Model): Query;
|
|
75
|
+
titles(model: Model): Promise<Titles>;
|
|
76
|
+
updateQuery(model: Model): Query;
|
|
77
|
+
update(model: Model): Promise<number>;
|
|
78
|
+
deleteQuery(model: Model): Query;
|
|
79
|
+
delete(model: Model): Promise<number>;
|
|
80
|
+
definition(filePath: string, sourcePath: string): Promise<void>;
|
|
81
|
+
migration(filePath: string, sourcePath: string): Promise<void>;
|
|
82
|
+
execute(models: Stored | Stored[]): Promise<void>;
|
|
83
|
+
load(loadPath: string): Promise<void>;
|
|
84
|
+
list(sourcePath: string): Promise<globalThis.Record<string, any>>;
|
|
85
|
+
migrate(sourcePath: string): Promise<boolean>;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=mock.d.ts.map
|