@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,78 @@
1
+ import { inArray, sql } from 'drizzle-orm'
2
+
3
+ import type { DomainEventName, OutboxReader, OutboxRecord } from '@meith/events'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { outbox } from './schema'
8
+
9
+ const MAX_CLAIMS = 10
10
+
11
+ export class PostgresOutboxReader implements OutboxReader {
12
+ constructor(private readonly db: Database) {}
13
+
14
+ async claimUnrelayed(limit: number): Promise<OutboxRecord[]> {
15
+ const result = await this.db.execute(sql`
16
+ update ${outbox}
17
+ set attempts = ${outbox.attempts} + 1
18
+ where id in (
19
+ select id from ${outbox}
20
+ where dispatched_at is null
21
+ and attempts < ${MAX_CLAIMS}
22
+ order by id
23
+ limit ${limit}
24
+ for update skip locked
25
+ )
26
+ returning id, topic, payload, created_at, dispatched_at
27
+ `)
28
+
29
+ return (
30
+ resultRows(result) as Array<{
31
+ id: number
32
+ topic: string
33
+ payload: unknown
34
+ created_at: Date | string
35
+ dispatched_at: Date | string | null
36
+ }>
37
+ ).map((row) => ({
38
+ id: Number(row.id),
39
+ name: row.topic as DomainEventName,
40
+ payload: row.payload,
41
+ dedupeKey: null,
42
+ createdAt: new Date(row.created_at),
43
+ relayedAt: row.dispatched_at === null ? null : new Date(row.dispatched_at),
44
+ }))
45
+ }
46
+
47
+ async markRelayed(ids: number[]): Promise<void> {
48
+ if (ids.length === 0) return
49
+ await this.db.update(outbox).set({ dispatchedAt: new Date() }).where(inArray(outbox.id, ids))
50
+ }
51
+
52
+ async stuck(limit = 50): Promise<OutboxRecord[]> {
53
+ const rows = await this.db.execute(sql`
54
+ select id, topic, payload, created_at, dispatched_at
55
+ from ${outbox}
56
+ where dispatched_at is null and attempts >= ${MAX_CLAIMS}
57
+ order by id
58
+ limit ${limit}
59
+ `)
60
+
61
+ return (
62
+ resultRows(rows) as Array<{
63
+ id: number
64
+ topic: string
65
+ payload: unknown
66
+ created_at: Date | string
67
+ dispatched_at: Date | string | null
68
+ }>
69
+ ).map((row) => ({
70
+ id: Number(row.id),
71
+ name: row.topic as DomainEventName,
72
+ payload: row.payload,
73
+ dedupeKey: null,
74
+ createdAt: new Date(row.created_at),
75
+ relayedAt: row.dispatched_at === null ? null : new Date(row.dispatched_at),
76
+ }))
77
+ }
78
+ }
@@ -0,0 +1,24 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+
5
+ export type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
6
+
7
+ export async function bumpPermissionVersion(tx: Tx): Promise<void> {
8
+ await tx.execute(sql`
9
+ insert into cache_versions (key, version) values ('permissions', 1)
10
+ on conflict (key) do update
11
+ set version = cache_versions.version + 1, bumped_at = now()
12
+ `)
13
+ }
14
+
15
+ export async function withPermissionVersionBump<T>(
16
+ db: Database,
17
+ work: (tx: Tx) => Promise<T>,
18
+ ): Promise<T> {
19
+ return db.transaction(async (tx) => {
20
+ const result = await work(tx)
21
+ await bumpPermissionVersion(tx)
22
+ return result
23
+ })
24
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ emptyPermissionSet,
3
+ FORUM_PERMISSION_FIELDS,
4
+ type ForumPermissions,
5
+ PERMISSION_FIELDS,
6
+ type PermissionField,
7
+ type PermissionSet,
8
+ } from '@meith/core'
9
+
10
+ export type PermissionRow = Record<string, unknown>
11
+
12
+ function coerceField(field: PermissionField, raw: unknown): boolean | number | undefined {
13
+ if (raw === null || raw === undefined) return undefined
14
+
15
+ if (field.kind === 'numeric') {
16
+ const n = typeof raw === 'number' ? raw : Number(raw)
17
+ if (!Number.isFinite(n)) {
18
+ throw new TypeError(
19
+ `Permission field "${field.key}" expected a number, got ${JSON.stringify(raw)}`,
20
+ )
21
+ }
22
+ return n
23
+ }
24
+
25
+ if (typeof raw === 'boolean') return raw
26
+ if (raw === 't' || raw === 1 || raw === '1') return true
27
+ if (raw === 'f' || raw === 0 || raw === '0') return false
28
+
29
+ throw new TypeError(
30
+ `Permission field "${field.key}" expected a boolean, got ${JSON.stringify(raw)}`,
31
+ )
32
+ }
33
+
34
+ export function groupRowToPermissionSet(row: PermissionRow): PermissionSet {
35
+ const out = emptyPermissionSet()
36
+ for (const field of PERMISSION_FIELDS) {
37
+ const value = coerceField(field, row[field.key])
38
+ if (value !== undefined) {
39
+ ;(out as Record<string, boolean | number>)[field.key] = value
40
+ }
41
+ }
42
+ return out
43
+ }
44
+
45
+ export function forumRowToOverride(row: PermissionRow): Partial<ForumPermissions> {
46
+ const out: Record<string, boolean | number> = {}
47
+ for (const field of FORUM_PERMISSION_FIELDS) {
48
+ const value = coerceField(field, row[field.key])
49
+ if (value !== undefined) out[field.key] = value
50
+ }
51
+ return out as Partial<ForumPermissions>
52
+ }
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { PGlite } from '@electric-sql/pglite'
6
+ import { drizzle } from 'drizzle-orm/pglite'
7
+
8
+ import type { Database } from './client'
9
+ import * as schema from './schema'
10
+
11
+ export interface QueryLog {
12
+ readonly count: number
13
+ readonly statements: readonly string[]
14
+ reset(): void
15
+ }
16
+
17
+ export interface TestDb {
18
+ readonly db: Database
19
+ readonly queries: QueryLog
20
+ /**
21
+ * The engine underneath, for the few tests that need to send it something
22
+ * drizzle cannot: `exec()` takes a script of several statements, which is how
23
+ * the migration files arrive and what a prepared statement refuses.
24
+ */
25
+ readonly client: PGlite
26
+ close(): Promise<void>
27
+ }
28
+
29
+ /** The checked-in migrations, as one script `client.exec()` will accept. */
30
+ export function migrationScript(): string {
31
+ return migrationSql().split('--> statement-breakpoint').join('\n')
32
+ }
33
+
34
+ function migrationSql(): string {
35
+ const here = path.dirname(fileURLToPath(import.meta.url))
36
+ const dir = path.resolve(here, '..', 'migrations')
37
+
38
+ const journal = JSON.parse(readFileSync(path.join(dir, 'meta', '_journal.json'), 'utf8')) as {
39
+ entries: { idx: number; tag: string }[]
40
+ }
41
+
42
+ return [...journal.entries]
43
+ .sort((a, b) => a.idx - b.idx)
44
+ .map((entry) => readFileSync(path.join(dir, `${entry.tag}.sql`), 'utf8'))
45
+ .join('\n')
46
+ }
47
+
48
+ export async function createTestDb(): Promise<TestDb> {
49
+ const client = new PGlite()
50
+
51
+ await client.exec(migrationScript())
52
+
53
+ const statements: string[] = []
54
+
55
+ const originalQuery = client.query.bind(client)
56
+ ;(client as unknown as { query: typeof originalQuery }).query = ((
57
+ sqlText: string,
58
+ ...rest: unknown[]
59
+ ) => {
60
+ statements.push(sqlText)
61
+ return (originalQuery as (...a: unknown[]) => unknown)(sqlText, ...rest)
62
+ }) as typeof originalQuery
63
+
64
+ const db = drizzle(client, {
65
+ schema,
66
+ casing: 'snake_case',
67
+ }) as unknown as Database
68
+
69
+ return {
70
+ db,
71
+ client,
72
+ queries: {
73
+ get count() {
74
+ return statements.length
75
+ },
76
+ get statements() {
77
+ return [...statements]
78
+ },
79
+ reset() {
80
+ statements.length = 0
81
+ },
82
+ },
83
+ async close() {
84
+ await client.close()
85
+ },
86
+ }
87
+ }
@@ -0,0 +1,88 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import type { PluginData } from '@meith/plugin-kit'
5
+
6
+ import type { Database } from './client'
7
+ import type { Tx } from './permission-version'
8
+ import { resultRows } from './result-rows'
9
+
10
+ export interface PluginDataOptions {
11
+ readonly statementTimeoutMs?: number
12
+ }
13
+
14
+ const DEFAULT_TIMEOUT_MS = 3_000
15
+
16
+ export function bindPluginSql(
17
+ text: string,
18
+ params: readonly unknown[],
19
+ where = 'plugin query',
20
+ ): SQL {
21
+ const out = sql.empty()
22
+ let last = 0
23
+
24
+ for (const match of text.matchAll(/\$(\d+)/g)) {
25
+ const index = Number(match[1]) - 1
26
+ if (index < 0 || index >= params.length) {
27
+ throw new ValidationError(
28
+ `${where}: the statement names $${match[1]} but only ${params.length} parameter(s) were passed.`,
29
+ )
30
+ }
31
+ out.append(sql.raw(text.slice(last, match.index)))
32
+ out.append(sql`${params[index]}`)
33
+ last = match.index + match[0].length
34
+ }
35
+
36
+ out.append(sql.raw(text.slice(last)))
37
+ return out
38
+ }
39
+
40
+ function onExecutor(executor: Tx, where: string): PluginData {
41
+ const run = async (text: string, params: readonly unknown[] = []) => {
42
+ return resultRows<Record<string, unknown>>(
43
+ await executor.execute(bindPluginSql(text, params, where)),
44
+ )
45
+ }
46
+
47
+ const data: PluginData = {
48
+ async query(text, params) {
49
+ return (await run(text, params ?? [])) as never
50
+ },
51
+ async one(text, params) {
52
+ const rows = await run(text, params ?? [])
53
+ return (rows[0] ?? null) as never
54
+ },
55
+ async tx(work) {
56
+ return work(data)
57
+ },
58
+ }
59
+ return data
60
+ }
61
+
62
+ export function pluginData(
63
+ db: Database,
64
+ pluginKey: string,
65
+ options: PluginDataOptions = {},
66
+ ): PluginData {
67
+ const timeoutMs = Math.max(1, Math.trunc(options.statementTimeoutMs ?? DEFAULT_TIMEOUT_MS))
68
+ const where = `plugin "${pluginKey}"`
69
+
70
+ const inTransaction = async <T>(work: (data: PluginData) => Promise<T>): Promise<T> => {
71
+ return db.transaction(async (tx) => {
72
+ await tx.execute(sql.raw(`set local statement_timeout = ${timeoutMs}`))
73
+ return work(onExecutor(tx, where))
74
+ })
75
+ }
76
+
77
+ return {
78
+ async query(text, params) {
79
+ return inTransaction(async (data) => data.query(text, params))
80
+ },
81
+ async one(text, params) {
82
+ return inTransaction(async (data) => data.one(text, params))
83
+ },
84
+ async tx(work) {
85
+ return inTransaction(work)
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,369 @@
1
+ import { and, eq, sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import type { PluginGrantRow, PluginGrants } from '@meith/plugin-kit'
5
+
6
+ import type { Database } from './client'
7
+ import { bumpPermissionVersion } from './permission-version'
8
+ import { groupRowToPermissionSet } from './permissions-map'
9
+ import { resultRows } from './result-rows'
10
+ import { userGroupMemberships, usergroups, users } from './schema'
11
+ import { permissionsCarryPower } from './staff-groups'
12
+
13
+ export {
14
+ PLUGIN_UNGRANTABLE_PERMISSIONS,
15
+ permissionsCarryPower,
16
+ type UngrantablePermission,
17
+ } from './staff-groups'
18
+
19
+ const MAX_GRANT_MS = 2 * 366 * 24 * 60 * 60 * 1000
20
+
21
+ export const DISPLACED_PRIMARY_REASON =
22
+ 'the group this member is primary in whenever no plugin grant stands in front of it'
23
+
24
+ interface GrantableGroup {
25
+ readonly id: number
26
+ readonly key: string
27
+ }
28
+
29
+ type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
30
+
31
+ export async function isStaffGroup(tx: Tx, groupId: number): Promise<boolean> {
32
+ const rows = resultRows(
33
+ await tx.execute(sql`select * from usergroups where id = ${groupId}`),
34
+ ) as Array<Record<string, unknown>>
35
+
36
+ const row = rows[0]
37
+ if (row === undefined) return false
38
+
39
+ return (
40
+ row.is_staff_group === true || permissionsCarryPower(groupRowToPermissionSet(camelise(row)))
41
+ )
42
+ }
43
+
44
+ async function promotePrimary(tx: Tx, userId: number, groupId: number): Promise<void> {
45
+ const userRows = resultRows(
46
+ await tx.execute(sql`select primary_group_id from users where id = ${userId} for update`),
47
+ ) as Array<{ primary_group_id: number }>
48
+
49
+ const current = userRows[0]
50
+ if (current === undefined) return
51
+
52
+ const held = Number(current.primary_group_id)
53
+ if (held === groupId) return
54
+ if (await isStaffGroup(tx, held)) return
55
+
56
+ const displacedRows = resultRows(
57
+ await tx.execute(sql`
58
+ select previous_primary_group_id
59
+ from user_group_memberships
60
+ where user_id = ${userId} and group_id = ${held}
61
+ `),
62
+ ) as Array<{ previous_primary_group_id: number | null }>
63
+
64
+ const rooted = displacedRows[0]?.previous_primary_group_id
65
+ const displaced = rooted === null || rooted === undefined ? held : Number(rooted)
66
+
67
+ await tx.execute(sql`
68
+ insert into user_group_memberships (user_id, group_id, grant_reason)
69
+ values (${userId}, ${displaced}, ${DISPLACED_PRIMARY_REASON})
70
+ on conflict (user_id, group_id) do nothing
71
+ `)
72
+
73
+ await tx.execute(sql`
74
+ update user_group_memberships
75
+ set previous_primary_group_id = ${displaced}
76
+ where user_id = ${userId} and group_id = ${groupId}
77
+ `)
78
+
79
+ await tx.execute(sql`
80
+ update users set primary_group_id = ${groupId}, updated_at = now() where id = ${userId}
81
+ `)
82
+ }
83
+
84
+ async function restorePrimary(
85
+ tx: Tx,
86
+ userId: number,
87
+ groupId: number,
88
+ displaced: number,
89
+ ): Promise<void> {
90
+ const changed = resultRows(
91
+ await tx.execute(sql`
92
+ update users
93
+ set primary_group_id = ${displaced},
94
+ display_group_id = case when display_group_id = ${groupId} then null else display_group_id end,
95
+ updated_at = now()
96
+ where id = ${userId} and primary_group_id = ${groupId}
97
+ returning id
98
+ `),
99
+ )
100
+
101
+ if (changed.length === 0) return
102
+
103
+ await tx.execute(sql`
104
+ delete from user_group_memberships
105
+ where user_id = ${userId}
106
+ and group_id = ${displaced}
107
+ and granted_by_plugin is null
108
+ and expires_at is null
109
+ and grant_reason = ${DISPLACED_PRIMARY_REASON}
110
+ `)
111
+ }
112
+
113
+ function camelise(row: Record<string, unknown>): Record<string, unknown> {
114
+ const out: Record<string, unknown> = {}
115
+ for (const [key, value] of Object.entries(row)) {
116
+ out[key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())] = value
117
+ }
118
+ return out
119
+ }
120
+
121
+ async function grantableGroup(
122
+ db: Database,
123
+ pluginKey: string,
124
+ groupKey: string,
125
+ ): Promise<GrantableGroup> {
126
+ const rows = resultRows(
127
+ await db.execute(sql`select * from usergroups where key = ${groupKey} limit 1`),
128
+ ) as Array<Record<string, unknown>>
129
+
130
+ const row = rows[0]
131
+ const where = `plugin "${pluginKey}"`
132
+
133
+ if (row === undefined) {
134
+ throw new ValidationError(`${where}: no group is keyed "${groupKey}".`)
135
+ }
136
+ if (row.is_system === true) {
137
+ throw new ValidationError(
138
+ `${where}: "${groupKey}" is a system group. The board resolves it by key; membership is not a plugin's to hand out.`,
139
+ )
140
+ }
141
+ if (row.is_staff_group === true) {
142
+ throw new ValidationError(
143
+ `${where}: "${groupKey}" is a staff group, and staff is appointed, not granted by code.`,
144
+ )
145
+ }
146
+ if (permissionsCarryPower(groupRowToPermissionSet(camelise(row)))) {
147
+ throw new ValidationError(
148
+ `${where}: "${groupKey}" carries administrative or moderation power, so no plugin may grant it.`,
149
+ )
150
+ }
151
+ if (row.plugin_grantable !== true) {
152
+ throw new ValidationError(
153
+ `${where}: "${groupKey}" is not marked as grantable by plugins. An administrator opts a group in under Admin → Groups.`,
154
+ )
155
+ }
156
+
157
+ return { id: Number(row.id), key: String(row.key) }
158
+ }
159
+
160
+ function checkedUntil(pluginKey: string, until: Date, now: Date): Date {
161
+ if (!(until instanceof Date) || Number.isNaN(until.getTime())) {
162
+ throw new ValidationError(`plugin "${pluginKey}": the grant needs a valid expiry date.`)
163
+ }
164
+ if (until.getTime() <= now.getTime()) {
165
+ throw new ValidationError(
166
+ `plugin "${pluginKey}": a grant expiring in the past is a grant that never was. Got ${until.toISOString()}.`,
167
+ )
168
+ }
169
+ if (until.getTime() - now.getTime() > MAX_GRANT_MS) {
170
+ throw new ValidationError(
171
+ `plugin "${pluginKey}": grants are capped at two years. A longer arrangement is an operator decision, made in the panel.`,
172
+ )
173
+ }
174
+ return until
175
+ }
176
+
177
+ export function pluginGrants(
178
+ db: Database,
179
+ pluginKey: string,
180
+ clock: () => Date = () => new Date(),
181
+ ): PluginGrants {
182
+ return {
183
+ async grant({ userId, groupKey, until, reason, primary }) {
184
+ const now = clock()
185
+ const expiry = checkedUntil(pluginKey, until, now)
186
+ if (reason.trim() === '') {
187
+ throw new ValidationError(
188
+ `plugin "${pluginKey}": a grant needs a reason — it is the row's audit trail.`,
189
+ )
190
+ }
191
+
192
+ const group = await grantableGroup(db, pluginKey, groupKey)
193
+
194
+ const userRows = await db
195
+ .select({ id: users.id })
196
+ .from(users)
197
+ .where(eq(users.id, userId))
198
+ .limit(1)
199
+ if (userRows.length === 0) {
200
+ throw new ValidationError(`plugin "${pluginKey}": no user ${userId}.`)
201
+ }
202
+
203
+ await db.transaction(async (tx) => {
204
+ const existing = await tx
205
+ .select({
206
+ grantedByPlugin: userGroupMemberships.grantedByPlugin,
207
+ expiresAt: userGroupMemberships.expiresAt,
208
+ })
209
+ .from(userGroupMemberships)
210
+ .where(
211
+ and(
212
+ eq(userGroupMemberships.userId, userId),
213
+ eq(userGroupMemberships.groupId, group.id),
214
+ ),
215
+ )
216
+ .limit(1)
217
+
218
+ const current = existing[0]
219
+ if (current === undefined) {
220
+ await tx.insert(userGroupMemberships).values({
221
+ userId,
222
+ groupId: group.id,
223
+ expiresAt: expiry,
224
+ grantedByPlugin: pluginKey,
225
+ grantReason: reason,
226
+ })
227
+ if (primary === true) await promotePrimary(tx, userId, group.id)
228
+ await bumpPermissionVersion(tx)
229
+ return
230
+ }
231
+
232
+ if (current.grantedByPlugin !== pluginKey) {
233
+ throw new ValidationError(
234
+ `plugin "${pluginKey}": user ${userId} is already a member of "${groupKey}" by someone else's hand. That membership is not this plugin's to change.`,
235
+ )
236
+ }
237
+
238
+ let touched = false
239
+ if (current.expiresAt === null || current.expiresAt.getTime() < expiry.getTime()) {
240
+ await tx
241
+ .update(userGroupMemberships)
242
+ .set({ expiresAt: expiry, grantReason: reason })
243
+ .where(
244
+ and(
245
+ eq(userGroupMemberships.userId, userId),
246
+ eq(userGroupMemberships.groupId, group.id),
247
+ ),
248
+ )
249
+ touched = true
250
+ }
251
+
252
+ if (primary === true) {
253
+ await promotePrimary(tx, userId, group.id)
254
+ touched = true
255
+ }
256
+
257
+ if (touched) await bumpPermissionVersion(tx)
258
+ })
259
+ },
260
+
261
+ async extend({ userId, groupKey, until }) {
262
+ const now = clock()
263
+ const expiry = checkedUntil(pluginKey, until, now)
264
+ const group = await grantableGroup(db, pluginKey, groupKey)
265
+
266
+ await db.transaction(async (tx) => {
267
+ const result = await tx.execute(sql`
268
+ update user_group_memberships
269
+ set expires_at = ${expiry}
270
+ where user_id = ${userId}
271
+ and group_id = ${group.id}
272
+ and granted_by_plugin = ${pluginKey}
273
+ and expires_at < ${expiry}
274
+ returning user_id
275
+ `)
276
+ void resultRows(result)
277
+ })
278
+ },
279
+
280
+ async revoke({ userId, groupKey, reason }) {
281
+ if (reason.trim() === '') {
282
+ throw new ValidationError(
283
+ `plugin "${pluginKey}": a revocation needs a reason, for the same audit trail as the grant.`,
284
+ )
285
+ }
286
+
287
+ const groupRows = await db
288
+ .select({ id: usergroups.id })
289
+ .from(usergroups)
290
+ .where(eq(usergroups.key, groupKey))
291
+ .limit(1)
292
+ const group = groupRows[0]
293
+ if (group === undefined) return
294
+
295
+ await db.transaction(async (tx) => {
296
+ const result = await tx.execute(sql`
297
+ delete from user_group_memberships
298
+ where user_id = ${userId}
299
+ and group_id = ${group.id}
300
+ and granted_by_plugin = ${pluginKey}
301
+ returning previous_primary_group_id
302
+ `)
303
+ const gone = resultRows(result) as Array<{ previous_primary_group_id: number | null }>
304
+ if (gone.length === 0) return
305
+
306
+ const displaced = gone[0]?.previous_primary_group_id
307
+ if (displaced !== null && displaced !== undefined) {
308
+ await restorePrimary(tx, userId, group.id, Number(displaced))
309
+ }
310
+ await bumpPermissionVersion(tx)
311
+ })
312
+ },
313
+
314
+ async list(userId) {
315
+ const rows = await db
316
+ .select({
317
+ groupKey: usergroups.key,
318
+ expiresAt: userGroupMemberships.expiresAt,
319
+ })
320
+ .from(userGroupMemberships)
321
+ .innerJoin(usergroups, eq(usergroups.id, userGroupMemberships.groupId))
322
+ .where(
323
+ and(
324
+ eq(userGroupMemberships.userId, userId),
325
+ eq(userGroupMemberships.grantedByPlugin, pluginKey),
326
+ ),
327
+ )
328
+
329
+ return rows
330
+ .filter((row): row is { groupKey: string; expiresAt: Date } => row.expiresAt !== null)
331
+ .map((row): PluginGrantRow => ({ groupKey: row.groupKey, expiresAt: row.expiresAt }))
332
+ },
333
+ }
334
+ }
335
+
336
+ export async function expireTimedGroupMemberships(db: Database, limit: number): Promise<number> {
337
+ return db.transaction(async (tx) => {
338
+ const result = await tx.execute(sql`
339
+ delete from user_group_memberships
340
+ where (user_id, group_id) in (
341
+ select user_id, group_id
342
+ from user_group_memberships
343
+ where expires_at is not null
344
+ and expires_at <= now()
345
+ limit ${limit}
346
+ )
347
+ returning user_id, group_id, previous_primary_group_id
348
+ `)
349
+
350
+ const gone = resultRows(result) as Array<{
351
+ user_id: number
352
+ group_id: number
353
+ previous_primary_group_id: number | null
354
+ }>
355
+
356
+ for (const row of gone) {
357
+ if (row.previous_primary_group_id === null) continue
358
+ await restorePrimary(
359
+ tx,
360
+ Number(row.user_id),
361
+ Number(row.group_id),
362
+ Number(row.previous_primary_group_id),
363
+ )
364
+ }
365
+
366
+ if (gone.length > 0) await bumpPermissionVersion(tx)
367
+ return gone.length
368
+ })
369
+ }