@akterjs/conformance 0.0.0-stage → 0.1.1

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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -2
  3. package/dist/client-contract.suite.d.mts +23 -0
  4. package/dist/client-contract.suite.mjs +279 -0
  5. package/dist/client-target.errors.d.mts +8 -0
  6. package/dist/client-target.errors.mjs +4 -0
  7. package/dist/engine-conformance.suite.d.mts +19 -0
  8. package/dist/engine-conformance.suite.mjs +357 -0
  9. package/dist/fork/fork-conformance.suite.d.mts +20 -0
  10. package/dist/fork/fork-conformance.suite.mjs +167 -0
  11. package/dist/guarantee-titles.pure.d.mts +31 -0
  12. package/dist/guarantee-titles.pure.mjs +32 -0
  13. package/dist/ledger/ledger-calls.pure.d.mts +79 -0
  14. package/dist/ledger/ledger-calls.pure.mjs +111 -0
  15. package/dist/ledger/ledger-checks.pure.d.mts +21 -0
  16. package/dist/ledger/ledger-checks.pure.mjs +37 -0
  17. package/dist/ledger/ledger.actor.d.mts +13 -0
  18. package/dist/ledger/ledger.actor.mjs +115 -0
  19. package/dist/ledger/ledger.contract.d.mts +310 -0
  20. package/dist/ledger/ledger.contract.mjs +112 -0
  21. package/dist/ledger/plain-ledger.actor.d.mts +4 -0
  22. package/dist/ledger/plain-ledger.actor.mjs +214 -0
  23. package/dist/rejected.errors.d.mts +8 -0
  24. package/dist/rejected.errors.mjs +4 -0
  25. package/dist/tally-clients.layer.d.mts +39 -0
  26. package/dist/tally-clients.layer.mjs +97 -0
  27. package/dist/tally.actor.d.mts +49 -0
  28. package/dist/tally.actor.mjs +26 -0
  29. package/dist/tally.contract.d.mts +51 -0
  30. package/dist/tally.contract.mjs +28 -0
  31. package/dist/workbench/computer-conformance.suite.d.mts +15 -0
  32. package/dist/workbench/computer-conformance.suite.mjs +330 -0
  33. package/dist/workbench/workbench-calls.pure.d.mts +45 -0
  34. package/dist/workbench/workbench-calls.pure.mjs +75 -0
  35. package/dist/workbench/workbench.actor.d.mts +4 -0
  36. package/dist/workbench/workbench.actor.mjs +107 -0
  37. package/dist/workbench/workbench.contract.d.mts +220 -0
  38. package/dist/workbench/workbench.contract.mjs +54 -0
  39. package/package.json +86 -3
  40. package/src/client-contract.suite.ts +413 -0
  41. package/src/client-target.errors.ts +7 -0
  42. package/src/engine-conformance.suite.ts +484 -0
  43. package/src/fork/fork-conformance.suite.ts +204 -0
  44. package/src/guarantee-titles.pure.ts +50 -0
  45. package/src/ledger/ledger-calls.pure.ts +179 -0
  46. package/src/ledger/ledger-checks.pure.ts +84 -0
  47. package/src/ledger/ledger.actor.ts +196 -0
  48. package/src/ledger/ledger.contract.ts +76 -0
  49. package/src/ledger/plain-ledger.actor.ts +246 -0
  50. package/src/rejected.errors.ts +6 -0
  51. package/src/tally-clients.layer.ts +221 -0
  52. package/src/tally.actor.ts +28 -0
  53. package/src/tally.contract.ts +22 -0
  54. package/src/workbench/computer-conformance.suite.ts +380 -0
  55. package/src/workbench/workbench-calls.pure.ts +92 -0
  56. package/src/workbench/workbench.actor.ts +175 -0
  57. package/src/workbench/workbench.contract.ts +47 -0
