@jant/core 0.6.15 → 0.6.16
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/bin/commands/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes the real 0005 backfill SQL against legacy-shaped rows.
|
|
3
|
+
*
|
|
4
|
+
* The backfill runs on user data through `jant migrate`, and the same file is
|
|
5
|
+
* executed on both SQLite and Postgres, so it has to be portable and safe to
|
|
6
|
+
* rerun. These tests cover the SQLite side; the SQL avoids dialect-specific
|
|
7
|
+
* syntax (no `UPDATE ... FROM`, only correlated subqueries and TRUE/FALSE
|
|
8
|
+
* literals, which both engines accept).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import type BetterSqlite3 from "better-sqlite3";
|
|
14
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
createTestDatabase,
|
|
17
|
+
DEFAULT_TEST_SITE_ID,
|
|
18
|
+
} from "../../__tests__/helpers/db.js";
|
|
19
|
+
|
|
20
|
+
const BACKFILL_SQL = readFileSync(
|
|
21
|
+
resolve(
|
|
22
|
+
import.meta.dirname,
|
|
23
|
+
"../backfills/0005_split_thread_activity_from_quiet_replies.sql",
|
|
24
|
+
),
|
|
25
|
+
"utf-8",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
interface RootRow {
|
|
29
|
+
quiet_reply: number;
|
|
30
|
+
last_activity_at: number | null;
|
|
31
|
+
thread_updated_at: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("0005 backfill — split thread activity from quiet replies", () => {
|
|
35
|
+
let sqlite: BetterSqlite3.Database;
|
|
36
|
+
|
|
37
|
+
function runBackfill() {
|
|
38
|
+
sqlite.exec(BACKFILL_SQL);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Insert a post row the way the pre-0031 schema would have left it. */
|
|
42
|
+
function insertLegacyPost(row: {
|
|
43
|
+
id: string;
|
|
44
|
+
threadId: string;
|
|
45
|
+
replyToId?: string | null;
|
|
46
|
+
publishedAt: number | null;
|
|
47
|
+
lastActivityAt: number | null;
|
|
48
|
+
status?: string;
|
|
49
|
+
}) {
|
|
50
|
+
sqlite
|
|
51
|
+
.prepare(
|
|
52
|
+
`INSERT INTO post (
|
|
53
|
+
id, site_id, format, status, visibility, thread_id, reply_to_id,
|
|
54
|
+
published_at, last_activity_at, created_at, updated_at
|
|
55
|
+
) VALUES (?, ?, 'note', ?, 'public', ?, ?, ?, ?, ?, ?)`,
|
|
56
|
+
)
|
|
57
|
+
.run(
|
|
58
|
+
row.id,
|
|
59
|
+
DEFAULT_TEST_SITE_ID,
|
|
60
|
+
row.status ?? "published",
|
|
61
|
+
row.threadId,
|
|
62
|
+
row.replyToId ?? null,
|
|
63
|
+
row.publishedAt,
|
|
64
|
+
row.lastActivityAt,
|
|
65
|
+
row.publishedAt ?? 0,
|
|
66
|
+
row.publishedAt ?? 0,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readPost(id: string): RootRow {
|
|
71
|
+
return sqlite
|
|
72
|
+
.prepare(
|
|
73
|
+
`SELECT quiet_reply, last_activity_at, thread_updated_at
|
|
74
|
+
FROM post WHERE id = ?`,
|
|
75
|
+
)
|
|
76
|
+
.get(id) as RootRow;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
sqlite = createTestDatabase().sqlite;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("recovers a quiet reply from the gap it left behind", () => {
|
|
84
|
+
// A reply sitting later than the root's recorded activity can only have
|
|
85
|
+
// gotten there by skipping the bump — the signature of a quiet reply.
|
|
86
|
+
insertLegacyPost({
|
|
87
|
+
id: "pst_root",
|
|
88
|
+
threadId: "pst_root",
|
|
89
|
+
publishedAt: 1000,
|
|
90
|
+
lastActivityAt: 1000,
|
|
91
|
+
});
|
|
92
|
+
insertLegacyPost({
|
|
93
|
+
id: "pst_quiet",
|
|
94
|
+
threadId: "pst_root",
|
|
95
|
+
replyToId: "pst_root",
|
|
96
|
+
publishedAt: 3000,
|
|
97
|
+
lastActivityAt: 3000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
runBackfill();
|
|
101
|
+
|
|
102
|
+
expect(readPost("pst_quiet").quiet_reply).toBe(1);
|
|
103
|
+
const root = readPost("pst_root");
|
|
104
|
+
expect(root.last_activity_at).toBe(1000);
|
|
105
|
+
expect(root.thread_updated_at).toBe(3000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves announced replies alone", () => {
|
|
109
|
+
insertLegacyPost({
|
|
110
|
+
id: "pst_root2",
|
|
111
|
+
threadId: "pst_root2",
|
|
112
|
+
publishedAt: 1000,
|
|
113
|
+
lastActivityAt: 2000,
|
|
114
|
+
});
|
|
115
|
+
insertLegacyPost({
|
|
116
|
+
id: "pst_reply2",
|
|
117
|
+
threadId: "pst_root2",
|
|
118
|
+
replyToId: "pst_root2",
|
|
119
|
+
publishedAt: 2000,
|
|
120
|
+
lastActivityAt: 2000,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
runBackfill();
|
|
124
|
+
|
|
125
|
+
expect(readPost("pst_reply2").quiet_reply).toBe(0);
|
|
126
|
+
const root = readPost("pst_root2");
|
|
127
|
+
expect(root.last_activity_at).toBe(2000);
|
|
128
|
+
expect(root.thread_updated_at).toBe(2000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("ignores drafts when computing both timestamps", () => {
|
|
132
|
+
insertLegacyPost({
|
|
133
|
+
id: "pst_root3",
|
|
134
|
+
threadId: "pst_root3",
|
|
135
|
+
publishedAt: 1000,
|
|
136
|
+
lastActivityAt: 1000,
|
|
137
|
+
});
|
|
138
|
+
insertLegacyPost({
|
|
139
|
+
id: "pst_draft3",
|
|
140
|
+
threadId: "pst_root3",
|
|
141
|
+
replyToId: "pst_root3",
|
|
142
|
+
publishedAt: null,
|
|
143
|
+
lastActivityAt: null,
|
|
144
|
+
status: "draft",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
runBackfill();
|
|
148
|
+
|
|
149
|
+
expect(readPost("pst_draft3").quiet_reply).toBe(0);
|
|
150
|
+
const root = readPost("pst_root3");
|
|
151
|
+
expect(root.last_activity_at).toBe(1000);
|
|
152
|
+
expect(root.thread_updated_at).toBe(1000);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps a fully unpublished thread's existing last_activity_at", () => {
|
|
156
|
+
insertLegacyPost({
|
|
157
|
+
id: "pst_root4",
|
|
158
|
+
threadId: "pst_root4",
|
|
159
|
+
publishedAt: null,
|
|
160
|
+
lastActivityAt: 500,
|
|
161
|
+
status: "draft",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
runBackfill();
|
|
165
|
+
|
|
166
|
+
const root = readPost("pst_root4");
|
|
167
|
+
expect(root.last_activity_at).toBe(500);
|
|
168
|
+
expect(root.thread_updated_at).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is idempotent", () => {
|
|
172
|
+
insertLegacyPost({
|
|
173
|
+
id: "pst_root5",
|
|
174
|
+
threadId: "pst_root5",
|
|
175
|
+
publishedAt: 1000,
|
|
176
|
+
lastActivityAt: 1000,
|
|
177
|
+
});
|
|
178
|
+
insertLegacyPost({
|
|
179
|
+
id: "pst_quiet5",
|
|
180
|
+
threadId: "pst_root5",
|
|
181
|
+
replyToId: "pst_root5",
|
|
182
|
+
publishedAt: 3000,
|
|
183
|
+
lastActivityAt: 3000,
|
|
184
|
+
});
|
|
185
|
+
insertLegacyPost({
|
|
186
|
+
id: "pst_loud5",
|
|
187
|
+
threadId: "pst_root5",
|
|
188
|
+
replyToId: "pst_root5",
|
|
189
|
+
publishedAt: 900,
|
|
190
|
+
lastActivityAt: 900,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
runBackfill();
|
|
194
|
+
const first = readPost("pst_root5");
|
|
195
|
+
runBackfill();
|
|
196
|
+
runBackfill();
|
|
197
|
+
const third = readPost("pst_root5");
|
|
198
|
+
|
|
199
|
+
expect(third).toEqual(first);
|
|
200
|
+
expect(first.last_activity_at).toBe(1000);
|
|
201
|
+
expect(first.thread_updated_at).toBe(3000);
|
|
202
|
+
expect(readPost("pst_quiet5").quiet_reply).toBe(1);
|
|
203
|
+
expect(readPost("pst_loud5").quiet_reply).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("scopes recovery to the reply's own thread", () => {
|
|
207
|
+
insertLegacyPost({
|
|
208
|
+
id: "pst_a",
|
|
209
|
+
threadId: "pst_a",
|
|
210
|
+
publishedAt: 5000,
|
|
211
|
+
lastActivityAt: 5000,
|
|
212
|
+
});
|
|
213
|
+
insertLegacyPost({
|
|
214
|
+
id: "pst_b",
|
|
215
|
+
threadId: "pst_b",
|
|
216
|
+
publishedAt: 1000,
|
|
217
|
+
lastActivityAt: 1000,
|
|
218
|
+
});
|
|
219
|
+
// Later than thread A's activity, but it belongs to thread B.
|
|
220
|
+
insertLegacyPost({
|
|
221
|
+
id: "pst_b_reply",
|
|
222
|
+
threadId: "pst_b",
|
|
223
|
+
replyToId: "pst_b",
|
|
224
|
+
publishedAt: 2000,
|
|
225
|
+
lastActivityAt: 2000,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
runBackfill();
|
|
229
|
+
|
|
230
|
+
expect(readPost("pst_b_reply").quiet_reply).toBe(1);
|
|
231
|
+
expect(readPost("pst_b").last_activity_at).toBe(1000);
|
|
232
|
+
expect(readPost("pst_a").last_activity_at).toBe(5000);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createTestDatabase,
|
|
5
|
+
DEFAULT_TEST_SITE_ID,
|
|
6
|
+
} from "../../__tests__/helpers/db.js";
|
|
7
|
+
import { posts, threadCollections } from "../schema.js";
|
|
8
|
+
import {
|
|
9
|
+
buildRootActivityExpr,
|
|
10
|
+
getRootActivityAt,
|
|
11
|
+
rootActivityColumns,
|
|
12
|
+
} from "../thread-activity.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The SQL assertions here look pedantic, but they guard a failure mode that
|
|
16
|
+
* produces no error and no obviously wrong output: Drizzle omits the table
|
|
17
|
+
* qualifier on interpolated columns in a single-table projection, so a
|
|
18
|
+
* correlated subquery silently binds to its own alias instead of the outer
|
|
19
|
+
* row. That is why the subquery flavor takes raw fragments.
|
|
20
|
+
*/
|
|
21
|
+
describe("thread activity definition", () => {
|
|
22
|
+
const normalize = (value: string) => value.replace(/\s+/g, " ");
|
|
23
|
+
|
|
24
|
+
it("prefers last activity, then publication, then the row's own write", () => {
|
|
25
|
+
expect(
|
|
26
|
+
getRootActivityAt({
|
|
27
|
+
lastActivityAt: 300,
|
|
28
|
+
publishedAt: 200,
|
|
29
|
+
updatedAt: 100,
|
|
30
|
+
}),
|
|
31
|
+
).toBe(300);
|
|
32
|
+
expect(
|
|
33
|
+
getRootActivityAt({
|
|
34
|
+
lastActivityAt: null,
|
|
35
|
+
publishedAt: 200,
|
|
36
|
+
updatedAt: 100,
|
|
37
|
+
}),
|
|
38
|
+
).toBe(200);
|
|
39
|
+
expect(
|
|
40
|
+
getRootActivityAt({
|
|
41
|
+
lastActivityAt: null,
|
|
42
|
+
publishedAt: null,
|
|
43
|
+
updatedAt: 100,
|
|
44
|
+
}),
|
|
45
|
+
).toBe(100);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("never falls back to a plain edit when real activity exists", () => {
|
|
49
|
+
// An edited root keeps its activity timestamp — the whole point.
|
|
50
|
+
expect(
|
|
51
|
+
getRootActivityAt({
|
|
52
|
+
lastActivityAt: 1000,
|
|
53
|
+
publishedAt: 1000,
|
|
54
|
+
updatedAt: 9999,
|
|
55
|
+
}),
|
|
56
|
+
).toBe(1000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("qualifies the subquery flavor against the root alias", () => {
|
|
60
|
+
const { db } = createTestDatabase();
|
|
61
|
+
const expr = sql<number>`(
|
|
62
|
+
SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
|
|
63
|
+
FROM post AS root
|
|
64
|
+
WHERE root.site_id = ${DEFAULT_TEST_SITE_ID}
|
|
65
|
+
AND root.id = ${posts.threadId}
|
|
66
|
+
)`.as("activity_at");
|
|
67
|
+
|
|
68
|
+
const generated = normalize(
|
|
69
|
+
(db as never as { select: (fields: unknown) => never })
|
|
70
|
+
.select({ threadId: posts.threadId, activityAt: expr })
|
|
71
|
+
// The join is what makes Drizzle qualify `posts.threadId` below.
|
|
72
|
+
.from(posts)
|
|
73
|
+
.innerJoin(
|
|
74
|
+
threadCollections,
|
|
75
|
+
and(
|
|
76
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
77
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.groupBy(posts.threadId)
|
|
81
|
+
.toSQL().sql,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Inner columns resolve against the subquery's own alias...
|
|
85
|
+
expect(generated).toContain(
|
|
86
|
+
"COALESCE( root.last_activity_at, root.published_at, root.updated_at )",
|
|
87
|
+
);
|
|
88
|
+
// ...and the correlation still points at the outer row, not at `root`.
|
|
89
|
+
expect(generated).toContain('root.id = "post"."thread_id"');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("uses the root's own columns when rows are already roots", () => {
|
|
93
|
+
const { db } = createTestDatabase();
|
|
94
|
+
const generated = normalize(
|
|
95
|
+
(db as never as { select: (fields: unknown) => never })
|
|
96
|
+
.select({
|
|
97
|
+
id: posts.id,
|
|
98
|
+
activityAt: buildRootActivityExpr({
|
|
99
|
+
lastActivityAt: posts.lastActivityAt,
|
|
100
|
+
publishedAt: posts.publishedAt,
|
|
101
|
+
updatedAt: posts.updatedAt,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
.from(posts)
|
|
105
|
+
.innerJoin(threadCollections, eq(threadCollections.threadId, posts.id))
|
|
106
|
+
.toSQL().sql,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(generated).toContain(
|
|
110
|
+
'COALESCE( "post"."last_activity_at", "post"."published_at", "post"."updated_at" )',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- Split "last announced" from "last changed" for Threads.
|
|
2
|
+
--
|
|
3
|
+
-- Until now a quiet reply was implemented by *skipping* the recalculation of
|
|
4
|
+
-- post.last_activity_at, and the quietness itself was never stored. That made
|
|
5
|
+
-- the flag non-durable: the next recalculation (deleting any post in the
|
|
6
|
+
-- Thread, changing a published_at, republishing) recomputed
|
|
7
|
+
-- MAX(published_at) over the whole Thread, absorbed the quiet reply, and
|
|
8
|
+
-- bumped the Thread on Latest anyway.
|
|
9
|
+
--
|
|
10
|
+
-- post.quiet_reply now persists the author's choice, so both timestamps are
|
|
11
|
+
-- pure functions of the rows:
|
|
12
|
+
-- last_activity_at = MAX(published_at) excluding quiet replies (Latest)
|
|
13
|
+
-- thread_updated_at = MAX(published_at) including quiet replies (archive)
|
|
14
|
+
--
|
|
15
|
+
-- Step 1 recovers the quiet decisions that are still visible in the data.
|
|
16
|
+
-- A published reply can only sit later than its root's recorded
|
|
17
|
+
-- last_activity_at if the recalculation was skipped for it — that is the
|
|
18
|
+
-- signature of a quiet reply. Decisions that were already absorbed by a later
|
|
19
|
+
-- recalculation are unrecoverable and stay false, which matches the ordering
|
|
20
|
+
-- those sites are already seeing.
|
|
21
|
+
--
|
|
22
|
+
-- Steps 2 and 3 then recompute both timestamps from scratch.
|
|
23
|
+
--
|
|
24
|
+
-- Idempotent: after step 3 a quiet reply is still later than the recomputed
|
|
25
|
+
-- last_activity_at (it is excluded from that MAX), so a rerun re-selects
|
|
26
|
+
-- exactly the same rows, and every non-quiet published reply is covered by
|
|
27
|
+
-- the MAX and can never be selected.
|
|
28
|
+
|
|
29
|
+
-- Step 1: recover still-detectable quiet replies.
|
|
30
|
+
UPDATE "post"
|
|
31
|
+
SET "quiet_reply" = TRUE
|
|
32
|
+
WHERE "post"."reply_to_id" IS NOT NULL
|
|
33
|
+
AND "post"."published_at" IS NOT NULL
|
|
34
|
+
AND EXISTS (
|
|
35
|
+
SELECT 1
|
|
36
|
+
FROM "post" AS root
|
|
37
|
+
WHERE root."site_id" = "post"."site_id"
|
|
38
|
+
AND root."id" = "post"."thread_id"
|
|
39
|
+
AND root."last_activity_at" IS NOT NULL
|
|
40
|
+
AND "post"."published_at" > root."last_activity_at"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
-- Step 2: thread_updated_at — newest published post in the Thread, quiet included.
|
|
44
|
+
UPDATE "post"
|
|
45
|
+
SET "thread_updated_at" = (
|
|
46
|
+
SELECT MAX(member."published_at")
|
|
47
|
+
FROM "post" AS member
|
|
48
|
+
WHERE member."site_id" = "post"."site_id"
|
|
49
|
+
AND member."thread_id" = "post"."id"
|
|
50
|
+
AND member."status" = 'published'
|
|
51
|
+
)
|
|
52
|
+
WHERE "post"."reply_to_id" IS NULL;
|
|
53
|
+
|
|
54
|
+
-- Step 3: last_activity_at — newest announced post in the Thread.
|
|
55
|
+
-- Threads with nothing published (drafts) keep whatever they had.
|
|
56
|
+
UPDATE "post"
|
|
57
|
+
SET "last_activity_at" = COALESCE(
|
|
58
|
+
(
|
|
59
|
+
SELECT MAX(member."published_at")
|
|
60
|
+
FROM "post" AS member
|
|
61
|
+
WHERE member."site_id" = "post"."site_id"
|
|
62
|
+
AND member."thread_id" = "post"."id"
|
|
63
|
+
AND member."status" = 'published'
|
|
64
|
+
AND member."quiet_reply" = FALSE
|
|
65
|
+
),
|
|
66
|
+
"post"."last_activity_at"
|
|
67
|
+
)
|
|
68
|
+
WHERE "post"."reply_to_id" IS NULL;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
ALTER TABLE `post` ADD `quiet_reply` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE `post` ADD `thread_updated_at` integer;--> statement-breakpoint
|
|
3
|
+
CREATE INDEX `idx_post_site_root_thread_updated` ON `post` (`site_id`,`thread_updated_at`,`id`) WHERE "post"."reply_to_id" IS NULL AND "post"."status" = 'published';
|