@bakery-framework/orm 2.1.1 → 2.2.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.
@@ -0,0 +1,309 @@
1
+ import type {
2
+ SessionListOptions,
3
+ SessionStore,
4
+ StoredSession,
5
+ } from '@bakery-framework/core/session'
6
+ import type { SQLAdapter } from './adapters/base'
7
+ import { getActiveDb } from './connection'
8
+
9
+ /**
10
+ * Sessions in the app's own database: `sessions.store` in
11
+ * `server.config.ts`.
12
+ *
13
+ * ```ts no-check: a config fragment
14
+ * import { databaseSessions } from '@bakery-framework/orm/sessions'
15
+ *
16
+ * export default {
17
+ * sessions: { store: databaseSessions(), account: 'accountId' },
18
+ * }
19
+ * ```
20
+ *
21
+ * Nothing is cached between requests, so every `--threads` worker reads the
22
+ * same rows: a session ended in one worker is gone for the next request in
23
+ * all of them. The table is the app's to create, in a migration or a schema
24
+ * declaration (docs/guides/sessions.md has both); this never creates or
25
+ * alters a table, which a database role without DDL rights could not do and
26
+ * migrations mode would not want done behind its back.
27
+ *
28
+ * Every statement runs on the connection in use at the time, so inside
29
+ * `DB.transaction()` it is part of the transaction: deleting an account and
30
+ * `Session.endForAccount()` commit together or not at all.
31
+ */
32
+ export interface DatabaseSessionsOptions {
33
+ /** The table the sessions live in. `bakery_sessions` unless named. */
34
+ table?: string
35
+ }
36
+
37
+ export function databaseSessions(
38
+ options: DatabaseSessionsOptions = {},
39
+ ): SessionStore {
40
+ return new DatabaseSessionStore(options.table ?? 'bakery_sessions')
41
+ }
42
+
43
+ /** The columns an insert writes, in the order `rowValues` gives them. */
44
+ const COLUMNS = [
45
+ 'id',
46
+ 'host',
47
+ 'account',
48
+ 'data',
49
+ 'persisted',
50
+ 'created_at',
51
+ 'accessed_at',
52
+ 'expires_at',
53
+ ] as const
54
+
55
+ type Row = {
56
+ id?: unknown
57
+ account: unknown
58
+ data: unknown
59
+ created_at: unknown
60
+ accessed_at: unknown
61
+ expires_at: unknown
62
+ }
63
+
64
+ /**
65
+ * A class rather than a closure over the options: it lives as long as the
66
+ * process, and holds the one string it needs.
67
+ */
68
+ class DatabaseSessionStore implements SessionStore {
69
+ constructor(private readonly table: string) {}
70
+
71
+ async load(
72
+ host: string,
73
+ id: string,
74
+ now: number,
75
+ ): Promise<StoredSession | undefined> {
76
+ const db = getActiveDb()
77
+ const q = (name: string) => db.quote(name)
78
+ const row = (await db
79
+ .query(
80
+ `SELECT ${q('account')}, ${q('data')}, ${q('created_at')}, ${q('accessed_at')}, ${q('expires_at')} ` +
81
+ `FROM ${q(this.table)} WHERE ${q('id')} = ? AND ${q('host')} = ? AND ${q('expires_at')} > ?`,
82
+ )
83
+ .get(id, host, now)) as Row | null | undefined
84
+ return row ? decode(id, host, row) : undefined
85
+ }
86
+
87
+ async insert(session: StoredSession): Promise<void> {
88
+ const db = getActiveDb()
89
+ const columns = COLUMNS.map(name => db.quote(name)).join(', ')
90
+ await db
91
+ .query(
92
+ `INSERT INTO ${db.quote(this.table)} (${columns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
93
+ )
94
+ .run(...rowValues(session))
95
+ }
96
+
97
+ async update(storedId: string, session: StoredSession): Promise<boolean> {
98
+ const db = getActiveDb()
99
+ const q = (name: string) => db.quote(name)
100
+ const result = await db
101
+ .query(
102
+ `UPDATE ${q(this.table)} SET ${q('id')} = ?, ${q('account')} = ?, ${q('data')} = ?, ` +
103
+ `${q('persisted')} = ?, ${q('accessed_at')} = ?, ${q('expires_at')} = ? ` +
104
+ `WHERE ${q('id')} = ? AND ${q('host')} = ?`,
105
+ )
106
+ .run(
107
+ session.id,
108
+ session.account,
109
+ encode(session),
110
+ session.persistKeys.length,
111
+ session.accessedAt,
112
+ session.expiresAt,
113
+ storedId,
114
+ session.host,
115
+ )
116
+ return (
117
+ Number(result.changes) > 0 ||
118
+ (await this.stored(db, session.host, session.id))
119
+ )
120
+ }
121
+
122
+ async touch(
123
+ host: string,
124
+ id: string,
125
+ accessedAt: number,
126
+ expiresAt: number,
127
+ ): Promise<boolean> {
128
+ const db = getActiveDb()
129
+ const q = (name: string) => db.quote(name)
130
+ const result = await db
131
+ .query(
132
+ `UPDATE ${q(this.table)} SET ${q('accessed_at')} = ?, ${q('expires_at')} = ? ` +
133
+ `WHERE ${q('id')} = ? AND ${q('host')} = ?`,
134
+ )
135
+ .run(accessedAt, expiresAt, id, host)
136
+ return Number(result.changes) > 0 || (await this.stored(db, host, id))
137
+ }
138
+
139
+ async remove(host: string, id: string): Promise<boolean> {
140
+ const db = getActiveDb()
141
+ const q = (name: string) => db.quote(name)
142
+ const result = await db
143
+ .query(
144
+ `DELETE FROM ${q(this.table)} WHERE ${q('id')} = ? AND ${q('host')} = ?`,
145
+ )
146
+ .run(id, host)
147
+ return Number(result.changes) > 0
148
+ }
149
+
150
+ async removeAccount(host: string, account: string): Promise<number> {
151
+ const db = getActiveDb()
152
+ const q = (name: string) => db.quote(name)
153
+ const result = await db
154
+ .query(
155
+ `DELETE FROM ${q(this.table)} WHERE ${q('account')} = ? AND ${q('host')} = ?`,
156
+ )
157
+ .run(account, host)
158
+ return Number(result.changes)
159
+ }
160
+
161
+ async count(now: number): Promise<number> {
162
+ const db = getActiveDb()
163
+ const q = (name: string) => db.quote(name)
164
+ const row = (await db
165
+ .query(
166
+ `SELECT COUNT(*) AS ${q('total')} FROM ${q(this.table)} WHERE ${q('expires_at')} > ?`,
167
+ )
168
+ .get(now)) as { total: unknown } | null
169
+ return Number(row?.total ?? 0)
170
+ }
171
+
172
+ async list(
173
+ host: string,
174
+ options: SessionListOptions,
175
+ now: number,
176
+ ): Promise<{ rows: StoredSession[]; totalRows: number }> {
177
+ const db = getActiveDb()
178
+ const q = (name: string) => db.quote(name)
179
+ const where = [`${q('host')} = ?`, `${q('expires_at')} > ?`]
180
+ const params: unknown[] = [host, now]
181
+
182
+ // Matched without regard to case, against the id and the stored JSON, as
183
+ // the built-in store's listing does; and literally, which that one does
184
+ // not: a search for `50%` there matches every session.
185
+ const needle = options.search?.trim().toLowerCase()
186
+ if (needle) {
187
+ const pattern = `%${db.escapeLike(needle)}%`
188
+ where.push(
189
+ `(LOWER(${q('id')}) LIKE ?${db.likeEscapeClause} OR LOWER(${q('data')}) LIKE ?${db.likeEscapeClause})`,
190
+ )
191
+ params.push(pattern, pattern)
192
+ }
193
+ const filter = where.join(' AND ')
194
+
195
+ const counted = (await db
196
+ .query(
197
+ `SELECT COUNT(*) AS ${q('total')} FROM ${q(this.table)} WHERE ${filter}`,
198
+ )
199
+ .get(...params)) as { total: unknown } | null
200
+ const totalRows = Number(counted?.total ?? 0)
201
+
202
+ // The same arithmetic as the built-in store's: a page past the end is the
203
+ // last page, and only DESC or ASC reaches the SQL.
204
+ const pageSize = Math.max(1, Math.trunc(options.pageSize))
205
+ const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
206
+ const page = Math.min(Math.max(1, Math.trunc(options.page)), totalPages)
207
+ const direction = options.sortOrder === 'ASC' ? 'ASC' : 'DESC'
208
+ const sortColumn =
209
+ options.sortBy === 'id'
210
+ ? 'id'
211
+ : options.sortBy === 'keys'
212
+ ? 'persisted'
213
+ : 'accessed_at'
214
+
215
+ const rows = (await db
216
+ .query(
217
+ `SELECT ${q('id')}, ${q('account')}, ${q('data')}, ${q('created_at')}, ${q('accessed_at')}, ${q('expires_at')} ` +
218
+ `FROM ${q(this.table)} WHERE ${filter} ` +
219
+ `ORDER BY ${q(sortColumn)} ${direction}, ${q('id')} ${direction} ` +
220
+ `LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
221
+ )
222
+ .all(...params)) as Row[]
223
+
224
+ return {
225
+ rows: rows.map(row => decode(String(row.id), host, row)),
226
+ totalRows,
227
+ }
228
+ }
229
+
230
+ async prune(now: number): Promise<number> {
231
+ const db = getActiveDb()
232
+ const q = (name: string) => db.quote(name)
233
+ const result = await db
234
+ .query(`DELETE FROM ${q(this.table)} WHERE ${q('expires_at')} <= ?`)
235
+ .run(now)
236
+ return Number(result.changes)
237
+ }
238
+
239
+ /**
240
+ * Whether a session is stored, asked only after an UPDATE reported no rows.
241
+ *
242
+ * That count cannot answer it alone on MySQL, which reports the rows an
243
+ * UPDATE *changed*, not the rows it matched. Measured against MySQL 8.4 on
244
+ * Bun 1.4.2: an UPDATE writing the values a row already holds reports 0
245
+ * there and 1 on SQLite and Postgres. `Session.save` of an unchanged
246
+ * session, or a renewal in the millisecond of the last write, would
247
+ * otherwise read as a session ended elsewhere.
248
+ */
249
+ private async stored(
250
+ db: SQLAdapter,
251
+ host: string,
252
+ id: string,
253
+ ): Promise<boolean> {
254
+ const q = (name: string) => db.quote(name)
255
+ const row = await db
256
+ .query(
257
+ `SELECT 1 AS ${q('found')} FROM ${q(this.table)} WHERE ${q('id')} = ? AND ${q('host')} = ?`,
258
+ )
259
+ .get(id, host)
260
+ return Boolean(row)
261
+ }
262
+ }
263
+
264
+ /** What the `data` column holds: the session's data and which keys persist. */
265
+ function encode(session: StoredSession): string {
266
+ return JSON.stringify({
267
+ data: session.data,
268
+ persistKeys: session.persistKeys,
269
+ })
270
+ }
271
+
272
+ function rowValues(session: StoredSession): unknown[] {
273
+ return [
274
+ session.id,
275
+ session.host,
276
+ session.account,
277
+ encode(session),
278
+ session.persistKeys.length,
279
+ session.createdAt,
280
+ session.accessedAt,
281
+ session.expiresAt,
282
+ ]
283
+ }
284
+
285
+ /**
286
+ * A row as a stored session. `BIGINT` comes back as a string from Postgres
287
+ * and MySQL, and a `data` column declared `json` or `jsonb` comes back
288
+ * already parsed, so both are taken as they come.
289
+ */
290
+ function decode(id: string, host: string, row: Row): StoredSession {
291
+ const payload = (
292
+ typeof row.data === 'string' ? JSON.parse(row.data) : row.data
293
+ ) as { data?: unknown; persistKeys?: unknown } | null
294
+ const data = payload?.data
295
+ const persistKeys = payload?.persistKeys
296
+ return {
297
+ id,
298
+ host,
299
+ account: row.account === null ? null : String(row.account),
300
+ createdAt: Number(row.created_at),
301
+ accessedAt: Number(row.accessed_at),
302
+ expiresAt: Number(row.expires_at),
303
+ persistKeys: Array.isArray(persistKeys) ? persistKeys.map(String) : [],
304
+ data:
305
+ data && typeof data === 'object' && !Array.isArray(data)
306
+ ? (data as Record<string, unknown>)
307
+ : {},
308
+ }
309
+ }
package/src/sync/index.ts CHANGED
@@ -9,6 +9,7 @@ import { Try } from '@bakery-framework/core/utils'
9
9
  import type { SQLAdapter } from '../adapters/base'
