@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -286,11 +286,11 @@ export async function cancelTombstoneDelete(
286
286
  return deleteActivityIds.length;
287
287
  }
288
288
 
289
- // Best-effort, opportunistic tombstone reaping on the read path. This Worker
290
- // has no `scheduled` handler, so (mirroring maybeCleanupExpiredStories) the
291
- // sweep is triggered probabilistically and guarded so at most one runs per
292
- // isolate at a time. Tombstones are already excluded from every serving query,
293
- // so a missed sweep only delays storage/key-material reclamation.
289
+ // Best-effort, opportunistic tombstone reaping on the read path. The public
290
+ // Worker has a scheduled retention handler too; this remains as a fallback for
291
+ // self-hosted runtimes without cron. It is guarded so at most one pass runs per
292
+ // isolate. Tombstones are already excluded from every serving query, so a
293
+ // missed sweep only delays storage/key-material reclamation.
294
294
  let tombstoneReapInFlight = false;
295
295
 
296
296
  export function maybeReapDrainedTombstones(db: Database): void {
@@ -7,6 +7,7 @@ import {
7
7
  fetchUserInfo,
8
8
  getAuthConfig,
9
9
  getClientCredentials,
10
+ getMobileOidcAudience,
10
11
  getProvider,
11
12
  } from "../lib/oauth-providers.ts";
12
13
  import { verifyOidcIdToken } from "../lib/oidc-id-token.ts";
@@ -296,7 +297,7 @@ auth.post("/mobile/oidc", async (c) => {
296
297
  }
297
298
 
298
299
  try {
299
- const { clientId } = getClientCredentials(c.env, "takos");
300
+ const clientId = getMobileOidcAudience(c.env);
300
301
  const claims = await verifyOidcIdToken(idToken, {
301
302
  issuer: provider.issuer,
302
303
  clientId,
@@ -15,7 +15,10 @@ import {
15
15
  communities,
16
16
  communityMembers,
17
17
  inbox as inboxTable,
18
+ insertMany,
18
19
  objects,
20
+ runBatch,
21
+ type D1Statement,
19
22
  } from "../../../db/index.ts";
20
23
  import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
21
24
  import type { Database } from "../../../db/index.ts";
@@ -666,33 +669,49 @@ export async function processMentions(
666
669
  }
667
670
 
668
671
  if (activitiesToCreate.length > 0) {
672
+ // A mention notification is one invariant: its activity and inbox edge
673
+ // either both exist or neither does. Build D1-safe chunked INSERT
674
+ // statements, then commit each bounded page as one atomic batch. This
675
+ // avoids both the 100-bind ceiling and the old activity-without-inbox
676
+ // partial state when the second independent INSERT failed.
677
+ const MENTION_NOTIFICATION_PAGE_SIZE = 200;
669
678
  try {
670
- await db.insert(activities).values(activitiesToCreate);
671
- } catch (e) {
672
- log.error("Failed to persist mention activities", {
673
- event: "posts.mention.activity_persist_failed",
674
- error: e,
675
- });
676
- mentionFailures.push({
677
- mention: "__batch__",
678
- stage: "persist_activity",
679
- reason: "mention_activity_persist_failed",
680
- });
681
- }
682
- }
683
- if (inboxEntriesToCreate.length > 0) {
684
- try {
685
- await db.insert(inboxTable).values(inboxEntriesToCreate);
679
+ for (
680
+ let offset = 0;
681
+ offset < activitiesToCreate.length;
682
+ offset += MENTION_NOTIFICATION_PAGE_SIZE
683
+ ) {
684
+ const activityPage = activitiesToCreate.slice(
685
+ offset,
686
+ offset + MENTION_NOTIFICATION_PAGE_SIZE,
687
+ );
688
+ const inboxPage = inboxEntriesToCreate.slice(
689
+ offset,
690
+ offset + MENTION_NOTIFICATION_PAGE_SIZE,
691
+ );
692
+ const statements = [
693
+ ...insertMany(db, activities, activityPage),
694
+ ...insertMany(db, inboxTable, inboxPage),
695
+ ];
696
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
697
+ }
686
698
  } catch (e) {
687
- log.error("Failed to persist mention inbox entries", {
688
- event: "posts.mention.inbox_persist_failed",
699
+ log.error("Failed to atomically persist mention notifications", {
700
+ event: "posts.mention.notification_persist_failed",
689
701
  error: e,
690
702
  });
691
- mentionFailures.push({
692
- mention: "__batch__",
693
- stage: "persist_inbox",
694
- reason: "mention_inbox_persist_failed",
695
- });
703
+ mentionFailures.push(
704
+ {
705
+ mention: "__batch__",
706
+ stage: "persist_activity",
707
+ reason: "mention_activity_persist_failed",
708
+ },
709
+ {
710
+ mention: "__batch__",
711
+ stage: "persist_inbox",
712
+ reason: "mention_inbox_persist_failed",
713
+ },
714
+ );
696
715
  }
697
716
  }
698
717
 
@@ -62,13 +62,11 @@ const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
62
62
 
63
63
  // Best-effort, opportunistic retention of expired stories.
64
64
  //
65
- // This is NOT a substitute for a scheduled job: this Worker has no `scheduled`
66
- // handler / cron trigger, so expiry cleanup is triggered probabilistically on
67
- // the read path. Expired stories are already excluded from every read query
68
- // (the feed/single-story handlers filter on `endTime`), so the only impact of
69
- // a missed sweep is storage growth, not stale data leaking to users. The guard
70
- // below ensures at most one sweep runs at a time per isolate, so a burst of
71
- // feed requests cannot kick off several concurrent full-table delete sweeps.
65
+ // The public Worker also exposes a scheduled retention handler. Keep this
66
+ // probabilistic read-path pass as a self-hosting fallback for runtimes that do
67
+ // not configure a cron trigger. Expired stories are already excluded from every
68
+ // read query, so a missed sweep affects storage only. The guard below ensures
69
+ // at most one fallback pass runs at a time per isolate.
72
70
  let expiredStoryCleanupInFlight = false;
73
71
 
74
72
  function maybeCleanupExpiredStories(
@@ -8,6 +8,8 @@ import type {
8
8
  D1Database,
9
9
  Fetcher,
10
10
  KVNamespace,
11
+ MessageBatch,
12
+ Queue,
11
13
  R2Bucket,
12
14
  R2Object,
13
15
  } from "@cloudflare/workers-types";
@@ -20,6 +22,13 @@ import type {
20
22
  ObjectMetadata,
21
23
  StorageObject,
22
24
  } from "./types.ts";
25
+ import type {
26
+ IQueueBatch,
27
+ IQueueMessage,
28
+ IQueueProducer,
29
+ QueueBatchItem,
30
+ QueueSendOptions,
31
+ } from "./queue.ts";
23
32
 
24
33
  /**
25
34
  * Cloudflare R2 Storage Adapter
@@ -170,6 +179,46 @@ class CloudflareAssets implements IStaticAssets {
170
179
  }
171
180
  }
172
181
 
182
+ class CloudflareQueueProducer<T> implements IQueueProducer<T> {
183
+ constructor(private readonly queue: Queue<T>) {}
184
+
185
+ async send(body: T, options?: QueueSendOptions): Promise<void> {
186
+ await this.queue.send(body, options);
187
+ }
188
+
189
+ async sendBatch(
190
+ messages: readonly QueueBatchItem<T>[],
191
+ options?: QueueSendOptions,
192
+ ): Promise<void> {
193
+ await this.queue.sendBatch([...messages], options);
194
+ }
195
+ }
196
+
197
+ export function wrapCloudflareQueue<T>(queue: Queue<T>): IQueueProducer<T> {
198
+ return new CloudflareQueueProducer(queue);
199
+ }
200
+
201
+ export function wrapCloudflareMessageBatch<T>(
202
+ batch: MessageBatch<T>,
203
+ ): IQueueBatch<T> {
204
+ const messages: readonly IQueueMessage<T>[] = batch.messages.map(
205
+ (message) => ({
206
+ id: message.id,
207
+ timestamp: message.timestamp,
208
+ body: message.body,
209
+ attempts: message.attempts,
210
+ ack: () => message.ack(),
211
+ retry: (options) => message.retry(options),
212
+ }),
213
+ );
214
+ return {
215
+ queue: batch.queue,
216
+ messages,
217
+ ackAll: () => batch.ackAll(),
218
+ retryAll: (options) => batch.retryAll(options),
219
+ };
220
+ }
221
+
173
222
  /**
174
223
  * Wrap a native Cloudflare Workers binding env into the app's runtime
175
224
  * `Env` shape. The Hono app and all helper functions speak the runtime
@@ -182,21 +231,33 @@ export function wrapCloudflareBindings<
182
231
  MEDIA?: R2Bucket;
183
232
  KV: KVNamespace;
184
233
  ASSETS?: Fetcher;
234
+ DELIVERY_QUEUE?: Queue<unknown>;
235
+ DELIVERY_DLQ?: Queue<unknown>;
185
236
  },
186
237
  >(
187
238
  bindings: T,
188
- ): Omit<T, "DB" | "MEDIA" | "KV" | "ASSETS"> & {
239
+ ): Omit<
240
+ T,
241
+ "DB" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
242
+ > & {
189
243
  DB_INSTANCE: ReturnType<typeof getDb>;
190
244
  MEDIA?: IObjectStorage;
191
245
  KV: IKeyValueStore;
192
246
  ASSETS?: IStaticAssets;
247
+ DELIVERY_QUEUE?: IQueueProducer<unknown>;
248
+ DELIVERY_DLQ?: IQueueProducer<unknown>;
193
249
  } {
194
- const { DB, MEDIA, KV, ASSETS, ...rest } = bindings;
250
+ const { DB, MEDIA, KV, ASSETS, DELIVERY_QUEUE, DELIVERY_DLQ, ...rest } =
251
+ bindings;
195
252
  return {
196
253
  ...rest,
197
254
  DB_INSTANCE: getDb(DB),
198
255
  MEDIA: MEDIA ? new CloudflareStorage(MEDIA) : undefined,
199
256
  KV: new CloudflareKV(KV),
200
257
  ASSETS: ASSETS ? new CloudflareAssets(ASSETS) : undefined,
258
+ DELIVERY_QUEUE: DELIVERY_QUEUE
259
+ ? wrapCloudflareQueue(DELIVERY_QUEUE)
260
+ : undefined,
261
+ DELIVERY_DLQ: DELIVERY_DLQ ? wrapCloudflareQueue(DELIVERY_DLQ) : undefined,
201
262
  };
202
263
  }
@@ -0,0 +1,197 @@
1
+ import {
2
+ TAKOSUMI_MANAGED_RELATIONAL_RUNTIME_CONTRACT,
3
+ managedRelationalBatchGatewayRequest,
4
+ managedRelationalConnection,
5
+ parseManagedRelationalBatchResponse,
6
+ type ManagedRelationalMethod,
7
+ type ManagedRelationalParameter,
8
+ } from "@takosjp/takosumi-contract/managed-relational-runtime";
9
+ import { parseManagedRuntimeConnectionMaterialization } from "@takosjp/takosumi-contract/managed-runtime-connections";
10
+ import {
11
+ drizzle as drizzleProxy,
12
+ type AsyncBatchRemoteCallback,
13
+ type AsyncRemoteCallback,
14
+ } from "drizzle-orm/sqlite-proxy";
15
+
16
+ import * as schema from "../../db/schema.ts";
17
+ import { ManagedRuntimeGatewayError } from "./managed-runtime.ts";
18
+ import type { ManagedRuntimeGateway } from "./managed-runtime.ts";
19
+
20
+ const DEFAULT_MAX_RELATIONAL_RESPONSE_BYTES = 8 * 1024 * 1024;
21
+
22
+ export interface ManagedRelationalDatabaseOptions {
23
+ readonly materialization: unknown;
24
+ readonly gateway: ManagedRuntimeGateway;
25
+ readonly alias: string;
26
+ readonly idempotencyKey?: () => string;
27
+ readonly maxResponseBytes?: number;
28
+ }
29
+
30
+ /**
31
+ * Provider-neutral Drizzle adapter for a host-issued RelationalDatabase.
32
+ *
33
+ * Every callback is one bounded prepared statement. Drizzle `batch()` maps to
34
+ * one ordered-atomic host call; transaction-control and migration SQL are
35
+ * deliberately unavailable on this request path.
36
+ */
37
+ export function createManagedRelationalDatabase(
38
+ options: ManagedRelationalDatabaseOptions,
39
+ ) {
40
+ const materialization = parseManagedRuntimeConnectionMaterialization(
41
+ options.materialization,
42
+ );
43
+ const connection = managedRelationalConnection(
44
+ materialization,
45
+ options.alias,
46
+ );
47
+ const idempotencyKey =
48
+ options.idempotencyKey ??
49
+ (() => `yurucommu.relational:${crypto.randomUUID()}`);
50
+ const maxResponseBytes =
51
+ options.maxResponseBytes ?? DEFAULT_MAX_RELATIONAL_RESPONSE_BYTES;
52
+ if (
53
+ !Number.isSafeInteger(maxResponseBytes) ||
54
+ maxResponseBytes < 1 ||
55
+ maxResponseBytes > DEFAULT_MAX_RELATIONAL_RESPONSE_BYTES
56
+ ) {
57
+ throw new TypeError("managed_relational_response_limit_invalid");
58
+ }
59
+
60
+ const execute = async (
61
+ statements: readonly {
62
+ readonly sql: string;
63
+ readonly params: readonly unknown[];
64
+ readonly method: ManagedRelationalMethod;
65
+ }[],
66
+ ) => {
67
+ const canonical = statements.map((statement) => ({
68
+ sql: statement.sql,
69
+ params: statement.params.map(relationalParameter),
70
+ method: statement.method,
71
+ }));
72
+ const request = managedRelationalBatchGatewayRequest(connection.authority, {
73
+ statements: canonical,
74
+ idempotencyKey: idempotencyKey(),
75
+ });
76
+ const response = await boundedResponse(
77
+ await options.gateway.fetch(request),
78
+ maxResponseBytes,
79
+ );
80
+ if (!response.ok) {
81
+ const value = (await response.json().catch(() => undefined)) as unknown;
82
+ const code =
83
+ isRecord(value) && typeof value.error === "string"
84
+ ? value.error
85
+ : "managed_relational_request_failed";
86
+ throw new ManagedRuntimeGatewayError(
87
+ code,
88
+ response.status,
89
+ response.status === 429 || response.status >= 500,
90
+ );
91
+ }
92
+ const value = parseManagedRelationalBatchResponse(
93
+ await response.json(),
94
+ canonical.length,
95
+ );
96
+ return value.results;
97
+ };
98
+
99
+ const callback: AsyncRemoteCallback = async (sql, params, method) => {
100
+ const [result] = await execute([{ sql, params, method }]);
101
+ if (!result) throw new Error("managed_relational_result_missing");
102
+ return drizzleResult(result, method);
103
+ };
104
+ const batchCallback: AsyncBatchRemoteCallback = async (batch) => {
105
+ const results = await execute(batch);
106
+ return results.map((result, index) =>
107
+ drizzleResult(result, batch[index]!.method),
108
+ );
109
+ };
110
+
111
+ return drizzleProxy(callback, batchCallback, { schema });
112
+ }
113
+
114
+ function drizzleResult(
115
+ result: Awaited<
116
+ ReturnType<typeof parseManagedRelationalBatchResponse>
117
+ >["results"][number],
118
+ method: ManagedRelationalMethod,
119
+ ) {
120
+ return {
121
+ // sqlite-proxy exposes mutable `any[]` at this boundary even though the
122
+ // public runtime contract is intentionally immutable. Copy here so the
123
+ // provider-neutral contract never leaks a mutable result reference.
124
+ rows:
125
+ method === "get"
126
+ ? [...(result.rows[0] ?? [])]
127
+ : result.rows.map((row) => [...row]),
128
+ meta: result.meta,
129
+ };
130
+ }
131
+
132
+ function relationalParameter(value: unknown): ManagedRelationalParameter {
133
+ if (value === null || typeof value === "string") return value;
134
+ if (typeof value === "number" && Number.isFinite(value)) return value;
135
+ if (typeof value === "boolean") return value ? 1 : 0;
136
+ throw new TypeError("managed_relational_parameter_unsupported");
137
+ }
138
+
139
+ async function boundedResponse(
140
+ response: Response,
141
+ maxBytes: number,
142
+ ): Promise<Response> {
143
+ const declaredLength = response.headers.get("content-length");
144
+ if (
145
+ declaredLength !== null &&
146
+ (!/^\d+$/u.test(declaredLength) || Number(declaredLength) > maxBytes)
147
+ ) {
148
+ throw new ManagedRuntimeGatewayError(
149
+ "managed_relational_response_too_large",
150
+ 502,
151
+ false,
152
+ );
153
+ }
154
+ const reader = response.body?.getReader();
155
+ if (!reader) {
156
+ return new Response(null, {
157
+ status: response.status,
158
+ statusText: response.statusText,
159
+ headers: response.headers,
160
+ });
161
+ }
162
+ const chunks: Uint8Array[] = [];
163
+ let size = 0;
164
+ try {
165
+ while (true) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ size += value.byteLength;
169
+ if (size > maxBytes) {
170
+ await reader.cancel("managed_relational_response_too_large");
171
+ throw new ManagedRuntimeGatewayError(
172
+ "managed_relational_response_too_large",
173
+ 502,
174
+ false,
175
+ );
176
+ }
177
+ chunks.push(value);
178
+ }
179
+ } finally {
180
+ reader.releaseLock();
181
+ }
182
+ const body = new Uint8Array(size);
183
+ let offset = 0;
184
+ for (const chunk of chunks) {
185
+ body.set(chunk, offset);
186
+ offset += chunk.byteLength;
187
+ }
188
+ return new Response(body, {
189
+ status: response.status,
190
+ statusText: response.statusText,
191
+ headers: response.headers,
192
+ });
193
+ }
194
+
195
+ function isRecord(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }