@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,246 @@
1
+ import { Akter as Engine } from "@akterjs/effect";
2
+ import { Akter } from "@akterjs/sdk";
3
+ import type { CommandContext, JobResult, JobSteps, Migrations, PlainActor, Sql, SqlRow } from "@akterjs/sdk";
4
+ import { engineOf } from "@akterjs/sdk/engine";
5
+ import { Array as Arr, Effect, Layer } from "effect";
6
+ import { z } from "zod";
7
+
8
+ import { fileWriter } from "./ledger-calls.pure.js";
9
+ import { doubled } from "./ledger.contract.js";
10
+ import type { LedgerLayer, Ledgers } from "./ledger.actor.js";
11
+
12
+ /** The plain SDK's Ledger: the wire contract of the Effect Ledger in `ledger.contract.ts`, so one suite checks both SDKs. */
13
+ const Cents = z.number().int();
14
+
15
+ const Amount = z.object({ amount: Cents });
16
+
17
+ const Later = z.object({ amount: Cents, minutes: z.number().int() });
18
+
19
+ const Approving = z.object({ amount: Cents, job: z.string(), refuse: z.boolean() });
20
+
21
+ const Release = z.object({});
22
+
23
+ const Transfer = z.object({ amount: Cents, to: z.string() });
24
+
25
+ const EntryId = z.string();
26
+
27
+ const OverdrawnError = Akter.error("OverdrawnError", z.object({ balance: Cents }));
28
+
29
+ const Account = z.object({ balance: Cents, closed: z.boolean() });
30
+
31
+ const Entries = z.array(z.object({ amount: Cents, id: EntryId }));
32
+
33
+ const TableNames = z.array(z.object({ name: z.string() }));
34
+
35
+ const TableList = z.array(z.string()).readonly();
36
+
37
+ /** Everything the plain Ledger declares but its `release` job, which the two builds below declare with different patches. */
38
+ const ledgerOptions = {
39
+ callers: { actor: Akter.ActorPrincipal, service: z.object({ id: z.string() }), user: Akter.Principal },
40
+ commands: {
41
+ approve: Akter.command({ payload: Approving }),
42
+ close: Akter.command({ callers: ["service"] }),
43
+ crash: Akter.command({ payload: Amount }),
44
+ credit: Akter.command({ callers: ["actor"], payload: Amount, success: EntryId }),
45
+ deposit: Akter.command({ payload: Amount, success: EntryId }),
46
+ depositLater: Akter.command({ payload: Later }),
47
+ hold: Akter.command({ success: z.string() }),
48
+ tamper: Akter.command({ payload: Amount }),
49
+ transfer: Akter.command({ errors: [OverdrawnError], payload: Transfer, success: EntryId }),
50
+ withdraw: Akter.command({ errors: [OverdrawnError], payload: Amount, success: EntryId }),
51
+ },
52
+ files: Akter.fileAccess({ callers: ["user"] }),
53
+ initial: () => ({ balance: 0, closed: false }),
54
+ key: z.string(),
55
+ queries: {
56
+ account: Akter.query({ success: Account }),
57
+ entries: Akter.query({ success: Entries }),
58
+ scribble: Akter.query(),
59
+ tables: Akter.query({ success: TableList }),
60
+ },
61
+ state: Account,
62
+ } as const;
63
+
64
+ const noPatches: readonly string[] = [];
65
+
66
+ const doubling: readonly string[] = [doubled];
67
+
68
+ const Ledger = Akter.make("Ledger", {
69
+ ...ledgerOptions,
70
+ jobs: { release: Akter.job({ patches: noPatches, payload: Release, success: Cents }) },
71
+ });
72
+
73
+ /** The Ledger deployed again with its `release` job patched, over holds the earlier build started. */
74
+ const PatchedLedger = Akter.make("Ledger", {
75
+ ...ledgerOptions,
76
+ jobs: { release: Akter.job({ patches: doubling, payload: Release, success: Cents }) },
77
+ });
78
+
79
+ type LedgerContext =
80
+ typeof Ledger extends PlainActor<infer Name, infer Options> ? CommandContext<Name, Options> : never;
81
+
82
+ const lowestBalance = 0;
83
+
84
+ const millisPerMinute = 60_000;
85
+
86
+ const scribbled = 1;
87
+
88
+ const insert = (sql: Sql, id: string, amount: number): readonly SqlRow[] =>
89
+ sql`INSERT INTO entries (id, amount) VALUES (${id}, ${amount})`;
90
+
91
+ /** Records an entry and moves the balance, as the Effect Ledger's `record` does. */
92
+ const record = (ctx: LedgerContext, balance: number, amount: number): string => {
93
+ const id = ctx.id();
94
+
95
+ insert(ctx.sql, id, amount);
96
+ ctx.update({ balance });
97
+
98
+ return id;
99
+ };
100
+
101
+ const withdrawal = (ctx: LedgerContext, amount: number): string => {
102
+ const balance = ctx.state.balance - amount;
103
+ const id = record(ctx, balance, amount);
104
+
105
+ if (balance < lowestBalance) {
106
+ throw new OverdrawnError({ balance });
107
+ }
108
+
109
+ return id;
110
+ };
111
+
112
+ /** Reads the engine's journal, then writes the engine's state table, which no handler may do. */
113
+ const tamperWith = (sql: Sql): readonly SqlRow[] =>
114
+ Arr.appendAll(sql`SELECT count(*) AS commands FROM akter_commands`, sql`UPDATE akter_state SET value = value`);
115
+
116
+ const tableNames = (sql: Sql): readonly string[] =>
117
+ Arr.map(
118
+ TableNames.parse(
119
+ sql`SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'akter_%' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
120
+ ),
121
+ (row) => row.name,
122
+ );
123
+
124
+ /** Signals the held job, then throws after the signal when `refuse` is set, so the command rolls back with it. */
125
+ const approve = (ctx: LedgerContext, { amount, job, refuse }: z.infer<typeof Approving>): void => {
126
+ ctx.signal(job, "approval", amount);
127
+
128
+ if (refuse) {
129
+ throw new Error("the approval was refused after signalling");
130
+ }
131
+ };
132
+
133
+ /** Waits for the approved amount and returns it, so its result records the entry. */
134
+ const release = (_payload: z.infer<typeof Release>, steps: JobSteps): Promise<number> =>
135
+ Effect.runPromise(
136
+ Effect.map(
137
+ Effect.promise(() => steps.waitFor("approval")),
138
+ (value) => Cents.parse(value),
139
+ ),
140
+ );
141
+
142
+ const twice = 2;
143
+
144
+ /** The later build's release: twice the approved amount, but only for a hold this build started. */
145
+ const doublingRelease = (payload: z.infer<typeof Release>, steps: JobSteps): Promise<number> =>
146
+ Effect.runPromise(
147
+ Effect.map(
148
+ Effect.promise(() => release(payload, steps)),
149
+ (amount) => {
150
+ if (steps.patched(doubled)) {
151
+ return amount * twice;
152
+ }
153
+
154
+ return amount;
155
+ },
156
+ ),
157
+ );
158
+
159
+ const released = (ctx: LedgerContext, result: JobResult<number, never>): void => {
160
+ if (result.outcome === "success") {
161
+ record(ctx, ctx.state.balance + result.value, result.value);
162
+ }
163
+ };
164
+
165
+ interface PlainBuild {
166
+ readonly job: typeof release;
167
+ readonly ledger: typeof Ledger;
168
+ }
169
+
170
+ /** The build the suite opens first: the Ledger without patches. */
171
+ const firstBuild: PlainBuild = { job: release, ledger: Ledger };
172
+
173
+ const plainLedgerLayer = (migrations: Migrations, { job, ledger }: PlainBuild = firstBuild): LedgerLayer => {
174
+ const implemented = ledger.implement({
175
+ commands: {
176
+ approve,
177
+ close: (ctx) => {
178
+ ctx.update({ closed: true });
179
+ },
180
+ crash: (ctx, { amount }) => {
181
+ record(ctx, ctx.state.balance + amount, amount);
182
+
183
+ throw new Error("the handler crashed after writing");
184
+ },
185
+ credit: (ctx, { amount }) => record(ctx, ctx.state.balance + amount, amount),
186
+ deposit: (ctx, { amount }) => record(ctx, ctx.state.balance + amount, amount),
187
+ depositLater: (ctx, { amount, minutes }) => {
188
+ ctx.schedule(minutes * millisPerMinute, Ledger.commands.deposit, { amount });
189
+ },
190
+ hold: (ctx) => ctx.start(ledger.jobs.release, {}),
191
+ tamper: (ctx, { amount }) => {
192
+ record(ctx, ctx.state.balance + amount, amount);
193
+ tamperWith(ctx.sql);
194
+ },
195
+ transfer: (ctx, { amount, to }) => {
196
+ ctx.send(Ledger.commands.credit, to, { amount });
197
+
198
+ return withdrawal(ctx, amount);
199
+ },
200
+ withdraw: (ctx, { amount }) => withdrawal(ctx, amount),
201
+ },
202
+ files: {
203
+ authorize: (ctx, { access }) => access === "read" || (ctx.principal.id === fileWriter && !ctx.state.closed),
204
+ },
205
+ jobs: { release: job },
206
+ migrations,
207
+ queries: {
208
+ account: (ctx) => ({ balance: ctx.state.balance, closed: ctx.state.closed }),
209
+ entries: (ctx) => Entries.parse(ctx.sql`SELECT id, amount FROM entries ORDER BY rowid`),
210
+ scribble: (ctx) => {
211
+ insert(ctx.sql, "query", scribbled);
212
+ },
213
+ tables: (ctx) => tableNames(ctx.sql),
214
+ },
215
+ results: { release: released },
216
+ });
217
+
218
+ return engineOf(Akter.app([implemented])).layer.pipe(Layer.provide(Engine.jobs));
219
+ };
220
+
221
+ const createEntries = "CREATE TABLE entries (id TEXT PRIMARY KEY, amount INTEGER NOT NULL)";
222
+
223
+ const createNotes = "CREATE TABLE notes (id TEXT PRIMARY KEY, text TEXT NOT NULL)";
224
+
225
+ const entriesVersion = 1;
226
+
227
+ const notesVersion = 2;
228
+
229
+ const reservedVersion = 3;
230
+
231
+ const plainLedgerIntruding = (prefix: string): LedgerLayer =>
232
+ plainLedgerLayer({
233
+ [entriesVersion]: createEntries,
234
+ [notesVersion]: createNotes,
235
+ [reservedVersion]: `CREATE TABLE "${prefix}intruder" (id TEXT PRIMARY KEY)`,
236
+ });
237
+
238
+ /** The plain SDK's Ledgers, for `conformanceOf({ engine, ledgers: plainLedgers, skip })`: every engine must pass the suite on both SDKs. */
239
+ const plainLedgers: Ledgers = {
240
+ intruding: plainLedgerIntruding,
241
+ live: plainLedgerLayer({ [entriesVersion]: createEntries }),
242
+ patched: plainLedgerLayer({ [entriesVersion]: createEntries }, { job: doublingRelease, ledger: PatchedLedger }),
243
+ withNotes: plainLedgerLayer({ [entriesVersion]: createEntries, [notesVersion]: createNotes }),
244
+ };
245
+
246
+ export { plainLedgers };
@@ -0,0 +1,6 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class RejectedError extends Schema.TaggedError<RejectedError, { readonly brand: unique symbol }>()(
4
+ "RejectedError",
5
+ { reason: Schema.Unknown },
6
+ ) {}
@@ -0,0 +1,221 @@
1
+ import { createClient } from "@akterjs/client";
2
+ import { akterClient } from "@akterjs/client/effect";
3
+ import { Cause, Effect, Layer, MutableRef, Option, Queue, Schema, Stream } from "effect";
4
+ import type { Scope } from "effect";
5
+ import { FetchHttpClient } from "effect/http";
6
+
7
+ import { RejectedError } from "./rejected.errors.js";
8
+ import { Tally } from "./tally.contract.js";
9
+
10
+ type Headers = Readonly<Record<string, string>>;
11
+
12
+ interface Update<Value> {
13
+ readonly value: Value;
14
+ readonly version: number;
15
+ }
16
+
17
+ interface Owned {
18
+ readonly owner: string;
19
+ readonly total: number;
20
+ }
21
+
22
+ interface TallyHandle {
23
+ readonly add: (amount: number) => Effect.Effect<number, RejectedError>;
24
+ readonly create: (owner: string) => Effect.Effect<void, RejectedError>;
25
+ readonly ensure: (owner: string) => Effect.Effect<void, RejectedError>;
26
+ readonly fork: (into: string) => Effect.Effect<TallyHandle, RejectedError>;
27
+ readonly owner: Effect.Effect<string, RejectedError>;
28
+ readonly state: Stream.Stream<Update<Owned>, RejectedError>;
29
+ readonly take: (amount: number) => Effect.Effect<number, RejectedError>;
30
+ readonly total: Effect.Effect<number, RejectedError>;
31
+ readonly totals: Stream.Stream<Update<number>, RejectedError>;
32
+ readonly warm: Effect.Effect<void, RejectedError>;
33
+ }
34
+
35
+ interface Wire {
36
+ readonly fetch: typeof globalThis.fetch;
37
+ readonly loseNextReply: Effect.Effect<void>;
38
+ readonly opened: MutableRef.MutableRef<number>;
39
+ readonly sockets: Set<globalThis.WebSocket>;
40
+ readonly webSocket: typeof globalThis.WebSocket;
41
+ }
42
+
43
+ interface Connection {
44
+ readonly headers: Headers;
45
+ readonly url: string;
46
+ readonly wire: Wire;
47
+ }
48
+
49
+ type Connect = (connection: Connection) => Effect.Effect<(key: string) => TallyHandle, never, Scope.Scope>;
50
+
51
+ type Subscribe<Value> = (onUpdate: (update: Update<Value>) => void, onFailure: (failure: Error) => void) => () => void;
52
+
53
+ const noSockets = 0;
54
+
55
+ type NoPayload = Schema.Void["Type"];
56
+
57
+ const withoutPayload = <Reply>(send: (payload: NoPayload) => Reply, payload?: NoPayload): Reply => send(payload);
58
+
59
+ const settle = <Success>(promise: () => Promise<Success>): Effect.Effect<Success, RejectedError> =>
60
+ Effect.tryPromise({ catch: (reason) => RejectedError.make({ reason }), try: promise });
61
+
62
+ const effectClient: Connect = ({ headers, url, wire }) =>
63
+ Effect.map(
64
+ Layer.build(akterClient({ fetch: wire.fetch, headers: () => headers, url, webSocket: wire.webSocket })),
65
+ (services) =>
66
+ function handleOf(key): TallyHandle {
67
+ const client = Tally.client(key);
68
+
69
+ const run = <Success, Failure>(
70
+ use: (tally: Effect.Success<typeof client>) => Effect.Effect<Success, Failure>,
71
+ ): Effect.Effect<Success, RejectedError> =>
72
+ Effect.scoped(Effect.flatMap(client, use)).pipe(
73
+ Effect.mapError((reason) => RejectedError.make({ reason })),
74
+ Effect.provide(services),
75
+ );
76
+
77
+ const watch = <Value, Failure>(
78
+ use: (tally: Effect.Success<typeof client>) => Stream.Stream<Update<Value>, Failure>,
79
+ ): Stream.Stream<Update<Value>, RejectedError> =>
80
+ Stream.unwrap(Effect.map(client, use)).pipe(
81
+ Stream.mapError((reason) => RejectedError.make({ reason })),
82
+ Stream.provide(services),
83
+ );
84
+
85
+ return {
86
+ add: (amount) => run((tally) => tally.add({ amount })),
87
+ create: (owner) => run((tally) => tally.create({ owner })),
88
+ ensure: (owner) => run((tally) => tally.ensure({ owner })),
89
+ // The copy's client the fork answers must reach the copy, so the fixture reads through it once.
90
+ fork: (into) =>
91
+ Effect.as(
92
+ run((tally) => Effect.flatMap(tally.fork(into), (copy) => withoutPayload(copy.owner))),
93
+ handleOf(into),
94
+ ),
95
+ owner: run((tally) => withoutPayload(tally.owner)),
96
+ state: watch((tally) => tally.state),
97
+ take: (amount) => run((tally) => tally.take({ amount })),
98
+ total: run((tally) => withoutPayload(tally.total)),
99
+ totals: watch((tally) => withoutPayload(tally.total.subscribe)),
100
+ warm: run((tally) => tally.warm),
101
+ };
102
+ },
103
+ );
104
+
105
+ const listened = <Value>(subscribe: Subscribe<Value>): Stream.Stream<Update<Value>, RejectedError> =>
106
+ Stream.callback<Update<Value>, RejectedError>((queue) =>
107
+ Effect.acquireRelease(
108
+ Effect.sync(() =>
109
+ subscribe(
110
+ (update) => {
111
+ Queue.offerUnsafe(queue, update);
112
+ },
113
+ (failure) => {
114
+ Queue.failCauseUnsafe(queue, Cause.fail(RejectedError.make({ reason: failure })));
115
+ },
116
+ ),
117
+ ),
118
+ (stop) => Effect.sync(stop),
119
+ ),
120
+ );
121
+
122
+ const promiseClient: Connect = ({ headers, url, wire }) =>
123
+ Effect.map(
124
+ Effect.acquireRelease(
125
+ Effect.sync(() =>
126
+ createClient({ actors: [Tally], fetch: wire.fetch, headers: () => headers, url, webSocket: wire.webSocket }),
127
+ ),
128
+ (client) => Effect.promise(() => client.close()),
129
+ ),
130
+ (client) =>
131
+ function handleOf(key): TallyHandle {
132
+ const tally = client.Tally(key);
133
+
134
+ return {
135
+ add: (amount) => settle(() => tally.add({ amount })),
136
+ create: (owner) => settle(() => tally.create({ owner })),
137
+ ensure: (owner) => settle(() => tally.ensure({ owner })),
138
+ // The copy's client the fork answers must reach the copy, so the fixture reads through it once.
139
+ fork: (into) =>
140
+ Effect.as(
141
+ Effect.flatMap(
142
+ settle(() => tally.fork(into)),
143
+ (copy) => settle(() => withoutPayload(copy.owner)),
144
+ ),
145
+ handleOf(into),
146
+ ),
147
+ owner: settle(() => withoutPayload(tally.owner)),
148
+ state: listened((onUpdate, onFailure) => tally.state.subscribe(onUpdate, onFailure)),
149
+ take: (amount) => settle(() => tally.take({ amount })),
150
+ total: settle(() => withoutPayload(tally.total)),
151
+ totals: listened((onUpdate, onFailure) => tally.total.subscribe(onUpdate, onFailure)),
152
+ warm: settle(() => tally.warm()),
153
+ };
154
+ },
155
+ );
156
+
157
+ const clients: readonly (readonly [string, Connect])[] = [
158
+ ["the Effect client", effectClient],
159
+ ["the Promise client", promiseClient],
160
+ ];
161
+
162
+ const isSocket = Schema.is(Schema.instanceOf(globalThis.WebSocket));
163
+
164
+ // A proxy rather than a subclass keeps the constructor's own type, whose overloads differ between runtimes' typings.
165
+ const trackedSockets = (
166
+ sockets: Set<globalThis.WebSocket>,
167
+ opened: MutableRef.MutableRef<number>,
168
+ ): typeof globalThis.WebSocket =>
169
+ new Proxy(globalThis.WebSocket, {
170
+ construct: (native, socket: readonly unknown[]): globalThis.WebSocket => {
171
+ const constructed: unknown = Reflect.construct(native, socket);
172
+ const tracked = Option.getOrThrow(Option.liftPredicate(constructed, isSocket));
173
+
174
+ sockets.add(tracked);
175
+ MutableRef.increment(opened);
176
+ tracked.addEventListener("close", () => {
177
+ sockets.delete(tracked);
178
+ });
179
+
180
+ return tracked;
181
+ },
182
+ });
183
+
184
+ const wired: Effect.Effect<Wire> = Effect.gen(function* wired() {
185
+ const losing = MutableRef.make(false);
186
+ const sockets = new Set<globalThis.WebSocket>();
187
+ const opened = MutableRef.make(noSockets);
188
+ const services = yield* Effect.context();
189
+ const send = yield* FetchHttpClient.Fetch;
190
+
191
+ const losingFetch = (input: Request | URL | string, init?: RequestInit): Promise<Response> =>
192
+ Effect.runPromiseWith(services)(
193
+ Effect.gen(function* fetchOrLoseReply() {
194
+ const response = yield* Effect.promise(() => send(input, init));
195
+
196
+ if (MutableRef.get(losing)) {
197
+ MutableRef.set(losing, false);
198
+ yield* Effect.promise(() => response.arrayBuffer());
199
+
200
+ return yield* Effect.die("the fixture dropped the connection after the server replied");
201
+ }
202
+
203
+ return response;
204
+ }),
205
+ );
206
+
207
+ // Runtimes' typings add their own members to fetch, such as Bun's preconnect, which the fixture passes through.
208
+ const fetch = Object.assign(losingFetch, send);
209
+
210
+ return {
211
+ fetch,
212
+ loseNextReply: Effect.sync(() => MutableRef.set(losing, true)),
213
+ opened,
214
+ sockets,
215
+ webSocket: trackedSockets(sockets, opened),
216
+ } satisfies Wire;
217
+ });
218
+
219
+ export { clients, wired };
220
+
221
+ export type { Connect, Headers, TallyHandle, Update, Wire };
@@ -0,0 +1,28 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { ShortError, Tally } from "./tally.contract.js";
4
+
5
+ const empty = 0;
6
+
7
+ const TallyLive = Tally.toLayer({
8
+ commands: {
9
+ add: (tally, { amount }) => Effect.as(Tally.update({ total: tally.total + amount }), tally.total + amount),
10
+ take: (tally, { amount }) => {
11
+ if (amount > tally.total) {
12
+ return ShortError.make({ total: tally.total });
13
+ }
14
+
15
+ return Effect.as(Tally.update({ total: tally.total - amount }), tally.total - amount);
16
+ },
17
+ },
18
+ create: ({ owner }) => Effect.succeed({ owner, total: empty }),
19
+ queries: {
20
+ owner: (tally) => Effect.succeed(tally.owner),
21
+ total: (tally) => Effect.succeed(tally.total),
22
+ },
23
+ });
24
+
25
+ // The app `akter deploy --entry` takes, so a deployed stage can serve the client contract's actor.
26
+ const app = { actors: [Tally], layer: TallyLive } as const;
27
+
28
+ export { app, TallyLive };
@@ -0,0 +1,22 @@
1
+ import { Akter } from "@akterjs/effect/contract";
2
+ import { Schema } from "effect";
3
+
4
+ class ShortError extends Schema.TaggedError<ShortError, { readonly brand: unique symbol }>()("ShortError", {
5
+ total: Schema.Int,
6
+ }) {}
7
+
8
+ const Tally = Akter.make("Tally", {
9
+ commands: {
10
+ add: Akter.command({ payload: { amount: Schema.Int }, success: Schema.Int }),
11
+ take: Akter.command({ error: ShortError, payload: { amount: Schema.Int }, success: Schema.Int }),
12
+ },
13
+ create: { owner: Schema.String },
14
+ key: Schema.String,
15
+ queries: {
16
+ owner: Akter.query({ success: Schema.String }),
17
+ total: Akter.query({ success: Schema.Int }),
18
+ },
19
+ state: Schema.Struct({ owner: Schema.String, total: Schema.Int }),
20
+ });
21
+
22
+ export { ShortError, Tally };