@meith/db 0.31.0 → 0.32.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 (41) hide show
  1. package/migrations/0060_webhook_payload_format.sql +1 -0
  2. package/migrations/0061_task_schedule.sql +1 -0
  3. package/migrations/0062_pretty_zombie.sql +12 -0
  4. package/migrations/0063_board_digest.sql +3 -0
  5. package/migrations/0064_redundant_lady_bullseye.sql +2 -0
  6. package/migrations/0065_serious_puff_adder.sql +1 -0
  7. package/migrations/meta/0061_snapshot.json +9515 -0
  8. package/migrations/meta/0062_snapshot.json +9617 -0
  9. package/migrations/meta/0063_snapshot.json +9653 -0
  10. package/migrations/meta/0064_snapshot.json +9667 -0
  11. package/migrations/meta/0065_snapshot.json +9674 -0
  12. package/migrations/meta/_journal.json +42 -0
  13. package/package.json +31 -30
  14. package/src/account-repos.ts +32 -15
  15. package/src/api-repo.ts +192 -21
  16. package/src/attachment-repo.ts +26 -0
  17. package/src/board-digest-repo.ts +45 -0
  18. package/src/content-counters.ts +14 -0
  19. package/src/discovery-repo.ts +22 -0
  20. package/src/draft-repo.ts +40 -1
  21. package/src/feed-token-repo.ts +103 -0
  22. package/src/forum-admin-repo.ts +43 -18
  23. package/src/index.ts +10 -0
  24. package/src/member-settings-repo.ts +25 -1
  25. package/src/moderation-queue.ts +61 -21
  26. package/src/plugin-data.ts +19 -0
  27. package/src/plugin-grants.ts +83 -0
  28. package/src/plugin-role.ts +79 -0
  29. package/src/post-repo.ts +26 -0
  30. package/src/post-writes.ts +32 -2
  31. package/src/presence-repo.ts +29 -1
  32. package/src/read-state-repo.ts +21 -1
  33. package/src/report-repo.ts +151 -7
  34. package/src/schema/content.ts +5 -0
  35. package/src/schema/identity.ts +33 -1
  36. package/src/schema/platform.ts +2 -0
  37. package/src/search-repo.ts +48 -17
  38. package/src/task-repo.ts +19 -7
  39. package/src/thread-writes.ts +7 -5
  40. package/src/upgrade-repo.ts +15 -7
  41. package/src/user-merge-map.ts +1 -0
@@ -0,0 +1,103 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { FeedTokenRecord } from '@meith/api'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { toDate } from './row-values'
8
+
9
+ export interface FeedTokenSummary {
10
+ readonly userId: number
11
+ readonly lookup: string
12
+ readonly createdAt: Date
13
+ readonly lastUsedAt: Date | null
14
+ }
15
+
16
+ const TOUCH_INTERVAL_MS = 60_000
17
+
18
+ export class PostgresFeedTokenRepository {
19
+ constructor(private readonly db: Database) {}
20
+
21
+ async findByLookup(lookup: string): Promise<FeedTokenRecord | null> {
22
+ const rows = resultRows<{
23
+ id: number
24
+ user_id: number
25
+ lookup: string
26
+ secret_hash: string
27
+ }>(
28
+ await this.db.execute(sql`
29
+ select id, user_id, lookup, secret_hash
30
+ from feed_tokens
31
+ where lookup = ${lookup}
32
+ limit 1
33
+ `),
34
+ )
35
+
36
+ const row = rows[0]
37
+ if (row === undefined) return null
38
+
39
+ return {
40
+ id: Number(row.id),
41
+ userId: Number(row.user_id),
42
+ lookup: row.lookup,
43
+ secretHash: row.secret_hash,
44
+ }
45
+ }
46
+
47
+ async summaryForUser(userId: number): Promise<FeedTokenSummary | null> {
48
+ const rows = resultRows<{
49
+ user_id: number
50
+ lookup: string
51
+ created_at: Date | string
52
+ last_used_at: Date | string | null
53
+ }>(
54
+ await this.db.execute(sql`
55
+ select user_id, lookup, created_at, last_used_at
56
+ from feed_tokens
57
+ where user_id = ${userId}
58
+ limit 1
59
+ `),
60
+ )
61
+
62
+ const row = rows[0]
63
+ if (row === undefined) return null
64
+
65
+ return {
66
+ userId: Number(row.user_id),
67
+ lookup: row.lookup,
68
+ createdAt: toDate(row.created_at),
69
+ lastUsedAt: row.last_used_at === null ? null : toDate(row.last_used_at),
70
+ }
71
+ }
72
+
73
+ async regenerate(input: {
74
+ readonly userId: number
75
+ readonly lookup: string
76
+ readonly secretHash: string
77
+ }): Promise<void> {
78
+ await this.db.execute(sql`
79
+ insert into feed_tokens (user_id, lookup, secret_hash, created_at, last_used_at)
80
+ values (${input.userId}, ${input.lookup}, ${input.secretHash}, now(), null)
81
+ on conflict (user_id) do update
82
+ set lookup = excluded.lookup,
83
+ secret_hash = excluded.secret_hash,
84
+ created_at = now(),
85
+ last_used_at = null
86
+ `)
87
+ }
88
+
89
+ async revokeForUser(userId: number): Promise<void> {
90
+ await this.db.execute(sql`
91
+ delete from feed_tokens where user_id = ${userId}
92
+ `)
93
+ }
94
+
95
+ async touch(id: number, at: Date): Promise<void> {
96
+ await this.db.execute(sql`
97
+ update feed_tokens
98
+ set last_used_at = ${at}
99
+ where id = ${id}
100
+ and (last_used_at is null or last_used_at < ${new Date(at.getTime() - TOUCH_INTERVAL_MS)})
101
+ `)
102
+ }
103
+ }
@@ -227,32 +227,57 @@ export class PostgresForumAdminRepository {
227
227
  })
