@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,50 @@
1
+ const guaranteeTitles = {
2
+ callersBeforeHandler: "caller checks run before the handler: a caller the command doesn't allow changes nothing",
3
+ engineTablesOwned:
4
+ "a handler may read akter_* tables but never write them: the write fails, names the table and changes nothing",
5
+ fileRuleDecides:
6
+ "a file call is decided by the actor's rule from the verified caller and state: a kind it doesn't declare is refused, and a payload naming another caller changes nothing",
7
+ liveAfterCommit:
8
+ "live queries update only after a commit: a subscriber sees each committed change and nothing from a rolled-back command",
9
+ migrationsOnOpen:
10
+ "migrations run when an actor opens, before its first call, and a migration added later runs on the next open",
11
+ patchedJobs:
12
+ "a job keeps the code its start declared: after a deploy adds a patch, a job started before it takes the old path and one started after takes the new",
13
+ queriesNeverWrite: "queries never write: a write inside a query fails and leaves the actor unchanged",
14
+ replyMeansCommitted:
15
+ "a reply means the command committed: an engine reopened on the same storage sees every replied command",
16
+ retriedCommandId: "a retried command id returns the recorded outcome and runs the handler once",
17
+ rollsBack: "a handler failure rolls back every write the command made",
18
+ sentAfterCommit:
19
+ "a message a command sends is delivered after the command commits, at least once, and a rolled-back command sends none",
20
+ signalAfterCommit:
21
+ "a command's signal reaches its own actor's job after the command commits, once: a rolled-back command signals nothing and a finished job ignores it",
22
+ timerFiresOnce: "a timer fires once at its due time, and not again after the engine restarts",
23
+ } as const;
24
+
25
+ /** The computer guarantees' titles, which `@akterjs/conformance/computers` registers. */
26
+ const computerGuaranteeTitles = {
27
+ computerHeldStaysPaused:
28
+ "a held computer stays paused, or stopped if it was stopped: an ensure, a step, and a resume or pause without its hold are refused until its hold resumes it",
29
+ computerKeepsFiles: "a computer keeps its files across pause, resume, stop and an engine restart",
30
+ computerLimitHolds:
31
+ "a computer past the store's limit is refused with ComputerLimitError and makes nothing, and one fits again once another is removed",
32
+ computerRemovedIsGone:
33
+ "a removed computer is gone with its disk: calls to it fail with ComputerNotFoundError, and ensuring it again starts it empty",
34
+ computerStepJournaled:
35
+ "a durable computer step journals its start and outcome in the actor, and its steps read back newest first, one with its output",
36
+ computerStepRunsOnce:
37
+ "a computer step runs once by its key: sent again, even after the engine restarts, it reads back the outcome it recorded",
38
+ computerStepSurvivesRestart:
39
+ "a durable computer step cut off by an engine restart never runs again: its replayed job settles it with its recorded outcome or unknown",
40
+ } as const;
41
+
42
+ /** The fork guarantees' titles, which `@akterjs/conformance/fork` registers. */
43
+ const forkGuaranteeTitles = {
44
+ forkCopiesComputers:
45
+ "a fork copies each of the actor's computers by its disk at the cut, stopped: the copy holds the files written before the cut, the original's computers go back to where they were, and the two change independently",
46
+ forkStartsQuiet:
47
+ "a fork copies an actor as committed at its cut and starts quiet: the copy keeps no pending timer, the two change independently, and a retried fork id makes one copy",
48
+ } as const;
49
+
50
+ export { computerGuaranteeTitles, forkGuaranteeTitles, guaranteeTitles };
@@ -0,0 +1,179 @@
1
+ import type { ActorHost } from "@akterjs/effect";
2
+ import { Array as Arr, Effect, Exit } from "effect";
3
+ import type { Duration, Schema } from "effect";
4
+
5
+ import { decodeReply } from "./ledger-checks.pure.js";
6
+ import { Cents, Ledger, OverdrawnError } from "./ledger.contract.js";
7
+ import type { Entry } from "./ledger.contract.js";
8
+
9
+ /** The callers, keys, amounts and payloads the engine conformance suite sends the Ledger, as the JSON the wire carries. */
10
+ interface Sent {
11
+ readonly account?: string;
12
+ readonly commandId: string;
13
+ readonly payload: string;
14
+ readonly principal?: string;
15
+ }
16
+
17
+ const ada = '{"kind":"user","principal":{"id":"ada"}}';
18
+
19
+ const billing = '{"kind":"service","principal":{"id":"billing"}}';
20
+
21
+ const bob = '{"kind":"user","principal":{"id":"bob"}}';
22
+
23
+ /** The one user the Ledger's file rule lets write. */
24
+ const fileWriter = "ada";
25
+
26
+ /** `akter.files` payloads: a read, an upload, and an upload whose payload claims to be ada's. */
27
+ const reading = '{"access":"read","path":"statements/may.pdf"}';
28
+
29
+ const writing = '{"access":"write","path":""}';
30
+
31
+ /** What the rule decides for bob's read, write and forged write, ada's write, billing's read, then ada's write once closed. */
32
+ const fileDecisions = ["allowed", "denied", "denied", "allowed", "CallerNotAllowedError", "denied"];
33
+
34
+ const forgedWriting = '{"access":"write","path":"","principal":{"kind":"user","principal":{"id":"ada"}}}';
35
+
36
+ const robot = '{"kind":"robot","principal":{"id":"r2"}}';
37
+
38
+ const key = "acct-1";
39
+
40
+ const payee = "acct-2";
41
+
42
+ /**
43
+ * The key a fork of `acct-1` goes into. Akter Cloud forks only into a key on the parent's own shard, and `acct-5` hashes
44
+ * to `acct-1`'s shard, so an engine that serves one serves the other.
45
+ */
46
+ const forked = "acct-5";
47
+
48
+ const nothing = "null";
49
+
50
+ const opening = 5;
51
+
52
+ const topUp = 7;
53
+
54
+ /** `topUp` as the patched build releases it for a hold it started: twice over. */
55
+ const topUpDoubled = 14;
56
+
57
+ const overdraft = 9;
58
+
59
+ const lostInCrash = 3;
60
+
61
+ const refill = 1;
62
+
63
+ const transferred = 2;
64
+
65
+ const dueMinutes = 5;
66
+
67
+ const beforeDue: Duration.Input = "4 minutes";
68
+
69
+ const untilDue: Duration.Input = "1 minute";
70
+
71
+ const longAfterDue: Duration.Input = "10 minutes";
72
+
73
+ const amountLater = (value: number): string => `{"amount":${value},"minutes":${dueMinutes}}`;
74
+
75
+ const amountTo = (value: number): string => `{"amount":${value},"to":"${payee}"}`;
76
+
77
+ const amount = (value: number): string => `{"amount":${value}}`;
78
+
79
+ const overdrawnBy = OverdrawnError.make({ balance: Cents.make(opening - overdraft) });
80
+
81
+ const depositOpening: Sent = { commandId: "a", payload: amount(opening) };
82
+
83
+ const withdrawOverdraft: Sent = { commandId: "w", payload: amount(overdraft) };
84
+
85
+ const robotDeposit: Sent = { commandId: "d-robot", payload: amount(opening), principal: robot };
86
+
87
+ interface Approving {
88
+ readonly amount: number;
89
+ readonly job: string;
90
+ readonly refuse: boolean;
91
+ }
92
+
93
+ const approving = ({ amount: value, job, refuse }: Approving): string =>
94
+ `{"amount":${value},"job":"${job}","refuse":${refuse}}`;
95
+
96
+ /** How the engine conformance suite calls the Ledger on an engine: commands and queries as JSON, replies decoded. */
97
+ type Host = ActorHost["Service"];
98
+
99
+ type Reply = ReturnType<Host["command"]>;
100
+
101
+ type Unexpected = Effect.Error<Reply> | Effect.Error<ReturnType<Host["fork"]>> | Schema.SchemaError;
102
+
103
+ const send = (host: Host, member: string, { account = key, commandId, payload, principal = ada }: Sent): Reply =>
104
+ host.command({ actor: "Ledger", commandId, key: account, member, payload, principal });
105
+
106
+ const ask = (host: Host, member: string, holder = key): Reply =>
107
+ host.query({ actor: "Ledger", key: holder, member, payload: nothing, principal: ada });
108
+
109
+ const account = (
110
+ host: Host,
111
+ holder = key,
112
+ ): Effect.Effect<Exit.Exit<typeof Ledger.queries.account.success.Type>, Unexpected> =>
113
+ Effect.flatMap(ask(host, "account", holder), (reply) => decodeReply(Ledger.queries.account, reply));
114
+
115
+ /** Forks `acct-1` into `into`, which is `forked` by default, and answers the parent's version at the cut. */
116
+ const fork = (host: Host, commandId: string, into = forked): ReturnType<Host["fork"]> =>
117
+ host.fork({ actor: "Ledger", commandId, into, key });
118
+
119
+ const amounts = (host: Host, holder = key): Effect.Effect<Exit.Exit<readonly number[]>, Unexpected> =>
120
+ Effect.map(
121
+ Effect.flatMap(ask(host, "entries", holder), (reply) => decodeReply(Ledger.queries.entries, reply)),
122
+ Exit.map(Arr.map((entry: Entry) => entry.amount)),
123
+ );
124
+
125
+ const approve = (host: Host, commandId: string, payload: string): Reply =>
126
+ send(host, "approve", { commandId, payload });
127
+
128
+ const drained = (host: Host): ReturnType<typeof amounts> =>
129
+ Effect.gen(function* drainThenRead() {
130
+ yield* account(host);
131
+ yield* host.drain;
132
+
133
+ return yield* amounts(host);
134
+ });
135
+
136
+ /** Holds funds: starts a `release` job and returns its id. */
137
+ const held = (host: Host, commandId: string): Effect.Effect<string, Unexpected> =>
138
+ Effect.flatMap(send(host, "hold", { commandId, payload: nothing }), (reply) =>
139
+ Effect.flatten(decodeReply(Ledger.commands.hold, reply)),
140
+ );
141
+
142
+ /** The calls, as one namespace: `send`, `ask`, `approve` and `held` take the host first. */
143
+ const ledgerHost = { account, amounts, approve, ask, drained, fork, held, send };
144
+
145
+ export {
146
+ ada,
147
+ amount,
148
+ amountLater,
149
+ amountTo,
150
+ approving,
151
+ beforeDue,
152
+ billing,
153
+ bob,
154
+ depositOpening,
155
+ fileDecisions,
156
+ fileWriter,
157
+ forgedWriting,
158
+ forked,
159
+ key,
160
+ ledgerHost,
161
+ lostInCrash,
162
+ longAfterDue,
163
+ nothing,
164
+ opening,
165
+ overdraft,
166
+ overdrawnBy,
167
+ payee,
168
+ reading,
169
+ refill,
170
+ robotDeposit,
171
+ topUp,
172
+ topUpDoubled,
173
+ transferred,
174
+ untilDue,
175
+ withdrawOverdraft,
176
+ writing,
177
+ };
178
+
179
+ export type { Host, Reply, Sent, Unexpected };
@@ -0,0 +1,84 @@
1
+ import { FileAccessDeniedError } from "@akterjs/effect/contract/errors";
2
+ import { Effect, Exit, Option, Predicate, Schema, String as Str } from "effect";
3
+ import { dual } from "effect/Function";
4
+
5
+ /** Checks the engine conformance suite applies to replies, table names and an engine's declared host table prefix. */
6
+ const akterTables = "akter_";
7
+
8
+ const isHostTable =
9
+ ({ hostTables }: { readonly hostTables: Option.Option<string> }) =>
10
+ (name: string): boolean => {
11
+ const lowered = Str.toLowerCase(name);
12
+ const prefix = Option.map(hostTables, Str.toLowerCase);
13
+
14
+ return Option.exists(prefix, (lower) => Str.startsWith(lower)(lowered));
15
+ };
16
+
17
+ const isRefused = <Failure>(reply: Effect.Effect<string, Failure>): Effect.Effect<boolean> =>
18
+ Effect.match(reply, {
19
+ onFailure: (failure) => Predicate.isTagged(failure, "CallerNotAllowedError"),
20
+ onSuccess: () => false,
21
+ });
22
+
23
+ interface Declaration<Success, SuccessEncoded, Failure, FailureEncoded> {
24
+ readonly error: Schema.Codec<Failure, FailureEncoded>;
25
+ readonly success: Schema.Codec<Success, SuccessEncoded>;
26
+ }
27
+
28
+ type Decoded<Success, Failure> = Effect.Effect<Exit.Exit<Success, Failure>, Schema.SchemaError>;
29
+
30
+ const replyArity = 2;
31
+
32
+ /** Decodes a member's reply, as the engine encodes it, to its exit. */
33
+ const decodeReply: {
34
+ (
35
+ reply: string,
36
+ ): <Success, SuccessEncoded, Failure, FailureEncoded>(
37
+ declaration: Declaration<Success, SuccessEncoded, Failure, FailureEncoded>,
38
+ ) => Decoded<Success, Failure>;
39
+ <Success, SuccessEncoded, Failure, FailureEncoded>(
40
+ declaration: Declaration<Success, SuccessEncoded, Failure, FailureEncoded>,
41
+ reply: string,
42
+ ): Decoded<Success, Failure>;
43
+ } = dual(
44
+ replyArity,
45
+ <Success, SuccessEncoded, Failure, FailureEncoded>(
46
+ declaration: Declaration<Success, SuccessEncoded, Failure, FailureEncoded>,
47
+ reply: string,
48
+ ): Decoded<Success, Failure> => {
49
+ const exit = Schema.Exit(declaration.success, declaration.error, Schema.Defect());
50
+
51
+ return Schema.decodeEffect(Schema.fromJsonString(Schema.toCodecJson(exit)))(reply);
52
+ },
53
+ );
54
+
55
+ /** What an actor's file rule decided for one `akter.files` query: `allowed`, `denied`, or the caller's refusal. */
56
+ const fileDecision = <Failure>(reply: Effect.Effect<string, Failure>): Effect.Effect<string, Schema.SchemaError> =>
57
+ Effect.matchEffect(reply, {
58
+ // A refused call reads as the engine's caller refusal by its tag, anything else as `failed`.
59
+ onFailure: (failure) =>
60
+ Option.liftPredicate(failure, Predicate.isTagged("CallerNotAllowedError")).pipe(
61
+ Option.as("CallerNotAllowedError"),
62
+ Option.getOrElse(() => "failed"),
63
+ Effect.succeed,
64
+ ),
65
+ onSuccess: (text) =>
66
+ Effect.map(
67
+ decodeReply({ error: FileAccessDeniedError, success: Schema.Void }, text),
68
+ Exit.match({ onFailure: () => "denied", onSuccess: () => "allowed" }),
69
+ ),
70
+ });
71
+
72
+ const refusedPrefix = (prefix: string): Option.Option<string> => {
73
+ if (Str.isEmpty(prefix)) {
74
+ return Option.some("an empty prefix would match every table");
75
+ }
76
+
77
+ if (Str.toLowerCase(prefix) === akterTables) {
78
+ return Option.some(`${akterTables} is Akter's own prefix`);
79
+ }
80
+
81
+ return Option.none();
82
+ };
83
+
84
+ export { akterTables, decodeReply, fileDecision, isHostTable, isRefused, refusedPrefix };
@@ -0,0 +1,196 @@
1
+ import { Akter as Engine } from "@akterjs/effect";
2
+ import { Akter } from "@akterjs/effect/contract";
3
+ import { FileAccessDeniedError } from "@akterjs/effect/contract/errors";
4
+ import type { Migrations } from "@akterjs/effect/contract";
5
+ import { Array as Arr, Duration, Effect, Exit, Layer } from "effect";
6
+ import { DurableDeferred } from "effect/workflow";
7
+
8
+ import { fileWriter } from "./ledger-calls.pure.js";
9
+ import { Cents, EntryId, Ledger, OverdrawnError, PatchedLedger, doubled } from "./ledger.contract.js";
10
+ import type { Account, Entry } from "./ledger.contract.js";
11
+
12
+ interface TableName {
13
+ readonly name: string;
14
+ }
15
+
16
+ const lowestBalance = 0;
17
+
18
+ const entriesVersion = 1;
19
+
20
+ const notesVersion = 2;
21
+
22
+ const reservedVersion = 3;
23
+
24
+ const record = Effect.fnUntraced(function* record(balance: Cents, amount: Cents) {
25
+ const minted = yield* Akter.id;
26
+ const id = EntryId.make(minted);
27
+ const sql = yield* Akter.sql;
28
+
29
+ yield* sql`INSERT INTO entries ${sql.insert({ amount, id })}`;
30
+ yield* Ledger.update({ balance });
31
+
32
+ return id;
33
+ });
34
+
35
+ const withdrawal = Effect.fnUntraced(function* withdrawal(ledger: Account, amount: Cents) {
36
+ const balance = Cents.make(ledger.balance - amount);
37
+ const id = yield* record(balance, amount);
38
+
39
+ if (balance < lowestBalance) {
40
+ return yield* OverdrawnError.make({ balance });
41
+ }
42
+
43
+ return id;
44
+ });
45
+
46
+ /** Writes an entry, reads the engine's journal, then writes the engine's state table, which no handler may do. */
47
+ const tamper = Effect.fnUntraced(function* tamper(balance: Cents, amount: Cents) {
48
+ yield* record(balance, amount);
49
+ const sql = yield* Akter.sql;
50
+
51
+ yield* sql`SELECT count(*) AS commands FROM akter_commands`;
52
+ yield* sql`UPDATE akter_state SET value = value`;
53
+ });
54
+
55
+ /** The amount a person approves for release: `hold` starts a job that waits for it, and `approve` signals it. */
56
+ const approval = DurableDeferred.make("approval", { success: Cents });
57
+
58
+ /** Signals the held job, then fails after the signal when `refuse` is set, so the command rolls back with it. */
59
+ const refusal = Effect.die(new Error("the approval was refused after signalling"));
60
+
61
+ const approve = (amount: Cents, job: string, refuse: boolean): ReturnType<typeof Akter.signal> => {
62
+ const signalled = Akter.signal(approval, { job, value: amount });
63
+
64
+ if (refuse) {
65
+ return Effect.andThen(signalled, refusal);
66
+ }
67
+
68
+ return signalled;
69
+ };
70
+
71
+ /** Any user reads the ledger's files; only ada writes them, and no one once the account is closed. */
72
+ const fileRule = (
73
+ ledger: { readonly closed: boolean },
74
+ { access, path }: { readonly access: "read" | "write"; readonly path: string },
75
+ ): Effect.Effect<void, FileAccessDeniedError, Akter> =>
76
+ Ledger.principal.pipe(
77
+ Effect.filterOrFail(
78
+ (caller) => access === "read" || (caller.id === fileWriter && !ledger.closed),
79
+ () => FileAccessDeniedError.make({ access, message: "only ada writes, while the account is open", path }),
80
+ ),
81
+ Effect.asVoid,
82
+ );
83
+
84
+ /** The `release` job a build's `hold` starts: the Ledger's, or the patched build's. */
85
+ type Release = typeof Ledger.jobs.release | typeof PatchedLedger.jobs.release;
86
+
87
+ const ledgerLayer = (
88
+ migrations: Migrations,
89
+ holding: Release = Ledger.jobs.release,
90
+ ): ReturnType<typeof Ledger.toLayer> =>
91
+ Ledger.toLayer({
92
+ commands: {
93
+ approve: (_ledger, { amount, job, refuse }) => approve(amount, job, refuse),
94
+ close: () => Ledger.update({ closed: true }),
95
+ crash: (ledger, { amount }) =>
96
+ Effect.andThen(
97
+ record(Cents.make(ledger.balance + amount), amount),
98
+ Effect.die(new Error("the handler crashed after writing")),
99
+ ),
100
+ credit: (ledger, { amount }) => record(Cents.make(ledger.balance + amount), amount),
101
+ deposit: (ledger, { amount }) => record(Cents.make(ledger.balance + amount), amount),
102
+ depositLater: (_ledger, { amount, minutes }) =>
103
+ Effect.asVoid(Akter.schedule(Duration.minutes(minutes), Ledger.commands.deposit, { amount })),
104
+ hold: () => Akter.start(holding, {}),
105
+ tamper: (ledger, { amount }) => tamper(Cents.make(ledger.balance + amount), amount),
106
+ transfer: (ledger, { amount, to }) =>
107
+ Effect.andThen(Akter.send(Ledger.commands.credit, to, { amount }), withdrawal(ledger, amount)),
108
+ withdraw: (ledger, { amount }) => withdrawal(ledger, amount),
109
+ },
110
+ files: { authorize: fileRule },
111
+ migrations,
112
+ queries: {
113
+ account: (ledger) => Effect.succeed({ balance: ledger.balance, closed: ledger.closed }),
114
+ entries: () => Effect.flatMap(Akter.sql, (sql) => sql<Entry>`SELECT id, amount FROM entries ORDER BY rowid`),
115
+ scribble: () =>
116
+ Effect.asVoid(
117
+ Effect.flatMap(Akter.sql, (sql) => sql`INSERT INTO entries ${sql.insert({ amount: 1, id: "query" })}`),
118
+ ),
119
+ tables: () =>
120
+ Effect.flatMap(Akter.sql, (sql) =>
121
+ Effect.map(
122
+ sql<TableName>`SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'akter_%' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
123
+ Arr.map((row) => row.name),
124
+ ),
125
+ ),
126
+ },
127
+ results: {
128
+ release: (ledger, result) =>
129
+ Exit.match(result, {
130
+ onFailure: () => Effect.void,
131
+ onSuccess: (amount) => Effect.asVoid(record(Cents.make(ledger.balance + amount), amount)),
132
+ }),
133
+ },
134
+ });
135
+
136
+ const releaseLive = Ledger.jobs.release.toLayer(() => DurableDeferred.await(approval));
137
+
138
+ const twice = 2;
139
+
140
+ /** The later build's release: twice the approved amount, but only for a hold this build started. */
141
+ const doublingReleaseLive = PatchedLedger.jobs.release.toLayer(() =>
142
+ Effect.gen(function* doublingRelease() {
143
+ const amount = yield* DurableDeferred.await(approval);
144
+
145
+ if (yield* PatchedLedger.jobs.release.patched(doubled)) {
146
+ return Cents.make(amount * twice);
147
+ }
148
+
149
+ return amount;
150
+ }),
151
+ );
152
+
153
+ /** Serves the Ledger with its job on the engine's job runtime, as an app does. */
154
+ const withJobs = (ledger: ReturnType<typeof Ledger.toLayer>, release = releaseLive): LedgerLayer =>
155
+ Layer.merge(ledger, release).pipe(Layer.provide(Engine.jobs));
156
+
157
+ const createEntries: Migrations[number] = (sql) =>
158
+ sql`CREATE TABLE entries (id TEXT PRIMARY KEY, amount INTEGER NOT NULL)`;
159
+
160
+ const createNotes: Migrations[number] = (sql) => sql`CREATE TABLE notes (id TEXT PRIMARY KEY, text TEXT NOT NULL)`;
161
+
162
+ const LedgerLive = withJobs(ledgerLayer({ [entriesVersion]: createEntries }));
163
+
164
+ const LedgerWithNotesLive = withJobs(ledgerLayer({ [entriesVersion]: createEntries, [notesVersion]: createNotes }));
165
+
166
+ const ledgerIntruding = (prefix: string): LedgerLayer =>
167
+ withJobs(
168
+ ledgerLayer({
169
+ [entriesVersion]: createEntries,
170
+ [notesVersion]: createNotes,
171
+ [reservedVersion]: (sql) => sql`CREATE TABLE ${sql(`${prefix}intruder`)} (id TEXT PRIMARY KEY)`,
172
+ }),
173
+ );
174
+
175
+ /** A Ledger implementation the suite serves on the engine, from either SDK. */
176
+ type LedgerLayer = Layer.Layer<never>;
177
+
178
+ /** The Ledger at each migration step the suite opens: entries only, entries and notes, and one that creates a table under the engine's prefix. */
179
+ interface Ledgers {
180
+ readonly intruding: (prefix: string) => LedgerLayer;
181
+ readonly live: LedgerLayer;
182
+ /** `live` deployed again with its `release` job patched (`doubled`), over holds the earlier build started. */
183
+ readonly patched: LedgerLayer;
184
+ readonly withNotes: LedgerLayer;
185
+ }
186
+
187
+ const effectLedgers: Ledgers = {
188
+ intruding: ledgerIntruding,
189
+ live: LedgerLive,
190
+ patched: withJobs(ledgerLayer({ [entriesVersion]: createEntries }, PatchedLedger.jobs.release), doublingReleaseLive),
191
+ withNotes: LedgerWithNotesLive,
192
+ };
193
+
194
+ export { effectLedgers };
195
+
196
+ export type { LedgerLayer, Ledgers };
@@ -0,0 +1,76 @@
1
+ import { Akter } from "@akterjs/effect/contract";
2
+ import { Schema } from "effect";
3
+
4
+ const AccountId = Schema.String.pipe(Schema.brand("AccountId"));
5
+
6
+ const EntryId = Schema.String.pipe(Schema.brand("EntryId"));
7
+
8
+ const Cents = Schema.Int.pipe(Schema.brand("Cents"));
9
+
10
+ type Cents = typeof Cents.Type;
11
+
12
+ const openingCents = 0;
13
+
14
+ const Entry = Schema.Struct({ amount: Cents, id: EntryId });
15
+
16
+ type Entry = typeof Entry.Type;
17
+
18
+ type Account = typeof Account.Type;
19
+
20
+ const Account = Schema.Struct({ balance: Cents, closed: Schema.Boolean });
21
+
22
+ const Service = Schema.Struct({ id: Akter.PrincipalId });
23
+
24
+ class OverdrawnError extends Schema.TaggedError<OverdrawnError, { readonly brand: unique symbol }>()("OverdrawnError", {
25
+ balance: Cents,
26
+ }) {}
27
+
28
+ /** Everything the Ledger declares but its `release` job, which the two builds below declare with different patches. */
29
+ const ledgerOptions = {
30
+ callers: { actor: Akter.ActorPrincipal, service: Service, user: Akter.Principal },
31
+ commands: {
32
+ approve: Akter.command({
33
+ payload: { amount: Cents, job: Schema.String, refuse: Schema.Boolean },
34
+ success: Schema.Void,
35
+ }),
36
+ close: Akter.command({ callers: ["service"], success: Schema.Void }),
37
+ crash: Akter.command({ payload: { amount: Cents }, success: Schema.Void }),
38
+ credit: Akter.command({ callers: ["actor"], payload: { amount: Cents }, success: EntryId }),
39
+ deposit: Akter.command({ payload: { amount: Cents }, success: EntryId }),
40
+ depositLater: Akter.command({ payload: { amount: Cents, minutes: Schema.Int }, success: Schema.Void }),
41
+ hold: Akter.command({ success: Schema.String }),
42
+ tamper: Akter.command({ payload: { amount: Cents }, success: Schema.Void }),
43
+ transfer: Akter.command({ error: OverdrawnError, payload: { amount: Cents, to: AccountId }, success: EntryId }),
44
+ withdraw: Akter.command({ error: OverdrawnError, payload: { amount: Cents }, success: EntryId }),
45
+ },
46
+ files: Akter.fileAccess({ callers: ["user"] }),
47
+ initial: () => ({ balance: Cents.make(openingCents), closed: false }),
48
+ key: AccountId,
49
+ queries: {
50
+ account: Akter.query({ success: Account }),
51
+ entries: Akter.query({ success: Schema.Array(Entry) }),
52
+ scribble: Akter.query({ success: Schema.Void }),
53
+ tables: Akter.query({ success: Schema.Array(Schema.String) }),
54
+ },
55
+ state: Account,
56
+ } as const;
57
+
58
+ /** The patch the later build's `release` declares: it releases twice the approved amount, for holds it started. */
59
+ const doubled = "double";
60
+
61
+ const noPatches: readonly string[] = [];
62
+
63
+ const doubling: readonly string[] = [doubled];
64
+
65
+ const Ledger = Akter.make("Ledger", {
66
+ ...ledgerOptions,
67
+ jobs: { release: Akter.job({ patches: noPatches, payload: {}, success: Cents }) },
68
+ });
69
+
70
+ /** The Ledger deployed again with its `release` job patched, over holds the earlier build started. */
71
+ const PatchedLedger = Akter.make("Ledger", {
72
+ ...ledgerOptions,
73
+ jobs: { release: Akter.job({ patches: doubling, payload: {}, success: Cents }) },
74
+ });
75
+
76
+ export { Account, Cents, Entry, EntryId, Ledger, OverdrawnError, PatchedLedger, doubled };