10
10
  import { DatabaseMissingError } from '../adapters/missing-database'
11
11
  import { closeDB, connection, initDB } from '../connection'
12
+ import { checkSchema, sqlColumnsIn } from '../migrate/check'
12
13
  import { loadSchema, schemaFromConfig } from './load'
13
14
 
14
15
  const logger = new Logger('db-sync')
@@ -28,6 +29,15 @@ const syncMsgs = {
28
29
  DB_MISSING:
29
30
  'E %rDatabase %y{name}%r does not exist on {server}%*. Run %ybun run db:sync --create-database%* to create it, or point DB_URL at one that exists.',
30
31
  DB_CREATED: 'I Created database %g{name}%* on %y{server}%*.',
32
+ CHECK_OK:
33
+ 'I Migrations mode (%y{dir}%*): the %y{count}%* declared table(s) match the database. db:sync changes nothing here; %ybun run db:migrate%* applies the files.',
34
+ CHECK_NOTHING:
35
+ 'I Migrations mode (%y{dir}%*): no tables are declared, so there is nothing to check. %ybun run db:migrate%* applies the files.',
36
+ CHECK_MISMATCH: 'W {problem}',
37
+ CHECK_DRIFT:
38
+ 'E %r{count} mismatch(es) between the declared tables and the database%* (migrations mode, %y{dir}%*). Correct the declarations, or write a migration; db:sync changes nothing here.',
39
+ SQL_NEEDS_MIGRATIONS:
40
+ 'E %rField.Sql needs migrations mode%*: {columns}. db:sync cannot create or compare a SQL type it does not know. Set %ymigrations%* in server.config.ts, or declare these columns with another Field.',
31
41
  } as const
