@nage-api/data 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +160 -0
- package/dist/conformance/repository-conformance.d.ts +76 -0
- package/dist/conformance/repository-conformance.js +295 -0
- package/dist/entity/audit.d.ts +26 -0
- package/dist/entity/audit.js +60 -0
- package/dist/health/data.health.d.ts +26 -0
- package/dist/health/data.health.js +46 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +59 -0
- package/dist/memory/memory.repository.d.ts +50 -0
- package/dist/memory/memory.repository.js +231 -0
- package/dist/memory/memory.unit-of-work.d.ts +21 -0
- package/dist/memory/memory.unit-of-work.js +50 -0
- package/dist/migrations/migration.runner.d.ts +70 -0
- package/dist/migrations/migration.runner.js +138 -0
- package/dist/migrations/seed.runner.d.ts +39 -0
- package/dist/migrations/seed.runner.js +100 -0
- package/dist/module/data.module.d.ts +67 -0
- package/dist/module/data.module.js +71 -0
- package/dist/query/parse-query.d.ts +38 -0
- package/dist/query/parse-query.js +285 -0
- package/dist/query/policy.d.ts +52 -0
- package/dist/query/policy.js +80 -0
- package/dist/query/where-matcher.d.ts +12 -0
- package/dist/query/where-matcher.js +139 -0
- package/dist/service/model.service.d.ts +69 -0
- package/dist/service/model.service.js +191 -0
- package/dist/tokens.d.ts +12 -0
- package/dist/tokens.js +14 -0
- package/package.json +59 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory `RepositoryPort` (PLAN.md §19 — mock at the port boundary).
|
|
4
|
+
*
|
|
5
|
+
* It is a real implementation, not a stub: it enforces the query policy, stamps
|
|
6
|
+
* audit fields, honours soft delete and participates in transactions. That is
|
|
7
|
+
* what makes it useful as the reference the shared conformance suite pins down,
|
|
8
|
+
* and as the driver unit tests run against instead of a database.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MemoryRepository = void 0;
|
|
12
|
+
const core_1 = require("@nage-api/core");
|
|
13
|
+
const audit_js_1 = require("../entity/audit.js");
|
|
14
|
+
const policy_js_1 = require("../query/policy.js");
|
|
15
|
+
const parse_query_js_1 = require("../query/parse-query.js");
|
|
16
|
+
const where_matcher_js_1 = require("../query/where-matcher.js");
|
|
17
|
+
class MemoryRepository {
|
|
18
|
+
descriptor;
|
|
19
|
+
#rows;
|
|
20
|
+
#policy;
|
|
21
|
+
#now;
|
|
22
|
+
#nextId = 1;
|
|
23
|
+
constructor(name, options = {}) {
|
|
24
|
+
this.descriptor = {
|
|
25
|
+
name,
|
|
26
|
+
primaryKey: 'id',
|
|
27
|
+
softDelete: true,
|
|
28
|
+
timestamps: true,
|
|
29
|
+
versioned: false,
|
|
30
|
+
...options.descriptor,
|
|
31
|
+
};
|
|
32
|
+
this.#policy = options.policy ?? (0, policy_js_1.defineQueryPolicy)();
|
|
33
|
+
this.#now = options.now ?? (() => new Date());
|
|
34
|
+
this.#rows = (options.records ?? []).map((record) => ({ ...record }));
|
|
35
|
+
for (const row of this.#rows)
|
|
36
|
+
this.#reserveId(row.id);
|
|
37
|
+
options.unitOfWork?.enlist(this);
|
|
38
|
+
}
|
|
39
|
+
// ── Snapshot support, used by MemoryUnitOfWork ────────────────────────────
|
|
40
|
+
/** @internal */
|
|
41
|
+
snapshot() {
|
|
42
|
+
return this.#rows.map((row) => ({ ...row }));
|
|
43
|
+
}
|
|
44
|
+
/** @internal */
|
|
45
|
+
restoreSnapshot(rows) {
|
|
46
|
+
this.#rows = rows;
|
|
47
|
+
}
|
|
48
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
49
|
+
findAll(query = {}) {
|
|
50
|
+
const matched = this.#match(query);
|
|
51
|
+
const { offset, limit } = (0, parse_query_js_1.resolvePagination)(query, this.#policy);
|
|
52
|
+
const page = matched.slice(offset, offset + limit);
|
|
53
|
+
return Promise.resolve({
|
|
54
|
+
records: page.map((row) => this.#project(row, query)),
|
|
55
|
+
pagination: { offset, limit, count: matched.length },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
findByCursor(query = {}) {
|
|
59
|
+
const matched = this.#match(query);
|
|
60
|
+
const { limit } = (0, parse_query_js_1.resolvePagination)(query, this.#policy);
|
|
61
|
+
const start = query.cursor === undefined ? 0 : Number(decodeCursor(query.cursor));
|
|
62
|
+
const page = matched.slice(start, start + limit);
|
|
63
|
+
const nextIndex = start + page.length;
|
|
64
|
+
const hasMore = nextIndex < matched.length;
|
|
65
|
+
return Promise.resolve({
|
|
66
|
+
records: page.map((row) => this.#project(row, query)),
|
|
67
|
+
pagination: {
|
|
68
|
+
nextCursor: hasMore ? encodeCursor(String(nextIndex)) : null,
|
|
69
|
+
limit,
|
|
70
|
+
hasMore,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
findOne(query = {}) {
|
|
75
|
+
const [first] = this.#match(query);
|
|
76
|
+
return Promise.resolve(first === undefined ? null : this.#project(first, query));
|
|
77
|
+
}
|
|
78
|
+
findById(id, query = {}) {
|
|
79
|
+
const row = this.#rows.find((candidate) => sameId(candidate.id, id));
|
|
80
|
+
if (row === undefined)
|
|
81
|
+
return Promise.resolve(null);
|
|
82
|
+
if ((0, audit_js_1.isSoftDeleted)(row) && query.withDeleted !== true)
|
|
83
|
+
return Promise.resolve(null);
|
|
84
|
+
return Promise.resolve(this.#project(row, query));
|
|
85
|
+
}
|
|
86
|
+
exists(query = {}) {
|
|
87
|
+
return Promise.resolve(this.#match(query).length > 0);
|
|
88
|
+
}
|
|
89
|
+
count(query = {}) {
|
|
90
|
+
return Promise.resolve(this.#match(query).length);
|
|
91
|
+
}
|
|
92
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
93
|
+
async create(data, _options = {}) {
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
const row = {
|
|
96
|
+
...(0, audit_js_1.stampCreate)(data, { now: this.#now }),
|
|
97
|
+
id: data['id'] ?? this.#nextId++,
|
|
98
|
+
};
|
|
99
|
+
this.#reserveId(row.id);
|
|
100
|
+
this.#rows.push(row);
|
|
101
|
+
return { ...row };
|
|
102
|
+
}
|
|
103
|
+
async update(id, data, _options = {}) {
|
|
104
|
+
// Awaiting first means a failed lookup *rejects* rather than throwing
|
|
105
|
+
// synchronously: a method typed `Promise<T>` must never do the latter, or
|
|
106
|
+
// `repository.update(...).catch(...)` misses the error.
|
|
107
|
+
const row = await Promise.resolve(this.#requireRow(id));
|
|
108
|
+
Object.assign(row, (0, audit_js_1.stampUpdate)(data, { now: this.#now }));
|
|
109
|
+
return { ...row };
|
|
110
|
+
}
|
|
111
|
+
async delete(id, options = {}) {
|
|
112
|
+
const row = await Promise.resolve(this.#requireRow(id));
|
|
113
|
+
const mode = options.mode ?? (this.descriptor.softDelete ? 'soft' : 'hard');
|
|
114
|
+
if (mode === 'hard' || !this.descriptor.softDelete) {
|
|
115
|
+
this.#rows = this.#rows.filter((candidate) => !sameId(candidate.id, id));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
Object.assign(row, (0, audit_js_1.stampSoftDelete)({ now: this.#now }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async restore(id, _options = {}) {
|
|
122
|
+
const row = await Promise.resolve(this.#rows.find((candidate) => sameId(candidate.id, id)));
|
|
123
|
+
if (row === undefined)
|
|
124
|
+
throw this.#notFound(id);
|
|
125
|
+
Object.assign(row, (0, audit_js_1.stampRestore)({ now: this.#now }));
|
|
126
|
+
return { ...row };
|
|
127
|
+
}
|
|
128
|
+
async bulkCreate(data, options = {}) {
|
|
129
|
+
const records = [];
|
|
130
|
+
for (const entry of data)
|
|
131
|
+
records.push(await this.create(entry, options));
|
|
132
|
+
return { affected: records.length, records };
|
|
133
|
+
}
|
|
134
|
+
async bulkUpdate(query, data, options = {}) {
|
|
135
|
+
const targets = this.#match(query);
|
|
136
|
+
const records = [];
|
|
137
|
+
for (const row of targets)
|
|
138
|
+
records.push(await this.update(row.id, data, options));
|
|
139
|
+
return { affected: records.length, records };
|
|
140
|
+
}
|
|
141
|
+
async bulkDelete(query, options = {}) {
|
|
142
|
+
const targets = this.#match(query);
|
|
143
|
+
for (const row of targets)
|
|
144
|
+
await this.delete(row.id, options);
|
|
145
|
+
return targets.length;
|
|
146
|
+
}
|
|
147
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
148
|
+
#match(query) {
|
|
149
|
+
let rows = this.#rows.filter((row) => {
|
|
150
|
+
if (this.descriptor.softDelete && (0, audit_js_1.isSoftDeleted)(row) && query.withDeleted !== true) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return (0, where_matcher_js_1.matchesWhere)(row, query.where);
|
|
154
|
+
});
|
|
155
|
+
if (query.search !== undefined) {
|
|
156
|
+
const term = query.search.term.toLowerCase();
|
|
157
|
+
const fields = query.search.fields ?? this.#policy.searchable;
|
|
158
|
+
rows = rows.filter((row) => fields.some((field) => searchableText(row[field]).includes(term)));
|
|
159
|
+
}
|
|
160
|
+
if (query.sort !== undefined && query.sort.length > 0) {
|
|
161
|
+
rows = [...rows].sort((left, right) => compareBySort(left, right, query.sort ?? []));
|
|
162
|
+
}
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
#project(row, query) {
|
|
166
|
+
if (query.select === undefined || query.select.length === 0) {
|
|
167
|
+
return { ...row };
|
|
168
|
+
}
|
|
169
|
+
const projected = {};
|
|
170
|
+
for (const field of query.select)
|
|
171
|
+
projected[field] = row[field];
|
|
172
|
+
return projected;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Keep the generated-id counter clear of an id the store already holds.
|
|
176
|
+
*
|
|
177
|
+
* This used to be `rows.length + 1`, computed once in the constructor, which
|
|
178
|
+
* only holds while every id is a contiguous integer from 1. Seed data with a
|
|
179
|
+
* gap — or a `create` that supplied its own id — left the counter behind the
|
|
180
|
+
* ids in use, and the next generated row duplicated one of them. Nothing
|
|
181
|
+
* rejected that: `findById` then answered with whichever row came first and
|
|
182
|
+
* `update` mutated it, so a write landed on the wrong record rather than
|
|
183
|
+
* failing.
|
|
184
|
+
*/
|
|
185
|
+
#reserveId(id) {
|
|
186
|
+
const numeric = typeof id === 'number' ? id : Number(id);
|
|
187
|
+
// A Mongo-style string id contributes nothing to a numeric counter.
|
|
188
|
+
if (Number.isFinite(numeric) && numeric >= this.#nextId)
|
|
189
|
+
this.#nextId = numeric + 1;
|
|
190
|
+
}
|
|
191
|
+
#requireRow(id) {
|
|
192
|
+
const row = this.#rows.find((candidate) => sameId(candidate.id, id));
|
|
193
|
+
if (row === undefined || (0, audit_js_1.isSoftDeleted)(row))
|
|
194
|
+
throw this.#notFound(id);
|
|
195
|
+
return row;
|
|
196
|
+
}
|
|
197
|
+
#notFound(id) {
|
|
198
|
+
return new core_1.NotFoundError({
|
|
199
|
+
detail: `${this.descriptor.name} ${String(id)} was not found`,
|
|
200
|
+
meta: { model: this.descriptor.name, id },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
exports.MemoryRepository = MemoryRepository;
|
|
205
|
+
/** Only primitives take part in search; an object column has no text to match. */
|
|
206
|
+
function searchableText(value) {
|
|
207
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
208
|
+
? String(value).toLowerCase()
|
|
209
|
+
: '';
|
|
210
|
+
}
|
|
211
|
+
function sameId(left, right) {
|
|
212
|
+
return String(left) === String(right);
|
|
213
|
+
}
|
|
214
|
+
function compareBySort(left, right, sort) {
|
|
215
|
+
for (const [field, direction] of sort) {
|
|
216
|
+
const a = left[field];
|
|
217
|
+
const b = right[field];
|
|
218
|
+
if (a === b)
|
|
219
|
+
continue;
|
|
220
|
+
const ascending = (a ?? '') < (b ?? '') ? -1 : 1;
|
|
221
|
+
return direction === 'desc' ? -ascending : ascending;
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
function encodeCursor(value) {
|
|
226
|
+
return Buffer.from(value, 'utf8').toString('base64url');
|
|
227
|
+
}
|
|
228
|
+
function decodeCursor(cursor) {
|
|
229
|
+
return Buffer.from(cursor, 'base64url').toString('utf8');
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=memory.repository.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactions for the in-memory driver (PLAN.md §14.2).
|
|
3
|
+
*
|
|
4
|
+
* Snapshot on entry, discard on success, restore on failure. It is not a real
|
|
5
|
+
* transaction manager, but it gives the same observable contract the conformance
|
|
6
|
+
* suite asserts of every driver: work inside `run()` is all-or-nothing, and a
|
|
7
|
+
* failing lifecycle hook rolls the write back.
|
|
8
|
+
*/
|
|
9
|
+
import type { Id, TxContext, UnitOfWork } from '@nage-api/contracts';
|
|
10
|
+
import type { MemoryRepository } from './memory.repository.js';
|
|
11
|
+
export declare class MemoryUnitOfWork implements UnitOfWork {
|
|
12
|
+
#private;
|
|
13
|
+
/** Register a repository whose state participates in transactions. */
|
|
14
|
+
enlist<TEntity extends {
|
|
15
|
+
id: Id;
|
|
16
|
+
}>(repository: MemoryRepository<TEntity>): void;
|
|
17
|
+
run<TResult>(fn: (tx: TxContext) => Promise<TResult>): Promise<TResult>;
|
|
18
|
+
/** True while a transaction is open — used by tests and diagnostics. */
|
|
19
|
+
get isActive(): boolean;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=memory.unit-of-work.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Transactions for the in-memory driver (PLAN.md §14.2).
|
|
4
|
+
*
|
|
5
|
+
* Snapshot on entry, discard on success, restore on failure. It is not a real
|
|
6
|
+
* transaction manager, but it gives the same observable contract the conformance
|
|
7
|
+
* suite asserts of every driver: work inside `run()` is all-or-nothing, and a
|
|
8
|
+
* failing lifecycle hook rolls the write back.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MemoryUnitOfWork = void 0;
|
|
12
|
+
const core_1 = require("@nage-api/core");
|
|
13
|
+
class MemoryUnitOfWork {
|
|
14
|
+
#repositories = new Set();
|
|
15
|
+
#depth = 0;
|
|
16
|
+
/** Register a repository whose state participates in transactions. */
|
|
17
|
+
enlist(repository) {
|
|
18
|
+
this.#repositories.add(repository);
|
|
19
|
+
}
|
|
20
|
+
async run(fn) {
|
|
21
|
+
const snapshots = new Map();
|
|
22
|
+
for (const repository of this.#repositories)
|
|
23
|
+
snapshots.set(repository, repository.snapshot());
|
|
24
|
+
const context = { id: (0, core_1.randomId)(), driver: 'memory' };
|
|
25
|
+
this.#depth += 1;
|
|
26
|
+
try {
|
|
27
|
+
return await fn(context);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// Restore every enlisted store, not just the one that failed: a
|
|
31
|
+
// transaction spanning two models must roll back both.
|
|
32
|
+
for (const [repository, rows] of snapshots)
|
|
33
|
+
repository.restoreSnapshot(rows);
|
|
34
|
+
// The caller's error propagates unchanged. Wrapping it would hide a
|
|
35
|
+
// NotFoundError or a validation failure behind an infrastructure error,
|
|
36
|
+
// and every driver behaves the same way here so the conformance suite can
|
|
37
|
+
// assert it.
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
this.#depth -= 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** True while a transaction is open — used by tests and diagnostics. */
|
|
45
|
+
get isActive() {
|
|
46
|
+
return this.#depth > 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.MemoryUnitOfWork = MemoryUnitOfWork;
|
|
50
|
+
//# sourceMappingURL=memory.unit-of-work.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration runner (PLAN.md §14.2, §21).
|
|
3
|
+
*
|
|
4
|
+
* Replaces `synchronize`/`alter` auto-sync, which rewrote tables at boot and put
|
|
5
|
+
* data loss one model edit away. Migrations are versioned, checked in, applied
|
|
6
|
+
* in order, and recorded — so what ran against a database is a fact rather than
|
|
7
|
+
* an inference.
|
|
8
|
+
*
|
|
9
|
+
* The runner is driver-agnostic: a driver supplies a `MigrationStore` (where the
|
|
10
|
+
* ledger lives) and each migration receives the driver's own context.
|
|
11
|
+
*/
|
|
12
|
+
import type { LoggerPort } from '@nage-api/contracts';
|
|
13
|
+
/** One checked-in migration. */
|
|
14
|
+
export interface Migration<TContext = unknown> {
|
|
15
|
+
/** Sort key; conventionally a timestamp such as `20260814120000`. */
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
up(context: TContext): Promise<void>;
|
|
19
|
+
/** Omit for a migration that cannot be undone; `down` then refuses to run. */
|
|
20
|
+
down?(context: TContext): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** A migration that has been applied. */
|
|
23
|
+
export interface AppliedMigration {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly appliedAt: string;
|
|
27
|
+
/** Fingerprint of the migration when it ran, used to detect edits. */
|
|
28
|
+
readonly checksum?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Where the ledger of applied migrations lives — a table, a collection. */
|
|
31
|
+
export interface MigrationStore {
|
|
32
|
+
/** Create the ledger if it does not exist. */
|
|
33
|
+
initialize(): Promise<void>;
|
|
34
|
+
applied(): Promise<readonly AppliedMigration[]>;
|
|
35
|
+
record(migration: AppliedMigration): Promise<void>;
|
|
36
|
+
remove(id: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Prevent two instances migrating at once. Drivers that cannot lock return
|
|
39
|
+
* `false`, and the runner says so rather than pretending.
|
|
40
|
+
*/
|
|
41
|
+
lock?(): Promise<boolean>;
|
|
42
|
+
unlock?(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface MigrationRunnerOptions<TContext> {
|
|
45
|
+
readonly store: MigrationStore;
|
|
46
|
+
readonly migrations: readonly Migration<TContext>[];
|
|
47
|
+
/** Passed to each migration — a transaction, a connection, a client. */
|
|
48
|
+
readonly context: TContext;
|
|
49
|
+
readonly logger?: LoggerPort;
|
|
50
|
+
/** Compute a fingerprint so an edited, already-applied migration is caught. */
|
|
51
|
+
readonly checksum?: (migration: Migration<TContext>) => string;
|
|
52
|
+
}
|
|
53
|
+
export interface MigrationResult {
|
|
54
|
+
readonly applied: readonly string[];
|
|
55
|
+
readonly skipped: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
export declare class MigrationRunner<TContext = unknown> {
|
|
58
|
+
#private;
|
|
59
|
+
private readonly options;
|
|
60
|
+
constructor(options: MigrationRunnerOptions<TContext>);
|
|
61
|
+
/** Migrations in id order; ids must be unique. */
|
|
62
|
+
get ordered(): readonly Migration<TContext>[];
|
|
63
|
+
/** Apply every migration that has not run yet. */
|
|
64
|
+
up(): Promise<MigrationResult>;
|
|
65
|
+
/** Undo the most recent `steps` migrations, newest first. */
|
|
66
|
+
down(steps?: number): Promise<MigrationResult>;
|
|
67
|
+
/** Which migrations have not been applied — what `nage db status` reports. */
|
|
68
|
+
pending(): Promise<readonly Migration<TContext>[]>;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=migration.runner.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Migration runner (PLAN.md §14.2, §21).
|
|
4
|
+
*
|
|
5
|
+
* Replaces `synchronize`/`alter` auto-sync, which rewrote tables at boot and put
|
|
6
|
+
* data loss one model edit away. Migrations are versioned, checked in, applied
|
|
7
|
+
* in order, and recorded — so what ran against a database is a fact rather than
|
|
8
|
+
* an inference.
|
|
9
|
+
*
|
|
10
|
+
* The runner is driver-agnostic: a driver supplies a `MigrationStore` (where the
|
|
11
|
+
* ledger lives) and each migration receives the driver's own context.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.MigrationRunner = void 0;
|
|
15
|
+
const core_1 = require("@nage-api/core");
|
|
16
|
+
class MigrationRunner {
|
|
17
|
+
options;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
21
|
+
/** Migrations in id order; ids must be unique. */
|
|
22
|
+
get ordered() {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const migration of this.options.migrations) {
|
|
25
|
+
if (seen.has(migration.id)) {
|
|
26
|
+
throw new core_1.DatabaseError({
|
|
27
|
+
detail: `Duplicate migration id "${migration.id}"`,
|
|
28
|
+
meta: { id: migration.id },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
seen.add(migration.id);
|
|
32
|
+
}
|
|
33
|
+
return [...this.options.migrations].sort((left, right) => left.id.localeCompare(right.id));
|
|
34
|
+
}
|
|
35
|
+
/** Apply every migration that has not run yet. */
|
|
36
|
+
async up() {
|
|
37
|
+
await this.options.store.initialize();
|
|
38
|
+
const locked = (await this.options.store.lock?.()) ?? true;
|
|
39
|
+
if (!locked) {
|
|
40
|
+
throw new core_1.DatabaseError({
|
|
41
|
+
detail: 'Another process is already applying migrations',
|
|
42
|
+
meta: { hint: 'Run migrations as a single deploy step, not on every instance boot.' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const applied = await this.options.store.applied();
|
|
47
|
+
const appliedById = new Map(applied.map((entry) => [entry.id, entry]));
|
|
48
|
+
this.#assertUnchanged(appliedById);
|
|
49
|
+
const pending = this.ordered.filter((migration) => !appliedById.has(migration.id));
|
|
50
|
+
const ran = [];
|
|
51
|
+
for (const migration of pending) {
|
|
52
|
+
this.options.logger?.info('Applying migration', { id: migration.id, name: migration.name });
|
|
53
|
+
try {
|
|
54
|
+
await migration.up(this.options.context);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Stop at the first failure: continuing would apply migrations on top
|
|
58
|
+
// of a schema that is not in the state they expect.
|
|
59
|
+
throw new core_1.DatabaseError({
|
|
60
|
+
detail: `Migration ${migration.id} (${migration.name}) failed`,
|
|
61
|
+
cause: error,
|
|
62
|
+
meta: { id: migration.id, applied: ran },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
await this.options.store.record({
|
|
66
|
+
id: migration.id,
|
|
67
|
+
name: migration.name,
|
|
68
|
+
appliedAt: new Date().toISOString(),
|
|
69
|
+
...(this.options.checksum === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { checksum: this.options.checksum(migration) }),
|
|
72
|
+
});
|
|
73
|
+
ran.push(migration.id);
|
|
74
|
+
}
|
|
75
|
+
return { applied: ran, skipped: [...appliedById.keys()] };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await this.options.store.unlock?.();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Undo the most recent `steps` migrations, newest first. */
|
|
82
|
+
async down(steps = 1) {
|
|
83
|
+
await this.options.store.initialize();
|
|
84
|
+
const applied = [...(await this.options.store.applied())].sort((left, right) => right.id.localeCompare(left.id));
|
|
85
|
+
const byId = new Map(this.ordered.map((migration) => [migration.id, migration]));
|
|
86
|
+
const reverted = [];
|
|
87
|
+
for (const entry of applied.slice(0, steps)) {
|
|
88
|
+
const migration = byId.get(entry.id);
|
|
89
|
+
if (migration === undefined) {
|
|
90
|
+
throw new core_1.DatabaseError({
|
|
91
|
+
detail: `Applied migration ${entry.id} is no longer in the migrations directory`,
|
|
92
|
+
meta: { id: entry.id },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (migration.down === undefined) {
|
|
96
|
+
throw new core_1.DatabaseError({
|
|
97
|
+
detail: `Migration ${migration.id} (${migration.name}) cannot be reverted`,
|
|
98
|
+
meta: { id: migration.id },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
await migration.down(this.options.context);
|
|
102
|
+
await this.options.store.remove(migration.id);
|
|
103
|
+
reverted.push(migration.id);
|
|
104
|
+
}
|
|
105
|
+
return { applied: [], skipped: reverted };
|
|
106
|
+
}
|
|
107
|
+
/** Which migrations have not been applied — what `nage db status` reports. */
|
|
108
|
+
async pending() {
|
|
109
|
+
await this.options.store.initialize();
|
|
110
|
+
const applied = new Set((await this.options.store.applied()).map((entry) => entry.id));
|
|
111
|
+
return this.ordered.filter((migration) => !applied.has(migration.id));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* An already-applied migration whose content changed is a divergence between
|
|
115
|
+
* environments — the schema here is not the schema its checksum describes.
|
|
116
|
+
*/
|
|
117
|
+
#assertUnchanged(applied) {
|
|
118
|
+
if (this.options.checksum === undefined)
|
|
119
|
+
return;
|
|
120
|
+
for (const migration of this.options.migrations) {
|
|
121
|
+
const entry = applied.get(migration.id);
|
|
122
|
+
if (entry?.checksum === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const current = this.options.checksum(migration);
|
|
125
|
+
if (current !== entry.checksum) {
|
|
126
|
+
throw new core_1.DatabaseError({
|
|
127
|
+
detail: `Migration ${migration.id} (${migration.name}) changed after it was applied`,
|
|
128
|
+
meta: {
|
|
129
|
+
id: migration.id,
|
|
130
|
+
hint: 'Add a new migration instead of editing one that has already run.',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.MigrationRunner = MigrationRunner;
|
|
138
|
+
//# sourceMappingURL=migration.runner.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed runner (PLAN.md §14.2).
|
|
3
|
+
*
|
|
4
|
+
* Keeps the two genuinely useful ideas from the legacy seeders — `once` versus
|
|
5
|
+
* `always`, and references between seeds — while separating them from
|
|
6
|
+
* migrations, which change schema rather than data.
|
|
7
|
+
*
|
|
8
|
+
* `once` seeds are recorded in the same ledger migrations use, so a single
|
|
9
|
+
* instance applies them even when four boot together.
|
|
10
|
+
*/
|
|
11
|
+
import type { LoggerPort } from '@nage-api/contracts';
|
|
12
|
+
import type { MigrationStore } from './migration.runner.js';
|
|
13
|
+
export type SeedMode = 'once' | 'always';
|
|
14
|
+
export interface Seed<TContext = unknown> {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** `once` runs a single time ever; `always` runs on every invocation. */
|
|
18
|
+
readonly mode?: SeedMode;
|
|
19
|
+
/** Ids of seeds that must run first — the cross-seed references. */
|
|
20
|
+
readonly dependsOn?: readonly string[];
|
|
21
|
+
run(context: TContext): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface SeedRunnerOptions<TContext> {
|
|
24
|
+
readonly store: MigrationStore;
|
|
25
|
+
readonly seeds: readonly Seed<TContext>[];
|
|
26
|
+
readonly context: TContext;
|
|
27
|
+
readonly logger?: LoggerPort;
|
|
28
|
+
}
|
|
29
|
+
export interface SeedResult {
|
|
30
|
+
readonly ran: readonly string[];
|
|
31
|
+
readonly skipped: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export declare class SeedRunner<TContext = unknown> {
|
|
34
|
+
#private;
|
|
35
|
+
private readonly options;
|
|
36
|
+
constructor(options: SeedRunnerOptions<TContext>);
|
|
37
|
+
run(): Promise<SeedResult>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=seed.runner.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Seed runner (PLAN.md §14.2).
|
|
4
|
+
*
|
|
5
|
+
* Keeps the two genuinely useful ideas from the legacy seeders — `once` versus
|
|
6
|
+
* `always`, and references between seeds — while separating them from
|
|
7
|
+
* migrations, which change schema rather than data.
|
|
8
|
+
*
|
|
9
|
+
* `once` seeds are recorded in the same ledger migrations use, so a single
|
|
10
|
+
* instance applies them even when four boot together.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.SeedRunner = void 0;
|
|
14
|
+
const core_1 = require("@nage-api/core");
|
|
15
|
+
class SeedRunner {
|
|
16
|
+
options;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
async run() {
|
|
21
|
+
await this.options.store.initialize();
|
|
22
|
+
const applied = new Set((await this.options.store.applied()).map((entry) => entry.id));
|
|
23
|
+
const ordered = this.#resolveOrder();
|
|
24
|
+
const ran = [];
|
|
25
|
+
const skipped = [];
|
|
26
|
+
for (const seed of ordered) {
|
|
27
|
+
const mode = seed.mode ?? 'once';
|
|
28
|
+
if (mode === 'once' && applied.has(seedKey(seed.id))) {
|
|
29
|
+
skipped.push(seed.id);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
this.options.logger?.info('Running seed', { id: seed.id, name: seed.name, mode });
|
|
33
|
+
try {
|
|
34
|
+
await seed.run(this.options.context);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new core_1.DatabaseError({
|
|
38
|
+
detail: `Seed ${seed.id} (${seed.name}) failed`,
|
|
39
|
+
cause: error,
|
|
40
|
+
meta: { id: seed.id, ran },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (mode === 'once') {
|
|
44
|
+
await this.options.store.record({
|
|
45
|
+
id: seedKey(seed.id),
|
|
46
|
+
name: `seed:${seed.name}`,
|
|
47
|
+
appliedAt: new Date().toISOString(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
ran.push(seed.id);
|
|
51
|
+
}
|
|
52
|
+
return { ran, skipped };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Topological order over `dependsOn`.
|
|
56
|
+
*
|
|
57
|
+
* A cycle is reported rather than silently resolved: two seeds that each
|
|
58
|
+
* expect the other's data cannot both be satisfied, and picking an order
|
|
59
|
+
* quietly would produce a database that depends on the sort.
|
|
60
|
+
*/
|
|
61
|
+
#resolveOrder() {
|
|
62
|
+
const byId = new Map(this.options.seeds.map((seed) => [seed.id, seed]));
|
|
63
|
+
const ordered = [];
|
|
64
|
+
const state = new Map();
|
|
65
|
+
const visit = (seed, trail) => {
|
|
66
|
+
const status = state.get(seed.id);
|
|
67
|
+
if (status === 'done')
|
|
68
|
+
return;
|
|
69
|
+
if (status === 'visiting') {
|
|
70
|
+
throw new core_1.DatabaseError({
|
|
71
|
+
detail: `Seed dependency cycle: ${[...trail, seed.id].join(' → ')}`,
|
|
72
|
+
meta: { cycle: [...trail, seed.id] },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
state.set(seed.id, 'visiting');
|
|
76
|
+
for (const dependency of seed.dependsOn ?? []) {
|
|
77
|
+
const target = byId.get(dependency);
|
|
78
|
+
if (target === undefined) {
|
|
79
|
+
throw new core_1.DatabaseError({
|
|
80
|
+
detail: `Seed "${seed.id}" depends on "${dependency}", which does not exist`,
|
|
81
|
+
meta: { id: seed.id, dependsOn: dependency },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
visit(target, [...trail, seed.id]);
|
|
85
|
+
}
|
|
86
|
+
state.set(seed.id, 'done');
|
|
87
|
+
ordered.push(seed);
|
|
88
|
+
};
|
|
89
|
+
for (const seed of [...this.options.seeds].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
90
|
+
visit(seed, []);
|
|
91
|
+
}
|
|
92
|
+
return ordered;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.SeedRunner = SeedRunner;
|
|
96
|
+
/** Seeds share the migrations ledger; the prefix keeps the two apart. */
|
|
97
|
+
function seedKey(id) {
|
|
98
|
+
return `seed:${id}`;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=seed.runner.js.map
|