@@ -0,0 +1,413 @@
1
+ import { ActorAlreadyExistsError, CallerNotAllowedError, UnauthenticatedError } from "@akterjs/effect/contract/errors";
2
+ import { expect } from "@effect/vitest";
3
+ import type { Vitest } from "@effect/vitest";
4
+ import type { TestContext } from "vitest";
5
+ import {
6
+ Array as Arr,
7
+ Effect,
8
+ Exit,
9
+ Function,
10
+ MutableRef,
11
+ Option,
12
+ Queue,
13
+ Record as Rec,
14
+ Schedule,
15
+ Schema,
16
+ Scope,
17
+ Stream,
18
+ } from "effect";
19
+ import type { Cause } from "effect";
20
+ import { SecureRandom } from "effect/crypto";
21
+
22
+ import { ClientTargetUnsetError } from "./client-target.errors.js";
23
+ import type { RejectedError } from "./rejected.errors.js";
24
+ import { clients, wired } from "./tally-clients.layer.js";
25
+ import type { Connect, Headers, TallyHandle, Update, Wire } from "./tally-clients.layer.js";
26
+ import { ShortError } from "./tally.contract.js";
27
+
28
+ interface ClientCallers {
29
+ readonly foreign?: Headers;
30
+ readonly stranger: Headers;
31
+ readonly unauthenticated: Headers;
32
+ readonly user: Headers;
33
+ }
34
+
35
+ interface ClientTarget {
36
+ readonly callers: ClientCallers;
37
+ // A new actor key for each case, by default a random `tally-<uuid>`.
38
+ // A target that serves only some shards, such as a local Cloud harness, supplies keys on those shards.
39
+ readonly freshKey?: Effect.Effect<string>;
40
+ readonly url: string;
41
+ }
42
+
43
+ type Target<Failure, Services> = Effect.Effect<ClientTarget, ClientTargetUnsetError | Failure, Scope.Scope | Services>;
44
+
45
+ interface Case {
46
+ readonly connect: Connect;
47
+ readonly target: ClientTarget;
48
+ readonly wire: Wire;
49
+ }
50
+
51
+ type ContractCase = (test: Case) => Effect.Effect<void, Cause.NoSuchElementError | RejectedError, Scope.Scope>;
52
+
53
+ type Updates<Value> = Queue.Dequeue<Update<Value>, RejectedError>;
54
+
55
+ const none = 0;
56
+
57
+ const one = 1;
58
+
59
+ const two = 2;
60
+
61
+ const three = 3;
62
+
63
+ const four = 4;
64
+
65
+ const five = 5;
66
+
67
+ const eight = 8;
68
+
69
+ const nine = 9;
70
+
71
+ const pollTries = 250;
72
+
73
+ const abnormalClose = 4000;
74
+
75
+ const dropped = "dropped by the client contract fixture";
76
+
77
+ const resubscribed = "250 millis";
78
+
79
+ const updateDeadline = "10 seconds";
80
+
81
+ const randomKey = Effect.sync(() => `tally-${SecureRandom.uuidV4Unsafe()}`);
82
+
83
+ const freshKeyOf = ({ freshKey }: ClientTarget): Effect.Effect<string> =>
84
+ Option.getOrElse(Option.fromNullishOr(freshKey), () => randomKey);
85
+
86
+ const polled = <Value>(read: Effect.Effect<Value>, done: (value: Value) => boolean): Effect.Effect<Value> =>
87
+ Effect.filterOrFail(read, done, () => "not yet").pipe(
88
+ Effect.retry({ schedule: Schedule.spaced("20 millis"), times: pollTries }),
89
+ Effect.orDie,
90
+ );
91
+
92
+ const nextOf = <Value>(queue: Updates<Value>): Effect.Effect<Update<Value>, RejectedError> =>
93
+ Effect.timeoutOrElse(Queue.take(queue), {
94
+ duration: updateDeadline,
95
+ orElse: () => Effect.die(`no live update arrived within ${updateDeadline}`),
96
+ });
97
+
98
+ const collected = <Value>(
99
+ updates: Stream.Stream<Update<Value>, RejectedError>,
100
+ ): Effect.Effect<Updates<Value>, never, Scope.Scope> =>
101
+ Effect.gen(function* collect() {
102
+ const queue = yield* Queue.unbounded<Update<Value>, RejectedError>();
103
+
104
+ yield* Effect.forkScoped(
105
+ Effect.matchEffect(
106
+ Stream.runForEach(updates, (update) => Queue.offer(queue, update)),
107
+ { onFailure: (failure) => Queue.fail(queue, failure), onSuccess: () => Effect.void },
108
+ ),
109
+ );
110
+
111
+ return queue;
112
+ });
113
+
114
+ const headOf = <Value>(
115
+ updates: Stream.Stream<Update<Value>, RejectedError>,
116
+ ): Effect.Effect<Update<Value>, Cause.NoSuchElementError | RejectedError> =>
117
+ Effect.flatMap(Stream.runHead(updates), Effect.fromOption);
118
+
119
+ const handle = (
120
+ { connect, target, wire }: Case,
121
+ headers: Headers,
122
+ key: string,
123
+ ): Effect.Effect<TallyHandle, never, Scope.Scope> =>
124
+ Effect.map(connect({ headers, url: target.url, wire }), (connected) => connected(key));
125
+
126
+ const reasonOf = (effect: Effect.Effect<unknown, RejectedError>): Effect.Effect<unknown> =>
127
+ Effect.match(effect, { onFailure: (failure) => failure.reason, onSuccess: (succeeded) => ({ succeeded }) });
128
+
129
+ const created = Effect.fnUntraced(function* created(test: Case) {
130
+ const key = yield* freshKeyOf(test.target);
131
+ const tally = yield* handle(test, test.target.callers.user, key);
132
+
133
+ yield* tally.create("ada");
134
+
135
+ return { key, tally };
136
+ });
137
+
138
+ const creation = Effect.fnUntraced(function* creation(test: Case) {
139
+ const { tally } = yield* created(test);
140
+ const again = yield* reasonOf(tally.create("bob"));
141
+
142
+ yield* tally.ensure("bob");
143
+
144
+ const owner = yield* tally.owner;
145
+
146
+ expect(again).toBeInstanceOf(ActorAlreadyExistsError);
147
+ expect(owner).toBe("ada");
148
+ });
149
+
150
+ const commands = Effect.fnUntraced(function* commands(test: Case) {
151
+ const { tally } = yield* created(test);
152
+ const added = yield* tally.add(five);
153
+ const short = yield* reasonOf(tally.take(nine));
154
+ const taken = yield* tally.take(two);
155
+
156
+ expect([added, taken]).toStrictEqual([five, three]);
157
+ expect(short).toBeInstanceOf(ShortError);
158
+ expect(short).toStrictEqual(ShortError.make({ total: five }));
159
+ });
160
+
161
+ const reads = Effect.fnUntraced(function* reads(test: Case) {
162
+ const { tally } = yield* created(test);
163
+
164
+ yield* tally.add(four);
165
+
166
+ const totals = yield* collected(tally.totals);
167
+ const [total, first, state] = yield* Effect.all([tally.total, nextOf(totals), headOf(tally.state)]);
168
+
169
+ yield* tally.add(one);
170
+
171
+ const next = yield* nextOf(totals);
172
+
173
+ expect([total, first.value, next.value]).toStrictEqual([four, four, five]);
174
+ expect(state).toStrictEqual({ value: { owner: "ada", total: four }, version: first.version });
175
+ expect(next.version).toBeGreaterThan(first.version);
176
+ });
177
+
178
+ // Each round first reads the total, which a cache may then hold, and reads it again right after the caller's own command.
179
+ const readYourWrites = Effect.fnUntraced(function* readYourWrites(test: Case) {
180
+ const { tally } = yield* created(test);
181
+
182
+ const seen = yield* Effect.forEach(Arr.makeBy(five, Function.identity), () =>
183
+ Effect.andThen(tally.total, Effect.andThen(tally.add(one), tally.total)),
184
+ );
185
+
186
+ expect(seen).toStrictEqual([one, two, three, four, five]);
187
+ });
188
+
189
+ // Two fresh keys to fork into, and a third that another caller creates, with a handle on it.
190
+ const forkTargets = Effect.fnUntraced(function* forkTargets(test: Case) {
191
+ const [first, second, taken] = yield* Effect.all([
192
+ freshKeyOf(test.target),
193
+ freshKeyOf(test.target),
194
+ freshKeyOf(test.target),
195
+ ]);
196
+
197
+ const occupied = yield* handle(test, test.target.callers.user, taken);
198
+
199
+ return { first, occupied, second, taken };
200
+ });
201
+
202
+ // A fork copies the actor at the moment it runs: its sibling forked later sees later state, and neither copy nor the
203
+ // Parent sees the others' commands. A lost fork reply retried with its id makes one copy.
204
+ const forking = Effect.fnUntraced(function* forking(test: Case) {
205
+ const { tally } = yield* created(test);
206
+ const { first, occupied, second, taken } = yield* forkTargets(test);
207
+
208
+ yield* Effect.andThen(tally.add(three), test.wire.loseNextReply);
209
+
210
+ const child = yield* tally.fork(first);
211
+ const sibling = yield* Effect.andThen(tally.add(one), tally.fork(second));
212
+
213
+ yield* Effect.andThen(child.add(five), occupied.create("bob"));
214
+
215
+ const totals = Effect.all([tally.total, child.total, sibling.total, child.owner, occupied.owner]);
216
+ const [refused, read] = yield* Effect.all([reasonOf(tally.fork(taken)), totals]);
217
+
218
+ expect(read).toStrictEqual([four, eight, four, "ada", "bob"]);
219
+ expect(refused).toBeInstanceOf(ActorAlreadyExistsError);
220
+ });
221
+
222
+ const warming = Effect.fnUntraced(function* warming(test: Case) {
223
+ const { tally } = yield* created(test);
224
+ const never = yield* Effect.flatMap(freshKeyOf(test.target), (key) => handle(test, test.target.callers.user, key));
225
+ const before = yield* headOf(tally.state);
226
+
227
+ yield* Effect.all([tally.warm, never.warm]);
228
+
229
+ const after = yield* headOf(tally.state);
230
+
231
+ yield* never.create("bob");
232
+
233
+ const owner = yield* never.owner;
234
+
235
+ expect([after, owner]).toStrictEqual([before, "bob"]);
236
+ });
237
+
238
+ const refusals = Effect.fnUntraced(function* refusals(test: Case) {
239
+ const { key, tally } = yield* created(test);
240
+ const { stranger, unauthenticated } = test.target.callers;
241
+ const [robot, anyone] = yield* Effect.all([handle(test, stranger, key), handle(test, unauthenticated, key)]);
242
+ const refused = yield* reasonOf(robot.add(one));
243
+ const anonymous = yield* reasonOf(anyone.add(one));
244
+ const total = yield* tally.total;
245
+
246
+ expect(refused).toBeInstanceOf(CallerNotAllowedError);
247
+ expect(anonymous).toBeInstanceOf(UnauthenticatedError);
248
+ expect(total).toBe(none);
249
+ });
250
+
251
+ const outsiders = (headers: Headers): ContractCase =>
252
+ Effect.fnUntraced(function* refuseOutsiders(test: Case) {
253
+ const { key } = yield* created(test);
254
+ const outsider = yield* handle(test, headers, key);
255
+ const refused = yield* reasonOf(outsider.add(one));
256
+ const read = yield* reasonOf(outsider.total);
257
+
258
+ expect(refused).toBeInstanceOf(CallerNotAllowedError);
259
+ expect(read).toBeInstanceOf(CallerNotAllowedError);
260
+ });
261
+
262
+ const lostReply = Effect.fnUntraced(function* lostReply(test: Case) {
263
+ const { tally } = yield* created(test);
264
+
265
+ yield* test.wire.loseNextReply;
266
+
267
+ const added = yield* tally.add(three);
268
+ const total = yield* tally.total;
269
+
270
+ expect([added, total]).toStrictEqual([three, three]);
271
+ });
272
+
273
+ const dropSockets = ({ wire }: Case): Effect.Effect<void> =>
274
+ Effect.gen(function* dropEverySocket() {
275
+ const before = MutableRef.get(wire.opened);
276
+
277
+ yield* Effect.sync(() => {
278
+ for (const socket of wire.sockets) {
279
+ socket.close(abnormalClose, dropped);
280
+ }
281
+ });
282
+ yield* polled(
283
+ Effect.sync(() => MutableRef.get(wire.opened)),
284
+ (count) => count > before,
285
+ );
286
+ yield* Effect.sleep(resubscribed);
287
+ });
288
+
289
+ const reconnect = Effect.fnUntraced(function* reconnect(test: Case) {
290
+ const { tally } = yield* created(test);
291
+ const totals = yield* collected(tally.totals);
292
+ const first = yield* nextOf(totals);
293
+
294
+ const next = yield* Effect.andThen(Effect.andThen(dropSockets(test), tally.add(two)), nextOf(totals));
295
+
296
+ yield* tally.add(one);
297
+
298
+ const last = yield* nextOf(totals);
299
+
300
+ expect([first.value, next.value, last.value]).toStrictEqual([none, two, three]);
301
+ expect(next.version).toBeGreaterThan(first.version);
302
+ expect(last.version).toBeGreaterThan(next.version);
303
+ });
304
+
305
+ const disposal = Effect.fnUntraced(function* disposal(test: Case) {
306
+ const scope = yield* Scope.make();
307
+ const { tally } = yield* Scope.provide(created(test), scope);
308
+ const totals = yield* Scope.provide(collected(tally.totals), scope);
309
+
310
+ yield* nextOf(totals);
311
+
312
+ const open = test.wire.sockets.size;
313
+
314
+ yield* Scope.close(scope, Exit.void);
315
+
316
+ const left = yield* polled(
317
+ Effect.sync(() => test.wire.sockets.size),
318
+ (size) => size === none,
319
+ );
320
+
321
+ expect([open, left]).toStrictEqual([one, none]);
322
+ });
323
+
324
+ type CaseName =
325
+ | "commands"
326
+ | "creation"
327
+ | "disposal"
328
+ | "foreign"
329
+ | "forking"
330
+ | "lostReply"
331
+ | "readYourWrites"
332
+ | "reads"
333
+ | "reconnect"
334
+ | "refusals"
335
+ | "warming";
336
+
337
+ const titles: Readonly<Record<CaseName, string>> = {
338
+ commands: "a command replies with its success or the contract's typed error",
339
+ creation: "create makes the actor once, and ensure attaches to it",
340
+ disposal: "disposing the client closes its live sockets",
341
+ foreign: "a caller from another organization or app is refused with CallerNotAllowedError",
342
+ forking:
343
+ "a fork copies the actor at one cut into a new key, independent of its parent and siblings, once per fork id",
344
+ lostReply: "a command whose reply was lost is retried with its id and runs once",
345
+ readYourWrites: "a query right after the caller's own command reads that command's write",
346
+ reads: "queries and state read committed state, and live updates carry increasing versions",
347
+ reconnect: "a subscription that loses its socket reconnects and resumes without repeating an update",
348
+ refusals: "a caller the contract doesn't allow, or one without valid credentials, is refused and changes nothing",
349
+ warming: "warm opens an actor without changing it, and does nothing for a key never created",
350
+ };
351
+
352
+ const casesOf = (callers: ClientCallers): Readonly<Record<CaseName, ContractCase>> => ({
353
+ commands,
354
+ creation,
355
+ disposal,
356
+ foreign: Option.match(Option.fromNullishOr(callers.foreign), {
357
+ onNone: () => () => Effect.die("this target declares no caller from another organization or app"),
358
+ onSome: outsiders,
359
+ }),
360
+ forking,
361
+ lostReply,
362
+ readYourWrites,
363
+ reads,
364
+ reconnect,
365
+ refusals,
366
+ warming,
367
+ });
368
+
369
+ interface ClientContract<Failure, Services> {
370
+ readonly skip: Readonly<Partial<Record<CaseName, string>>>;
371
+ readonly target: Target<Failure, Services>;
372
+ }
373
+
374
+ const perTest = 60_000;
375
+
376
+ const isUnset = Schema.is(ClientTargetUnsetError);
377
+
378
+ const clientConformanceOf =
379
+ <Failure, Services>({ skip, target }: ClientContract<Failure, Services>) =>
380
+ (test: Vitest.MethodsNonLive<Services>): void => {
381
+ for (const [client, connect] of clients) {
382
+ for (const name of Rec.keys(titles)) {
383
+ const title = `${client}: ${titles[name]}`;
384
+
385
+ const run = ({
386
+ skip: skipCase,
387
+ }: TestContext): Effect.Effect<void, Cause.NoSuchElementError | Failure | RejectedError, Services> =>
388
+ Effect.scoped(
389
+ Effect.gen(function* runCase() {
390
+ const resolved = yield* target;
391
+ const wire = yield* wired;
392
+
393
+ yield* casesOf(resolved.callers)[name]({ connect, target: resolved, wire });
394
+ }),
395
+ ).pipe(Effect.catchIf(isUnset, ({ reason }) => Effect.sync(() => skipCase(reason))));
396
+
397
+ Option.match(Option.fromNullishOr(skip[name]), {
398
+ onNone: () => {
399
+ test.effect(title, run, perTest);
400
+ },
401
+ onSome: (reason) => {
402
+ test.effect.skip(`${title} (skipped: ${reason})`, run);
403
+ },
404
+ });
405
+ }
406
+ }
407
+ };
408
+
409
+ export { clientConformanceOf };
410
+
411
+ export { ClientTargetUnsetError } from "./client-target.errors.js";
412
+
413
+ export type { CaseName, ClientCallers, ClientTarget };
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+
3
+ // A target that can't serve this run, such as a deployed stage nobody configured, fails with this and the case is skipped.
4
+ export class ClientTargetUnsetError extends Schema.TaggedError<
5
+ ClientTargetUnsetError,
6
+ { readonly brand: unique symbol }
7
+ >()("ClientTargetUnsetError", { reason: Schema.String }) {}