32
42
 
33
43
  const MESSAGES = messageLogger(logger, syncMsgs)
@@ -219,13 +229,24 @@ Flags:
219
229
  MESSAGES.MIGRATE_RETIRED({ to: to.slice(cwd.length + 1) })
220
230
  }
221
231
 
222
- static async run() {
232
+ /**
233
+ * Sync, or in migrations mode check. Resolves `false` when the database and
234
+ * the declarations disagree in a way that is reported rather than exited on
235
+ * (drift found by the check, or a `Field.Sql` column in classic mode): the
236
+ * command exits 1 on it, `bakery --sync` refuses to serve, and the dev
237
+ * server reports it and serves.
238
+ */
239
+ static async run(): Promise<boolean> {
223
240
  // Before initConfig/initDB/loadSchema: help must not depend on a working
224
241
  // connection, a loadable schema, or the absence of a `foreign()`.
225
- if (SyncService.helpRequested()) return SyncService.printHelp()
242
+ if (SyncService.helpRequested()) {
243
+ SyncService.printHelp()
244
+ return true
245
+ }
226
246
 
227
247
  const { initConfig } = await import('@bakery-framework/core/core/config')
228
248
  const config = await initConfig()
249
+
229
250
  await initDB()
230
251
  // `schema` in server.config.ts when the app sets one; otherwise prefers an
231
252
  // orm/ folder and falls back to a single schema.ts.
@@ -244,6 +265,21 @@ Flags:
244
265
  return process.exit(1)
245
266
  }
246
267
 
268
+ // In migrations mode the SQL files are the schema, and a sync built from
269
+ // the declarations would drop what only the files can say: a partial
270
+ // index, a trigger, a CHECK beyond an enum. So the declarations are only
271
+ // checked against the database, from the catalog, and nothing is changed.
272
+ if (config.migrations) {
273
+ return await SyncService.check(config.migrations, constraints)
274
+ }
275
+
276
+ const sqlColumns = sqlColumnsIn(constraints)
277
+ if (sqlColumns.length) {
278
+ MESSAGES.SQL_NEEDS_MIGRATIONS({ columns: sqlColumns.join(', ') })
279
+ await closeDB()
280
+ return false
281
+ }
282
+
247
283
  if (loaded.unreferenceable?.length) {
248
284
  MESSAGES.FOREIGN_TARGET({ refs: loaded.unreferenceable.join(', ') })
249
285
  await closeDB()
@@ -299,10 +335,36 @@ Flags:
299
335
  }
300
336
 
301
337
  await closeDB()
338
+ return true
339
+ }
340
+
341
+ /** Migrations mode: compare the declarations with the database, report, change nothing. */
342
+ static async check(
343
+ dir: string,
344
+ constraints: Parameters<typeof checkSchema>[1],
345
+ ): Promise<boolean> {
346
+ try {
347
+ const tables = Object.keys(constraints).length
348
+ if (!tables) {
349
+ MESSAGES.CHECK_NOTHING({ dir })
350
+ return true
351
+ }
352
+ const mismatches = await checkSchema(connection, constraints)
353
+ for (const mismatch of mismatches) {
354
+ MESSAGES.CHECK_MISMATCH({ problem: mismatch.problem })
355
+ }
356
+ if (mismatches.length) {
357
+ MESSAGES.CHECK_DRIFT({ count: mismatches.length, dir })
358
+ return false
359
+ }
360
+ MESSAGES.CHECK_OK({ count: tables, dir })
361
+ return true
362
+ } finally {
363
+ await closeDB()
364
+ }
302
365
  }
