@payload-auth-link/migrate 0.3.1 → 0.4.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/README.md +6 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +49 -6
- package/dist/cli.js.map +1 -1
- package/dist/codemods.d.ts +17 -8
- package/dist/codemods.d.ts.map +1 -1
- package/dist/codemods.js +439 -20
- package/dist/codemods.js.map +1 -1
- package/dist/doctor/database.d.ts +5 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +98 -34
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/plugins.d.ts +15 -0
- package/dist/doctor/plugins.d.ts.map +1 -1
- package/dist/doctor/plugins.js +43 -0
- package/dist/doctor/plugins.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts +11 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +66 -20
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/source-scan.d.ts +5 -0
- package/dist/doctor/source-scan.d.ts.map +1 -1
- package/dist/doctor/source-scan.js +9 -0
- package/dist/doctor/source-scan.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/exporters.d.ts +7 -0
- package/dist/exporters.d.ts.map +1 -1
- package/dist/exporters.js +36 -1
- package/dist/exporters.js.map +1 -1
- package/dist/importer/apply.d.ts +5 -0
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +61 -33
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/lifecycle.d.ts +15 -6
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +131 -39
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/run-import.d.ts +6 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +72 -20
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +47 -4
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +159 -27
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +33 -0
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js +48 -0
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -11
- package/src/cli.ts +52 -6
- package/src/codemods.ts +537 -35
- package/src/doctor/database.ts +129 -46
- package/src/doctor/plugins.ts +51 -0
- package/src/doctor/run-doctor.ts +90 -25
- package/src/doctor/source-scan.ts +10 -0
- package/src/doctor/types.ts +9 -0
- package/src/exporters.ts +46 -1
- package/src/importer/apply.ts +78 -32
- package/src/importer/lifecycle.ts +163 -43
- package/src/importer/run-import.ts +102 -19
- package/src/importer/source.ts +214 -30
- package/src/importer/types.ts +86 -0
- package/src/index.ts +25 -2
package/src/doctor/database.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Client } from 'pg'
|
|
2
|
+
import {
|
|
3
|
+
resolveSourceTables,
|
|
4
|
+
sourceModelNames,
|
|
5
|
+
type ResolvedSourceTables,
|
|
6
|
+
} from '../importer/types.js'
|
|
2
7
|
import type { DatabaseFinding } from './types.js'
|
|
3
8
|
|
|
4
9
|
/**
|
|
@@ -6,26 +11,18 @@ import type { DatabaseFinding } from './types.js'
|
|
|
6
11
|
* payload-auth layout's auth tables, unverified users (who will not
|
|
7
12
|
* JIT-link), and orphans. Only an honest SELECT — the doctor never
|
|
8
13
|
* writes.
|
|
14
|
+
*
|
|
15
|
+
* Map-aware (issue #68): the ten source models are counted through the
|
|
16
|
+
* `--source-map` (defaulting to payload-auth's layout), and a mapped
|
|
17
|
+
* table that is absent comes back in `missingTables` for the caller to
|
|
18
|
+
* block on — a missing table is never silently reported as 0 rows.
|
|
19
|
+
* Satellite tables beyond the map (two-factor, passkey, sso, …) stay
|
|
20
|
+
* informational: apps drop plugins, and only the mapped models gate.
|
|
9
21
|
*/
|
|
10
22
|
|
|
11
|
-
/**
|
|
12
|
-
* The payload-auth 1.9.x Postgres layout (snake_case columns; Payload's
|
|
13
|
-
* drizzle adapter stores hasMany selects as child tables like
|
|
14
|
-
* `users_role`). Tables that don't exist are simply absent from the
|
|
15
|
-
* report — apps customize slugs and drop plugins.
|
|
16
|
-
*/
|
|
23
|
+
/** Satellite/extra tables reported when present, never gating. */
|
|
17
24
|
const knownTables: readonly string[] = [
|
|
18
|
-
'users',
|
|
19
|
-
'accounts',
|
|
20
|
-
'sessions',
|
|
21
|
-
'verifications',
|
|
22
|
-
'api_keys',
|
|
23
|
-
'organizations',
|
|
24
|
-
'members',
|
|
25
|
-
'invitations',
|
|
26
|
-
'jwks',
|
|
27
25
|
'admin_invitations',
|
|
28
|
-
'users_role',
|
|
29
26
|
'two_factors',
|
|
30
27
|
'passkeys',
|
|
31
28
|
'oauth_applications',
|
|
@@ -60,70 +57,156 @@ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
|
|
|
60
57
|
return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
export
|
|
60
|
+
export type IntrospectOptions = {
|
|
61
|
+
/** Model → source table map (`--source-map`); defaults to the default layout. */
|
|
62
|
+
readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function introspectDatabase(
|
|
66
|
+
connectionString: string,
|
|
67
|
+
options: IntrospectOptions = {}
|
|
68
|
+
): Promise<DatabaseFinding> {
|
|
69
|
+
const tables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
|
|
64
70
|
const client = new Client({ connectionString })
|
|
65
71
|
await client.connect()
|
|
66
72
|
try {
|
|
67
|
-
const
|
|
73
|
+
const counted: { name: string; rows: number }[] = []
|
|
74
|
+
const missing: { model: string; table: string }[] = []
|
|
75
|
+
|
|
76
|
+
// The mapped models count, and a missing mapped table is reported
|
|
77
|
+
// for the caller to block on (issue #68).
|
|
78
|
+
for (const model of sourceModelNames) {
|
|
79
|
+
const table = tables[model]
|
|
80
|
+
if (table === null) {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!(await tableExists(client, table))) {
|
|
84
|
+
missing.push({ model, table })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const result = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
|
|
88
|
+
const first = result.rows[0] as { n?: number } | undefined
|
|
89
|
+
counted.push({ name: table, rows: first?.n ?? 0 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Satellite tables: informational when present.
|
|
68
93
|
for (const table of knownTables) {
|
|
69
94
|
if (!(await tableExists(client, table))) {
|
|
70
95
|
continue
|
|
71
96
|
}
|
|
72
|
-
const
|
|
73
|
-
const first =
|
|
74
|
-
|
|
97
|
+
const result = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
|
|
98
|
+
const first = result.rows[0] as { n?: number } | undefined
|
|
99
|
+
counted.push({ name: table, rows: first?.n ?? 0 })
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
let unverifiedUsers: number | null = null
|
|
78
|
-
|
|
79
|
-
|
|
103
|
+
const usersTable = tables['users']
|
|
104
|
+
if (usersTable !== null && (await tableExists(client, usersTable))) {
|
|
105
|
+
const columns = await columnsOf(client, usersTable)
|
|
80
106
|
if (columns.has('email_verified')) {
|
|
81
|
-
const
|
|
82
|
-
|
|
107
|
+
const result = await client.query(
|
|
108
|
+
`SELECT count(*)::int AS n FROM ${usersTable} WHERE email_verified = false`
|
|
83
109
|
)
|
|
84
|
-
const first =
|
|
110
|
+
const first = result.rows[0] as { n?: number } | undefined
|
|
85
111
|
unverifiedUsers = first?.n ?? null
|
|
86
112
|
}
|
|
87
113
|
}
|
|
88
114
|
|
|
89
115
|
const orphans: { kind: string; count: number }[] = []
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
const skippedChecks: string[] = []
|
|
117
|
+
const orphanChecks: readonly {
|
|
118
|
+
kind: string
|
|
119
|
+
table: string | null
|
|
120
|
+
column: string
|
|
121
|
+
target: string | null
|
|
122
|
+
}[] = [
|
|
123
|
+
{
|
|
124
|
+
kind: 'accounts-without-user',
|
|
125
|
+
table: tables['accounts'],
|
|
126
|
+
column: 'user_id',
|
|
127
|
+
target: usersTable,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
kind: 'members-without-org',
|
|
131
|
+
table: tables['members'],
|
|
132
|
+
column: 'organization_id',
|
|
133
|
+
target: tables['organizations'],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
kind: 'members-without-user',
|
|
137
|
+
table: tables['members'],
|
|
138
|
+
column: 'user_id',
|
|
139
|
+
target: usersTable,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kind: 'invitations-without-org',
|
|
143
|
+
table: tables['invitations'],
|
|
144
|
+
column: 'organization_id',
|
|
145
|
+
target: tables['organizations'],
|
|
146
|
+
},
|
|
147
|
+
]
|
|
107
148
|
for (const check of orphanChecks) {
|
|
149
|
+
if (check.table === null || check.target === null) {
|
|
150
|
+
skippedChecks.push(`${check.kind}: skipped — a mapped model is null-mapped (not installed)`)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
108
153
|
if (
|
|
109
154
|
(await tableExists(client, check.table)) &&
|
|
110
155
|
(await tableExists(client, check.target)) &&
|
|
111
156
|
(await columnsOf(client, check.table)).has(check.column)
|
|
112
157
|
) {
|
|
113
|
-
const
|
|
158
|
+
const result = await client.query(
|
|
114
159
|
`SELECT count(*)::int AS n FROM ${check.table}
|
|
115
160
|
WHERE ${check.column} IS NOT NULL
|
|
116
161
|
AND NOT EXISTS (SELECT 1 FROM ${check.target} t WHERE t.id = ${check.table}.${check.column})`
|
|
117
162
|
)
|
|
118
|
-
const first =
|
|
163
|
+
const first = result.rows[0] as { n?: number } | undefined
|
|
119
164
|
const count = first?.n ?? 0
|
|
120
165
|
if (count > 0) {
|
|
121
166
|
orphans.push({ kind: check.kind, count })
|
|
122
167
|
}
|
|
168
|
+
} else {
|
|
169
|
+
skippedChecks.push(`${check.kind}: skipped — a table or column involved is absent`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// api keys: the owner column is named per layout — probe it
|
|
174
|
+
// (issue #68: `reference_id` on current layouts, `user_id` on 1.6).
|
|
175
|
+
const apiKeysTable = tables['api_keys']
|
|
176
|
+
if (apiKeysTable === null) {
|
|
177
|
+
skippedChecks.push('api-keys-without-user: skipped — the model is null-mapped')
|
|
178
|
+
} else if (!(await tableExists(client, apiKeysTable))) {
|
|
179
|
+
if (!missing.some((entry) => entry.model === 'api_keys')) {
|
|
180
|
+
skippedChecks.push(`api-keys-without-user: skipped — table '${apiKeysTable}' is absent`)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
const columns = await columnsOf(client, apiKeysTable)
|
|
184
|
+
const column = columns.has('reference_id')
|
|
185
|
+
? 'reference_id'
|
|
186
|
+
: columns.has('user_id')
|
|
187
|
+
? 'user_id'
|
|
188
|
+
: null
|
|
189
|
+
if (column === null || usersTable === null) {
|
|
190
|
+
skippedChecks.push(
|
|
191
|
+
`api-keys-without-user: skipped — neither 'reference_id' nor 'user_id' exists on '${apiKeysTable}'`
|
|
192
|
+
)
|
|
193
|
+
} else {
|
|
194
|
+
// Text casts on both sides: the owner column may be varchar
|
|
195
|
+
// (reference_id) or integer (1.6-era user_id).
|
|
196
|
+
const result = await client.query(
|
|
197
|
+
`SELECT count(*)::int AS n FROM ${apiKeysTable}
|
|
198
|
+
WHERE ${column} IS NOT NULL
|
|
199
|
+
AND NOT EXISTS (SELECT 1 FROM ${usersTable} t WHERE t.id::text = ${apiKeysTable}.${column}::text)`
|
|
200
|
+
)
|
|
201
|
+
const first = result.rows[0] as { n?: number } | undefined
|
|
202
|
+
const count = first?.n ?? 0
|
|
203
|
+
if (count > 0) {
|
|
204
|
+
orphans.push({ kind: 'api-keys-without-user', count })
|
|
205
|
+
}
|
|
123
206
|
}
|
|
124
207
|
}
|
|
125
208
|
|
|
126
|
-
return { tables, unverifiedUsers, orphans }
|
|
209
|
+
return { tables: counted, unverifiedUsers, orphans, missingTables: missing, skippedChecks }
|
|
127
210
|
} finally {
|
|
128
211
|
await client.end()
|
|
129
212
|
}
|
package/src/doctor/plugins.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
1
3
|
import type { PluginFinding } from './types.js'
|
|
4
|
+
import { listSourceFiles } from './source-scan.js'
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Better Auth plugin classification (issue #52): for each plugin the
|
|
@@ -193,9 +196,17 @@ const callNameToPlugin: Readonly<Record<string, string>> = {
|
|
|
193
196
|
nextCookies: 'next-cookies',
|
|
194
197
|
tanstackStartCookies: 'tanstack-start-cookies',
|
|
195
198
|
oidcProvider: 'oidcProvider',
|
|
199
|
+
mcp: 'mcp',
|
|
196
200
|
passkey: 'passkey',
|
|
197
201
|
apiKey: 'api-key',
|
|
198
202
|
sso: 'sso',
|
|
203
|
+
multiSession: 'multi-session',
|
|
204
|
+
bearer: 'bearer',
|
|
205
|
+
oneTimeToken: 'one-time-token',
|
|
206
|
+
siwe: 'siwe',
|
|
207
|
+
expo: 'expo',
|
|
208
|
+
deviceAuthorization: 'device-authorization',
|
|
209
|
+
subscriptions: 'subscriptions',
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
/**
|
|
@@ -214,3 +225,43 @@ export function pluginsFromConfigSource(configSource: string): string[] {
|
|
|
214
225
|
}
|
|
215
226
|
return [...ids].sort()
|
|
216
227
|
}
|
|
228
|
+
|
|
229
|
+
/** One plugin factory call found in a source file. */
|
|
230
|
+
export type PluginSourceHit = {
|
|
231
|
+
/** The Better Auth plugin id (or payload-auth-era name). */
|
|
232
|
+
readonly id: string
|
|
233
|
+
/** The file it was found in, relative to the scanned root. */
|
|
234
|
+
readonly file: string
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Plugin factory calls across the WHOLE source tree (issue #71), not
|
|
239
|
+
* just the config file: a mid-upgrade app calls `admin(...)` or
|
|
240
|
+
* `oidcProvider(...)` in helpers too, and a schema-adding plugin that
|
|
241
|
+
* hides in a non-config file changes what the migration must carry.
|
|
242
|
+
* Same walk/skip rules as the source scan.
|
|
243
|
+
*/
|
|
244
|
+
export function pluginsFromSourceTree(srcDir: string): readonly PluginSourceHit[] {
|
|
245
|
+
const hits: PluginSourceHit[] = []
|
|
246
|
+
const seen = new Set<string>()
|
|
247
|
+
for (const file of listSourceFiles(srcDir)) {
|
|
248
|
+
let content: string
|
|
249
|
+
try {
|
|
250
|
+
content = readFileSync(file, 'utf8')
|
|
251
|
+
} catch {
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
for (const [call, id] of Object.entries(callNameToPlugin)) {
|
|
255
|
+
const pattern = new RegExp(`\\b${call}\\s*\\(`)
|
|
256
|
+
if (pattern.test(content)) {
|
|
257
|
+
const rel = relative(srcDir, file)
|
|
258
|
+
const key = `${id}\n${rel}`
|
|
259
|
+
if (!seen.has(key)) {
|
|
260
|
+
seen.add(key)
|
|
261
|
+
hits.push({ id, file: rel })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return hits
|
|
267
|
+
}
|
package/src/doctor/run-doctor.ts
CHANGED
|
@@ -4,10 +4,16 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { checkVersions } from './bands.js'
|
|
6
6
|
import { introspectDatabase } from './database.js'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
classifyPlugins,
|
|
9
|
+
pluginsFromCollectionSlugs,
|
|
10
|
+
pluginsFromConfigSource,
|
|
11
|
+
pluginsFromSourceTree,
|
|
12
|
+
} from './plugins.js'
|
|
8
13
|
import { scanSource } from './source-scan.js'
|
|
9
14
|
import { analyzeUsersCollection, findUsersCollection } from './users.js'
|
|
10
|
-
import
|
|
15
|
+
import { parseSourceMapJson, resolveSourceTables } from '../importer/types.js'
|
|
16
|
+
import type { BehaviourChange, DoctorIssue, DoctorReport, PluginFinding } from './types.js'
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* The read-only doctor (issue #52): point it at a payload-auth app and
|
|
@@ -16,6 +22,12 @@ import type { BehaviourChange, DoctorIssue, DoctorReport } from './types.js'
|
|
|
16
22
|
* Auth plugins, scans the source tree for call sites that must
|
|
17
23
|
* migrate, and (with a database URL) reports row counts, unverified
|
|
18
24
|
* users, and orphans. It never writes.
|
|
25
|
+
*
|
|
26
|
+
* Never crashes (issue #71): a config that cannot even be IMPORTED
|
|
27
|
+
* (the mid-upgrade state — better-auth 1.7 installed while the config
|
|
28
|
+
* still calls `oidcProvider`) is a `config-unloadable` blocker, not a
|
|
29
|
+
* crash. The static scans (config text, source tree, database) still
|
|
30
|
+
* run and still catch what the load cannot.
|
|
19
31
|
*/
|
|
20
32
|
|
|
21
33
|
export type DoctorOptions = {
|
|
@@ -25,6 +37,11 @@ export type DoctorOptions = {
|
|
|
25
37
|
readonly srcDir?: string | undefined
|
|
26
38
|
/** Postgres connection string. Defaults to `DATABASE_URL`. */
|
|
27
39
|
readonly databaseUrl?: string | undefined
|
|
40
|
+
/**
|
|
41
|
+
* The `--source-map` JSON (issue #68): model → source table name, or
|
|
42
|
+
* `null` for "model not installed". Shapes the database section.
|
|
43
|
+
*/
|
|
44
|
+
readonly sourceMapJson?: string | undefined
|
|
28
45
|
/** Config loader, injectable for tests. Defaults to dynamic import. */
|
|
29
46
|
readonly loadModule?: ((path: string) => Promise<unknown>) | undefined
|
|
30
47
|
}
|
|
@@ -141,7 +158,6 @@ async function defaultLoadModule(path: string): Promise<unknown> {
|
|
|
141
158
|
export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
142
159
|
const configPath = resolve(options.configPath)
|
|
143
160
|
const loadModule = options.loadModule ?? defaultLoadModule
|
|
144
|
-
const config = await loadModule(configPath)
|
|
145
161
|
const configDir = dirname(configPath)
|
|
146
162
|
const srcDir = options.srcDir === undefined ? configDir : resolve(options.srcDir)
|
|
147
163
|
|
|
@@ -162,8 +178,25 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
// Issue #71: the load can fail on the mid-upgrade state (better-auth
|
|
182
|
+
// 1.7 installed while the config still calls `oidcProvider`). That is
|
|
183
|
+
// a blocker — never a crash — and the static scans below still run.
|
|
184
|
+
let config: unknown = null
|
|
185
|
+
let configLoaded = true
|
|
186
|
+
try {
|
|
187
|
+
config = await loadModule(configPath)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
configLoaded = false
|
|
190
|
+
blockers.push({
|
|
191
|
+
code: 'config-unloadable',
|
|
192
|
+
detail: `the config module could not be loaded: ${
|
|
193
|
+
error instanceof Error ? error.message : String(error)
|
|
194
|
+
} — fix the load error before migrating; the report below still reflects the static scans`,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const plugin = configLoaded ? findPayloadAuthPlugin(config) : null
|
|
199
|
+
if (configLoaded && plugin === null) {
|
|
167
200
|
warnings.push({
|
|
168
201
|
code: 'payload-auth-not-detected',
|
|
169
202
|
detail:
|
|
@@ -179,22 +212,35 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
179
212
|
}
|
|
180
213
|
|
|
181
214
|
// payload-auth keeps the builder's options in its closure, so the
|
|
182
|
-
// plugin set comes from what the
|
|
183
|
-
// payload-auth
|
|
215
|
+
// plugin set comes from what the sources prove: the collections
|
|
216
|
+
// payload-auth built, the plugin calls in the config source, AND the
|
|
217
|
+
// plugin calls anywhere under --src (issue #71 — schema-adding
|
|
218
|
+
// plugins hide in helper files too).
|
|
184
219
|
let configSource: string
|
|
185
220
|
try {
|
|
186
221
|
configSource = readFileSync(configPath, 'utf8')
|
|
187
222
|
} catch {
|
|
188
223
|
configSource = ''
|
|
189
224
|
}
|
|
225
|
+
const sourceTreeHits = pluginsFromSourceTree(srcDir)
|
|
226
|
+
const filesById = new Map<string, string[]>()
|
|
227
|
+
for (const hit of sourceTreeHits) {
|
|
228
|
+
const list = filesById.get(hit.id) ?? []
|
|
229
|
+
list.push(hit.file)
|
|
230
|
+
filesById.set(hit.id, list)
|
|
231
|
+
}
|
|
190
232
|
const pluginIds = [
|
|
191
233
|
...new Set([
|
|
192
|
-
...betterAuthPluginIds(plugin?.pluginOptions ?? {}),
|
|
193
|
-
...pluginsFromCollectionSlugs(collectionSlugs(config)),
|
|
234
|
+
...(configLoaded ? betterAuthPluginIds(plugin?.pluginOptions ?? {}) : []),
|
|
235
|
+
...(configLoaded ? pluginsFromCollectionSlugs(collectionSlugs(config)) : []),
|
|
194
236
|
...pluginsFromConfigSource(configSource),
|
|
237
|
+
...sourceTreeHits.map((hit) => hit.id),
|
|
195
238
|
]),
|
|
196
239
|
]
|
|
197
|
-
const pluginFindings = classifyPlugins(pluginIds)
|
|
240
|
+
const pluginFindings: PluginFinding[] = classifyPlugins(pluginIds).map((finding) => {
|
|
241
|
+
const files = filesById.get(finding.id)
|
|
242
|
+
return files === undefined ? finding : { ...finding, files }
|
|
243
|
+
})
|
|
198
244
|
for (const finding of pluginFindings) {
|
|
199
245
|
if (finding.status === 'removed-upstream') {
|
|
200
246
|
blockers.push({
|
|
@@ -206,20 +252,24 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
206
252
|
}
|
|
207
253
|
}
|
|
208
254
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
255
|
+
let users: DoctorReport['users'] = null
|
|
256
|
+
if (configLoaded) {
|
|
257
|
+
const usersSlug = usersSlugOf(plugin?.pluginOptions ?? {})
|
|
258
|
+
const { finding, issues: userIssues } = analyzeUsersCollection(
|
|
259
|
+
findUsersCollection(config, usersSlug),
|
|
260
|
+
'roles'
|
|
261
|
+
)
|
|
262
|
+
users = finding
|
|
263
|
+
for (const issue of userIssues) {
|
|
264
|
+
if (
|
|
265
|
+
issue.code === 'injected-field-collision' ||
|
|
266
|
+
issue.code === 'roles-field-not-select' ||
|
|
267
|
+
issue.code === 'users-collection-missing'
|
|
268
|
+
) {
|
|
269
|
+
blockers.push(issue)
|
|
270
|
+
} else {
|
|
271
|
+
warnings.push(issue)
|
|
272
|
+
}
|
|
223
273
|
}
|
|
224
274
|
}
|
|
225
275
|
|
|
@@ -237,7 +287,11 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
237
287
|
let database: DoctorReport['database'] = null
|
|
238
288
|
if (databaseUrl !== undefined && databaseUrl !== '') {
|
|
239
289
|
try {
|
|
240
|
-
const
|
|
290
|
+
const sourceTables =
|
|
291
|
+
options.sourceMapJson === undefined
|
|
292
|
+
? undefined
|
|
293
|
+
: resolveSourceTables(parseSourceMapJson(options.sourceMapJson))
|
|
294
|
+
const found = await introspectDatabase(databaseUrl, { sourceTables })
|
|
241
295
|
database = found
|
|
242
296
|
if (found.unverifiedUsers !== null && found.unverifiedUsers > 0) {
|
|
243
297
|
warnings.push({
|
|
@@ -245,12 +299,23 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
245
299
|
detail: `${found.unverifiedUsers} user(s) have emailVerified = false: they will not JIT-link on a provider sign-in; they need a verified email (or a role manager's manual link) after migration`,
|
|
246
300
|
})
|
|
247
301
|
}
|
|
302
|
+
// Fail closed on the source layout (issue #68): a mapped model
|
|
303
|
+
// whose table is absent blocks until --source-map explains it.
|
|
304
|
+
for (const table of found.missingTables ?? []) {
|
|
305
|
+
blockers.push({
|
|
306
|
+
code: 'source-table-missing',
|
|
307
|
+
detail: `model '${table.model}' expects source table '${table.table}', which does not exist — if your layout renamed it, pass --source-map '{"${table.model}":"<table>"}', or map it null to record it as not installed`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
248
310
|
for (const orphan of found.orphans) {
|
|
249
311
|
blockers.push({
|
|
250
312
|
code: 'orphan-rows',
|
|
251
313
|
detail: `${orphan.count} orphaned row(s) of kind '${orphan.kind}': the importer refuses unmappable references rather than silently skipping them`,
|
|
252
314
|
})
|
|
253
315
|
}
|
|
316
|
+
for (const skip of found.skippedChecks ?? []) {
|
|
317
|
+
warnings.push({ code: 'orphan-check-skipped', detail: skip })
|
|
318
|
+
}
|
|
254
319
|
} catch (error) {
|
|
255
320
|
warnings.push({
|
|
256
321
|
code: 'database-unreachable',
|
|
@@ -74,6 +74,16 @@ function walk(dir: string, into: string[]): void {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Every scannable file under `dir` — the walk/skip rules the source
|
|
79
|
+
* scan uses, shared with the plugin factory scan (issue #71).
|
|
80
|
+
*/
|
|
81
|
+
export function listSourceFiles(dir: string): readonly string[] {
|
|
82
|
+
const files: string[] = []
|
|
83
|
+
walk(dir, files)
|
|
84
|
+
return files
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
export function scanSource(srcDir: string): SourceFinding[] {
|
|
78
88
|
const files: string[] = []
|
|
79
89
|
walk(srcDir, files)
|
package/src/doctor/types.ts
CHANGED
|
@@ -29,6 +29,8 @@ export type PluginFinding = {
|
|
|
29
29
|
readonly id: string
|
|
30
30
|
readonly status: 'supported' | 'auto-declarable' | 'unprobed' | 'unsupported' | 'removed-upstream'
|
|
31
31
|
readonly detail: string
|
|
32
|
+
/** Files under `--src` whose source names this plugin (issue #71). */
|
|
33
|
+
readonly files?: readonly string[]
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** The users collection, as the migration will meet it. */
|
|
@@ -71,6 +73,13 @@ export type DatabaseFinding = {
|
|
|
71
73
|
/** Users with `emailVerified = false` — they will not JIT-link. */
|
|
72
74
|
readonly unverifiedUsers: number | null
|
|
73
75
|
readonly orphans: readonly { readonly kind: string; readonly count: number }[]
|
|
76
|
+
/**
|
|
77
|
+
* Mapped source models whose table is absent (issue #68) — blockers
|
|
78
|
+
* unless `--source-map` records the model as not installed.
|
|
79
|
+
*/
|
|
80
|
+
readonly missingTables?: readonly { readonly model: string; readonly table: string }[]
|
|
81
|
+
/** Orphan checks that could not run, with the reason (issue #68). */
|
|
82
|
+
readonly skippedChecks?: readonly string[]
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/** Default-behaviour changes in payload-auth-link 0.7.0 that bite. */
|
package/src/exporters.ts
CHANGED
|
@@ -39,6 +39,46 @@ export type ExportResult = {
|
|
|
39
39
|
/** The JSONL document: one JSON object per line, `{"table": …, "row": …}`. */
|
|
40
40
|
readonly jsonl: string
|
|
41
41
|
readonly totalRows: number
|
|
42
|
+
/**
|
|
43
|
+
* Requested tables that do not exist on the source (issue #68). The
|
|
44
|
+
* caller decides policy: `--tables` names must abort (exit 1),
|
|
45
|
+
* default-list gaps are reported but tolerated.
|
|
46
|
+
*/
|
|
47
|
+
readonly missing: readonly string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `--tables` flag value (issue #68): a JSON array of table names or
|
|
52
|
+
* a comma-separated list of table names. Anything else is a usage error.
|
|
53
|
+
*/
|
|
54
|
+
const tableName = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
55
|
+
|
|
56
|
+
export function parseExportTables(value: string): readonly string[] {
|
|
57
|
+
const trimmed = value.trim()
|
|
58
|
+
const reject = (): never => {
|
|
59
|
+
throw new Error('--tables must be a JSON array of table names or a comma-separated list')
|
|
60
|
+
}
|
|
61
|
+
if (trimmed === '') {
|
|
62
|
+
return reject()
|
|
63
|
+
}
|
|
64
|
+
if (trimmed.startsWith('[')) {
|
|
65
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
66
|
+
if (
|
|
67
|
+
!Array.isArray(parsed) ||
|
|
68
|
+
parsed.some((entry) => typeof entry !== 'string' || !tableName.test(entry.trim()))
|
|
69
|
+
) {
|
|
70
|
+
return reject()
|
|
71
|
+
}
|
|
72
|
+
return (parsed as string[]).map((entry) => entry.trim())
|
|
73
|
+
}
|
|
74
|
+
const names = trimmed
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((entry) => entry.trim())
|
|
77
|
+
.filter((entry) => entry !== '')
|
|
78
|
+
if (names.length === 0 || names.some((entry) => !tableName.test(entry))) {
|
|
79
|
+
return reject()
|
|
80
|
+
}
|
|
81
|
+
return names
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
/** Export the auth tables to lossless JSONL (issue #58). */
|
|
@@ -49,6 +89,7 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
|
|
|
49
89
|
try {
|
|
50
90
|
const lines: string[] = []
|
|
51
91
|
const exported: { name: string; rows: number }[] = []
|
|
92
|
+
const missing: string[] = []
|
|
52
93
|
let totalRows = 0
|
|
53
94
|
for (const table of tables) {
|
|
54
95
|
const exists = await client.query(
|
|
@@ -56,6 +97,10 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
|
|
|
56
97
|
[table]
|
|
57
98
|
)
|
|
58
99
|
if (exists.rows.length === 0) {
|
|
100
|
+
// Nothing is silently omitted (issue #68): every absent table
|
|
101
|
+
// is logged and lands in `missing` for the caller's policy.
|
|
102
|
+
missing.push(table)
|
|
103
|
+
options.log?.(`table '${table}' does not exist on the source — not exported`)
|
|
59
104
|
continue
|
|
60
105
|
}
|
|
61
106
|
// Export EVERY column raw (JSON types preserved by ::jsonb cast
|
|
@@ -70,7 +115,7 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
|
|
|
70
115
|
}
|
|
71
116
|
}
|
|
72
117
|
options.log?.(`exported ${totalRows} row(s) across ${exported.length} table(s)`)
|
|
73
|
-
return { tables: exported, jsonl: `${lines.join('\n')}\n`, totalRows }
|
|
118
|
+
return { tables: exported, jsonl: `${lines.join('\n')}\n`, totalRows, missing }
|
|
74
119
|
} finally {
|
|
75
120
|
await client.end()
|
|
76
121
|
}
|