@bakery-framework/orm 2.1.2 → 2.2.1
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.
- package/package.json +5 -3
- package/src/adapters/base.ts +137 -4
- package/src/adapters/live-types.ts +308 -0
- package/src/adapters/mysql.ts +75 -0
- package/src/adapters/pgsql.ts +124 -5
- package/src/adapters/sqlite.ts +39 -3
- package/src/array-value.ts +48 -0
- package/src/field.ts +98 -0
- package/src/migrate/check.ts +108 -0
- package/src/migrate/files.ts +111 -0
- package/src/migrate/index.ts +168 -0
- package/src/migrate/runner.ts +208 -0
- package/src/migrate/script.ts +228 -0
- package/src/orm/query.ts +30 -0
- package/src/schema-util.ts +8 -0
- package/src/sessions.ts +309 -0
- package/src/sync/index.ts +66 -4
- package/src/sync/ledger.ts +11 -5
- package/src/sync/rollback.ts +6 -0
- package/src/sync/types.ts +4 -0
package/src/sessions.ts
ADDED
|
@@ -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
|
-
|
|
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())
|
|
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
|
}
|
package/src/sync/ledger.ts
CHANGED
|
@@ -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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(
|
package/src/sync/rollback.ts
CHANGED
|
@@ -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
|