@meith/db 0.33.0 → 0.33.2

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.
@@ -463,6 +463,13 @@
463
463
  "when": 1788346593870,
464
464
  "tag": "0065_serious_puff_adder",
465
465
  "breakpoints": true
466
+ },
467
+ {
468
+ "idx": 66,
469
+ "version": "7",
470
+ "when": 1788367778886,
471
+ "tag": "0066_board_digest_due_index",
472
+ "breakpoints": true
466
473
  }
467
474
  ]
468
475
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/db",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,36 +22,36 @@
22
22
  "dependencies": {
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "postgres": "^3.4.7",
25
- "@meith/accounts": "0.33.0",
26
- "@meith/admin": "0.33.0",
27
- "@meith/antispam": "0.33.0",
28
- "@meith/api": "0.33.0",
29
- "@meith/attachments": "0.33.0",
30
- "@meith/authorization": "0.33.0",
31
- "@meith/avatars": "0.33.0",
32
- "@meith/board-digest": "0.33.0",
33
- "@meith/core": "0.33.0",
34
- "@meith/drafts": "0.33.0",
35
- "@meith/events": "0.33.0",
36
- "@meith/forums": "0.33.0",
37
- "@meith/groups": "0.33.0",
38
- "@meith/i18n": "0.33.0",
39
- "@meith/markdown": "0.33.0",
40
- "@meith/marketplace": "0.33.0",
41
- "@meith/messages": "0.33.0",
42
- "@meith/moderation": "0.33.0",
43
- "@meith/notifications": "0.33.0",
44
- "@meith/plugin-kit": "0.33.0",
45
- "@meith/polls": "0.33.0",
46
- "@meith/profile-fields": "0.33.0",
47
- "@meith/relations": "0.33.0",
48
- "@meith/reputation": "0.33.0",
49
- "@meith/search": "0.33.0",
50
- "@meith/settings": "0.33.0",
51
- "@meith/signatures": "0.33.0",
52
- "@meith/subscriptions": "0.33.0",
53
- "@meith/tasks": "0.33.0",
54
- "@meith/threads": "0.33.0"
25
+ "@meith/accounts": "0.33.2",
26
+ "@meith/admin": "0.33.2",
27
+ "@meith/antispam": "0.33.2",
28
+ "@meith/api": "0.33.2",
29
+ "@meith/attachments": "0.33.2",
30
+ "@meith/authorization": "0.33.2",
31
+ "@meith/avatars": "0.33.2",
32
+ "@meith/board-digest": "0.33.2",
33
+ "@meith/core": "0.33.2",
34
+ "@meith/drafts": "0.33.2",
35
+ "@meith/events": "0.33.2",
36
+ "@meith/forums": "0.33.2",
37
+ "@meith/groups": "0.33.2",
38
+ "@meith/i18n": "0.33.2",
39
+ "@meith/marketplace": "0.33.2",
40
+ "@meith/messages": "0.33.2",
41
+ "@meith/markdown": "0.33.2",
42
+ "@meith/moderation": "0.33.2",
43
+ "@meith/notifications": "0.33.2",
44
+ "@meith/plugin-kit": "0.33.2",
45
+ "@meith/polls": "0.33.2",
46
+ "@meith/profile-fields": "0.33.2",
47
+ "@meith/relations": "0.33.2",
48
+ "@meith/reputation": "0.33.2",
49
+ "@meith/search": "0.33.2",
50
+ "@meith/settings": "0.33.2",
51
+ "@meith/signatures": "0.33.2",
52
+ "@meith/subscriptions": "0.33.2",
53
+ "@meith/tasks": "0.33.2",
54
+ "@meith/threads": "0.33.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "drizzle-kit": "^0.31.6",
package/src/index.ts CHANGED
@@ -172,7 +172,7 @@ export {
172
172
  export { PostgresMemberProfileRepository } from './member-profile-repo'
173
173
  export { PostgresMemberSettingsRepository } from './member-settings-repo'
174
174
  export { PostgresMessageRepository } from './message-repo'
175
- export { migrationUrl, runMigrations } from './migrate'
175
+ export { migrationUrl, pendingCoreMigrations, runMigrations } from './migrate'
176
176
  export { PostgresModCpRepository } from './modcp-repo'
177
177
  export { PostgresModerationQueueRepository } from './moderation-queue'
178
178
  export {
package/src/migrate.ts CHANGED
@@ -1,17 +1,37 @@
1
- import { existsSync } from 'node:fs'
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import { fileURLToPath } from 'node:url'
4
5
 
5
- import { drizzle } from 'drizzle-orm/postgres-js'
6
- import { migrate } from 'drizzle-orm/postgres-js/migrator'
6
+ import { sql } from 'drizzle-orm'
7
7
  import postgres from 'postgres'
8
8
 
9
9
  import { ConfigurationError, env, logger } from '@meith/core'
10
10
 
11
+ import type { Database } from './client'
12
+ import { resultRows } from './result-rows'
13
+
11
14
  const JOURNAL = path.join('meta', '_journal.json')
12
15
 
13
16
  const IMAGE_MIGRATIONS = '/app/migrations'
14
17
 
18
+ const STATEMENT_BREAKPOINT = '--> statement-breakpoint'
19
+
20
+ export const MIGRATIONS_TABLE = 'drizzle.__drizzle_migrations'
21
+
22
+ export interface Migration {
23
+ readonly tag: string
24
+ readonly hash: string
25
+ readonly when: number
26
+ readonly statements: readonly string[]
27
+ }
28
+
29
+ export interface MigrationExecutor {
30
+ exec(statement: string): Promise<void>
31
+ query<T>(statement: string): Promise<readonly T[]>
32
+ transaction(work: (exec: (statement: string) => Promise<void>) => Promise<void>): Promise<void>
33
+ }
34
+
15
35
  export function migrationFolderCandidates(input: {
16
36
  readonly explicit?: string | undefined
17
37
  readonly moduleDir?: string | undefined
@@ -31,6 +51,7 @@ export function migrationFolderCandidates(input: {
31
51
  for (;;) {
32
52
  candidates.push(path.join(dir, 'migrations'))
33
53
  candidates.push(path.join(dir, 'packages', 'db', 'migrations'))
54
+ candidates.push(path.join(dir, 'node_modules', '@meith', 'db', 'migrations'))
34
55
  const parent = path.dirname(dir)
35
56
  if (parent === dir) break
36
57
  dir = parent
@@ -63,6 +84,95 @@ function migrationsFolder(): string {
63
84
  )
64
85
  }
65
86
 
87
+ export function readMigrations(folder: string): readonly Migration[] {
88
+ const journal = JSON.parse(readFileSync(path.join(folder, JOURNAL), 'utf8')) as {
89
+ readonly entries: readonly { readonly tag: string; readonly when: number }[]
90
+ }
91
+
92
+ return journal.entries.map((entry) => {
93
+ if (!Number.isSafeInteger(entry.when)) {
94
+ throw new ConfigurationError(`Migration ${entry.tag} has no usable timestamp in the journal.`)
95
+ }
96
+
97
+ const source = readFileSync(path.join(folder, `${entry.tag}.sql`), 'utf8')
98
+
99
+ return {
100
+ tag: entry.tag,
101
+ when: entry.when,
102
+ hash: createHash('sha256').update(source).digest('hex'),
103
+ statements: source.split(STATEMENT_BREAKPOINT).filter((statement) => statement.trim() !== ''),
104
+ }
105
+ })
106
+ }
107
+
108
+ async function appliedHashes(client: MigrationExecutor): Promise<ReadonlySet<string>> {
109
+ const [table] = await client.query<{ found: string | null }>(
110
+ `select to_regclass('${MIGRATIONS_TABLE}')::text as found`,
111
+ )
112
+ if (table === undefined || table.found === null) return new Set()
113
+
114
+ const rows = await client.query<{ hash: string }>(`select hash from ${MIGRATIONS_TABLE}`)
115
+ return new Set(rows.map((row) => row.hash))
116
+ }
117
+
118
+ export async function pendingMigrations(
119
+ client: MigrationExecutor,
120
+ migrations: readonly Migration[],
121
+ ): Promise<readonly Migration[]> {
122
+ const applied = await appliedHashes(client)
123
+ return migrations.filter((migration) => !applied.has(migration.hash))
124
+ }
125
+
126
+ export async function applyMigrations(
127
+ client: MigrationExecutor,
128
+ migrations: readonly Migration[],
129
+ ): Promise<readonly Migration[]> {
130
+ const pending = await pendingMigrations(client, migrations)
131
+ if (pending.length === 0) return pending
132
+
133
+ await client.exec('create schema if not exists drizzle')
134
+ await client.exec(
135
+ `create table if not exists ${MIGRATIONS_TABLE} (id serial primary key, ` +
136
+ 'hash text not null, created_at bigint)',
137
+ )
138
+
139
+ await client.transaction(async (exec) => {
140
+ for (const migration of pending) {
141
+ for (const statement of migration.statements) await exec(statement)
142
+ await exec(
143
+ `insert into ${MIGRATIONS_TABLE} ("hash", "created_at") ` +
144
+ `values ('${migration.hash}', ${migration.when})`,
145
+ )
146
+ }
147
+ })
148
+
149
+ return pending
150
+ }
151
+
152
+ function databaseExecutor(db: Database): MigrationExecutor {
153
+ return {
154
+ async exec(statement) {
155
+ await db.execute(sql.raw(statement))
156
+ },
157
+ async query<T>(statement: string) {
158
+ return resultRows<T>(await db.execute(sql.raw(statement)))
159
+ },
160
+ async transaction(work) {
161
+ await db.transaction(async (tx) => {
162
+ await work(async (statement) => {
163
+ await tx.execute(sql.raw(statement))
164
+ })
165
+ })
166
+ },
167
+ }
168
+ }
169
+
170
+ export async function pendingCoreMigrations(db: Database): Promise<readonly string[]> {
171
+ const migrations = readMigrations(migrationsFolder())
172
+ const pending = await pendingMigrations(databaseExecutor(db), migrations)
173
+ return pending.map((migration) => migration.tag)
174
+ }
175
+
66
176
  export const MIGRATION_LOCK_KEY = '-2943916371013839929'
67
177
 
68
178
  export function migrationUrl(source: {
@@ -91,39 +201,43 @@ export async function withMigrationLock<T>(
91
201
  }
92
202
  }
93
203
 
204
+ function postgresExecutor(sql: ReturnType<typeof postgres>): MigrationExecutor {
205
+ return {
206
+ async exec(statement) {
207
+ await sql.unsafe(statement)
208
+ },
209
+ async query<T>(statement: string) {
210
+ return (await sql.unsafe(statement)) as unknown as readonly T[]
211
+ },
212
+ async transaction(work) {
213
+ await sql.begin(async (tx) => {
214
+ await work(async (statement) => {
215
+ await tx.unsafe(statement)
216
+ })
217
+ })
218
+ },
219
+ }
220
+ }
221
+
94
222
  export async function runMigrations(
95
223
  options: { readonly folder?: string; readonly url?: string } = {},
96
224
  ): Promise<number> {
97
225
  const url = options.url ?? migrationUrl(env)
226
+ const migrations = readMigrations(options.folder ?? migrationsFolder())
98
227
 
99
228
  const sql = postgres(url, { max: 1, prepare: false, onnotice: () => {} })
100
229
 
101
230
  try {
102
231
  return await withMigrationLock(sql, async () => {
103
- const before = await appliedCount(sql)
104
- await migrate(drizzle(sql), { migrationsFolder: options.folder ?? migrationsFolder() })
105
- const after = await appliedCount(sql)
232
+ const applied = await applyMigrations(postgresExecutor(sql), migrations)
106
233
 
107
- const applied = after - before
108
- logger({ component: 'migrate' }).info({ applied }, 'migrations applied')
109
- return applied
234
+ logger({ component: 'migrate' }).info(
235
+ { applied: applied.length, tags: applied.map((migration) => migration.tag) },
236
+ 'migrations applied',
237
+ )
238
+ return applied.length
110
239
  })
111
240
  } finally {
112
241
  await sql.end({ timeout: 5 })
113
242
  }
114
243
  }
115
-
116
- async function appliedCount(sql: ReturnType<typeof postgres>): Promise<number> {
117
- const rows = await sql<{ count: string }[]>`
118
- SELECT COUNT(*)::text AS count
119
- FROM information_schema.tables
120
- WHERE table_schema = 'drizzle' AND table_name = '__drizzle_migrations'
121
- `
122
-
123
- if (rows[0]?.count === '0') return 0
124
-
125
- const applied = await sql<{ count: string }[]>`
126
- SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations
127
- `
128
- return Number(applied[0]?.count ?? '0')
129
- }
@@ -149,6 +149,9 @@ export const users = pgTable(
149
149
  .on(t.signatureRenderVersion, t.id)
150
150
  .where(sql`${t.signature} <> ''`),
151
151
  index('users_mass_mail_opt_in_idx').on(t.id).where(sql`${t.massMailOptInAt} is not null`),
152
+ index('users_board_digest_due_idx')
153
+ .on(t.boardDigestCadence, t.lastActiveAt)
154
+ .where(sql`${t.state} = 'active'`),
152
155
  ],
153
156
  )
154
157
 
@@ -1,5 +1,6 @@
1
1
  import { sql } from 'drizzle-orm'
2
2
 
3
+ import { MYBB_PREFIX, PHPBB_PREFIX } from '@meith/accounts'
3
4
  import type { TaskHealthInput } from '@meith/tasks'
4
5
 
5
6
  import type { Database } from './client'
@@ -116,6 +117,20 @@ export class PostgresSystemHealthRepository {
116
117
  }
117
118
  }
118
119
 
120
+ async legacyPasswordHashes(): Promise<number> {
121
+ const rows = resultRows(
122
+ await this.db.execute(sql`
123
+ select count(*)::int as count
124
+ from users
125
+ where deleted_at is null
126
+ and (password_hash like ${`${MYBB_PREFIX}%`}
127
+ or password_hash like ${`${PHPBB_PREFIX}%`})
128
+ `),
129
+ ) as Array<Record<string, unknown>>
130
+
131
+ return Number(rows[0]?.count ?? 0)
132
+ }
133
+
119
134
  async activeConnections(): Promise<number> {
120
135
  const rows = resultRows(
121
136
  await this.db.execute(sql`