@akterjs/client 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.
- package/LICENSE +201 -0
- package/README.md +17 -2
- package/dist/actor-fields.pure.d.mts +77 -0
- package/dist/actor-fields.pure.mjs +85 -0
- package/dist/actor-operations.pure.d.mts +49 -0
- package/dist/actor-operations.pure.mjs +512 -0
- package/dist/actor-protocol.layer.d.mts +18 -0
- package/dist/actor-protocol.layer.mjs +172 -0
- package/dist/actor-rpc.pure.d.mts +28 -0
- package/dist/actor-rpc.pure.mjs +154 -0
- package/dist/akter-client.layer.d.mts +13 -0
- package/dist/akter-client.layer.mjs +27 -0
- package/dist/promise-client.adapter.d.mts +55 -0
- package/dist/promise-client.adapter.mjs +114 -0
- package/dist/promise-files.pure.d.mts +48 -0
- package/dist/promise-files.pure.mjs +35 -0
- package/dist/read-floors.pure.d.mts +7 -0
- package/dist/read-floors.pure.mjs +49 -0
- package/dist/remote-interrupt.pure.d.mts +13 -0
- package/dist/remote-interrupt.pure.mjs +45 -0
- package/dist/upload-source.errors.d.mts +9 -0
- package/dist/upload-source.errors.mjs +5 -0
- package/package.json +68 -3
- package/src/actor-fields.pure.ts +60 -0
- package/src/actor-operations.pure.ts +484 -0
- package/src/actor-protocol.layer.ts +331 -0
- package/src/actor-rpc.pure.ts +179 -0
- package/src/akter-client.layer.ts +67 -0
- package/src/promise-client.adapter.ts +354 -0
- package/src/promise-files.pure.ts +106 -0
- package/src/read-floors.pure.ts +82 -0
- package/src/remote-interrupt.pure.ts +79 -0
- package/src/upload-source.errors.ts +9 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Fiber, ManagedRuntime, Option, Record as Rec, Schema, Stream } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
import type { RpcClient } from "effect/rpc";
|
|
4
|
+
|
|
5
|
+
import type { FileClient } from "@akterjs/effect/contract";
|
|
6
|
+
import type { ActorClients } from "@akterjs/effect/contract/actor-client";
|
|
7
|
+
|
|
8
|
+
import { akterClient } from "./akter-client.layer.js";
|
|
9
|
+
import type { AkterClientOptions } from "./akter-client.layer.js";
|
|
10
|
+
import { promiseFiles } from "./promise-files.pure.js";
|
|
11
|
+
import type { PromiseFiles } from "./promise-files.pure.js";
|
|
12
|
+
|
|
13
|
+
type ClientServices = ActorClients | RpcClient.Protocol | Scope.Scope;
|
|
14
|
+
|
|
15
|
+
interface ClientActor {
|
|
16
|
+
readonly client: (key: never) => Effect.Effect<object, never, ClientServices>;
|
|
17
|
+
readonly fileAccess: Option.Option<unknown>;
|
|
18
|
+
readonly files: (key: never) => Effect.Effect<FileClient, Error>;
|
|
19
|
+
readonly key: Schema.Top & { readonly DecodingServices: never; readonly Encoded: string };
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly queries: Readonly<Record<string, { readonly payload: Schema.Top }>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type EffectClientOf<Actor extends ClientActor> = Effect.Success<ReturnType<Actor["client"]>>;
|
|
25
|
+
|
|
26
|
+
type Stop = () => void;
|
|
27
|
+
|
|
28
|
+
type OnError = (error: Error) => void;
|
|
29
|
+
|
|
30
|
+
type PromiseCall<Call> = Call extends (payload: infer Payload) => Effect.Effect<infer Success, infer _Failure>
|
|
31
|
+
? (payload: Payload) => Promise<Success>
|
|
32
|
+
: never;
|
|
33
|
+
|
|
34
|
+
/** A query declared without a payload subscribes as `subscribe(onUpdate, onError?)`; one with a payload takes it first. */
|
|
35
|
+
type PromiseSubscribe<Call> = Call extends {
|
|
36
|
+
readonly subscribe: (payload: infer Payload) => Stream.Stream<infer Update, infer _Failure>;
|
|
37
|
+
}
|
|
38
|
+
? undefined extends Payload
|
|
39
|
+
? { readonly subscribe: (onUpdate: (update: Update) => void, onError?: OnError) => Stop }
|
|
40
|
+
: { readonly subscribe: (payload: Payload, onUpdate: (update: Update) => void, onError?: OnError) => Stop }
|
|
41
|
+
: unknown;
|
|
42
|
+
|
|
43
|
+
type PromiseState<Updates> =
|
|
44
|
+
Updates extends Stream.Stream<infer Update, infer _Failure>
|
|
45
|
+
? { readonly subscribe: (onUpdate: (update: Update) => void, onError?: OnError) => Stop }
|
|
46
|
+
: never;
|
|
47
|
+
|
|
48
|
+
/** An actor's files, when its contract declares file calls (`files: Akter.fileAccess(...)`); otherwise none. */
|
|
49
|
+
type PromiseFilesOf<Actor extends ClientActor> =
|
|
50
|
+
Actor["fileAccess"] extends Option.None<unknown> ? unknown : { readonly files: PromiseFiles };
|
|
51
|
+
|
|
52
|
+
type PromiseActor<Actor extends ClientActor> = {
|
|
53
|
+
readonly [Member in Exclude<keyof EffectClientOf<Actor>, "files" | "fork" | "state" | "warm">]: PromiseCall<
|
|
54
|
+
EffectClientOf<Actor>[Member]
|
|
55
|
+
> &
|
|
56
|
+
PromiseSubscribe<EffectClientOf<Actor>[Member]>;
|
|
57
|
+
} & PromiseFilesOf<Actor> & {
|
|
58
|
+
/** Forks this actor into a new one at `into` and answers the copy's client (ADR 0017). */
|
|
59
|
+
readonly fork: (into: Actor["key"]["Encoded"]) => Promise<PromiseActor<Actor>>;
|
|
60
|
+
readonly state: PromiseState<EffectClientOf<Actor>["state" & keyof EffectClientOf<Actor>]>;
|
|
61
|
+
readonly warm: () => Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type PromiseClient<Actors extends readonly ClientActor[]> = {
|
|
65
|
+
readonly [Actor in Actors[number] as Actor["name"]]: (key: Actor["key"]["Encoded"]) => PromiseActor<Actor>;
|
|
66
|
+
} & {
|
|
67
|
+
readonly close: () => Promise<void>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
interface PromiseClientOptions<Actors extends readonly ClientActor[]> extends AkterClientOptions {
|
|
71
|
+
readonly actors: Actors;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface WireValue {
|
|
75
|
+
readonly wire: unique symbol;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type WireUpdates = Stream.Stream<WireValue, Error>;
|
|
79
|
+
|
|
80
|
+
type WireCall = ((payload: WireValue) => Effect.Effect<WireValue, Error>) & {
|
|
81
|
+
readonly subscribe: (payload?: WireValue) => WireUpdates;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type WireWarm = Effect.Effect<WireValue, Error>;
|
|
85
|
+
|
|
86
|
+
type WireFork = (into: WireValue) => Effect.Effect<unknown, Error>;
|
|
87
|
+
|
|
88
|
+
type WireMember = WireCall | WireFork | WireUpdates | WireWarm;
|
|
89
|
+
|
|
90
|
+
type OnUpdate = (update: WireValue) => void;
|
|
91
|
+
|
|
92
|
+
type WithPayload = (payload: WireValue, onUpdate: OnUpdate, onError?: OnError) => Stop;
|
|
93
|
+
|
|
94
|
+
type WithoutPayload = (onUpdate: OnUpdate, onError?: OnError) => Stop;
|
|
95
|
+
|
|
96
|
+
interface WireSubscription {
|
|
97
|
+
readonly subscribe: WithPayload | WithoutPayload;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface WireStateSubscription {
|
|
101
|
+
readonly subscribe: WithoutPayload;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type WireActor = (key: string) => object;
|
|
105
|
+
|
|
106
|
+
interface WirePromiseClient {
|
|
107
|
+
readonly [actor: string]: WireActor | (() => Promise<void>);
|
|
108
|
+
readonly close: () => Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface WireClientActor {
|
|
112
|
+
readonly client: (key: WireValue) => Effect.Effect<Readonly<Record<string, WireMember>>, never, ClientServices>;
|
|
113
|
+
readonly fileAccess: Option.Option<unknown>;
|
|
114
|
+
readonly files: (key: WireValue) => Effect.Effect<FileClient, Error>;
|
|
115
|
+
readonly key: Schema.Codec<WireValue, string>;
|
|
116
|
+
readonly name: string;
|
|
117
|
+
readonly queries: Readonly<Record<string, { readonly payload: Schema.Top }>>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const filesMember = "files";
|
|
121
|
+
|
|
122
|
+
const stateMember = "state";
|
|
123
|
+
|
|
124
|
+
const warmMember = "warm";
|
|
125
|
+
|
|
126
|
+
const forkMember = "fork";
|
|
127
|
+
|
|
128
|
+
const thenMember = "then";
|
|
129
|
+
|
|
130
|
+
function wireActor(actor: ClientActor): WireClientActor;
|
|
131
|
+
function wireActor(actor: ClientActor): ClientActor {
|
|
132
|
+
return actor;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function asCall(member: WireMember): WireCall;
|
|
136
|
+
function asCall(member: WireMember): WireMember {
|
|
137
|
+
return member;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function asWarm(member: WireMember): WireWarm;
|
|
141
|
+
function asWarm(member: WireMember): WireMember {
|
|
142
|
+
return member;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function asFork(member: WireMember): WireFork;
|
|
146
|
+
function asFork(member: WireMember): WireMember {
|
|
147
|
+
return member;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function asUpdates(member: WireMember): WireUpdates;
|
|
151
|
+
function asUpdates(member: WireMember): WireMember {
|
|
152
|
+
return member;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type Runtime = ManagedRuntime.ManagedRuntime<ActorClients | RpcClient.Protocol, never>;
|
|
156
|
+
|
|
157
|
+
interface Listener {
|
|
158
|
+
readonly onError?: OnError | undefined;
|
|
159
|
+
readonly onUpdate: OnUpdate;
|
|
160
|
+
readonly updates: Stream.Stream<WireValue, Error, ActorClients | RpcClient.Protocol>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const listen = (runtime: Runtime, { onError, onUpdate, updates }: Listener): Stop => {
|
|
164
|
+
const delivered = Stream.runForEach(updates, (update) =>
|
|
165
|
+
Effect.sync(() => {
|
|
166
|
+
onUpdate(update);
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const reported = Effect.matchEffect(delivered, {
|
|
171
|
+
onFailure: (error) =>
|
|
172
|
+
Effect.sync(() => {
|
|
173
|
+
onError?.(error);
|
|
174
|
+
}),
|
|
175
|
+
onSuccess: () => Effect.void,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const fiber = runtime.runFork(reported);
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
runtime.runFork(Fiber.interrupt(fiber));
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const listenTo =
|
|
186
|
+
(runtime: Runtime, updates: Effect.Effect<WireUpdates, Schema.SchemaError, ClientServices>): WithoutPayload =>
|
|
187
|
+
(onUpdate, onError) =>
|
|
188
|
+
listen(runtime, { onError, onUpdate, updates: Stream.unwrap(updates) });
|
|
189
|
+
|
|
190
|
+
const memberOf = (
|
|
191
|
+
actor: WireClientActor,
|
|
192
|
+
key: string,
|
|
193
|
+
member: string,
|
|
194
|
+
): Effect.Effect<WireMember, Schema.SchemaError, ClientServices> =>
|
|
195
|
+
Effect.gen(function* findMember() {
|
|
196
|
+
const decoded = yield* Schema.decodeEffect(actor.key)(key);
|
|
197
|
+
const client = yield* actor.client(decoded);
|
|
198
|
+
|
|
199
|
+
return yield* Option.match(Rec.get(client, member), {
|
|
200
|
+
onNone: () => Effect.die(`${actor.name} has no member ${member}`),
|
|
201
|
+
onSome: Effect.succeed,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
interface Addressed {
|
|
206
|
+
readonly actor: WireClientActor;
|
|
207
|
+
readonly key: string;
|
|
208
|
+
readonly runtime: Runtime;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const callOf =
|
|
212
|
+
({ actor, key, runtime }: Addressed) =>
|
|
213
|
+
(member: string): ((payload: WireValue) => Promise<WireValue>) & WireSubscription => {
|
|
214
|
+
const found = memberOf(actor, key, member);
|
|
215
|
+
|
|
216
|
+
const call = (payload: WireValue): Promise<WireValue> =>
|
|
217
|
+
runtime.runPromise(Effect.scoped(Effect.flatMap(found, (send) => asCall(send)(payload))));
|
|
218
|
+
|
|
219
|
+
// A query declared without a payload has the Schema.Void payload, and subscribes with no payload argument.
|
|
220
|
+
if (Option.exists(Rec.get(actor.queries, member), (query) => query.payload === Schema.Void)) {
|
|
221
|
+
return Object.assign(call, {
|
|
222
|
+
subscribe: listenTo(
|
|
223
|
+
runtime,
|
|
224
|
+
Effect.map(found, (query) => asCall(query).subscribe()),
|
|
225
|
+
),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const withPayload: WithPayload = (payload, onUpdate, onError) =>
|
|
230
|
+
listenTo(
|
|
231
|
+
runtime,
|
|
232
|
+
Effect.map(found, (query) => asCall(query).subscribe(payload)),
|
|
233
|
+
)(onUpdate, onError);
|
|
234
|
+
|
|
235
|
+
return Object.assign(call, { subscribe: withPayload });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
interface Handle extends Addressed {
|
|
239
|
+
// Makes the handle on another key of the same actor, such as a fork's copy.
|
|
240
|
+
readonly open: WireActor;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Forks the actor at `key` into `into`, then answers a handle on the copy like the one `key` has.
|
|
244
|
+
const forkOf =
|
|
245
|
+
({ actor, key, open, runtime }: Handle) =>
|
|
246
|
+
(into: string): Promise<object> => {
|
|
247
|
+
const forked = Effect.gen(function* forkActor() {
|
|
248
|
+
const fork = yield* Effect.map(memberOf(actor, key, forkMember), asFork);
|
|
249
|
+
const decoded = yield* Schema.decodeEffect(actor.key)(into);
|
|
250
|
+
|
|
251
|
+
yield* fork(decoded);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return runtime.runPromise(Effect.as(Effect.scoped(forked), open(into)));
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const stateOf = ({ actor, key, runtime }: Addressed): WireStateSubscription => ({
|
|
258
|
+
subscribe: (onUpdate, onError) => {
|
|
259
|
+
const updates = Stream.unwrap(Effect.map(memberOf(actor, key, stateMember), asUpdates));
|
|
260
|
+
|
|
261
|
+
return listen(runtime, { onError, onUpdate, updates });
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const warmOf = ({ actor, key, runtime }: Addressed): (() => Promise<void>) => {
|
|
266
|
+
const warmed = Effect.flatMap(memberOf(actor, key, warmMember), (warm) => Effect.asVoid(asWarm(warm)));
|
|
267
|
+
|
|
268
|
+
return (): Promise<void> => runtime.runPromise(Effect.scoped(warmed));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const filesOf = ({ actor, key, runtime }: Addressed): PromiseFiles => {
|
|
272
|
+
const connected = Effect.flatMap(Schema.decodeEffect(actor.key)(key), actor.files);
|
|
273
|
+
|
|
274
|
+
return promiseFiles({ connected, run: (effect) => runtime.runPromise(effect) });
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// A command or query, or the handle's fork and warm.
|
|
278
|
+
const actionMember = (handle: Handle, member: string): object => {
|
|
279
|
+
if (member === forkMember) {
|
|
280
|
+
return forkOf(handle);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (member === warmMember) {
|
|
284
|
+
return warmOf(handle);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return callOf(handle)(member);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handleMember = (handle: Handle, member: string): object | undefined => {
|
|
291
|
+
// A handle is no promise: a fork resolves to one, and a promise would call its `then` as a member.
|
|
292
|
+
if (member === thenMember) {
|
|
293
|
+
return Option.getOrUndefined(Option.none<object>());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (member === stateMember) {
|
|
297
|
+
return stateOf(handle);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (member === filesMember) {
|
|
301
|
+
return Option.getOrUndefined(Option.map(handle.actor.fileAccess, () => filesOf(handle)));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return actionMember(handle, member);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const handleOf =
|
|
308
|
+
(runtime: Runtime, actor: WireClientActor): WireActor =>
|
|
309
|
+
(key) => {
|
|
310
|
+
const handle = { actor, key, open: handleOf(runtime, actor), runtime };
|
|
311
|
+
|
|
312
|
+
return new Proxy({}, { get: (_target, property) => handleMember(handle, String(property)) });
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
function createClient<const Actors extends readonly ClientActor[]>(
|
|
316
|
+
options: PromiseClientOptions<Actors>,
|
|
317
|
+
): PromiseClient<Actors>;
|
|
318
|
+
function createClient({ actors, ...connection }: PromiseClientOptions<readonly ClientActor[]>): WirePromiseClient {
|
|
319
|
+
const runtime = ManagedRuntime.make(akterClient(connection));
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
...Rec.fromEntries(Arr.map(actors, (actor) => [actor.name, handleOf(runtime, wireActor(actor))] as const)),
|
|
323
|
+
close: () => runtime.dispose(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export { createClient };
|
|
328
|
+
|
|
329
|
+
export {
|
|
330
|
+
ActorAlreadyExistsError,
|
|
331
|
+
ActorNotCreatedError,
|
|
332
|
+
ActorErasedError,
|
|
333
|
+
ActorUnavailableError,
|
|
334
|
+
BillingUnavailableError,
|
|
335
|
+
BlobTransferError,
|
|
336
|
+
CallerNotAllowedError,
|
|
337
|
+
CommandIdExpiredError,
|
|
338
|
+
FileAccessDeniedError,
|
|
339
|
+
FileNotFoundError,
|
|
340
|
+
FilesUnavailableError,
|
|
341
|
+
FileTooLargeError,
|
|
342
|
+
HandlerFailedError,
|
|
343
|
+
InvalidFileRequestError,
|
|
344
|
+
InvalidRequestError,
|
|
345
|
+
SpendCapReachedError,
|
|
346
|
+
StorageLimitError,
|
|
347
|
+
UnauthenticatedError,
|
|
348
|
+
} from "@akterjs/effect/contract/errors";
|
|
349
|
+
|
|
350
|
+
export { UploadSourceError } from "./upload-source.errors.js";
|
|
351
|
+
|
|
352
|
+
export type { PromiseActor, PromiseClient, PromiseClientOptions };
|
|
353
|
+
|
|
354
|
+
export type { FileListing, FilePage, FileRange, FileRef, FileUsage, PromiseFiles } from "./promise-files.pure.js";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Effect, Option, Predicate, Record as Rec, Stream } from "effect";
|
|
2
|
+
import type { FileClient, FileListing as EffectListing } from "@akterjs/effect/contract";
|
|
3
|
+
|
|
4
|
+
import { UploadSourceError } from "./upload-source.errors.js";
|
|
5
|
+
|
|
6
|
+
/** One file an actor holds. */
|
|
7
|
+
interface FileRef {
|
|
8
|
+
readonly bytes: number;
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly sha256: string;
|
|
11
|
+
readonly writtenAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Which files to list: those under `prefix`, at most `limit`, after the `cursor` a previous page returned. */
|
|
15
|
+
interface FileListing {
|
|
16
|
+
readonly cursor?: string | undefined;
|
|
17
|
+
readonly limit?: number | undefined;
|
|
18
|
+
readonly prefix?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A byte range of one file. */
|
|
22
|
+
interface FileRange {
|
|
23
|
+
readonly length: number;
|
|
24
|
+
readonly offset: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One page of files; `cursor` is present when there are more. */
|
|
28
|
+
interface FilePage {
|
|
29
|
+
readonly cursor?: string;
|
|
30
|
+
readonly entries: readonly FileRef[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One page of files and the total bytes every file the listing matches holds. */
|
|
34
|
+
interface FileUsage extends FilePage {
|
|
35
|
+
readonly bytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An actor's files over its `<Actor>/files/*` RPCs (ADR 0016). `upload` returns the receipt a command attaches the file
|
|
40
|
+
* with; the others read what the actor holds. Each rejects with the hosted error classes the Effect client fails with.
|
|
41
|
+
*/
|
|
42
|
+
interface PromiseFiles {
|
|
43
|
+
readonly download: (path: string, range?: FileRange) => Promise<ReadableStream<Uint8Array>>;
|
|
44
|
+
readonly list: (listing?: FileListing) => Promise<FilePage>;
|
|
45
|
+
readonly upload: (body: ReadableStream<Uint8Array> | Uint8Array) => Promise<string>;
|
|
46
|
+
readonly usage: (listing?: FileListing) => Promise<FileUsage>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Filing {
|
|
50
|
+
/** Connects to one actor's files. */
|
|
51
|
+
readonly connected: Effect.Effect<FileClient, Error>;
|
|
52
|
+
/** Runs an effect on the client's runtime. */
|
|
53
|
+
readonly run: <Success>(effect: Effect.Effect<Success, Error>) => Promise<Success>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface EffectPage {
|
|
57
|
+
readonly cursor: Option.Option<string>;
|
|
58
|
+
readonly entries: readonly FileRef[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pageOf = ({ cursor, entries }: EffectPage): FilePage =>
|
|
62
|
+
Option.match(cursor, {
|
|
63
|
+
onNone: (): FilePage => ({ entries }),
|
|
64
|
+
onSome: (next): FilePage => ({ cursor: next, entries }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const listingOf = ({ cursor, limit, prefix }: FileListing = {}): EffectListing => ({
|
|
68
|
+
...Rec.getSomes({ cursor: Option.fromUndefinedOr(cursor), prefix: Option.fromUndefinedOr(prefix) }),
|
|
69
|
+
...Rec.getSomes({ limit: Option.fromUndefinedOr(limit) }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const sourceOf = (body: ReadableStream<Uint8Array> | Uint8Array): Stream.Stream<Uint8Array, UploadSourceError> => {
|
|
73
|
+
if (Predicate.isUint8Array(body)) {
|
|
74
|
+
return Stream.make(body);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Stream.fromReadableStream({
|
|
78
|
+
evaluate: () => body,
|
|
79
|
+
onError: (cause) => UploadSourceError.make({ message: `the upload's source stream failed: ${String(cause)}` }),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The Promise face of one actor's file client. */
|
|
84
|
+
const promiseFiles = ({ connected, run }: Filing): PromiseFiles => ({
|
|
85
|
+
download: (path, range) =>
|
|
86
|
+
run(Effect.map(connected, (files) => Stream.toReadableStream(files.download(path, range)))),
|
|
87
|
+
list: (listing) =>
|
|
88
|
+
run(
|
|
89
|
+
Effect.map(
|
|
90
|
+
Effect.flatMap(connected, (files) => files.list(listingOf(listing))),
|
|
91
|
+
pageOf,
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
upload: (body) => run(Effect.flatMap(connected, (files) => files.upload(sourceOf(body)))),
|
|
95
|
+
usage: (listing) =>
|
|
96
|
+
run(
|
|
97
|
+
Effect.map(
|
|
98
|
+
Effect.flatMap(connected, (files) => files.fileUsage(listingOf(listing))),
|
|
99
|
+
(usage): FileUsage => ({ ...pageOf(usage), bytes: Number(usage.bytes) }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export { promiseFiles };
|
|
105
|
+
|
|
106
|
+
export type { FileListing, FilePage, FileRange, FileRef, FileUsage, PromiseFiles };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Array as Arr, Option, Predicate, Schema, String as Str } from "effect";
|
|
2
|
+
import type { RpcMessage } from "effect/rpc";
|
|
3
|
+
|
|
4
|
+
// The highest commit version this client has seen for each actor, sent as each request's `akter-floor` header.
|
|
5
|
+
// Akter Cloud answers each command and query `Exit` with the actor's `version`, and never serves a cached reading
|
|
6
|
+
// Older than a request's floor, so a query after the client's own command reads that command's write.
|
|
7
|
+
// A server that sends no versions, such as `akter dev`, never gets a floor.
|
|
8
|
+
interface ReadFloors {
|
|
9
|
+
readonly received: (clientId: number, response: RpcMessage.FromServerEncoded) => void;
|
|
10
|
+
readonly sent: (clientId: number, message: RpcMessage.FromClientEncoded) => RpcMessage.FromClientEncoded;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const floorHeader = "akter-floor";
|
|
14
|
+
|
|
15
|
+
const Keyed = Schema.Struct({ key: Schema.Unknown });
|
|
16
|
+
|
|
17
|
+
const Versioned = Schema.Struct({ requestId: Schema.Union([Schema.String, Schema.Finite]), version: Schema.Int });
|
|
18
|
+
|
|
19
|
+
const KeyText = Schema.fromJsonString(Schema.Unknown);
|
|
20
|
+
|
|
21
|
+
const keyedOf = Schema.decodeUnknownOption(Keyed);
|
|
22
|
+
|
|
23
|
+
const versionedOf = Schema.decodeUnknownOption(Versioned);
|
|
24
|
+
|
|
25
|
+
const keyTextOf = Schema.encodeUnknownOption(KeyText);
|
|
26
|
+
|
|
27
|
+
const actorKeyOf = ({ payload, tag }: RpcMessage.RequestEncoded): Option.Option<string> =>
|
|
28
|
+
Option.flatMap(keyedOf(payload), ({ key }) =>
|
|
29
|
+
Option.map(
|
|
30
|
+
keyTextOf(key),
|
|
31
|
+
(keyText) => `${Option.getOrElse(Arr.head(Str.split(tag, "/")), () => tag)}\n${keyText}`,
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const pendingOf = (clientId: number, requestId: number | string): string => `${clientId}/${requestId}`;
|
|
36
|
+
|
|
37
|
+
const trackedFloors = (): ReadFloors => {
|
|
38
|
+
const floors = new Map<string, number>();
|
|
39
|
+
const pending = new Map<string, string>();
|
|
40
|
+
|
|
41
|
+
const floored = (clientId: number, request: RpcMessage.RequestEncoded): RpcMessage.RequestEncoded =>
|
|
42
|
+
Option.match(actorKeyOf(request), {
|
|
43
|
+
onNone: () => request,
|
|
44
|
+
onSome: (actorKey) => {
|
|
45
|
+
pending.set(pendingOf(clientId, request.id), actorKey);
|
|
46
|
+
|
|
47
|
+
return Option.match(Option.fromNullishOr(floors.get(actorKey)), {
|
|
48
|
+
onNone: () => request,
|
|
49
|
+
onSome: (floor) => ({ ...request, headers: [...request.headers, [floorHeader, String(floor)]] }),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const sent: ReadFloors["sent"] = (clientId, message) => {
|
|
55
|
+
if (Predicate.isTagged(message, "Request")) {
|
|
56
|
+
return floored(clientId, message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return message;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const received: ReadFloors["received"] = (clientId, response) => {
|
|
63
|
+
if (!Predicate.isTagged(response, "Exit")) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const id = pendingOf(clientId, response.requestId);
|
|
68
|
+
const actorKey = pending.get(id);
|
|
69
|
+
|
|
70
|
+
pending.delete(id);
|
|
71
|
+
|
|
72
|
+
Option.map(Option.all([Option.fromNullishOr(actorKey), versionedOf(response)]), ([key, { version }]) => {
|
|
73
|
+
floors.set(key, Math.max(version, floors.get(key) ?? version));
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return { received, sent };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export { trackedFloors };
|
|
81
|
+
|
|
82
|
+
export type { ReadFloors };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Array as Arr, Option, Predicate, Schema, String as Str } from "effect";
|
|
2
|
+
import type { RpcMessage } from "effect/rpc";
|
|
3
|
+
|
|
4
|
+
import { ActorUnavailableError } from "@akterjs/effect/contract/errors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a server's answers become before they reach a caller: an `Exit` whose cause is only interrupts says the server
|
|
8
|
+
* stopped the request without an answer. Left alone, the caller's fiber ends interrupted with no message, and a CLI
|
|
9
|
+
* built on Effect's `runMain` exits 130 and prints nothing. This answers it as `ActorUnavailableError` for the request's
|
|
10
|
+
* actor instead, a typed error every call can fail with, which says to retry.
|
|
11
|
+
*/
|
|
12
|
+
interface RemoteInterrupts {
|
|
13
|
+
readonly received: (clientId: number, response: RpcMessage.FromServerEncoded) => RpcMessage.FromServerEncoded;
|
|
14
|
+
readonly sent: (clientId: number, message: RpcMessage.FromClientEncoded) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const InterruptReasons = Schema.NonEmptyArray(Schema.TaggedStruct("Interrupt", {}));
|
|
18
|
+
|
|
19
|
+
const InterruptedOnly = Schema.Struct({ exit: Schema.TaggedStruct("Failure", { cause: InterruptReasons }) });
|
|
20
|
+
|
|
21
|
+
const UnavailableWire = Schema.toEncoded(ActorUnavailableError);
|
|
22
|
+
|
|
23
|
+
const FailReason = Schema.TaggedStruct("Fail", { error: UnavailableWire });
|
|
24
|
+
|
|
25
|
+
/** An encoded exit that failed with one error, as Effect RPC writes it. */
|
|
26
|
+
const FailedExit = Schema.TaggedStruct("Failure", { cause: Schema.Tuple([FailReason]) });
|
|
27
|
+
|
|
28
|
+
const isInterruptedOnly = Schema.is(InterruptedOnly);
|
|
29
|
+
|
|
30
|
+
const encodeUnavailable = Schema.encodeOption(ActorUnavailableError);
|
|
31
|
+
|
|
32
|
+
const message = "the server stopped this request before it answered. Retry shortly.";
|
|
33
|
+
|
|
34
|
+
const requestOf = (clientId: number, requestId: number | string): string => `${clientId}/${requestId}`;
|
|
35
|
+
|
|
36
|
+
/** `Room/files/list` and `Room/post` are both Room's. */
|
|
37
|
+
const actorOf = (tag: string): string => Option.getOrElse(Arr.head(Str.split(tag, "/")), () => tag);
|
|
38
|
+
|
|
39
|
+
const failedWith = (error: typeof UnavailableWire.Type): RpcMessage.ExitEncoded<unknown, unknown> =>
|
|
40
|
+
FailedExit.make({ cause: [FailReason.make({ error })] });
|
|
41
|
+
|
|
42
|
+
const answered = (actor: string, response: RpcMessage.ResponseExitEncoded): RpcMessage.FromServerEncoded =>
|
|
43
|
+
Option.match(encodeUnavailable(ActorUnavailableError.make({ actor, message })), {
|
|
44
|
+
onNone: () => response,
|
|
45
|
+
onSome: (error) => ({ ...response, exit: failedWith(error) }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const remoteInterrupts = (): RemoteInterrupts => {
|
|
49
|
+
const actors = new Map<string, string>();
|
|
50
|
+
|
|
51
|
+
const sent: RemoteInterrupts["sent"] = (clientId, outgoing) => {
|
|
52
|
+
if (Predicate.isTagged(outgoing, "Request")) {
|
|
53
|
+
actors.set(requestOf(clientId, outgoing.id), actorOf(outgoing.tag));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const received: RemoteInterrupts["received"] = (clientId, response) => {
|
|
58
|
+
if (!Predicate.isTagged(response, "Exit")) {
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const id = requestOf(clientId, response.requestId);
|
|
63
|
+
const actor = Option.fromNullishOr(actors.get(id));
|
|
64
|
+
|
|
65
|
+
actors.delete(id);
|
|
66
|
+
|
|
67
|
+
if (Option.isNone(actor) || !isInterruptedOnly(response)) {
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return answered(actor.value, response);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return { received, sent };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export { remoteInterrupts };
|
|
78
|
+
|
|
79
|
+
export type { RemoteInterrupts };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/** The upload's source stream failed before the upload finished. */
|
|
4
|
+
class UploadSourceError extends Schema.TaggedError<UploadSourceError, { readonly brand: unique symbol }>()(
|
|
5
|
+
"UploadSourceError",
|
|
6
|
+
{ message: Schema.String },
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
export { UploadSourceError };
|