@takosjp/yurucommu-core 3.0.1 → 3.0.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/0018_actor_notes.sql +18 -0
- package/migrations/README.md +0 -11
- package/package.json +3 -1
- package/packages/api/package.json +2 -1
- package/packages/api/src/lib/api/normalize.ts +6 -0
- package/packages/api/src/lib/api/notes.ts +25 -0
- package/packages/api/src/lib/api/stories.ts +16 -1
- package/packages/api/src/lib/api.ts +1 -0
- package/packages/api/src/types/index.ts +23 -1
- package/src/backend/index.ts +21 -4
- package/src/backend/middleware/cache.ts +26 -1
- package/src/backend/routes/notes.ts +218 -0
- package/src/backend/routes/recommendations.ts +61 -50
- package/src/backend/routes/stories/interactions.ts +65 -1
- package/src/backend/routes/stories/query-helpers.ts +48 -1
- package/src/backend/routes/stories/routes.ts +2 -30
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/notes.ts +26 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS actor_notes (
|
|
2
|
+
actor_ap_id TEXT PRIMARY KEY,
|
|
3
|
+
content TEXT NOT NULL,
|
|
4
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
5
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
6
|
+
expires_at TEXT NOT NULL,
|
|
7
|
+
deleted_at TEXT,
|
|
8
|
+
FOREIGN KEY (actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE INDEX IF NOT EXISTS actor_notes_expires_idx
|
|
12
|
+
ON actor_notes(expires_at);
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS actor_notes_updated_idx
|
|
15
|
+
ON actor_notes(updated_at);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS actor_notes_deleted_idx
|
|
18
|
+
ON actor_notes(deleted_at);
|
package/migrations/README.md
CHANGED
|
@@ -83,17 +83,6 @@ file name (not the numeric version) in `yurucommu_migrations`.
|
|
|
83
83
|
credentials come from the same reviewed Provider Connection used by the
|
|
84
84
|
OpenTofu run.
|
|
85
85
|
|
|
86
|
-
In the fallback path where the Worker script is not managed by OpenTofu,
|
|
87
|
-
`takosumi_release.post_apply` runs `bun run takosumi:release` as an opaque
|
|
88
|
-
operator release command. It reads non-secret outputs from `TAKOSUMI_OUTPUTS_JSON`,
|
|
89
|
-
writes a temporary Wrangler config, runs `bun install --frozen-lockfile`,
|
|
90
|
-
runs `bun run build:takos-worker`, applies D1 migrations through
|
|
91
|
-
`wrangler d1 execute` without explicit SQL transaction wrappers, and deploys
|
|
92
|
-
with `wrangler deploy`.
|
|
93
|
-
Provider credentials and app-specific secrets come from the selected
|
|
94
|
-
operator release activator boundary through
|
|
95
|
-
`TAKOSUMI_RELEASE_COMMAND_ENV_ALLOWLIST`.
|
|
96
|
-
|
|
97
86
|
Common operator env names:
|
|
98
87
|
|
|
99
88
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takosjp/yurucommu-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"packages/api/src/lib/api/follow.ts",
|
|
30
30
|
"packages/api/src/lib/api/media.ts",
|
|
31
31
|
"packages/api/src/lib/api/moderation.ts",
|
|
32
|
+
"packages/api/src/lib/api/notes.ts",
|
|
32
33
|
"packages/api/src/lib/api/normalize.ts",
|
|
33
34
|
"packages/api/src/lib/api/notifications.ts",
|
|
34
35
|
"packages/api/src/lib/api/posts.ts",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"test:backend": "bun test src/backend/",
|
|
60
61
|
"build": "bun run build:api",
|
|
61
62
|
"build:api": "cd packages/api && bun run build",
|
|
63
|
+
"prepublishOnly": "bun scripts/check-publish-version-discipline.mjs .",
|
|
62
64
|
"pack:core": "npm pack --dry-run",
|
|
63
65
|
"pack:api": "cd packages/api && bun run pack:dry",
|
|
64
66
|
"app:activate": "bun scripts/apply-takosumi-migrations.ts",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takosjp/yurucommu-api",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Typed client SDK and public API contract for yurucommu-server clients.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
21
21
|
"build": "rm -rf dist && bun build src/index.ts --target browser --format esm --outfile dist/index.js && bunx tsc -p tsconfig.build.json && bun scripts/rewrite-dts-imports.mjs",
|
|
22
|
+
"prepublishOnly": "bun ../../scripts/check-publish-version-discipline.mjs .",
|
|
22
23
|
"pack:dry": "npm pack --dry-run"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Actor,
|
|
3
3
|
ActorStories,
|
|
4
|
+
ActorNote,
|
|
4
5
|
Notification,
|
|
5
6
|
Post,
|
|
6
7
|
Story,
|
|
@@ -63,6 +64,11 @@ export const normalizeActorStories = (stories: ActorStories): ActorStories => ({
|
|
|
63
64
|
stories: (stories.stories || []).map(normalizeStory),
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
export const normalizeActorNote = (note: ActorNote): ActorNote => ({
|
|
68
|
+
...note,
|
|
69
|
+
actor: normalizeActor(note.actor),
|
|
70
|
+
});
|
|
71
|
+
|
|
66
72
|
export const normalizeNotification = (
|
|
67
73
|
notification: Notification,
|
|
68
74
|
): Notification => ({
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ActorNote } from "../../types/index.ts";
|
|
2
|
+
import { normalizeActorNote } from "./normalize.ts";
|
|
3
|
+
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
|
+
|
|
5
|
+
export async function fetchNotes(): Promise<ActorNote[]> {
|
|
6
|
+
const res = await apiFetch("/api/notes");
|
|
7
|
+
await assertOk(res, "Failed to fetch notes");
|
|
8
|
+
const data = (await res.json()) as { notes?: ActorNote[] };
|
|
9
|
+
return (data.notes || []).map(normalizeActorNote);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function createNote(data: {
|
|
13
|
+
content: string;
|
|
14
|
+
expires_in_hours?: number;
|
|
15
|
+
}): Promise<ActorNote> {
|
|
16
|
+
const res = await apiPost("/api/notes", data);
|
|
17
|
+
await assertOk(res, "Failed to create note");
|
|
18
|
+
const result = (await res.json()) as { note: ActorNote };
|
|
19
|
+
return normalizeActorNote(result.note);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function deleteMyNote(): Promise<void> {
|
|
23
|
+
const res = await apiDelete("/api/notes/me");
|
|
24
|
+
await assertOk(res, "Failed to delete note");
|
|
25
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ActorStories,
|
|
3
|
+
Story,
|
|
4
|
+
StoryOverlay,
|
|
5
|
+
StoryViewersResponse,
|
|
6
|
+
} from "../../types/index.ts";
|
|
2
7
|
import { normalizeActorStories, normalizeStory } from "./normalize.ts";
|
|
3
8
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
9
|
|
|
@@ -78,3 +83,13 @@ export async function shareStory(
|
|
|
78
83
|
await assertOk(res, "Failed to share story");
|
|
79
84
|
return (await res.json()) as { shared: boolean; share_count: number };
|
|
80
85
|
}
|
|
86
|
+
|
|
87
|
+
// Author-only "seen by" list for a story. GETs the same id-encoded path the
|
|
88
|
+
// sibling like/share routes use.
|
|
89
|
+
export async function getStoryViewers(
|
|
90
|
+
apId: string,
|
|
91
|
+
): Promise<StoryViewersResponse> {
|
|
92
|
+
const res = await apiFetch(`/api/stories/${encodeURIComponent(apId)}/views`);
|
|
93
|
+
await assertOk(res, "Failed to fetch story viewers");
|
|
94
|
+
return (await res.json()) as StoryViewersResponse;
|
|
95
|
+
}
|
|
@@ -10,6 +10,7 @@ export * from "./api/notifications.ts";
|
|
|
10
10
|
export * from "./api/search.ts";
|
|
11
11
|
export * from "./api/media.ts";
|
|
12
12
|
export * from "./api/stories.ts";
|
|
13
|
+
export * from "./api/notes.ts";
|
|
13
14
|
export * from "./api/recommendations.ts";
|
|
14
15
|
export * from "./api/moderation.ts";
|
|
15
16
|
export * from "./api/normalize.ts";
|
|
@@ -150,7 +150,6 @@ export interface StoryOverlay {
|
|
|
150
150
|
// Question-specific
|
|
151
151
|
name?: string; // Question text
|
|
152
152
|
oneOf?: Array<{ type: string; name: string }>; // Options
|
|
153
|
-
closed?: string; // Close time
|
|
154
153
|
// Link-specific
|
|
155
154
|
href?: string;
|
|
156
155
|
// Generic
|
|
@@ -183,3 +182,26 @@ export interface ActorStories {
|
|
|
183
182
|
stories: Story[];
|
|
184
183
|
has_unviewed: boolean;
|
|
185
184
|
}
|
|
185
|
+
|
|
186
|
+
// A single viewer in a story's "seen by" list (author-only).
|
|
187
|
+
export interface StoryViewer {
|
|
188
|
+
actor: PostAuthor;
|
|
189
|
+
viewed_at: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Response of GET /api/stories/:id/views (author-only "seen by").
|
|
193
|
+
export interface StoryViewersResponse {
|
|
194
|
+
view_count: number;
|
|
195
|
+
viewers: StoryViewer[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Short-lived actor status note. This is the Instagram-Notes-style current
|
|
199
|
+
// status surface, not the ActivityPub `Note` object used for normal posts.
|
|
200
|
+
export interface ActorNote {
|
|
201
|
+
actor: PostAuthor;
|
|
202
|
+
content: string;
|
|
203
|
+
created_at: string;
|
|
204
|
+
updated_at: string;
|
|
205
|
+
expires_at: string;
|
|
206
|
+
is_mine: boolean;
|
|
207
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import actorsRoutes from "./routes/actors.ts";
|
|
|
14
14
|
import followRoutes from "./routes/follow.ts";
|
|
15
15
|
import timelineRoutes from "./routes/timeline.ts";
|
|
16
16
|
import postsRoutes from "./routes/posts.ts";
|
|
17
|
+
import notesRoutes from "./routes/notes.ts";
|
|
17
18
|
import notificationsRoutes from "./routes/notifications.ts";
|
|
18
19
|
import storiesRoutes from "./routes/stories.ts";
|
|
19
20
|
import searchRoutes from "./routes/search.ts";
|
|
@@ -218,7 +219,8 @@ function buildSocialServerDiscovery(
|
|
|
218
219
|
...DEFAULT_DISCOVERY_OPTIONS,
|
|
219
220
|
...options,
|
|
220
221
|
clients: options.clients ?? DEFAULT_DISCOVERY_OPTIONS.clients,
|
|
221
|
-
capabilities:
|
|
222
|
+
capabilities:
|
|
223
|
+
options.capabilities ?? DEFAULT_DISCOVERY_OPTIONS.capabilities,
|
|
222
224
|
};
|
|
223
225
|
return {
|
|
224
226
|
product: discovery.product,
|
|
@@ -599,6 +601,8 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
599
601
|
app.use("/media/*", rateLimit(RateLimitConfigs.mediaUpload));
|
|
600
602
|
app.use("/api/dm/*", rateLimit(RateLimitConfigs.dm));
|
|
601
603
|
app.post("/api/posts", rateLimit(RateLimitConfigs.postCreate));
|
|
604
|
+
app.post("/api/notes", rateLimit(RateLimitConfigs.postCreate));
|
|
605
|
+
app.delete("/api/notes/me", rateLimit(RateLimitConfigs.postCreate));
|
|
602
606
|
// Like/repost are federated WRITES (they sign + deliver activities to remote
|
|
603
607
|
// inboxes), so bound them at the write budget rather than the general read
|
|
604
608
|
// budget to limit mass-interaction delivery storms.
|
|
@@ -676,6 +680,7 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
676
680
|
app.route("/api/follow", followRoutes);
|
|
677
681
|
app.route("/api/timeline", timelineRoutes);
|
|
678
682
|
app.route("/api/posts", postsRoutes);
|
|
683
|
+
app.route("/api/notes", notesRoutes);
|
|
679
684
|
|
|
680
685
|
app.get("/api/bookmarks", async (c) => {
|
|
681
686
|
const url = new URL(c.req.url);
|
|
@@ -702,23 +707,35 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
702
707
|
|
|
703
708
|
function mountStaticFallback(app: YurucommuApp): void {
|
|
704
709
|
app.all("*", async (c) => {
|
|
710
|
+
const url = new URL(c.req.url);
|
|
705
711
|
// A request that reaches the static fallback under a backend route prefix
|
|
706
712
|
// means no API / AP / media route matched it — return a genuine JSON 404
|
|
707
713
|
// instead of the SPA HTML shell. Without this, the Cloudflare ASSETS binding
|
|
708
714
|
// (single-page-application mode) served index.html with a 200 for unmatched
|
|
709
715
|
// /api/* paths, so an API client (or our own fetch) got HTML 200 instead of
|
|
710
716
|
// a 404 — the Bun runtime already guarded this; share one source of truth.
|
|
711
|
-
if (isBackendPath(
|
|
717
|
+
if (isBackendPath(url.pathname)) {
|
|
712
718
|
return c.json({ error: "Not Found", code: "NOT_FOUND" }, 404);
|
|
713
719
|
}
|
|
714
720
|
|
|
715
721
|
if (c.env.ASSETS) {
|
|
716
|
-
|
|
722
|
+
const response = await c.env.ASSETS.fetch(c.req.raw);
|
|
723
|
+
const method = c.req.method.toUpperCase();
|
|
724
|
+
if (
|
|
725
|
+
response.status === 404 &&
|
|
726
|
+
(method === "GET" || method === "HEAD") &&
|
|
727
|
+
!url.pathname.includes(".")
|
|
728
|
+
) {
|
|
729
|
+
const indexUrl = new URL(c.req.url);
|
|
730
|
+
indexUrl.pathname = "/index.html";
|
|
731
|
+
indexUrl.search = "";
|
|
732
|
+
return c.env.ASSETS.fetch(new Request(indexUrl, c.req.raw));
|
|
733
|
+
}
|
|
734
|
+
return response;
|
|
717
735
|
}
|
|
718
736
|
|
|
719
737
|
const storage = (c.env as { STORAGE?: R2Bucket }).STORAGE;
|
|
720
738
|
if (storage) {
|
|
721
|
-
const url = new URL(c.req.url);
|
|
722
739
|
let assetPath = url.pathname;
|
|
723
740
|
|
|
724
741
|
if (assetPath === "/" || assetPath === "") {
|
|
@@ -319,7 +319,20 @@ async function handleCloudflareCache(
|
|
|
319
319
|
const url = new URL(c.req.url);
|
|
320
320
|
const fullCacheKey = new Request(`${url.origin}/_cache${cacheKey}`);
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
let cachedResponse: Response | undefined;
|
|
323
|
+
try {
|
|
324
|
+
cachedResponse = await cache.match(fullCacheKey);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (isDefaultCacheUnavailable(error)) {
|
|
327
|
+
log.warn("Cloudflare default cache is unavailable; using memory cache", {
|
|
328
|
+
event: "cache.default_unavailable",
|
|
329
|
+
cacheKey,
|
|
330
|
+
error,
|
|
331
|
+
});
|
|
332
|
+
return handleMemoryCache(c, next, cacheKey, config);
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
323
336
|
|
|
324
337
|
if (cachedResponse) {
|
|
325
338
|
const etag = cachedResponse.headers.get("ETag");
|
|
@@ -371,6 +384,18 @@ async function handleCloudflareCache(
|
|
|
371
384
|
c.res = responseToCache;
|
|
372
385
|
}
|
|
373
386
|
|
|
387
|
+
function isDefaultCacheUnavailable(error: unknown): boolean {
|
|
388
|
+
const message =
|
|
389
|
+
error instanceof Error
|
|
390
|
+
? error.message
|
|
391
|
+
: typeof error === "string"
|
|
392
|
+
? error
|
|
393
|
+
: "";
|
|
394
|
+
return /not permitted to access the default cache|default cache.*(?:not|un)available|Cache API.*not available/i.test(
|
|
395
|
+
message,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
374
399
|
async function handleMemoryCache(
|
|
375
400
|
c: HonoContext,
|
|
376
401
|
next: Next,
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { and, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
|
3
|
+
import type { SQL } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
import { actorNotes, actors, follows, type Database } from "../../db/index.ts";
|
|
6
|
+
import type { Env, Variables } from "../types.ts";
|
|
7
|
+
import { formatUsername } from "../federation-helpers.ts";
|
|
8
|
+
import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
|
|
9
|
+
|
|
10
|
+
const notes = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
11
|
+
|
|
12
|
+
const MAX_NOTE_CONTENT_LENGTH = 80;
|
|
13
|
+
const DEFAULT_NOTE_TTL_HOURS = 24;
|
|
14
|
+
const MIN_NOTE_TTL_HOURS = 1;
|
|
15
|
+
const MAX_NOTE_TTL_HOURS = 24;
|
|
16
|
+
const MAX_NOTE_FEED_ITEMS = 60;
|
|
17
|
+
|
|
18
|
+
type NoteRow = {
|
|
19
|
+
actorApId: string;
|
|
20
|
+
content: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
preferredUsername: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
iconUrl: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type NoteResponse = {
|
|
30
|
+
actor: {
|
|
31
|
+
ap_id: string;
|
|
32
|
+
username: string;
|
|
33
|
+
preferred_username: string;
|
|
34
|
+
name: string | null;
|
|
35
|
+
icon_url: string | null;
|
|
36
|
+
};
|
|
37
|
+
content: string;
|
|
38
|
+
created_at: string;
|
|
39
|
+
updated_at: string;
|
|
40
|
+
expires_at: string;
|
|
41
|
+
is_mine: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function sanitizeContent(input: unknown): string | null {
|
|
45
|
+
if (typeof input !== "string") return null;
|
|
46
|
+
const content = input.trim();
|
|
47
|
+
if (content.length === 0 || content.length > MAX_NOTE_CONTENT_LENGTH) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return content;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ttlHours(input: unknown): number {
|
|
54
|
+
if (typeof input !== "number" || !Number.isFinite(input)) {
|
|
55
|
+
return DEFAULT_NOTE_TTL_HOURS;
|
|
56
|
+
}
|
|
57
|
+
return Math.min(
|
|
58
|
+
MAX_NOTE_TTL_HOURS,
|
|
59
|
+
Math.max(MIN_NOTE_TTL_HOURS, Math.floor(input)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatNote(row: NoteRow, viewerApId: string): NoteResponse {
|
|
64
|
+
return {
|
|
65
|
+
actor: {
|
|
66
|
+
ap_id: row.actorApId,
|
|
67
|
+
username: formatUsername(row.actorApId),
|
|
68
|
+
preferred_username: row.preferredUsername,
|
|
69
|
+
name: row.name,
|
|
70
|
+
icon_url: row.iconUrl,
|
|
71
|
+
},
|
|
72
|
+
content: row.content,
|
|
73
|
+
created_at: row.createdAt,
|
|
74
|
+
updated_at: row.updatedAt,
|
|
75
|
+
expires_at: row.expiresAt,
|
|
76
|
+
is_mine: row.actorApId === viewerApId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadActiveNotes(
|
|
81
|
+
db: Database,
|
|
82
|
+
viewerApId: string,
|
|
83
|
+
): Promise<NoteRow[]> {
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
const followingSubquery = db
|
|
86
|
+
.select({ id: follows.followingApId })
|
|
87
|
+
.from(follows)
|
|
88
|
+
.where(
|
|
89
|
+
and(eq(follows.followerApId, viewerApId), eq(follows.status, "accepted")),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const filters: SQL[] = [
|
|
93
|
+
isNull(actorNotes.deletedAt),
|
|
94
|
+
isNull(actors.deletedAt),
|
|
95
|
+
gt(actorNotes.expiresAt, now),
|
|
96
|
+
or(
|
|
97
|
+
eq(actorNotes.actorApId, viewerApId),
|
|
98
|
+
inArray(actorNotes.actorApId, followingSubquery),
|
|
99
|
+
)!,
|
|
100
|
+
];
|
|
101
|
+
const excludeAuthors = excludeBlockedMutedAuthors(
|
|
102
|
+
db,
|
|
103
|
+
viewerApId,
|
|
104
|
+
actorNotes.actorApId,
|
|
105
|
+
);
|
|
106
|
+
if (excludeAuthors) filters.push(excludeAuthors);
|
|
107
|
+
|
|
108
|
+
return await db
|
|
109
|
+
.select({
|
|
110
|
+
actorApId: actorNotes.actorApId,
|
|
111
|
+
content: actorNotes.content,
|
|
112
|
+
createdAt: actorNotes.createdAt,
|
|
113
|
+
updatedAt: actorNotes.updatedAt,
|
|
114
|
+
expiresAt: actorNotes.expiresAt,
|
|
115
|
+
preferredUsername: actors.preferredUsername,
|
|
116
|
+
name: actors.name,
|
|
117
|
+
iconUrl: actors.iconUrl,
|
|
118
|
+
})
|
|
119
|
+
.from(actorNotes)
|
|
120
|
+
.innerJoin(actors, eq(actorNotes.actorApId, actors.apId))
|
|
121
|
+
.where(and(...filters))
|
|
122
|
+
.orderBy(desc(actorNotes.updatedAt))
|
|
123
|
+
.limit(MAX_NOTE_FEED_ITEMS);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
notes.get("/", async (c) => {
|
|
127
|
+
const actor = c.get("actor");
|
|
128
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
129
|
+
|
|
130
|
+
const rows = await loadActiveNotes(c.get("db"), actor.ap_id);
|
|
131
|
+
return c.json({ notes: rows.map((row) => formatNote(row, actor.ap_id)) });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
notes.post("/", async (c) => {
|
|
135
|
+
const actor = c.get("actor");
|
|
136
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
137
|
+
|
|
138
|
+
const body = await c.req.json().catch(() => null);
|
|
139
|
+
if (!body || typeof body !== "object") {
|
|
140
|
+
return c.json({ error: "JSON body required" }, 400);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const content = sanitizeContent((body as { content?: unknown }).content);
|
|
144
|
+
if (!content) {
|
|
145
|
+
return c.json(
|
|
146
|
+
{
|
|
147
|
+
error: `content must be 1-${MAX_NOTE_CONTENT_LENGTH} characters`,
|
|
148
|
+
},
|
|
149
|
+
400,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
const expiresAt = new Date(
|
|
155
|
+
Date.now() +
|
|
156
|
+
ttlHours((body as { expires_in_hours?: unknown }).expires_in_hours) *
|
|
157
|
+
60 *
|
|
158
|
+
60 *
|
|
159
|
+
1000,
|
|
160
|
+
).toISOString();
|
|
161
|
+
const db = c.get("db");
|
|
162
|
+
|
|
163
|
+
await db
|
|
164
|
+
.insert(actorNotes)
|
|
165
|
+
.values({
|
|
166
|
+
actorApId: actor.ap_id,
|
|
167
|
+
content,
|
|
168
|
+
createdAt: now,
|
|
169
|
+
updatedAt: now,
|
|
170
|
+
expiresAt,
|
|
171
|
+
deletedAt: null,
|
|
172
|
+
})
|
|
173
|
+
.onConflictDoUpdate({
|
|
174
|
+
target: actorNotes.actorApId,
|
|
175
|
+
set: {
|
|
176
|
+
content,
|
|
177
|
+
createdAt: now,
|
|
178
|
+
updatedAt: now,
|
|
179
|
+
expiresAt,
|
|
180
|
+
deletedAt: null,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return c.json(
|
|
185
|
+
{
|
|
186
|
+
note: formatNote(
|
|
187
|
+
{
|
|
188
|
+
actorApId: actor.ap_id,
|
|
189
|
+
content,
|
|
190
|
+
createdAt: now,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
expiresAt,
|
|
193
|
+
preferredUsername: actor.preferred_username,
|
|
194
|
+
name: actor.name,
|
|
195
|
+
iconUrl: actor.icon_url,
|
|
196
|
+
},
|
|
197
|
+
actor.ap_id,
|
|
198
|
+
),
|
|
199
|
+
},
|
|
200
|
+
201,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
notes.delete("/me", async (c) => {
|
|
205
|
+
const actor = c.get("actor");
|
|
206
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
207
|
+
|
|
208
|
+
const now = new Date().toISOString();
|
|
209
|
+
await c
|
|
210
|
+
.get("db")
|
|
211
|
+
.update(actorNotes)
|
|
212
|
+
.set({ deletedAt: now, updatedAt: now, expiresAt: now })
|
|
213
|
+
.where(eq(actorNotes.actorApId, actor.ap_id));
|
|
214
|
+
|
|
215
|
+
return c.json({ success: true });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export default notes;
|
|
@@ -2,10 +2,12 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { sql } from "drizzle-orm";
|
|
3
3
|
import type { Env, Variables } from "../types.ts";
|
|
4
4
|
import { formatUsername } from "../federation-helpers.ts";
|
|
5
|
+
import { logger } from "../lib/logger.ts";
|
|
5
6
|
import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
|
|
6
7
|
import { batchLoadActorInfo } from "./communities/membership-shared.ts";
|
|
7
8
|
|
|
8
9
|
const recommendations = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
10
|
+
const log = logger.child({ component: "recommendations" });
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* GET /api/recommendations/users
|
|
@@ -28,60 +30,69 @@ recommendations.get(
|
|
|
28
30
|
const db = c.get("db");
|
|
29
31
|
const myApId = actor.ap_id;
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
33
|
+
try {
|
|
34
|
+
const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
|
|
35
|
+
sql`
|
|
36
|
+
SELECT f2.following_ap_id AS ap_id, COUNT(DISTINCT f2.follower_ap_id) AS mutual_count
|
|
37
|
+
FROM follows f1
|
|
38
|
+
JOIN follows f2 ON f1.following_ap_id = f2.follower_ap_id AND f2.status = 'accepted'
|
|
39
|
+
WHERE f1.follower_ap_id = ${myApId}
|
|
40
|
+
AND f1.status = 'accepted'
|
|
41
|
+
AND f2.following_ap_id != ${myApId}
|
|
42
|
+
AND f2.following_ap_id NOT IN (
|
|
43
|
+
SELECT following_ap_id FROM follows
|
|
44
|
+
WHERE follower_ap_id = ${myApId} AND status IN ('accepted', 'pending')
|
|
45
|
+
)
|
|
46
|
+
AND f2.following_ap_id NOT IN (
|
|
47
|
+
SELECT blocked_ap_id FROM blocks WHERE blocker_ap_id = ${myApId}
|
|
48
|
+
)
|
|
49
|
+
AND f2.following_ap_id NOT IN (
|
|
50
|
+
SELECT muted_ap_id FROM mutes WHERE muter_ap_id = ${myApId}
|
|
51
|
+
)
|
|
52
|
+
AND f2.following_ap_id NOT IN (
|
|
53
|
+
-- Hide deleted AND private/locked (is_private = 1) accounts. A locked
|
|
54
|
+
-- account opted out of discovery (discoverable:false), and every other
|
|
55
|
+
-- actor-discovery surface (search.actors, takos-tools searchUsers /
|
|
56
|
+
-- getUserProfile) excludes is_private = 1 — this friends-of-friends
|
|
57
|
+
-- panel must match or it leaks the locked account's handle/name/icon.
|
|
58
|
+
-- is_private lives only on the LOCAL actors table; remote candidates
|
|
59
|
+
-- (actorCache) are unaffected, same scope as the other surfaces.
|
|
60
|
+
SELECT ap_id FROM actors WHERE deleted_at IS NOT NULL OR is_private = 1
|
|
61
|
+
)
|
|
62
|
+
GROUP BY f2.following_ap_id
|
|
63
|
+
ORDER BY mutual_count DESC
|
|
64
|
+
LIMIT 5
|
|
65
|
+
`,
|
|
66
|
+
);
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
if (candidates.length === 0) return c.json({ users: [] });
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
const actorMap = await batchLoadActorInfo(
|
|
71
|
+
db,
|
|
72
|
+
candidates.map((r) => r.ap_id),
|
|
73
|
+
);
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
const users = candidates.map((row) => {
|
|
76
|
+
const info = actorMap.get(row.ap_id);
|
|
77
|
+
return {
|
|
78
|
+
ap_id: row.ap_id,
|
|
79
|
+
preferred_username: info?.preferredUsername ?? null,
|
|
80
|
+
name: info?.name ?? null,
|
|
81
|
+
icon_url: info?.iconUrl ?? null,
|
|
82
|
+
username: formatUsername(row.ap_id),
|
|
83
|
+
mutual_count: Number(row.mutual_count),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
return c.json({ users });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
log.warn("Recommendations failed; returning empty suggestions", {
|
|
90
|
+
event: "recommendations.failed",
|
|
91
|
+
actorApId: myApId,
|
|
92
|
+
error,
|
|
93
|
+
});
|
|
94
|
+
return c.json({ users: [] });
|
|
95
|
+
}
|
|
85
96
|
},
|
|
86
97
|
);
|
|
87
98
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { and, eq, gt, sql } from "drizzle-orm";
|
|
2
|
+
import { and, count, desc, eq, gt, sql } from "drizzle-orm";
|
|
3
3
|
import type { Env, Variables } from "../../types.ts";
|
|
4
4
|
import {
|
|
5
5
|
activities,
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
safeJsonParse,
|
|
19
19
|
} from "../../federation-helpers.ts";
|
|
20
20
|
import {
|
|
21
|
+
buildAuthor,
|
|
21
22
|
canViewerReadStory,
|
|
22
23
|
findStory,
|
|
23
24
|
getVoteCounts,
|
|
24
25
|
resolveStoryApId,
|
|
25
26
|
sumVotes,
|
|
26
27
|
} from "./query-helpers.ts";
|
|
28
|
+
import { loadActorInfoMap } from "../actors-helpers.ts";
|
|
27
29
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
28
30
|
import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
29
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
@@ -573,4 +575,66 @@ stories.get("/:id/votes", async (c) => {
|
|
|
573
575
|
return c.json({ votes, total: sumVotes(votes), user_vote });
|
|
574
576
|
});
|
|
575
577
|
|
|
578
|
+
// Cap the returned viewer list most-recent-first. `view_count` stays the true
|
|
579
|
+
// total (the author sees the real number even when the list is truncated).
|
|
580
|
+
const STORY_VIEWERS_LIMIT = 200;
|
|
581
|
+
|
|
582
|
+
// Get the "seen by" viewer list for a story (author-only).
|
|
583
|
+
stories.get("/:id/views", async (c) => {
|
|
584
|
+
const db = c.get("db");
|
|
585
|
+
const actor = c.get("actor");
|
|
586
|
+
const baseUrl = c.env.APP_URL;
|
|
587
|
+
// Resolve the id the same way every sibling /:id/* route does.
|
|
588
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
589
|
+
|
|
590
|
+
const story = await findStory(db, apId);
|
|
591
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
592
|
+
|
|
593
|
+
// Author-only: only the story author may see WHO viewed. Anyone else
|
|
594
|
+
// (including anonymous) gets 404 so the viewer list isn't disclosed and the
|
|
595
|
+
// endpoint isn't a story-existence oracle for non-authors.
|
|
596
|
+
if (!actor || actor.ap_id !== story.attributedTo) {
|
|
597
|
+
return c.json({ error: "Story not found" }, 404);
|
|
598
|
+
}
|
|
599
|
+
// Mirror the /:id/votes expiry gate: don't serve the list for an expired
|
|
600
|
+
// story before the reaper runs.
|
|
601
|
+
if (story.endTime && story.endTime < new Date().toISOString()) {
|
|
602
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// True total (uncapped) — stays accurate even when the list below is capped.
|
|
606
|
+
const totalRow = await db
|
|
607
|
+
.select({ value: count() })
|
|
608
|
+
.from(storyViews)
|
|
609
|
+
.where(eq(storyViews.storyApId, apId))
|
|
610
|
+
.get();
|
|
611
|
+
const view_count = totalRow?.value ?? 0;
|
|
612
|
+
|
|
613
|
+
// Most-recent-first page of viewers, capped.
|
|
614
|
+
const rows = await db
|
|
615
|
+
.select({
|
|
616
|
+
actorApId: storyViews.actorApId,
|
|
617
|
+
viewedAt: storyViews.viewedAt,
|
|
618
|
+
})
|
|
619
|
+
.from(storyViews)
|
|
620
|
+
.where(eq(storyViews.storyApId, apId))
|
|
621
|
+
.orderBy(desc(storyViews.viewedAt))
|
|
622
|
+
.limit(STORY_VIEWERS_LIMIT);
|
|
623
|
+
|
|
624
|
+
// Hydrate ap_id → PostAuthor via the same batch loader the follower/story
|
|
625
|
+
// lists use (local `actors` + `actor_cache`, local wins). A remote viewer
|
|
626
|
+
// absent from both degrades to a best-effort author in `buildAuthor`.
|
|
627
|
+
const infoMap = await loadActorInfoMap(
|
|
628
|
+
db,
|
|
629
|
+
rows.map((r) => r.actorApId),
|
|
630
|
+
"author",
|
|
631
|
+
);
|
|
632
|
+
const viewers = rows.map((r) => ({
|
|
633
|
+
actor: buildAuthor(r.actorApId, infoMap.get(r.actorApId)),
|
|
634
|
+
viewed_at: r.viewedAt,
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
return c.json({ view_count, viewers });
|
|
638
|
+
});
|
|
639
|
+
|
|
576
640
|
export default stories;
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
storyVotes,
|
|
12
12
|
} from "../../../db/index.ts";
|
|
13
13
|
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
formatUsername,
|
|
16
|
+
objectApId,
|
|
17
|
+
safeJsonParse,
|
|
18
|
+
} from "../../federation-helpers.ts";
|
|
15
19
|
import {
|
|
16
20
|
deleteObjectCascade,
|
|
17
21
|
purgeMediaBlobs,
|
|
@@ -271,6 +275,49 @@ export async function fetchActorCache(
|
|
|
271
275
|
);
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Author projection
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Public author projection for a story author / viewer (the `PostAuthor` shape
|
|
284
|
+
* used across the feed / story surfaces). Shared by the story feed and the
|
|
285
|
+
* viewer ("seen by") list so both hydrate an ap_id → author identically.
|
|
286
|
+
*/
|
|
287
|
+
export type StoryAuthor = {
|
|
288
|
+
ap_id: string;
|
|
289
|
+
username: string;
|
|
290
|
+
preferred_username: string | null;
|
|
291
|
+
name: string | null;
|
|
292
|
+
icon_url: string | null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build a StoryAuthor from available data sources. A remote actor with no local
|
|
297
|
+
* `actors` / `actor_cache` row (`data` undefined) degrades gracefully to a
|
|
298
|
+
* best-effort `username` derived from the ap_id plus null fields, so a viewer is
|
|
299
|
+
* never dropped just because its profile isn't cached locally.
|
|
300
|
+
*/
|
|
301
|
+
export function buildAuthor(
|
|
302
|
+
apId: string,
|
|
303
|
+
data:
|
|
304
|
+
| {
|
|
305
|
+
preferredUsername?: string | null;
|
|
306
|
+
name?: string | null;
|
|
307
|
+
iconUrl?: string | null;
|
|
308
|
+
}
|
|
309
|
+
| null
|
|
310
|
+
| undefined,
|
|
311
|
+
): StoryAuthor {
|
|
312
|
+
return {
|
|
313
|
+
ap_id: apId,
|
|
314
|
+
username: formatUsername(apId),
|
|
315
|
+
preferred_username: data?.preferredUsername || null,
|
|
316
|
+
name: data?.name || null,
|
|
317
|
+
icon_url: data?.iconUrl || null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
// ---------------------------------------------------------------------------
|
|
275
322
|
// Story data cleanup & transformation
|
|
276
323
|
// ---------------------------------------------------------------------------
|
|
@@ -21,7 +21,6 @@ import type { IObjectStorage } from "../../runtime/types.ts";
|
|
|
21
21
|
import {
|
|
22
22
|
activityApId,
|
|
23
23
|
actorApId,
|
|
24
|
-
formatUsername,
|
|
25
24
|
generateId,
|
|
26
25
|
objectApId,
|
|
27
26
|
} from "../../federation-helpers.ts";
|
|
@@ -31,10 +30,12 @@ import { maybeReapDrainedTombstones } from "../actors.ts";
|
|
|
31
30
|
import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
|
|
32
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
33
32
|
import {
|
|
33
|
+
buildAuthor,
|
|
34
34
|
cleanupExpiredStories,
|
|
35
35
|
fetchActorCache,
|
|
36
36
|
fetchBatchVotes,
|
|
37
37
|
fetchBlockedAndMutedIds,
|
|
38
|
+
type StoryAuthor,
|
|
38
39
|
sumVotes,
|
|
39
40
|
transformStoryData,
|
|
40
41
|
validateOverlays,
|
|
@@ -101,14 +102,6 @@ stories.post("/delete", storyWriteLimiter);
|
|
|
101
102
|
|
|
102
103
|
type VoteResults = Record<number, number>;
|
|
103
104
|
|
|
104
|
-
type StoryAuthor = {
|
|
105
|
-
ap_id: string;
|
|
106
|
-
username: string;
|
|
107
|
-
preferred_username: string | null;
|
|
108
|
-
name: string | null;
|
|
109
|
-
icon_url: string | null;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
105
|
type StoryResponse = {
|
|
113
106
|
ap_id: string;
|
|
114
107
|
author: StoryAuthor;
|
|
@@ -160,27 +153,6 @@ const MAX_STORY_CAPTION_LENGTH = 500;
|
|
|
160
153
|
// feed page; a busy instance simply shows the 90 most recent.
|
|
161
154
|
const MAX_STORY_FEED_ITEMS = 90;
|
|
162
155
|
|
|
163
|
-
/** Build a StoryAuthor from available data sources. */
|
|
164
|
-
function buildAuthor(
|
|
165
|
-
apId: string,
|
|
166
|
-
data:
|
|
167
|
-
| {
|
|
168
|
-
preferredUsername?: string | null;
|
|
169
|
-
name?: string | null;
|
|
170
|
-
iconUrl?: string | null;
|
|
171
|
-
}
|
|
172
|
-
| null
|
|
173
|
-
| undefined,
|
|
174
|
-
): StoryAuthor {
|
|
175
|
-
return {
|
|
176
|
-
ap_id: apId,
|
|
177
|
-
username: formatUsername(apId),
|
|
178
|
-
preferred_username: data?.preferredUsername || null,
|
|
179
|
-
name: data?.name || null,
|
|
180
|
-
icon_url: data?.iconUrl || null,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
156
|
/** Build a StoryResponse from a story object row and pre-fetched data. */
|
|
185
157
|
function buildStoryResponse(
|
|
186
158
|
s: {
|
package/src/db/schema/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./social.ts";
|
|
|
12
12
|
export * from "./reports.ts";
|
|
13
13
|
export * from "./communities.ts";
|
|
14
14
|
export * from "./stories.ts";
|
|
15
|
+
export * from "./notes.ts";
|
|
15
16
|
export * from "./messaging.ts";
|
|
16
17
|
export * from "./mobile.ts";
|
|
17
18
|
export * from "./relations.ts";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived actor status notes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
6
|
+
import { nowIso } from "./date-utils.ts";
|
|
7
|
+
|
|
8
|
+
export const actorNotes = sqliteTable(
|
|
9
|
+
"actor_notes",
|
|
10
|
+
{
|
|
11
|
+
actorApId: text("actor_ap_id").primaryKey(),
|
|
12
|
+
content: text("content").notNull(),
|
|
13
|
+
createdAt: text("created_at").notNull().$defaultFn(nowIso),
|
|
14
|
+
updatedAt: text("updated_at")
|
|
15
|
+
.notNull()
|
|
16
|
+
.$defaultFn(nowIso)
|
|
17
|
+
.$onUpdateFn(nowIso),
|
|
18
|
+
expiresAt: text("expires_at").notNull(),
|
|
19
|
+
deletedAt: text("deleted_at"),
|
|
20
|
+
},
|
|
21
|
+
(t) => [
|
|
22
|
+
index("actor_notes_expires_idx").on(t.expiresAt),
|
|
23
|
+
index("actor_notes_updated_idx").on(t.updatedAt),
|
|
24
|
+
index("actor_notes_deleted_idx").on(t.deletedAt),
|
|
25
|
+
],
|
|
26
|
+
);
|