@meith/db 0.16.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.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- package/src/warning-repo.ts +328 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
|
|
4
|
+
import { ConfigurationError, env } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
import * as schema from './schema'
|
|
7
|
+
|
|
8
|
+
export type Database = PostgresJsDatabase<typeof schema>
|
|
9
|
+
|
|
10
|
+
interface DbGlobal {
|
|
11
|
+
__forumSql: ReturnType<typeof postgres> | undefined
|
|
12
|
+
__forumDb: Database | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const globalRef = globalThis as unknown as DbGlobal
|
|
16
|
+
|
|
17
|
+
function connectionOptions() {
|
|
18
|
+
return {
|
|
19
|
+
max: env.DATABASE_POOL_MAX,
|
|
20
|
+
prepare: false,
|
|
21
|
+
idle_timeout: 20,
|
|
22
|
+
max_lifetime: 60 * 30,
|
|
23
|
+
connect_timeout: 10,
|
|
24
|
+
onnotice: () => {},
|
|
25
|
+
} as const
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getDb(): Database {
|
|
29
|
+
if (env.DATA_SOURCE !== 'postgres') {
|
|
30
|
+
throw new ConfigurationError(
|
|
31
|
+
'getDb() called while DATA_SOURCE is "' +
|
|
32
|
+
env.DATA_SOURCE +
|
|
33
|
+
'". A repository is talking to Postgres directly instead of going ' +
|
|
34
|
+
'through its driver. Fix the call site, do not set DATABASE_URL.',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (globalRef.__forumDb) return globalRef.__forumDb
|
|
39
|
+
|
|
40
|
+
const url = env.DATABASE_URL
|
|
41
|
+
if (!url) {
|
|
42
|
+
throw new ConfigurationError('DATABASE_URL is required when DATA_SOURCE is "postgres".')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sql = postgres(url, connectionOptions())
|
|
46
|
+
const db = drizzle(sql, { schema, casing: 'snake_case' })
|
|
47
|
+
restoreDateSerialisers(sql)
|
|
48
|
+
|
|
49
|
+
globalRef.__forumSql = sql
|
|
50
|
+
globalRef.__forumDb = db
|
|
51
|
+
|
|
52
|
+
return db
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DATE_OIDS = ['1184', '1114', '1082', '1083', '1182', '1185', '1115', '1231'] as const
|
|
56
|
+
|
|
57
|
+
function restoreDateSerialisers(client: ReturnType<typeof postgres>): void {
|
|
58
|
+
const serialisers = (
|
|
59
|
+
client as unknown as { options: { serializers: Record<string, (v: unknown) => unknown> } }
|
|
60
|
+
).options.serializers
|
|
61
|
+
for (const oid of DATE_OIDS) {
|
|
62
|
+
serialisers[oid] = (value: unknown) => (value instanceof Date ? value.toISOString() : value)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createIsolatedDb(url: string, poolMax = 1) {
|
|
67
|
+
const sql = postgres(url, { ...connectionOptions(), max: poolMax })
|
|
68
|
+
const db = drizzle(sql, { schema, casing: 'snake_case' })
|
|
69
|
+
restoreDateSerialisers(sql)
|
|
70
|
+
return {
|
|
71
|
+
db,
|
|
72
|
+
sql,
|
|
73
|
+
close: () => sql.end({ timeout: 5 }),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function closeDb(): Promise<void> {
|
|
78
|
+
const sql = globalRef.__forumSql
|
|
79
|
+
if (!sql) return
|
|
80
|
+
await sql.end({ timeout: 5 })
|
|
81
|
+
globalRef.__forumSql = undefined
|
|
82
|
+
globalRef.__forumDb = undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { schema }
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
import type { WordFilterRule } from '@meith/markdown'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
|
|
10
|
+
export interface WordFilterRow extends WordFilterRule {
|
|
11
|
+
readonly id: number
|
|
12
|
+
readonly enabled: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SmileyRow {
|
|
16
|
+
readonly id: number
|
|
17
|
+
readonly code: string
|
|
18
|
+
readonly src: string
|
|
19
|
+
readonly alt: string | null
|
|
20
|
+
readonly enabled: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DirectiveRow {
|
|
24
|
+
readonly id: number
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly block: boolean
|
|
27
|
+
readonly description: string | null
|
|
28
|
+
readonly enabled: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ThreadPrefixRow {
|
|
32
|
+
readonly id: number
|
|
33
|
+
readonly label: string
|
|
34
|
+
readonly token: string | null
|
|
35
|
+
readonly displayOrder: number
|
|
36
|
+
readonly forumPathPrefix: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class PostgresContentAdminRepository {
|
|
40
|
+
constructor(private readonly db: Database) {}
|
|
41
|
+
|
|
42
|
+
async listWordFilters(): Promise<readonly WordFilterRow[]> {
|
|
43
|
+
const rows = resultRows(
|
|
44
|
+
await this.db.execute(sql`
|
|
45
|
+
select id, pattern, replacement, whole_word, enabled
|
|
46
|
+
from word_filters order by id
|
|
47
|
+
`),
|
|
48
|
+
) as Array<Record<string, unknown>>
|
|
49
|
+
|
|
50
|
+
return rows.map(toWordFilter)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async activeWordFilters(): Promise<readonly WordFilterRule[]> {
|
|
54
|
+
const rows = resultRows(
|
|
55
|
+
await this.db.execute(sql`
|
|
56
|
+
select pattern, replacement, whole_word from word_filters
|
|
57
|
+
where enabled = true and pattern <> ''
|
|
58
|
+
order by id
|
|
59
|
+
`),
|
|
60
|
+
) as Array<Record<string, unknown>>
|
|
61
|
+
|
|
62
|
+
return rows.map((row) => ({
|
|
63
|
+
pattern: String(row.pattern),
|
|
64
|
+
replacement: String(row.replacement),
|
|
65
|
+
wholeWord: row.whole_word === true,
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async createWordFilter(input: WordFilterRule): Promise<number> {
|
|
70
|
+
const pattern = input.pattern.trim()
|
|
71
|
+
if (pattern === '') throw new ValidationError(msg('error.db.filter-needs-something-match'))
|
|
72
|
+
|
|
73
|
+
const rows = resultRows(
|
|
74
|
+
await this.db.execute(sql`
|
|
75
|
+
insert into word_filters (pattern, replacement, whole_word)
|
|
76
|
+
values (${pattern}, ${input.replacement}, ${input.wholeWord})
|
|
77
|
+
returning id
|
|
78
|
+
`),
|
|
79
|
+
) as Array<{ id: number }>
|
|
80
|
+
|
|
81
|
+
return Number(rows[0]?.id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async updateWordFilter(
|
|
85
|
+
id: number,
|
|
86
|
+
input: WordFilterRule & { readonly enabled: boolean },
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const pattern = input.pattern.trim()
|
|
89
|
+
if (pattern === '') throw new ValidationError(msg('error.db.filter-needs-something-match'))
|
|
90
|
+
|
|
91
|
+
const rows = resultRows(
|
|
92
|
+
await this.db.execute(sql`
|
|
93
|
+
update word_filters
|
|
94
|
+
set pattern = ${pattern}, replacement = ${input.replacement},
|
|
95
|
+
whole_word = ${input.wholeWord}, enabled = ${input.enabled}
|
|
96
|
+
where id = ${id}
|
|
97
|
+
returning id
|
|
98
|
+
`),
|
|
99
|
+
) as Array<{ id: number }>
|
|
100
|
+
|
|
101
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-filter'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async deleteWordFilter(id: number): Promise<void> {
|
|
105
|
+
await this.db.execute(sql`delete from word_filters where id = ${id}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listPrefixes(): Promise<readonly ThreadPrefixRow[]> {
|
|
109
|
+
const rows = resultRows(
|
|
110
|
+
await this.db.execute(sql`
|
|
111
|
+
select id, label, token, display_order, forum_path_prefix
|
|
112
|
+
from thread_prefixes order by display_order, id
|
|
113
|
+
`),
|
|
114
|
+
) as Array<Record<string, unknown>>
|
|
115
|
+
|
|
116
|
+
return rows.map((row) => ({
|
|
117
|
+
id: Number(row.id),
|
|
118
|
+
label: String(row.label),
|
|
119
|
+
token: row.token === null ? null : String(row.token),
|
|
120
|
+
displayOrder: Number(row.display_order),
|
|
121
|
+
forumPathPrefix: row.forum_path_prefix === null ? null : String(row.forum_path_prefix),
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async createPrefix(input: {
|
|
126
|
+
readonly label: string
|
|
127
|
+
readonly token: string | null
|
|
128
|
+
readonly displayOrder: number
|
|
129
|
+
readonly forumPathPrefix: string | null
|
|
130
|
+
}): Promise<number> {
|
|
131
|
+
if (input.label.trim() === '') throw new ValidationError(msg('error.db.prefix-needs-label'))
|
|
132
|
+
|
|
133
|
+
const rows = resultRows(
|
|
134
|
+
await this.db.execute(sql`
|
|
135
|
+
insert into thread_prefixes (label, token, display_order, forum_path_prefix)
|
|
136
|
+
values (${input.label.trim()}, ${input.token}, ${input.displayOrder},
|
|
137
|
+
${input.forumPathPrefix})
|
|
138
|
+
returning id
|
|
139
|
+
`),
|
|
140
|
+
) as Array<{ id: number }>
|
|
141
|
+
|
|
142
|
+
return Number(rows[0]?.id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async deletePrefix(id: number): Promise<void> {
|
|
146
|
+
await this.db.execute(sql`delete from thread_prefixes where id = ${id}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async vocabularyRevision(): Promise<number> {
|
|
150
|
+
const rows = resultRows(
|
|
151
|
+
await this.db.execute(sql`
|
|
152
|
+
select version from cache_versions where key = 'markdown_vocabulary'
|
|
153
|
+
`),
|
|
154
|
+
) as Array<{ version: number }>
|
|
155
|
+
|
|
156
|
+
return Number(rows[0]?.version ?? 0)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async bumpVocabulary(): Promise<void> {
|
|
160
|
+
await this.db.execute(sql`
|
|
161
|
+
insert into cache_versions (key, version) values ('markdown_vocabulary', 1)
|
|
162
|
+
on conflict (key) do update
|
|
163
|
+
set version = cache_versions.version + 1, bumped_at = now()
|
|
164
|
+
`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async listSmilies(): Promise<readonly SmileyRow[]> {
|
|
168
|
+
const rows = resultRows(
|
|
169
|
+
await this.db.execute(sql`
|
|
170
|
+
select id, code, src, alt, enabled from smilies order by code
|
|
171
|
+
`),
|
|
172
|
+
) as Array<Record<string, unknown>>
|
|
173
|
+
|
|
174
|
+
return rows.map((row) => ({
|
|
175
|
+
id: Number(row.id),
|
|
176
|
+
code: String(row.code),
|
|
177
|
+
src: String(row.src),
|
|
178
|
+
alt: row.alt === null ? null : String(row.alt),
|
|
179
|
+
enabled: row.enabled === true,
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async createSmiley(input: {
|
|
184
|
+
readonly code: string
|
|
185
|
+
readonly src: string
|
|
186
|
+
readonly alt: string | null
|
|
187
|
+
}): Promise<number> {
|
|
188
|
+
const rows = resultRows(
|
|
189
|
+
await this.db.execute(sql`
|
|
190
|
+
insert into smilies (code, src, alt)
|
|
191
|
+
values (${input.code}, ${input.src}, ${input.alt})
|
|
192
|
+
returning id
|
|
193
|
+
`),
|
|
194
|
+
) as Array<{ id: number }>
|
|
195
|
+
|
|
196
|
+
await this.bumpVocabulary()
|
|
197
|
+
return Number(rows[0]?.id)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async updateSmiley(
|
|
201
|
+
id: number,
|
|
202
|
+
input: {
|
|
203
|
+
readonly code: string
|
|
204
|
+
readonly src: string
|
|
205
|
+
readonly alt: string | null
|
|
206
|
+
readonly enabled: boolean
|
|
207
|
+
},
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const rows = resultRows(
|
|
210
|
+
await this.db.execute(sql`
|
|
211
|
+
update smilies
|
|
212
|
+
set code = ${input.code}, src = ${input.src}, alt = ${input.alt},
|
|
213
|
+
enabled = ${input.enabled}
|
|
214
|
+
where id = ${id}
|
|
215
|
+
returning id
|
|
216
|
+
`),
|
|
217
|
+
) as Array<{ id: number }>
|
|
218
|
+
|
|
219
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-smiley'))
|
|
220
|
+
await this.bumpVocabulary()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async deleteSmiley(id: number): Promise<void> {
|
|
224
|
+
await this.db.execute(sql`delete from smilies where id = ${id}`)
|
|
225
|
+
await this.bumpVocabulary()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async listDirectives(): Promise<readonly DirectiveRow[]> {
|
|
229
|
+
const rows = resultRows(
|
|
230
|
+
await this.db.execute(sql`
|
|
231
|
+
select id, name, block, description, enabled from custom_directives order by name
|
|
232
|
+
`),
|
|
233
|
+
) as Array<Record<string, unknown>>
|
|
234
|
+
|
|
235
|
+
return rows.map((row) => ({
|
|
236
|
+
id: Number(row.id),
|
|
237
|
+
name: String(row.name),
|
|
238
|
+
block: row.block === true,
|
|
239
|
+
description: row.description === null ? null : String(row.description),
|
|
240
|
+
enabled: row.enabled === true,
|
|
241
|
+
}))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async createDirective(input: {
|
|
245
|
+
readonly name: string
|
|
246
|
+
readonly block: boolean
|
|
247
|
+
readonly description: string | null
|
|
248
|
+
}): Promise<number> {
|
|
249
|
+
const rows = resultRows(
|
|
250
|
+
await this.db.execute(sql`
|
|
251
|
+
insert into custom_directives (name, block, description)
|
|
252
|
+
values (${input.name}, ${input.block}, ${input.description})
|
|
253
|
+
returning id
|
|
254
|
+
`),
|
|
255
|
+
) as Array<{ id: number }>
|
|
256
|
+
|
|
257
|
+
await this.bumpVocabulary()
|
|
258
|
+
return Number(rows[0]?.id)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async updateDirective(
|
|
262
|
+
id: number,
|
|
263
|
+
input: {
|
|
264
|
+
readonly name: string
|
|
265
|
+
readonly block: boolean
|
|
266
|
+
readonly description: string | null
|
|
267
|
+
readonly enabled: boolean
|
|
268
|
+
},
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const rows = resultRows(
|
|
271
|
+
await this.db.execute(sql`
|
|
272
|
+
update custom_directives
|
|
273
|
+
set name = ${input.name}, block = ${input.block},
|
|
274
|
+
description = ${input.description}, enabled = ${input.enabled}
|
|
275
|
+
where id = ${id}
|
|
276
|
+
returning id
|
|
277
|
+
`),
|
|
278
|
+
) as Array<{ id: number }>
|
|
279
|
+
|
|
280
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-directive'))
|
|
281
|
+
await this.bumpVocabulary()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async deleteDirective(id: number): Promise<void> {
|
|
285
|
+
await this.db.execute(sql`delete from custom_directives where id = ${id}`)
|
|
286
|
+
await this.bumpVocabulary()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function toWordFilter(row: Record<string, unknown>): WordFilterRow {
|
|
291
|
+
return {
|
|
292
|
+
id: Number(row.id),
|
|
293
|
+
pattern: String(row.pattern),
|
|
294
|
+
replacement: String(row.replacement),
|
|
295
|
+
wholeWord: row.whole_word === true,
|
|
296
|
+
enabled: row.enabled === true,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { SQLWrapper } from 'drizzle-orm'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
import type { Database } from './client'
|
|
5
|
+
import { resultRows } from './result-rows'
|
|
6
|
+
import { applyAncestorVisibilityChange } from './visibility-counters'
|
|
7
|
+
|
|
8
|
+
export interface CreatedContent {
|
|
9
|
+
readonly postId: number
|
|
10
|
+
readonly threadId: number
|
|
11
|
+
readonly forumId: number
|
|
12
|
+
readonly authorId: number | null
|
|
13
|
+
readonly authorUsername: string
|
|
14
|
+
readonly threadTitle: string
|
|
15
|
+
readonly createdAt: Date
|
|
16
|
+
readonly isNewThread: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CounterTransaction {
|
|
20
|
+
execute(query: SQLWrapper): Promise<unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function applyCreatedContentCounters(
|
|
24
|
+
tx: CounterTransaction,
|
|
25
|
+
content: CreatedContent,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const newer = sql`last_post_at is null or last_post_id is null
|
|
28
|
+
or last_post_at < ${content.createdAt}
|
|
29
|
+
or (last_post_at = ${content.createdAt} and last_post_id < ${content.postId})`
|
|
30
|
+
const threadNewer = content.isNewThread ? sql`true` : newer
|
|
31
|
+
|
|
32
|
+
await tx.execute(sql`
|
|
33
|
+
update forums
|
|
34
|
+
set post_count = post_count + 1,
|
|
35
|
+
thread_count = thread_count + ${content.isNewThread ? 1 : 0},
|
|
36
|
+
last_post_id = case when ${newer} then ${content.postId} else last_post_id end,
|
|
37
|
+
last_post_thread_id = case when ${newer} then ${content.threadId} else last_post_thread_id end,
|
|
38
|
+
last_post_thread_title = case when ${newer} then ${content.threadTitle} else last_post_thread_title end,
|
|
39
|
+
last_post_user_id = case when ${newer} then ${content.authorId} else last_post_user_id end,
|
|
40
|
+
last_post_username = case when ${newer} then ${content.authorUsername} else last_post_username end,
|
|
41
|
+
last_post_at = case when ${newer} then ${content.createdAt} else last_post_at end,
|
|
42
|
+
updated_at = now()
|
|
43
|
+
where id = ${content.forumId}
|
|
44
|
+
`)
|
|
45
|
+
|
|
46
|
+
await tx.execute(sql`
|
|
47
|
+
update threads
|
|
48
|
+
set first_post_id = case when ${content.isNewThread} then ${content.postId} else first_post_id end,
|
|
49
|
+
reply_count = reply_count + ${content.isNewThread ? 0 : 1},
|
|
50
|
+
last_post_id = case when ${threadNewer} then ${content.postId} else last_post_id end,
|
|
51
|
+
last_post_user_id = case when ${threadNewer} then ${content.authorId} else last_post_user_id end,
|
|
52
|
+
last_post_username = case when ${threadNewer} then ${content.authorUsername} else last_post_username end,
|
|
53
|
+
last_post_at = case when ${threadNewer} then ${content.createdAt} else last_post_at end,
|
|
54
|
+
updated_at = now()
|
|
55
|
+
where id = ${content.threadId}
|
|
56
|
+
`)
|
|
57
|
+
|
|
58
|
+
if (content.authorId !== null) {
|
|
59
|
+
await tx.execute(sql`
|
|
60
|
+
update users
|
|
61
|
+
set post_count = post_count + 1,
|
|
62
|
+
thread_count = thread_count + ${content.isNewThread ? 1 : 0},
|
|
63
|
+
updated_at = now()
|
|
64
|
+
where id = ${content.authorId}
|
|
65
|
+
`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await tx.execute(sql`
|
|
69
|
+
insert into outbox (topic, payload)
|
|
70
|
+
values (
|
|
71
|
+
'post.created',
|
|
72
|
+
${JSON.stringify({
|
|
73
|
+
postId: content.postId,
|
|
74
|
+
threadId: content.threadId,
|
|
75
|
+
forumId: content.forumId,
|
|
76
|
+
authorId: content.authorId,
|
|
77
|
+
})}::jsonb
|
|
78
|
+
)
|
|
79
|
+
`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function rollUpAncestorCounters(db: Database, postId: number): Promise<boolean> {
|
|
83
|
+
return db.transaction(async (tx) => {
|
|
84
|
+
const claimed = await tx.execute(sql`
|
|
85
|
+
insert into content_counter_rollups (post_id)
|
|
86
|
+
select p.id from posts p
|
|
87
|
+
where p.id = ${postId} and p.visibility = 'visible'
|
|
88
|
+
on conflict (post_id) do nothing
|
|
89
|
+
returning post_id
|
|
90
|
+
`)
|
|
91
|
+
if (resultRows(claimed).length === 0) return false
|
|
92
|
+
|
|
93
|
+
const found = await tx.execute(sql`
|
|
94
|
+
select p.thread_id, p.forum_id, p.author_user_id, p.author_username,
|
|
95
|
+
p.created_at, p.is_first_post, t.title as thread_title
|
|
96
|
+
from posts p
|
|
97
|
+
join threads t on t.id = p.thread_id
|
|
98
|
+
where p.id = ${postId}
|
|
99
|
+
`)
|
|
100
|
+
const post = resultRows(found)[0] as
|
|
101
|
+
| {
|
|
102
|
+
thread_id: number
|
|
103
|
+
forum_id: number
|
|
104
|
+
author_user_id: number | null
|
|
105
|
+
author_username: string
|
|
106
|
+
created_at: Date
|
|
107
|
+
is_first_post: boolean
|
|
108
|
+
thread_title: string
|
|
109
|
+
}
|
|
110
|
+
| undefined
|
|
111
|
+
if (!post) return false
|
|
112
|
+
|
|
113
|
+
const ancestorNewer = sql`f.last_post_at is null or f.last_post_id is null
|
|
114
|
+
or f.last_post_at < ${post.created_at}
|
|
115
|
+
or (f.last_post_at = ${post.created_at} and f.last_post_id < ${postId})`
|
|
116
|
+
|
|
117
|
+
await tx.execute(sql`
|
|
118
|
+
update forums f
|
|
119
|
+
set post_count = f.post_count + 1,
|
|
120
|
+
thread_count = f.thread_count + ${post.is_first_post ? 1 : 0},
|
|
121
|
+
last_post_id = case when ${ancestorNewer} then ${postId} else f.last_post_id end,
|
|
122
|
+
last_post_thread_id = case when ${ancestorNewer} then ${post.thread_id} else f.last_post_thread_id end,
|
|
123
|
+
last_post_thread_title = case when ${ancestorNewer} then ${post.thread_title} else f.last_post_thread_title end,
|
|
124
|
+
last_post_user_id = case when ${ancestorNewer} then ${post.author_user_id} else f.last_post_user_id end,
|
|
125
|
+
last_post_username = case when ${ancestorNewer} then ${post.author_username} else f.last_post_username end,
|
|
126
|
+
last_post_at = case when ${ancestorNewer} then ${post.created_at} else f.last_post_at end,
|
|
127
|
+
updated_at = now()
|
|
128
|
+
from forums child
|
|
129
|
+
where child.id = ${post.forum_id}
|
|
130
|
+
and f.id <> child.id
|
|
131
|
+
and child.path like f.path || '.%'
|
|
132
|
+
`)
|
|
133
|
+
|
|
134
|
+
return true
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class PostgresContentCounterRepository {
|
|
139
|
+
constructor(private readonly db: Database) {}
|
|
140
|
+
|
|
141
|
+
async recordCreated(content: CreatedContent): Promise<void> {
|
|
142
|
+
await this.db.transaction((tx) => applyCreatedContentCounters(tx, content))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async rollUpAncestors(postId: number): Promise<boolean> {
|
|
146
|
+
return rollUpAncestorCounters(this.db, postId)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async applyVisibilityChange(postId: number): Promise<boolean> {
|
|
150
|
+
return applyAncestorVisibilityChange(this.db, postId)
|
|
151
|
+
}
|
|
152
|
+
}
|