@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/task-repo.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { TaskRepository } from '@meith/tasks'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { taskLog, tasks } from './schema'
|
|
8
|
+
|
|
9
|
+
const NEVER_RUN = new Date(0)
|
|
10
|
+
|
|
11
|
+
export class PostgresTaskRepository implements TaskRepository {
|
|
12
|
+
constructor(private readonly db: Database) {}
|
|
13
|
+
|
|
14
|
+
async ensureRegistered(
|
|
15
|
+
definitions: readonly { id: string; intervalSeconds: number }[],
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
if (definitions.length === 0) return
|
|
18
|
+
|
|
19
|
+
await this.db
|
|
20
|
+
.insert(tasks)
|
|
21
|
+
.values(
|
|
22
|
+
definitions.map((d) => ({
|
|
23
|
+
key: d.id,
|
|
24
|
+
intervalSeconds: d.intervalSeconds,
|
|
25
|
+
nextRunAt: NEVER_RUN,
|
|
26
|
+
})),
|
|
27
|
+
)
|
|
28
|
+
.onConflictDoUpdate({
|
|
29
|
+
target: tasks.key,
|
|
30
|
+
set: {
|
|
31
|
+
intervalSeconds: sql`excluded.interval_seconds`,
|
|
32
|
+
nextRunAt: sql`
|
|
33
|
+
case when ${tasks.intervalSeconds} is distinct from excluded.interval_seconds
|
|
34
|
+
then coalesce(${tasks.lastRunAt}, now())
|
|
35
|
+
+ make_interval(secs => excluded.interval_seconds)
|
|
36
|
+
else ${tasks.nextRunAt}
|
|
37
|
+
end
|
|
38
|
+
`,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async claim(input: {
|
|
44
|
+
taskId: string
|
|
45
|
+
now: Date
|
|
46
|
+
staleBefore: Date
|
|
47
|
+
}): Promise<{ previousLastRunAt: Date | null } | null> {
|
|
48
|
+
const leaseMs = input.now.getTime() - input.staleBefore.getTime()
|
|
49
|
+
const lockedUntil = new Date(input.now.getTime() + leaseMs)
|
|
50
|
+
|
|
51
|
+
const result = await this.db.execute(sql`
|
|
52
|
+
with previous as (
|
|
53
|
+
select key, last_run_at from tasks where key = ${input.taskId}
|
|
54
|
+
)
|
|
55
|
+
update tasks as t
|
|
56
|
+
set locked_until = ${lockedUntil},
|
|
57
|
+
last_run_at = ${input.now}
|
|
58
|
+
from previous p
|
|
59
|
+
where t.key = p.key
|
|
60
|
+
and t.enabled
|
|
61
|
+
and (t.locked_until is null or t.locked_until <= ${input.now})
|
|
62
|
+
and t.next_run_at <= ${input.now}
|
|
63
|
+
returning p.last_run_at as previous_last_run_at
|
|
64
|
+
`)
|
|
65
|
+
|
|
66
|
+
const row = resultRows<{ previous_last_run_at: Date | string | null }>(result)[0]
|
|
67
|
+
if (!row) return null
|
|
68
|
+
|
|
69
|
+
const previous = row.previous_last_run_at
|
|
70
|
+
return {
|
|
71
|
+
previousLastRunAt:
|
|
72
|
+
previous === null ? null : previous instanceof Date ? previous : new Date(previous),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async release(input: {
|
|
77
|
+
taskId: string
|
|
78
|
+
finishedAt: Date
|
|
79
|
+
success: boolean
|
|
80
|
+
detail?: Record<string, unknown>
|
|
81
|
+
error?: string
|
|
82
|
+
}): Promise<void> {
|
|
83
|
+
await this.db.transaction(async (tx) => {
|
|
84
|
+
await tx.execute(sql`
|
|
85
|
+
update tasks
|
|
86
|
+
set locked_until = null,
|
|
87
|
+
-- Explicit cast: an untyped bind parameter leaves Postgres
|
|
88
|
+
-- unable to resolve timestamp-plus-interval at parse time.
|
|
89
|
+
next_run_at = ${input.finishedAt}::timestamptz
|
|
90
|
+
+ make_interval(secs => interval_seconds),
|
|
91
|
+
consecutive_failures = ${input.success ? sql`0` : sql`consecutive_failures + 1`}
|
|
92
|
+
where key = ${input.taskId}
|
|
93
|
+
`)
|
|
94
|
+
|
|
95
|
+
await tx.insert(taskLog).values({
|
|
96
|
+
taskKey: input.taskId,
|
|
97
|
+
succeeded: input.success,
|
|
98
|
+
detail: input.detail === undefined ? null : JSON.stringify(input.detail),
|
|
99
|
+
error: input.error ?? null,
|
|
100
|
+
ranAt: input.finishedAt,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { resultRows } from './result-rows'
|
|
8
|
+
|
|
9
|
+
export interface ThemeRecord {
|
|
10
|
+
readonly key: string
|
|
11
|
+
readonly title: string
|
|
12
|
+
readonly tokenOverrides: unknown
|
|
13
|
+
readonly customCss: string | null
|
|
14
|
+
readonly enabled: boolean
|
|
15
|
+
readonly isDefault: boolean
|
|
16
|
+
readonly updatedAt: Date | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThemeExport {
|
|
20
|
+
readonly version: 2
|
|
21
|
+
readonly key: string
|
|
22
|
+
readonly tokenOverrides: unknown
|
|
23
|
+
readonly customCss: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PostgresThemeAdminRepository {
|
|
27
|
+
constructor(private readonly db: Database) {}
|
|
28
|
+
|
|
29
|
+
async list(): Promise<readonly ThemeRecord[]> {
|
|
30
|
+
const rows = resultRows(
|
|
31
|
+
await this.db.execute(sql`
|
|
32
|
+
select key, title, token_overrides, custom_css, enabled, is_default, updated_at
|
|
33
|
+
from themes order by key
|
|
34
|
+
`),
|
|
35
|
+
) as Array<Record<string, unknown>>
|
|
36
|
+
|
|
37
|
+
return rows.map(toRecord)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async read(key: string): Promise<ThemeRecord | null> {
|
|
41
|
+
const rows = resultRows(
|
|
42
|
+
await this.db.execute(sql`
|
|
43
|
+
select key, title, token_overrides, custom_css, enabled, is_default, updated_at
|
|
44
|
+
from themes where key = ${key}
|
|
45
|
+
`),
|
|
46
|
+
) as Array<Record<string, unknown>>
|
|
47
|
+
|
|
48
|
+
const row = rows[0]
|
|
49
|
+
return row === undefined ? null : toRecord(row)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async save(input: {
|
|
53
|
+
readonly key: string
|
|
54
|
+
readonly title: string
|
|
55
|
+
readonly tokenOverrides: unknown
|
|
56
|
+
readonly customCss: string | null
|
|
57
|
+
}): Promise<void> {
|
|
58
|
+
if (input.key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
|
|
59
|
+
|
|
60
|
+
await this.db.execute(sql`
|
|
61
|
+
insert into themes (key, title, token_overrides, custom_css, updated_at)
|
|
62
|
+
values (${input.key}, ${input.title},
|
|
63
|
+
${JSON.stringify(input.tokenOverrides ?? {})}::jsonb, ${input.customCss}, now())
|
|
64
|
+
on conflict (key) do update
|
|
65
|
+
set title = excluded.title,
|
|
66
|
+
token_overrides = excluded.token_overrides,
|
|
67
|
+
custom_css = excluded.custom_css,
|
|
68
|
+
updated_at = now()
|
|
69
|
+
`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async setEnabled(key: string, enabled: boolean, title: string): Promise<void> {
|
|
73
|
+
if (key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
|
|
74
|
+
|
|
75
|
+
await this.db.execute(sql`
|
|
76
|
+
insert into themes (key, title, enabled, updated_at)
|
|
77
|
+
values (${key}, ${title}, ${enabled}, now())
|
|
78
|
+
on conflict (key) do update set enabled = excluded.enabled, updated_at = now()
|
|
79
|
+
`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async setDefault(key: string, title: string): Promise<void> {
|
|
83
|
+
if (key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
|
|
84
|
+
|
|
85
|
+
await this.db.transaction(async (tx) => {
|
|
86
|
+
await tx.execute(sql`update themes set is_default = false where is_default`)
|
|
87
|
+
await tx.execute(sql`
|
|
88
|
+
insert into themes (key, title, is_default, enabled, updated_at)
|
|
89
|
+
values (${key}, ${title}, true, true, now())
|
|
90
|
+
on conflict (key) do update
|
|
91
|
+
set is_default = true, enabled = true, updated_at = now()
|
|
92
|
+
`)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async reset(key: string): Promise<void> {
|
|
97
|
+
await this.db.execute(sql`
|
|
98
|
+
update themes set token_overrides = '{}'::jsonb, custom_css = null, updated_at = now()
|
|
99
|
+
where key = ${key}
|
|
100
|
+
`)
|
|
101
|
+
await this.db.execute(sql`
|
|
102
|
+
delete from themes where key = ${key} and enabled and not is_default
|
|
103
|
+
`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async exportTheme(key: string): Promise<ThemeExport> {
|
|
107
|
+
const record = await this.read(key)
|
|
108
|
+
return {
|
|
109
|
+
version: 2,
|
|
110
|
+
key,
|
|
111
|
+
tokenOverrides: record?.tokenOverrides ?? {},
|
|
112
|
+
customCss: record?.customCss ?? null,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function parseThemeExport(raw: string): {
|
|
118
|
+
readonly tokenOverrides: Readonly<Record<string, unknown>>
|
|
119
|
+
readonly customCss: string | null
|
|
120
|
+
} {
|
|
121
|
+
let parsed: unknown
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(raw)
|
|
124
|
+
} catch {
|
|
125
|
+
throw new ValidationError(msg('error.db.valid-json'))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
129
|
+
throw new ValidationError(msg('error.db.theme-file-json-object'))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const document = parsed as Record<string, unknown>
|
|
133
|
+
if (document.version !== 1 && document.version !== 2) {
|
|
134
|
+
throw new ValidationError(msg('error.db.theme-file-written-by-different'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tokens = document.tokenOverrides
|
|
138
|
+
if (typeof tokens !== 'object' || tokens === null || Array.isArray(tokens)) {
|
|
139
|
+
throw new ValidationError(msg('error.db.theme-file-needs-tokenoverrides-object'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const css = document.customCss
|
|
143
|
+
if (css !== null && css !== undefined && typeof css !== 'string') {
|
|
144
|
+
throw new ValidationError(msg('error.db.customcss-must-text-null'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
tokenOverrides: tokens as Record<string, unknown>,
|
|
149
|
+
customCss: typeof css === 'string' ? css : null,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toRecord(row: Record<string, unknown>): ThemeRecord {
|
|
154
|
+
const overrides = row.token_overrides
|
|
155
|
+
return {
|
|
156
|
+
key: String(row.key),
|
|
157
|
+
title: String(row.title),
|
|
158
|
+
tokenOverrides:
|
|
159
|
+
typeof overrides === 'object' && overrides !== null && !Array.isArray(overrides)
|
|
160
|
+
? overrides
|
|
161
|
+
: {},
|
|
162
|
+
customCss: row.custom_css === null ? null : String(row.custom_css),
|
|
163
|
+
enabled: row.enabled !== false,
|
|
164
|
+
isDefault: row.is_default === true,
|
|
165
|
+
updatedAt:
|
|
166
|
+
row.updated_at === null || row.updated_at === undefined
|
|
167
|
+
? null
|
|
168
|
+
: row.updated_at instanceof Date
|
|
169
|
+
? row.updated_at
|
|
170
|
+
: new Date(String(row.updated_at)),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { themes } from './schema'
|
|
5
|
+
|
|
6
|
+
export interface ThemeRuntimeRecord {
|
|
7
|
+
readonly tokenOverrides: unknown
|
|
8
|
+
readonly customCss: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ThemeRuntimeState extends ThemeRuntimeRecord {
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly enabled: boolean
|
|
14
|
+
readonly isDefault: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class PostgresThemeRepository {
|
|
18
|
+
constructor(private readonly db: Database) {}
|
|
19
|
+
|
|
20
|
+
async findRuntimeByKey(key: string): Promise<ThemeRuntimeRecord | null> {
|
|
21
|
+
const rows = await this.db
|
|
22
|
+
.select({ tokenOverrides: themes.tokenOverrides, customCss: themes.customCss })
|
|
23
|
+
.from(themes)
|
|
24
|
+
.where(eq(themes.key, key))
|
|
25
|
+
.limit(1)
|
|
26
|
+
|
|
27
|
+
return rows[0] ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async listRuntime(): Promise<readonly ThemeRuntimeState[]> {
|
|
31
|
+
const rows = await this.db
|
|
32
|
+
.select({
|
|
33
|
+
key: themes.key,
|
|
34
|
+
tokenOverrides: themes.tokenOverrides,
|
|
35
|
+
customCss: themes.customCss,
|
|
36
|
+
enabled: themes.enabled,
|
|
37
|
+
isDefault: themes.isDefault,
|
|
38
|
+
})
|
|
39
|
+
.from(themes)
|
|
40
|
+
|
|
41
|
+
return rows
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
import type { PgColumn } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
audienceForumIds,
|
|
6
|
+
audienceIsEmpty,
|
|
7
|
+
type ThreadAudience,
|
|
8
|
+
type ThreadAuthorFilter,
|
|
9
|
+
} from '@meith/core'
|
|
10
|
+
|
|
11
|
+
import { idList } from './sql-lists'
|
|
12
|
+
|
|
13
|
+
export function authoredBy(column: PgColumn | SQL, filter: ThreadAuthorFilter): SQL {
|
|
14
|
+
switch (filter.kind) {
|
|
15
|
+
case 'all':
|
|
16
|
+
return sql`true`
|
|
17
|
+
case 'none':
|
|
18
|
+
return sql`false`
|
|
19
|
+
case 'author':
|
|
20
|
+
return sql`${column} = ${filter.userId}`
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inAudience(
|
|
25
|
+
forumColumn: PgColumn | SQL,
|
|
26
|
+
authorColumn: PgColumn | SQL,
|
|
27
|
+
audience: ThreadAudience,
|
|
28
|
+
): SQL {
|
|
29
|
+
if (audienceIsEmpty(audience)) return sql`false`
|
|
30
|
+
|
|
31
|
+
const { open, ownThreadsOnly } = audienceForumIds(audience)
|
|
32
|
+
const parts: SQL[] = []
|
|
33
|
+
|
|
34
|
+
if (open.length > 0) parts.push(sql`${forumColumn} in ${idList(open)}`)
|
|
35
|
+
if (ownThreadsOnly.length > 0) {
|
|
36
|
+
parts.push(
|
|
37
|
+
sql`(${forumColumn} in ${idList(ownThreadsOnly)}
|
|
38
|
+
and ${authorColumn} = ${audience.viewerUserId})`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sql`(${sql.join(parts, sql` or `)})`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { audienceIsEmpty }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { resultRows } from './result-rows'
|
|
4
|
+
|
|
5
|
+
export interface CounterTx {
|
|
6
|
+
execute(query: ReturnType<typeof sql>): Promise<unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ThreadTally {
|
|
10
|
+
readonly posts: number
|
|
11
|
+
readonly byAuthor: ReadonlyArray<{ userId: number; posts: number }>
|
|
12
|
+
readonly threadAuthorId: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function tallyThread(tx: CounterTx, threadId: number): Promise<ThreadTally> {
|
|
16
|
+
const rows = resultRows(
|
|
17
|
+
await tx.execute(sql`
|
|
18
|
+
select p.author_user_id, count(*)::int as n
|
|
19
|
+
from posts p
|
|
20
|
+
where p.thread_id = ${threadId} and p.visibility = 'visible'
|
|
21
|
+
group by p.author_user_id
|
|
22
|
+
`),
|
|
23
|
+
) as Array<{ author_user_id: number | null; n: number }>
|
|
24
|
+
|
|
25
|
+
const owner = resultRows(
|
|
26
|
+
await tx.execute(sql`select author_user_id from threads where id = ${threadId}`),
|
|
27
|
+
) as Array<{ author_user_id: number | null }>
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
posts: rows.reduce((total, row) => total + Number(row.n), 0),
|
|
31
|
+
byAuthor: rows.flatMap((row) =>
|
|
32
|
+
row.author_user_id === null
|
|
33
|
+
? []
|
|
34
|
+
: [{ userId: Number(row.author_user_id), posts: Number(row.n) }],
|
|
35
|
+
),
|
|
36
|
+
threadAuthorId: owner[0]?.author_user_id == null ? null : Number(owner[0].author_user_id),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function applyForumChain(
|
|
41
|
+
tx: CounterTx,
|
|
42
|
+
forumId: number,
|
|
43
|
+
delta: 1 | -1,
|
|
44
|
+
tally: ThreadTally,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
await tx.execute(sql`
|
|
47
|
+
update forums f
|
|
48
|
+
set post_count = greatest(f.post_count + ${delta * tally.posts}, 0),
|
|
49
|
+
thread_count = greatest(f.thread_count + ${delta}, 0),
|
|
50
|
+
updated_at = now()
|
|
51
|
+
from forums child
|
|
52
|
+
where child.id = ${forumId}
|
|
53
|
+
and (f.id = child.id or child.path like f.path || '.%')
|
|
54
|
+
`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function applyAuthorCounts(
|
|
58
|
+
tx: CounterTx,
|
|
59
|
+
delta: 1 | -1,
|
|
60
|
+
tally: ThreadTally,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
for (const author of tally.byAuthor) {
|
|
63
|
+
await tx.execute(sql`
|
|
64
|
+
update users
|
|
65
|
+
set post_count = greatest(post_count + ${delta * author.posts}, 0),
|
|
66
|
+
updated_at = now()
|
|
67
|
+
where id = ${author.userId}
|
|
68
|
+
`)
|
|
69
|
+
}
|
|
70
|
+
if (tally.threadAuthorId !== null) {
|
|
71
|
+
await tx.execute(sql`
|
|
72
|
+
update users
|
|
73
|
+
set thread_count = greatest(thread_count + ${delta}, 0), updated_at = now()
|
|
74
|
+
where id = ${tally.threadAuthorId}
|
|
75
|
+
`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function syncLedger(tx: CounterTx, threadId: number, counted: boolean): Promise<void> {
|
|
80
|
+
if (counted) {
|
|
81
|
+
await tx.execute(sql`
|
|
82
|
+
insert into content_counter_rollups (post_id)
|
|
83
|
+
select p.id from posts p
|
|
84
|
+
where p.thread_id = ${threadId} and p.visibility = 'visible'
|
|
85
|
+
on conflict (post_id) do nothing
|
|
86
|
+
`)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
await tx.execute(sql`
|
|
90
|
+
delete from content_counter_rollups r
|
|
91
|
+
using posts p
|
|
92
|
+
where r.post_id = p.id and p.thread_id = ${threadId}
|
|
93
|
+
`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function logModeratorAction(
|
|
97
|
+
tx: CounterTx,
|
|
98
|
+
action: string,
|
|
99
|
+
actorUserId: number,
|
|
100
|
+
detail: Record<string, unknown>,
|
|
101
|
+
at: Date,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
await tx.execute(sql`
|
|
104
|
+
insert into admin_log (user_id, action, detail, created_at)
|
|
105
|
+
values (${actorUserId}, ${action}, ${JSON.stringify(detail)}::jsonb, ${at})
|
|
106
|
+
`)
|
|
107
|
+
}
|