228
228
  }
229
229
 
230
+ private async writeOverrides(
231
+ executor: Pick<Database, 'execute'>,
232
+ forumId: number,
233
+ groupId: number,
234
+ values: Readonly<Record<string, boolean | number | null>>,
235
+ ): Promise<void> {
236
+ if (FORUM_PERMISSION_FIELDS.every((field) => values[field.key] == null)) {
237
+ await executor.execute(sql`
238
+ delete from forum_permissions
239
+ where forum_id = ${forumId} and group_id = ${groupId}
240
+ `)
241
+ return
242
+ }
243
+
244
+ const columns = FORUM_PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
245
+ const literals = FORUM_PERMISSION_FIELDS.map((field) => sql`${values[field.key] ?? null}`)
246
+ const assignments = FORUM_PERMISSION_FIELDS.map(
247
+ (field) =>
248
+ sql`${sql.raw(columnName(field.key))} = excluded.${sql.raw(columnName(field.key))}`,
249
+ )
250
+
251
+ await executor.execute(sql`
252
+ insert into forum_permissions (forum_id, group_id, ${sql.join(columns, sql`, `)})
253
+ values (${forumId}, ${groupId}, ${sql.join(literals, sql`, `)})
254
+ on conflict (forum_id, group_id) do update set ${sql.join(assignments, sql`, `)}
255
+ `)
256
+ }
257
+
230
258
  async saveOverrides(
231
259
  forumId: number,
232
260
  groupId: number,
233
261
  values: Readonly<Record<string, boolean | number | null>>,
234
262
  ): Promise<void> {
235
263
  await this.db.transaction(async (tx) => {
236
- if (FORUM_PERMISSION_FIELDS.every((field) => values[field.key] == null)) {
237
- await tx.execute(sql`
238
- delete from forum_permissions
239
- where forum_id = ${forumId} and group_id = ${groupId}
240
- `)
241
- return
242
- }
264
+ await this.writeOverrides(tx, forumId, groupId, values)
265
+ })
266
+ }
243
267
 
