@loopbacktools/server 0.0.0-stage → 0.4.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 +21 -0
- package/README.md +26 -2
- package/dist/package.json +57 -0
- package/dist/src/domain/ask.d.ts +78 -0
- package/dist/src/domain/ask.js +161 -0
- package/dist/src/domain/errors.d.ts +70 -0
- package/dist/src/domain/errors.js +57 -0
- package/dist/src/generated/app.gen.d.ts +2311 -0
- package/dist/src/generated/app.gen.js +400 -0
- package/dist/src/generated/runtime.gen.d.ts +357 -0
- package/dist/src/generated/runtime.gen.js +298 -0
- package/dist/src/generated/schemas.gen.d.ts +6416 -0
- package/dist/src/generated/schemas.gen.js +306 -0
- package/dist/src/generated/wire-contract.gen.d.ts +77 -0
- package/dist/src/generated/wire-contract.gen.js +3 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +8 -0
- package/dist/src/loopback.d.ts +27 -0
- package/dist/src/loopback.js +16 -0
- package/dist/src/space/arguments.d.ts +8 -0
- package/dist/src/space/arguments.js +52 -0
- package/dist/src/space/migrations.d.ts +10 -0
- package/dist/src/space/migrations.js +106 -0
- package/dist/src/space/object.d.ts +30 -0
- package/dist/src/space/object.js +169 -0
- package/dist/src/space/operations.d.ts +149 -0
- package/dist/src/space/operations.js +568 -0
- package/dist/src/space/read.d.ts +8 -0
- package/dist/src/space/read.js +33 -0
- package/dist/src/space/rpc.d.ts +61 -0
- package/dist/src/space/rpc.js +96 -0
- package/dist/src/space/services.d.ts +41 -0
- package/dist/src/space/services.js +58 -0
- package/dist/src/space/store.d.ts +87 -0
- package/dist/src/space/store.js +276 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +3 -0
- package/dist/src/worker/env.d.ts +19 -0
- package/dist/src/worker/env.js +3 -0
- package/dist/src/worker/files.d.ts +21 -0
- package/dist/src/worker/files.js +42 -0
- package/dist/src/worker/gate.d.ts +55 -0
- package/dist/src/worker/gate.js +83 -0
- package/dist/src/worker/http.d.ts +2092 -0
- package/dist/src/worker/http.js +291 -0
- package/dist/src/worker/jwt.d.ts +49 -0
- package/dist/src/worker/jwt.js +142 -0
- package/dist/src/worker/mcp.d.ts +15 -0
- package/dist/src/worker/mcp.js +401 -0
- package/dist/src/worker/run.d.ts +45 -0
- package/dist/src/worker/run.js +71 -0
- package/dist/src/worker/spaces.d.ts +24 -0
- package/dist/src/worker/spaces.js +64 -0
- package/package.json +51 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Data, Effect, Schedule, Schema } from "effect";
|
|
2
|
+
import { AskClosed, AskNotFound, ContentInvalid, NotYourTurn, ReplyInvalid, TicketInvalid, UploadExpired, UploadNotFound, UploadRejected, } from "../domain/errors.js";
|
|
3
|
+
import { operations } from "./operations.js";
|
|
4
|
+
/** A deployment's build, from its version metadata binding, if it has one. */
|
|
5
|
+
export const buildOf = (env) => {
|
|
6
|
+
const metadata = env
|
|
7
|
+
.CF_VERSION_METADATA;
|
|
8
|
+
return typeof metadata?.timestamp === "string" ? metadata.timestamp : undefined;
|
|
9
|
+
};
|
|
10
|
+
/** Whether a build is from a later deploy than another; unknown when either is. */
|
|
11
|
+
export const isLater = (build, than) => build !== undefined && than !== undefined && Date.parse(build) > Date.parse(than);
|
|
12
|
+
/**
|
|
13
|
+
* A space's refusal of a call from newer code than its own: it ran nothing, and restarts onto the new
|
|
14
|
+
* code a moment later. The client tries the call again, whatever it is, and never hands this on.
|
|
15
|
+
*/
|
|
16
|
+
export class Outdated extends Schema.TaggedError()("Outdated", {}) {
|
|
17
|
+
}
|
|
18
|
+
/** Every error a space operation fails with. */
|
|
19
|
+
export const SpaceFailure = Schema.Union([
|
|
20
|
+
AskNotFound,
|
|
21
|
+
AskClosed,
|
|
22
|
+
NotYourTurn,
|
|
23
|
+
ReplyInvalid,
|
|
24
|
+
ContentInvalid,
|
|
25
|
+
TicketInvalid,
|
|
26
|
+
UploadNotFound,
|
|
27
|
+
UploadExpired,
|
|
28
|
+
UploadRejected,
|
|
29
|
+
]);
|
|
30
|
+
/** What a call can fail with across the boundary: an operation's errors, or the space's refusal. */
|
|
31
|
+
const CallFailure = Schema.Union([...SpaceFailure.members, Outdated]);
|
|
32
|
+
// A defect keeps its stack across the hop, so it is logged where it happened: this codec only ever
|
|
33
|
+
// runs between the Worker and its own Durable Object, never towards a client.
|
|
34
|
+
let codec;
|
|
35
|
+
const exitCodec = () => (codec ??= Schema.toCodecJson(Schema.Exit(Schema.Json, CallFailure, Schema.Defect({ includeStack: true }))));
|
|
36
|
+
export const encodeExit = (exit) => Schema.encodeSync(exitCodec())(exit);
|
|
37
|
+
const decodeExit = (json) => Schema.decodeUnknownSync(exitCodec())(json);
|
|
38
|
+
/**
|
|
39
|
+
* Operations that are safe to run twice: reads, and writes that leave the space as the first run
|
|
40
|
+
* did. Only these are retried. A reply, an upload ticket, or an upload claim is not: a second run
|
|
41
|
+
* is refused, or issues another.
|
|
42
|
+
*/
|
|
43
|
+
const idempotent = new Set([
|
|
44
|
+
"open",
|
|
45
|
+
"answerFollowUp",
|
|
46
|
+
"answerBriefing",
|
|
47
|
+
"awaitReply",
|
|
48
|
+
"markDelivered",
|
|
49
|
+
"followTask",
|
|
50
|
+
"cancelTask",
|
|
51
|
+
"get",
|
|
52
|
+
"snapshot",
|
|
53
|
+
"history",
|
|
54
|
+
"findUpload",
|
|
55
|
+
]);
|
|
56
|
+
/** A call the object never answered: it was reset (by a deploy, say), or the transport failed. */
|
|
57
|
+
class Refused extends Data.TaggedError("Refused") {
|
|
58
|
+
/** Whether Cloudflare marks it safe to try again: retryable, and not because the object is overloaded. */
|
|
59
|
+
get retryable() {
|
|
60
|
+
const flags = this.cause;
|
|
61
|
+
return flags?.retryable === true && flags.overloaded !== true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** A deploy resets every space; its new instance is up within moments. */
|
|
65
|
+
const RETRY = {
|
|
66
|
+
schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
|
|
67
|
+
times: 5,
|
|
68
|
+
while: (refused) => refused.retryable,
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* A space restarting onto new code takes a moment, and refuses as `Outdated` until it has; the
|
|
72
|
+
* retries outlast that.
|
|
73
|
+
*/
|
|
74
|
+
const OUTDATED_RETRY = {
|
|
75
|
+
schedule: Schedule.exponential("50 millis").pipe(Schedule.jittered),
|
|
76
|
+
times: 6,
|
|
77
|
+
while: (failure) => failure instanceof Outdated,
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The typed client for one space, for code of the version `caller` names. Each call is decoded back
|
|
81
|
+
* into the operation's own success value or tagged error. A call the object refused is tried again on
|
|
82
|
+
* a fresh stub when it is safe to, as Cloudflare advises; otherwise it is a defect. A call a space
|
|
83
|
+
* refused as `Outdated` ran nothing, so it is tried again whatever it is, until the space is on the
|
|
84
|
+
* new code.
|
|
85
|
+
*/
|
|
86
|
+
export const spaceClient = (stub, caller) => Object.fromEntries(Object.keys(operations).map((method) => {
|
|
87
|
+
const once = (args) => Effect.tryPromise({
|
|
88
|
+
try: () => stub().call(method, args, caller),
|
|
89
|
+
catch: (cause) => new Refused({ cause }),
|
|
90
|
+
});
|
|
91
|
+
const attempt = (args) => (idempotent.has(method) ? Effect.retry(once(args), RETRY) : once(args)).pipe(Effect.catchTag("Refused", (refused) => Effect.die(refused.cause)), Effect.flatMap((json) => decodeExit(json)));
|
|
92
|
+
return [
|
|
93
|
+
method,
|
|
94
|
+
(...args) => attempt(args).pipe(Effect.retry(OUTDATED_RETRY), Effect.catchIf((failure) => failure instanceof Outdated, () => Effect.die(new Error("The space did not move onto this code in time.")))),
|
|
95
|
+
];
|
|
96
|
+
}));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ServerEvent } from "@loopbacktools/protocol";
|
|
2
|
+
import { Context, Effect, Layer, PubSub } from "effect";
|
|
3
|
+
/** The longest a single wait for a reply may be held inside the object. */
|
|
4
|
+
export declare const MAX_WAIT_MS = 30000;
|
|
5
|
+
/** How long an ask still shows an agent following it after its last lap or poll. */
|
|
6
|
+
export declare const PRESENCE_GRACE_MS = 10000;
|
|
7
|
+
declare const Replies_base: Context.ServiceClass<Replies, "loopback/Replies", PubSub.PubSub<void>>;
|
|
8
|
+
/**
|
|
9
|
+
* A signal for every reply the space records. A caller waiting on the space subscribes before it
|
|
10
|
+
* first looks, so a reply landing between its looking and its waiting still wakes it.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Replies extends Replies_base {
|
|
13
|
+
static readonly layer: Layer.Layer<Replies, never, never>;
|
|
14
|
+
}
|
|
15
|
+
declare const Presence_base: Context.ServiceClass<Presence, "loopback/Presence", {
|
|
16
|
+
readonly isWaiting: (askId: string) => boolean;
|
|
17
|
+
/** A lap or poll begins. True when that starts the ask being followed. */
|
|
18
|
+
readonly begin: (askId: string) => Effect.Effect<boolean>;
|
|
19
|
+
/** A lap or poll ends; `whenGone` runs if no other begins within the grace. */
|
|
20
|
+
readonly end: <R>(askId: string, whenGone: Effect.Effect<void, never, R>) => Effect.Effect<void, never, R>;
|
|
21
|
+
/** The agent has said it stopped following: no grace. True when that ends the following. */
|
|
22
|
+
readonly drop: (askId: string) => Effect.Effect<boolean>;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Which asks an agent is following right now: blocked on in laps, or polling as a background task.
|
|
26
|
+
* An ask stays followed for `PRESENCE_GRACE_MS` after the last lap or poll ends; if none comes by
|
|
27
|
+
* then, the agent has gone. That grace is a fiber per ask, so a lap that begins in time cancels it.
|
|
28
|
+
*/
|
|
29
|
+
export declare class Presence extends Presence_base {
|
|
30
|
+
static readonly layer: Layer.Layer<Presence, never, never>;
|
|
31
|
+
}
|
|
32
|
+
declare const Host_base: Context.ServiceClass<Host, "loopback/Host", {
|
|
33
|
+
/** Sends an event to every connected inbox. */
|
|
34
|
+
readonly broadcast: (event: ServerEvent) => Effect.Effect<void>;
|
|
35
|
+
/** The DO id, which upload URLs name the space by. */
|
|
36
|
+
readonly spaceId: string;
|
|
37
|
+
}>;
|
|
38
|
+
/** What the space's operations need from the object that hosts them. */
|
|
39
|
+
export declare class Host extends Host_base {
|
|
40
|
+
}
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Context, Effect, FiberMap, Layer, PubSub } from "effect";
|
|
2
|
+
/** The longest a single wait for a reply may be held inside the object. */
|
|
3
|
+
export const MAX_WAIT_MS = 30_000;
|
|
4
|
+
/** How long an ask still shows an agent following it after its last lap or poll. */
|
|
5
|
+
export const PRESENCE_GRACE_MS = 10_000;
|
|
6
|
+
/**
|
|
7
|
+
* A signal for every reply the space records. A caller waiting on the space subscribes before it
|
|
8
|
+
* first looks, so a reply landing between its looking and its waiting still wakes it.
|
|
9
|
+
*/
|
|
10
|
+
export class Replies extends Context.Service()("loopback/Replies") {
|
|
11
|
+
static layer = Layer.effect(Replies, PubSub.unbounded());
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Which asks an agent is following right now: blocked on in laps, or polling as a background task.
|
|
15
|
+
* An ask stays followed for `PRESENCE_GRACE_MS` after the last lap or poll ends; if none comes by
|
|
16
|
+
* then, the agent has gone. That grace is a fiber per ask, so a lap that begins in time cancels it.
|
|
17
|
+
*/
|
|
18
|
+
export class Presence extends Context.Service()("loopback/Presence") {
|
|
19
|
+
static layer = Layer.effect(Presence, Effect.gen(function* () {
|
|
20
|
+
const laps = new Map();
|
|
21
|
+
// Asks in their grace: still shown as followed until their timer fires or a lap begins.
|
|
22
|
+
const leaving = new Set();
|
|
23
|
+
const timers = yield* FiberMap.make();
|
|
24
|
+
const isWaiting = (askId) => laps.has(askId) || leaving.has(askId);
|
|
25
|
+
return Presence.of({
|
|
26
|
+
isWaiting,
|
|
27
|
+
begin: (askId) => Effect.gen(function* () {
|
|
28
|
+
const was = isWaiting(askId);
|
|
29
|
+
leaving.delete(askId);
|
|
30
|
+
yield* FiberMap.remove(timers, askId);
|
|
31
|
+
laps.set(askId, (laps.get(askId) ?? 0) + 1);
|
|
32
|
+
return !was;
|
|
33
|
+
}),
|
|
34
|
+
end: (askId, whenGone) => Effect.gen(function* () {
|
|
35
|
+
const left = (laps.get(askId) ?? 1) - 1;
|
|
36
|
+
if (left > 0) {
|
|
37
|
+
laps.set(askId, left);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
laps.delete(askId);
|
|
41
|
+
leaving.add(askId);
|
|
42
|
+
yield* FiberMap.run(timers, askId, Effect.sleep(PRESENCE_GRACE_MS).pipe(Effect.andThen(Effect.suspend(() => {
|
|
43
|
+
leaving.delete(askId);
|
|
44
|
+
return whenGone;
|
|
45
|
+
}))));
|
|
46
|
+
}),
|
|
47
|
+
drop: (askId) => Effect.gen(function* () {
|
|
48
|
+
if (laps.has(askId) || !leaving.delete(askId))
|
|
49
|
+
return false;
|
|
50
|
+
yield* FiberMap.remove(timers, askId);
|
|
51
|
+
return true;
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/** What the space's operations need from the object that hosts them. */
|
|
57
|
+
export class Host extends Context.Service()("loopback/Host") {
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { AskStatus, Entry, ImageType } from "@loopbacktools/protocol";
|
|
2
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
|
+
import type { AskRecord, EntryRecord } from "../domain/ask.ts";
|
|
5
|
+
/** A page of closed asks, and where the next one starts. */
|
|
6
|
+
export interface ClosedPage {
|
|
7
|
+
readonly records: AskRecord[];
|
|
8
|
+
readonly nextBefore: string | null;
|
|
9
|
+
}
|
|
10
|
+
declare const AskStore_base: Context.ServiceClass<AskStore, "loopback/AskStore", {
|
|
11
|
+
readonly find: (id: string) => Effect.Effect<Option.Option<AskRecord>>;
|
|
12
|
+
/** The newest ask with this key, still open or holding an undelivered reply, waited on since `since`. */
|
|
13
|
+
readonly findRetry: (key: string, since: string) => Effect.Effect<Option.Option<AskRecord>>;
|
|
14
|
+
readonly insert: (record: AskRecord) => Effect.Effect<void>;
|
|
15
|
+
readonly setStatus: (id: string, status: AskStatus, at: string) => Effect.Effect<void>;
|
|
16
|
+
readonly touchWaited: (id: string, at: string) => Effect.Effect<void>;
|
|
17
|
+
/** An ask's thread. One holding an entry this code can't read is a defect. */
|
|
18
|
+
readonly thread: (id: string) => Effect.Effect<EntryRecord[]>;
|
|
19
|
+
/** The threads of several asks, leaving out each ask whose thread this code can't read. */
|
|
20
|
+
readonly threads: (ids: readonly string[]) => Effect.Effect<Map<string, EntryRecord[]>>;
|
|
21
|
+
readonly append: (askId: string, entry: Entry) => Effect.Effect<EntryRecord>;
|
|
22
|
+
/**
|
|
23
|
+
* The oldest human entry not yet handed to an agent: replies reach it in the order they were
|
|
24
|
+
* given, so replies on a briefing are not passed over for a dismissal that followed them.
|
|
25
|
+
*/
|
|
26
|
+
readonly undelivered: (askId: string) => Effect.Effect<Option.Option<EntryRecord>>;
|
|
27
|
+
readonly markDelivered: (askId: string, seq: number, at: string) => Effect.Effect<void>;
|
|
28
|
+
/** Marks every human entry on the ask as handed to the agent. */
|
|
29
|
+
readonly markAllDelivered: (askId: string, at: string) => Effect.Effect<void>;
|
|
30
|
+
/** Every open ask, newest first. */
|
|
31
|
+
readonly open: Effect.Effect<AskRecord[]>;
|
|
32
|
+
/** Up to `limit` closed asks, newest first, closed before `before` when given. */
|
|
33
|
+
readonly closed: (before: string | undefined, limit: number) => Effect.Effect<ClosedPage>;
|
|
34
|
+
/** Records that an agent stopped following the reply to its turn `afterSeq`. */
|
|
35
|
+
readonly cancelTask: (askId: string, afterSeq: number, at: string) => Effect.Effect<void>;
|
|
36
|
+
readonly taskCancelledAt: (askId: string, afterSeq: number) => Effect.Effect<Option.Option<string>>;
|
|
37
|
+
/** Runs `effect` as one storage transaction. */
|
|
38
|
+
readonly transaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Persistence for asks and their threads. Storage failing is a defect, not an error to handle.
|
|
42
|
+
*
|
|
43
|
+
* Every ask and entry is stamped with the revision of the contract it was written at. An ask's is the
|
|
44
|
+
* newest any of it was written at, its thread included, so code reading it knows from that alone
|
|
45
|
+
* whether it can. One from a later revision than this code's, as after a rollback, is never read:
|
|
46
|
+
* left out of every list and found by no lookup, so nothing here rewrites it. Everything read is
|
|
47
|
+
* decoded, and a row that doesn't decode is passed over the same way, and logged, rather than
|
|
48
|
+
* handed on. See docs/design/versioned-messages.md.
|
|
49
|
+
*/
|
|
50
|
+
export declare class AskStore extends AskStore_base {
|
|
51
|
+
static readonly layer: Layer.Layer<AskStore, never, SqlClient.SqlClient>;
|
|
52
|
+
}
|
|
53
|
+
declare const TicketStore_base: Context.ServiceClass<TicketStore, "loopback/TicketStore", {
|
|
54
|
+
readonly insert: (tokenHash: string, actorId: string, expiresAt: string) => Effect.Effect<void>;
|
|
55
|
+
/** Removes the ticket and gives back who it was for and until when; none if it is unknown. */
|
|
56
|
+
readonly take: (tokenHash: string) => Effect.Effect<Option.Option<{
|
|
57
|
+
readonly actorId: string;
|
|
58
|
+
readonly expiresAt: string;
|
|
59
|
+
}>>;
|
|
60
|
+
/** Forgets tickets that expired before `at`. */
|
|
61
|
+
readonly purge: (at: string) => Effect.Effect<void>;
|
|
62
|
+
}>;
|
|
63
|
+
/** Persistence for event tickets, kept only by their hash. */
|
|
64
|
+
export declare class TicketStore extends TicketStore_base {
|
|
65
|
+
static readonly layer: Layer.Layer<TicketStore, never, SqlClient.SqlClient>;
|
|
66
|
+
}
|
|
67
|
+
export interface UploadRecord {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly tokenHash: string;
|
|
70
|
+
readonly contentType: ImageType;
|
|
71
|
+
readonly maxBytes: number;
|
|
72
|
+
readonly status: "pending" | "claimed" | "stored";
|
|
73
|
+
readonly size: number | null;
|
|
74
|
+
readonly createdAt: string;
|
|
75
|
+
readonly expiresAt: string;
|
|
76
|
+
}
|
|
77
|
+
declare const UploadStore_base: Context.ServiceClass<UploadStore, "loopback/UploadStore", {
|
|
78
|
+
readonly insert: (record: UploadRecord) => Effect.Effect<void>;
|
|
79
|
+
readonly find: (id: string) => Effect.Effect<Option.Option<UploadRecord>>;
|
|
80
|
+
readonly findByToken: (tokenHash: string) => Effect.Effect<Option.Option<UploadRecord>>;
|
|
81
|
+
readonly setStatus: (id: string, status: UploadRecord["status"], size: number | null) => Effect.Effect<void>;
|
|
82
|
+
}>;
|
|
83
|
+
/** Persistence for upload tickets and the uploads they produce. */
|
|
84
|
+
export declare class UploadStore extends UploadStore_base {
|
|
85
|
+
static readonly layer: Layer.Layer<UploadStore, never, SqlClient.SqlClient>;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as Contract from "@loopbacktools/protocol/schema";
|
|
2
|
+
import { Context, Effect, Layer, Option, Result } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
|
+
import { read } from "./read.js";
|
|
5
|
+
const { REVISION } = Contract;
|
|
6
|
+
/** A column holding a message as JSON, decoded. */
|
|
7
|
+
const parsed = (schema, json) => {
|
|
8
|
+
try {
|
|
9
|
+
return read(schema, JSON.parse(json));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return Result.fail("it is not JSON");
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A stored ask as this code reads it, or why it can't: what a deploy once left half-written, say.
|
|
17
|
+
* What `read` hands back holds nothing from a newer revision, so it is what this code writes.
|
|
18
|
+
*/
|
|
19
|
+
const askOf = (row) => Result.gen(function* () {
|
|
20
|
+
const head = {
|
|
21
|
+
id: row.id,
|
|
22
|
+
key: row.key,
|
|
23
|
+
status: (yield* read(Contract.AskStatus, row.status)),
|
|
24
|
+
project: row.project,
|
|
25
|
+
client: row.client,
|
|
26
|
+
raisedBy: row.raisedBy === null ? null : (yield* parsed(Contract.Actor, row.raisedBy)),
|
|
27
|
+
createdAt: row.createdAt,
|
|
28
|
+
updatedAt: row.updatedAt,
|
|
29
|
+
closedAt: row.closedAt,
|
|
30
|
+
lastWaitedAt: row.lastWaitedAt,
|
|
31
|
+
};
|
|
32
|
+
switch (row.kind) {
|
|
33
|
+
case "question": {
|
|
34
|
+
const prompt = yield* parsed(Contract.QuestionPrompt, row.prompt);
|
|
35
|
+
return { ...head, kind: "question", prompt: prompt };
|
|
36
|
+
}
|
|
37
|
+
case "review": {
|
|
38
|
+
const prompt = yield* parsed(Contract.ReviewPrompt, row.prompt);
|
|
39
|
+
return {
|
|
40
|
+
...head,
|
|
41
|
+
kind: "review",
|
|
42
|
+
prompt: prompt,
|
|
43
|
+
revises: row.revises,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case "briefing": {
|
|
47
|
+
const prompt = yield* parsed(Contract.BriefingPrompt, row.prompt);
|
|
48
|
+
return { ...head, kind: "briefing", prompt: prompt };
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return yield* Result.fail(`it is of the kind "${row.kind}"`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
/** A stored thread entry as this code reads it, or why it can't. */
|
|
55
|
+
const entryOf = (row) => row.revision > REVISION
|
|
56
|
+
? Result.fail(`it was written at revision ${row.revision}, after this code's`)
|
|
57
|
+
: Result.map(parsed(Contract.Entry, row.body), (entry) => ({
|
|
58
|
+
askId: row.askId,
|
|
59
|
+
seq: row.seq,
|
|
60
|
+
entry: entry,
|
|
61
|
+
deliveredAt: row.deliveredAt,
|
|
62
|
+
}));
|
|
63
|
+
/**
|
|
64
|
+
* Persistence for asks and their threads. Storage failing is a defect, not an error to handle.
|
|
65
|
+
*
|
|
66
|
+
* Every ask and entry is stamped with the revision of the contract it was written at. An ask's is the
|
|
67
|
+
* newest any of it was written at, its thread included, so code reading it knows from that alone
|
|
68
|
+
* whether it can. One from a later revision than this code's, as after a rollback, is never read:
|
|
69
|
+
* left out of every list and found by no lookup, so nothing here rewrites it. Everything read is
|
|
70
|
+
* decoded, and a row that doesn't decode is passed over the same way, and logged, rather than
|
|
71
|
+
* handed on. See docs/design/versioned-messages.md.
|
|
72
|
+
*/
|
|
73
|
+
export class AskStore extends Context.Service()("loopback/AskStore") {
|
|
74
|
+
static layer = Layer.effect(AskStore, Effect.gen(function* () {
|
|
75
|
+
const sql = yield* SqlClient.SqlClient;
|
|
76
|
+
/** The asks among `rows` this code can read. */
|
|
77
|
+
const readable = Effect.fnUntraced(function* (rows) {
|
|
78
|
+
const records = [];
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const record = askOf(row);
|
|
81
|
+
if (Result.isSuccess(record))
|
|
82
|
+
records.push(record.success);
|
|
83
|
+
else
|
|
84
|
+
yield* Effect.logError(`Ask ${row.id} can't be read, so it is left out: ${record.failure}.`);
|
|
85
|
+
}
|
|
86
|
+
return records;
|
|
87
|
+
});
|
|
88
|
+
const asks = (statement) => statement.pipe(Effect.orDie, Effect.flatMap(readable));
|
|
89
|
+
// Rows from a later revision are left out by every query, never read.
|
|
90
|
+
const columns = sql.literal(`
|
|
91
|
+
id, kind, key, status, prompt, project, client, raised_by AS raisedBy, revises,
|
|
92
|
+
created_at AS createdAt, updated_at AS updatedAt, closed_at AS closedAt,
|
|
93
|
+
last_waited_at AS lastWaitedAt
|
|
94
|
+
`);
|
|
95
|
+
const entryColumns = sql.literal("ask_id AS askId, seq, body, delivered_at AS deliveredAt, revision");
|
|
96
|
+
/** An ask's entries, or a defect when one can't be read: an operation must see its whole thread. */
|
|
97
|
+
const entries = (askId, rows) => Effect.forEach(rows, (row) => Result.match(entryOf(row), {
|
|
98
|
+
onSuccess: Effect.succeed,
|
|
99
|
+
onFailure: (why) => Effect.die(new Error(`Entry ${row.seq} of ask ${askId} can't be read: ${why}.`)),
|
|
100
|
+
}));
|
|
101
|
+
const first = (effect) => Effect.map(effect, (rows) => Option.fromNullishOr(rows[0]));
|
|
102
|
+
return AskStore.of({
|
|
103
|
+
find: (id) => first(asks(sql `SELECT ${columns} FROM asks WHERE id = ${id} AND revision <= ${REVISION}`)),
|
|
104
|
+
findRetry: (key, since) => first(asks(sql `
|
|
105
|
+
SELECT ${columns} FROM asks
|
|
106
|
+
WHERE key = ${key}
|
|
107
|
+
AND revision <= ${REVISION}
|
|
108
|
+
AND last_waited_at >= ${since}
|
|
109
|
+
AND (
|
|
110
|
+
status != 'closed'
|
|
111
|
+
OR EXISTS (
|
|
112
|
+
SELECT 1 FROM entries
|
|
113
|
+
WHERE entries.ask_id = asks.id
|
|
114
|
+
AND entries.author = 'human'
|
|
115
|
+
AND entries.delivered_at IS NULL
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
ORDER BY created_at DESC
|
|
119
|
+
LIMIT 1
|
|
120
|
+
`)),
|
|
121
|
+
insert: (record) => sql `INSERT INTO asks ${sql.insert({
|
|
122
|
+
id: record.id,
|
|
123
|
+
kind: record.kind,
|
|
124
|
+
key: record.key,
|
|
125
|
+
status: record.status,
|
|
126
|
+
prompt: JSON.stringify(record.prompt),
|
|
127
|
+
project: record.project,
|
|
128
|
+
client: record.client,
|
|
129
|
+
raised_by: record.raisedBy === null ? null : JSON.stringify(record.raisedBy),
|
|
130
|
+
revises: record.kind === "review" ? record.revises : null,
|
|
131
|
+
created_at: record.createdAt,
|
|
132
|
+
updated_at: record.updatedAt,
|
|
133
|
+
closed_at: record.closedAt,
|
|
134
|
+
last_waited_at: record.lastWaitedAt,
|
|
135
|
+
revision: REVISION,
|
|
136
|
+
})}`.pipe(Effect.asVoid, Effect.orDie),
|
|
137
|
+
setStatus: (id, status, at) => sql `
|
|
138
|
+
UPDATE asks
|
|
139
|
+
SET status = ${status},
|
|
140
|
+
updated_at = ${at},
|
|
141
|
+
closed_at = ${status === "closed" ? at : null},
|
|
142
|
+
revision = MAX(revision, ${REVISION})
|
|
143
|
+
WHERE id = ${id}
|
|
144
|
+
`.pipe(Effect.asVoid, Effect.orDie),
|
|
145
|
+
touchWaited: (id, at) => sql `UPDATE asks SET last_waited_at = ${at} WHERE id = ${id}`.pipe(Effect.asVoid, Effect.orDie),
|
|
146
|
+
thread: (id) => sql `SELECT ${entryColumns} FROM entries WHERE ask_id = ${id} ORDER BY seq`.pipe(Effect.orDie, Effect.flatMap((rows) => entries(id, rows))),
|
|
147
|
+
// The ids travel as one JSON parameter: SQLite in a Durable Object binds at most 100.
|
|
148
|
+
threads: (ids) => ids.length === 0
|
|
149
|
+
? Effect.succeed(new Map())
|
|
150
|
+
: sql `
|
|
151
|
+
SELECT ${entryColumns} FROM entries
|
|
152
|
+
WHERE ask_id IN (SELECT value FROM json_each(${JSON.stringify(ids)}))
|
|
153
|
+
ORDER BY ask_id, seq
|
|
154
|
+
`.pipe(Effect.orDie, Effect.flatMap(Effect.fnUntraced(function* (rows) {
|
|
155
|
+
const byAsk = new Map(ids.map((id) => [id, []]));
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const thread = byAsk.get(row.askId);
|
|
158
|
+
if (thread === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const entry = entryOf(row);
|
|
161
|
+
if (Result.isSuccess(entry)) {
|
|
162
|
+
thread.push(entry.success);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
byAsk.delete(row.askId);
|
|
166
|
+
yield* Effect.logError(`Ask ${row.askId} can't be read, so it is left out: entry ${row.seq} can't, as ${entry.failure}.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return byAsk;
|
|
170
|
+
}))),
|
|
171
|
+
append: (askId, entry) => Effect.gen(function* () {
|
|
172
|
+
const [row] = yield* sql `
|
|
173
|
+
SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM entries WHERE ask_id = ${askId}
|
|
174
|
+
`;
|
|
175
|
+
const seq = row?.next ?? 1;
|
|
176
|
+
yield* sql `INSERT INTO entries ${sql.insert({
|
|
177
|
+
ask_id: askId,
|
|
178
|
+
seq,
|
|
179
|
+
author: entry.author,
|
|
180
|
+
body: JSON.stringify(entry),
|
|
181
|
+
delivered_at: null,
|
|
182
|
+
revision: REVISION,
|
|
183
|
+
})}`;
|
|
184
|
+
yield* sql `UPDATE asks SET revision = MAX(revision, ${REVISION}) WHERE id = ${askId}`;
|
|
185
|
+
return { askId, seq, entry, deliveredAt: null };
|
|
186
|
+
}).pipe(Effect.orDie),
|
|
187
|
+
undelivered: (askId) => sql `
|
|
188
|
+
SELECT ${entryColumns} FROM entries
|
|
189
|
+
WHERE ask_id = ${askId} AND author = 'human' AND delivered_at IS NULL
|
|
190
|
+
ORDER BY seq
|
|
191
|
+
LIMIT 1
|
|
192
|
+
`.pipe(Effect.orDie, Effect.flatMap((rows) => entries(askId, rows)), Effect.map((found) => Option.fromNullishOr(found[0]))),
|
|
193
|
+
markDelivered: (askId, seq, at) => sql `
|
|
194
|
+
UPDATE entries SET delivered_at = ${at}
|
|
195
|
+
WHERE ask_id = ${askId} AND seq = ${seq} AND delivered_at IS NULL
|
|
196
|
+
`.pipe(Effect.asVoid, Effect.orDie),
|
|
197
|
+
markAllDelivered: (askId, at) => sql `
|
|
198
|
+
UPDATE entries SET delivered_at = ${at}
|
|
199
|
+
WHERE ask_id = ${askId} AND author = 'human' AND delivered_at IS NULL
|
|
200
|
+
`.pipe(Effect.asVoid, Effect.orDie),
|
|
201
|
+
open: asks(sql `
|
|
202
|
+
SELECT ${columns} FROM asks
|
|
203
|
+
WHERE status != 'closed' AND revision <= ${REVISION}
|
|
204
|
+
ORDER BY created_at DESC
|
|
205
|
+
`),
|
|
206
|
+
// One row past the page says whether there is another, counted before any is left out.
|
|
207
|
+
closed: (before, limit) => (before === undefined
|
|
208
|
+
? sql `
|
|
209
|
+
SELECT ${columns} FROM asks
|
|
210
|
+
WHERE status = 'closed' AND revision <= ${REVISION}
|
|
211
|
+
ORDER BY closed_at DESC LIMIT ${limit + 1}
|
|
212
|
+
`
|
|
213
|
+
: sql `
|
|
214
|
+
SELECT ${columns} FROM asks
|
|
215
|
+
WHERE status = 'closed' AND revision <= ${REVISION} AND closed_at < ${before}
|
|
216
|
+
ORDER BY closed_at DESC LIMIT ${limit + 1}
|
|
217
|
+
`).pipe(Effect.orDie, Effect.flatMap((rows) => Effect.map(readable(rows.slice(0, limit)), (records) => ({
|
|
218
|
+
records,
|
|
219
|
+
nextBefore: rows.length > limit ? (rows[limit - 1]?.closedAt ?? null) : null,
|
|
220
|
+
})))),
|
|
221
|
+
cancelTask: (askId, afterSeq, at) => sql `
|
|
222
|
+
INSERT OR IGNORE INTO task_cancellations (ask_id, after_seq, cancelled_at)
|
|
223
|
+
VALUES (${askId}, ${afterSeq}, ${at})
|
|
224
|
+
`.pipe(Effect.asVoid, Effect.orDie),
|
|
225
|
+
taskCancelledAt: (askId, afterSeq) => sql `
|
|
226
|
+
SELECT cancelled_at AS cancelledAt FROM task_cancellations
|
|
227
|
+
WHERE ask_id = ${askId} AND after_seq = ${afterSeq}
|
|
228
|
+
`.pipe(Effect.map((rows) => Option.map(Option.fromNullishOr(rows[0]), (row) => row.cancelledAt)), Effect.orDie),
|
|
229
|
+
transaction: (effect) => sql.withTransaction(effect).pipe(Effect.catchTag("SqlError", Effect.die)),
|
|
230
|
+
});
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
/** Persistence for event tickets, kept only by their hash. */
|
|
234
|
+
export class TicketStore extends Context.Service()("loopback/TicketStore") {
|
|
235
|
+
static layer = Layer.effect(TicketStore, Effect.gen(function* () {
|
|
236
|
+
const sql = yield* SqlClient.SqlClient;
|
|
237
|
+
return TicketStore.of({
|
|
238
|
+
insert: (tokenHash, actorId, expiresAt) => sql `INSERT INTO event_tickets ${sql.insert({
|
|
239
|
+
token_hash: tokenHash,
|
|
240
|
+
actor_id: actorId,
|
|
241
|
+
expires_at: expiresAt,
|
|
242
|
+
})}`.pipe(Effect.asVoid, Effect.orDie),
|
|
243
|
+
take: (tokenHash) => sql `
|
|
244
|
+
DELETE FROM event_tickets WHERE token_hash = ${tokenHash}
|
|
245
|
+
RETURNING actor_id AS actorId, expires_at AS expiresAt
|
|
246
|
+
`.pipe(Effect.map((rows) => Option.fromNullishOr(rows[0])), Effect.orDie),
|
|
247
|
+
purge: (at) => sql `DELETE FROM event_tickets WHERE expires_at < ${at}`.pipe(Effect.asVoid, Effect.orDie),
|
|
248
|
+
});
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
/** Persistence for upload tickets and the uploads they produce. */
|
|
252
|
+
export class UploadStore extends Context.Service()("loopback/UploadStore") {
|
|
253
|
+
static layer = Layer.effect(UploadStore, Effect.gen(function* () {
|
|
254
|
+
const sql = yield* SqlClient.SqlClient;
|
|
255
|
+
const columns = sql.literal(`
|
|
256
|
+
id, token_hash AS tokenHash, content_type AS contentType, max_bytes AS maxBytes,
|
|
257
|
+
status, size, created_at AS createdAt, expires_at AS expiresAt
|
|
258
|
+
`);
|
|
259
|
+
const one = (statement) => statement.pipe(Effect.map((rows) => Option.fromNullishOr(rows[0])), Effect.orDie);
|
|
260
|
+
return UploadStore.of({
|
|
261
|
+
insert: (record) => sql `INSERT INTO uploads ${sql.insert({
|
|
262
|
+
id: record.id,
|
|
263
|
+
token_hash: record.tokenHash,
|
|
264
|
+
content_type: record.contentType,
|
|
265
|
+
max_bytes: record.maxBytes,
|
|
266
|
+
status: record.status,
|
|
267
|
+
size: record.size,
|
|
268
|
+
created_at: record.createdAt,
|
|
269
|
+
expires_at: record.expiresAt,
|
|
270
|
+
})}`.pipe(Effect.asVoid, Effect.orDie),
|
|
271
|
+
find: (id) => one(sql `SELECT ${columns} FROM uploads WHERE id = ${id}`),
|
|
272
|
+
findByToken: (tokenHash) => one(sql `SELECT ${columns} FROM uploads WHERE token_hash = ${tokenHash}`),
|
|
273
|
+
setStatus: (id, status, size) => sql `UPDATE uploads SET status = ${status}, size = ${size} WHERE id = ${id}`.pipe(Effect.asVoid, Effect.orDie),
|
|
274
|
+
});
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
/** The bindings a Loopback Worker must declare, under these names. */
|
|
3
|
+
export interface LoopbackBindings {
|
|
4
|
+
readonly FILES: R2Bucket;
|
|
5
|
+
/**
|
|
6
|
+
* Which deploy the code is from. A space still running older code than the Worker calling it
|
|
7
|
+
* restarts onto the newer; without it, nothing is compared. Declare it in every deployment.
|
|
8
|
+
*/
|
|
9
|
+
readonly CF_VERSION_METADATA?: WorkerVersionMetadata;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The Worker's environment as the runtime hands it over: Loopback's bindings, and whatever else the
|
|
13
|
+
* deployment declares, which a gate may read (the token gate reads `LOOPBACK_TOKEN`).
|
|
14
|
+
*/
|
|
15
|
+
export type WorkerBindings = LoopbackBindings & Readonly<Record<string, unknown>>;
|
|
16
|
+
declare const WorkerEnv_base: Context.ServiceClass<WorkerEnv, "loopback/WorkerEnv", WorkerBindings>;
|
|
17
|
+
export declare class WorkerEnv extends WorkerEnv_base {
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { UploadIncomplete } from "../domain/errors.ts";
|
|
3
|
+
import { WorkerEnv } from "./env.ts";
|
|
4
|
+
declare const Files_base: Context.ServiceClass<Files, "loopback/Files", {
|
|
5
|
+
/**
|
|
6
|
+
* Stores exactly `length` bytes from `body`. A body that turns out longer or shorter than it
|
|
7
|
+
* declared is refused by the stream itself, so the size limit holds whatever the client claims.
|
|
8
|
+
* R2 failing is a defect: nothing the client sent caused it.
|
|
9
|
+
*/
|
|
10
|
+
readonly put: (key: string, body: ReadableStream<Uint8Array>, length: number, contentType: string) => Effect.Effect<void, UploadIncomplete>;
|
|
11
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<ReadableStream<Uint8Array>>>;
|
|
12
|
+
}>;
|
|
13
|
+
/** Uploaded images, kept in R2 under the space that holds them. */
|
|
14
|
+
export declare class Files extends Files_base {
|
|
15
|
+
static readonly layer: Layer.Layer<Files, never, WorkerEnv>;
|
|
16
|
+
}
|
|
17
|
+
/** Where every file a space holds lives, under one prefix. */
|
|
18
|
+
export declare const filesOf: (spaceId: string) => string;
|
|
19
|
+
/** Where an upload's bytes live. */
|
|
20
|
+
export declare const fileKey: (spaceId: string, uploadId: string) => string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { UploadIncomplete } from "../domain/errors.js";
|
|
3
|
+
import { WorkerEnv } from "./env.js";
|
|
4
|
+
/** Uploaded images, kept in R2 under the space that holds them. */
|
|
5
|
+
export class Files extends Context.Service()("loopback/Files") {
|
|
6
|
+
static layer = Layer.effect(Files, Effect.gen(function* () {
|
|
7
|
+
const env = yield* WorkerEnv;
|
|
8
|
+
return Files.of({
|
|
9
|
+
put: (key, body, length, contentType) => Effect.gen(function* () {
|
|
10
|
+
const sized = new FixedLengthStream(length);
|
|
11
|
+
// A wrong length fails both the pipe and R2's put, as R2 failing does, so what the
|
|
12
|
+
// body itself did decides whose fault it was.
|
|
13
|
+
let seen = 0;
|
|
14
|
+
let ended = false;
|
|
15
|
+
const counted = body.pipeThrough(new TransformStream({
|
|
16
|
+
transform: (chunk, controller) => {
|
|
17
|
+
seen += chunk.byteLength;
|
|
18
|
+
controller.enqueue(chunk);
|
|
19
|
+
},
|
|
20
|
+
flush: () => {
|
|
21
|
+
ended = true;
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
const [stored, piped] = yield* Effect.promise(() => Promise.allSettled([
|
|
25
|
+
env.FILES.put(key, sized.readable, { httpMetadata: { contentType } }),
|
|
26
|
+
counted.pipeTo(sized.writable),
|
|
27
|
+
]));
|
|
28
|
+
if (seen > length || (ended && seen !== length))
|
|
29
|
+
return yield* new UploadIncomplete();
|
|
30
|
+
if (stored.status === "rejected")
|
|
31
|
+
return yield* Effect.die(stored.reason);
|
|
32
|
+
if (piped.status === "rejected")
|
|
33
|
+
return yield* Effect.die(piped.reason);
|
|
34
|
+
}),
|
|
35
|
+
get: (key) => Effect.promise(() => env.FILES.get(key)).pipe(Effect.map((object) => Option.map(Option.fromNullishOr(object), (found) => found.body))),
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
/** Where every file a space holds lives, under one prefix. */
|
|
40
|
+
export const filesOf = (spaceId) => `spaces/${spaceId}/`;
|
|
41
|
+
/** Where an upload's bytes live. */
|
|
42
|
+
export const fileKey = (spaceId, uploadId) => `${filesOf(spaceId)}uploads/${uploadId}`;
|