@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,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The shared `RepositoryPort` conformance suite (PLAN.md §24 Phase 5).
|
|
4
|
+
*
|
|
5
|
+
* Driver parity is the risk this phase carries, and a shared suite is the gate.
|
|
6
|
+
* Every driver runs *these* tests — not its own paraphrase of them — so a
|
|
7
|
+
* behaviour that differs between SQL and Mongo is a failing test rather than a
|
|
8
|
+
* surprise in production.
|
|
9
|
+
*
|
|
10
|
+
* The test API is injected rather than imported, so the published package does
|
|
11
|
+
* not depend on a test framework and any runner can drive the suite.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.describeRepositoryConformance = describeRepositoryConformance;
|
|
15
|
+
const widget = (overrides = {}) => ({
|
|
16
|
+
name: 'Widget',
|
|
17
|
+
price: 10,
|
|
18
|
+
archived: false,
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Register the conformance suite for one driver.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* describeRepositoryConformance({ describe, it, beforeEach, afterEach, expect }, harness);
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
function describeRepositoryConformance(api, harness) {
|
|
29
|
+
const { describe, it, beforeEach, afterEach, expect } = api;
|
|
30
|
+
const unsupported = new Set(harness.unsupported ?? []);
|
|
31
|
+
describe(`RepositoryPort conformance (${harness.name})`, () => {
|
|
32
|
+
let repository;
|
|
33
|
+
let unitOfWork;
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
const context = await harness.setup();
|
|
36
|
+
repository = context.repository;
|
|
37
|
+
unitOfWork = context.unitOfWork;
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await harness.teardown?.();
|
|
41
|
+
});
|
|
42
|
+
const seed = async (records) => {
|
|
43
|
+
for (const record of records)
|
|
44
|
+
await repository.create(record);
|
|
45
|
+
};
|
|
46
|
+
describe('create', () => {
|
|
47
|
+
it('should return the created record with an id', async () => {
|
|
48
|
+
const created = await repository.create(widget({ name: 'First' }));
|
|
49
|
+
expect(created.id).toBeDefined();
|
|
50
|
+
expect(created.name).toBe('First');
|
|
51
|
+
expect(created.price).toBe(10);
|
|
52
|
+
});
|
|
53
|
+
it('should stamp audit timestamps', async () => {
|
|
54
|
+
const created = await repository.create(widget());
|
|
55
|
+
expect(created.created_at).toBeDefined();
|
|
56
|
+
expect(created.updated_at).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
it('should be findable afterwards', async () => {
|
|
59
|
+
const created = await repository.create(widget({ name: 'Findable' }));
|
|
60
|
+
const found = await repository.findById(created.id);
|
|
61
|
+
expect(found?.name).toBe('Findable');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('findById', () => {
|
|
65
|
+
it('should return null rather than throwing for a missing record', async () => {
|
|
66
|
+
expect(await repository.findById(999_999)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('findAll', () => {
|
|
70
|
+
it('should return a page and a total count', async () => {
|
|
71
|
+
await seed([widget({ name: 'A' }), widget({ name: 'B' }), widget({ name: 'C' })]);
|
|
72
|
+
const page = await repository.findAll({ limit: 2 });
|
|
73
|
+
expect(page.records).toHaveLength(2);
|
|
74
|
+
expect(page.pagination.count).toBe(3);
|
|
75
|
+
expect(page.pagination.limit).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
it('should honour offset', async () => {
|
|
78
|
+
await seed([widget({ name: 'A' }), widget({ name: 'B' }), widget({ name: 'C' })]);
|
|
79
|
+
const page = await repository.findAll({ offset: 2, limit: 10, sort: [['name', 'asc']] });
|
|
80
|
+
expect(page.records).toHaveLength(1);
|
|
81
|
+
expect(page.records[0]?.name).toBe('C');
|
|
82
|
+
// The count is of matching rows, not of the page.
|
|
83
|
+
expect(page.pagination.count).toBe(3);
|
|
84
|
+
});
|
|
85
|
+
it('should filter with a bare value', async () => {
|
|
86
|
+
await seed([widget({ name: 'A', archived: true }), widget({ name: 'B' })]);
|
|
87
|
+
const page = await repository.findAll({ where: { archived: true } });
|
|
88
|
+
expect(page.records).toHaveLength(1);
|
|
89
|
+
expect(page.records[0]?.name).toBe('A');
|
|
90
|
+
});
|
|
91
|
+
it('should filter with comparison operators', async () => {
|
|
92
|
+
await seed([widget({ price: 5 }), widget({ price: 15 }), widget({ price: 25 })]);
|
|
93
|
+
const page = await repository.findAll({ where: { price: { gte: 15 } } });
|
|
94
|
+
expect(page.pagination.count).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
it('should filter with in', async () => {
|
|
97
|
+
await seed([widget({ name: 'A' }), widget({ name: 'B' }), widget({ name: 'C' })]);
|
|
98
|
+
const page = await repository.findAll({ where: { name: { in: ['A', 'C'] } } });
|
|
99
|
+
expect(page.pagination.count).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
it('should combine clauses with or', async () => {
|
|
102
|
+
await seed([widget({ name: 'A', price: 1 }), widget({ name: 'B', price: 99 })]);
|
|
103
|
+
const page = await repository.findAll({
|
|
104
|
+
where: { or: [{ name: 'A' }, { price: { gt: 50 } }] },
|
|
105
|
+
});
|
|
106
|
+
expect(page.pagination.count).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
it('should sort ascending and descending', async () => {
|
|
109
|
+
await seed([widget({ price: 30 }), widget({ price: 10 }), widget({ price: 20 })]);
|
|
110
|
+
const ascending = await repository.findAll({ sort: [['price', 'asc']] });
|
|
111
|
+
const descending = await repository.findAll({ sort: [['price', 'desc']] });
|
|
112
|
+
expect(ascending.records[0]?.price).toBe(10);
|
|
113
|
+
expect(descending.records[0]?.price).toBe(30);
|
|
114
|
+
});
|
|
115
|
+
it('should project only selected fields', async () => {
|
|
116
|
+
await seed([widget({ name: 'Projected', price: 42 })]);
|
|
117
|
+
const page = await repository.findAll({ select: ['id', 'name'] });
|
|
118
|
+
expect(page.records[0]?.name).toBe('Projected');
|
|
119
|
+
expect(page.records[0]?.price).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
it('should project a selection that omits the primary key', async () => {
|
|
122
|
+
// The case above cannot catch the defect this one exists for: a driver
|
|
123
|
+
// that widens the underlying query with the primary key — as the SQL
|
|
124
|
+
// driver deliberately does — and then forgets to narrow the row back
|
|
125
|
+
// still passes it, because `id` was selected anyway. Selecting a single
|
|
126
|
+
// non-key column is what made the SQL and memory drivers return
|
|
127
|
+
// different shapes for identical input.
|
|
128
|
+
await seed([widget({ name: 'Narrow', price: 42 })]);
|
|
129
|
+
const page = await repository.findAll({ select: ['name'] });
|
|
130
|
+
expect(Object.keys(page.records[0] ?? {})).toEqual(['name']);
|
|
131
|
+
});
|
|
132
|
+
it('should return an empty page rather than null when nothing matches', async () => {
|
|
133
|
+
const page = await repository.findAll({ where: { name: 'absent' } });
|
|
134
|
+
expect(page.records).toHaveLength(0);
|
|
135
|
+
expect(page.pagination.count).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe('count and exists', () => {
|
|
139
|
+
it('should count matching records', async () => {
|
|
140
|
+
await seed([widget({ archived: true }), widget({ archived: false })]);
|
|
141
|
+
expect(await repository.count()).toBe(2);
|
|
142
|
+
expect(await repository.count({ where: { archived: true } })).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
it('should report existence', async () => {
|
|
145
|
+
await seed([widget({ name: 'Here' })]);
|
|
146
|
+
expect(await repository.exists({ where: { name: 'Here' } })).toBe(true);
|
|
147
|
+
expect(await repository.exists({ where: { name: 'Nowhere' } })).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('update', () => {
|
|
151
|
+
it('should apply the change and return the record', async () => {
|
|
152
|
+
const created = await repository.create(widget({ name: 'Before' }));
|
|
153
|
+
const updated = await repository.update(created.id, { name: 'After' });
|
|
154
|
+
expect(updated.name).toBe('After');
|
|
155
|
+
expect((await repository.findById(created.id))?.name).toBe('After');
|
|
156
|
+
});
|
|
157
|
+
it('should leave unspecified fields untouched', async () => {
|
|
158
|
+
const created = await repository.create(widget({ name: 'Keep', price: 77 }));
|
|
159
|
+
const updated = await repository.update(created.id, { name: 'Changed' });
|
|
160
|
+
expect(updated.price).toBe(77);
|
|
161
|
+
});
|
|
162
|
+
it('should reject an update to a missing record', async () => {
|
|
163
|
+
await expect(repository.update(999_999, { name: 'x' })).rejects.toThrow();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('delete and restore', () => {
|
|
167
|
+
it('should hide a soft-deleted record from reads', async () => {
|
|
168
|
+
const created = await repository.create(widget({ name: 'Gone' }));
|
|
169
|
+
await repository.delete(created.id);
|
|
170
|
+
expect(await repository.findById(created.id)).toBeNull();
|
|
171
|
+
expect(await repository.count()).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
it('should still return it when asked for deleted records', async () => {
|
|
174
|
+
const created = await repository.create(widget());
|
|
175
|
+
await repository.delete(created.id);
|
|
176
|
+
const found = await repository.findById(created.id, { withDeleted: true });
|
|
177
|
+
expect(found).not.toBeNull();
|
|
178
|
+
expect(found?.deleted_at).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
it('should restore a soft-deleted record', async () => {
|
|
181
|
+
const created = await repository.create(widget({ name: 'Back' }));
|
|
182
|
+
await repository.delete(created.id);
|
|
183
|
+
const restored = await repository.restore(created.id);
|
|
184
|
+
expect(restored.name).toBe('Back');
|
|
185
|
+
expect(await repository.findById(created.id)).not.toBeNull();
|
|
186
|
+
});
|
|
187
|
+
it('should remove a hard-deleted record entirely', async () => {
|
|
188
|
+
const created = await repository.create(widget());
|
|
189
|
+
await repository.delete(created.id, { mode: 'hard' });
|
|
190
|
+
expect(await repository.findById(created.id, { withDeleted: true })).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('bulk operations', () => {
|
|
194
|
+
it('should create many records', async () => {
|
|
195
|
+
const result = await repository.bulkCreate([widget({ name: 'A' }), widget({ name: 'B' })]);
|
|
196
|
+
expect(result.affected).toBe(2);
|
|
197
|
+
expect(await repository.count()).toBe(2);
|
|
198
|
+
});
|
|
199
|
+
it('should update every matching record', async () => {
|
|
200
|
+
await seed([widget({ price: 1 }), widget({ price: 2 })]);
|
|
201
|
+
const result = await repository.bulkUpdate({ where: { price: { lt: 10 } } }, { archived: true });
|
|
202
|
+
expect(result.affected).toBe(2);
|
|
203
|
+
expect(await repository.count({ where: { archived: true } })).toBe(2);
|
|
204
|
+
});
|
|
205
|
+
it('should delete every matching record', async () => {
|
|
206
|
+
await seed([widget({ archived: true }), widget({ archived: true }), widget()]);
|
|
207
|
+
const affected = await repository.bulkDelete({ where: { archived: true } });
|
|
208
|
+
expect(affected).toBe(2);
|
|
209
|
+
expect(await repository.count()).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
if (!unsupported.has('transactions')) {
|
|
213
|
+
describe('transactions', () => {
|
|
214
|
+
it('should supply a unit of work', () => {
|
|
215
|
+
// Every case below has to tolerate an absent unit of work to satisfy
|
|
216
|
+
// the harness type, which means a driver that forgot to return one —
|
|
217
|
+
// without declaring `transactions` unsupported — would see four tests
|
|
218
|
+
// pass having asserted nothing. This is the one case that fails
|
|
219
|
+
// instead, so the escape hatch stays something a reviewer sees.
|
|
220
|
+
expect(unitOfWork).toBeDefined();
|
|
221
|
+
});
|
|
222
|
+
it('should commit work that succeeds', async () => {
|
|
223
|
+
if (unitOfWork === undefined)
|
|
224
|
+
return;
|
|
225
|
+
await unitOfWork.run(async () => {
|
|
226
|
+
await repository.create(widget({ name: 'Committed' }));
|
|
227
|
+
return undefined;
|
|
228
|
+
});
|
|
229
|
+
expect(await repository.count()).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
it('should roll back every write when the callback throws', async () => {
|
|
232
|
+
if (unitOfWork === undefined)
|
|
233
|
+
return;
|
|
234
|
+
await expect(unitOfWork.run(async () => {
|
|
235
|
+
await repository.create(widget({ name: 'One' }));
|
|
236
|
+
await repository.create(widget({ name: 'Two' }));
|
|
237
|
+
throw new Error('rollback please');
|
|
238
|
+
})).rejects.toThrow();
|
|
239
|
+
// Neither write survives: the unit is all-or-nothing.
|
|
240
|
+
expect(await repository.count()).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
it('should propagate the original error rather than wrapping it', async () => {
|
|
243
|
+
if (unitOfWork === undefined)
|
|
244
|
+
return;
|
|
245
|
+
class DomainSpecificError extends Error {
|
|
246
|
+
}
|
|
247
|
+
await expect(unitOfWork.run(() => {
|
|
248
|
+
throw new DomainSpecificError('specific');
|
|
249
|
+
})).rejects.toThrow(DomainSpecificError);
|
|
250
|
+
});
|
|
251
|
+
it('should roll an update back to its previous value', async () => {
|
|
252
|
+
if (unitOfWork === undefined)
|
|
253
|
+
return;
|
|
254
|
+
const created = await repository.create(widget({ name: 'Original' }));
|
|
255
|
+
await expect(unitOfWork.run(async () => {
|
|
256
|
+
await repository.update(created.id, { name: 'Modified' });
|
|
257
|
+
throw new Error('nope');
|
|
258
|
+
})).rejects.toThrow();
|
|
259
|
+
expect((await repository.findById(created.id))?.name).toBe('Original');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (!unsupported.has('cursor')) {
|
|
264
|
+
describe('cursor pagination', () => {
|
|
265
|
+
it('should walk the whole set without repeating a record', async () => {
|
|
266
|
+
await seed([widget({ name: 'A' }), widget({ name: 'B' }), widget({ name: 'C' })]);
|
|
267
|
+
const first = await repository.findByCursor({ limit: 2, sort: [['name', 'asc']] });
|
|
268
|
+
expect(first.records).toHaveLength(2);
|
|
269
|
+
expect(first.pagination.hasMore).toBe(true);
|
|
270
|
+
const second = await repository.findByCursor({
|
|
271
|
+
limit: 2,
|
|
272
|
+
sort: [['name', 'asc']],
|
|
273
|
+
...(first.pagination.nextCursor === null
|
|
274
|
+
? {}
|
|
275
|
+
: { cursor: first.pagination.nextCursor }),
|
|
276
|
+
});
|
|
277
|
+
expect(second.records).toHaveLength(1);
|
|
278
|
+
expect(second.pagination.hasMore).toBe(false);
|
|
279
|
+
expect(second.records[0]?.name).toBe('C');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (!unsupported.has('search')) {
|
|
284
|
+
describe('search', () => {
|
|
285
|
+
it('should match records case-insensitively', async () => {
|
|
286
|
+
await seed([widget({ name: 'Hammer' }), widget({ name: 'Screwdriver' })]);
|
|
287
|
+
const page = await repository.findAll({ search: { term: 'hamm', fields: ['name'] } });
|
|
288
|
+
expect(page.pagination.count).toBe(1);
|
|
289
|
+
expect(page.records[0]?.name).toBe('Hammer');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=repository-conformance.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit and soft-delete stamping (PLAN.md §14.2).
|
|
3
|
+
*
|
|
4
|
+
* Who created or changed a row comes from the request context, not from a
|
|
5
|
+
* parameter every service has to remember to pass. That is what makes the audit
|
|
6
|
+
* trail trustworthy: a caller cannot forget it, and cannot claim to be someone
|
|
7
|
+
* else without changing the authenticated principal.
|
|
8
|
+
*/
|
|
9
|
+
import type { Id, UnknownRecord } from '@nage-api/contracts';
|
|
10
|
+
export interface AuditOptions {
|
|
11
|
+
/** Override the actor; defaults to the authenticated user in the context. */
|
|
12
|
+
readonly actorId?: Id | null;
|
|
13
|
+
/** Injectable clock, so stamped timestamps are testable. */
|
|
14
|
+
readonly now?: () => Date;
|
|
15
|
+
}
|
|
16
|
+
/** Stamp `created_at`/`updated_at`/`created_by`/`updated_by` on a new row. */
|
|
17
|
+
export declare function stampCreate<TData extends UnknownRecord>(data: TData, options?: AuditOptions): TData & UnknownRecord;
|
|
18
|
+
/** Stamp `updated_at`/`updated_by`, and refuse to let a caller forge them. */
|
|
19
|
+
export declare function stampUpdate<TData extends UnknownRecord>(data: TData, options?: AuditOptions): TData & UnknownRecord;
|
|
20
|
+
/** Fields written when a row is soft-deleted. */
|
|
21
|
+
export declare function stampSoftDelete(options?: AuditOptions): UnknownRecord;
|
|
22
|
+
/** Fields written when a soft-deleted row is restored. */
|
|
23
|
+
export declare function stampRestore(options?: AuditOptions): UnknownRecord;
|
|
24
|
+
/** Is this row soft-deleted? */
|
|
25
|
+
export declare function isSoftDeleted(record: UnknownRecord): boolean;
|
|
26
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Audit and soft-delete stamping (PLAN.md §14.2).
|
|
4
|
+
*
|
|
5
|
+
* Who created or changed a row comes from the request context, not from a
|
|
6
|
+
* parameter every service has to remember to pass. That is what makes the audit
|
|
7
|
+
* trail trustworthy: a caller cannot forget it, and cannot claim to be someone
|
|
8
|
+
* else without changing the authenticated principal.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.stampCreate = stampCreate;
|
|
12
|
+
exports.stampUpdate = stampUpdate;
|
|
13
|
+
exports.stampSoftDelete = stampSoftDelete;
|
|
14
|
+
exports.stampRestore = stampRestore;
|
|
15
|
+
exports.isSoftDeleted = isSoftDeleted;
|
|
16
|
+
const core_1 = require("@nage-api/core");
|
|
17
|
+
function timestamp(options) {
|
|
18
|
+
return (options.now?.() ?? new Date()).toISOString();
|
|
19
|
+
}
|
|
20
|
+
function actorOf(options) {
|
|
21
|
+
if (options.actorId !== undefined)
|
|
22
|
+
return options.actorId;
|
|
23
|
+
return (0, core_1.getActiveContext)()?.user?.id ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** Stamp `created_at`/`updated_at`/`created_by`/`updated_by` on a new row. */
|
|
26
|
+
function stampCreate(data, options = {}) {
|
|
27
|
+
const at = timestamp(options);
|
|
28
|
+
const by = actorOf(options);
|
|
29
|
+
return { ...data, created_at: at, updated_at: at, created_by: by, updated_by: by };
|
|
30
|
+
}
|
|
31
|
+
/** Stamp `updated_at`/`updated_by`, and refuse to let a caller forge them. */
|
|
32
|
+
function stampUpdate(data, options = {}) {
|
|
33
|
+
const stamped = { ...data, updated_at: timestamp(options), updated_by: actorOf(options) };
|
|
34
|
+
// Creation facts are immutable: an update that carries them is either a
|
|
35
|
+
// mistake or an attempt to rewrite history.
|
|
36
|
+
delete stamped['created_at'];
|
|
37
|
+
delete stamped['created_by'];
|
|
38
|
+
delete stamped['id'];
|
|
39
|
+
return stamped;
|
|
40
|
+
}
|
|
41
|
+
/** Fields written when a row is soft-deleted. */
|
|
42
|
+
function stampSoftDelete(options = {}) {
|
|
43
|
+
const at = timestamp(options);
|
|
44
|
+
return { deleted_at: at, deleted_by: actorOf(options), updated_at: at };
|
|
45
|
+
}
|
|
46
|
+
/** Fields written when a soft-deleted row is restored. */
|
|
47
|
+
function stampRestore(options = {}) {
|
|
48
|
+
return {
|
|
49
|
+
deleted_at: null,
|
|
50
|
+
deleted_by: null,
|
|
51
|
+
updated_at: timestamp(options),
|
|
52
|
+
updated_by: actorOf(options),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Is this row soft-deleted? */
|
|
56
|
+
function isSoftDeleted(record) {
|
|
57
|
+
const deletedAt = record['deleted_at'];
|
|
58
|
+
return deletedAt !== undefined && deletedAt !== null;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection health (PLAN.md §14.2, §21).
|
|
3
|
+
*
|
|
4
|
+
* Feeds `/health/ready`: an instance whose database is unreachable should be
|
|
5
|
+
* taken out of rotation rather than serving 500s. `@nage-api/observability` (Phase 8)
|
|
6
|
+
* wires this into terminus.
|
|
7
|
+
*/
|
|
8
|
+
import type { DataSourceHealth } from '@nage-api/contracts';
|
|
9
|
+
export type HealthStatus = 'up' | 'down';
|
|
10
|
+
export interface HealthReport {
|
|
11
|
+
readonly status: HealthStatus;
|
|
12
|
+
readonly driver: string;
|
|
13
|
+
readonly durationMs: number;
|
|
14
|
+
/** Present only when the probe failed; safe to surface to an operator. */
|
|
15
|
+
readonly error?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface DataHealthOptions {
|
|
18
|
+
/** A probe that hangs is a probe that fails. */
|
|
19
|
+
readonly timeoutMs?: number;
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Probe a data source without letting it hang the readiness endpoint.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkDataSourceHealth(source: DataSourceHealth, options?: DataHealthOptions): Promise<HealthReport>;
|
|
26
|
+
//# sourceMappingURL=data.health.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Connection health (PLAN.md §14.2, §21).
|
|
4
|
+
*
|
|
5
|
+
* Feeds `/health/ready`: an instance whose database is unreachable should be
|
|
6
|
+
* taken out of rotation rather than serving 500s. `@nage-api/observability` (Phase 8)
|
|
7
|
+
* wires this into terminus.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.checkDataSourceHealth = checkDataSourceHealth;
|
|
11
|
+
/**
|
|
12
|
+
* Probe a data source without letting it hang the readiness endpoint.
|
|
13
|
+
*/
|
|
14
|
+
async function checkDataSourceHealth(source, options = {}) {
|
|
15
|
+
const now = options.now ?? Date.now;
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? 2000;
|
|
17
|
+
const startedAt = now();
|
|
18
|
+
let timer;
|
|
19
|
+
try {
|
|
20
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
21
|
+
timer = setTimeout(() => {
|
|
22
|
+
reject(new Error(`Health check timed out after ${timeoutMs}ms`));
|
|
23
|
+
}, timeoutMs);
|
|
24
|
+
});
|
|
25
|
+
const healthy = await Promise.race([source.ping(), timeout]);
|
|
26
|
+
return {
|
|
27
|
+
status: healthy ? 'up' : 'down',
|
|
28
|
+
driver: source.driver,
|
|
29
|
+
durationMs: now() - startedAt,
|
|
30
|
+
...(healthy ? {} : { error: 'The data source reported that it is not healthy' }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
status: 'down',
|
|
36
|
+
driver: source.driver,
|
|
37
|
+
durationMs: now() - startedAt,
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (timer !== undefined)
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=data.health.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nage-api/data` — the engine-agnostic data layer (PLAN.md §14).
|
|
3
|
+
*
|
|
4
|
+
* One contract, pluggable drivers, one engine per project. Application code
|
|
5
|
+
* depends on `RepositoryPort` and `ModelService`; `@nage-api/data-sql` and
|
|
6
|
+
* `@nage-api/data-mongo` implement the ports and are validated by the conformance
|
|
7
|
+
* suite exported here.
|
|
8
|
+
*/
|
|
9
|
+
export type * from '@nage-api/contracts';
|
|
10
|
+
export { defineQueryPolicy, trustedQueryPolicy, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_POPULATE_DEPTH, DEFAULT_OPERATORS, type QueryPolicyInput, } from './query/policy.js';
|
|
11
|
+
export { parseQuery, parseSort, parseWhere, resolvePagination, type RawQuery, } from './query/parse-query.js';
|
|
12
|
+
export { matchesWhere } from './query/where-matcher.js';
|
|
13
|
+
export { isSoftDeleted, stampCreate, stampRestore, stampSoftDelete, stampUpdate, type AuditOptions, } from './entity/audit.js';
|
|
14
|
+
export { MemoryRepository, type MemoryRepositoryOptions } from './memory/memory.repository.js';
|
|
15
|
+
export { MemoryUnitOfWork } from './memory/memory.unit-of-work.js';
|
|
16
|
+
export { ModelService, type ModelServiceOptions } from './service/model.service.js';
|
|
17
|
+
export { MigrationRunner, type AppliedMigration, type Migration, type MigrationResult, type MigrationRunnerOptions, type MigrationStore, } from './migrations/migration.runner.js';
|
|
18
|
+
export { SeedRunner, type Seed, type SeedMode, type SeedResult, type SeedRunnerOptions, } from './migrations/seed.runner.js';
|
|
19
|
+
export { checkDataSourceHealth, type DataHealthOptions, type HealthReport, type HealthStatus, } from './health/data.health.js';
|
|
20
|
+
export { NageDataModule, registerModel, type DataModuleOptions, type ErasedModelRegistration, type ModelRegistration, } from './module/data.module.js';
|
|
21
|
+
export { NAGE_DATA_HEALTH } from './tokens.js';
|
|
22
|
+
export { describeRepositoryConformance, type ConformanceHarness, type ConformanceWidget, type TestApi, } from './conformance/repository-conformance.js';
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@nage-api/data` — the engine-agnostic data layer (PLAN.md §14).
|
|
4
|
+
*
|
|
5
|
+
* One contract, pluggable drivers, one engine per project. Application code
|
|
6
|
+
* depends on `RepositoryPort` and `ModelService`; `@nage-api/data-sql` and
|
|
7
|
+
* `@nage-api/data-mongo` implement the ports and are validated by the conformance
|
|
8
|
+
* suite exported here.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.describeRepositoryConformance = exports.NAGE_DATA_HEALTH = exports.registerModel = exports.NageDataModule = exports.checkDataSourceHealth = exports.SeedRunner = exports.MigrationRunner = exports.ModelService = exports.MemoryUnitOfWork = exports.MemoryRepository = exports.stampUpdate = exports.stampSoftDelete = exports.stampRestore = exports.stampCreate = exports.isSoftDeleted = exports.matchesWhere = exports.resolvePagination = exports.parseWhere = exports.parseSort = exports.parseQuery = exports.DEFAULT_OPERATORS = exports.DEFAULT_MAX_POPULATE_DEPTH = exports.DEFAULT_MAX_LIMIT = exports.DEFAULT_LIMIT = exports.trustedQueryPolicy = exports.defineQueryPolicy = void 0;
|
|
12
|
+
// Query DSL: typed, allow-listed, bounded (§12).
|
|
13
|
+
var policy_js_1 = require("./query/policy.js");
|
|
14
|
+
Object.defineProperty(exports, "defineQueryPolicy", { enumerable: true, get: function () { return policy_js_1.defineQueryPolicy; } });
|
|
15
|
+
Object.defineProperty(exports, "trustedQueryPolicy", { enumerable: true, get: function () { return policy_js_1.trustedQueryPolicy; } });
|
|
16
|
+
Object.defineProperty(exports, "DEFAULT_LIMIT", { enumerable: true, get: function () { return policy_js_1.DEFAULT_LIMIT; } });
|
|
17
|
+
Object.defineProperty(exports, "DEFAULT_MAX_LIMIT", { enumerable: true, get: function () { return policy_js_1.DEFAULT_MAX_LIMIT; } });
|
|
18
|
+
Object.defineProperty(exports, "DEFAULT_MAX_POPULATE_DEPTH", { enumerable: true, get: function () { return policy_js_1.DEFAULT_MAX_POPULATE_DEPTH; } });
|
|
19
|
+
Object.defineProperty(exports, "DEFAULT_OPERATORS", { enumerable: true, get: function () { return policy_js_1.DEFAULT_OPERATORS; } });
|
|
20
|
+
var parse_query_js_1 = require("./query/parse-query.js");
|
|
21
|
+
Object.defineProperty(exports, "parseQuery", { enumerable: true, get: function () { return parse_query_js_1.parseQuery; } });
|
|
22
|
+
Object.defineProperty(exports, "parseSort", { enumerable: true, get: function () { return parse_query_js_1.parseSort; } });
|
|
23
|
+
Object.defineProperty(exports, "parseWhere", { enumerable: true, get: function () { return parse_query_js_1.parseWhere; } });
|
|
24
|
+
Object.defineProperty(exports, "resolvePagination", { enumerable: true, get: function () { return parse_query_js_1.resolvePagination; } });
|
|
25
|
+
var where_matcher_js_1 = require("./query/where-matcher.js");
|
|
26
|
+
Object.defineProperty(exports, "matchesWhere", { enumerable: true, get: function () { return where_matcher_js_1.matchesWhere; } });
|
|
27
|
+
// Audit and soft delete, stamped from the request context (§14.2).
|
|
28
|
+
var audit_js_1 = require("./entity/audit.js");
|
|
29
|
+
Object.defineProperty(exports, "isSoftDeleted", { enumerable: true, get: function () { return audit_js_1.isSoftDeleted; } });
|
|
30
|
+
Object.defineProperty(exports, "stampCreate", { enumerable: true, get: function () { return audit_js_1.stampCreate; } });
|
|
31
|
+
Object.defineProperty(exports, "stampRestore", { enumerable: true, get: function () { return audit_js_1.stampRestore; } });
|
|
32
|
+
Object.defineProperty(exports, "stampSoftDelete", { enumerable: true, get: function () { return audit_js_1.stampSoftDelete; } });
|
|
33
|
+
Object.defineProperty(exports, "stampUpdate", { enumerable: true, get: function () { return audit_js_1.stampUpdate; } });
|
|
34
|
+
// The in-memory driver: a real implementation, used as a test double.
|
|
35
|
+
var memory_repository_js_1 = require("./memory/memory.repository.js");
|
|
36
|
+
Object.defineProperty(exports, "MemoryRepository", { enumerable: true, get: function () { return memory_repository_js_1.MemoryRepository; } });
|
|
37
|
+
var memory_unit_of_work_js_1 = require("./memory/memory.unit-of-work.js");
|
|
38
|
+
Object.defineProperty(exports, "MemoryUnitOfWork", { enumerable: true, get: function () { return memory_unit_of_work_js_1.MemoryUnitOfWork; } });
|
|
39
|
+
// The retained template-method service with lifecycle hooks (§14.1).
|
|
40
|
+
var model_service_js_1 = require("./service/model.service.js");
|
|
41
|
+
Object.defineProperty(exports, "ModelService", { enumerable: true, get: function () { return model_service_js_1.ModelService; } });
|
|
42
|
+
// Migrations and seeds replace `synchronize` auto-sync (§14.2).
|
|
43
|
+
var migration_runner_js_1 = require("./migrations/migration.runner.js");
|
|
44
|
+
Object.defineProperty(exports, "MigrationRunner", { enumerable: true, get: function () { return migration_runner_js_1.MigrationRunner; } });
|
|
45
|
+
var seed_runner_js_1 = require("./migrations/seed.runner.js");
|
|
46
|
+
Object.defineProperty(exports, "SeedRunner", { enumerable: true, get: function () { return seed_runner_js_1.SeedRunner; } });
|
|
47
|
+
// Connection health for `/health/ready` (§21).
|
|
48
|
+
var data_health_js_1 = require("./health/data.health.js");
|
|
49
|
+
Object.defineProperty(exports, "checkDataSourceHealth", { enumerable: true, get: function () { return data_health_js_1.checkDataSourceHealth; } });
|
|
50
|
+
// Wiring.
|
|
51
|
+
var data_module_js_1 = require("./module/data.module.js");
|
|
52
|
+
Object.defineProperty(exports, "NageDataModule", { enumerable: true, get: function () { return data_module_js_1.NageDataModule; } });
|
|
53
|
+
Object.defineProperty(exports, "registerModel", { enumerable: true, get: function () { return data_module_js_1.registerModel; } });
|
|
54
|
+
var tokens_js_1 = require("./tokens.js");
|
|
55
|
+
Object.defineProperty(exports, "NAGE_DATA_HEALTH", { enumerable: true, get: function () { return tokens_js_1.NAGE_DATA_HEALTH; } });
|
|
56
|
+
// The gate every driver has to pass.
|
|
57
|
+
var repository_conformance_js_1 = require("./conformance/repository-conformance.js");
|
|
58
|
+
Object.defineProperty(exports, "describeRepositoryConformance", { enumerable: true, get: function () { return repository_conformance_js_1.describeRepositoryConformance; } });
|
|
59
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory `RepositoryPort` (PLAN.md §19 — mock at the port boundary).
|
|
3
|
+
*
|
|
4
|
+
* It is a real implementation, not a stub: it enforces the query policy, stamps
|
|
5
|
+
* audit fields, honours soft delete and participates in transactions. That is
|
|
6
|
+
* what makes it useful as the reference the shared conformance suite pins down,
|
|
7
|
+
* and as the driver unit tests run against instead of a database.
|
|
8
|
+
*/
|
|
9
|
+
import type { BulkResult, ByIdQuery, CursorPaginated, DeepPartial, DeleteOptions, Id, ModelDescriptor, Paginated, Query, QueryPolicy, RepositoryPort, UnknownRecord, WriteOptions } from '@nage-api/contracts';
|
|
10
|
+
import type { MemoryUnitOfWork } from './memory.unit-of-work.js';
|
|
11
|
+
export interface MemoryRepositoryOptions<TEntity> {
|
|
12
|
+
readonly descriptor?: Partial<ModelDescriptor<TEntity>>;
|
|
13
|
+
readonly policy?: QueryPolicy<TEntity>;
|
|
14
|
+
/** Seed data, inserted without audit stamping. */
|
|
15
|
+
readonly records?: readonly TEntity[];
|
|
16
|
+
/** Injectable clock for deterministic timestamps. */
|
|
17
|
+
readonly now?: () => Date;
|
|
18
|
+
/** Registers the store with a unit of work so transactions can roll it back. */
|
|
19
|
+
readonly unitOfWork?: MemoryUnitOfWork;
|
|
20
|
+
}
|
|
21
|
+
/** Anything the store can hold: an object with an id. */
|
|
22
|
+
type Row = UnknownRecord & {
|
|
23
|
+
id: Id;
|
|
24
|
+
};
|
|
25
|
+
export declare class MemoryRepository<TEntity extends {
|
|
26
|
+
id: Id;
|
|
27
|
+
}> implements RepositoryPort<TEntity> {
|
|
28
|
+
#private;
|
|
29
|
+
readonly descriptor: ModelDescriptor<TEntity>;
|
|
30
|
+
constructor(name: string, options?: MemoryRepositoryOptions<TEntity>);
|
|
31
|
+
/** @internal */
|
|
32
|
+
snapshot(): Row[];
|
|
33
|
+
/** @internal */
|
|
34
|
+
restoreSnapshot(rows: Row[]): void;
|
|
35
|
+
findAll(query?: Query<TEntity>): Promise<Paginated<TEntity>>;
|
|
36
|
+
findByCursor(query?: Query<TEntity>): Promise<CursorPaginated<TEntity>>;
|
|
37
|
+
findOne(query?: Query<TEntity>): Promise<TEntity | null>;
|
|
38
|
+
findById(id: Id, query?: Omit<ByIdQuery<TEntity>, 'id'>): Promise<TEntity | null>;
|
|
39
|
+
exists(query?: Query<TEntity>): Promise<boolean>;
|
|
40
|
+
count(query?: Query<TEntity>): Promise<number>;
|
|
41
|
+
create(data: DeepPartial<TEntity>, _options?: WriteOptions): Promise<TEntity>;
|
|
42
|
+
update(id: Id, data: DeepPartial<TEntity>, _options?: WriteOptions): Promise<TEntity>;
|
|
43
|
+
delete(id: Id, options?: DeleteOptions): Promise<void>;
|
|
44
|
+
restore(id: Id, _options?: WriteOptions): Promise<TEntity>;
|
|
45
|
+
bulkCreate(data: readonly DeepPartial<TEntity>[], options?: WriteOptions): Promise<BulkResult<TEntity>>;
|
|
46
|
+
bulkUpdate(query: Query<TEntity>, data: DeepPartial<TEntity>, options?: WriteOptions): Promise<BulkResult<TEntity>>;
|
|
47
|
+
bulkDelete(query: Query<TEntity>, options?: DeleteOptions): Promise<number>;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=memory.repository.d.ts.map
|