@akterjs/conformance 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +52 -2
- package/dist/client-contract.suite.d.mts +23 -0
- package/dist/client-contract.suite.mjs +279 -0
- package/dist/client-target.errors.d.mts +8 -0
- package/dist/client-target.errors.mjs +4 -0
- package/dist/engine-conformance.suite.d.mts +19 -0
- package/dist/engine-conformance.suite.mjs +357 -0
- package/dist/fork/fork-conformance.suite.d.mts +20 -0
- package/dist/fork/fork-conformance.suite.mjs +167 -0
- package/dist/guarantee-titles.pure.d.mts +31 -0
- package/dist/guarantee-titles.pure.mjs +32 -0
- package/dist/ledger/ledger-calls.pure.d.mts +79 -0
- package/dist/ledger/ledger-calls.pure.mjs +111 -0
- package/dist/ledger/ledger-checks.pure.d.mts +21 -0
- package/dist/ledger/ledger-checks.pure.mjs +37 -0
- package/dist/ledger/ledger.actor.d.mts +13 -0
- package/dist/ledger/ledger.actor.mjs +115 -0
- package/dist/ledger/ledger.contract.d.mts +310 -0
- package/dist/ledger/ledger.contract.mjs +112 -0
- package/dist/ledger/plain-ledger.actor.d.mts +4 -0
- package/dist/ledger/plain-ledger.actor.mjs +214 -0
- package/dist/rejected.errors.d.mts +8 -0
- package/dist/rejected.errors.mjs +4 -0
- package/dist/tally-clients.layer.d.mts +39 -0
- package/dist/tally-clients.layer.mjs +97 -0
- package/dist/tally.actor.d.mts +49 -0
- package/dist/tally.actor.mjs +26 -0
- package/dist/tally.contract.d.mts +51 -0
- package/dist/tally.contract.mjs +28 -0
- package/dist/workbench/computer-conformance.suite.d.mts +15 -0
- package/dist/workbench/computer-conformance.suite.mjs +330 -0
- package/dist/workbench/workbench-calls.pure.d.mts +45 -0
- package/dist/workbench/workbench-calls.pure.mjs +75 -0
- package/dist/workbench/workbench.actor.d.mts +4 -0
- package/dist/workbench/workbench.actor.mjs +107 -0
- package/dist/workbench/workbench.contract.d.mts +220 -0
- package/dist/workbench/workbench.contract.mjs +54 -0
- package/package.json +86 -3
- package/src/client-contract.suite.ts +413 -0
- package/src/client-target.errors.ts +7 -0
- package/src/engine-conformance.suite.ts +484 -0
- package/src/fork/fork-conformance.suite.ts +204 -0
- package/src/guarantee-titles.pure.ts +50 -0
- package/src/ledger/ledger-calls.pure.ts +179 -0
- package/src/ledger/ledger-checks.pure.ts +84 -0
- package/src/ledger/ledger.actor.ts +196 -0
- package/src/ledger/ledger.contract.ts +76 -0
- package/src/ledger/plain-ledger.actor.ts +246 -0
- package/src/rejected.errors.ts +6 -0
- package/src/tally-clients.layer.ts +221 -0
- package/src/tally.actor.ts +28 -0
- package/src/tally.contract.ts +22 -0
- package/src/workbench/computer-conformance.suite.ts +380 -0
- package/src/workbench/workbench-calls.pure.ts +92 -0
- package/src/workbench/workbench.actor.ts +175 -0
- package/src/workbench/workbench.contract.ts +47 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { ActorHost } from "@akterjs/effect";
|
|
2
|
+
import type { ActorRegistry } from "@akterjs/effect/contract/actor-registry";
|
|
3
|
+
import { expect } from "@effect/vitest";
|
|
4
|
+
import type { Vitest } from "@effect/vitest";
|
|
5
|
+
import {
|
|
6
|
+
Array as Arr,
|
|
7
|
+
Cause,
|
|
8
|
+
Context,
|
|
9
|
+
Effect,
|
|
10
|
+
Exit,
|
|
11
|
+
Layer,
|
|
12
|
+
Option,
|
|
13
|
+
Predicate,
|
|
14
|
+
Queue,
|
|
15
|
+
Record as Rec,
|
|
16
|
+
Stream,
|
|
17
|
+
String as Str,
|
|
18
|
+
} from "effect";
|
|
19
|
+
import type { Duration, Schema, Scope } from "effect";
|
|
20
|
+
import { TestClock } from "effect/testing";
|
|
21
|
+
|
|
22
|
+
import { guaranteeTitles } from "./guarantee-titles.pure.js";
|
|
23
|
+
import { effectLedgers } from "./ledger/ledger.actor.js";
|
|
24
|
+
import type { LedgerLayer, Ledgers } from "./ledger/ledger.actor.js";
|
|
25
|
+
import {
|
|
26
|
+
ada,
|
|
27
|
+
amount,
|
|
28
|
+
amountLater,
|
|
29
|
+
amountTo,
|
|
30
|
+
approving,
|
|
31
|
+
beforeDue,
|
|
32
|
+
billing,
|
|
33
|
+
bob,
|
|
34
|
+
depositOpening,
|
|
35
|
+
fileDecisions,
|
|
36
|
+
forgedWriting,
|
|
37
|
+
key,
|
|
38
|
+
ledgerHost,
|
|
39
|
+
lostInCrash,
|
|
40
|
+
longAfterDue,
|
|
41
|
+
nothing,
|
|
42
|
+
opening,
|
|
43
|
+
overdraft,
|
|
44
|
+
overdrawnBy,
|
|
45
|
+
payee,
|
|
46
|
+
reading,
|
|
47
|
+
refill,
|
|
48
|
+
robotDeposit,
|
|
49
|
+
topUp,
|
|
50
|
+
topUpDoubled,
|
|
51
|
+
transferred,
|
|
52
|
+
untilDue,
|
|
53
|
+
withdrawOverdraft,
|
|
54
|
+
writing,
|
|
55
|
+
} from "./ledger/ledger-calls.pure.js";
|
|
56
|
+
import type { Host, Unexpected } from "./ledger/ledger-calls.pure.js";
|
|
57
|
+
import {
|
|
58
|
+
akterTables,
|
|
59
|
+
decodeReply,
|
|
60
|
+
fileDecision,
|
|
61
|
+
isHostTable,
|
|
62
|
+
isRefused,
|
|
63
|
+
refusedPrefix,
|
|
64
|
+
} from "./ledger/ledger-checks.pure.js";
|
|
65
|
+
import { Ledger } from "./ledger/ledger.contract.js";
|
|
66
|
+
|
|
67
|
+
const { account, amounts, approve, ask, drained, held, send } = ledgerHost;
|
|
68
|
+
|
|
69
|
+
type Storage<Failure> = Layer.Layer<ActorHost | ActorRegistry, Failure>;
|
|
70
|
+
|
|
71
|
+
type Engine<Failure, Services> = Effect.Effect<Storage<Failure>, Failure, Scope.Scope | Services>;
|
|
72
|
+
|
|
73
|
+
interface Declared {
|
|
74
|
+
readonly hostTables: Option.Option<string>;
|
|
75
|
+
readonly ledgers: Ledgers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Checked<Failure> = Effect.Effect<void, Failure | Unexpected>;
|
|
79
|
+
|
|
80
|
+
type Guarantee<Failure> = (storage: Storage<Failure>, declared: Declared) => Checked<Failure>;
|
|
81
|
+
|
|
82
|
+
type GuaranteeName = keyof typeof guaranteeTitles;
|
|
83
|
+
|
|
84
|
+
const tables = (host: Host, declared: Declared): Effect.Effect<Exit.Exit<readonly string[]>, Unexpected> => {
|
|
85
|
+
const appTable = Predicate.not(isHostTable(declared));
|
|
86
|
+
|
|
87
|
+
return Effect.map(
|
|
88
|
+
Effect.flatMap(ask(host, "tables"), (reply) => decodeReply(Ledger.queries.tables, reply)),
|
|
89
|
+
Exit.map(Arr.filter(appTable)),
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onEngine = <Failure, Success, UseFailure>(
|
|
94
|
+
storage: Storage<Failure>,
|
|
95
|
+
handlers: LedgerLayer,
|
|
96
|
+
use: (host: Host) => Effect.Effect<Success, UseFailure>,
|
|
97
|
+
): Effect.Effect<Success, Failure | UseFailure> => {
|
|
98
|
+
const engine = storage.pipe(Layer.provide(handlers));
|
|
99
|
+
const run = Effect.flatMap(Layer.build(engine), (services) => use(Context.get(services, ActorHost)));
|
|
100
|
+
|
|
101
|
+
return Effect.scoped(run);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const depositThenOverdraw = (host: Host): Effect.Effect<readonly [string, string], Unexpected> =>
|
|
105
|
+
Effect.all([send(host, "deposit", depositOpening), send(host, "withdraw", withdrawOverdraft)]);
|
|
106
|
+
|
|
107
|
+
const replyMeansCommitted = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
108
|
+
Effect.gen(function* reopenAfterReplies() {
|
|
109
|
+
yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
110
|
+
Effect.all([
|
|
111
|
+
send(host, "deposit", depositOpening),
|
|
112
|
+
send(host, "deposit", { commandId: "b", payload: amount(topUp) }),
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const seen = yield* onEngine(storage, declared.ledgers.live, (host) => Effect.all([account(host), amounts(host)]));
|
|
117
|
+
|
|
118
|
+
expect(seen).toStrictEqual([
|
|
119
|
+
Exit.succeed({ balance: opening + topUp, closed: false }),
|
|
120
|
+
Exit.succeed([opening, topUp]),
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const retriedCommandId = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
125
|
+
Effect.gen(function* retryAcrossReopens() {
|
|
126
|
+
const [first, retriedLive] = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
127
|
+
Effect.all([depositThenOverdraw(host), depositThenOverdraw(host)]),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const retriedReopened = yield* onEngine(storage, declared.ledgers.live, depositThenOverdraw);
|
|
131
|
+
const after = yield* onEngine(storage, declared.ledgers.live, (host) => Effect.all([account(host), amounts(host)]));
|
|
132
|
+
const [, retriedWithdraw] = retriedReopened;
|
|
133
|
+
const overdrawn = yield* decodeReply(Ledger.commands.withdraw, retriedWithdraw);
|
|
134
|
+
|
|
135
|
+
expect([retriedLive, retriedReopened]).toStrictEqual([first, first]);
|
|
136
|
+
expect(overdrawn).toStrictEqual(Exit.fail(overdrawnBy));
|
|
137
|
+
expect(after).toStrictEqual([Exit.succeed({ balance: opening, closed: false }), Exit.succeed([opening])]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const rollsBack = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
141
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
142
|
+
Effect.gen(function* failThenDeposit() {
|
|
143
|
+
yield* send(host, "deposit", depositOpening);
|
|
144
|
+
|
|
145
|
+
const withdrawn = yield* send(host, "withdraw", withdrawOverdraft);
|
|
146
|
+
const crashed = yield* send(host, "crash", { commandId: "c", payload: amount(lostInCrash) });
|
|
147
|
+
|
|
148
|
+
const failures = yield* Effect.all([
|
|
149
|
+
decodeReply(Ledger.commands.withdraw, withdrawn),
|
|
150
|
+
decodeReply(Ledger.commands.crash, crashed),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const afterFailures = yield* Effect.all([account(host), amounts(host)]);
|
|
154
|
+
|
|
155
|
+
yield* send(host, "deposit", { commandId: "b", payload: amount(refill) });
|
|
156
|
+
|
|
157
|
+
const afterDeposit = yield* account(host);
|
|
158
|
+
|
|
159
|
+
expect(failures).toStrictEqual([
|
|
160
|
+
Exit.fail(overdrawnBy),
|
|
161
|
+
Exit.die(new Error("the handler crashed after writing")),
|
|
162
|
+
]);
|
|
163
|
+
expect(afterFailures).toStrictEqual([Exit.succeed({ balance: opening, closed: false }), Exit.succeed([opening])]);
|
|
164
|
+
expect(afterDeposit).toStrictEqual(Exit.succeed({ balance: opening + refill, closed: false }));
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const callersBeforeHandler = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
169
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
170
|
+
Effect.gen(function* refuseThenAllow() {
|
|
171
|
+
const refused = yield* Effect.all([
|
|
172
|
+
isRefused(send(host, "close", { commandId: "c-user", payload: nothing })),
|
|
173
|
+
isRefused(send(host, "deposit", robotDeposit)),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
const untouched = yield* Effect.all([account(host), amounts(host)]);
|
|
177
|
+
|
|
178
|
+
yield* send(host, "close", { commandId: "c-service", payload: nothing, principal: billing });
|
|
179
|
+
|
|
180
|
+
const closed = yield* account(host);
|
|
181
|
+
|
|
182
|
+
expect(refused).toStrictEqual([true, true]);
|
|
183
|
+
expect(untouched).toStrictEqual([Exit.succeed({ balance: 0, closed: false }), Exit.succeed([])]);
|
|
184
|
+
expect(closed).toStrictEqual(Exit.succeed({ balance: 0, closed: true }));
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const fileRuleDecides = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
189
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
190
|
+
Effect.gen(function* decideFiles() {
|
|
191
|
+
const asked = (payload: string, principal: string): Effect.Effect<string, Schema.SchemaError> =>
|
|
192
|
+
fileDecision(host.query({ actor: "Ledger", key, member: "akter.files", payload, principal }));
|
|
193
|
+
|
|
194
|
+
const bobs = [yield* asked(reading, bob), yield* asked(writing, bob), yield* asked(forgedWriting, bob)];
|
|
195
|
+
const others = [yield* asked(writing, ada), yield* asked(reading, billing)];
|
|
196
|
+
|
|
197
|
+
yield* send(host, "close", { commandId: "c-files", payload: nothing, principal: billing });
|
|
198
|
+
|
|
199
|
+
expect([...bobs, ...others, yield* asked(writing, ada)]).toStrictEqual(fileDecisions);
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const migrationsOnOpen = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
204
|
+
Effect.gen(function* reopenWithNewMigration() {
|
|
205
|
+
const reserved = Option.getOrElse(declared.hostTables, () => akterTables);
|
|
206
|
+
|
|
207
|
+
const opened = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
208
|
+
Effect.tap(tables(host, declared), () => send(host, "deposit", depositOpening)),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const reopened = yield* onEngine(storage, declared.ledgers.withNotes, (host) =>
|
|
212
|
+
Effect.all([tables(host, declared), amounts(host)]),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const intruded = yield* onEngine(storage, declared.ledgers.intruding(reserved), (host) =>
|
|
216
|
+
Effect.exit(ask(host, "account")),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const after = yield* onEngine(storage, declared.ledgers.withNotes, (host) => tables(host, declared));
|
|
220
|
+
|
|
221
|
+
expect(opened).toStrictEqual(Exit.succeed(["entries"]));
|
|
222
|
+
expect(reopened).toStrictEqual([Exit.succeed(["entries", "notes"]), Exit.succeed([opening])]);
|
|
223
|
+
expect(Exit.match(intruded, { onFailure: () => "refused", onSuccess: () => "opened" })).toBe("refused");
|
|
224
|
+
expect(after).toStrictEqual(Exit.succeed(["entries", "notes"]));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const queriesNeverWrite = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
228
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
229
|
+
Effect.gen(function* writeInsideQuery() {
|
|
230
|
+
yield* send(host, "deposit", depositOpening);
|
|
231
|
+
|
|
232
|
+
const scribbled = yield* Effect.exit(
|
|
233
|
+
Effect.flatMap(ask(host, "scribble"), (reply) => decodeReply(Ledger.queries.scribble, reply)),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const after = yield* amounts(host);
|
|
237
|
+
|
|
238
|
+
expect([Exit.isSuccess(scribbled) && Exit.isSuccess(scribbled.value), after]).toStrictEqual([
|
|
239
|
+
false,
|
|
240
|
+
Exit.succeed([opening]),
|
|
241
|
+
]);
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const engineTablesOwned = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
246
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
247
|
+
Effect.gen(function* writeEngineTable() {
|
|
248
|
+
yield* send(host, "deposit", depositOpening);
|
|
249
|
+
const tampered = yield* Effect.exit(send(host, "tamper", { commandId: "t", payload: amount(topUp) }));
|
|
250
|
+
const refusal = Exit.match(tampered, { onFailure: Cause.pretty, onSuccess: Str.trim });
|
|
251
|
+
const after = yield* Effect.all([account(host), amounts(host)]);
|
|
252
|
+
|
|
253
|
+
expect([Str.includes("akter_state is engine-owned")(refusal), after]).toStrictEqual([
|
|
254
|
+
true,
|
|
255
|
+
[Exit.succeed({ balance: opening, closed: false }), Exit.succeed([opening])],
|
|
256
|
+
]);
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const liveAfterCommit = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
261
|
+
onEngine(storage, declared.ledgers.live, (host) =>
|
|
262
|
+
Effect.gen(function* watchCommits() {
|
|
263
|
+
const updates = yield* Queue.unbounded<string>();
|
|
264
|
+
const watched = host.subscribe({ actor: "Ledger", key, member: "account", payload: nothing, principal: ada });
|
|
265
|
+
|
|
266
|
+
yield* Effect.forkChild(Stream.runForEach(watched, (update) => Queue.offer(updates, update.value)));
|
|
267
|
+
|
|
268
|
+
const next = Effect.flatMap(Queue.take(updates), (value) => decodeReply(Ledger.queries.account, value));
|
|
269
|
+
const before = yield* next;
|
|
270
|
+
|
|
271
|
+
yield* Effect.all([
|
|
272
|
+
send(host, "deposit", depositOpening),
|
|
273
|
+
send(host, "withdraw", withdrawOverdraft),
|
|
274
|
+
send(host, "crash", { commandId: "c", payload: amount(lostInCrash) }),
|
|
275
|
+
send(host, "deposit", { commandId: "b", payload: amount(refill) }),
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const seen = yield* Effect.all([next, next]);
|
|
279
|
+
|
|
280
|
+
expect([before, ...seen]).toStrictEqual([
|
|
281
|
+
Exit.succeed({ balance: 0, closed: false }),
|
|
282
|
+
Exit.succeed({ balance: opening, closed: false }),
|
|
283
|
+
Exit.succeed({ balance: opening + refill, closed: false }),
|
|
284
|
+
]);
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const sentAfterCommit = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
289
|
+
Effect.gen(function* deliverAcrossReopen() {
|
|
290
|
+
const overdrawn = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
291
|
+
Effect.gen(function* transferTwice() {
|
|
292
|
+
yield* send(host, "deposit", depositOpening);
|
|
293
|
+
|
|
294
|
+
const refused = yield* send(host, "transfer", { commandId: "t-over", payload: amountTo(overdraft) });
|
|
295
|
+
|
|
296
|
+
yield* send(host, "transfer", { commandId: "t", payload: amountTo(transferred) });
|
|
297
|
+
|
|
298
|
+
return yield* decodeReply(Ledger.commands.transfer, refused);
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const delivered = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
303
|
+
Effect.gen(function* drainTwice() {
|
|
304
|
+
yield* account(host);
|
|
305
|
+
yield* host.drain;
|
|
306
|
+
|
|
307
|
+
const firstDrain = yield* Effect.all([account(host), amounts(host, payee)]);
|
|
308
|
+
|
|
309
|
+
yield* host.drain;
|
|
310
|
+
|
|
311
|
+
const drainedAgain = yield* amounts(host, payee);
|
|
312
|
+
|
|
313
|
+
return [...firstDrain, drainedAgain];
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(overdrawn).toStrictEqual(Exit.fail(overdrawnBy));
|
|
318
|
+
expect(delivered).toStrictEqual([
|
|
319
|
+
Exit.succeed({ balance: opening - transferred, closed: false }),
|
|
320
|
+
Exit.succeed([transferred]),
|
|
321
|
+
Exit.succeed([transferred]),
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const signalAfterCommit = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
326
|
+
Effect.gen(function* approveAcrossReopen() {
|
|
327
|
+
const [job, live] = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
328
|
+
Effect.gen(function* holdThenApprove() {
|
|
329
|
+
const id = yield* held(host, "h");
|
|
330
|
+
|
|
331
|
+
yield* approve(host, "refused", approving({ amount: lostInCrash, job: id, refuse: true }));
|
|
332
|
+
const afterRefusal = yield* drained(host);
|
|
333
|
+
|
|
334
|
+
yield* approve(host, "approved", approving({ amount: opening, job: id, refuse: false }));
|
|
335
|
+
|
|
336
|
+
return [id, [afterRefusal, yield* drained(host)]] as const;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const reopened = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
341
|
+
Effect.gen(function* retryThenApproveLate() {
|
|
342
|
+
yield* approve(host, "approved", approving({ amount: opening, job, refuse: false }));
|
|
343
|
+
const afterRetry = yield* drained(host);
|
|
344
|
+
|
|
345
|
+
yield* approve(host, "late", approving({ amount: topUp, job, refuse: false }));
|
|
346
|
+
|
|
347
|
+
return [afterRetry, yield* drained(host)];
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
expect([...live, ...reopened]).toStrictEqual([
|
|
352
|
+
Exit.succeed([]),
|
|
353
|
+
Exit.succeed([opening]),
|
|
354
|
+
Exit.succeed([opening]),
|
|
355
|
+
Exit.succeed([opening]),
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const patchedJobs = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
360
|
+
Effect.gen(function* deployOverHeldJob() {
|
|
361
|
+
const before = yield* onEngine(storage, declared.ledgers.live, (host) => held(host, "h-before"));
|
|
362
|
+
|
|
363
|
+
const released = yield* onEngine(storage, declared.ledgers.patched, (host) =>
|
|
364
|
+
Effect.gen(function* approveBothHolds() {
|
|
365
|
+
yield* approve(host, "a-before", approving({ amount: opening, job: before, refuse: false }));
|
|
366
|
+
const afterOld = yield* drained(host);
|
|
367
|
+
|
|
368
|
+
const after = yield* held(host, "h-after");
|
|
369
|
+
yield* approve(host, "a-after", approving({ amount: topUp, job: after, refuse: false }));
|
|
370
|
+
|
|
371
|
+
return [afterOld, yield* drained(host)];
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(released).toStrictEqual([Exit.succeed([opening]), Exit.succeed([opening, topUpDoubled])]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const advance = (host: Host, elapsed: Duration.Input): ReturnType<typeof account> =>
|
|
379
|
+
Effect.gen(function* advanceAndDrain() {
|
|
380
|
+
yield* TestClock.adjust(elapsed);
|
|
381
|
+
yield* account(host);
|
|
382
|
+
yield* host.drain;
|
|
383
|
+
|
|
384
|
+
return yield* account(host);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const timerFiresOnce = <Failure>(storage: Storage<Failure>, declared: Declared): Checked<Failure> =>
|
|
388
|
+
Effect.gen(function* fireAcrossReopen() {
|
|
389
|
+
const early = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
390
|
+
Effect.andThen(
|
|
391
|
+
send(host, "depositLater", { commandId: "l", payload: amountLater(opening) }),
|
|
392
|
+
advance(host, beforeDue),
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const later = yield* onEngine(storage, declared.ledgers.live, (host) =>
|
|
397
|
+
Effect.all([advance(host, untilDue), advance(host, longAfterDue), amounts(host)]),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect([early, ...later]).toStrictEqual([
|
|
401
|
+
Exit.succeed({ balance: 0, closed: false }),
|
|
402
|
+
Exit.succeed({ balance: opening, closed: false }),
|
|
403
|
+
Exit.succeed({ balance: opening, closed: false }),
|
|
404
|
+
Exit.succeed([opening]),
|
|
405
|
+
]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const onFresh = <Failure, Services>(
|
|
409
|
+
engine: Engine<Failure, Services>,
|
|
410
|
+
guarantee: (storage: Storage<Failure>) => Effect.Effect<void, Failure | Unexpected>,
|
|
411
|
+
): Effect.Effect<void, Failure | Unexpected, Services> => Effect.scoped(Effect.flatMap(engine, guarantee));
|
|
412
|
+
|
|
413
|
+
const guarantees = {
|
|
414
|
+
callersBeforeHandler,
|
|
415
|
+
engineTablesOwned,
|
|
416
|
+
fileRuleDecides,
|
|
417
|
+
liveAfterCommit,
|
|
418
|
+
migrationsOnOpen,
|
|
419
|
+
patchedJobs,
|
|
420
|
+
queriesNeverWrite,
|
|
421
|
+
replyMeansCommitted,
|
|
422
|
+
retriedCommandId,
|
|
423
|
+
rollsBack,
|
|
424
|
+
sentAfterCommit,
|
|
425
|
+
signalAfterCommit,
|
|
426
|
+
timerFiresOnce,
|
|
427
|
+
} as const satisfies Record<GuaranteeName, Guarantee<never>>;
|
|
428
|
+
|
|
429
|
+
/** Every guarantee, by name. */
|
|
430
|
+
const everyGuarantee: readonly GuaranteeName[] = Rec.keys(guarantees);
|
|
431
|
+
|
|
432
|
+
interface Skipping<Failure, Services> {
|
|
433
|
+
readonly engine: Engine<Failure, Services>;
|
|
434
|
+
readonly hostTables?: string;
|
|
435
|
+
/** The Ledger implementation to run: the Effect SDK's by default, or the plain SDK's from `@akterjs/conformance/plain`. */
|
|
436
|
+
readonly ledgers?: Ledgers;
|
|
437
|
+
readonly skip: Readonly<Partial<Record<GuaranteeName, string>>>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const conformanceOf =
|
|
441
|
+
<Failure, Services>({
|
|
442
|
+
engine,
|
|
443
|
+
hostTables,
|
|
444
|
+
ledgers = effectLedgers,
|
|
445
|
+
skip,
|
|
446
|
+
}: Skipping<Failure, Services>): ((test: Vitest.MethodsNonLive<Services>) => void) =>
|
|
447
|
+
(test) => {
|
|
448
|
+
const declared: Declared = { hostTables: Option.fromNullishOr(hostTables), ledgers };
|
|
449
|
+
const refused = Option.flatMap(declared.hostTables, refusedPrefix);
|
|
450
|
+
|
|
451
|
+
if (Option.isSome(refused)) {
|
|
452
|
+
test.effect(`the engine's host table prefix "${String(hostTables)}" is refused: ${refused.value}`, () =>
|
|
453
|
+
Effect.die(refused.value),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (const name of everyGuarantee) {
|
|
460
|
+
const title = guaranteeTitles[name];
|
|
461
|
+
|
|
462
|
+
const run = (): Effect.Effect<void, Failure | Unexpected, Services> =>
|
|
463
|
+
onFresh<Failure, Services>(engine, (storage) => guarantees[name](storage, declared));
|
|
464
|
+
|
|
465
|
+
Option.match(Option.fromNullishOr(skip[name]), {
|
|
466
|
+
onNone: () => {
|
|
467
|
+
test.effect(title, run);
|
|
468
|
+
},
|
|
469
|
+
onSome: (reason) => {
|
|
470
|
+
test.effect.skip(`${title} (skipped: ${reason})`, run);
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const conformance = <Failure, Services>(
|
|
477
|
+
engine: Engine<Failure, Services>,
|
|
478
|
+
): ((test: Vitest.MethodsNonLive<Services>) => void) => conformanceOf({ engine, skip: {} });
|
|
479
|
+
|
|
480
|
+
export { conformance, conformanceOf };
|
|
481
|
+
|
|
482
|
+
export type { Engine, GuaranteeName, Storage };
|
|
483
|
+
|
|
484
|
+
export type { Ledgers } from "./ledger/ledger.actor.js";
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { ActorHost } from "@akterjs/effect";
|
|
2
|
+
import { expect } from "@effect/vitest";
|
|
3
|
+
import type { Vitest } from "@effect/vitest";
|
|
4
|
+
import { Clock, Context, Effect, Exit, Layer, Option, Record as Rec } from "effect";
|
|
5
|
+
import { TestClock } from "effect/testing";
|
|
6
|
+
|
|
7
|
+
import type { Engine, Storage } from "#src/engine-conformance.suite.js";
|
|
8
|
+
import { forkGuaranteeTitles } from "#src/guarantee-titles.pure.js";
|
|
9
|
+
import { effectLedgers } from "#src/ledger/ledger.actor.js";
|
|
10
|
+
import {
|
|
11
|
+
amount,
|
|
12
|
+
amountLater,
|
|
13
|
+
depositOpening,
|
|
14
|
+
forked,
|
|
15
|
+
ledgerHost,
|
|
16
|
+
longAfterDue,
|
|
17
|
+
opening,
|
|
18
|
+
refill,
|
|
19
|
+
topUp,
|
|
20
|
+
} from "#src/ledger/ledger-calls.pure.js";
|
|
21
|
+
import type { Host, Unexpected } from "#src/ledger/ledger-calls.pure.js";
|
|
22
|
+
import { WorkbenchLive } from "#src/workbench/workbench.actor.js";
|
|
23
|
+
import { bench, ensure, forkedBench, get, list, stop, workbenchHost } from "#src/workbench/workbench-calls.pure.js";
|
|
24
|
+
|
|
25
|
+
const { account, amounts, fork, send } = ledgerHost;
|
|
26
|
+
|
|
27
|
+
type Checked<Failure> = Effect.Effect<void, Failure | Unexpected>;
|
|
28
|
+
|
|
29
|
+
const onEngine = <Failure, Success>(
|
|
30
|
+
storage: Storage<Failure>,
|
|
31
|
+
use: (host: Host) => Effect.Effect<Success, Unexpected>,
|
|
32
|
+
): Effect.Effect<Success, Failure | Unexpected> => {
|
|
33
|
+
const engine = storage.pipe(Layer.provide(effectLedgers.live));
|
|
34
|
+
const run = Effect.flatMap(Layer.build(engine), (services) => use(Context.get(services, ActorHost)));
|
|
35
|
+
|
|
36
|
+
return Effect.scoped(run);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Both accounts: the parent `acct-1`, then the fork `acct-5`, each with its balance and its entries. */
|
|
40
|
+
const both = (host: Host): Effect.Effect<readonly unknown[], Unexpected> =>
|
|
41
|
+
Effect.all([account(host), amounts(host), account(host, forked), amounts(host, forked)]);
|
|
42
|
+
|
|
43
|
+
const startsQuiet = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
44
|
+
Effect.gen(function* forkThenDiverge() {
|
|
45
|
+
const [cut, retried, apart] = yield* onEngine(storage, (host) =>
|
|
46
|
+
Effect.gen(function* forkWithATimerPending() {
|
|
47
|
+
yield* send(host, "deposit", depositOpening);
|
|
48
|
+
yield* send(host, "depositLater", { commandId: "l", payload: amountLater(opening) });
|
|
49
|
+
|
|
50
|
+
const version = yield* fork(host, "f");
|
|
51
|
+
|
|
52
|
+
yield* send(host, "deposit", { commandId: "b", payload: amount(topUp) });
|
|
53
|
+
yield* send(host, "deposit", { account: forked, commandId: "c", payload: amount(refill) });
|
|
54
|
+
|
|
55
|
+
const again = yield* fork(host, "f");
|
|
56
|
+
|
|
57
|
+
yield* TestClock.adjust(longAfterDue);
|
|
58
|
+
yield* account(host);
|
|
59
|
+
yield* host.drain;
|
|
60
|
+
|
|
61
|
+
return [version, again, yield* both(host)] as const;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const reopened = yield* onEngine(storage, both);
|
|
66
|
+
|
|
67
|
+
const settled = [
|
|
68
|
+
Exit.succeed({ balance: opening + topUp + opening, closed: false }),
|
|
69
|
+
Exit.succeed([opening, topUp, opening]),
|
|
70
|
+
Exit.succeed({ balance: opening + refill, closed: false }),
|
|
71
|
+
Exit.succeed([opening, refill]),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
expect(retried).toBe(cut);
|
|
75
|
+
expect([apart, reopened]).toStrictEqual([settled, settled]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const { exec, ran, ranAt } = workbenchHost;
|
|
79
|
+
|
|
80
|
+
/** Runs `use` on the Workbench on the real clock: a computer's calls wait on real processes and machines. */
|
|
81
|
+
const onBench = <Failure, Success>(
|
|
82
|
+
storage: Storage<Failure>,
|
|
83
|
+
use: (host: Host) => Effect.Effect<Success, Unexpected>,
|
|
84
|
+
): Effect.Effect<Success, Failure | Unexpected> => {
|
|
85
|
+
const engine = storage.pipe(Layer.provide(WorkbenchLive));
|
|
86
|
+
const run = Effect.flatMap(Layer.build(engine), (services) => use(Context.get(services, ActorHost)));
|
|
87
|
+
|
|
88
|
+
return Effect.provideService(Effect.scoped(run), Clock.Clock, Clock.Clock.defaultValue());
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const forkBench = (host: Host, commandId: string): ReturnType<Host["fork"]> =>
|
|
92
|
+
host.fork({ actor: "Workbench", commandId, into: forkedBench, key: bench });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The Workbench with a running `box` that wrote `notes` and a stopped `idle`, forked twice under one fork id; then the
|
|
96
|
+
* original writes again, and the copy reads its `notes` and writes to them. Each exec has its own key, since a copy
|
|
97
|
+
* holds the original's recorded steps too.
|
|
98
|
+
*/
|
|
99
|
+
/** What each step of the computers guarantee answered: the runs' outcomes and the fork's version, twice. */
|
|
100
|
+
interface ForkedComputers {
|
|
101
|
+
readonly after: readonly string[];
|
|
102
|
+
readonly copy: readonly string[];
|
|
103
|
+
readonly cut: number;
|
|
104
|
+
readonly made: readonly string[];
|
|
105
|
+
readonly original: readonly string[];
|
|
106
|
+
readonly retried: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const forkedWithComputers = (host: Host): Effect.Effect<ForkedComputers, Unexpected> =>
|
|
110
|
+
Effect.gen(function* forkTwiceThenDiverge() {
|
|
111
|
+
const made = yield* ran(host, "fc-1", [
|
|
112
|
+
ensure("box"),
|
|
113
|
+
exec("box", "write", "echo at-cut > notes"),
|
|
114
|
+
ensure("idle"),
|
|
115
|
+
stop("idle"),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const cut = yield* forkBench(host, "fc");
|
|
119
|
+
const retried = yield* forkBench(host, "fc");
|
|
120
|
+
const original = yield* ran(host, "fc-2", [get("box"), get("idle"), exec("box", "later", "echo later >> notes")]);
|
|
121
|
+
|
|
122
|
+
const copy = yield* ranAt(host, forkedBench, {
|
|
123
|
+
commandId: "fc-3",
|
|
124
|
+
ops: [
|
|
125
|
+
list,
|
|
126
|
+
get("box"),
|
|
127
|
+
get("idle"),
|
|
128
|
+
exec("box", "copy-read", "cat notes"),
|
|
129
|
+
exec("box", "copy-write", "echo copy >> notes"),
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const after = yield* ran(host, "fc-4", [exec("box", "reread", "cat notes")]);
|
|
134
|
+
|
|
135
|
+
return { after, copy, cut, made, original, retried };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const copiesComputers = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
139
|
+
Effect.gen(function* forkWithComputers() {
|
|
140
|
+
const { cut, retried, ...outcomes } = yield* onBench(storage, forkedWithComputers);
|
|
141
|
+
|
|
142
|
+
expect(retried).toBe(cut);
|
|
143
|
+
expect(outcomes).toStrictEqual({
|
|
144
|
+
after: ["exit 0: at-cut\nlater\n"],
|
|
145
|
+
copy: ["[box,idle]", "stopped", "stopped", "exit 0: at-cut\n", "exit 0: "],
|
|
146
|
+
made: ["up", "exit 0: ", "up", "stopped"],
|
|
147
|
+
original: ["up", "stopped", "exit 0: "],
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
type ForkGuaranteeName = keyof typeof forkGuaranteeTitles;
|
|
152
|
+
|
|
153
|
+
type Guarantee = <Failure>(storage: Storage<Failure>) => Checked<Failure>;
|
|
154
|
+
|
|
155
|
+
/** The fork guarantees, by name; each runs the Ledger on a fresh store. */
|
|
156
|
+
const forkGuarantees = {
|
|
157
|
+
forkCopiesComputers: copiesComputers,
|
|
158
|
+
forkStartsQuiet: startsQuiet,
|
|
159
|
+
} as const satisfies Record<ForkGuaranteeName, Guarantee>;
|
|
160
|
+
|
|
161
|
+
const everyForkGuarantee: readonly ForkGuaranteeName[] = Rec.keys(forkGuarantees);
|
|
162
|
+
|
|
163
|
+
interface ForkSkipping<Failure, Services> {
|
|
164
|
+
/**
|
|
165
|
+
* An engine that serves `Ledger/acct-1` and `Ledger/acct-5`, a key the suite forks it into, and `Workbench/bench-1`
|
|
166
|
+
* and `Workbench/bench-250`, with their computers bound to the job runtime's `Actor.computer(key)` and reached by the
|
|
167
|
+
* engine's fork. An engine that places an actor by its key must place each fork's key on its parent's shard: Akter
|
|
168
|
+
* Cloud forks only within a shard.
|
|
169
|
+
*/
|
|
170
|
+
readonly engine: Engine<Failure, Services>;
|
|
171
|
+
readonly skip: Readonly<Partial<Record<ForkGuaranteeName, string>>>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Registers every fork guarantee against `engine`, each on fresh storage, skipping only those `skip` names with a
|
|
176
|
+
* reason. A guarantee added later runs on every engine that registers the suite.
|
|
177
|
+
*/
|
|
178
|
+
const forkConformanceOf =
|
|
179
|
+
<Failure, Services>({
|
|
180
|
+
engine,
|
|
181
|
+
skip,
|
|
182
|
+
}: ForkSkipping<Failure, Services>): ((test: Vitest.MethodsNonLive<Services>) => void) =>
|
|
183
|
+
(test) => {
|
|
184
|
+
for (const name of everyForkGuarantee) {
|
|
185
|
+
const title = forkGuaranteeTitles[name];
|
|
186
|
+
const guarantee: Guarantee = forkGuarantees[name];
|
|
187
|
+
|
|
188
|
+
const run = (): Effect.Effect<void, Failure | Unexpected, Services> =>
|
|
189
|
+
Effect.scoped(Effect.flatMap(engine, (storage) => guarantee(storage)));
|
|
190
|
+
|
|
191
|
+
Option.match(Option.fromNullishOr(skip[name]), {
|
|
192
|
+
onNone: () => {
|
|
193
|
+
test.effect(title, run);
|
|
194
|
+
},
|
|
195
|
+
onSome: (reason) => {
|
|
196
|
+
test.effect.skip(`${title} (skipped: ${reason})`, run);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export { forkConformanceOf };
|
|
203
|
+
|
|
204
|
+
export type { ForkGuaranteeName };
|