@takosjp/yurucommu-core 3.4.0 → 3.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 (35) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/src/lib/api/notifications.ts +1 -0
  4. package/packages/api/src/lib/api/posts.ts +1 -0
  5. package/src/backend/index.ts +43 -12
  6. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  7. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  8. package/src/backend/lib/delivery/queue.ts +13 -9
  9. package/src/backend/lib/delivery/types.ts +12 -0
  10. package/src/backend/lib/notification-push.ts +2 -2
  11. package/src/backend/lib/oauth-providers.ts +9 -0
  12. package/src/backend/lib/strip-image-metadata.ts +50 -30
  13. package/src/backend/middleware/bearer-auth.ts +24 -9
  14. package/src/backend/public.ts +22 -1
  15. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  16. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  17. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  18. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  19. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  20. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  21. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  22. package/src/backend/routes/activitypub/inbox.ts +410 -205
  23. package/src/backend/routes/activitypub/outbox.ts +0 -0
  24. package/src/backend/routes/auth.ts +2 -1
  25. package/src/backend/routes/posts/post-helpers.ts +42 -23
  26. package/src/backend/runtime/cloudflare.ts +63 -2
  27. package/src/backend/runtime/managed-relational.ts +197 -0
  28. package/src/backend/runtime/managed-runtime.ts +631 -0
  29. package/src/backend/runtime/queue.ts +40 -0
  30. package/src/backend/server.ts +15 -18
  31. package/src/backend/types.ts +9 -2
  32. package/src/db/d1-write.ts +270 -0
  33. package/src/db/index.ts +17 -0
  34. package/src/db/schema/federation.ts +19 -0
  35. package/src/db/schema/index.ts +1 -0
@@ -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
 
@@ -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
+ }