303
366
  }
304
367
 
305
368
  if (import.meta.main) {
306
- await SyncService.run()
307
- process.exit(0)
369
+ process.exit((await SyncService.run()) ? 0 : 1)
308
370
  }
@@ -1,5 +1,6 @@
1
1
  import { Case } from '@bakery-framework/core/utils'
2
2
  import type { SQLAdapter } from '../adapters/base'
3
+ import { MIGRATIONS_TABLE } from '../migrate/runner'
3
4
  import type * as SyncTypes from './types'
4
5
 
5
6
  /**
@@ -191,11 +192,16 @@ export async function writeLedger(
191
192
  * a table the schema did not declare. Which then made the shape check fail
192
193
  * forever and the ledger never get used at all.
193
194
  */
194
- const LEDGER_ALIASES = new Set([
195
- LEDGER_TABLE,
196
- Case.camel(LEDGER_TABLE),
197
- Case.snake(LEDGER_TABLE),
198
- ])
195
+ const LEDGER_ALIASES = new Set(
196
+ // The migration runner's ledger too (`migrate/runner.ts`): a database that
197
+ // has been migrated and is then synced the classic way must not see it as a
198
+ // table the schema forgot to declare, and offer to drop it.
199
+ [LEDGER_TABLE, MIGRATIONS_TABLE].flatMap(name => [
200
+ name,
201
+ Case.camel(name),
202
+ Case.snake(name),
203
+ ]),
204
+ )
199
205
 
