@loopbacktools/server 0.0.0-stage → 0.4.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -2
  3. package/dist/package.json +57 -0
  4. package/dist/src/domain/ask.d.ts +78 -0
  5. package/dist/src/domain/ask.js +161 -0
  6. package/dist/src/domain/errors.d.ts +70 -0
  7. package/dist/src/domain/errors.js +57 -0
  8. package/dist/src/generated/app.gen.d.ts +2311 -0
  9. package/dist/src/generated/app.gen.js +400 -0
  10. package/dist/src/generated/runtime.gen.d.ts +357 -0
  11. package/dist/src/generated/runtime.gen.js +298 -0
  12. package/dist/src/generated/schemas.gen.d.ts +6416 -0
  13. package/dist/src/generated/schemas.gen.js +306 -0
  14. package/dist/src/generated/wire-contract.gen.d.ts +77 -0
  15. package/dist/src/generated/wire-contract.gen.js +3 -0
  16. package/dist/src/index.d.ts +11 -0
  17. package/dist/src/index.js +8 -0
  18. package/dist/src/loopback.d.ts +27 -0
  19. package/dist/src/loopback.js +16 -0
  20. package/dist/src/space/arguments.d.ts +8 -0
  21. package/dist/src/space/arguments.js +52 -0
  22. package/dist/src/space/migrations.d.ts +10 -0
  23. package/dist/src/space/migrations.js +106 -0
  24. package/dist/src/space/object.d.ts +30 -0
  25. package/dist/src/space/object.js +169 -0
  26. package/dist/src/space/operations.d.ts +149 -0
  27. package/dist/src/space/operations.js +568 -0
  28. package/dist/src/space/read.d.ts +8 -0
  29. package/dist/src/space/read.js +33 -0
  30. package/dist/src/space/rpc.d.ts +61 -0
  31. package/dist/src/space/rpc.js +96 -0
  32. package/dist/src/space/services.d.ts +41 -0
  33. package/dist/src/space/services.js +58 -0
  34. package/dist/src/space/store.d.ts +87 -0
  35. package/dist/src/space/store.js +276 -0
  36. package/dist/src/version.d.ts +2 -0
  37. package/dist/src/version.js +3 -0
  38. package/dist/src/worker/env.d.ts +19 -0
  39. package/dist/src/worker/env.js +3 -0
  40. package/dist/src/worker/files.d.ts +21 -0
  41. package/dist/src/worker/files.js +42 -0
  42. package/dist/src/worker/gate.d.ts +55 -0
  43. package/dist/src/worker/gate.js +83 -0
  44. package/dist/src/worker/http.d.ts +2092 -0
  45. package/dist/src/worker/http.js +291 -0
  46. package/dist/src/worker/jwt.d.ts +49 -0
  47. package/dist/src/worker/jwt.js +142 -0
  48. package/dist/src/worker/mcp.d.ts +15 -0
  49. package/dist/src/worker/mcp.js +401 -0
  50. package/dist/src/worker/run.d.ts +45 -0
  51. package/dist/src/worker/run.js +71 -0
  52. package/dist/src/worker/spaces.d.ts +24 -0
  53. package/dist/src/worker/spaces.js +64 -0
  54. 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,2 @@
1
+ /** This release of Loopback: what the MCP server and the OpenAPI document report. */
2
+ export declare const VERSION: string;
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+ /** This release of Loopback: what the MCP server and the OpenAPI document report. */
3
+ export const VERSION = packageJson.version;
@@ -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,3 @@
1
+ import { Context } from "effect";
2
+ export class WorkerEnv extends Context.Service()("loopback/WorkerEnv") {
3
+ }
@@ -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}`;