@takosjp/yurucommu-core 3.2.0 → 3.3.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.
Files changed (45) hide show
  1. package/migrations/0019_notification_push_delivery.sql +10 -7
  2. package/migrations/0020_call_sessions.sql +23 -0
  3. package/package.json +6 -2
  4. package/packages/api/package.json +1 -1
  5. package/packages/api/src/index.ts +1 -0
  6. package/packages/api/src/lib/api/browser-push.ts +11 -0
  7. package/packages/api/src/lib/api/normalize.ts +15 -4
  8. package/packages/api/src/lib/api/notification-target.ts +106 -0
  9. package/packages/api/src/lib/api/push-config.ts +132 -0
  10. package/packages/api/src/lib/api.ts +2 -0
  11. package/packages/api/src/lib/rtc-client.ts +542 -0
  12. package/packages/api/src/social-server.ts +7 -0
  13. package/packages/api/src/types/call.ts +306 -0
  14. package/packages/api/src/types/index.ts +16 -4
  15. package/src/backend/index.ts +65 -10
  16. package/src/backend/lib/attachments.ts +52 -0
  17. package/src/backend/lib/delivery/queue.ts +55 -0
  18. package/src/backend/lib/notification-eligibility.ts +150 -0
  19. package/src/backend/lib/notification-push.ts +159 -146
  20. package/src/backend/lib/rtc/call-store.ts +101 -0
  21. package/src/backend/lib/rtc/provider.ts +135 -0
  22. package/src/backend/lib/rtc/signal-transport.ts +125 -0
  23. package/src/backend/lib/session-actor.ts +16 -1
  24. package/src/backend/lib/unread-counts.ts +79 -0
  25. package/src/backend/middleware/csrf.ts +11 -0
  26. package/src/backend/public.ts +3 -0
  27. package/src/backend/routes/account-teardown.ts +13 -0
  28. package/src/backend/routes/activitypub.ts +5 -1
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +131 -3
  31. package/src/backend/routes/communities/messages.ts +16 -33
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +5 -39
  34. package/src/backend/routes/notifications.ts +27 -70
  35. package/src/backend/routes/posts/transformers.ts +8 -10
  36. package/src/backend/routes/rtc/index.ts +147 -0
  37. package/src/backend/runtime/call-hub-core.ts +470 -0
  38. package/src/backend/runtime/call-hub-port.ts +78 -0
  39. package/src/backend/runtime/call-signaling-do.ts +242 -0
  40. package/src/backend/runtime/signaling-hub.ts +187 -0
  41. package/src/backend/types.ts +28 -0
  42. package/src/db/index.ts +11 -10
  43. package/src/db/schema/calls.ts +46 -0
  44. package/src/db/schema/index.ts +1 -0
  45. package/src/db/schema/mobile.ts +7 -9