244
- const columns = FORUM_PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
245
- const literals = FORUM_PERMISSION_FIELDS.map((field) => sql`${values[field.key] ?? null}`)
246
- const assignments = FORUM_PERMISSION_FIELDS.map(
247
- (field) =>
248
- sql`${sql.raw(columnName(field.key))} = excluded.${sql.raw(columnName(field.key))}`,
249
- )
268
+ async saveOverridesForGroups(
269
+ forumId: number,
270
+ changes: readonly {
271
+ readonly groupId: number
272
+ readonly values: Readonly<Record<string, boolean | number | null>>
273
+ }[],
274
+ ): Promise<void> {
275
+ if (changes.length === 0) return
250
276
 
251
- await tx.execute(sql`
252
- insert into forum_permissions (forum_id, group_id, ${sql.join(columns, sql`, `)})
253
- values (${forumId}, ${groupId}, ${sql.join(literals, sql`, `)})
254
- on conflict (forum_id, group_id) do update set ${sql.join(assignments, sql`, `)}
255
- `)
277
+ await this.db.transaction(async (tx) => {
278
+ for (const change of changes) {
279
+ await this.writeOverrides(tx, forumId, change.groupId, change.values)
280
+ }
256
281
  })
257
282
  }
258
283
 
package/src/index.ts CHANGED
@@ -36,7 +36,10 @@ export {
36
36
  PostgresApiTokenRepository,
37
37
  PostgresRateLimitStore,
38
38
  PostgresWebhookRepository,
39
+ type WebhookActiveSubscription,
40
+ type WebhookDeliveryLogRow,
39
41
  type WebhookDeliveryRow,
42
+ type WebhookSummary,
40
43
  } from './api-repo'
