@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.
Files changed (99) hide show
  1. package/README.md +157 -25
  2. package/dist/adapters.d.ts +25 -0
  3. package/dist/adapters.js +56 -0
  4. package/dist/core/application/exceptions.d.ts +17 -0
  5. package/dist/core/application/migrate.d.ts +19 -0
  6. package/dist/core/application/schema.d.ts +30 -0
  7. package/dist/core/application/start.d.ts +7 -0
  8. package/dist/core/application/types/application.interface.d.ts +73 -2
  9. package/dist/core/auth/jwt.d.ts +30 -0
  10. package/dist/core/auth/types/jwt.interface.d.ts +66 -0
  11. package/dist/core/blueprint/exceptions.d.ts +5 -0
  12. package/dist/core/blueprint/optional.d.ts +3 -1
  13. package/dist/core/blueprint/types/blueprint.interface.d.ts +79 -5
  14. package/dist/core/blueprint/variant.d.ts +32 -0
  15. package/dist/core/command/types/command.interface.d.ts +7 -0
  16. package/dist/core/entity/abstract-entity.d.ts +33 -1
  17. package/dist/core/entity/entity.d.ts +1 -1
  18. package/dist/core/entity/event.d.ts +13 -2
  19. package/dist/core/entity/exceptions.d.ts +17 -0
  20. package/dist/core/entity/trust.d.ts +24 -0
  21. package/dist/core/entity/types/entity.interface.d.ts +96 -2
  22. package/dist/core/entity/version.d.ts +13 -0
  23. package/dist/core/event-policy/event-policy.d.ts +25 -0
  24. package/dist/core/event-policy/exceptions.d.ts +11 -0
  25. package/dist/core/event-policy/publish.d.ts +17 -0
  26. package/dist/core/event-policy/publishers.d.ts +18 -0
  27. package/dist/core/event-policy/types/event-policy.interface.d.ts +72 -0
  28. package/dist/core/output/access.d.ts +16 -0
  29. package/dist/core/output/coerce.d.ts +9 -0
  30. package/dist/core/output/exceptions.d.ts +16 -0
  31. package/dist/core/output/http.d.ts +36 -0
  32. package/dist/core/output/openapi-sink.d.ts +160 -39
  33. package/dist/core/output/stage.d.ts +7 -2
  34. package/dist/core/output/status.d.ts +2 -2
  35. package/dist/core/output/types/http.interface.d.ts +130 -0
  36. package/dist/core/output/types/output.interface.d.ts +25 -5
  37. package/dist/core/parse/issue.d.ts +28 -3
  38. package/dist/core/parse/parse.d.ts +19 -0
  39. package/dist/core/parse/strip.d.ts +13 -0
  40. package/dist/core/property/abstract-property.d.ts +9 -0
  41. package/dist/core/property/define-property.d.ts +34 -6
  42. package/dist/core/property/types/property-options.interface.d.ts +10 -0
  43. package/dist/core/property/types/property-type.interface.d.ts +4 -0
  44. package/dist/core/record/abstract-record.d.ts +7 -0
  45. package/dist/core/record/types/record.interface.d.ts +12 -1
  46. package/dist/core/repository/exceptions.d.ts +43 -0
  47. package/dist/core/repository/in-memory.d.ts +16 -3
  48. package/dist/core/repository/migrate.d.ts +20 -0
  49. package/dist/core/repository/plan.d.ts +20 -0
  50. package/dist/core/repository/provider.d.ts +1 -1
  51. package/dist/core/repository/query.d.ts +9 -2
  52. package/dist/core/repository/repository.d.ts +40 -8
  53. package/dist/core/repository/state.d.ts +32 -2
  54. package/dist/core/repository/types/query.interface.d.ts +106 -5
  55. package/dist/core/repository/types/repository.interface.d.ts +139 -7
  56. package/dist/core/repository/types/schema.interface.d.ts +126 -0
  57. package/dist/core/repository/upcast.d.ts +64 -0
  58. package/dist/core/schema/schema.d.ts +28 -0
  59. package/dist/core/symbols/kind.d.ts +1 -1
  60. package/dist/core/use-case/access.d.ts +12 -0
  61. package/dist/core/use-case/decorators.d.ts +40 -0
  62. package/dist/core/use-case/route.d.ts +18 -0
  63. package/dist/core/use-case/steps/types/check.interface.d.ts +214 -0
  64. package/dist/core/use-case/types/access.interface.d.ts +39 -0
  65. package/dist/core/use-case/types/route.interface.d.ts +66 -0
  66. package/dist/core/use-case/use-case.d.ts +52 -2
  67. package/dist/experimental.d.ts +13 -1
  68. package/dist/experimental.js +64 -26
  69. package/dist/index-3aae5jfq.js +2997 -0
  70. package/dist/index-hg1z2s2v.js +1055 -0
  71. package/dist/index-ny3as79r.js +4567 -0
  72. package/dist/index-xegv6a1p.js +4291 -0
  73. package/dist/index-y280ew0k.js +396 -0
  74. package/dist/index.d.ts +55 -48
  75. package/dist/index.js +141 -169
  76. package/dist/std/blueprint-plugin.d.ts +6 -2
  77. package/dist/std/blueprint.d.ts +3 -0
  78. package/dist/std/entity.d.ts +2 -1
  79. package/dist/std/event-policies.d.ts +2 -0
  80. package/dist/std/properties/exceptions.d.ts +11 -0
  81. package/dist/std/properties/json.d.ts +54 -0
  82. package/dist/std/properties/password.d.ts +0 -2
  83. package/dist/std/properties/time.d.ts +4 -0
  84. package/dist/std/record-plugins.d.ts +2 -2
  85. package/dist/std/resource.d.ts +31 -0
  86. package/dist/std/runtime.d.ts +6 -0
  87. package/dist/std/use-cases/auth.d.ts +5 -1
  88. package/dist/std/use-cases/crud.d.ts +6 -4
  89. package/dist/std/use-cases/index.d.ts +1 -1
  90. package/dist/std/use-cases/shared.d.ts +34 -6
  91. package/dist/std/use-cases/types.d.ts +197 -22
  92. package/dist/testing/http-provider-contract.d.ts +32 -0
  93. package/dist/testing/index.d.ts +4 -0
  94. package/dist/testing/index.js +839 -3
  95. package/dist/testing/memory-publisher.d.ts +16 -0
  96. package/dist/testing/provider-contract.d.ts +31 -1
  97. package/package.json +17 -3
  98. package/dist/index-q6hggpzw.js +0 -6667
  99. package/dist/index-r78dsvtc.js +0 -1721
@@ -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
- entityOf2
7
- } from "../index-q6hggpzw.js";
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
  };