@afora/core 0.0.8 → 0.0.9
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 +127 -23
- package/dist/adapters.d.ts +25 -0
- package/dist/adapters.js +56 -0
- package/dist/core/application/exceptions.d.ts +17 -0
- package/dist/core/application/migrate.d.ts +19 -0
- package/dist/core/application/schema.d.ts +30 -0
- package/dist/core/application/types/application.interface.d.ts +37 -0
- package/dist/core/auth/jwt.d.ts +30 -0
- package/dist/core/auth/types/jwt.interface.d.ts +66 -0
- package/dist/core/blueprint/exceptions.d.ts +5 -0
- package/dist/core/blueprint/optional.d.ts +3 -1
- package/dist/core/blueprint/types/blueprint.interface.d.ts +79 -5
- package/dist/core/blueprint/variant.d.ts +32 -0
- package/dist/core/entity/abstract-entity.d.ts +5 -0
- package/dist/core/entity/entity.d.ts +1 -1
- package/dist/core/entity/exceptions.d.ts +9 -0
- package/dist/core/entity/trust.d.ts +24 -0
- package/dist/core/entity/types/entity.interface.d.ts +30 -0
- package/dist/core/output/access.d.ts +16 -0
- package/dist/core/output/exceptions.d.ts +16 -0
- package/dist/core/output/http.d.ts +36 -0
- package/dist/core/output/openapi-sink.d.ts +23 -2
- package/dist/core/output/stage.d.ts +7 -2
- package/dist/core/output/status.d.ts +1 -1
- package/dist/core/output/types/http.interface.d.ts +130 -0
- package/dist/core/output/types/output.interface.d.ts +25 -5
- package/dist/core/parse/issue.d.ts +28 -3
- package/dist/core/parse/parse.d.ts +9 -0
- package/dist/core/property/abstract-property.d.ts +9 -0
- package/dist/core/property/define-property.d.ts +34 -6
- package/dist/core/property/types/property-options.interface.d.ts +10 -0
- package/dist/core/property/types/property-type.interface.d.ts +4 -0
- package/dist/core/record/abstract-record.d.ts +7 -0
- package/dist/core/record/types/record.interface.d.ts +12 -1
- package/dist/core/repository/exceptions.d.ts +13 -0
- package/dist/core/repository/in-memory.d.ts +2 -2
- package/dist/core/repository/migrate.d.ts +20 -0
- package/dist/core/repository/state.d.ts +2 -0
- package/dist/core/repository/types/repository.interface.d.ts +33 -0
- package/dist/core/repository/types/schema.interface.d.ts +126 -0
- package/dist/core/repository/upcast.d.ts +64 -0
- package/dist/core/schema/schema.d.ts +28 -0
- package/dist/core/symbols/kind.d.ts +1 -1
- package/dist/core/use-case/access.d.ts +12 -0
- package/dist/core/use-case/decorators.d.ts +40 -0
- package/dist/core/use-case/route.d.ts +18 -0
- package/dist/core/use-case/steps/types/check.interface.d.ts +214 -0
- package/dist/core/use-case/types/access.interface.d.ts +39 -0
- package/dist/core/use-case/types/route.interface.d.ts +66 -0
- package/dist/core/use-case/use-case.d.ts +52 -2
- package/dist/experimental.d.ts +13 -1
- package/dist/experimental.js +63 -25
- package/dist/index-3aae5jfq.js +2997 -0
- package/dist/index-hg1z2s2v.js +1055 -0
- package/dist/index-ny3as79r.js +4567 -0
- package/dist/index-xegv6a1p.js +4291 -0
- package/dist/index-y280ew0k.js +396 -0
- package/dist/index.d.ts +45 -49
- package/dist/index.js +110 -186
- package/dist/std/properties/password.d.ts +0 -2
- package/dist/std/record-plugins.d.ts +2 -2
- package/dist/std/resource.d.ts +31 -0
- package/dist/std/runtime.d.ts +6 -0
- package/dist/std/use-cases/auth.d.ts +5 -1
- package/dist/std/use-cases/index.d.ts +1 -1
- package/dist/std/use-cases/shared.d.ts +2 -2
- package/dist/std/use-cases/types.d.ts +97 -1
- package/dist/testing/http-provider-contract.d.ts +32 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +495 -4
- package/dist/testing/provider-contract.d.ts +17 -1
- package/package.json +16 -3
- package/dist/index-qxfkyd6k.js +0 -1721
- package/dist/index-t5qwtyj5.js +0 -8194
package/dist/testing/index.js
CHANGED
|
@@ -1,12 +1,361 @@
|
|
|
1
|
-
import"../index-qxfkyd6k.js";
|
|
2
1
|
import {
|
|
2
|
+
exceptionOf2,
|
|
3
|
+
SCHEMA_VERSION_KEY2,
|
|
3
4
|
RepositoryUniqueViolation2,
|
|
4
5
|
RepositoryEntityNotFound2,
|
|
5
|
-
RepositoryConcurrencyConflict2
|
|
6
|
+
RepositoryConcurrencyConflict2
|
|
7
|
+
} from "../index-xegv6a1p.js";
|
|
8
|
+
import {
|
|
9
|
+
repositoryOf2,
|
|
10
|
+
useCaseOf2
|
|
11
|
+
} from "../index-3aae5jfq.js";
|
|
12
|
+
import {
|
|
13
|
+
outputOf2,
|
|
14
|
+
applicationOf2,
|
|
6
15
|
blueprintOf2,
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
commandOf2,
|
|
17
|
+
entityOf2,
|
|
18
|
+
recordOf2
|
|
19
|
+
} from "../index-ny3as79r.js";
|
|
20
|
+
import"../index-hg1z2s2v.js";
|
|
21
|
+
import"../index-y280ew0k.js";
|
|
22
|
+
|
|
23
|
+
// src/testing/http-provider-contract.ts
|
|
24
|
+
var Note = recordOf2(blueprintOf2((ctx) => ({ title: ctx.string, secret: ctx.password })));
|
|
25
|
+
var Rejected = exceptionOf2({
|
|
26
|
+
layer: "use-case" /* UseCase */,
|
|
27
|
+
name: "ContractRejected",
|
|
28
|
+
message: "rejected"
|
|
29
|
+
});
|
|
30
|
+
var Echo = blueprintOf2((ctx) => ({
|
|
31
|
+
id: ctx.optional.string,
|
|
32
|
+
page: ctx.optional.integer,
|
|
33
|
+
flag: ctx.optional.boolean,
|
|
34
|
+
locale: ctx.optional.string,
|
|
35
|
+
session: ctx.optional.string,
|
|
36
|
+
title: ctx.optional.string,
|
|
37
|
+
caller: ctx.optional.string,
|
|
38
|
+
tenant: ctx.optional.string
|
|
39
|
+
}));
|
|
40
|
+
function operations() {
|
|
41
|
+
|
|
42
|
+
class Get extends useCaseOf2({
|
|
43
|
+
name: "ContractGet",
|
|
44
|
+
blueprint: blueprintOf2((ctx) => ({
|
|
45
|
+
id: ctx.string,
|
|
46
|
+
page: ctx.optional.integer,
|
|
47
|
+
flag: ctx.optional.boolean
|
|
48
|
+
})),
|
|
49
|
+
response: Echo,
|
|
50
|
+
route: { method: "get", path: "/items/{id}" }
|
|
51
|
+
}) {
|
|
52
|
+
async execute() {
|
|
53
|
+
return { ...this.input };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class Change extends useCaseOf2({
|
|
58
|
+
name: "ContractChange",
|
|
59
|
+
blueprint: blueprintOf2((ctx) => ({
|
|
60
|
+
id: ctx.string,
|
|
61
|
+
page: ctx.integer,
|
|
62
|
+
locale: ctx.optional.string,
|
|
63
|
+
session: ctx.optional.string,
|
|
64
|
+
title: ctx.string
|
|
65
|
+
})),
|
|
66
|
+
response: Echo,
|
|
67
|
+
context: blueprintOf2((ctx) => ({ tenant: ctx.optional.string })),
|
|
68
|
+
errors: [Rejected],
|
|
69
|
+
route: {
|
|
70
|
+
method: "put",
|
|
71
|
+
path: "/items/{id}",
|
|
72
|
+
status: 201,
|
|
73
|
+
query: ["page"],
|
|
74
|
+
headers: { locale: "accept-language" },
|
|
75
|
+
cookie: { session: "sid" }
|
|
76
|
+
}
|
|
77
|
+
}) {
|
|
78
|
+
async execute() {
|
|
79
|
+
if (this.input.title === "reject")
|
|
80
|
+
throw new Rejected({ source: "x" });
|
|
81
|
+
if (this.input.title === "crash")
|
|
82
|
+
throw new Error("internal detail");
|
|
83
|
+
return { ...this.input, tenant: this.context.tenant };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
9
86
|
|
|
87
|
+
class Remove extends useCaseOf2({
|
|
88
|
+
name: "ContractRemove",
|
|
89
|
+
blueprint: blueprintOf2((ctx) => ({ id: ctx.string })),
|
|
90
|
+
response: blueprintOf2(() => ({})),
|
|
91
|
+
route: { method: "delete", path: "/items/{id}", status: 204 }
|
|
92
|
+
}) {
|
|
93
|
+
async execute() {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class Reveal extends useCaseOf2({
|
|
99
|
+
name: "ContractReveal",
|
|
100
|
+
blueprint: blueprintOf2(() => ({})),
|
|
101
|
+
response: blueprintOf2(() => ({ note: Note })),
|
|
102
|
+
route: { method: "get", path: "/note" }
|
|
103
|
+
}) {
|
|
104
|
+
async execute() {
|
|
105
|
+
return { note: new Note({ title: "t", secret: await hashed() }) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class Mine extends useCaseOf2({
|
|
110
|
+
name: "ContractMine",
|
|
111
|
+
blueprint: blueprintOf2(() => ({})),
|
|
112
|
+
response: Echo,
|
|
113
|
+
context: blueprintOf2((ctx) => ({ caller: ctx.string })),
|
|
114
|
+
route: { method: "get", path: "/mine" },
|
|
115
|
+
access: { scopes: ["items:read"], context: { caller: "subject" } }
|
|
116
|
+
}) {
|
|
117
|
+
async execute() {
|
|
118
|
+
return { caller: this.context.caller };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class Enter extends useCaseOf2({
|
|
123
|
+
name: "ContractEnter",
|
|
124
|
+
blueprint: blueprintOf2((ctx) => ({ title: ctx.string })),
|
|
125
|
+
response: blueprintOf2((ctx) => ({
|
|
126
|
+
title: ctx.string,
|
|
127
|
+
token: ctx.string,
|
|
128
|
+
refreshToken: ctx.string
|
|
129
|
+
})),
|
|
130
|
+
route: {
|
|
131
|
+
method: "post",
|
|
132
|
+
path: "/enter",
|
|
133
|
+
setCookie: {
|
|
134
|
+
token: { name: "sid", secure: false },
|
|
135
|
+
refreshToken: { name: "rid", secure: false, path: "/refresh" }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}) {
|
|
139
|
+
async execute() {
|
|
140
|
+
return { title: this.input.title, token: "t 1", refreshToken: "" };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return commandOf2({
|
|
144
|
+
name: "Contract",
|
|
145
|
+
useCases: {
|
|
146
|
+
get: Get,
|
|
147
|
+
change: Change,
|
|
148
|
+
remove: Remove,
|
|
149
|
+
reveal: Reveal,
|
|
150
|
+
mine: Mine,
|
|
151
|
+
enter: Enter
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async function hashed() {
|
|
156
|
+
return "$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$4VbQ3OqJ7xk8Y0mG0iZ1c2k1m2Qe0sQeJ7xk8Y0mG0i";
|
|
157
|
+
}
|
|
158
|
+
var authenticator = (request) => {
|
|
159
|
+
const token = request.header("authorization");
|
|
160
|
+
if (token === "Bearer reader")
|
|
161
|
+
return { subject: "u1", scopes: ["items:read"] };
|
|
162
|
+
if (token === "Bearer other")
|
|
163
|
+
return { subject: "u2", scopes: [] };
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
function httpProviderContract(name, settings, runner) {
|
|
167
|
+
const { describe, it, expect } = runner;
|
|
168
|
+
const origin = "http://contract.test";
|
|
169
|
+
const withServer = async (options, body, hooks = { authenticator }) => {
|
|
170
|
+
const server = outputOf2({
|
|
171
|
+
name: "Contract",
|
|
172
|
+
commands: [operations()],
|
|
173
|
+
statuses: { ContractRejected: 409 },
|
|
174
|
+
pipe: [settings.sink(options, hooks)]
|
|
175
|
+
}).build();
|
|
176
|
+
try {
|
|
177
|
+
await body(async (method, path, init = {}) => {
|
|
178
|
+
const hasBody = init.body !== undefined;
|
|
179
|
+
const response = await settings.fetch(server, new Request(`${origin}${path}`, {
|
|
180
|
+
method,
|
|
181
|
+
headers: {
|
|
182
|
+
...hasBody ? { "content-type": "application/json" } : {},
|
|
183
|
+
...init.headers
|
|
184
|
+
},
|
|
185
|
+
...hasBody ? {
|
|
186
|
+
body: typeof init.body === "string" ? init.body : JSON.stringify(init.body)
|
|
187
|
+
} : {}
|
|
188
|
+
}));
|
|
189
|
+
const text = await response.text();
|
|
190
|
+
let json;
|
|
191
|
+
try {
|
|
192
|
+
json = text === "" ? undefined : JSON.parse(text);
|
|
193
|
+
} catch {
|
|
194
|
+
json = undefined;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
status: response.status,
|
|
198
|
+
type: (response.headers.get("content-type") ?? "").split(";")[0],
|
|
199
|
+
headers: response.headers,
|
|
200
|
+
text,
|
|
201
|
+
json
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
await settings.close?.(server);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
describe(`${name}: HTTP provider contract`, () => {
|
|
209
|
+
it("serves the route a use case declares, path and query coerced", async () => {
|
|
210
|
+
await withServer({ basePath: "/api" }, async (send) => {
|
|
211
|
+
const found = await send("GET", "/api/items/a%20b?page=2&flag=true");
|
|
212
|
+
expect(found.status).toBe(200);
|
|
213
|
+
expect(found.type).toBe("application/json");
|
|
214
|
+
expect(found.json).toEqual({ id: "a b", page: 2, flag: true });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
it("reads each key where the route places it, over the body", async () => {
|
|
218
|
+
await withServer({}, async (send) => {
|
|
219
|
+
const changed = await send("PUT", "/items/p?page=3", {
|
|
220
|
+
headers: { "accept-language": "pt-BR", cookie: "x=1; sid=s%201" },
|
|
221
|
+
body: { id: "from-body", page: 9, title: "T" }
|
|
222
|
+
});
|
|
223
|
+
expect(changed.status).toBe(201);
|
|
224
|
+
expect(changed.json).toEqual({
|
|
225
|
+
id: "p",
|
|
226
|
+
page: 3,
|
|
227
|
+
locale: "pt-BR",
|
|
228
|
+
session: "s 1",
|
|
229
|
+
title: "T"
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
it("fills the context from headers", async () => {
|
|
234
|
+
await withServer({ context: { headers: { tenant: "x-tenant" } } }, async (send) => {
|
|
235
|
+
const changed = await send("PUT", "/items/p?page=1", {
|
|
236
|
+
headers: { "x-tenant": "acme" },
|
|
237
|
+
body: { title: "T" }
|
|
238
|
+
});
|
|
239
|
+
expect(changed.json.tenant).toBe("acme");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
it("answers invalid input and invalid JSON with a 400 problem", async () => {
|
|
243
|
+
await withServer({}, async (send) => {
|
|
244
|
+
const invalid = await send("PUT", "/items/p?page=1", {
|
|
245
|
+
body: { title: 1 }
|
|
246
|
+
});
|
|
247
|
+
expect(invalid.status).toBe(400);
|
|
248
|
+
expect(invalid.type).toBe("application/problem+json");
|
|
249
|
+
const problem = invalid.json;
|
|
250
|
+
expect(problem.title).toBe("InvalidUseCaseInput");
|
|
251
|
+
expect(problem.issues.map((issue) => issue.path)).toEqual([["title"]]);
|
|
252
|
+
const notJson = await send("PUT", "/items/p?page=1", {
|
|
253
|
+
body: "{nope"
|
|
254
|
+
});
|
|
255
|
+
expect(notJson.status).toBe(400);
|
|
256
|
+
expect(notJson.json.title).toBe("InvalidRequestBody");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it("gives errors the status of the output, and hides internal ones", async () => {
|
|
260
|
+
await withServer({}, async (send) => {
|
|
261
|
+
const rejected = await send("PUT", "/items/p?page=1", {
|
|
262
|
+
body: { title: "reject" }
|
|
263
|
+
});
|
|
264
|
+
expect(rejected.status).toBe(409);
|
|
265
|
+
expect(rejected.type).toBe("application/problem+json");
|
|
266
|
+
const crashed = await send("PUT", "/items/p?page=1", {
|
|
267
|
+
body: { title: "crash" }
|
|
268
|
+
});
|
|
269
|
+
expect(crashed.status).toBe(500);
|
|
270
|
+
expect(crashed.text.includes("internal detail")).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
await withServer({ debug: true }, async (send) => {
|
|
273
|
+
const crashed = await send("PUT", "/items/p?page=1", {
|
|
274
|
+
body: { title: "crash" }
|
|
275
|
+
});
|
|
276
|
+
expect(crashed.text.includes("internal detail")).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
it("answers a 204 without a body, and an unknown route with a 404 problem", async () => {
|
|
280
|
+
await withServer({}, async (send) => {
|
|
281
|
+
const removed = await send("DELETE", "/items/p");
|
|
282
|
+
expect(removed.status).toBe(204);
|
|
283
|
+
expect(removed.text).toBe("");
|
|
284
|
+
const unknown = await send("GET", "/nowhere");
|
|
285
|
+
expect(unknown.status).toBe(404);
|
|
286
|
+
expect(unknown.type).toBe("application/problem+json");
|
|
287
|
+
expect(unknown.json.title).toBe("NotFound");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
it("masks sensitive values", async () => {
|
|
291
|
+
await withServer({}, async (send) => {
|
|
292
|
+
const revealed = await send("GET", "/note");
|
|
293
|
+
expect(revealed.json).toEqual({
|
|
294
|
+
note: { title: "t", secret: "********" }
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
it("enforces the access of a use case", async () => {
|
|
299
|
+
await withServer({}, async (send) => {
|
|
300
|
+
const anonymous = await send("GET", "/mine");
|
|
301
|
+
expect(anonymous.status).toBe(401);
|
|
302
|
+
expect(anonymous.json.title).toBe("Unauthenticated");
|
|
303
|
+
const other = await send("GET", "/mine", {
|
|
304
|
+
headers: { authorization: "Bearer other" }
|
|
305
|
+
});
|
|
306
|
+
expect(other.status).toBe(403);
|
|
307
|
+
const reader = await send("GET", "/mine", {
|
|
308
|
+
headers: { authorization: "Bearer reader" }
|
|
309
|
+
});
|
|
310
|
+
expect(reader.status).toBe(200);
|
|
311
|
+
expect(reader.json).toEqual({ caller: "u1" });
|
|
312
|
+
});
|
|
313
|
+
await withServer({}, async (send) => {
|
|
314
|
+
const closed = await send("GET", "/mine", {
|
|
315
|
+
headers: { authorization: "Bearer reader" }
|
|
316
|
+
});
|
|
317
|
+
expect(closed.status).toBe(401);
|
|
318
|
+
}, {});
|
|
319
|
+
});
|
|
320
|
+
it("sends the cookies of a route, out of the body", async () => {
|
|
321
|
+
await withServer({}, async (send) => {
|
|
322
|
+
const entered = await send("POST", "/enter", { body: { title: "T" } });
|
|
323
|
+
expect(entered.status).toBe(200);
|
|
324
|
+
expect(entered.json).toEqual({ title: "T" });
|
|
325
|
+
expect(entered.headers.getSetCookie()).toEqual([
|
|
326
|
+
"sid=t%201; Path=/; HttpOnly; SameSite=Lax",
|
|
327
|
+
"rid=; Path=/refresh; HttpOnly; SameSite=Lax; Max-Age=0"
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
it("puts the request id on successes and problems", async () => {
|
|
332
|
+
await withServer({ requestId: true }, async (send) => {
|
|
333
|
+
const sent = await send("GET", "/items/a", {
|
|
334
|
+
headers: { "x-request-id": "abc-1" }
|
|
335
|
+
});
|
|
336
|
+
expect(sent.headers.get("x-request-id")).toBe("abc-1");
|
|
337
|
+
const problem = await send("GET", "/mine");
|
|
338
|
+
expect((problem.headers.get("x-request-id") ?? "").length).toBe(36);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
it("serves the document of the same routes", async () => {
|
|
342
|
+
await withServer({
|
|
343
|
+
basePath: "/api",
|
|
344
|
+
docs: { path: "/openapi.json", info: { title: "C", version: "1" } }
|
|
345
|
+
}, async (send) => {
|
|
346
|
+
const docs = await send("GET", "/openapi.json");
|
|
347
|
+
expect(docs.status).toBe(200);
|
|
348
|
+
expect(docs.type).toBe("application/json");
|
|
349
|
+
expect(Object.keys(docs.json.paths).sort()).toEqual([
|
|
350
|
+
"/api/enter",
|
|
351
|
+
"/api/items/{id}",
|
|
352
|
+
"/api/mine",
|
|
353
|
+
"/api/note"
|
|
354
|
+
]);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
10
359
|
// src/testing/memory-publisher.ts
|
|
11
360
|
function memoryPublisher() {
|
|
12
361
|
const published = [];
|
|
@@ -37,6 +386,11 @@ function row(overrides = {}) {
|
|
|
37
386
|
const id = `id-${String(sequence).padStart(4, "0")}`;
|
|
38
387
|
return { id, email: `${id}@example.com`, name: id, ...overrides };
|
|
39
388
|
}
|
|
389
|
+
function evolvedRow(overrides = {}) {
|
|
390
|
+
sequence += 1;
|
|
391
|
+
const id = `0190a1b2-c3d4-7e5f-8a9b-${String(sequence).padStart(12, "0")}`;
|
|
392
|
+
return { ...row(), id, email: `${sequence}@example.com`, ...overrides };
|
|
393
|
+
}
|
|
40
394
|
async function failure(promise) {
|
|
41
395
|
try {
|
|
42
396
|
await promise;
|
|
@@ -936,9 +1290,146 @@ function providerContract(runner, makeProvider, options = {}) {
|
|
|
936
1290
|
expect(await provider.count(context, { now: NOW })).toBe(1);
|
|
937
1291
|
});
|
|
938
1292
|
});
|
|
1293
|
+
describe("schema evolution", () => {
|
|
1294
|
+
const evolving = (key) => (name, body) => it(name, async () => {
|
|
1295
|
+
if (options.schemaEvolution === false)
|
|
1296
|
+
return;
|
|
1297
|
+
if (key && options.schemaVersionKey === false)
|
|
1298
|
+
return;
|
|
1299
|
+
await body();
|
|
1300
|
+
});
|
|
1301
|
+
const always = evolving(false);
|
|
1302
|
+
const keyed = evolving(true);
|
|
1303
|
+
const contextFor = (entity, name) => ({
|
|
1304
|
+
entity,
|
|
1305
|
+
name: `${name}Repository`,
|
|
1306
|
+
unique: ["id", "email"],
|
|
1307
|
+
indexes: {},
|
|
1308
|
+
env: {}
|
|
1309
|
+
});
|
|
1310
|
+
const accountsOf = (options = {}) => class Accounts extends entityOf2(blueprintOf2((ctx) => ({
|
|
1311
|
+
email: ctx.string,
|
|
1312
|
+
name: ctx.string,
|
|
1313
|
+
age: ctx.integer.with({ fill: 18 }),
|
|
1314
|
+
visits: ctx.integer.with({ renamedFrom: "n" }),
|
|
1315
|
+
active: ctx.optionalOf(ctx.boolean)
|
|
1316
|
+
})), {
|
|
1317
|
+
name: "Accounts",
|
|
1318
|
+
unique: ["email"],
|
|
1319
|
+
...options.schemaVersion === undefined ? {} : {
|
|
1320
|
+
schemaVersion: options.schemaVersion,
|
|
1321
|
+
upgrade: {
|
|
1322
|
+
2: (data) => ({ ...data, active: true })
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
...options.previousNames === undefined ? {} : { previousNames: options.previousNames }
|
|
1326
|
+
}) {
|
|
1327
|
+
};
|
|
1328
|
+
async function setup(entity) {
|
|
1329
|
+
const provider = await makeProvider();
|
|
1330
|
+
const Accounts = repositoryOf2({
|
|
1331
|
+
entity,
|
|
1332
|
+
actions: ["findMany"],
|
|
1333
|
+
provider
|
|
1334
|
+
});
|
|
1335
|
+
const app = applicationOf2({
|
|
1336
|
+
name: "contract",
|
|
1337
|
+
entities: { Accounts: entity },
|
|
1338
|
+
repositories: { Accounts }
|
|
1339
|
+
});
|
|
1340
|
+
const context = contextFor(entity, "Accounts");
|
|
1341
|
+
const old = [evolvedRow({ n: 3 }), evolvedRow({ n: 5 })];
|
|
1342
|
+
for (const data of old)
|
|
1343
|
+
await provider.insert(context, data);
|
|
1344
|
+
const read = async () => (await Accounts.findMany()).map((account) => account.toJSON());
|
|
1345
|
+
return { provider, app, context, old, read };
|
|
1346
|
+
}
|
|
1347
|
+
always("reads a row of an older shape back in the current one", async () => {
|
|
1348
|
+
const { old, read } = await setup(accountsOf());
|
|
1349
|
+
const [first] = await read();
|
|
1350
|
+
expect(first?.id).toBe(old[0]?.id);
|
|
1351
|
+
expect([first?.age, first?.visits]).toEqual([18, 3]);
|
|
1352
|
+
expect(Object.hasOwn(first ?? {}, "n")).toBe(false);
|
|
1353
|
+
});
|
|
1354
|
+
keyed("reads a row of an older version through upgrade", async () => {
|
|
1355
|
+
const { read } = await setup(accountsOf({ schemaVersion: 2 }));
|
|
1356
|
+
const [first] = await read();
|
|
1357
|
+
expect([first?.age, first?.visits, first?.active]).toEqual([
|
|
1358
|
+
18,
|
|
1359
|
+
3,
|
|
1360
|
+
true
|
|
1361
|
+
]);
|
|
1362
|
+
});
|
|
1363
|
+
always("the backfill reports in check and rewrites in apply", async () => {
|
|
1364
|
+
const { provider, app, context, old } = await setup(accountsOf());
|
|
1365
|
+
const check = await app.migrate({ mode: "check", pageSize: 1 });
|
|
1366
|
+
expect([
|
|
1367
|
+
check.entities.Accounts?.scanned,
|
|
1368
|
+
check.entities.Accounts?.upgraded
|
|
1369
|
+
]).toEqual([2, 2]);
|
|
1370
|
+
const untouched = await provider.findOne(context, {
|
|
1371
|
+
where: { id: old[0]?.id }
|
|
1372
|
+
});
|
|
1373
|
+
expect(untouched?.n).toBe(3);
|
|
1374
|
+
await app.migrate({ mode: "apply", pageSize: 1 });
|
|
1375
|
+
const stored = await provider.findOne(context, {
|
|
1376
|
+
where: { id: old[0]?.id }
|
|
1377
|
+
});
|
|
1378
|
+
expect([stored?.age, stored?.visits]).toEqual([18, 3]);
|
|
1379
|
+
expect(stored?.n ?? undefined).toBe(undefined);
|
|
1380
|
+
const again = await app.migrate({ mode: "check" });
|
|
1381
|
+
expect(again.entities.Accounts?.upToDate).toBe(2);
|
|
1382
|
+
});
|
|
1383
|
+
keyed("the backfill stores the schema version", async () => {
|
|
1384
|
+
const { provider, app, context, old } = await setup(accountsOf({ schemaVersion: 2 }));
|
|
1385
|
+
await app.migrate({ mode: "apply" });
|
|
1386
|
+
const stored = await provider.findOne(context, {
|
|
1387
|
+
where: { id: old[1]?.id }
|
|
1388
|
+
});
|
|
1389
|
+
expect([stored?.[SCHEMA_VERSION_KEY2], stored?.active]).toEqual([
|
|
1390
|
+
2,
|
|
1391
|
+
true
|
|
1392
|
+
]);
|
|
1393
|
+
const again = await app.migrate({ mode: "check" });
|
|
1394
|
+
expect(again.entities.Accounts?.upToDate).toBe(2);
|
|
1395
|
+
});
|
|
1396
|
+
always("the backfill moves the rows of a previous name", async () => {
|
|
1397
|
+
const entity = accountsOf({ previousNames: ["One"] });
|
|
1398
|
+
const provider = await makeProvider();
|
|
1399
|
+
const Accounts = repositoryOf2({
|
|
1400
|
+
entity,
|
|
1401
|
+
actions: ["findMany"],
|
|
1402
|
+
provider
|
|
1403
|
+
});
|
|
1404
|
+
const app = applicationOf2({
|
|
1405
|
+
name: "contract",
|
|
1406
|
+
entities: { Accounts: entity },
|
|
1407
|
+
repositories: { Accounts }
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
class One extends entityOf2(blueprintOf2((ctx) => ({ email: ctx.string, name: ctx.string })), { name: "One" }) {
|
|
1411
|
+
}
|
|
1412
|
+
const previous = contextFor(One, "One");
|
|
1413
|
+
const current = contextFor(entity, "Accounts");
|
|
1414
|
+
const old = [evolvedRow({ n: 1 }), evolvedRow({ n: 2 })];
|
|
1415
|
+
for (const data of old)
|
|
1416
|
+
await provider.insert(previous, data);
|
|
1417
|
+
const check = await app.migrate({ mode: "check" });
|
|
1418
|
+
expect(check.entities.Accounts?.movedFromPreviousNames).toBe(2);
|
|
1419
|
+
expect(await provider.count(current)).toBe(0);
|
|
1420
|
+
await app.migrate({ mode: "apply", pageSize: 1 });
|
|
1421
|
+
expect(await provider.count(current)).toBe(2);
|
|
1422
|
+
expect(await provider.count(previous)).toBe(2);
|
|
1423
|
+
expect((await Accounts.findMany()).map((account) => account.visits)).toEqual([1, 2]);
|
|
1424
|
+
await app.migrate({ mode: "apply", destructive: true, pageSize: 1 });
|
|
1425
|
+
expect(await provider.count(previous)).toBe(0);
|
|
1426
|
+
expect(await provider.count(current)).toBe(2);
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
939
1429
|
});
|
|
940
1430
|
}
|
|
941
1431
|
export {
|
|
1432
|
+
httpProviderContract,
|
|
942
1433
|
memoryPublisher,
|
|
943
1434
|
providerContract
|
|
944
1435
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type IRepositoryProvider } from "../
|
|
1
|
+
import { type IRepositoryProvider } from "../adapters.js";
|
|
2
2
|
/** The parts of a test runner the kit uses; `bun:test`, vitest and jest fit. */
|
|
3
3
|
export interface ContractRunner {
|
|
4
4
|
describe(name: string, body: () => void): void;
|
|
@@ -32,6 +32,22 @@ export interface ProviderContractOptions {
|
|
|
32
32
|
readonly atomic?: boolean;
|
|
33
33
|
/** Run the cases of expired records. Defaults to `provider.capabilities?.expiry`. */
|
|
34
34
|
readonly expiry?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Run the cases of schema evolution, through repositories and
|
|
37
|
+
* `app.migrate` over the basic methods: an old row read back upgraded,
|
|
38
|
+
* the backfill in check and apply, rows moved from a previous name.
|
|
39
|
+
* Default `true`. Their entities are named `Accounts` (and `One`, the
|
|
40
|
+
* previous name), with the keys of the other cases; `createdAt` and
|
|
41
|
+
* `updatedAt` are never written.
|
|
42
|
+
*/
|
|
43
|
+
readonly schemaEvolution?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Run the schema evolution cases that store the version key
|
|
46
|
+
* (`SCHEMA_VERSION_KEY`, `"$v"`) in rows, for an entity declared with
|
|
47
|
+
* `schemaVersion`. Default `true`; a provider with a fixed set of
|
|
48
|
+
* columns needs one for it (or maps `context.schema.key` to its own).
|
|
49
|
+
*/
|
|
50
|
+
readonly schemaVersionKey?: boolean;
|
|
35
51
|
}
|
|
36
52
|
/**
|
|
37
53
|
* Registers the behavior every `IRepositoryProvider` v2 must have. Pass the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afora/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Domain modeling on TypeBox: entities with invariants, repositories, use cases, sensitive data masking and layered exceptions, usable by any Standard Schema consumer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"domain-driven-design",
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"import": "./dist/index.js",
|
|
41
41
|
"default": "./dist/index.js"
|
|
42
42
|
},
|
|
43
|
+
"./adapters": {
|
|
44
|
+
"types": "./dist/adapters.d.ts",
|
|
45
|
+
"import": "./dist/adapters.js",
|
|
46
|
+
"default": "./dist/adapters.js"
|
|
47
|
+
},
|
|
43
48
|
"./experimental": {
|
|
44
49
|
"types": "./dist/experimental.d.ts",
|
|
45
50
|
"import": "./dist/experimental.js",
|
|
@@ -68,9 +73,11 @@
|
|
|
68
73
|
"lint": "biome lint .",
|
|
69
74
|
"fmt": "biome check --write .",
|
|
70
75
|
"knip": "knip",
|
|
71
|
-
"build": "rm -rf dist && bun build ./src/index.ts ./src/experimental.ts ./src/testing/index.ts --outdir ./dist --target node --format esm --splitting --packages external && tsc -p tsconfig.build.json",
|
|
76
|
+
"build": "rm -rf dist && bun build ./src/index.ts ./src/adapters.ts ./src/experimental.ts ./src/testing/index.ts --outdir ./dist --target node --format esm --splitting --packages external && tsc -p tsconfig.build.json",
|
|
72
77
|
"postbuild": "bun scripts/add-dts-extensions.ts",
|
|
73
78
|
"bench": "cd bench && bun install && bun run.ts",
|
|
79
|
+
"bench:guard": "cd bench && bun install && bun run.ts --guard",
|
|
80
|
+
"bench:check": "cd bench && bun install && bun run.ts --check",
|
|
74
81
|
"smoke": "bun run build && node scripts/smoke.mjs",
|
|
75
82
|
"test:types": "bun run build && bun tests/types-compat/run.ts",
|
|
76
83
|
"test:type-perf": "bun run build && bun test ./tests/type-perf",
|
|
@@ -78,15 +85,21 @@
|
|
|
78
85
|
"docs:links": "bun scripts/check-doc-links.ts",
|
|
79
86
|
"test:interop": "cd examples/interop && bun install && bun test",
|
|
80
87
|
"test:blog": "cd examples/blog && bun install && bun test",
|
|
88
|
+
"test:agent-built": "cd examples/agent-built && bun install && bun check && bun test",
|
|
89
|
+
"eval:agent-built": "cd examples/agent-built && bun install && bun run eval:all",
|
|
81
90
|
"prepare": "husky",
|
|
82
91
|
"prepublishOnly": "bun run typecheck && bun test && bun run smoke",
|
|
83
92
|
"test:codegen": "bun run --cwd packages/codegen test",
|
|
84
93
|
"test:roundtrip": "bun run --cwd packages/codegen test:roundtrip",
|
|
85
94
|
"test:mcp": "bun test --cwd packages/mcp",
|
|
86
95
|
"test:hono": "bun test --cwd packages/hono",
|
|
96
|
+
"test:fastify": "bun test --cwd packages/fastify",
|
|
97
|
+
"test:express": "bun test --cwd packages/express",
|
|
98
|
+
"test:elysia": "bun test --cwd packages/elysia",
|
|
87
99
|
"test:ws": "bun test --cwd packages/ws",
|
|
88
100
|
"test:drizzle": "bun test --cwd packages/drizzle",
|
|
89
|
-
"
|
|
101
|
+
"test:dynamodb": "bun test --cwd packages/dynamodb",
|
|
102
|
+
"build:packages": "bun run build && bun run --cwd packages/codegen build && bun run --cwd packages/mcp build && bun run --cwd packages/hono build && bun run --cwd packages/fastify build && bun run --cwd packages/express build && bun run --cwd packages/elysia build && bun run --cwd packages/ws build && bun run --cwd packages/drizzle build && bun run --cwd packages/dynamodb build"
|
|
90
103
|
},
|
|
91
104
|
"dependencies": {
|
|
92
105
|
"slugify": "^1.6.9"
|