41
44
  export {
42
45
  type AttachmentAdminFilter,
@@ -55,6 +58,7 @@ export {
55
58
  PostgresBanFilterRepository,
56
59
  PostgresBanRepository,
57
60
  } from './ban-repos'
61
+ export { PostgresBoardDigestRepository } from './board-digest-repo'
58
62
  export {
59
63
  closeDb,
60
64
  createIsolatedDb,
@@ -97,6 +101,7 @@ export {
97
101
  type SitemapForum,
98
102
  type SitemapThread,
99
103
  } from './feed-repo'
104
+ export { type FeedTokenSummary, PostgresFeedTokenRepository } from './feed-token-repo'
100
105
  export {
101
106
  type AppointModeratorInput,
102
107
  type ForumOptionsInput,
@@ -203,11 +208,13 @@ export {
203
208
  pluginOwnedTables,
204
209
  purgePlugin,
205
210
  } from './plugin-purge-repo'
211
+ export { ensurePluginDataRole, pluginDbRole } from './plugin-role'
206
212
  export { pluginUsers } from './plugin-users'
207
213
  export { PostgresPollRepository } from './poll-repo'
208
214
  export { PostgresPostRepository } from './post-repo'
209
215
  export { PostgresPostWriteRepository } from './post-writes'
210
216
  export {
217
+ type MemberPresence,
211
218
  ONLINE_WINDOW_MINUTES,
212
219
  type OnlineMember,
213
220
  type OnlineRecord,
@@ -230,9 +237,12 @@ export { resultRows } from './result-rows'
230
237
  export * from './schema'
231
238
  export { expectedTables, missingTables } from './schema-state'
232
239
  export {
240
+ DEFAULT_SEARCH_CONFIG,
233
241
  indexedSubjectSql,
234
242
  PostgresSearchRepository,
235
243
  type ReindexResult,
244
+ readSearchConfig,
245
+ resolveSearchConfig,
236
246
  SEARCH_DOCUMENT_VERSION,
237
247
  SEARCH_WINDOW,
238
248
  searchVectorSql,
@@ -1,6 +1,7 @@
1
1
  import { sql } from 'drizzle-orm'
2
2
 
3
3
  import type { MemberGroupChoice, MemberSettings, MemberSettingsRepository } from '@meith/accounts'
4
+ import { parseSubscriptionMode } from '@meith/subscriptions'
4
5
 
5
6
  import type { Database } from './client'
6
7
  import { resultRows } from './result-rows'
@@ -19,6 +20,9 @@ interface RawSettings {
19
20
  bio: string | null
20
21
  display_group_id: number | null
21
22
  mass_mail_opt_in_at: Date | string | null
23
+ board_digest_cadence: string
24
+ auto_watch_own_threads: string
25
+ auto_watch_replied_threads: string
22
26
  }
23
27
 
24
28
  interface RawGroupChoice {
@@ -36,7 +40,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
36
40
  await this.db.execute(sql`
37
41
  select id, email, timezone, locale, posts_per_page, threads_per_page,
38
42
  invisible, location, website, bio, display_group_id,
39
- mass_mail_opt_in_at
43
+ mass_mail_opt_in_at, board_digest_cadence,
44
+ auto_watch_own_threads, auto_watch_replied_threads
40
45
  from users
41
46
  where id = ${userId} and state <> 'deleted'
42
47
  `),
@@ -58,6 +63,9 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
58
63
  bio: row.bio,
59
64
  displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
60
65
  massMailOptInAt: row.mass_mail_opt_in_at === null ? null : new Date(row.mass_mail_opt_in_at),
66
+ boardDigestCadence: row.board_digest_cadence,
67
+ autoWatchOwnThreads: parseSubscriptionMode(row.auto_watch_own_threads) ?? 'none',
68
+ autoWatchRepliedThreads: parseSubscriptionMode(row.auto_watch_replied_threads) ?? 'none',
61
69
  }
62
70
  }
63
71
 
@@ -123,6 +131,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
123
131
  readonly postsPerPage: number | null
124
132
  readonly threadsPerPage: number | null
125
133
  readonly invisible: boolean
134
+ readonly autoWatchOwnThreads: string
135
+ readonly autoWatchRepliedThreads: string
126
136
  }): Promise<void> {
127
137
  await this.db.execute(sql`
128
138
  update users
@@ -131,6 +141,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
131
141
  posts_per_page = ${input.postsPerPage},
132
142
  threads_per_page = ${input.threadsPerPage},
133
143
  invisible = ${input.invisible},
144
+ auto_watch_own_threads = ${input.autoWatchOwnThreads},
145
+ auto_watch_replied_threads = ${input.autoWatchRepliedThreads},
134
146
  updated_at = now()
135
147
  where id = ${input.userId}
136
148
  `)
@@ -150,6 +162,18 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
150
162
  `)
151
163
  }
152
164
 
165
+ async saveBoardDigestCadence(input: {
166
+ readonly userId: number
167
+ readonly cadence: string
168
+ }): Promise<void> {
169
+ await this.db.execute(sql`
170
+ update users
171
+ set board_digest_cadence = ${input.cadence},
172
+ updated_at = now()
173
+ where id = ${input.userId} and state <> 'deleted'
174
+ `)
175
+ }
176
+
153
177
  async adoptEmail(input: {
154
178
  readonly userId: number
155
179
  readonly email: string
@@ -66,6 +66,37 @@ function toItem(row: QueueRow): QueueItem {
66
66
  }
67
67
  }
68
68
 
69
+ async function flagHeldPosts(
70
+ tx: { execute(query: ReturnType<typeof sql>): Promise<unknown> },
71
+ threadIds: readonly number[],
72
+ ): Promise<Map<number, number[]>> {
73
+ const map = new Map<number, number[]>()
74
+ if (threadIds.length === 0) return map
75
+
76
+ const rows = resultRows(
77
+ await tx.execute(sql`
78
+ select (l.detail->>'threadId')::int as thread_id, l.detail as detail
79
+ from admin_log l
80
+ where l.action = 'post.autohold'
81
+ and (l.detail->>'threadId')::int in ${idList(threadIds)}
82
+ order by l.created_at desc, l.id desc
83
+ `),
84
+ ) as Array<{ thread_id: number; detail: Record<string, unknown> | null }>
85
+
86
+ for (const row of rows) {
87
+ const threadId = Number(row.thread_id)
88
+ if (map.has(threadId)) continue
89
+ const ids = row.detail?.heldPostIds
90
+ if (Array.isArray(ids)) {
91
+ map.set(
92
+ threadId,
93
+ ids.map((value) => Number(value)).filter((value) => Number.isSafeInteger(value)),
94
+ )
95
+ }
96
+ }
97
+ return map
98
+ }
99
+
69
100
  export class PostgresModerationQueueRepository implements ModerationQueueRepository {
70
101
  constructor(private readonly db: Database) {}
71
102
 
@@ -183,40 +214,49 @@ export class PostgresModerationQueueRepository implements ModerationQueueReposit
183
214
  let applied = 0
184
215
  const touched = new Set<number>()
185
216
 
217
+ const heldByThread = await flagHeldPosts(tx, input.threadIds)
218
+
186
219
  for (const threadId of input.threadIds) {
187
220
  const moved = resultRows(
188
221
  await tx.execute(sql`
189
222
  update threads set visibility = ${to}, updated_at = now()
190
223
  where id = ${threadId} and visibility = ${PENDING_APPROVAL}
191
- returning id, forum_id, first_post_id, author_user_id
224
+ returning id, forum_id, first_post_id
192
225
  `),
193
- ) as Array<{
194
- id: number
195
- forum_id: number
196
- first_post_id: number | null
197
- author_user_id: number | null
198
- }>
226
+ ) as Array<{ id: number; forum_id: number; first_post_id: number | null }>
199
227
  const thread = moved[0]
200
228
  if (!thread) continue
201
229
  applied += 1
202
- touched.add(Number(thread.forum_id))
230
+ const forumId = Number(thread.forum_id)
231
+ touched.add(forumId)
232
+
233
+ const held = heldByThread.get(threadId) ?? []
234
+ const targetIds =
235
+ held.length > 0
236
+ ? held
237
+ : thread.first_post_id === null
238
+ ? []
239
+ : [Number(thread.first_post_id)]
240
+ if (targetIds.length === 0) continue
203
241
 
204
- if (thread.first_post_id !== null) {
205
- const post = resultRows(
206
- await tx.execute(sql`
207
- update posts set visibility = ${to}
208
- where id = ${thread.first_post_id} and visibility = ${PENDING_APPROVAL}
209
- returning id
210
- `),
211
- ) as Array<{ id: number }>
242
+ const posts = resultRows(
243
+ await tx.execute(sql`
244
+ update posts set visibility = ${to}
245
+ where thread_id = ${threadId}
246
+ and id in ${idList(targetIds)}
247
+ and visibility = ${PENDING_APPROVAL}
248
+ returning id, author_user_id, is_first_post
249
+ `),
250
+ ) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
212
251
 
213
- if (post[0] && approving) {
252
+ if (approving) {
253
+ for (const post of posts) {
214
254
  await applyVisibilityChangeCounters(tx, {
215
- postId: Number(post[0].id),
255
+ postId: Number(post.id),
216
256
  threadId: Number(thread.id),
217
- forumId: Number(thread.forum_id),
218
- authorId: thread.author_user_id === null ? null : Number(thread.author_user_id),
219
- isFirstPost: true,
257
+ forumId,
258
+ authorId: post.author_user_id === null ? null : Number(post.author_user_id),
259
+ isFirstPost: post.is_first_post === true,
220
260
  delta: 1,
221
261
  })
222
262
  }
@@ -5,6 +5,7 @@ import type { PluginData } from '@meith/plugin-kit'
5
5
 
6
6
  import type { Database } from './client'
7
7
  import type { Tx } from './permission-version'
8
+ import { ensurePluginDataRole, pluginDbRole } from './plugin-role'
8
9
  import { resultRows } from './result-rows'
9
10
 
10
11
  export interface PluginDataOptions {
@@ -13,6 +14,21 @@ export interface PluginDataOptions {
13
14
 
14
15
  const DEFAULT_TIMEOUT_MS = 3_000
15
16
 
17
+ const ensuredRoles = new WeakMap<Database, Set<string>>()
18
+
19
+ async function ensureRoleOnce(db: Database, pluginKey: string): Promise<void> {
20
+ let seen = ensuredRoles.get(db)
21
+ if (seen === undefined) {
22
+ seen = new Set<string>()
23
+ ensuredRoles.set(db, seen)
24
+ }
25
+ if (seen.has(pluginKey)) return
26
+ try {
27
+ await db.transaction((tx) => ensurePluginDataRole(tx, pluginKey))
28
+ } catch {}
29
+ seen.add(pluginKey)
30
+ }
31
+
16
32
  export function bindPluginSql(
17
33
  text: string,
18
34
  params: readonly unknown[],
@@ -66,10 +82,13 @@ export function pluginData(
66
82
  ): PluginData {
67
83
  const timeoutMs = Math.max(1, Math.trunc(options.statementTimeoutMs ?? DEFAULT_TIMEOUT_MS))
68
84
  const where = `plugin "${pluginKey}"`
85
+ const role = pluginDbRole(pluginKey)
69
86
 
70
87
  const inTransaction = async <T>(work: (data: PluginData) => Promise<T>): Promise<T> => {
88
+ await ensureRoleOnce(db, pluginKey)
71
89
  return db.transaction(async (tx) => {
72
90
  await tx.execute(sql.raw(`set local statement_timeout = ${timeoutMs}`))
91
+ await tx.execute(sql.raw(`set local role "${role.replace(/"/g, '""')}"`))
73
92
  return work(onExecutor(tx, where))
74
93
  })
