@afora/core 0.0.7 → 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 +157 -25
- 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/start.d.ts +7 -0
- package/dist/core/application/types/application.interface.d.ts +73 -2
- 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/command/types/command.interface.d.ts +7 -0
- package/dist/core/entity/abstract-entity.d.ts +33 -1
- package/dist/core/entity/entity.d.ts +1 -1
- package/dist/core/entity/event.d.ts +13 -2
- package/dist/core/entity/exceptions.d.ts +17 -0
- package/dist/core/entity/trust.d.ts +24 -0
- package/dist/core/entity/types/entity.interface.d.ts +96 -2
- package/dist/core/entity/version.d.ts +13 -0
- package/dist/core/event-policy/event-policy.d.ts +25 -0
- package/dist/core/event-policy/exceptions.d.ts +11 -0
- package/dist/core/event-policy/publish.d.ts +17 -0
- package/dist/core/event-policy/publishers.d.ts +18 -0
- package/dist/core/event-policy/types/event-policy.interface.d.ts +72 -0
- package/dist/core/output/access.d.ts +16 -0
- package/dist/core/output/coerce.d.ts +9 -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 +160 -39
- package/dist/core/output/stage.d.ts +7 -2
- package/dist/core/output/status.d.ts +2 -2
- 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 +19 -0
- package/dist/core/parse/strip.d.ts +13 -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 +43 -0
- package/dist/core/repository/in-memory.d.ts +16 -3
- package/dist/core/repository/migrate.d.ts +20 -0
- package/dist/core/repository/plan.d.ts +20 -0
- package/dist/core/repository/provider.d.ts +1 -1
- package/dist/core/repository/query.d.ts +9 -2
- package/dist/core/repository/repository.d.ts +40 -8
- package/dist/core/repository/state.d.ts +32 -2
- package/dist/core/repository/types/query.interface.d.ts +106 -5
- package/dist/core/repository/types/repository.interface.d.ts +139 -7
- 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 +64 -26
- 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 +55 -48
- package/dist/index.js +141 -169
- package/dist/std/blueprint-plugin.d.ts +6 -2
- package/dist/std/blueprint.d.ts +3 -0
- package/dist/std/entity.d.ts +2 -1
- package/dist/std/event-policies.d.ts +2 -0
- package/dist/std/properties/exceptions.d.ts +11 -0
- package/dist/std/properties/json.d.ts +54 -0
- package/dist/std/properties/password.d.ts +0 -2
- package/dist/std/properties/time.d.ts +4 -0
- 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/crud.d.ts +6 -4
- package/dist/std/use-cases/index.d.ts +1 -1
- package/dist/std/use-cases/shared.d.ts +34 -6
- package/dist/std/use-cases/types.d.ts +197 -22
- package/dist/testing/http-provider-contract.d.ts +32 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +839 -3
- package/dist/testing/memory-publisher.d.ts +16 -0
- package/dist/testing/provider-contract.d.ts +31 -1
- package/package.json +17 -3
- package/dist/index-q6hggpzw.js +0 -6667
- package/dist/index-r78dsvtc.js +0 -1721
package/dist/testing/index.js
CHANGED
|
@@ -1,11 +1,374 @@
|
|
|
1
|
-
import"../index-r78dsvtc.js";
|
|
2
1
|
import {
|
|
2
|
+
exceptionOf2,
|
|
3
|
+
SCHEMA_VERSION_KEY2,
|
|
3
4
|
RepositoryUniqueViolation2,
|
|
4
5
|
RepositoryEntityNotFound2,
|
|
6
|
+
RepositoryConcurrencyConflict2
|
|
7
|
+
} from "../index-xegv6a1p.js";
|
|
8
|
+
import {
|
|
9
|
+
repositoryOf2,
|
|
10
|
+
useCaseOf2
|
|
11
|
+
} from "../index-3aae5jfq.js";
|
|
12
|
+
import {
|
|
13
|
+
outputOf2,
|
|
14
|
+
applicationOf2,
|
|
5
15
|
blueprintOf2,
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
8
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
|
+
}
|
|
359
|
+
// src/testing/memory-publisher.ts
|
|
360
|
+
function memoryPublisher() {
|
|
361
|
+
const published = [];
|
|
362
|
+
return {
|
|
363
|
+
published,
|
|
364
|
+
publish(topic, event) {
|
|
365
|
+
published.push({ topic, event });
|
|
366
|
+
},
|
|
367
|
+
clear() {
|
|
368
|
+
published.length = 0;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
9
372
|
// src/testing/provider-contract.ts
|
|
10
373
|
function contextOf(name = "Accounts") {
|
|
11
374
|
class Account extends entityOf2(blueprintOf2((ctx) => ({ email: ctx.string, name: ctx.string }))) {
|
|
@@ -23,6 +386,11 @@ function row(overrides = {}) {
|
|
|
23
386
|
const id = `id-${String(sequence).padStart(4, "0")}`;
|
|
24
387
|
return { id, email: `${id}@example.com`, name: id, ...overrides };
|
|
25
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
|
+
}
|
|
26
394
|
async function failure(promise) {
|
|
27
395
|
try {
|
|
28
396
|
await promise;
|
|
@@ -43,6 +411,11 @@ function providerContract(runner, makeProvider, options = {}) {
|
|
|
43
411
|
};
|
|
44
412
|
return { provider, context, seed };
|
|
45
413
|
}
|
|
414
|
+
const gated = (capability) => (name, body) => it(name, async () => {
|
|
415
|
+
const enabled = options[capability] ?? (await makeProvider()).capabilities?.[capability];
|
|
416
|
+
if (enabled === true)
|
|
417
|
+
await body();
|
|
418
|
+
});
|
|
46
419
|
describe("provider contract", () => {
|
|
47
420
|
describe("writes and reads", () => {
|
|
48
421
|
it("inserts and finds by id, empty provider counts zero", async () => {
|
|
@@ -468,6 +841,80 @@ function providerContract(runner, makeProvider, options = {}) {
|
|
|
468
841
|
})).toBe(5);
|
|
469
842
|
});
|
|
470
843
|
});
|
|
844
|
+
describe("indexes", () => {
|
|
845
|
+
const indexed = (name, body) => it(name, async () => {
|
|
846
|
+
const enabled = options.indexes ?? (await makeProvider()).capabilities?.indexes;
|
|
847
|
+
if (enabled === true)
|
|
848
|
+
await body();
|
|
849
|
+
});
|
|
850
|
+
const seeded = async () => {
|
|
851
|
+
const f = await fixture();
|
|
852
|
+
const context = {
|
|
853
|
+
...f.context,
|
|
854
|
+
indexes: {
|
|
855
|
+
byGroup: { where: ["group"], orderBy: "n" },
|
|
856
|
+
byName: { where: ["name"] }
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
const rows = [
|
|
860
|
+
row({ n: 3, group: 1, name: "Gil" }),
|
|
861
|
+
row({ group: 1, name: "Érico" }),
|
|
862
|
+
row({ n: 2, group: 2, name: "Ana" }),
|
|
863
|
+
row({ n: 2, group: 1, name: "Ana" }),
|
|
864
|
+
row({ n: 1, group: 1, name: "Bia" }),
|
|
865
|
+
row({ n: 4, group: 2, name: "Zoe" })
|
|
866
|
+
];
|
|
867
|
+
for (const data of rows)
|
|
868
|
+
await f.provider.insert(context, data);
|
|
869
|
+
await f.provider.update(context, {
|
|
870
|
+
...rows[4],
|
|
871
|
+
deletedAt: "2024-01-01T00:00:00.000Z"
|
|
872
|
+
});
|
|
873
|
+
return { ...f, context };
|
|
874
|
+
};
|
|
875
|
+
indexed("an indexed query gives what the scan gives", async () => {
|
|
876
|
+
const { provider, context } = await seeded();
|
|
877
|
+
const queries = [
|
|
878
|
+
["byGroup", { where: { group: 1 } }],
|
|
879
|
+
[
|
|
880
|
+
"byGroup",
|
|
881
|
+
{
|
|
882
|
+
where: { group: 1 },
|
|
883
|
+
orderBy: [{ key: "n" }, { key: "id" }]
|
|
884
|
+
}
|
|
885
|
+
],
|
|
886
|
+
[
|
|
887
|
+
"byGroup",
|
|
888
|
+
{
|
|
889
|
+
where: { group: 1, name: "Ana" },
|
|
890
|
+
orderBy: [{ key: "n", direction: "desc" }, { key: "id" }],
|
|
891
|
+
limit: 2,
|
|
892
|
+
offset: 1
|
|
893
|
+
}
|
|
894
|
+
],
|
|
895
|
+
[
|
|
896
|
+
"byGroup",
|
|
897
|
+
{
|
|
898
|
+
where: { group: 1 },
|
|
899
|
+
orderBy: [{ key: "n" }, { key: "id" }],
|
|
900
|
+
withDeleted: true
|
|
901
|
+
}
|
|
902
|
+
],
|
|
903
|
+
["byName", { where: { name: "Ana" }, orderBy: [{ key: "id" }] }]
|
|
904
|
+
];
|
|
905
|
+
for (const [index, query] of queries) {
|
|
906
|
+
expect(ids(await provider.findMany(context, { ...query, index }))).toEqual(ids(await provider.findMany(context, query)));
|
|
907
|
+
expect(await provider.count(context, {
|
|
908
|
+
...query,
|
|
909
|
+
index
|
|
910
|
+
})).toBe(await provider.count(context, query));
|
|
911
|
+
expect((await provider.findOne(context, {
|
|
912
|
+
...query,
|
|
913
|
+
index
|
|
914
|
+
}))?.id).toBe((await provider.findOne(context, query))?.id);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
});
|
|
471
918
|
const withTransactions = async () => {
|
|
472
919
|
const enabled = options.transactions ?? (await makeProvider()).capabilities?.transactions;
|
|
473
920
|
return enabled === true;
|
|
@@ -592,8 +1039,397 @@ function providerContract(runner, makeProvider, options = {}) {
|
|
|
592
1039
|
expect(await provider.count(context)).toBe(0);
|
|
593
1040
|
});
|
|
594
1041
|
});
|
|
1042
|
+
describe("versioning", () => {
|
|
1043
|
+
const versioned = gated("versioning");
|
|
1044
|
+
versioned("update writes with the expected version, and throws RepositoryConcurrencyConflict with a stale one", async () => {
|
|
1045
|
+
const { provider, context, seed } = await fixture();
|
|
1046
|
+
const data = row({ version: 1 });
|
|
1047
|
+
await seed(data);
|
|
1048
|
+
await provider.update(context, { ...data, name: "Bia", version: 2 }, { expectedVersion: 1 });
|
|
1049
|
+
const stale = await failure(provider.update(context, { ...data, name: "Caio", version: 2 }, { expectedVersion: 1 }));
|
|
1050
|
+
expect(stale instanceof RepositoryConcurrencyConflict2).toBe(true);
|
|
1051
|
+
const { details } = stale;
|
|
1052
|
+
expect([details.id, details.expected]).toEqual([data.id, 1]);
|
|
1053
|
+
expect(await provider.findOne(context, { where: { id: data.id } })).toEqual({ ...data, name: "Bia", version: 2 });
|
|
1054
|
+
});
|
|
1055
|
+
versioned("two updates with the same expected version: exactly one succeeds", async () => {
|
|
1056
|
+
const { provider, context, seed } = await fixture();
|
|
1057
|
+
const data = row({ version: 1 });
|
|
1058
|
+
await seed(data);
|
|
1059
|
+
const results = await Promise.allSettled(["Bia", "Caio"].map((name) => provider.update(context, { ...data, name, version: 2 }, { expectedVersion: 1 })));
|
|
1060
|
+
const rejected = results.filter((result) => result.status === "rejected");
|
|
1061
|
+
expect(rejected.length).toBe(1);
|
|
1062
|
+
expect(rejected[0]?.reason instanceof RepositoryConcurrencyConflict2).toBe(true);
|
|
1063
|
+
const stored = await provider.findOne(context, {
|
|
1064
|
+
where: { id: data.id }
|
|
1065
|
+
});
|
|
1066
|
+
expect(stored?.version).toBe(2);
|
|
1067
|
+
});
|
|
1068
|
+
versioned("update without an expected version ignores the stored one", async () => {
|
|
1069
|
+
const { provider, context, seed } = await fixture();
|
|
1070
|
+
const data = row({ version: 3 });
|
|
1071
|
+
await seed(data);
|
|
1072
|
+
await provider.update(context, { ...data, name: "Bia", version: 1 });
|
|
1073
|
+
expect(await provider.findOne(context, { where: { id: data.id } })).toEqual({ ...data, name: "Bia", version: 1 });
|
|
1074
|
+
});
|
|
1075
|
+
versioned("an unknown id is still RepositoryEntityNotFound", async () => {
|
|
1076
|
+
const { provider, context } = await fixture();
|
|
1077
|
+
const error = await failure(provider.update(context, row({ version: 2 }), {
|
|
1078
|
+
expectedVersion: 1
|
|
1079
|
+
}));
|
|
1080
|
+
expect(error instanceof RepositoryEntityNotFound2).toBe(true);
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
describe("atomic", () => {
|
|
1084
|
+
const atomic = gated("atomic");
|
|
1085
|
+
const increment = (provider, ...args) => provider.increment(...args);
|
|
1086
|
+
atomic("increment adds the deltas, stores set and gives the row", async () => {
|
|
1087
|
+
const { provider, context, seed } = await fixture();
|
|
1088
|
+
const data = row({ visits: 2, age: 30 });
|
|
1089
|
+
await seed(data);
|
|
1090
|
+
const stored = await increment(provider, context, data.id, { visits: 3, age: -1, n: 5 }, { set: { name: "Bia" } });
|
|
1091
|
+
const expected = { ...data, visits: 5, age: 29, n: 5, name: "Bia" };
|
|
1092
|
+
expect(stored).toEqual(expected);
|
|
1093
|
+
expect(await provider.findOne(context, { where: { id: data.id } })).toEqual(expected);
|
|
1094
|
+
});
|
|
1095
|
+
atomic("N concurrent increments sum to N", async () => {
|
|
1096
|
+
const { provider, context, seed } = await fixture();
|
|
1097
|
+
const data = row({ visits: 0 });
|
|
1098
|
+
await seed(data);
|
|
1099
|
+
await Promise.all(Array.from({ length: 25 }, () => increment(provider, context, data.id, { visits: 1 })));
|
|
1100
|
+
const stored = await provider.findOne(context, {
|
|
1101
|
+
where: { id: data.id }
|
|
1102
|
+
});
|
|
1103
|
+
expect(stored?.visits).toBe(25);
|
|
1104
|
+
});
|
|
1105
|
+
atomic("a missing id is RepositoryEntityNotFound without create, and inserted with it", async () => {
|
|
1106
|
+
const { provider, context } = await fixture();
|
|
1107
|
+
const data = row({ visits: 1 });
|
|
1108
|
+
const missing = await failure(increment(provider, context, data.id, { visits: 1 }));
|
|
1109
|
+
expect(missing instanceof RepositoryEntityNotFound2).toBe(true);
|
|
1110
|
+
expect(await provider.count(context)).toBe(0);
|
|
1111
|
+
const created = await increment(provider, context, data.id, { visits: 1 }, { create: data, set: { name: "Bia" } });
|
|
1112
|
+
expect(created).toEqual(data);
|
|
1113
|
+
const again = await increment(provider, context, data.id, { visits: 1 }, { create: data });
|
|
1114
|
+
expect(again.visits).toBe(2);
|
|
1115
|
+
expect(await provider.count(context)).toBe(1);
|
|
1116
|
+
});
|
|
1117
|
+
atomic("concurrent increments with create lose nothing", async () => {
|
|
1118
|
+
const { provider, context } = await fixture();
|
|
1119
|
+
const data = row({ visits: 1 });
|
|
1120
|
+
await Promise.all(Array.from({ length: 10 }, () => increment(provider, context, data.id, { visits: 1 }, { create: data })));
|
|
1121
|
+
const stored = await provider.findOne(context, {
|
|
1122
|
+
where: { id: data.id }
|
|
1123
|
+
});
|
|
1124
|
+
expect(stored?.visits).toBe(10);
|
|
1125
|
+
});
|
|
1126
|
+
atomic("soft-deleted rows are not incremented", async () => {
|
|
1127
|
+
const { provider, context, seed } = await fixture();
|
|
1128
|
+
const data = row({ visits: 1, deletedAt: "2024-01-01T00:00:00.000Z" });
|
|
1129
|
+
await seed(data);
|
|
1130
|
+
const error = await failure(increment(provider, context, data.id, { visits: 1 }));
|
|
1131
|
+
expect(error instanceof RepositoryEntityNotFound2).toBe(true);
|
|
1132
|
+
const stored = await provider.findOne(context, {
|
|
1133
|
+
where: { id: data.id },
|
|
1134
|
+
withDeleted: true
|
|
1135
|
+
});
|
|
1136
|
+
expect(stored?.visits).toBe(1);
|
|
1137
|
+
});
|
|
1138
|
+
atomic("an increment of version makes an update with the old one conflict", async () => {
|
|
1139
|
+
const { provider, context, seed } = await fixture();
|
|
1140
|
+
if ((options.versioning ?? provider.capabilities?.versioning) !== true)
|
|
1141
|
+
return;
|
|
1142
|
+
const data = row({ visits: 0, version: 1 });
|
|
1143
|
+
await seed(data);
|
|
1144
|
+
const stored = await increment(provider, context, data.id, {
|
|
1145
|
+
visits: 1,
|
|
1146
|
+
version: 1
|
|
1147
|
+
});
|
|
1148
|
+
expect(stored.version).toBe(2);
|
|
1149
|
+
const error = await failure(provider.update(context, { ...data, name: "Bia", version: 2 }, { expectedVersion: 1 }));
|
|
1150
|
+
expect(error instanceof RepositoryConcurrencyConflict2).toBe(true);
|
|
1151
|
+
expect((await provider.findOne(context, { where: { id: data.id } }))?.visits).toBe(1);
|
|
1152
|
+
});
|
|
1153
|
+
atomic("an expired row counts as absent: not found, or replaced by create", async () => {
|
|
1154
|
+
const provider = await makeProvider();
|
|
1155
|
+
if ((options.expiry ?? provider.capabilities?.expiry) !== true)
|
|
1156
|
+
return;
|
|
1157
|
+
const now = "2026-06-01T12:00:00.000Z";
|
|
1158
|
+
const context = {
|
|
1159
|
+
...contextOf(),
|
|
1160
|
+
expiry: { key: "expiresAt" }
|
|
1161
|
+
};
|
|
1162
|
+
const window = row({
|
|
1163
|
+
visits: 7,
|
|
1164
|
+
expiresAt: "2026-06-01T11:00:00.000Z"
|
|
1165
|
+
});
|
|
1166
|
+
const live = row({
|
|
1167
|
+
visits: 1,
|
|
1168
|
+
expiresAt: "2026-06-01T13:00:00.000Z"
|
|
1169
|
+
});
|
|
1170
|
+
await provider.insert(context, window);
|
|
1171
|
+
await provider.insert(context, live);
|
|
1172
|
+
const missing = await failure(increment(provider, context, window.id, { visits: 1 }, {
|
|
1173
|
+
now
|
|
1174
|
+
}));
|
|
1175
|
+
expect(missing instanceof RepositoryEntityNotFound2).toBe(true);
|
|
1176
|
+
const fresh = {
|
|
1177
|
+
id: window.id,
|
|
1178
|
+
email: window.email,
|
|
1179
|
+
name: "fresh",
|
|
1180
|
+
visits: 1,
|
|
1181
|
+
expiresAt: "2026-06-01T13:00:00.000Z"
|
|
1182
|
+
};
|
|
1183
|
+
const created = await increment(provider, context, window.id, { visits: 1 }, { create: fresh, set: { name: "incremented" }, now });
|
|
1184
|
+
expect(created).toEqual(fresh);
|
|
1185
|
+
const again = await increment(provider, context, window.id, { visits: 1 }, { create: fresh, set: { name: "incremented" }, now });
|
|
1186
|
+
expect([again.visits, again.name]).toEqual([2, "incremented"]);
|
|
1187
|
+
const counted = await increment(provider, context, live.id, { visits: 1 }, { create: { ...live, visits: 100 }, now });
|
|
1188
|
+
expect(counted.visits).toBe(2);
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
describe("expiry", () => {
|
|
1192
|
+
const expiring = gated("expiry");
|
|
1193
|
+
const NOW = "2026-06-01T12:00:00.000Z";
|
|
1194
|
+
const seeded = async () => {
|
|
1195
|
+
const provider = await makeProvider();
|
|
1196
|
+
const context = {
|
|
1197
|
+
...contextOf(),
|
|
1198
|
+
expiry: { key: "expiresAt" }
|
|
1199
|
+
};
|
|
1200
|
+
const [past, edge, never, future] = [
|
|
1201
|
+
row({ n: 1, expiresAt: "2026-05-01T00:00:00.000Z" }),
|
|
1202
|
+
row({ n: 2, expiresAt: NOW }),
|
|
1203
|
+
row({ n: 3 }),
|
|
1204
|
+
row({ n: 4, expiresAt: "2026-07-01T00:00:00.000Z" })
|
|
1205
|
+
];
|
|
1206
|
+
for (const data of [past, edge, never, future])
|
|
1207
|
+
await provider.insert(context, data);
|
|
1208
|
+
return { provider, context, past, edge, never, future };
|
|
1209
|
+
};
|
|
1210
|
+
expiring("reads leave out rows whose date is at or before now", async () => {
|
|
1211
|
+
const { provider, context, past, edge, never, future } = await seeded();
|
|
1212
|
+
const query = { now: NOW };
|
|
1213
|
+
expect(ids(await provider.findMany(context, query))).toEqual([
|
|
1214
|
+
never.id,
|
|
1215
|
+
future.id
|
|
1216
|
+
]);
|
|
1217
|
+
expect(await provider.count(context, query)).toBe(2);
|
|
1218
|
+
expect(await provider.findOne(context, {
|
|
1219
|
+
...query,
|
|
1220
|
+
where: { id: past.id }
|
|
1221
|
+
})).toBe(undefined);
|
|
1222
|
+
expect(await provider.findOne(context, {
|
|
1223
|
+
...query,
|
|
1224
|
+
where: { id: edge.id }
|
|
1225
|
+
})).toBe(undefined);
|
|
1226
|
+
expect((await provider.findOne(context, {
|
|
1227
|
+
...query,
|
|
1228
|
+
where: { id: future.id }
|
|
1229
|
+
}))?.id).toBe(future.id);
|
|
1230
|
+
expect(ids(await provider.findMany(context, {
|
|
1231
|
+
...query,
|
|
1232
|
+
where: { id: { in: [past.id, never.id] } }
|
|
1233
|
+
}))).toEqual([never.id]);
|
|
1234
|
+
});
|
|
1235
|
+
expiring("a row without a date never expires", async () => {
|
|
1236
|
+
const { provider, context, never } = await seeded();
|
|
1237
|
+
const later = { now: "2999-01-01T00:00:00.000Z" };
|
|
1238
|
+
expect(ids(await provider.findMany(context, later))).toEqual([
|
|
1239
|
+
never.id
|
|
1240
|
+
]);
|
|
1241
|
+
expect(await provider.count(context, later)).toBe(1);
|
|
1242
|
+
});
|
|
1243
|
+
expiring("withExpired gives the expired rows too", async () => {
|
|
1244
|
+
const { provider, context, past, edge, never, future } = await seeded();
|
|
1245
|
+
const query = { now: NOW, withExpired: true };
|
|
1246
|
+
expect(ids(await provider.findMany(context, query))).toEqual([
|
|
1247
|
+
past.id,
|
|
1248
|
+
edge.id,
|
|
1249
|
+
never.id,
|
|
1250
|
+
future.id
|
|
1251
|
+
]);
|
|
1252
|
+
expect(await provider.count(context, query)).toBe(4);
|
|
1253
|
+
expect((await provider.findOne(context, {
|
|
1254
|
+
...query,
|
|
1255
|
+
where: { id: past.id }
|
|
1256
|
+
}))?.id).toBe(past.id);
|
|
1257
|
+
});
|
|
1258
|
+
expiring("leaves expired rows out of a read after a position", async () => {
|
|
1259
|
+
const enabled = options.keyset ?? (await makeProvider()).capabilities?.keyset;
|
|
1260
|
+
if (enabled !== true)
|
|
1261
|
+
return;
|
|
1262
|
+
const { provider, context, past, edge, never, future } = await seeded();
|
|
1263
|
+
const orderBy = [{ key: "n" }, { key: "id" }];
|
|
1264
|
+
expect(ids(await provider.findMany(context, {
|
|
1265
|
+
now: NOW,
|
|
1266
|
+
orderBy,
|
|
1267
|
+
after: [0, ""]
|
|
1268
|
+
}))).toEqual([never.id, future.id]);
|
|
1269
|
+
expect(ids(await provider.findMany(context, {
|
|
1270
|
+
now: NOW,
|
|
1271
|
+
orderBy,
|
|
1272
|
+
after: [1, past.id],
|
|
1273
|
+
withExpired: true
|
|
1274
|
+
}))).toEqual([edge.id, never.id, future.id]);
|
|
1275
|
+
});
|
|
1276
|
+
expiring("unique keys still count expired rows", async () => {
|
|
1277
|
+
const { provider, context, past } = await seeded();
|
|
1278
|
+
const error = await failure(provider.insert(context, row({ email: past.email })));
|
|
1279
|
+
expect(error instanceof RepositoryUniqueViolation2).toBe(true);
|
|
1280
|
+
expect(error.details.key).toBe("email");
|
|
1281
|
+
expect(await provider.count(context, { now: NOW, withExpired: true })).toBe(4);
|
|
1282
|
+
});
|
|
1283
|
+
expiring("an entity that does not expire ignores now", async () => {
|
|
1284
|
+
const { provider, context, seed } = await fixture();
|
|
1285
|
+
const data = row({ expiresAt: "2000-01-01T00:00:00.000Z" });
|
|
1286
|
+
await seed(data);
|
|
1287
|
+
expect(ids(await provider.findMany(context, { now: NOW }))).toEqual([
|
|
1288
|
+
data.id
|
|
1289
|
+
]);
|
|
1290
|
+
expect(await provider.count(context, { now: NOW })).toBe(1);
|
|
1291
|
+
});
|
|
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
|
+
});
|
|
595
1429
|
});
|
|
596
1430
|
}
|
|
597
1431
|
export {
|
|
1432
|
+
httpProviderContract,
|
|
1433
|
+
memoryPublisher,
|
|
598
1434
|
providerContract
|
|
599
1435
|
};
|