@takosjp/yurucommu-core 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { argv, env } from "node:process";
|
|
5
|
+
|
|
6
|
+
export type SqlExecutionContext = {
|
|
7
|
+
resource: string;
|
|
8
|
+
migration?: string;
|
|
9
|
+
purpose: "ledger-init" | "ledger-read" | "migration";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SqlExecutor = (
|
|
13
|
+
sql: string,
|
|
14
|
+
context: SqlExecutionContext,
|
|
15
|
+
) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
export const MIGRATION_LEDGER_TABLE = "yurucommu_migrations";
|
|
18
|
+
|
|
19
|
+
export type Options = {
|
|
20
|
+
resource: string;
|
|
21
|
+
migrationsDir: string;
|
|
22
|
+
space?: string;
|
|
23
|
+
wrapTransactions?: boolean;
|
|
24
|
+
retryAttempts?: number;
|
|
25
|
+
retryDelayMs?: number;
|
|
26
|
+
batchPendingMigrations?: boolean;
|
|
27
|
+
sqlCommand?: readonly string[];
|
|
28
|
+
sqlCommandTemplate?: readonly string[];
|
|
29
|
+
executeSql?: SqlExecutor;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RETRY_ATTEMPTS = 4;
|
|
33
|
+
const DEFAULT_RETRY_DELAY_MS = 1500;
|
|
34
|
+
|
|
35
|
+
export function parseArgs(args: string[]): Options {
|
|
36
|
+
const batchPendingMigrations = parseOptionalBooleanEnv(
|
|
37
|
+
env.YURUCOMMU_SQL_BATCH_PENDING,
|
|
38
|
+
);
|
|
39
|
+
const options: Options = {
|
|
40
|
+
resource:
|
|
41
|
+
env.YURUCOMMU_SQL_RESOURCE ??
|
|
42
|
+
env.TAKOSUMI_RESOURCE ??
|
|
43
|
+
env.TAKOSUMI_D1_RESOURCE ??
|
|
44
|
+
"database",
|
|
45
|
+
migrationsDir: env.MIGRATIONS_DIR ?? "migrations",
|
|
46
|
+
sqlCommand: parseSqlCommandEnv(env.YURUCOMMU_SQL_COMMAND_JSON),
|
|
47
|
+
sqlCommandTemplate: parseSqlCommandEnv(
|
|
48
|
+
env.YURUCOMMU_SQL_COMMAND_TEMPLATE_JSON,
|
|
49
|
+
),
|
|
50
|
+
wrapTransactions: parseBooleanEnv(
|
|
51
|
+
env.YURUCOMMU_SQL_WRAP_TRANSACTIONS,
|
|
52
|
+
true,
|
|
53
|
+
),
|
|
54
|
+
retryAttempts: parseIntegerEnv(
|
|
55
|
+
env.YURUCOMMU_SQL_RETRY_ATTEMPTS,
|
|
56
|
+
DEFAULT_RETRY_ATTEMPTS,
|
|
57
|
+
),
|
|
58
|
+
retryDelayMs: parseIntegerEnv(
|
|
59
|
+
env.YURUCOMMU_SQL_RETRY_DELAY_MS,
|
|
60
|
+
DEFAULT_RETRY_DELAY_MS,
|
|
61
|
+
),
|
|
62
|
+
...(batchPendingMigrations === undefined ? {} : { batchPendingMigrations }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
const next = args[index + 1];
|
|
68
|
+
if (arg === "--resource" && next) {
|
|
69
|
+
options.resource = next;
|
|
70
|
+
index += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (arg === "--migrations-dir" && next) {
|
|
74
|
+
options.migrationsDir = next;
|
|
75
|
+
index += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === "--space" && next) {
|
|
79
|
+
options.space = next;
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg === "--sql-command-json" && next) {
|
|
84
|
+
options.sqlCommand = parseSqlCommandEnv(next);
|
|
85
|
+
index += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg === "--sql-command-template-json" && next) {
|
|
89
|
+
options.sqlCommandTemplate = parseSqlCommandEnv(next);
|
|
90
|
+
index += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg === "--no-wrap-transactions") {
|
|
94
|
+
options.wrapTransactions = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (arg === "--retry-attempts" && next) {
|
|
98
|
+
options.retryAttempts = parsePositiveInteger(next, "--retry-attempts");
|
|
99
|
+
index += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg === "--retry-delay-ms" && next) {
|
|
103
|
+
options.retryDelayMs = parseNonNegativeInteger(next, "--retry-delay-ms");
|
|
104
|
+
index += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg === "--batch-pending-migrations") {
|
|
108
|
+
options.batchPendingMigrations = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg === "--no-batch-pending-migrations") {
|
|
112
|
+
options.batchPendingMigrations = false;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Unknown or incomplete argument: ${arg}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return options;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function applyMigrations(options: Options): Promise<{
|
|
122
|
+
applied: string[];
|
|
123
|
+
skipped: string[];
|
|
124
|
+
}> {
|
|
125
|
+
const files = await listMigrationFiles(options.migrationsDir);
|
|
126
|
+
if (files.length === 0) {
|
|
127
|
+
throw new Error(`No .sql migrations found in ${options.migrationsDir}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await executeSql(
|
|
131
|
+
options,
|
|
132
|
+
`
|
|
133
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
name TEXT UNIQUE NOT NULL,
|
|
136
|
+
applied_at TEXT NOT NULL
|
|
137
|
+
)
|
|
138
|
+
`,
|
|
139
|
+
{ resource: options.resource, purpose: "ledger-init" },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const appliedRows = await readAppliedMigrationRows(options);
|
|
143
|
+
const appliedSet = new Set(parseAppliedMigrationNames(appliedRows));
|
|
144
|
+
const skipped = files.filter((file) => appliedSet.has(file));
|
|
145
|
+
const pending = files.filter((file) => !appliedSet.has(file));
|
|
146
|
+
|
|
147
|
+
for (const file of skipped) {
|
|
148
|
+
console.log(`[app:activate] Skipping already applied ${file}`);
|
|
149
|
+
}
|
|
150
|
+
if (pending.length === 0) return { applied: [], skipped };
|
|
151
|
+
if (shouldBatchPendingMigrations(options)) {
|
|
152
|
+
const applied = await applyPendingMigrationsBatch(options, pending);
|
|
153
|
+
return { applied, skipped };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const applied: string[] = [];
|
|
157
|
+
for (const file of pending) {
|
|
158
|
+
const path = `${options.migrationsDir.replace(/\/+$/, "")}/${file}`;
|
|
159
|
+
const sql = await readFile(path, "utf8");
|
|
160
|
+
console.log(`[app:activate] Applying ${file} to ${options.resource}`);
|
|
161
|
+
await executeSql(options, migrationSqlWithLedgerMark(file, sql, options), {
|
|
162
|
+
resource: options.resource,
|
|
163
|
+
migration: file,
|
|
164
|
+
purpose: "migration",
|
|
165
|
+
});
|
|
166
|
+
appliedSet.add(file);
|
|
167
|
+
applied.push(file);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { applied, skipped };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function applyPendingMigrationsBatch(
|
|
174
|
+
options: Options,
|
|
175
|
+
files: readonly string[],
|
|
176
|
+
): Promise<string[]> {
|
|
177
|
+
const blocks: string[] = [];
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const path = `${options.migrationsDir.replace(/\/+$/, "")}/${file}`;
|
|
180
|
+
const sql = await readFile(path, "utf8");
|
|
181
|
+
blocks.push(`-- yurucommu migration: ${file}`);
|
|
182
|
+
blocks.push(migrationSqlWithLedgerMark(file, sql, options));
|
|
183
|
+
}
|
|
184
|
+
const first = files[0]!;
|
|
185
|
+
const last = files.at(-1)!;
|
|
186
|
+
console.log(
|
|
187
|
+
`[app:activate] Applying ${files.length} pending migrations to ${options.resource} in one batch`,
|
|
188
|
+
);
|
|
189
|
+
await executeSql(
|
|
190
|
+
{
|
|
191
|
+
...options,
|
|
192
|
+
// A failed batch may have applied and ledger-marked an earlier migration.
|
|
193
|
+
// A later run can resume from the ledger, but retrying the same batch in
|
|
194
|
+
// this process would re-run non-idempotent ALTER statements.
|
|
195
|
+
retryAttempts: 1,
|
|
196
|
+
},
|
|
197
|
+
blocks.join("\n\n"),
|
|
198
|
+
{
|
|
199
|
+
resource: options.resource,
|
|
200
|
+
migration: first === last ? first : `${first}..${last}`,
|
|
201
|
+
purpose: "migration",
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
return [...files];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shouldBatchPendingMigrations(options: Options): boolean {
|
|
208
|
+
if (options.batchPendingMigrations === false) return false;
|
|
209
|
+
if (options.batchPendingMigrations === true) return true;
|
|
210
|
+
return Boolean(
|
|
211
|
+
options.sqlCommandTemplate?.some((part) => part.includes("{sql_file}")),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function listMigrationFiles(migrationsDir: string): Promise<string[]> {
|
|
216
|
+
const files: string[] = [];
|
|
217
|
+
for (const entry of await readdir(migrationsDir, { withFileTypes: true })) {
|
|
218
|
+
if (entry.isFile() && entry.name.endsWith(".sql")) {
|
|
219
|
+
files.push(entry.name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function migrationSqlWithLedgerMark(
|
|
226
|
+
file: string,
|
|
227
|
+
sql: string,
|
|
228
|
+
options: Pick<Options, "wrapTransactions">,
|
|
229
|
+
): string {
|
|
230
|
+
const ensureLedger = migrationLedgerTableSql();
|
|
231
|
+
const markApplied =
|
|
232
|
+
`INSERT INTO ${MIGRATION_LEDGER_TABLE} (name, applied_at) ` +
|
|
233
|
+
`VALUES (${sqlString(file)}, ${sqlString(new Date().toISOString())});`;
|
|
234
|
+
if (options.wrapTransactions === false || migrationOwnsTransaction(sql)) {
|
|
235
|
+
return `${ensureLedger}\n${sql.trim()}\n${markApplied}\n`;
|
|
236
|
+
}
|
|
237
|
+
return `BEGIN;\n${ensureLedger}\n${sql.trim()}\n${markApplied}\nCOMMIT;\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function migrationLedgerTableSql(): string {
|
|
241
|
+
return `
|
|
242
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (
|
|
243
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
244
|
+
name TEXT UNIQUE NOT NULL,
|
|
245
|
+
applied_at TEXT NOT NULL
|
|
246
|
+
);`.trim();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readAppliedMigrationRows(options: Options): Promise<unknown> {
|
|
250
|
+
try {
|
|
251
|
+
return await executeSql(
|
|
252
|
+
options,
|
|
253
|
+
`SELECT name FROM ${MIGRATION_LEDGER_TABLE}`,
|
|
254
|
+
{ resource: options.resource, purpose: "ledger-read" },
|
|
255
|
+
);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!isMissingMigrationLedgerTableError(error)) throw error;
|
|
258
|
+
console.warn(
|
|
259
|
+
`[app:activate] Migration ledger table ${MIGRATION_LEDGER_TABLE} is not visible yet; treating this as a fresh database.`,
|
|
260
|
+
);
|
|
261
|
+
return { rows: [] };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isMissingMigrationLedgerTableError(error: unknown): boolean {
|
|
266
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
267
|
+
return new RegExp(
|
|
268
|
+
`no such table:\\s*(?:main\\.)?${MIGRATION_LEDGER_TABLE}\\b`,
|
|
269
|
+
"iu",
|
|
270
|
+
).test(message);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function migrationOwnsTransaction(sql: string): boolean {
|
|
274
|
+
return /(^|\n)\s*BEGIN(\s+(IMMEDIATE|DEFERRED|EXCLUSIVE|TRANSACTION))?\s*;/i.test(
|
|
275
|
+
sql,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseAppliedMigrationNames(raw: unknown): string[] {
|
|
280
|
+
const rows = extractRows(raw);
|
|
281
|
+
return rows
|
|
282
|
+
.map((row) =>
|
|
283
|
+
isRecord(row) && typeof row.name === "string" ? row.name : "",
|
|
284
|
+
)
|
|
285
|
+
.filter(Boolean);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function extractRows(raw: unknown): unknown[] {
|
|
289
|
+
if (Array.isArray(raw)) {
|
|
290
|
+
if (raw.every(isRecord) && raw.some((row) => "name" in row)) return raw;
|
|
291
|
+
const nested = raw.flatMap((entry) => extractRows(entry));
|
|
292
|
+
if (nested.length > 0) return nested;
|
|
293
|
+
}
|
|
294
|
+
if (!isRecord(raw)) return [];
|
|
295
|
+
if (Array.isArray(raw.rows)) return raw.rows;
|
|
296
|
+
if (Array.isArray(raw.results)) return raw.results;
|
|
297
|
+
if (Array.isArray(raw.result)) return extractRows(raw.result);
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function executeSql(
|
|
302
|
+
options: Options,
|
|
303
|
+
sql: string,
|
|
304
|
+
context: SqlExecutionContext,
|
|
305
|
+
): Promise<unknown> {
|
|
306
|
+
if (options.executeSql) return await options.executeSql(sql, context);
|
|
307
|
+
|
|
308
|
+
const sqlFile = await materializeSqlFileIfNeeded(options, sql);
|
|
309
|
+
try {
|
|
310
|
+
const args = buildSqlCommandArgs(options, sql, sqlFile?.path);
|
|
311
|
+
const target = context.migration ?? context.purpose;
|
|
312
|
+
const maxAttempts = Math.max(
|
|
313
|
+
1,
|
|
314
|
+
options.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS,
|
|
315
|
+
);
|
|
316
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
317
|
+
const result = await runSqlCommand(args);
|
|
318
|
+
const output = parseSqlCommandOutput(result.stdout);
|
|
319
|
+
const outputFailure = sqlCommandOutputFailure(output);
|
|
320
|
+
const failure = sqlExecutionFailure({
|
|
321
|
+
code: result.code,
|
|
322
|
+
stdout: result.stdout,
|
|
323
|
+
stderr: result.stderr,
|
|
324
|
+
outputFailure,
|
|
325
|
+
});
|
|
326
|
+
if (!failure) return output;
|
|
327
|
+
if (attempt < maxAttempts && isRetryableSqlFailure(failure.message)) {
|
|
328
|
+
const delayMs = retryDelayMs(options, attempt);
|
|
329
|
+
console.warn(
|
|
330
|
+
`[app:activate] Retrying ${target} after transient SQL failure ` +
|
|
331
|
+
`(${attempt}/${maxAttempts}): ${oneLine(failure.message)}`,
|
|
332
|
+
);
|
|
333
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
process.stdout.write(result.stdout);
|
|
337
|
+
process.stderr.write(result.stderr);
|
|
338
|
+
throw new Error(
|
|
339
|
+
`SQL command failed for ${target}${sqlFailureDetail(
|
|
340
|
+
result.stdout,
|
|
341
|
+
result.stderr,
|
|
342
|
+
failure.message,
|
|
343
|
+
)}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`SQL command failed for ${target}`);
|
|
347
|
+
} finally {
|
|
348
|
+
if (sqlFile) await rm(sqlFile.dir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runSqlCommand(args: readonly string[]): Promise<{
|
|
353
|
+
stdout: string;
|
|
354
|
+
stderr: string;
|
|
355
|
+
code: number;
|
|
356
|
+
}> {
|
|
357
|
+
const [command, ...commandArgs] = args;
|
|
358
|
+
const child = Bun.spawn([command!, ...commandArgs], {
|
|
359
|
+
stdout: "pipe",
|
|
360
|
+
stderr: "pipe",
|
|
361
|
+
});
|
|
362
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
363
|
+
new Response(child.stdout).text(),
|
|
364
|
+
new Response(child.stderr).text(),
|
|
365
|
+
child.exited,
|
|
366
|
+
]);
|
|
367
|
+
return { stdout, stderr, code };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sqlExecutionFailure(input: {
|
|
371
|
+
code: number;
|
|
372
|
+
stdout: string;
|
|
373
|
+
stderr: string;
|
|
374
|
+
outputFailure?: string;
|
|
375
|
+
}): { message: string } | undefined {
|
|
376
|
+
if (input.code === 0 && !input.outputFailure) return undefined;
|
|
377
|
+
return {
|
|
378
|
+
message:
|
|
379
|
+
input.outputFailure ??
|
|
380
|
+
[input.stdout, input.stderr].filter(Boolean).join("\n") ??
|
|
381
|
+
`exit code ${input.code}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isRetryableSqlFailure(message: string): boolean {
|
|
386
|
+
return /(?:no such table|database is locked|SQLITE_BUSY|SQLITE_LOCKED|internal error|timed?\s*out|timeout)/iu.test(
|
|
387
|
+
message,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function retryDelayMs(
|
|
392
|
+
options: Pick<Options, "retryDelayMs">,
|
|
393
|
+
attempt: number,
|
|
394
|
+
): number {
|
|
395
|
+
const base = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
396
|
+
if (base <= 0) return 0;
|
|
397
|
+
return Math.min(base * 2 ** Math.max(0, attempt - 1), 8000);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function oneLine(value: string): string {
|
|
401
|
+
return value.replace(/\s+/gu, " ").slice(0, 220);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function sleep(ms: number): Promise<void> {
|
|
405
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function materializeSqlFileIfNeeded(
|
|
409
|
+
options: Pick<Options, "sqlCommandTemplate">,
|
|
410
|
+
sql: string,
|
|
411
|
+
): Promise<{ dir: string; path: string } | undefined> {
|
|
412
|
+
if (
|
|
413
|
+
!options.sqlCommandTemplate?.some((part) => part.includes("{sql_file}"))
|
|
414
|
+
) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
const dir = await mkdtemp(join(tmpdir(), "yurucommu-d1-sql-"));
|
|
418
|
+
const path = join(dir, "command.sql");
|
|
419
|
+
await writeFile(path, sql);
|
|
420
|
+
return { dir, path };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sqlFailureDetail(
|
|
424
|
+
stdout: string,
|
|
425
|
+
stderr: string,
|
|
426
|
+
parsedFailure?: string,
|
|
427
|
+
): string {
|
|
428
|
+
const combined = [parsedFailure, stdout, stderr]
|
|
429
|
+
.map((value) => value?.trim() ?? "")
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.join("\n")
|
|
432
|
+
.replaceAll(
|
|
433
|
+
/\b(CLOUDFLARE_API_TOKEN|CF_API_TOKEN|AUTH_TOKEN)=\S+/giu,
|
|
434
|
+
"$1=[redacted]",
|
|
435
|
+
);
|
|
436
|
+
if (!combined) return "";
|
|
437
|
+
const maxLength = 2_000;
|
|
438
|
+
const clipped =
|
|
439
|
+
combined.length > maxLength
|
|
440
|
+
? `${combined.slice(0, maxLength)}...`
|
|
441
|
+
: combined;
|
|
442
|
+
return `: ${clipped}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function buildSqlCommandArgs(
|
|
446
|
+
options: Pick<
|
|
447
|
+
Options,
|
|
448
|
+
"resource" | "space" | "sqlCommand" | "sqlCommandTemplate"
|
|
449
|
+
>,
|
|
450
|
+
sql: string,
|
|
451
|
+
sqlFilePath?: string,
|
|
452
|
+
): readonly string[] {
|
|
453
|
+
if (options.sqlCommandTemplate) {
|
|
454
|
+
if (
|
|
455
|
+
options.sqlCommandTemplate.some((part) => part.includes("{sql_file}")) &&
|
|
456
|
+
!sqlFilePath
|
|
457
|
+
) {
|
|
458
|
+
throw new Error("SQL command template uses {sql_file} without a file");
|
|
459
|
+
}
|
|
460
|
+
return options.sqlCommandTemplate.map((part) =>
|
|
461
|
+
part
|
|
462
|
+
.replaceAll("{resource}", options.resource)
|
|
463
|
+
.replaceAll("{space}", options.space ?? "")
|
|
464
|
+
.replaceAll("{sql_file}", sqlFilePath ?? "")
|
|
465
|
+
.replaceAll("{sql}", sql),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (options.sqlCommand) {
|
|
469
|
+
const args = [...options.sqlCommand];
|
|
470
|
+
if (options.space) args.push("--space", options.space);
|
|
471
|
+
args.push(options.resource, sql);
|
|
472
|
+
return args;
|
|
473
|
+
}
|
|
474
|
+
throw new Error(
|
|
475
|
+
"No SQL command configured for app activation. Set YURUCOMMU_SQL_COMMAND_JSON " +
|
|
476
|
+
'for prefix mode, or YURUCOMMU_SQL_COMMAND_TEMPLATE_JSON with "{resource}" and "{sql}" placeholders.',
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function parseSqlCommandOutput(stdout: string): unknown {
|
|
481
|
+
const trimmed = stdout.trim();
|
|
482
|
+
if (!trimmed) return {};
|
|
483
|
+
for (const candidate of jsonCandidates(trimmed)) {
|
|
484
|
+
try {
|
|
485
|
+
return JSON.parse(candidate);
|
|
486
|
+
} catch {
|
|
487
|
+
// Try the next JSON-looking suffix. Wrangler prints progress lines before
|
|
488
|
+
// the JSON payload when executing SQL files remotely.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return {};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function jsonCandidates(text: string): string[] {
|
|
495
|
+
const candidates = [text];
|
|
496
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
497
|
+
const char = text[index];
|
|
498
|
+
if (char === "{" || char === "[") {
|
|
499
|
+
const suffix = text.slice(index).trim();
|
|
500
|
+
if (!candidates.includes(suffix)) candidates.push(suffix);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return candidates;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function sqlCommandOutputFailure(value: unknown): string | undefined {
|
|
507
|
+
if (Array.isArray(value)) {
|
|
508
|
+
for (const entry of value) {
|
|
509
|
+
const failure = sqlCommandOutputFailure(entry);
|
|
510
|
+
if (failure) return failure;
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
if (!isRecord(value)) return undefined;
|
|
515
|
+
if (value.success === false) {
|
|
516
|
+
return `D1 command returned success=false${sqlErrorSuffix(value.error)}`;
|
|
517
|
+
}
|
|
518
|
+
if ("error" in value && value.error !== undefined && value.error !== null) {
|
|
519
|
+
return `D1 command returned error${sqlErrorSuffix(value.error)}`;
|
|
520
|
+
}
|
|
521
|
+
for (const key of ["result", "results"]) {
|
|
522
|
+
const failure = sqlCommandOutputFailure(value[key]);
|
|
523
|
+
if (failure) return failure;
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function sqlErrorSuffix(value: unknown): string {
|
|
529
|
+
const message = sqlErrorMessage(value);
|
|
530
|
+
return message ? `: ${message}` : "";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function sqlErrorMessage(value: unknown): string | undefined {
|
|
534
|
+
if (typeof value === "string") return value;
|
|
535
|
+
if (!isRecord(value)) return undefined;
|
|
536
|
+
for (const key of ["text", "message", "code", "name"]) {
|
|
537
|
+
const candidate = value[key];
|
|
538
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
return JSON.stringify(value);
|
|
542
|
+
} catch {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function parseSqlCommandEnv(
|
|
548
|
+
value: string | undefined,
|
|
549
|
+
): readonly string[] | undefined {
|
|
550
|
+
if (!value) return undefined;
|
|
551
|
+
const parsed = JSON.parse(value) as unknown;
|
|
552
|
+
if (
|
|
553
|
+
!Array.isArray(parsed) ||
|
|
554
|
+
parsed.length === 0 ||
|
|
555
|
+
!parsed.every((part) => typeof part === "string" && part.trim())
|
|
556
|
+
) {
|
|
557
|
+
throw new Error("SQL command env must be a string array");
|
|
558
|
+
}
|
|
559
|
+
return parsed;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseBooleanEnv(
|
|
563
|
+
value: string | undefined,
|
|
564
|
+
defaultValue: boolean,
|
|
565
|
+
): boolean {
|
|
566
|
+
const parsed = parseOptionalBooleanEnv(value);
|
|
567
|
+
return parsed ?? defaultValue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseOptionalBooleanEnv(
|
|
571
|
+
value: string | undefined,
|
|
572
|
+
): boolean | undefined {
|
|
573
|
+
const normalized = value?.trim().toLowerCase();
|
|
574
|
+
if (!normalized) return undefined;
|
|
575
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
576
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
577
|
+
throw new Error(`Invalid boolean env value: ${value}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseIntegerEnv(
|
|
581
|
+
value: string | undefined,
|
|
582
|
+
defaultValue: number,
|
|
583
|
+
): number {
|
|
584
|
+
if (!value?.trim()) return defaultValue;
|
|
585
|
+
return parsePositiveInteger(value, "integer env value");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function parsePositiveInteger(value: string, label: string): number {
|
|
589
|
+
const parsed = Number(value);
|
|
590
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
591
|
+
throw new Error(`${label} must be a positive integer`);
|
|
592
|
+
}
|
|
593
|
+
return parsed;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function parseNonNegativeInteger(value: string, label: string): number {
|
|
597
|
+
const parsed = Number(value);
|
|
598
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
599
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
600
|
+
}
|
|
601
|
+
return parsed;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function sqlString(value: string): string {
|
|
605
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
609
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (import.meta.main) {
|
|
613
|
+
const result = await applyMigrations(parseArgs(argv.slice(2)));
|
|
614
|
+
console.log(
|
|
615
|
+
JSON.stringify({
|
|
616
|
+
ok: true,
|
|
617
|
+
applied: result.applied.length,
|
|
618
|
+
skipped: result.skipped.length,
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Thin re-export barrel. The federation helpers were split along their four
|
|
2
|
+
// unrelated seams into focused `lib/` modules; this file preserves the
|
|
3
|
+
// historical `../federation-helpers.ts` import surface so existing importers
|
|
4
|
+
// do not churn. Prefer importing directly from the focused modules in new code:
|
|
5
|
+
// - URL / ActivityPub id helpers -> ./lib/ap-ids.ts
|
|
6
|
+
// - SSRF / DNS-rebinding defense -> ./lib/ssrf.ts
|
|
7
|
+
// - RSA keygen / HTTP signature signing -> ./lib/ap-signing.ts
|
|
8
|
+
// - capped / timeout federation fetch -> ./lib/federation-fetch.ts
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
activityApId,
|
|
12
|
+
actorApId,
|
|
13
|
+
communityApId,
|
|
14
|
+
formatUsername,
|
|
15
|
+
generateId,
|
|
16
|
+
getDomain,
|
|
17
|
+
isLocal,
|
|
18
|
+
objectApId,
|
|
19
|
+
parseLimit,
|
|
20
|
+
parseOffset,
|
|
21
|
+
safeJsonParse,
|
|
22
|
+
} from "./lib/ap-ids.ts";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
assertSafeRemoteUrlResolved,
|
|
26
|
+
isSafeRemoteUrl,
|
|
27
|
+
normalizeRemoteDomain,
|
|
28
|
+
type RemoteUrlSafetyOptions,
|
|
29
|
+
} from "./lib/ssrf.ts";
|
|
30
|
+
|
|
31
|
+
export { generateKeyPair, signRequest } from "./lib/ap-signing.ts";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
FederationBodyTooLargeError,
|
|
35
|
+
fetchWithTimeout,
|
|
36
|
+
} from "./lib/federation-fetch.ts";
|