75
94
  }
@@ -157,6 +157,38 @@ async function grantableGroup(
157
157
  return { id: Number(row.id), key: String(row.key) }
158
158
  }
159
159
 
160
+ async function readableGroup(
161
+ db: Database,
162
+ pluginKey: string,
163
+ groupKey: string,
164
+ ): Promise<GrantableGroup | null> {
165
+ const rows = resultRows(
166
+ await db.execute(sql`select * from usergroups where key = ${groupKey} limit 1`),
167
+ ) as Array<Record<string, unknown>>
168
+
169
+ const row = rows[0]
170
+ if (row === undefined || row.plugin_grantable !== true) return null
171
+
172
+ const where = `plugin "${pluginKey}"`
173
+ if (row.is_system === true) {
174
+ throw new ValidationError(
175
+ `${where}: "${groupKey}" is a system group. The board resolves it by key; its membership is not a plugin's to read.`,
176
+ )
177
+ }
178
+ if (row.is_staff_group === true) {
179
+ throw new ValidationError(
180
+ `${where}: "${groupKey}" is a staff group, and staff standing is not a plugin's to read.`,
181
+ )
182
+ }
183
+ if (permissionsCarryPower(groupRowToPermissionSet(camelise(row)))) {
184
+ throw new ValidationError(
185
+ `${where}: "${groupKey}" carries administrative or moderation power, so no plugin may read its membership.`,
186
+ )
187
+ }
188
+
189
+ return { id: Number(row.id), key: String(row.key) }
190
+ }
191
+
160
192
  function checkedUntil(pluginKey: string, until: Date, now: Date): Date {
161
193
  if (!(until instanceof Date) || Number.isNaN(until.getTime())) {
162
194
  throw new ValidationError(`plugin "${pluginKey}": the grant needs a valid expiry date.`)
@@ -330,6 +362,57 @@ export function pluginGrants(
330
362
  .filter((row): row is { groupKey: string; expiresAt: Date } => row.expiresAt !== null)
331
363
  .map((row): PluginGrantRow => ({ groupKey: row.groupKey, expiresAt: row.expiresAt }))
332
364
  },
365
+
366
+ async holds(userId, groupKey) {
367
+ const group = await readableGroup(db, pluginKey, groupKey)
368
+ if (group === null) return false
369
+
370
+ const now = clock()
371
+
372
+ const userRows = resultRows(
373
+ await db.execute(
374
+ sql`select primary_group_id from users where id = ${userId} and deleted_at is null limit 1`,
375
+ ),
376
+ ) as Array<{ primary_group_id: number }>
377
+ const user = userRows[0]
378
+ if (user === undefined) return false
379
+
380
+ const membershipRows = resultRows(
381
+ await db.execute(sql`
382
+ select group_id, expires_at, previous_primary_group_id
383
+ from user_group_memberships
384
+ where user_id = ${userId}
385
+ `),
386
+ ) as Array<{
387
+ group_id: number
388
+ expires_at: Date | string | null
389
+ previous_primary_group_id: number | null
390
+ }>
391
+
392
+ const rows = membershipRows.map((row) => ({
393
+ groupId: Number(row.group_id),
394
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
395
+ previousPrimary:
396
+ row.previous_primary_group_id === null ? null : Number(row.previous_primary_group_id),
397
+ }))
398
+
399
+ const held = Number(user.primary_group_id)
400
+ const lapsed = rows.find(
401
+ (row) =>
402
+ row.groupId === held &&
403
+ row.previousPrimary !== null &&
404
+ row.expiresAt !== null &&
405
+ row.expiresAt.getTime() <= now.getTime(),
406
+ )
407
+ const effectivePrimary = lapsed?.previousPrimary ?? held
408
+ if (effectivePrimary === group.id) return true
409
+
410
+ return rows.some(
411
+ (row) =>
412
+ row.groupId === group.id &&
413
+ (row.expiresAt === null || row.expiresAt.getTime() > now.getTime()),
414
+ )
415
+ },
333
416
  }
334
417
  }
