@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.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -0,0 +1,182 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ type FieldType,
5
+ type ProfileFieldDefinition,
6
+ type ProfileFieldGroupRule,
7
+ type ProfileFieldRepository,
8
+ type ProfileFieldValue,
9
+ parseFieldType,
10
+ } from '@meith/profile-fields'
11
+
12
+ import type { Database } from './client'
13
+ import { resultRows } from './result-rows'
14
+
15
+ interface RawField {
16
+ id: number
17
+ key: string
18
+ label: string
19
+ description: string | null
20
+ type: string
21
+ options: unknown
22
+ max_length: number | null
23
+ display_order: number
24
+ is_active: boolean
25
+ required_at_registration: boolean
26
+ default_visible: boolean
27
+ default_editable: boolean
28
+ show_in_postbit: boolean
29
+ }
30
+
31
+ function toOptions(value: unknown): readonly string[] {
32
+ if (!Array.isArray(value)) return []
33
+ return value.filter((entry): entry is string => typeof entry === 'string')
34
+ }
35
+
36
+ function toField(row: RawField): ProfileFieldDefinition {
37
+ return {
38
+ id: Number(row.id),
39
+ key: row.key,
40
+ label: row.label,
41
+ description: row.description,
42
+ type: parseFieldType(row.type),
43
+ options: toOptions(row.options),
44
+ maxLength: row.max_length === null ? null : Number(row.max_length),
45
+ displayOrder: Number(row.display_order),
46
+ isActive: row.is_active === true,
47
+ requiredAtRegistration: row.required_at_registration === true,
48
+ defaultVisible: row.default_visible === true,
49
+ defaultEditable: row.default_editable === true,
50
+ showInPostbit: row.show_in_postbit === true,
51
+ }
52
+ }
53
+
54
+ const SELECT_FIELD = sql`
55
+ select id, key, label, description, type, options, max_length, display_order,
56
+ is_active, required_at_registration, default_visible, default_editable,
57
+ show_in_postbit
58
+ from profile_fields
59
+ `
60
+
61
+ export class PostgresProfileFieldRepository implements ProfileFieldRepository {
62
+ constructor(private readonly db: Database) {}
63
+
64
+ async listFields(): Promise<readonly ProfileFieldDefinition[]> {
65
+ const rows = resultRows(
66
+ await this.db.execute(sql`${SELECT_FIELD} order by display_order, id`),
67
+ ) as RawField[]
68
+
69
+ return rows.map(toField)
70
+ }
71
+
72
+ async listGroupRules(): Promise<readonly ProfileFieldGroupRule[]> {
73
+ const rows = resultRows(
74
+ await this.db.execute(sql`
75
+ select field_id, group_id, can_view, can_edit from profile_field_groups
76
+ `),
77
+ ) as Array<{
78
+ field_id: number
79
+ group_id: number
80
+ can_view: boolean | null
81
+ can_edit: boolean | null
82
+ }>
83
+
84
+ return rows.map((row) => ({
85
+ fieldId: Number(row.field_id),
86
+ groupId: Number(row.group_id),
87
+ canView: row.can_view,
88
+ canEdit: row.can_edit,
89
+ }))
90
+ }
91
+
92
+ async valuesFor(userId: number): Promise<readonly ProfileFieldValue[]> {
93
+ const rows = resultRows(
94
+ await this.db.execute(sql`
95
+ select field_id, value from profile_field_values where user_id = ${userId}
96
+ `),
97
+ ) as Array<{ field_id: number; value: string }>
98
+
99
+ return rows.map((row) => ({ fieldId: Number(row.field_id), value: row.value }))
100
+ }
101
+
102
+ async saveValues(input: {
103
+ readonly userId: number
104
+ readonly values: readonly ProfileFieldValue[]
105
+ }): Promise<void> {
106
+ if (input.values.length === 0) return
107
+
108
+ await this.db.transaction(async (tx) => {
109
+ const cleared = input.values.filter((value) => value.value === '')
110
+ const written = input.values.filter((value) => value.value !== '')
111
+
112
+ if (cleared.length > 0) {
113
+ await tx.execute(sql`
114
+ delete from profile_field_values
115
+ where user_id = ${input.userId}
116
+ and field_id in (${sql.join(
117
+ cleared.map((value) => sql`${value.fieldId}`),
118
+ sql`, `,
119
+ )})
120
+ `)
121
+ }
122
+
123
+ if (written.length > 0) {
124
+ await tx.execute(sql`
125
+ insert into profile_field_values (user_id, field_id, value, updated_at)
126
+ values ${sql.join(
127
+ written.map((value) => sql`(${input.userId}, ${value.fieldId}, ${value.value}, now())`),
128
+ sql`, `,
129
+ )}
130
+ on conflict (user_id, field_id)
131
+ do update set value = excluded.value, updated_at = now()
132
+ `)
133
+ }
134
+ })
135
+ }
136
+
137
+ async findByKey(key: string): Promise<ProfileFieldDefinition | null> {
138
+ const rows = resultRows(
139
+ await this.db.execute(sql`${SELECT_FIELD} where key = ${key}`),
140
+ ) as RawField[]
141
+
142
+ return rows[0] === undefined ? null : toField(rows[0])
143
+ }
144
+
145
+ async create(input: {
146
+ readonly key: string
147
+ readonly label: string
148
+ readonly type: FieldType
149
+ readonly options: readonly string[]
150
+ readonly requiredAtRegistration: boolean
151
+ readonly showInPostbit: boolean
152
+ readonly displayOrder: number
153
+ }): Promise<ProfileFieldDefinition> {
154
+ const rows = resultRows(
155
+ await this.db.execute(sql`
156
+ insert into profile_fields
157
+ (key, label, type, options, required_at_registration,
158
+ show_in_postbit, display_order)
159
+ values (${input.key}, ${input.label}, ${input.type},
160
+ ${JSON.stringify(input.options)}::jsonb,
161
+ ${input.requiredAtRegistration}, ${input.showInPostbit},
162
+ ${input.displayOrder})
163
+ returning id, key, label, description, type, options, max_length,
164
+ display_order, is_active, required_at_registration,
165
+ default_visible, default_editable, show_in_postbit
166
+ `),
167
+ ) as RawField[]
168
+
169
+ if (rows[0] === undefined) throw new Error('Profile field insert returned no row')
170
+ return toField(rows[0])
171
+ }
172
+
173
+ async remove(key: string): Promise<boolean> {
174
+ const rows = resultRows(
175
+ await this.db.execute(sql`
176
+ delete from profile_fields where key = ${key} returning id
177
+ `),
178
+ ) as Array<{ id: number }>
179
+
180
+ return rows.length > 0
181
+ }
182
+ }
@@ -0,0 +1,152 @@
1
+ import { asc, eq, gt, sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import type {
5
+ PromotionCandidate,
6
+ PromotionOutcome,
7
+ PromotionRepository,
8
+ PromotionRule,
9
+ PromotionRuleInput,
10
+ PromotionRuleRepository,
11
+ } from '@meith/groups'
12
+ import { promotionRuleProblem } from '@meith/groups'
13
+ import { msg } from '@meith/i18n'
14
+
15
+ import type { Database } from './client'
16
+ import { groupPromotions, users } from './schema'
17
+ import { keptDisplayGroupSql } from './staff-groups'
18
+
19
+ function optionalNumber(value: number | null): number | undefined {
20
+ return value === null ? undefined : value
21
+ }
22
+
23
+ function checked(input: PromotionRuleInput): PromotionRuleInput {
24
+ const problem = promotionRuleProblem(input)
25
+ if (problem !== null) throw new ValidationError(problem)
26
+ return { ...input, title: input.title.trim() }
27
+ }
28
+
29
+ export class PostgresPromotionRepository implements PromotionRepository, PromotionRuleRepository {
30
+ constructor(private readonly db: Database) {}
31
+
32
+ async listRules(): Promise<readonly PromotionRule[]> {
33
+ const rows = await this.db
34
+ .select()
35
+ .from(groupPromotions)
36
+ .orderBy(asc(groupPromotions.displayOrder), asc(groupPromotions.id))
37
+
38
+ return rows.map((r) => ({
39
+ id: r.id,
40
+ title: r.title,
41
+ enabled: r.enabled,
42
+ displayOrder: r.displayOrder,
43
+ minPostCount: optionalNumber(r.minPostCount),
44
+ minReputation: optionalNumber(r.minReputation),
45
+ minDaysRegistered: optionalNumber(r.minDaysRegistered),
46
+ fromPrimaryGroupId: r.fromPrimaryGroupId,
47
+ toPrimaryGroupId: r.toPrimaryGroupId,
48
+ }))
49
+ }
50
+
51
+ async createRule(input: PromotionRuleInput): Promise<number> {
52
+ const rule = checked(input)
53
+
54
+ const [row] = await this.db
55
+ .insert(groupPromotions)
56
+ .values({
57
+ title: rule.title,
58
+ displayOrder: rule.displayOrder,
59
+ minPostCount: rule.minPostCount,
60
+ minReputation: rule.minReputation,
61
+ minDaysRegistered: rule.minDaysRegistered,
62
+ fromPrimaryGroupId: rule.fromPrimaryGroupId,
63
+ toPrimaryGroupId: rule.toPrimaryGroupId,
64
+ })
65
+ .returning({ id: groupPromotions.id })
66
+
67
+ if (row === undefined) throw new ValidationError(msg('error.db.rule-could-stored'))
68
+ return row.id
69
+ }
70
+
71
+ async updateRule(id: number, input: PromotionRuleInput): Promise<void> {
72
+ const rule = checked(input)
73
+
74
+ const rows = await this.db
75
+ .update(groupPromotions)
76
+ .set({
77
+ title: rule.title,
78
+ displayOrder: rule.displayOrder,
79
+ minPostCount: rule.minPostCount,
80
+ minReputation: rule.minReputation,
81
+ minDaysRegistered: rule.minDaysRegistered,
82
+ fromPrimaryGroupId: rule.fromPrimaryGroupId,
83
+ toPrimaryGroupId: rule.toPrimaryGroupId,
84
+ updatedAt: new Date(),
85
+ })
86
+ .where(eq(groupPromotions.id, id))
87
+ .returning({ id: groupPromotions.id })
88
+
89
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-promotion-rule'))
90
+ }
91
+
92
+ async setRuleEnabled(id: number, enabled: boolean): Promise<void> {
93
+ const rows = await this.db
94
+ .update(groupPromotions)
95
+ .set({ enabled, updatedAt: new Date() })
96
+ .where(eq(groupPromotions.id, id))
97
+ .returning({ id: groupPromotions.id })
98
+
99
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-promotion-rule'))
100
+ }
101
+
102
+ async deleteRule(id: number): Promise<void> {
103
+ await this.db.delete(groupPromotions).where(eq(groupPromotions.id, id))
104
+ }
105
+
106
+ async candidates(afterUserId: number, limit: number): Promise<readonly PromotionCandidate[]> {
107
+ const rows = await this.db
108
+ .select({
109
+ userId: users.id,
110
+ postCount: users.postCount,
111
+ reputation: users.reputation,
112
+ registeredAt: users.createdAt,
113
+ primaryGroupId: users.primaryGroupId,
114
+ })
115
+ .from(users)
116
+ .where(gt(users.id, afterUserId))
117
+ .orderBy(asc(users.id))
118
+ .limit(limit)
119
+
120
+ return rows
121
+ }
122
+
123
+ async applyPromotions(outcomes: readonly PromotionOutcome[]): Promise<void> {
124
+ if (outcomes.length === 0) return
125
+
126
+ await this.db.transaction(async (tx) => {
127
+ const values = sql.join(
128
+ outcomes.map((o) => sql`(${o.userId}::int, ${o.toPrimaryGroupId}::int)`),
129
+ sql`, `,
130
+ )
131
+
132
+ const displayGroup = keptDisplayGroupSql({
133
+ column: sql.raw('u.display_group_id'),
134
+ leavingGroupId: sql.raw('u.primary_group_id'),
135
+ arrivingGroupId: sql.raw('v.group_id'),
136
+ })
137
+
138
+ await tx.execute(sql`
139
+ update ${users} as u
140
+ set primary_group_id = v.group_id, display_group_id = ${displayGroup}
141
+ from (values ${values}) as v(user_id, group_id)
142
+ where u.id = v.user_id
143
+ `)
144
+
145
+ await tx.execute(sql`
146
+ insert into cache_versions (key, version)
147
+ values ('permissions', 1)
148
+ on conflict (key) do update set version = cache_versions.version + 1, bumped_at = now()
149
+ `)
150
+ })
151
+ }
152
+ }
@@ -0,0 +1,81 @@
1
+ import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'
2
+
3
+ import { PUBLIC_CONTENT } from '@meith/core'
4
+ import type { ReadState, ReadStateRepository } from '@meith/threads'
5
+
6
+ import type { Database } from './client'
7
+ import { forumsRead, threads, threadsRead } from './schema'
8
+ import { visibleIn } from './visibility'
9
+
10
+ export class PostgresReadStateRepository implements ReadStateRepository {
11
+ constructor(private readonly db: Database) {}
12
+
13
+ async forUser(userId: number): Promise<ReadState> {
14
+ const [forums, threadReads, unread] = await Promise.all([
15
+ this.db
16
+ .select({ forumId: forumsRead.forumId, readAt: forumsRead.readAt })
17
+ .from(forumsRead)
18
+ .where(eq(forumsRead.userId, userId)),
19
+ this.db
20
+ .select({ threadId: threadsRead.threadId, lastReadPostId: threadsRead.lastReadPostId })
21
+ .from(threadsRead)
22
+ .where(eq(threadsRead.userId, userId)),
23
+ this.db
24
+ .selectDistinct({ forumId: threads.forumId })
25
+ .from(threads)
26
+ .leftJoin(
27
+ forumsRead,
28
+ and(eq(forumsRead.userId, userId), eq(forumsRead.forumId, threads.forumId)),
29
+ )
30
+ .leftJoin(
31
+ threadsRead,
32
+ and(eq(threadsRead.userId, userId), eq(threadsRead.threadId, threads.id)),
33
+ )
34
+ .where(
35
+ and(
36
+ visibleIn(threads.visibility, PUBLIC_CONTENT),
37
+ sql`${threads.lastPostId} is not null`,
38
+ or(
39
+ isNull(threadsRead.lastReadPostId),
40
+ gt(threads.lastPostId, threadsRead.lastReadPostId),
41
+ ),
42
+ or(isNull(forumsRead.readAt), gt(threads.lastPostAt, forumsRead.readAt)),
43
+ ),
44
+ ),
45
+ ])
46
+
47
+ return {
48
+ forumReadAt: new Map(forums.map((row) => [row.forumId, row.readAt])),
49
+ threadLastPostId: new Map(
50
+ threadReads.flatMap((row) =>
51
+ row.lastReadPostId === null ? [] : [[row.threadId, row.lastReadPostId] as const],
52
+ ),
53
+ ),
54
+ unreadForumIds: new Set(unread.map((row) => row.forumId)),
55
+ }
56
+ }
57
+
58
+ async markForumsRead(userId: number, forumIds: readonly number[], at: Date): Promise<void> {
59
+ if (forumIds.length === 0) return
60
+ await this.db
61
+ .insert(forumsRead)
62
+ .values(forumIds.map((forumId) => ({ userId, forumId, readAt: at })))
63
+ .onConflictDoUpdate({
64
+ target: [forumsRead.userId, forumsRead.forumId],
65
+ set: { readAt: at },
66
+ })
67
+ }
68
+
69
+ async markThreadRead(userId: number, threadId: number, postId: number, at: Date): Promise<void> {
70
+ await this.db
71
+ .insert(threadsRead)
72
+ .values({ userId, threadId, lastReadPostId: postId, readAt: at })
73
+ .onConflictDoUpdate({
74
+ target: [threadsRead.userId, threadsRead.threadId],
75
+ set: {
76
+ lastReadPostId: sql`greatest(coalesce(${threadsRead.lastReadPostId}, 0), excluded.last_read_post_id)`,
77
+ readAt: at,
78
+ },
79
+ })
80
+ }
81
+ }
@@ -0,0 +1,113 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { RelationKind, RelationRepository, RelationRow } from '@meith/relations'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { toDate, toNullableDate } from './row-values'
8
+
9
+ export class PostgresRelationRepository implements RelationRepository {
10
+ constructor(private readonly db: Database) {}
11
+
12
+ async list(input: {
13
+ readonly userId: number
14
+ readonly kind: RelationKind
15
+ }): Promise<readonly RelationRow[]> {
16
+ const rows = resultRows(
17
+ await this.db.execute(sql`
18
+ select r.other_user_id, r.kind, r.created_at, u.username, u.last_active_at
19
+ from user_relations r
20
+ join users u on u.id = r.other_user_id
21
+ where r.user_id = ${input.userId}
22
+ and r.kind = ${input.kind}
23
+ /*
24
+ * A deleted account is not on anybody's list. The row survives by
25
+ * design — deleted_at is a tombstone, not a removal — but showing
26
+ * a name the board no longer recognises anywhere else would be a
27
+ * list somebody cannot act on.
28
+ */
29
+ and u.deleted_at is null
30
+ order by u.username
31
+ `),
32
+ ) as Array<{
33
+ other_user_id: number
34
+ kind: string
35
+ created_at: string | Date
36
+ username: string
37
+ last_active_at: string | Date | null
38
+ }>
39
+
40
+ return rows.map((row) => ({
41
+ userId: Number(row.other_user_id),
42
+ username: row.username,
43
+ kind: row.kind as RelationKind,
44
+ lastActiveAt: toNullableDate(row.last_active_at),
45
+ createdAt: toDate(row.created_at),
46
+ }))
47
+ }
48
+
49
+ async count(userId: number): Promise<number> {
50
+ const rows = resultRows(
51
+ await this.db.execute(sql`
52
+ select count(*)::int as n from user_relations where user_id = ${userId}
53
+ `),
54
+ ) as Array<{ n: number }>
55
+
56
+ return Number(rows[0]?.n ?? 0)
57
+ }
58
+
59
+ async ignoredIds(userId: number): Promise<readonly number[]> {
60
+ const rows = resultRows(
61
+ await this.db.execute(sql`
62
+ select other_user_id from user_relations
63
+ where user_id = ${userId} and kind = 'ignore'
64
+ `),
65
+ ) as Array<{ other_user_id: number }>
66
+
67
+ return rows.map((row) => Number(row.other_user_id))
68
+ }
69
+
70
+ async set(input: {
71
+ readonly userId: number
72
+ readonly otherUserId: number
73
+ readonly kind: RelationKind
74
+ readonly at: Date
75
+ }): Promise<void> {
76
+ await this.db.execute(sql`
77
+ insert into user_relations (user_id, other_user_id, kind, created_at)
78
+ values (${input.userId}, ${input.otherUserId}, ${input.kind}, ${input.at})
79
+ on conflict (user_id, other_user_id)
80
+ /*
81
+ * The kind is replaced; created_at is not. "On my list since" is a
82
+ * property of the relationship, not of the last time somebody changed
83
+ * their mind about which list it belongs on.
84
+ */
85
+ do update set kind = excluded.kind
86
+ `)
87
+ }
88
+
89
+ async remove(input: { readonly userId: number; readonly otherUserId: number }): Promise<boolean> {
90
+ const rows = resultRows(
91
+ await this.db.execute(sql`
92
+ delete from user_relations
93
+ where user_id = ${input.userId} and other_user_id = ${input.otherUserId}
94
+ returning user_id
95
+ `),
96
+ ) as Array<{ user_id: number }>
97
+
98
+ return rows.length > 0
99
+ }
100
+
101
+ async ignores(ownerUserId: number, otherUserId: number): Promise<boolean> {
102
+ const rows = resultRows(
103
+ await this.db.execute(sql`
104
+ select 1 as hit from user_relations
105
+ where user_id = ${ownerUserId}
106
+ and other_user_id = ${otherUserId}
107
+ and kind = 'ignore'
108
+ `),
109
+ ) as Array<{ hit: number }>
110
+
111
+ return rows.length > 0
112
+ }
113
+ }
@@ -0,0 +1,110 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ authorRef,
5
+ BodyFormat,
6
+ CORE_RENDERING,
7
+ type MarkdownPipeline,
8
+ RENDER_VERSION,
9
+ renderThrough,
10
+ sourceAsMarkdown,
11
+ vocabularyOptions,
12
+ } from '@meith/markdown'
13
+
14
+ import type { Database } from './client'
15
+ import { resultRows } from './result-rows'
16
+ import { readBoardVocabulary } from './vocabulary-repo'
17
+
18
+ export interface RenderBackfillRun {
19
+ readonly rendered: number
20
+ readonly converted: number
21
+ }
22
+
23
+ export class PostgresRenderBackfill {
24
+ constructor(
25
+ private readonly db: Database,
26
+ private readonly rendering: MarkdownPipeline = CORE_RENDERING,
27
+ ) {}
28
+
29
+ async pending(): Promise<number> {
30
+ const vocabulary = await readBoardVocabulary(this.db, this.rendering)
31
+ const rows = resultRows(
32
+ await this.db.execute(sql`
33
+ select count(*)::int as pending
34
+ from posts
35
+ where render_version <> ${RENDER_VERSION}
36
+ or vocab_version <> ${vocabulary.revision}
37
+ or body_format <> ${BodyFormat.Markdown}
38
+ `),
39
+ ) as Array<{ pending: number }>
40
+ return Number(rows[0]?.pending ?? 0)
41
+ }
42
+
43
+ async run(batchSize: number): Promise<RenderBackfillRun> {
44
+ if (batchSize <= 0) return { rendered: 0, converted: 0 }
45
+
46
+ const vocabulary = await readBoardVocabulary(this.db, this.rendering)
47
+
48
+ const stale = resultRows(
49
+ await this.db.execute(sql`
50
+ select id, message, body_format, author_user_id
51
+ from posts
52
+ where render_version <> ${RENDER_VERSION}
53
+ or vocab_version <> ${vocabulary.revision}
54
+ or body_format <> ${BodyFormat.Markdown}
55
+ order by id
56
+ limit ${batchSize}
57
+ `),
58
+ ) as Array<{
59
+ id: number
60
+ message: string
61
+ body_format: number
62
+ author_user_id: number | null
63
+ }>
64
+
65
+ if (stale.length === 0) return { rendered: 0, converted: 0 }
66
+
67
+ const rendered = await Promise.all(
68
+ stale.map(async (row) => {
69
+ const format = Number(row.body_format)
70
+ const message = sourceAsMarkdown(row.message, format)
71
+ const body = await renderThrough(
72
+ this.rendering,
73
+ message,
74
+ {
75
+ source: 'post',
76
+ viewer: authorRef(row.author_user_id === null ? null : Number(row.author_user_id)),
77
+ postId: Number(row.id),
78
+ },
79
+ vocabularyOptions(vocabulary),
80
+ )
81
+ return {
82
+ id: Number(row.id),
83
+ message,
84
+ html: body.html,
85
+ converted: format !== BodyFormat.Markdown,
86
+ }
87
+ }),
88
+ )
89
+
90
+ const values = sql.join(
91
+ rendered.map((row) => sql`(${row.id}::int, ${row.message}::text, ${row.html}::text)`),
92
+ sql`, `,
93
+ )
94
+ await this.db.execute(sql`
95
+ update posts as p
96
+ set message = v.message,
97
+ message_html = v.html,
98
+ render_version = ${RENDER_VERSION},
99
+ vocab_version = ${vocabulary.revision},
100
+ body_format = ${BodyFormat.Markdown}
101
+ from (values ${values}) as v(id, message, html)
102
+ where p.id = v.id
103
+ `)
104
+
105
+ return {
106
+ rendered: rendered.length,
107
+ converted: rendered.filter((row) => row.converted).length,
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,33 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+
6
+ const SIGNATURE_KEY = 'plugin_render'
7
+ const VOCABULARY_KEY = 'markdown_vocabulary'
8
+
9
+ export async function syncRenderSignature(db: Database, signature: number): Promise<boolean> {
10
+ return db.transaction(async (tx) => {
11
+ const rows = resultRows(
12
+ await tx.execute(sql`select version from cache_versions where key = ${SIGNATURE_KEY}`),
13
+ ) as Array<{ version: number }>
14
+
15
+ const recorded = rows[0] === undefined ? null : Number(rows[0].version)
16
+ if (recorded === signature) return false
17
+
18
+ await tx.execute(sql`
19
+ insert into cache_versions (key, version) values (${SIGNATURE_KEY}, ${signature})
20
+ on conflict (key) do update set version = ${signature}, bumped_at = now()
21
+ `)
22
+
23
+ if (recorded === null) return false
24
+
25
+ await tx.execute(sql`
26
+ insert into cache_versions (key, version) values (${VOCABULARY_KEY}, 1)
27
+ on conflict (key)
28
+ do update set version = cache_versions.version + 1, bumped_at = now()
29
+ `)
30
+
31
+ return true
32
+ })
33
+ }