@@ -0,0 +1,242 @@
1
+ /**
2
+ * CallSignalingDurableObject — per-local-user signaling hub (call feature).
3
+ *
4
+ * One DO instance per local actor (`idFromName(actorApId)`). It is the standing
5
+ * presence socket the browser connects to (so an incoming ring can arrive before
6
+ * any call object exists), the fan-in point for cross-instance signals delivered
7
+ * to `/ap/rtc/signal`, and the owner of the per-call state machine (via the
8
+ * runtime-neutral `CallHub`).
9
+ *
10
+ * Uses Hibernatable WebSockets: idle presence sockets can be evicted and the DO
11
+ * reconstructs its `CallHub` from durable storage (`call:*` records) on wake.
12
+ * The CF/DO + Hibernatable-WebSocket surface is typed file-locally so this file
13
+ * does not depend on a specific `@cloudflare/workers-types` version.
14
+ */
15
+
16
+ import { getDb } from "../../db/index.ts";
17
+ import type { EnvVars } from "../types.ts";
18
+ import { CallHub, type CallRecord } from "./call-hub-core.ts";
19
+ import { createCallHubPort } from "./call-hub-port.ts";
20
+ import type {
21
+ ClientToHubFrame,
22
+ HubToClientFrame,
23
+ RtcSignalEnvelopeV1,
24
+ } from "../../../packages/api/src/types/call.ts";
25
+ import {
26
+ isTerminalCallState,
27
+ parseRtcSignalEnvelope,
28
+ } from "../../../packages/api/src/types/call.ts";
29
+
30
+ // --- Minimal Cloudflare DO + Hibernatable WebSocket surface ----------------
31
+ interface DoWebSocket {
32
+ send(data: string): void;
33
+ close(code?: number, reason?: string): void;
34
+ }
35
+ interface DoStorage {
36
+ get<T = unknown>(key: string): Promise<T | undefined>;
37
+ put(key: string, value: unknown): Promise<void>;
38
+ delete(key: string): Promise<boolean>;
39
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
40
+ setAlarm(scheduledTime: number): Promise<void>;
41
+ getAlarm(): Promise<number | null>;
42
+ }
43
+ interface DoState {
44
+ acceptWebSocket(ws: DoWebSocket, tags?: string[]): void;
45
+ getWebSockets(tag?: string): DoWebSocket[];
46
+ readonly storage: DoStorage;
47
+ }
48
+ declare const WebSocketPair: {
49
+ new (): { 0: DoWebSocket; 1: DoWebSocket };
50
+ };
51
+
52
+ type CallDoEnv = EnvVars & {
53
+ DB: D1Database;
54
+ CALL_SIGNALING?: DurableObjectNamespace;
55
+ };
56
+
57
+ const ALARM_INTERVAL_MS = 15_000;
58
+ const ACTOR_KEY = "actor";
59
+ const CALL_PREFIX = "call:";
60
+
61
+ export class CallSignalingDurableObject {
62
+ private hub: CallHub | null = null;
63
+ private actorApId: string | null = null;
64
+
65
+ constructor(
66
+ private readonly state: DoState,
67
+ private readonly env: CallDoEnv,
68
+ ) {}
69
+
70
+ // -------------------------------------------------------------------------
71
+ // HTTP entry (from the CloudflareSignalingHub adapter)
72
+ // -------------------------------------------------------------------------
73
+ async fetch(request: Request): Promise<Response> {
74
+ const url = new URL(request.url);
75
+ if (url.pathname === "/_ws") {
76
+ return this.handleUpgrade(request, url);
77
+ }
78
+ if (url.pathname === "/_ingest") {
79
+ return this.handleIngest(request);
80
+ }
81
+ return new Response("not found", { status: 404 });
82
+ }
83
+
84
+ private async handleUpgrade(request: Request, url: URL): Promise<Response> {
85
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
86
+ return new Response("expected websocket", { status: 426 });
87
+ }
88
+ const actor =
89
+ request.headers.get("X-Call-Actor") ?? url.searchParams.get("actor");
90
+ if (!actor) return new Response("missing actor", { status: 400 });
91
+ await this.setActor(actor);
92
+
93
+ const pair = new WebSocketPair();
94
+ const client = pair[0];
95
+ const server = pair[1];
96
+ this.state.acceptWebSocket(server);
97
+ await this.scheduleAlarm();
98
+ return new Response(null, {
99
+ status: 101,
100
+ // `webSocket` is a Cloudflare-specific ResponseInit field.
101
+ webSocket: client,
102
+ } as unknown as ResponseInit);
103
+ }
104
+
105
+ private async handleIngest(request: Request): Promise<Response> {
106
+ let body: unknown;
107
+ try {
108
+ body = await request.json();
109
+ } catch {
110
+ return new Response("bad json", { status: 400 });
111
+ }
112
+ const envelope = parseRtcSignalEnvelope(body);
113
+ if (!envelope) return new Response("bad envelope", { status: 400 });
114
+ await this.setActor(envelope.to);
115
+ const hub = await this.ensureHub();
116
+ if (!hub) return new Response("no actor", { status: 409 });
117
+ await hub.handleInboundSignal(envelope);
118
+ await this.scheduleAlarm();
119
+ return new Response(null, { status: 204 });
120
+ }
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Hibernatable WebSocket events
124
+ // -------------------------------------------------------------------------
125
+ async webSocketMessage(
126
+ ws: DoWebSocket,
127
+ message: string | ArrayBuffer,
128
+ ): Promise<void> {
129
+ if (typeof message !== "string") return;
130
+ let frame: ClientToHubFrame;
131
+ try {
132
+ const parsed = JSON.parse(message);
133
+ if (!parsed || typeof parsed.t !== "string") return;
134
+ frame = parsed as ClientToHubFrame;
135
+ } catch {
136
+ return;
137
+ }
138
+ const hub = await this.ensureHub();
139
+ if (!hub) {
140
+ this.send(ws, { t: "error", code: "no_session" });
141
+ return;
142
+ }
143
+ await hub.handleClientFrame(this.wrap(ws), frame);
144
+ await this.scheduleAlarm();
145
+ }
146
+
147
+ async webSocketClose(ws: DoWebSocket): Promise<void> {
148
+ try {
149
+ ws.close();
150
+ } catch {
151
+ // already closing
152
+ }
153
+ }
154
+
155
+ async webSocketError(): Promise<void> {
156
+ // getWebSockets() excludes the errored socket automatically.
157
+ }
158
+
159
+ async alarm(): Promise<void> {
160
+ const hub = await this.ensureHub();
161
+ hub?.tick();
162
+ const active = (hub?.activeCalls().length ?? 0) > 0;
163
+ const connected = this.state.getWebSockets().length > 0;
164
+ if (active || connected) await this.scheduleAlarm(true);
165
+ }
166
+
167
+ // -------------------------------------------------------------------------
168
+ // Internals
169
+ // -------------------------------------------------------------------------
170
+ private async setActor(actor: string): Promise<void> {
171
+ if (this.actorApId === actor) return;
172
+ this.actorApId = actor;
173
+ await this.state.storage.put(ACTOR_KEY, actor);
174
+ }
175
+
176
+ private async ensureHub(): Promise<CallHub | null> {
177
+ if (this.hub) return this.hub;
178
+ const actor =
179
+ this.actorApId ?? (await this.state.storage.get<string>(ACTOR_KEY));
180
+ if (!actor) return null;
181
+ this.actorApId = actor;
182
+
183
+ const db = getDb(this.env.DB);
184
+ const storage = this.state.storage;
185
+ const base = createCallHubPort({
186
+ localActorApId: actor,
187
+ db,
188
+ env: this.env,
189
+ broadcast: (frame: HubToClientFrame) => {
190
+ for (const ws of this.state.getWebSockets()) this.send(ws, frame);
191
+ },
192
+ hasClients: () => this.state.getWebSockets().length > 0,
193
+ });
194
+ // Layer durable DO storage on top of the D1 persist so the in-memory call
195
+ // map survives hibernation.
196
+ const hub = new CallHub({
197
+ ...base,
198
+ persist: async (call: CallRecord) => {
199
+ if (isTerminalCallState(call.state)) {
200
+ await storage.delete(`${CALL_PREFIX}${call.callId}`);
201
+ } else {
202
+ await storage.put(`${CALL_PREFIX}${call.callId}`, call);
203
+ }
204
+ await base.persist?.(call);
205
+ },
206
+ });
207
+ const stored = await storage.list<CallRecord>({ prefix: CALL_PREFIX });
208
+ hub.hydrate([...stored.values()]);
209
+ this.hub = hub;
210
+ return hub;
211
+ }
212
+
213
+ private wrap(ws: DoWebSocket) {
214
+ return {
215
+ send: (frame: HubToClientFrame) => this.send(ws, frame),
216
+ close: (code?: number, reason?: string) => {
217
+ try {
218
+ ws.close(code, reason);
219
+ } catch {
220
+ // ignore
221
+ }
222
+ },
223
+ };
224
+ }
225
+
226
+ private send(ws: DoWebSocket, frame: HubToClientFrame): void {
227
+ try {
228
+ ws.send(JSON.stringify(frame));
229
+ } catch {
230
+ // socket gone; getWebSockets() will drop it
231
+ }
232
+ }
233
+
234
+ private async scheduleAlarm(force = false): Promise<void> {
235
+ const existing = await this.state.storage.getAlarm();
236
+ if (existing !== null && !force) return;
237
+ await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
238
+ }
239
+ }
240
+
241
+ // Re-exported here so the type is available to callers that only import the DO.
242
+ export type { RtcSignalEnvelopeV1 };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * ISignalingHub — the seam that decouples call signaling from the Durable Object
3
+ * runtime. The `/api/rtc/socket` and `/ap/rtc/signal` routes talk to this
4
+ * interface; on Cloudflare it forwards to the per-user `CallSignalingDurable
5
+ * Object`, and on a Bun/Node self-host it uses an in-process hub.
6
+ *
7
+ * The DO instance itself is addressed by `idFromName(actorApId)`, so signaling
8
+ * for a given local user always lands on the same object regardless of which
9
+ * edge handled the request.
10
+ */
11
+
12
+ import type { Env } from "../types.ts";
13
+ import type { RtcSignalEnvelopeV1 } from "../../../packages/api/src/types/call.ts";
14
+ import type {
15
+ ClientToHubFrame,
16
+ HubToClientFrame,
17
+ } from "../../../packages/api/src/types/call.ts";
18
+ import { CallHub, type HubConnection } from "./call-hub-core.ts";
19
+ import { createCallHubPort } from "./call-hub-port.ts";
20
+ import { logger } from "../lib/logger.ts";
21
+
22
+ const log = logger.child({ component: "rtc.hub" });
23
+
24
+ export interface ISignalingHub {
25
+ /** Handle a browser WebSocket upgrade for `actorApId` (returns 101). */
26
+ upgrade(request: Request, actorApId: string): Promise<Response>;
27
+ /** Push an inbound cross-instance signal to `actorApId`'s live sockets. */
28
+ deliver(actorApId: string, envelope: RtcSignalEnvelopeV1): Promise<void>;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Cloudflare: forward to the per-user Durable Object
33
+ // ---------------------------------------------------------------------------
34
+ class CloudflareSignalingHub implements ISignalingHub {
35
+ constructor(private readonly ns: DurableObjectNamespace) {}
36
+
37
+ private stub(actorApId: string): DurableObjectStub {
38
+ return this.ns.get(this.ns.idFromName(actorApId));
39
+ }
40
+
41
+ async upgrade(request: Request, actorApId: string): Promise<Response> {
42
+ const headers = new Headers(request.headers);
43
+ headers.set("X-Call-Actor", actorApId);
44
+ const forwarded = new Request("https://call-do/_ws", {
45
+ method: "GET",
46
+ headers,
47
+ });
48
+ return this.stub(actorApId).fetch(
49
+ forwarded as unknown as Parameters<DurableObjectStub["fetch"]>[0],
50
+ ) as unknown as Promise<Response>;
51
+ }
52
+
53
+ async deliver(
54
+ actorApId: string,
55
+ envelope: RtcSignalEnvelopeV1,
56
+ ): Promise<void> {
57
+ await this.stub(actorApId).fetch("https://call-do/_ingest", {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify(envelope),
61
+ });
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // In-process (Bun/Node self-host): a per-actor CallHub + live socket set.
67
+ // The Bun server WebSocket wiring drives attach()/message()/detach(); the DO-
68
+ // less runtime therefore keeps calls working without Cloudflare.
69
+ // ---------------------------------------------------------------------------
70
+ export interface LocalSocket {
71
+ send(data: string): void;
72
+ close(code?: number, reason?: string): void;
73
+ }
74
+
75
+ interface LocalUserHub {
76
+ hub: CallHub;
77
+ sockets: Set<LocalSocket>;
78
+ }
79
+
80
+ class LocalSignalingHub implements ISignalingHub {
81
+ private readonly users = new Map<string, LocalUserHub>();
82
+
83
+ constructor(private readonly env: Env) {}
84
+
85
+ private getOrCreate(actorApId: string): LocalUserHub {
86
+ let entry = this.users.get(actorApId);
87
+ if (entry) return entry;
88
+ const sockets = new Set<LocalSocket>();
89
+ const port = createCallHubPort({
90
+ localActorApId: actorApId,
91
+ db: this.env.DB_INSTANCE,
92
+ env: this.env,
93
+ broadcast: (frame: HubToClientFrame) => {
94
+ const data = JSON.stringify(frame);
95
+ for (const s of sockets) {
96
+ try {
97
+ s.send(data);
98
+ } catch {
99
+ // drop dead socket on next detach
100
+ }
101
+ }
102
+ },
103
+ hasClients: () => sockets.size > 0,
104
+ });
105
+ entry = { hub: new CallHub(port), sockets };
106
+ this.users.set(actorApId, entry);
107
+ return entry;
108
+ }
109
+
110
+ private wrap(socket: LocalSocket): HubConnection {
111
+ return {
112
+ send: (frame) => socket.send(JSON.stringify(frame)),
113
+ close: (code, reason) => socket.close(code, reason),
114
+ };
115
+ }
116
+
117
+ /** Register a live browser socket (called by the Bun server WS handler). */
118
+ attach(actorApId: string, socket: LocalSocket): void {
119
+ this.getOrCreate(actorApId).sockets.add(socket);
120
+ }
121
+
122
+ detach(actorApId: string, socket: LocalSocket): void {
123
+ this.users.get(actorApId)?.sockets.delete(socket);
124
+ }
125
+
126
+ /** Route a browser frame (called by the Bun server WS message handler). */
127
+ async message(
128
+ actorApId: string,
129
+ socket: LocalSocket,
130
+ raw: string,
131
+ ): Promise<void> {
132
+ let frame: ClientToHubFrame;
133
+ try {
134
+ const parsed = JSON.parse(raw);
135
+ if (!parsed || typeof parsed.t !== "string") return;
136
+ frame = parsed as ClientToHubFrame;
137
+ } catch {
138
+ return;
139
+ }
140
+ await this.getOrCreate(actorApId).hub.handleClientFrame(
141
+ this.wrap(socket),
142
+ frame,
143
+ );
144
+ }
145
+
146
+ async upgrade(_request: Request, _actorApId: string): Promise<Response> {
147
+ // The Bun runtime upgrades WebSockets at the server boundary (server.upgrade)
148
+ // and drives attach()/message()/detach() directly, so this Hono-level path is
149
+ // never used there. Reaching it means a runtime without Durable Objects and
150
+ // without the Bun WS wiring.
151
+ return new Response(
152
+ JSON.stringify({
153
+ error: "signaling_unavailable",
154
+ message: "Call signaling requires the Durable Objects runtime.",
155
+ }),
156
+ { status: 503, headers: { "Content-Type": "application/json" } },
157
+ );
158
+ }
159
+
160
+ async deliver(
161
+ actorApId: string,
162
+ envelope: RtcSignalEnvelopeV1,
163
+ ): Promise<void> {
164
+ await this.getOrCreate(actorApId).hub.handleInboundSignal(envelope);
165
+ }
166
+ }
167
+
168
+ // One in-process hub per worker process (Bun/Node path only).
169
+ let localHubSingleton: LocalSignalingHub | null = null;
170
+
171
+ /** Resolve the signaling hub for this runtime. */
172
+ export function getSignalingHub(env: Env): ISignalingHub {
173
+ if (env.CALL_SIGNALING) {
174
+ return new CloudflareSignalingHub(env.CALL_SIGNALING);
175
+ }
176
+ if (!localHubSingleton) localHubSingleton = new LocalSignalingHub(env);
177
+ return localHubSingleton;
178
+ }
179
+
180
+ /** Whether calls can be served on this runtime (a signaling transport exists). */
181
+ export function isSignalingAvailable(env: Env): boolean {
182
+ // Cloudflare DO binding is the supported production transport. (The in-process
183
+ // Bun hub exists but its browser WS wiring is host-server-driven.)
184
+ return Boolean(env.CALL_SIGNALING);
185
+ }
186
+
187
+ export { log as signalingLog };
@@ -85,6 +85,30 @@ export interface EnvVars {
85
85
  // 既存動作と同じ (= APP_URL 単一 origin のみ accept、 production 影響ゼロ)。
86
86
  CSRF_ALLOWED_ORIGINS?: string;
87
87
 
88
+ // --- Call feature (WebRTC voice + video) -------------------------------
89
+ // ICE (STUN/TURN) servers advertised to authenticated call clients as a JSON
90
+ // array of { urls, username?, credential? }. When TURN uses coturn's REST
91
+ // ephemeral-credential scheme instead, set YURUCOMMU_RTC_TURN_URIS +
92
+ // YURUCOMMU_RTC_TURN_SECRET and the app mints short-lived creds per request.
93
+ // Unset => STUN-only (P2P still works on permissive networks; TURN is the
94
+ // single biggest determinant of cross-network 1:1 success, so operators
95
+ // should configure it). None of this is required for the worker to boot.
96
+ YURUCOMMU_RTC_ICE_SERVERS?: string;
97
+ // coturn REST-API ephemeral credentials (RFC 8489 long-term-cred via HMAC).
98
+ // Comma-separated turn:/turns: URIs + a shared secret; TTL in seconds.
99
+ YURUCOMMU_RTC_TURN_URIS?: string;
100
+ YURUCOMMU_RTC_TURN_SECRET?: string; // secret
101
+ YURUCOMMU_RTC_TURN_TTL?: string;
102
+ // SFU adapter selector for GROUP calls. "p2p" (default) = no SFU, 1:1 P2P
103
+ // only. Other values ("whip" / "livekit" / "cloudflare-realtime") select a
104
+ // WHIP/WHEP-speaking focus so the SFU backend stays vendor-neutral. 1:1 calls
105
+ // never require any SFU config.
106
+ YURUCOMMU_RTC_SFU_ADAPTER?: string;
107
+ YURUCOMMU_RTC_SFU_URL?: string;
108
+ YURUCOMMU_RTC_SFU_TOKEN?: string; // secret
109
+ YURUCOMMU_RTC_SFU_APP_ID?: string;
110
+ YURUCOMMU_RTC_SFU_APP_SECRET?: string; // secret
111
+
88
112
  // Declare the reverse-proxy type so the client-IP resolver trusts the right
89
113
  // forwarding header (opt-in; a worker fronted directly by a client cannot
90
114
  // spoof its own IP otherwise). Accepted values:
@@ -119,6 +143,10 @@ export type Env = {
119
143
  ASSETS?: IStaticAssets;
120
144
  DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
121
145
  DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
146
+ // Signaling hub for the call feature. Passes through wrapCloudflareBindings
147
+ // untouched (it is not one of DB/MEDIA/KV/ASSETS). Optional: when unbound the
148
+ // call routes 503 and the rest of the app serves normally.
149
+ CALL_SIGNALING?: DurableObjectNamespace;
122
150
  } & EnvVars;
123
151
 
124
152
  export type Variables = {
package/src/db/index.ts CHANGED
@@ -47,16 +47,17 @@ export async function getDbSQLite(databasePath: string): Promise<Database> {
47
47
  const { drizzle } = await import("drizzle-orm/libsql");
48
48
 
49
49
  const client = createClient({ url: `file:${databasePath}` });
50
- // Foreign keys are explicitly turned OFF so the libsql engine matches
51
- // Cloudflare D1, which ignores the FK constraints declared in the
52
- // migrations. NOTE: libsql (unlike bun:sqlite / stock SQLite, which default
53
- // OFF) defaults foreign_keys ON, so this must be set explicitly — simply
54
- // not enabling it is not enough. Remote actors are stored in actor_cache
55
- // (never in actors), yet objects.attributed_to / follows.* / likes.* /
56
- // announces.* FK-reference actors(ap_id); enforcement would make every
57
- // inbound federated activity from a remote actor violate the FK and fail to
58
- // insert. Referential cleanup is handled at the app level by
59
- // delete-cascade.ts, identically on D1.
50
+ // Foreign keys are explicitly turned OFF so the libsql engine matches the
51
+ // MIGRATED production schema. Cloudflare D1 ENFORCES declared FKs (the old
52
+ // "D1 ignores FK" assumption was wrong see migrations/0011, which exists
53
+ // precisely because enforcement broke inbound federation): remote actors
54
+ // live only in actor_cache, never in actors, so 0010/0011 REBUILT the
55
+ // affected tables to drop their actors FKs. Post-0011 the schema declares
56
+ // essentially no FKs and referential cleanup is handled at the app level by
57
+ // delete-cascade.ts / account-teardown.ts, identically on D1. NOTE: libsql
58
+ // (unlike bun:sqlite / stock SQLite, which default OFF) defaults
59
+ // foreign_keys ON, so this must be set explicitly — pre-0010 FKs still
60
+ // present in older local DB files must not diverge from D1 behavior.
60
61
  await client.execute("PRAGMA foreign_keys = OFF");
61
62
  sqliteDb = drizzle(client, { schema });
62
63
  return sqliteDb;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Call sessions (WebRTC voice + video).
3
+ *
4
+ * One row per call, owned by the LOCAL actor. Ephemeral signaling (SDP/ICE)
5
+ * never lands here — it flows over the dedicated `/ap/rtc/signal` transport and
6
+ * the Signaling Durable Object. This table is the durable record: call history,
7
+ * missed-call surfacing, and current-state lookups.
8
+ */
9
+
10
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
11
+ import { nowIso } from "./date-utils.ts";
12
+ import { actors } from "./actors.ts";
13
+
14
+ export const callSessions = sqliteTable(
15
+ "call_sessions",
16
+ {
17
+ // callId (client-minted uuid; also the signaling anti-replay nonce).
18
+ id: text("id").primaryKey(),
19
+ localActorApId: text("local_actor_ap_id")
20
+ .notNull()
21
+ .references(() => actors.apId, { onDelete: "cascade" }),
22
+ peerActorApId: text("peer_actor_ap_id").notNull(),
23
+ direction: text("direction").notNull(), // "incoming" | "outgoing"
24
+ // CallState: ringing | connecting | connected | ended | missed | rejected |
25
+ // failed | cancelled.
26
+ state: text("state").notNull().default("ringing"),
27
+ mediaAudio: integer("media_audio").notNull().default(1),
28
+ mediaVideo: integer("media_video").notNull().default(0),
29
+ // Selected SFU focus JSON, or NULL for pure P2P (1:1).
30
+ sfuFocus: text("sfu_focus"),
31
+ // Cached peer signaling endpoint so mid-call frames skip re-resolution.
32
+ peerSignalEndpoint: text("peer_signal_endpoint"),
33
+ endReason: text("end_reason"),
34
+ createdAt: text("created_at").notNull().$defaultFn(nowIso),
35
+ updatedAt: text("updated_at")
36
+ .notNull()
37
+ .$defaultFn(nowIso)
38
+ .$onUpdateFn(nowIso),
39
+ connectedAt: text("connected_at"),
40
+ endedAt: text("ended_at"),
41
+ },
42
+ (t) => [
43
+ index("call_sessions_local_created_idx").on(t.localActorApId, t.createdAt),
44
+ index("call_sessions_state_idx").on(t.state),
45
+ ],
46
+ );
@@ -15,4 +15,5 @@ export * from "./stories.ts";
15
15
  export * from "./notes.ts";
16
16
  export * from "./messaging.ts";
17
17
  export * from "./mobile.ts";
18
+ export * from "./calls.ts";
18
19
  export * from "./relations.ts";
@@ -47,14 +47,16 @@ export const mobilePushRegistrations = sqliteTable(
47
47
  *
48
48
  * `pushkey` is an opaque downstream-provider identifier. Provider credentials
49
49
  * never live in this table; they stay in the configured stateless gateway.
50
+ *
51
+ * NO foreign key on actor_ap_id: D1 ENFORCES declared FKs (0010/0011 dropped
52
+ * the actors FKs for that reason) and cleanup is app-level
53
+ * (routes/account-teardown.ts), matching the rest of the schema.
50
54
  */
51
55
  export const notificationPushers = sqliteTable(
52
56
  "notification_pushers",
53
57
  {
54
58
  id: text("id").primaryKey(),
55
- actorApId: text("actor_ap_id")
56
- .notNull()
57
- .references(() => actors.apId),
59
+ actorApId: text("actor_ap_id").notNull(),
58
60
  product: text("product").notNull(),
59
61
  scope: text("scope"),
60
62
  kind: text("kind").notNull().default("http"),
@@ -75,13 +77,9 @@ export const notificationPushers = sqliteTable(
75
77
  lastSeenAt: text("last_seen_at").notNull().$defaultFn(nowIsoUtc),
76
78
  },
77
79
  (t) => [
78
- uniqueIndex("notification_pushers_actor_product_app_pushkey_idx").on(
79
- t.actorApId,
80
- t.product,
81
- t.appId,
82
- t.pushkeyHash,
83
- ),
84
80
  index("notification_pushers_actor_product_idx").on(t.actorApId, t.product),
81
+ // Device uniqueness — strictly stronger than any actor-scoped unique
82
+ // variant, so this is the ONLY unique index.
85
83
  uniqueIndex("notification_pushers_device_idx").on(
86
84
  t.product,
87
85
  t.appId,