200
206
  /** The ledger must never appear in a diff, or sync would try to manage itself. */
201
207
  export function stripLedger(
@@ -28,6 +28,8 @@ const rollbackMsgs = {
28
28
  SCHEMA_KEPT:
29
29
  'W %y--keep-schema: schema.ts still describes the newer schema.%* The next %ydb:sync%* (including the one a dev boot runs) will apply it again and undo this rollback. Revert the file yourself, or re-run without the flag.',
30
30
  DONE: 'I %gRollback complete%*.',
31
+ MIGRATIONS_MODE:
32
+ 'E %rdb:rollback does not run in migrations mode%* (%y{dir}%*): it rolls back by altering and dropping toward a stored snapshot, and the migration files are the schema here. Write a migration that undoes the change.',
31
33
  } as const
32
34
 
33
35
  const MESSAGES = messageLogger(logger, rollbackMsgs)
@@ -101,6 +103,10 @@ changes, exactly as db:sync does.
101
103
  const { initConfig } = await import('@bakery-framework/core/core/config')
102
104
  const { closeDB, connection, initDB } = await import('../connection')
103
105
  const config = await initConfig()
106
+ if (config.migrations) {
107
+ MESSAGES.MIGRATIONS_MODE({ dir: config.migrations })
108
+ process.exit(1)
109
+ }
104
110
  await initDB()
105
111
 
106
112
  const entries = await readLedgerEntries(connection)
package/src/sync/types.ts CHANGED
@@ -6,6 +6,8 @@ export type ColumnType =
6
6
  | 'buffer'
7
7
  | 'bigint'
8
8
  | 'json'
9
+ /** `Field.Sql`: the SQL type is `sqlType`, given verbatim. Migrations mode only. */
10
+ | 'sql'
9
11
 
10
12
  export interface ColumnConstraint {
11
13
  type: ColumnType
@@ -35,6 +37,8 @@ export interface ColumnConstraint {
35
37
  * enum's members does not migrate on its own.**
36
38
  */
37
39
  _enum?: string[]
40
+ /** The SQL type of a `Field.Sql` column, as its declaration spelled it. */
41
+ sqlType?: string
38
42
  primary?: boolean
39
43
  autoIncrement?: boolean
40
44
  nullable?: boolean