@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,67 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import postgres from 'postgres'
3
+
4
+ import type { Database } from './client'
5
+ import { resultRows } from './result-rows'
6
+
7
+ export const INSTALL_LOCK_KEY = '8626403014885544245'
8
+
9
+ export async function isInstalled(db: Database): Promise<boolean> {
10
+ try {
11
+ const rows = (await db.execute(
12
+ sql`select 1 from install_state where id = 1`,
13
+ )) as unknown as unknown[]
14
+ return rows.length > 0
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ export async function countUsers(db: Database): Promise<number | null> {
21
+ try {
22
+ const rows = (await db.execute(sql`select count(*)::int as total from users`)) as unknown as {
23
+ total: number
24
+ }[]
25
+ return rows[0]?.total ?? 0
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ export async function canConnect(db: Database): Promise<boolean> {
32
+ try {
33
+ await db.execute(sql`select 1`)
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ export async function markInstalled(db: Database, version: string): Promise<boolean> {
41
+ const rows = resultRows(
42
+ await db.execute(sql`
43
+ insert into install_state (id, installed_version)
44
+ values (1, ${version})
45
+ on conflict (id) do nothing
46
+ returning id
47
+ `),
48
+ )
49
+
50
+ return rows.length > 0
51
+ }
52
+
53
+ export async function withInstallLock<T>(url: string, body: () => Promise<T>): Promise<T | null> {
54
+ const client = postgres(url, { max: 1, prepare: false, onnotice: () => {} })
55
+
56
+ try {
57
+ const rows = await client`
58
+ select pg_try_advisory_lock(${INSTALL_LOCK_KEY}::bigint) as locked
59
+ `
60
+
61
+ if (rows[0]?.locked !== true) return null
62
+
63
+ return await body()
64
+ } finally {
65
+ await client.end({ timeout: 5 })
66
+ }
67
+ }
@@ -0,0 +1,115 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { ContentScope } from '@meith/core'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { toDate } from './row-values'
8
+ import { audienceIsEmpty, inAudience } from './thread-audience'
9
+ import { visibleIn } from './visibility'
10
+
11
+ export interface LatestScope {
12
+ readonly forumIds: readonly number[]
13
+ readonly ownThreadsOnlyForumIds: readonly number[]
14
+ readonly viewerUserId: number | null
15
+ readonly content: ContentScope
16
+ }
17
+
18
+ export interface LatestThreadRow {
19
+ readonly threadId: number
20
+ readonly title: string
21
+ readonly slug: string
22
+ readonly forumId: number
23
+ readonly forumTitle: string
24
+ readonly forumSlug: string
25
+ readonly authorUserId: number | null
26
+ readonly authorUsername: string
27
+ readonly replyCount: number
28
+ readonly createdAt: Date
29
+ }
30
+
31
+ export interface LatestPostRow {
32
+ readonly postId: number
33
+ readonly threadId: number
34
+ readonly threadTitle: string
35
+ readonly threadSlug: string
36
+ readonly forumId: number
37
+ readonly forumTitle: string
38
+ readonly forumSlug: string
39
+ readonly authorUserId: number | null
40
+ readonly authorUsername: string
41
+ readonly createdAt: Date
42
+ readonly messageSource: string
43
+ }
44
+
45
+ const EXCERPT_CHARS = 300
46
+
47
+ export class PostgresLatestRepository {
48
+ constructor(private readonly db: Database) {}
49
+
50
+ async threads(limit: number, scope: LatestScope): Promise<readonly LatestThreadRow[]> {
51
+ if (audienceIsEmpty(scope)) return []
52
+
53
+ const rows = resultRows(
54
+ await this.db.execute(sql`
55
+ select t.id, t.title, t.slug, t.forum_id, f.title as forum_title,
56
+ f.slug as forum_slug, t.author_user_id, t.author_username,
57
+ t.reply_count, t.created_at
58
+ from threads t
59
+ join forums f on f.id = t.forum_id
60
+ where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
61
+ and ${visibleIn(sql`t.visibility`, scope.content)}
62
+ order by t.id desc
63
+ limit ${limit}
64
+ `),
65
+ ) as Array<Record<string, unknown>>
66
+
67
+ return rows.map((row) => ({
68
+ threadId: Number(row.id),
69
+ title: String(row.title),
70
+ slug: String(row.slug),
71
+ forumId: Number(row.forum_id),
72
+ forumTitle: String(row.forum_title),
73
+ forumSlug: String(row.forum_slug),
74
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
75
+ authorUsername: String(row.author_username),
76
+ replyCount: Number(row.reply_count),
77
+ createdAt: toDate(row.created_at),
78
+ }))
79
+ }
80
+
81
+ async posts(limit: number, scope: LatestScope): Promise<readonly LatestPostRow[]> {
82
+ if (audienceIsEmpty(scope)) return []
83
+
84
+ const rows = resultRows(
85
+ await this.db.execute(sql`
86
+ select p.id, p.thread_id, p.author_user_id, p.author_username, p.created_at,
87
+ left(p.message, ${EXCERPT_CHARS}) as message,
88
+ t.title as thread_title, t.slug as thread_slug,
89
+ t.forum_id, f.title as forum_title, f.slug as forum_slug
90
+ from posts p
91
+ join threads t on t.id = p.thread_id
92
+ join forums f on f.id = t.forum_id
93
+ where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
94
+ and ${visibleIn(sql`t.visibility`, scope.content)}
95
+ and ${visibleIn(sql`p.visibility`, scope.content)}
96
+ order by p.id desc
97
+ limit ${limit}
98
+ `),
99
+ ) as Array<Record<string, unknown>>
100
+
101
+ return rows.map((row) => ({
102
+ postId: Number(row.id),
103
+ threadId: Number(row.thread_id),
104
+ threadTitle: String(row.thread_title),
105
+ threadSlug: String(row.thread_slug),
106
+ forumId: Number(row.forum_id),
107
+ forumTitle: String(row.forum_title),
108
+ forumSlug: String(row.forum_slug),
109
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
110
+ authorUsername: String(row.author_username),
111
+ createdAt: toDate(row.created_at),
112
+ messageSource: String(row.message),
113
+ }))
114
+ }
115
+ }
@@ -0,0 +1,54 @@
1
+ import { and, isNotNull, lt, or, sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+ import { credentialTokens, sessions } from './schema'
6
+
7
+ export class PostgresMaintenanceRepository {
8
+ constructor(private readonly db: Database) {}
9
+
10
+ async pruneSessions(now: Date, limit = 5000, revokedGraceHours = 24): Promise<number> {
11
+ const revokedBefore = new Date(now.getTime() - revokedGraceHours * 3_600_000)
12
+
13
+ const result = await this.db.execute(sql`
14
+ delete from ${sessions}
15
+ where id in (
16
+ select id from ${sessions}
17
+ where expires_at <= ${now}
18
+ or (revoked_at is not null and revoked_at <= ${revokedBefore})
19
+ limit ${limit}
20
+ )
21
+ returning id
22
+ `)
23
+
24
+ return resultRows(result).length
25
+ }
26
+
27
+ async pruneExpiredTokens(now: Date, limit = 5000): Promise<number> {
28
+ const result = await this.db.execute(sql`
29
+ delete from ${credentialTokens}
30
+ where id in (
31
+ select id from ${credentialTokens}
32
+ where expires_at <= ${now} or consumed_at is not null
33
+ limit ${limit}
34
+ )
35
+ returning id
36
+ `)
37
+
38
+ return resultRows(result).length
39
+ }
40
+
41
+ async countPrunableSessions(now: Date, revokedGraceHours = 24): Promise<number> {
42
+ const revokedBefore = new Date(now.getTime() - revokedGraceHours * 3_600_000)
43
+ const rows = await this.db
44
+ .select({ id: sessions.id })
45
+ .from(sessions)
46
+ .where(
47
+ or(
48
+ lt(sessions.expiresAt, now),
49
+ and(isNotNull(sessions.revokedAt), lt(sessions.revokedAt, revokedBefore)),
50
+ ),
51
+ )
52
+ return rows.length
53
+ }
54
+ }
@@ -0,0 +1,111 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ CachedMarketplace,
5
+ MarketplaceCacheRepository,
6
+ MarketplaceFeed,
7
+ } from '@meith/marketplace'
8
+
9
+ import type { Database } from './client'
10
+ import { resultRows } from './result-rows'
11
+
12
+ interface Row {
13
+ readonly feed: unknown
14
+ readonly source_url: string | null
15
+ readonly fetched_at: string | null
16
+ readonly error: string | null
17
+ readonly error_at: string | null
18
+ readonly notified_updates: unknown
19
+ }
20
+
21
+ function toCached(row: Row | undefined): CachedMarketplace {
22
+ if (row === undefined) {
23
+ return { feed: null, sourceUrl: null, fetchedAt: null, error: null, errorAt: null }
24
+ }
25
+
26
+ return {
27
+ feed: (row.feed as MarketplaceFeed | null) ?? null,
28
+ sourceUrl: row.source_url,
29
+ fetchedAt: row.fetched_at === null ? null : new Date(row.fetched_at),
30
+ error: row.error,
31
+ errorAt: row.error_at === null ? null : new Date(row.error_at),
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The single-row cache behind the Browse tab — see the `marketplace_catalog`
37
+ * table comment in `packages/db/src/schema/platform.ts` for why a table
38
+ * rather than the generic `settings` store: this holds a whole feed
39
+ * document and fetch bookkeeping, not a board-operator preference.
40
+ */
41
+ export class PostgresMarketplaceCacheRepository implements MarketplaceCacheRepository {
42
+ constructor(private readonly db: Database) {}
43
+
44
+ async read(): Promise<CachedMarketplace> {
45
+ const rows = resultRows(
46
+ await this.db.execute(sql`
47
+ select feed, source_url, fetched_at, error, error_at, notified_updates
48
+ from marketplace_catalog
49
+ where id = 1
50
+ `),
51
+ ) as Row[]
52
+
53
+ return toCached(rows[0])
54
+ }
55
+
56
+ async saveFeed(input: {
57
+ readonly feed: MarketplaceFeed
58
+ readonly sourceUrl: string
59
+ readonly fetchedAt: Date
60
+ }): Promise<void> {
61
+ await this.db.execute(sql`
62
+ insert into marketplace_catalog (id, feed, source_url, fetched_at, error, error_at, updated_at)
63
+ values (1, ${JSON.stringify(input.feed)}::jsonb, ${input.sourceUrl}, ${input.fetchedAt}, null, null, now())
64
+ on conflict (id) do update
65
+ set feed = excluded.feed,
66
+ source_url = excluded.source_url,
67
+ fetched_at = excluded.fetched_at,
68
+ error = null,
69
+ error_at = null,
70
+ updated_at = now()
71
+ `)
72
+ }
73
+
74
+ async saveError(input: { readonly message: string; readonly at: Date }): Promise<void> {
75
+ await this.db.execute(sql`
76
+ insert into marketplace_catalog (id, error, error_at, updated_at)
77
+ values (1, ${input.message}, ${input.at}, now())
78
+ on conflict (id) do update
79
+ set error = excluded.error,
80
+ error_at = excluded.error_at,
81
+ updated_at = now()
82
+ `)
83
+ }
84
+
85
+ async hasNotified(key: string, version: string): Promise<boolean> {
86
+ const rows = resultRows(
87
+ await this.db.execute(sql`
88
+ select 1
89
+ from marketplace_catalog
90
+ where id = 1
91
+ and notified_updates @> ${JSON.stringify([`${key}@${version}`])}::jsonb
92
+ `),
93
+ ) as unknown[]
94
+
95
+ return rows.length > 0
96
+ }
97
+
98
+ async markNotified(key: string, version: string): Promise<void> {
99
+ const marker = `${key}@${version}`
100
+ await this.db.execute(sql`
101
+ insert into marketplace_catalog (id, notified_updates, updated_at)
102
+ values (1, ${JSON.stringify([marker])}::jsonb, now())
103
+ on conflict (id) do update
104
+ set notified_updates = (
105
+ select jsonb_agg(distinct value)
106
+ from jsonb_array_elements(marketplace_catalog.notified_updates || excluded.notified_updates)
107
+ ),
108
+ updated_at = now()
109
+ `)
110
+ }
111
+ }
@@ -0,0 +1,82 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ MemberProfileRecord,
5
+ MemberProfileRepository,
6
+ MemberSuggestion,
7
+ } from '@meith/accounts'
8
+
9
+ import type { Database } from './client'
10
+ import { resultRows } from './result-rows'
11
+ import { displayGroupIdSql } from './staff-groups'
12
+
13
+ function likePrefix(prefix: string): string {
14
+ return `${prefix.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
15
+ }
16
+
17
+ interface RawProfile {
18
+ id: number
19
+ username: string
20
+ title: string | null
21
+ post_count: number
22
+ created_at: Date
23
+ last_active_at: Date | null
24
+ location: string | null
25
+ website: string | null
26
+ bio: string | null
27
+ }
28
+
29
+ export class PostgresMemberProfileRepository implements MemberProfileRepository {
30
+ constructor(private readonly db: Database) {}
31
+
32
+ async findPublicById(id: number): Promise<MemberProfileRecord | null> {
33
+ const rows = resultRows(
34
+ await this.db.execute(sql`
35
+ select u.id, u.username, g.title, u.post_count, u.created_at,
36
+ u.last_active_at, u.location, u.website, u.bio
37
+ from users u
38
+ join usergroups p
39
+ on p.id = u.primary_group_id
40
+ left join usergroups g
41
+ on g.id = ${displayGroupIdSql('u', 'p')}
42
+ where u.id = ${id} and u.state <> 'deleted'
43
+ limit 1
44
+ `),
45
+ ) as RawProfile[]
46
+
47
+ const row = rows[0]
48
+ if (row === undefined) return null
49
+
50
+ return {
51
+ id: Number(row.id),
52
+ username: row.username,
53
+ title: row.title,
54
+ postCount: Number(row.post_count),
55
+ createdAt: new Date(row.created_at),
56
+ lastActiveAt: row.last_active_at === null ? null : new Date(row.last_active_at),
57
+ location: row.location,
58
+ website: row.website,
59
+ bio: row.bio,
60
+ }
61
+ }
62
+
63
+ async searchByUsernamePrefix(
64
+ prefix: string,
65
+ limit: number,
66
+ ): Promise<readonly MemberSuggestion[]> {
67
+ if (prefix === '') return []
68
+
69
+ const rows = resultRows(
70
+ await this.db.execute(sql`
71
+ select id, username
72
+ from users
73
+ where state = 'active'
74
+ and username_lower like ${likePrefix(prefix.toLowerCase())}
75
+ order by username_lower
76
+ limit ${limit}
77
+ `),
78
+ ) as Array<{ id: number; username: string }>
79
+
80
+ return rows.map((row) => ({ id: Number(row.id), username: row.username }))
81
+ }
82
+ }
@@ -0,0 +1,160 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { MemberGroupChoice, MemberSettings, MemberSettingsRepository } from '@meith/accounts'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { isStaffGroupSql } from './staff-groups'
8
+
9
+ interface RawSettings {
10
+ id: number
11
+ email: string
12
+ timezone: string
13
+ locale: string
14
+ posts_per_page: number | null
15
+ threads_per_page: number | null
16
+ invisible: boolean
17
+ location: string | null
18
+ website: string | null
19
+ bio: string | null
20
+ display_group_id: number | null
21
+ }
22
+
23
+ interface RawGroupChoice {
24
+ id: number
25
+ title: string
26
+ is_primary: boolean
27
+ is_staff: boolean
28
+ }
29
+
30
+ export class PostgresMemberSettingsRepository implements MemberSettingsRepository {
31
+ constructor(private readonly db: Database) {}
32
+
33
+ async read(userId: number): Promise<MemberSettings | null> {
34
+ const rows = resultRows(
35
+ await this.db.execute(sql`
36
+ select id, email, timezone, locale, posts_per_page, threads_per_page,
37
+ invisible, location, website, bio, display_group_id
38
+ from users
39
+ where id = ${userId} and state <> 'deleted'
40
+ `),
41
+ ) as RawSettings[]
42
+
43
+ const row = rows[0]
44
+ if (row === undefined) return null
45
+
46
+ return {
47
+ userId: Number(row.id),
48
+ email: row.email,
49
+ timezone: row.timezone,
50
+ locale: row.locale,
51
+ postsPerPage: row.posts_per_page === null ? null : Number(row.posts_per_page),
52
+ threadsPerPage: row.threads_per_page === null ? null : Number(row.threads_per_page),
53
+ invisible: row.invisible === true,
54
+ location: row.location,
55
+ website: row.website,
56
+ bio: row.bio,
57
+ displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
58
+ }
59
+ }
60
+
61
+ async groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]> {
62
+ const rows = resultRows(
63
+ await this.db.execute(sql`
64
+ select g.id, g.title, (g.id = u.primary_group_id) as is_primary,
65
+ (${isStaffGroupSql('g')}) as is_staff
66
+ from users u
67
+ join usergroups g
68
+ on g.id = u.primary_group_id
69
+ or g.id in (
70
+ select m.group_id
71
+ from user_group_memberships m
72
+ where m.user_id = u.id
73
+ and (m.expires_at is null or m.expires_at > now())
74
+ )
75
+ where u.id = ${userId} and u.state <> 'deleted'
76
+ order by is_primary desc, g.display_order, g.title
77
+ `),
78
+ ) as RawGroupChoice[]
79
+
80
+ return rows.map((row) => ({
81
+ groupId: Number(row.id),
82
+ title: row.title,
83
+ isPrimary: row.is_primary === true,
84
+ isStaff: row.is_staff === true,
85
+ }))
86
+ }
87
+
88
+ async saveDisplayGroup(input: {
89
+ readonly userId: number
90
+ readonly displayGroupId: number | null
91
+ }): Promise<void> {
92
+ await this.db.execute(sql`
93
+ update users
94
+ set display_group_id = ${input.displayGroupId},
95
+ updated_at = now()
96
+ where id = ${input.userId}
97
+ `)
98
+ }
99
+
100
+ async saveProfile(input: {
101
+ readonly userId: number
102
+ readonly location: string | null
103
+ readonly website: string | null
104
+ readonly bio: string | null
105
+ }): Promise<void> {
106
+ await this.db.execute(sql`
107
+ update users
108
+ set location = ${input.location},
109
+ website = ${input.website},
110
+ bio = ${input.bio},
111
+ updated_at = now()
112
+ where id = ${input.userId}
113
+ `)
114
+ }
115
+
116
+ async saveOptions(input: {
117
+ readonly userId: number
118
+ readonly timezone: string
119
+ readonly locale: string
120
+ readonly postsPerPage: number | null
121
+ readonly threadsPerPage: number | null
122
+ readonly invisible: boolean
123
+ }): Promise<void> {
124
+ await this.db.execute(sql`
125
+ update users
126
+ set timezone = ${input.timezone},
127
+ locale = ${input.locale},
128
+ posts_per_page = ${input.postsPerPage},
129
+ threads_per_page = ${input.threadsPerPage},
130
+ invisible = ${input.invisible},
131
+ updated_at = now()
132
+ where id = ${input.userId}
133
+ `)
134
+ }
135
+
136
+ async adoptEmail(input: {
137
+ readonly userId: number
138
+ readonly email: string
139
+ readonly emailLower: string
140
+ }): Promise<boolean> {
141
+ const rows = resultRows(
142
+ await this.db.execute(sql`
143
+ update users u
144
+ set email = ${input.email},
145
+ email_lower = ${input.emailLower},
146
+ email_verified_at = now(),
147
+ updated_at = now()
148
+ where u.id = ${input.userId}
149
+ and u.state <> 'deleted'
150
+ and not exists (
151
+ select 1 from users other
152
+ where other.email_lower = ${input.emailLower} and other.id <> u.id
153
+ )
154
+ returning u.id
155
+ `),
156
+ ) as Array<{ id: number }>
157
+
158
+ return rows.length > 0
159
+ }
160
+ }