335
418
 
@@ -0,0 +1,79 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { pluginTablePrefix } from '@meith/plugin-kit'
4
+
5
+ import type { Tx } from './permission-version'
6
+ import { resultRows } from './result-rows'
7
+
8
+ const ROLE_PATTERN = /^plugin_[a-z][a-z0-9_]{1,63}$/
9
+
10
+ export function pluginDbRole(pluginKey: string): string {
11
+ const role = `plugin_${pluginKey.replace(/-/g, '_')}`
12
+ if (!ROLE_PATTERN.test(role)) {
13
+ throw new Error(`plugin key "${pluginKey}" does not map to a usable database role name.`)
14
+ }
15
+ return role
16
+ }
17
+
18
+ function quoteIdentifier(name: string): string {
19
+ return `"${name.replace(/"/g, '""')}"`
20
+ }
21
+
22
+ function quoteLiteral(value: string): string {
23
+ return `'${value.replace(/'/g, "''")}'`
24
+ }
25
+
26
+ export async function ensurePluginDataRole(executor: Tx, pluginKey: string): Promise<void> {
27
+ const role = pluginDbRole(pluginKey)
28
+ const roleIdent = quoteIdentifier(role)
29
+ const prefix = pluginTablePrefix(pluginKey)
30
+
31
+ await executor.execute(
32
+ sql.raw(
33
+ `do $meith$ begin
34
+ if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(role)}) then
35
+ create role ${roleIdent} with nologin;
36
+ end if;
37
+ exception when duplicate_object then null;
38
+ end $meith$;`,
39
+ ),
40
+ )
41
+
42
+ await executor.execute(sql.raw(`grant ${roleIdent} to current_user`))
43
+ await executor.execute(sql.raw(`grant usage on schema public to ${roleIdent}`))
44
+
45
+ const tables = resultRows(
46
+ await executor.execute(sql`
47
+ select table_name
48
+ from information_schema.tables
49
+ where table_schema = 'public'
50
+ and table_type = 'BASE TABLE'
51
+ and table_name like ${`${prefix}%`}
52
+ `),
53
+ ) as Array<{ table_name: string }>
54
+
55
+ const sequences = resultRows(
56
+ await executor.execute(sql`
57
+ select sequence_name
58
+ from information_schema.sequences
59
+ where sequence_schema = 'public'
60
+ and sequence_name like ${`${prefix}%`}
61
+ `),
62
+ ) as Array<{ sequence_name: string }>
63
+
64
+ for (const { table_name } of tables) {
65
+ await executor.execute(
66
+ sql.raw(
67
+ `grant select, insert, update, delete on ${quoteIdentifier(String(table_name))} to ${roleIdent}`,
68
+ ),
69
+ )
70
+ }
71
+
72
+ for (const { sequence_name } of sequences) {
73
+ await executor.execute(
74
+ sql.raw(
75
+ `grant usage, select, update on ${quoteIdentifier(String(sequence_name))} to ${roleIdent}`,
76
+ ),
77
+ )
78
+ }
79
+ }
package/src/post-repo.ts CHANGED
@@ -176,6 +176,32 @@ export class PostgresPostRepository implements PostRepository {
176
176
  return { number, page, afterId: cursor[0]?.id ?? null }
177
177
  }
178
178
 
179
+ async locateFirstUnread(
180
+ threadId: number,
181
+ after: { readonly postId: number; readonly since: Date | null },
182
+ options: { readonly scope: ContentScope; readonly pageSize: number },
183
+ ): Promise<PostLocation | null> {
184
+ const visible: SQL = visibleIn(posts.visibility, options.scope)
185
+
186
+ const target = await this.db
187
+ .select({ id: posts.id })
188
+ .from(posts)
189
+ .where(
190
+ and(
191
+ eq(posts.threadId, threadId),
192
+ visible,
193
+ gt(posts.id, after.postId),
194
+ ...(after.since === null ? [] : [gt(posts.createdAt, after.since)]),
195
+ ),
196
+ )
197
+ .orderBy(asc(posts.id))
198
+ .limit(1)
199
+
200
+ const firstId = target[0]?.id
201
+ if (firstId === undefined) return null
202
+ return this.locate(threadId, firstId, options)
203
+ }
204
+
179
205
  async findVisibleById(threadId: number, postId: number): Promise<number | null> {
180
206
  const rows = await this.db
181
207
  .select({ id: posts.id })