@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.
- package/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/src/backend/index.ts +62 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +13 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +38 -1
- package/src/backend/retention.ts +78 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/actors.ts +5 -5
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/stories/routes.ts +5 -7
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +9 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- package/src/db/schema/index.ts +1 -0
package/src/backend/server.ts
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
* TAKOS_URL - Optional Takos API base URL for proxy/tool integration
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
|
|
23
22
|
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
24
23
|
import process from "node:process";
|
|
25
24
|
import { and, inArray, lt, or } from "drizzle-orm";
|
|
@@ -33,6 +32,13 @@ import type {
|
|
|
33
32
|
import { buildDeliverEndpointMessage } from "./lib/delivery/queue.ts";
|
|
34
33
|
import { deliveryQueue, getDbSQLite } from "../db/index.ts";
|
|
35
34
|
import { logger } from "./lib/logger.ts";
|
|
35
|
+
import type {
|
|
36
|
+
IQueueBatch,
|
|
37
|
+
IQueueMessage,
|
|
38
|
+
IQueueProducer,
|
|
39
|
+
QueueBatchItem,
|
|
40
|
+
QueueSendOptions,
|
|
41
|
+
} from "./runtime/queue.ts";
|
|
36
42
|
|
|
37
43
|
const log = logger.child({ component: "server.bootstrap" });
|
|
38
44
|
|
|
@@ -111,21 +117,12 @@ function isTruthyEnv(value: string | undefined): boolean {
|
|
|
111
117
|
|
|
112
118
|
type LocalQueueBody = DeliveryQueueMessageV1 | DeliveryDlqMessageV1;
|
|
113
119
|
|
|
114
|
-
type LocalQueueSendOptions = {
|
|
115
|
-
delaySeconds?: number;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
type LocalQueueBatchItem<T> = {
|
|
119
|
-
body: T;
|
|
120
|
-
delaySeconds?: number;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
120
|
function createLocalMessageBatch<T extends LocalQueueBody>(
|
|
124
121
|
queueName: string,
|
|
125
122
|
bodies: T[],
|
|
126
123
|
requeue: (body: T, delaySeconds?: number) => void,
|
|
127
|
-
):
|
|
128
|
-
const messages = bodies.map((body):
|
|
124
|
+
): IQueueBatch<T> {
|
|
125
|
+
const messages = bodies.map((body): IQueueMessage<T> => {
|
|
129
126
|
let settled = false;
|
|
130
127
|
return {
|
|
131
128
|
id: crypto.randomUUID(),
|
|
@@ -140,7 +137,7 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
|
|
|
140
137
|
settled = true;
|
|
141
138
|
requeue(body, options?.delaySeconds);
|
|
142
139
|
},
|
|
143
|
-
}
|
|
140
|
+
};
|
|
144
141
|
});
|
|
145
142
|
|
|
146
143
|
return {
|
|
@@ -152,13 +149,13 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
|
|
|
152
149
|
retryAll: (options?: { delaySeconds?: number }) => {
|
|
153
150
|
for (const message of messages) message.retry(options);
|
|
154
151
|
},
|
|
155
|
-
}
|
|
152
|
+
};
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
function createLocalQueue<T extends LocalQueueBody>(
|
|
159
156
|
env: LocalServerEnv,
|
|
160
157
|
queueName: string,
|
|
161
|
-
):
|
|
158
|
+
): IQueueProducer<T> {
|
|
162
159
|
const pending: T[] = [];
|
|
163
160
|
let draining = false;
|
|
164
161
|
let drainScheduled = false;
|
|
@@ -206,15 +203,15 @@ function createLocalQueue<T extends LocalQueueBody>(
|
|
|
206
203
|
}
|
|
207
204
|
|
|
208
205
|
return {
|
|
209
|
-
send: async (body: T, options?:
|
|
206
|
+
send: async (body: T, options?: QueueSendOptions) => {
|
|
210
207
|
enqueue(body, options?.delaySeconds);
|
|
211
208
|
},
|
|
212
|
-
sendBatch: async (messages:
|
|
209
|
+
sendBatch: async (messages: readonly QueueBatchItem<T>[]) => {
|
|
213
210
|
for (const message of messages) {
|
|
214
211
|
enqueue(message.body, message.delaySeconds);
|
|
215
212
|
}
|
|
216
213
|
},
|
|
217
|
-
}
|
|
214
|
+
};
|
|
218
215
|
}
|
|
219
216
|
|
|
220
217
|
function attachLocalDeliveryQueues(env: LocalServerEnv): void {
|
package/src/backend/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
IObjectStorage,
|
|
5
5
|
IStaticAssets,
|
|
6
6
|
} from "./runtime/types.ts";
|
|
7
|
+
import type { IQueueProducer } from "./runtime/queue.ts";
|
|
7
8
|
import type {
|
|
8
9
|
DeliveryDlqMessageV1,
|
|
9
10
|
DeliveryQueueMessageV1,
|
|
@@ -34,8 +35,14 @@ export interface EnvVars {
|
|
|
34
35
|
// Pins the OIDC/OAuth subject allowed to take the single owner slot. When set,
|
|
35
36
|
// only a first-login whose subject equals this value becomes `owner`; any other
|
|
36
37
|
// first-login is refused. Prevents an owner-slot race on an OIDC-seeded Capsule.
|
|
38
|
+
// Accepts either the bare issuer subject or the namespaced `<provider>:<sub>`.
|
|
37
39
|
OIDC_OWNER_SUB?: string;
|
|
38
40
|
TAKOSUMI_ACCOUNTS_OWNER_SUB?: string;
|
|
41
|
+
// Explicit opt-in to let an UNPINNED first OAuth/OIDC login take the owner
|
|
42
|
+
// slot. Without a pin and without this flag, owner creation over OAuth is
|
|
43
|
+
// refused. It exists because a pairwise subject cannot be known before the
|
|
44
|
+
// first login: enable it, sign in, then pin OIDC_OWNER_SUB and clear it.
|
|
45
|
+
ALLOW_UNPINNED_OWNER_CLAIM?: string;
|
|
39
46
|
// Comma-separated allowlist of OAuth/OIDC subjects permitted to auto-provision
|
|
40
47
|
// a NON-owner (member) account. Empty/unset = member auto-provisioning is
|
|
41
48
|
// CLOSED (single-user default): once the owner exists, no new external subject
|
|
@@ -141,8 +148,8 @@ export type Env = {
|
|
|
141
148
|
MEDIA?: IObjectStorage;
|
|
142
149
|
KV: IKeyValueStore;
|
|
143
150
|
ASSETS?: IStaticAssets;
|
|
144
|
-
DELIVERY_QUEUE?:
|
|
145
|
-
DELIVERY_DLQ?:
|
|
151
|
+
DELIVERY_QUEUE?: IQueueProducer<DeliveryQueueMessageV1>;
|
|
152
|
+
DELIVERY_DLQ?: IQueueProducer<DeliveryDlqMessageV1>;
|
|
146
153
|
// Signaling hub for the call feature. Passes through wrapCloudflareBindings
|
|
147
154
|
// untouched (it is not one of DB/MEDIA/KV/ASSETS). Optional: when unbound the
|
|
148
155
|
// call routes 503 and the rest of the app serves normally.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1-safe write primitives (yurucommu-core owner module).
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare D1 rejects any statement that binds more than 100 parameters and
|
|
5
|
+
* has no interactive transactions (`BEGIN`/`COMMIT` cannot be composed across
|
|
6
|
+
* its stateless prepared-statement round-trips). Neither limit exists on the
|
|
7
|
+
* libsql / bun:sqlite engines the test suite runs on (~32k parameters, real
|
|
8
|
+
* transactions), so an unbounded multi-row INSERT is green in CI and throws
|
|
9
|
+
* "too many SQL variables" in production.
|
|
10
|
+
*
|
|
11
|
+
* The dangerous shape is not the throw itself: it is `DELETE` followed by a
|
|
12
|
+
* separate `INSERT`. The DELETE has already committed when the INSERT is
|
|
13
|
+
* rejected, so exceeding the ceiling is DATA LOSS rather than a 500.
|
|
14
|
+
*
|
|
15
|
+
* This module makes that shape unrepresentable:
|
|
16
|
+
* - {@link insertMany} chunks by the real parameter budget and RETURNS
|
|
17
|
+
* statements instead of executing them, so a caller cannot accidentally
|
|
18
|
+
* issue a partial write;
|
|
19
|
+
* - {@link replaceSet} is the only supported delete-then-insert and emits a
|
|
20
|
+
* single `db.batch([...])`, which both drivers commit atomically — the
|
|
21
|
+
* DELETE cannot survive a failed INSERT because there is no way to hand it
|
|
22
|
+
* to the driver on its own.
|
|
23
|
+
*
|
|
24
|
+
* The same export surface exists in the other owner repos (takos, takosumi,
|
|
25
|
+
* takos-git, road-to-me); a root gate asserts the names and constants match.
|
|
26
|
+
* See `docs/quality/d1-write.md`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { getTableColumns, sql, type SQL } from "drizzle-orm";
|
|
30
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
31
|
+
import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
32
|
+
import type { Database } from "./index.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bound-parameter budget for a single statement. D1's hard ceiling is 100; the
|
|
36
|
+
* remaining 10 are headroom for the WHERE / conflict-target parameters that
|
|
37
|
+
* share the statement with the row values.
|
|
38
|
+
*/
|
|
39
|
+
export const D1_SAFE_PARAM_BUDGET = 90;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Statements per `db.batch([...])`. D1 accepts larger batches but degrades, and
|
|
43
|
+
* a batch this size already means the caller is writing an unbounded set in one
|
|
44
|
+
* shot — which is the pattern this module exists to stop.
|
|
45
|
+
*/
|
|
46
|
+
export const D1_MAX_BATCH_STATEMENTS = 50;
|
|
47
|
+
|
|
48
|
+
/** A driver-executable statement, as accepted by `db.batch([...])`. */
|
|
49
|
+
export type D1Statement = BatchItem<"sqlite">;
|
|
50
|
+
|
|
51
|
+
/** The row shape or the caller's usage cannot be expressed within D1's limits. */
|
|
52
|
+
export class D1WriteShapeError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "D1WriteShapeError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The driver in use does not expose the atomic `batch()` surface. */
|
|
60
|
+
export class D1BatchUnsupportedError extends Error {
|
|
61
|
+
constructor(message: string) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "D1BatchUnsupportedError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The write needs more statements than one batch may carry. */
|
|
68
|
+
export class D1BatchTooLargeError extends Error {
|
|
69
|
+
constructor(message: string) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "D1BatchTooLargeError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface BatchableDb {
|
|
76
|
+
batch(statements: readonly [D1Statement, ...D1Statement[]]): Promise<unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireBatchable(db: Database, context: string): BatchableDb {
|
|
80
|
+
const candidate = db as unknown as Partial<BatchableDb>;
|
|
81
|
+
if (typeof candidate.batch !== "function") {
|
|
82
|
+
throw new D1BatchUnsupportedError(
|
|
83
|
+
`${context}: the active driver exposes no batch(); a delete-then-insert ` +
|
|
84
|
+
`cannot be made atomic without it. Both the D1 and libsql drivers do — ` +
|
|
85
|
+
`a driver that does not is a test double that must gain one.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return candidate as BatchableDb;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bound parameters Drizzle emits for one row of `rows`.
|
|
93
|
+
*
|
|
94
|
+
* Drizzle builds ONE column list for the whole `values([...])` call (the union
|
|
95
|
+
* of the keys present across the rows) and binds a parameter per column per
|
|
96
|
+
* row, so the budget must be divided by that union — not by the keys of the
|
|
97
|
+
* first row.
|
|
98
|
+
*/
|
|
99
|
+
function paramsPerRow(
|
|
100
|
+
table: SQLiteTable,
|
|
101
|
+
rows: readonly Record<string, unknown>[],
|
|
102
|
+
): number {
|
|
103
|
+
const keys = new Set<string>();
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
for (const key of Object.keys(row)) keys.add(key);
|
|
106
|
+
}
|
|
107
|
+
const known = new Set(Object.keys(getTableColumns(table)));
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (!known.has(key)) {
|
|
110
|
+
throw new D1WriteShapeError(
|
|
111
|
+
`insertMany: row key "${key}" is not a column of the target table`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return keys.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the INSERT statements for `rows`, chunked so no statement exceeds
|
|
120
|
+
* {@link D1_SAFE_PARAM_BUDGET} bound parameters.
|
|
121
|
+
*
|
|
122
|
+
* Returns statements; it deliberately does NOT execute them. Executing a
|
|
123
|
+
* multi-statement write is only safe inside one `db.batch([...])`, and making
|
|
124
|
+
* the caller pass the statements to a batch is what keeps a half-applied write
|
|
125
|
+
* from being expressible.
|
|
126
|
+
*/
|
|
127
|
+
export function insertMany<TTable extends SQLiteTable>(
|
|
128
|
+
db: Database,
|
|
129
|
+
table: TTable,
|
|
130
|
+
rows: readonly TTable["$inferInsert"][],
|
|
131
|
+
opts?: { readonly conflict?: "error" | "ignore" },
|
|
132
|
+
): readonly D1Statement[] {
|
|
133
|
+
if (rows.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
const perRow = paramsPerRow(
|
|
136
|
+
table,
|
|
137
|
+
rows as readonly Record<string, unknown>[],
|
|
138
|
+
);
|
|
139
|
+
if (perRow === 0) {
|
|
140
|
+
throw new D1WriteShapeError("insertMany: rows bind no columns");
|
|
141
|
+
}
|
|
142
|
+
if (perRow > D1_SAFE_PARAM_BUDGET) {
|
|
143
|
+
throw new D1WriteShapeError(
|
|
144
|
+
`insertMany: a single row binds ${perRow} parameters, over the D1 ` +
|
|
145
|
+
`budget of ${D1_SAFE_PARAM_BUDGET}; no chunking can make this fit`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const chunkSize = Math.floor(D1_SAFE_PARAM_BUDGET / perRow);
|
|
150
|
+
const statements: D1Statement[] = [];
|
|
151
|
+
for (let offset = 0; offset < rows.length; offset += chunkSize) {
|
|
152
|
+
const chunk = rows.slice(offset, offset + chunkSize);
|
|
153
|
+
// Drizzle's insert builder is generic over the table; the concrete row type
|
|
154
|
+
// is already checked by the public signature, so the builder-local widening
|
|
155
|
+
// here is the narrowest cast that keeps the call site typed.
|
|
156
|
+
const builder = db
|
|
157
|
+
.insert(table)
|
|
158
|
+
.values(chunk as TTable["$inferInsert"][]) as unknown as D1Statement & {
|
|
159
|
+
onConflictDoNothing(): D1Statement;
|
|
160
|
+
};
|
|
161
|
+
statements.push(
|
|
162
|
+
opts?.conflict === "ignore" ? builder.onConflictDoNothing() : builder,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (statements.length > D1_MAX_BATCH_STATEMENTS) {
|
|
167
|
+
throw new D1BatchTooLargeError(
|
|
168
|
+
`insertMany: ${rows.length} rows need ${statements.length} statements, ` +
|
|
169
|
+
`over the batch cap of ${D1_MAX_BATCH_STATEMENTS}. Do not split the ` +
|
|
170
|
+
`batch — that reintroduces the partial-write window this module ` +
|
|
171
|
+
`exists to remove. Page the source set instead, or apply the ` +
|
|
172
|
+
`generational-replace pattern in docs/quality/d1-write.md §3.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return statements;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Atomically replace the rows matching `where` with `rows`.
|
|
180
|
+
*
|
|
181
|
+
* The DELETE and every chunked INSERT go to the driver as ONE batch, so a
|
|
182
|
+
* rejected INSERT rolls the DELETE back. There is no overload that returns the
|
|
183
|
+
* DELETE on its own.
|
|
184
|
+
*/
|
|
185
|
+
export async function replaceSet<TTable extends SQLiteTable>(args: {
|
|
186
|
+
readonly db: Database;
|
|
187
|
+
readonly table: TTable;
|
|
188
|
+
readonly where: SQL;
|
|
189
|
+
readonly rows: readonly TTable["$inferInsert"][];
|
|
190
|
+
readonly conflict?: "error" | "ignore";
|
|
191
|
+
}): Promise<void> {
|
|
192
|
+
const { db, table, where, rows, conflict } = args;
|
|
193
|
+
const batch = requireBatchable(db, "replaceSet");
|
|
194
|
+
const inserts = insertMany(db, table, rows, { conflict });
|
|
195
|
+
|
|
196
|
+
if (inserts.length + 1 > D1_MAX_BATCH_STATEMENTS) {
|
|
197
|
+
throw new D1BatchTooLargeError(
|
|
198
|
+
`replaceSet: ${rows.length} rows need ${inserts.length + 1} statements, ` +
|
|
199
|
+
`over the batch cap of ${D1_MAX_BATCH_STATEMENTS}. See ` +
|
|
200
|
+
`docs/quality/d1-write.md §3.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const del = db.delete(table).where(where) as unknown as D1Statement;
|
|
205
|
+
await batch.batch([del, ...inserts] as [D1Statement, ...D1Statement[]]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Execute `statements` as ONE atomic batch. Both the D1 and libsql drivers
|
|
210
|
+
* commit a batch all-or-nothing; this is the only atomicity primitive D1 has,
|
|
211
|
+
* since `BEGIN`/`COMMIT` cannot be composed across its stateless
|
|
212
|
+
* prepared-statement round-trips.
|
|
213
|
+
*/
|
|
214
|
+
export async function runBatch(
|
|
215
|
+
db: Database,
|
|
216
|
+
statements: readonly [D1Statement, ...D1Statement[]],
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
await requireBatchable(db, "runBatch").batch(statements);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run `run` once per chunk of at most {@link D1_SAFE_PARAM_BUDGET} ids and
|
|
223
|
+
* concatenate the results. Use for an `inArray(col, ids)` whose ids only exist
|
|
224
|
+
* as an in-memory array; prefer {@link notInSubquery} / a `db.select()`
|
|
225
|
+
* subquery when the ids are themselves a query result (zero bound parameters).
|
|
226
|
+
*/
|
|
227
|
+
export async function inChunks<T, R>(
|
|
228
|
+
ids: readonly T[],
|
|
229
|
+
run: (chunk: readonly T[]) => Promise<R[]>,
|
|
230
|
+
): Promise<R[]> {
|
|
231
|
+
if (ids.length === 0) return [];
|
|
232
|
+
if (ids.length <= D1_SAFE_PARAM_BUDGET) return await run(ids);
|
|
233
|
+
const out: R[] = [];
|
|
234
|
+
for (let i = 0; i < ids.length; i += D1_SAFE_PARAM_BUDGET) {
|
|
235
|
+
out.push(...(await run(ids.slice(i, i + D1_SAFE_PARAM_BUDGET))));
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `col NOT IN (<subquery>)` with zero per-element bound parameters.
|
|
242
|
+
*
|
|
243
|
+
* There is deliberately no `notInChunks`: NOT IN does not decompose over
|
|
244
|
+
* chunks (the per-chunk results must be INTERSECTED, not concatenated), and
|
|
245
|
+
* every attempt to write one has been wrong. Express the excluded set as a
|
|
246
|
+
* subquery instead.
|
|
247
|
+
*/
|
|
248
|
+
export function notInSubquery(col: SQLiteColumn, subquery: SQL | unknown): SQL {
|
|
249
|
+
return sql`${col} NOT IN ${subquery}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Escape hatch for a statement whose parameter count is bounded by something
|
|
254
|
+
* the type system cannot see (a fixed-arity literal list, a `LIMIT`ed page).
|
|
255
|
+
*
|
|
256
|
+
* `reason.boundedBy` must name the concrete bound, and this call is what a
|
|
257
|
+
* reviewer greps for. It performs no work at runtime; its whole value is that
|
|
258
|
+
* an unchunked statement cannot be written without saying why.
|
|
259
|
+
*/
|
|
260
|
+
export function unsafeUnchunkedStatement(
|
|
261
|
+
stmt: D1Statement,
|
|
262
|
+
reason: { readonly why: string; readonly boundedBy: string },
|
|
263
|
+
): D1Statement {
|
|
264
|
+
if (!reason.why.trim() || !reason.boundedBy.trim()) {
|
|
265
|
+
throw new D1WriteShapeError(
|
|
266
|
+
"unsafeUnchunkedStatement: both `why` and `boundedBy` must be stated",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return stmt;
|
|
270
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -96,3 +96,20 @@ export function notDeleted(
|
|
|
96
96
|
|
|
97
97
|
export { nowIso } from "./schema.ts";
|
|
98
98
|
export * from "./schema.ts";
|
|
99
|
+
|
|
100
|
+
// D1-safe write primitives. Re-exported here so a writer reaching for the DB
|
|
101
|
+
// module finds the chunking/batch surface without knowing the file name.
|
|
102
|
+
export {
|
|
103
|
+
D1_MAX_BATCH_STATEMENTS,
|
|
104
|
+
D1_SAFE_PARAM_BUDGET,
|
|
105
|
+
D1BatchTooLargeError,
|
|
106
|
+
D1BatchUnsupportedError,
|
|
107
|
+
D1WriteShapeError,
|
|
108
|
+
inChunks,
|
|
109
|
+
insertMany,
|
|
110
|
+
notInSubquery,
|
|
111
|
+
replaceSet,
|
|
112
|
+
runBatch,
|
|
113
|
+
unsafeUnchunkedStatement,
|
|
114
|
+
type D1Statement,
|
|
115
|
+
} from "./d1-write.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { nowIsoUtc } from "./date-utils.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived fencing leases for inbound ActivityPub dispatch.
|
|
6
|
+
*
|
|
7
|
+
* The durable activity remains in `activities`; this table only identifies the
|
|
8
|
+
* Worker currently allowed to apply and commit its effects.
|
|
9
|
+
*/
|
|
10
|
+
export const inboundActivityClaims = sqliteTable(
|
|
11
|
+
"inbound_activity_claims",
|
|
12
|
+
{
|
|
13
|
+
activityApId: text("activity_ap_id").primaryKey(),
|
|
14
|
+
processingToken: text("processing_token"),
|
|
15
|
+
leaseExpiresAt: text("lease_expires_at"),
|
|
16
|
+
updatedAt: text("updated_at").notNull().$defaultFn(nowIsoUtc),
|
|
17
|
+
},
|
|
18
|
+
(t) => [index("inbound_activity_claims_lease_idx").on(t.leaseExpiresAt)],
|
|
19
|
+
);
|