@payload-auth-link/migrate 0.3.1 → 0.4.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.
- 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
|
@@ -9,12 +9,24 @@ import {
|
|
|
9
9
|
verifyImport,
|
|
10
10
|
type VerifyResult,
|
|
11
11
|
} from './lifecycle.js'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
countTables,
|
|
14
|
+
findOrphans,
|
|
15
|
+
openSource,
|
|
16
|
+
probeSourceLayout,
|
|
17
|
+
readSourceUsers,
|
|
18
|
+
unverifiedEmails,
|
|
19
|
+
type SourceLayoutIssue,
|
|
20
|
+
} from './source.js'
|
|
13
21
|
import {
|
|
14
22
|
defaultTargetSlugs,
|
|
23
|
+
parseSourceMapJson,
|
|
24
|
+
presentTables,
|
|
25
|
+
resolveSourceTables,
|
|
15
26
|
type ImportManifest,
|
|
16
27
|
type ImportPlan,
|
|
17
28
|
type ImportResult,
|
|
29
|
+
type ResolvedSourceTables,
|
|
18
30
|
type TargetSlugMap,
|
|
19
31
|
} from './types.js'
|
|
20
32
|
|
|
@@ -36,6 +48,12 @@ export type ImportCommandOptions = {
|
|
|
36
48
|
readonly sessions: 'carry' | 'drop'
|
|
37
49
|
readonly manifestPath?: string | undefined
|
|
38
50
|
readonly slugsJson?: string | undefined
|
|
51
|
+
/**
|
|
52
|
+
* The `--source-map` JSON (issue #68): model → source table name, or
|
|
53
|
+
* `null` for "model not installed". Missing mapped tables abort the
|
|
54
|
+
* run before anything is written without it.
|
|
55
|
+
*/
|
|
56
|
+
readonly sourceMapJson?: string | undefined
|
|
39
57
|
readonly json?: boolean
|
|
40
58
|
readonly log?: (message: string) => void
|
|
41
59
|
readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
|
|
@@ -67,33 +85,53 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
|
|
|
67
85
|
return { ...defaultTargetSlugs, ...(parsed as Record<string, string>) }
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/** The resolved source layout (`--source-map` over the default layout). */
|
|
89
|
+
function sourceTablesOf(options: ImportCommandOptions): ResolvedSourceTables {
|
|
90
|
+
return resolveSourceTables(
|
|
91
|
+
options.sourceMapJson === undefined ? undefined : parseSourceMapJson(options.sourceMapJson)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fail-closed probe (issue #68): runs FIRST, before any plan, read, or
|
|
97
|
+
* Payload boot. Findings come back as unmappable rows — the caller's
|
|
98
|
+
* exit-1 path — so nothing is silently read as empty.
|
|
99
|
+
*/
|
|
100
|
+
async function probeOrFail(
|
|
101
|
+
options: ImportCommandOptions,
|
|
102
|
+
log: (message: string) => void
|
|
103
|
+
): Promise<readonly SourceLayoutIssue[]> {
|
|
104
|
+
const client = await openSource(options.databaseUrl)
|
|
105
|
+
try {
|
|
106
|
+
const issues = await probeSourceLayout(client, sourceTablesOf(options))
|
|
107
|
+
for (const issue of issues) {
|
|
108
|
+
log(` X ${issue.model}: ${issue.reason}`)
|
|
109
|
+
}
|
|
110
|
+
return issues
|
|
111
|
+
} finally {
|
|
112
|
+
await client.end()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
70
116
|
/** Plan-only pass: counts, orphans, unverified users, warnings. */
|
|
71
|
-
async function buildPlan(
|
|
117
|
+
async function buildPlan(
|
|
118
|
+
options: ImportCommandOptions,
|
|
119
|
+
sourceTables: ResolvedSourceTables
|
|
120
|
+
): Promise<{
|
|
72
121
|
plan: ImportPlan
|
|
73
122
|
sourceCounts: Record<string, number>
|
|
74
123
|
}> {
|
|
75
124
|
const client = await openSource(options.databaseUrl)
|
|
76
125
|
try {
|
|
77
|
-
const sourceCounts = await countTables(client,
|
|
78
|
-
|
|
79
|
-
'accounts',
|
|
80
|
-
'sessions',
|
|
81
|
-
'verifications',
|
|
82
|
-
'api_keys',
|
|
83
|
-
'jwks',
|
|
84
|
-
'organizations',
|
|
85
|
-
'members',
|
|
86
|
-
'invitations',
|
|
87
|
-
'users_role',
|
|
88
|
-
])
|
|
89
|
-
const orphans = await findOrphans(client)
|
|
126
|
+
const sourceCounts = await countTables(client, presentTables(sourceTables))
|
|
127
|
+
const { orphans, skipped } = await findOrphans(client, sourceTables)
|
|
90
128
|
return {
|
|
91
129
|
sourceCounts,
|
|
92
130
|
plan: {
|
|
93
131
|
rows: [],
|
|
94
132
|
unmappable: orphans,
|
|
95
133
|
sessions: options.sessions,
|
|
96
|
-
warnings:
|
|
134
|
+
warnings: skipped.map((reason) => `orphan check ${reason}`),
|
|
97
135
|
},
|
|
98
136
|
}
|
|
99
137
|
} finally {
|
|
@@ -110,13 +148,34 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
110
148
|
}> {
|
|
111
149
|
const log = log_(options)
|
|
112
150
|
const slugs = slugsOf(options)
|
|
113
|
-
const
|
|
151
|
+
const sourceTables = sourceTablesOf(options)
|
|
152
|
+
|
|
153
|
+
// The probe runs before anything else (issue #68): a missing mapped
|
|
154
|
+
// table or column aborts here — exit 1, nothing planned or written.
|
|
155
|
+
const probeIssues = await probeOrFail(options, log)
|
|
156
|
+
if (probeIssues.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
exitCode: 1,
|
|
159
|
+
plan: {
|
|
160
|
+
rows: [],
|
|
161
|
+
unmappable: probeIssues.map((issue) => ({
|
|
162
|
+
model: issue.model,
|
|
163
|
+
key: issue.key,
|
|
164
|
+
reason: issue.reason,
|
|
165
|
+
})),
|
|
166
|
+
sessions: options.sessions,
|
|
167
|
+
warnings: [],
|
|
168
|
+
},
|
|
169
|
+
unverified: [],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { plan: basePlan, sourceCounts } = await buildPlan(options, sourceTables)
|
|
114
174
|
|
|
115
175
|
const unverified = await (async () => {
|
|
116
176
|
const client = await openSource(options.databaseUrl)
|
|
117
177
|
try {
|
|
118
|
-
|
|
119
|
-
return unverifiedEmails(await readSourceUsers(client))
|
|
178
|
+
return unverifiedEmails(await readSourceUsers(client, sourceTables))
|
|
120
179
|
} finally {
|
|
121
180
|
await client.end()
|
|
122
181
|
}
|
|
@@ -152,6 +211,7 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
152
211
|
sourceCounts,
|
|
153
212
|
sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
|
|
154
213
|
slugs,
|
|
214
|
+
sourceTables,
|
|
155
215
|
log,
|
|
156
216
|
})
|
|
157
217
|
const plan: ImportPlan = {
|
|
@@ -186,11 +246,34 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
186
246
|
/** The `verify` command. */
|
|
187
247
|
export async function runVerify(options: ImportCommandOptions): Promise<VerifyResult> {
|
|
188
248
|
const log = log_(options)
|
|
249
|
+
|
|
250
|
+
// The probe runs first (issue #68): a missing mapped table fails the
|
|
251
|
+
// check with a blocker — before any Payload boot or read.
|
|
252
|
+
const probeIssues = await probeOrFail(options, log)
|
|
253
|
+
if (probeIssues.length > 0) {
|
|
254
|
+
const result: VerifyResult = {
|
|
255
|
+
ok: false,
|
|
256
|
+
issues: probeIssues.map((issue) => ({
|
|
257
|
+
code: 'source-table-missing',
|
|
258
|
+
detail: issue.reason,
|
|
259
|
+
})),
|
|
260
|
+
checks: [],
|
|
261
|
+
}
|
|
262
|
+
if (!options.json) {
|
|
263
|
+
for (const issue of result.issues) {
|
|
264
|
+
log(` [${issue.code}] ${issue.detail}`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
|
|
189
270
|
const payload = await (options.loadPayload ?? loadPayloadInstance)(options.configPath)
|
|
190
271
|
const result = await verifyImport({
|
|
191
272
|
payload,
|
|
192
273
|
sourceConnectionString: options.databaseUrl,
|
|
193
274
|
slugs: slugsOf(options),
|
|
275
|
+
sourceTables: sourceTablesOf(options),
|
|
276
|
+
sessions: options.sessions,
|
|
194
277
|
})
|
|
195
278
|
if (!options.json) {
|
|
196
279
|
for (const check of result.checks) {
|
package/src/importer/source.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { Client } from 'pg'
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
defaultSourceTables,
|
|
4
|
+
type ResolvedSourceTables,
|
|
5
|
+
type SourceModel,
|
|
6
|
+
type Unmappable,
|
|
7
|
+
} from './types.js'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Read side (issue #50): the payload-auth layout, straight from
|
|
6
11
|
* Postgres. Rows are read with plain SELECTs — the source tables are
|
|
7
12
|
* never modified until `finalize` (which only marks the manifest; the
|
|
8
13
|
* drop itself stays the operator's step).
|
|
14
|
+
*
|
|
15
|
+
* Fail closed (issue #68): nothing here tolerates a missing table.
|
|
16
|
+
* `probeSourceLayout` is the gate — the run commands call it first and
|
|
17
|
+
* abort on any finding — so every read below assumes the mapped tables
|
|
18
|
+
* exist and throws if they don't. A customized layout renames tables
|
|
19
|
+
* with `--source-map`, never with silent skips.
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
/** One payload-auth source user with its role rows and accounts. */
|
|
@@ -37,6 +48,34 @@ export type SourceAccount = {
|
|
|
37
48
|
|
|
38
49
|
export type SourceRow = Record<string, unknown>
|
|
39
50
|
|
|
51
|
+
/** One fail-closed finding: a mapped model's table or column is absent. */
|
|
52
|
+
export type SourceLayoutIssue = {
|
|
53
|
+
readonly model: SourceModel
|
|
54
|
+
/** The table (or `table.column`) the probe looked for. */
|
|
55
|
+
readonly key: string
|
|
56
|
+
/** Human reason, naming the table and the `--source-map` escape hatch. */
|
|
57
|
+
readonly reason: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The columns each model's reads dereference (issue #68). A layout
|
|
62
|
+
* missing any of these cannot be imported faithfully — the probe
|
|
63
|
+
* blocks on it instead of letting the SELECT fail mid-run (or worse,
|
|
64
|
+
* silently store `undefined`-derived values).
|
|
65
|
+
*/
|
|
66
|
+
const requiredColumns: Readonly<Record<SourceModel, readonly string[]>> = {
|
|
67
|
+
users: ['id', 'email', 'email_verified'],
|
|
68
|
+
accounts: ['id', 'account_id', 'provider_id', 'user_id'],
|
|
69
|
+
sessions: ['id', 'user_id', 'token', 'expires_at'],
|
|
70
|
+
verifications: ['id', 'identifier', 'value', 'expires_at'],
|
|
71
|
+
api_keys: ['id', 'key', 'reference_id'],
|
|
72
|
+
jwks: ['id', 'public_key', 'private_key'],
|
|
73
|
+
organizations: ['id', 'name', 'slug'],
|
|
74
|
+
members: ['id', 'organization_id', 'user_id'],
|
|
75
|
+
invitations: ['id', 'organization_id', 'email'],
|
|
76
|
+
users_role: ['parent_id', 'value'],
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
export async function openSource(connectionString: string): Promise<Client> {
|
|
41
80
|
const client = new Client({ connectionString })
|
|
42
81
|
await client.connect()
|
|
@@ -61,18 +100,62 @@ async function tableExists(client: Client, table: string): Promise<boolean> {
|
|
|
61
100
|
return result.rows.length > 0
|
|
62
101
|
}
|
|
63
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Fail-closed probe (issue #68): every mapped model's table must
|
|
105
|
+
* exist (a `null` mapping records "not installed" and is skipped),
|
|
106
|
+
* and every column the reads dereference must be present. The run
|
|
107
|
+
* commands call this FIRST and abort on any finding — nothing is
|
|
108
|
+
* read, planned, or written past a failed probe.
|
|
109
|
+
*/
|
|
110
|
+
export async function probeSourceLayout(
|
|
111
|
+
client: Client,
|
|
112
|
+
tables: ResolvedSourceTables
|
|
113
|
+
): Promise<readonly SourceLayoutIssue[]> {
|
|
114
|
+
const issues: SourceLayoutIssue[] = []
|
|
115
|
+
for (const model of Object.keys(defaultSourceTables) as SourceModel[]) {
|
|
116
|
+
const table = tables[model]
|
|
117
|
+
if (table === null) {
|
|
118
|
+
// Explicitly not installed: skipped by config, never an error.
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (!(await tableExists(client, table))) {
|
|
122
|
+
issues.push({
|
|
123
|
+
model,
|
|
124
|
+
key: table,
|
|
125
|
+
reason: `model '${model}' expects source table '${table}', which does not exist — if your layout renamed it, pass --source-map '{"${model}":"<table>"}', or map it null to record it as not installed`,
|
|
126
|
+
})
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const columns = await columnsOf(client, table)
|
|
130
|
+
for (const column of requiredColumns[model]) {
|
|
131
|
+
if (!columns.has(column)) {
|
|
132
|
+
issues.push({
|
|
133
|
+
model,
|
|
134
|
+
key: `${table}.${column}`,
|
|
135
|
+
reason: `model '${model}' needs column '${column}' on source table '${table}', which does not exist — --source-map maps tables, not columns, so this layout needs a manual review`,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return issues
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Row counts for the given (mapped, existing) tables. A missing table
|
|
145
|
+
* throws — the probe owns the structured "missing table" report, so a
|
|
146
|
+
* throw here means the probe was skipped.
|
|
147
|
+
*/
|
|
64
148
|
export async function countTables(
|
|
65
149
|
client: Client,
|
|
66
150
|
tables: readonly string[]
|
|
67
151
|
): Promise<Record<string, number>> {
|
|
68
152
|
const counts: Record<string, number> = {}
|
|
69
153
|
for (const table of tables) {
|
|
70
|
-
if (await tableExists(client, table)) {
|
|
71
|
-
|
|
72
|
-
counts[table] = (counted.rows[0] as { n?: number } | undefined)?.n ?? 0
|
|
73
|
-
} else {
|
|
74
|
-
counts[table] = 0
|
|
154
|
+
if (!(await tableExists(client, table))) {
|
|
155
|
+
throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
|
|
75
156
|
}
|
|
157
|
+
const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
|
|
158
|
+
counts[table] = (counted.rows[0] as { n?: number } | undefined)?.n ?? 0
|
|
76
159
|
}
|
|
77
160
|
return counts
|
|
78
161
|
}
|
|
@@ -86,25 +169,38 @@ function text(value: unknown): string | null {
|
|
|
86
169
|
}
|
|
87
170
|
|
|
88
171
|
/** Every source user, with role rows and accounts attached. */
|
|
89
|
-
export async function readSourceUsers(
|
|
90
|
-
|
|
172
|
+
export async function readSourceUsers(
|
|
173
|
+
client: Client,
|
|
174
|
+
tables: ResolvedSourceTables = defaultSourceTables
|
|
175
|
+
): Promise<SourceUser[]> {
|
|
176
|
+
const usersTable = tables['users']
|
|
177
|
+
const accountsTable = tables['accounts']
|
|
178
|
+
// A null-mapped model is "not installed" by config: no rows, no read.
|
|
179
|
+
if (usersTable === null) {
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
91
182
|
const users = await client.query(
|
|
92
|
-
`SELECT id, email, name, email_verified, image, created_at, updated_at FROM
|
|
183
|
+
`SELECT id, email, name, email_verified, image, created_at, updated_at FROM ${usersTable} ORDER BY id`
|
|
93
184
|
)
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
185
|
+
const rolesTable = tables['users_role']
|
|
186
|
+
const roleRows =
|
|
187
|
+
rolesTable === null
|
|
188
|
+
? { rows: [] as unknown[] }
|
|
189
|
+
: await client.query(`SELECT parent_id, value FROM ${rolesTable} ORDER BY parent_id, "order"`)
|
|
97
190
|
const rolesByUser = new Map<string, string[]>()
|
|
98
191
|
for (const row of roleRows.rows as { parent_id: number; value: string }[]) {
|
|
99
192
|
const list = rolesByUser.get(String(row.parent_id)) ?? []
|
|
100
193
|
list.push(row.value)
|
|
101
194
|
rolesByUser.set(String(row.parent_id), list)
|
|
102
195
|
}
|
|
103
|
-
const accounts =
|
|
104
|
-
|
|
196
|
+
const accounts =
|
|
197
|
+
accountsTable === null
|
|
198
|
+
? { rows: [] as unknown[] }
|
|
199
|
+
: await client.query(
|
|
200
|
+
`SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
|
|
105
201
|
scope, access_token_expires_at, refresh_token_expires_at, user_id
|
|
106
|
-
FROM
|
|
107
|
-
|
|
202
|
+
FROM ${accountsTable} ORDER BY id`
|
|
203
|
+
)
|
|
108
204
|
const accountsByUser = new Map<string, SourceAccount[]>()
|
|
109
205
|
for (const row of accounts.rows as Record<string, unknown>[]) {
|
|
110
206
|
const userId = String(row.user_id)
|
|
@@ -142,51 +238,100 @@ export async function readSourceUsers(client: Client): Promise<SourceUser[]> {
|
|
|
142
238
|
return out
|
|
143
239
|
}
|
|
144
240
|
|
|
145
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Rows of one flat plugin table (sessions, verifications, apiKeys, jwks, …).
|
|
243
|
+
* A missing table throws — a `null` mapping keeps the caller from ever
|
|
244
|
+
* asking for it, and an unmapped missing table aborts at the probe.
|
|
245
|
+
*/
|
|
146
246
|
export async function readFlatRows(
|
|
147
247
|
client: Client,
|
|
148
248
|
table: string,
|
|
149
249
|
columns: readonly string[]
|
|
150
250
|
): Promise<SourceRow[]> {
|
|
151
251
|
if (!(await tableExists(client, table))) {
|
|
152
|
-
|
|
252
|
+
throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
|
|
153
253
|
}
|
|
154
254
|
const result = await client.query(`SELECT ${columns.join(', ')} FROM ${table} ORDER BY id`)
|
|
155
255
|
return result.rows as SourceRow[]
|
|
156
256
|
}
|
|
157
257
|
|
|
258
|
+
/** What the orphan sweep found, and which checks could not run (and why). */
|
|
259
|
+
export type OrphanScan = {
|
|
260
|
+
readonly orphans: readonly Unmappable[]
|
|
261
|
+
/** Checks skipped with a reason — reported, never silent (issue #68). */
|
|
262
|
+
readonly skipped: readonly string[]
|
|
263
|
+
}
|
|
264
|
+
|
|
158
265
|
/**
|
|
159
266
|
* Members and invitations reference organizations and users by numeric
|
|
160
267
|
* id; the importer remaps to the target's baIds. Orphaned references
|
|
161
268
|
* are unmappable — they abort the run rather than skip silently.
|
|
269
|
+
*
|
|
270
|
+
* Every check is table-mapped (issue #68). The api-key check probes
|
|
271
|
+
* its columns: better-auth names the owner reference `reference_id`
|
|
272
|
+
* (1.7 / payload-auth layouts) or `user_id` (1.6-era layouts); when
|
|
273
|
+
* neither exists the check is reported as skipped-with-reason instead
|
|
274
|
+
* of passing silently.
|
|
162
275
|
*/
|
|
163
|
-
export async function findOrphans(
|
|
276
|
+
export async function findOrphans(
|
|
277
|
+
client: Client,
|
|
278
|
+
tables: ResolvedSourceTables = defaultSourceTables
|
|
279
|
+
): Promise<OrphanScan> {
|
|
164
280
|
const orphans: Unmappable[] = []
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
281
|
+
const skipped: string[] = []
|
|
282
|
+
const checks: readonly {
|
|
283
|
+
model: string
|
|
284
|
+
table: string | null
|
|
285
|
+
column: string
|
|
286
|
+
target: string | null
|
|
287
|
+
}[] = [
|
|
288
|
+
{ model: 'account', table: tables['accounts'], column: 'user_id', target: tables['users'] },
|
|
289
|
+
{ model: 'session', table: tables['sessions'], column: 'user_id', target: tables['users'] },
|
|
290
|
+
{
|
|
291
|
+
model: 'member',
|
|
292
|
+
table: tables['members'],
|
|
293
|
+
column: 'organization_id',
|
|
294
|
+
target: tables['organizations'],
|
|
295
|
+
},
|
|
296
|
+
{ model: 'member', table: tables['members'], column: 'user_id', target: tables['users'] },
|
|
170
297
|
{
|
|
171
298
|
model: 'invitation',
|
|
172
|
-
table: 'invitations',
|
|
299
|
+
table: tables['invitations'],
|
|
173
300
|
column: 'organization_id',
|
|
174
|
-
target: 'organizations',
|
|
301
|
+
target: tables['organizations'],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
model: 'invitation',
|
|
305
|
+
table: tables['invitations'],
|
|
306
|
+
column: 'inviter_id',
|
|
307
|
+
target: tables['users'],
|
|
175
308
|
},
|
|
176
|
-
{ model: 'invitation', table: 'invitations', column: 'inviter_id', target: 'users' },
|
|
177
|
-
{ model: 'apikey', table: 'api_keys', column: 'user_id', target: 'users' },
|
|
178
309
|
]
|
|
179
310
|
for (const check of checks) {
|
|
311
|
+
if (check.table === null || check.target === null) {
|
|
312
|
+
// Null-mapped model: not installed by config — an expected skip,
|
|
313
|
+
// recorded so the report says why nothing was checked.
|
|
314
|
+
skipped.push(
|
|
315
|
+
`${check.model}: skipped — ${check.table === null ? 'the model is null-mapped (not installed)' : `its target '${String(check.target)}' is null-mapped (not installed)`}`
|
|
316
|
+
)
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
180
319
|
const exists = await Promise.all([
|
|
181
320
|
tableExists(client, check.table),
|
|
182
321
|
tableExists(client, check.target),
|
|
183
322
|
])
|
|
184
323
|
if (!exists[0] || !exists[1]) {
|
|
324
|
+
skipped.push(
|
|
325
|
+
`${check.model}: skipped — table '${!exists[0] ? check.table : String(check.target)}' is absent`
|
|
326
|
+
)
|
|
185
327
|
continue
|
|
186
328
|
}
|
|
187
329
|
// Customized layouts drop columns; a missing reference column is
|
|
188
|
-
// not an
|
|
330
|
+
// reported, not treated as an all-clear.
|
|
189
331
|
if (!(await columnsOf(client, check.table)).has(check.column)) {
|
|
332
|
+
skipped.push(
|
|
333
|
+
`${check.model}: skipped — column '${check.column}' is absent on '${check.table}'`
|
|
334
|
+
)
|
|
190
335
|
continue
|
|
191
336
|
}
|
|
192
337
|
const result = await client.query(
|
|
@@ -202,7 +347,46 @@ export async function findOrphans(client: Client): Promise<readonly Unmappable[]
|
|
|
202
347
|
})
|
|
203
348
|
}
|
|
204
349
|
}
|
|
205
|
-
|
|
350
|
+
|
|
351
|
+
// api_keys: the owner column is named per layout — probe it.
|
|
352
|
+
const apiKeysTable = tables['api_keys']
|
|
353
|
+
if (apiKeysTable === null) {
|
|
354
|
+
skipped.push('apikey: skipped — the model is null-mapped (not installed)')
|
|
355
|
+
} else if (!(await tableExists(client, apiKeysTable))) {
|
|
356
|
+
skipped.push(`apikey: skipped — table '${apiKeysTable}' is absent`)
|
|
357
|
+
} else {
|
|
358
|
+
const columns = await columnsOf(client, apiKeysTable)
|
|
359
|
+
const column = columns.has('reference_id')
|
|
360
|
+
? 'reference_id'
|
|
361
|
+
: columns.has('user_id')
|
|
362
|
+
? 'user_id'
|
|
363
|
+
: null
|
|
364
|
+
const usersTable = tables['users']
|
|
365
|
+
if (column === null) {
|
|
366
|
+
skipped.push(
|
|
367
|
+
`apikey: skipped — neither 'reference_id' nor 'user_id' exists on '${apiKeysTable}'; the owner check cannot run`
|
|
368
|
+
)
|
|
369
|
+
} else if (usersTable === null) {
|
|
370
|
+
skipped.push('apikey: skipped — its target is null-mapped (not installed)')
|
|
371
|
+
} else {
|
|
372
|
+
// The owner column can be varchar (reference_id) or integer
|
|
373
|
+
// (1.6-era user_id): compare through text on both sides so the
|
|
374
|
+
// check runs on either layout.
|
|
375
|
+
const result = await client.query(
|
|
376
|
+
`SELECT t.id FROM ${apiKeysTable} t
|
|
377
|
+
WHERE t.${column} IS NOT NULL
|
|
378
|
+
AND NOT EXISTS (SELECT 1 FROM ${usersTable} s WHERE s.id::text = t.${column}::text)`
|
|
379
|
+
)
|
|
380
|
+
for (const row of result.rows as { id: unknown }[]) {
|
|
381
|
+
orphans.push({
|
|
382
|
+
model: 'apikey',
|
|
383
|
+
key: String(row.id),
|
|
384
|
+
reason: `${apiKeysTable}.${column} references a missing ${usersTable} row`,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { orphans, skipped }
|
|
206
390
|
}
|
|
207
391
|
|
|
208
392
|
/** Users whose email never verified — reported, never granted `linkable`. */
|
package/src/importer/types.ts
CHANGED
|
@@ -67,6 +67,92 @@ export type ImportResult = {
|
|
|
67
67
|
readonly plan: ImportPlan
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The models the source side names (issue #68). One entry per
|
|
72
|
+
* payload-auth table the importer/doctor reads — the keys are stable
|
|
73
|
+
* model names; the values are whatever the source database actually
|
|
74
|
+
* called the table.
|
|
75
|
+
*/
|
|
76
|
+
export type SourceModel =
|
|
77
|
+
| 'users'
|
|
78
|
+
| 'accounts'
|
|
79
|
+
| 'sessions'
|
|
80
|
+
| 'verifications'
|
|
81
|
+
| 'api_keys'
|
|
82
|
+
| 'jwks'
|
|
83
|
+
| 'organizations'
|
|
84
|
+
| 'members'
|
|
85
|
+
| 'invitations'
|
|
86
|
+
| 'users_role'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A model → source-table override for custom layouts (`--source-map`):
|
|
90
|
+
* `{"accounts":"user_accounts","sessions":"user_sessions"}`. A `null`
|
|
91
|
+
* value means the model is explicitly NOT installed on the source
|
|
92
|
+
* (recorded as skipped-by-config, never probed as an error).
|
|
93
|
+
*/
|
|
94
|
+
export type SourceTableMap = { readonly [M in SourceModel]?: string | null }
|
|
95
|
+
|
|
96
|
+
/** Every model resolved to its source table (`null` = not installed). */
|
|
97
|
+
export type ResolvedSourceTables = Readonly<Record<SourceModel, string | null>>
|
|
98
|
+
|
|
99
|
+
/** payload-auth's default Postgres layout. */
|
|
100
|
+
export const defaultSourceTables: Readonly<Record<SourceModel, string>> = {
|
|
101
|
+
users: 'users',
|
|
102
|
+
accounts: 'accounts',
|
|
103
|
+
sessions: 'sessions',
|
|
104
|
+
verifications: 'verifications',
|
|
105
|
+
api_keys: 'api_keys',
|
|
106
|
+
jwks: 'jwks',
|
|
107
|
+
organizations: 'organizations',
|
|
108
|
+
members: 'members',
|
|
109
|
+
invitations: 'invitations',
|
|
110
|
+
users_role: 'users_role',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** All known source models — the error message names them. */
|
|
114
|
+
export const sourceModelNames: readonly SourceModel[] = Object.keys(
|
|
115
|
+
defaultSourceTables
|
|
116
|
+
) as SourceModel[]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse the `--source-map` JSON (same boundary style as `--slugs`):
|
|
120
|
+
* keys must be known source models, values a table name or `null`
|
|
121
|
+
* ("model not installed"). Unknown keys throw — a typo would
|
|
122
|
+
* otherwise silently skip the model it meant to map.
|
|
123
|
+
*/
|
|
124
|
+
export function parseSourceMapJson(json: string): SourceTableMap {
|
|
125
|
+
const parsed: unknown = JSON.parse(json)
|
|
126
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
127
|
+
throw new Error('--source-map must be a JSON object of model -> table name (or null)')
|
|
128
|
+
}
|
|
129
|
+
const map: Record<string, string | null> = {}
|
|
130
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
131
|
+
if (!sourceModelNames.includes(key as SourceModel)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`--source-map has an unknown model '${key}'; the known models are: ${sourceModelNames.join(', ')}`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
if (value !== null && (typeof value !== 'string' || value.trim() === '')) {
|
|
137
|
+
throw new Error(`--source-map['${key}'] must be a table name or null`)
|
|
138
|
+
}
|
|
139
|
+
map[key] = value === null ? null : value.trim()
|
|
140
|
+
}
|
|
141
|
+
return map
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Merge `--source-map` overrides onto the default layout. */
|
|
145
|
+
export function resolveSourceTables(map: SourceTableMap | undefined): ResolvedSourceTables {
|
|
146
|
+
return { ...defaultSourceTables, ...(map ?? {}) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The tables to read (the mapped ones — `null` entries are skipped). */
|
|
150
|
+
export function presentTables(tables: ResolvedSourceTables): readonly string[] {
|
|
151
|
+
return sourceModelNames
|
|
152
|
+
.map((model) => tables[model])
|
|
153
|
+
.filter((table): table is string => table !== null)
|
|
154
|
+
}
|
|
155
|
+
|
|
70
156
|
/** The models the importer copies. */
|
|
71
157
|
export type TargetModel =
|
|
72
158
|
| 'user'
|
package/src/index.ts
CHANGED
|
@@ -12,12 +12,26 @@ export {
|
|
|
12
12
|
} from './doctor/run-doctor.js'
|
|
13
13
|
export type { DoctorOptions } from './doctor/run-doctor.js'
|
|
14
14
|
export { checkVersions, payloadBand, betterAuthBand, nextBand } from './doctor/bands.js'
|
|
15
|
-
export { classifyPlugins } from './doctor/plugins.js'
|
|
16
|
-
export { scanSource, projectedUserFields } from './doctor/source-scan.js'
|
|
15
|
+
export { classifyPlugins, pluginsFromSourceTree } from './doctor/plugins.js'
|
|
16
|
+
export { scanSource, projectedUserFields, listSourceFiles } from './doctor/source-scan.js'
|
|
17
17
|
export { analyzeUsersCollection, findUsersCollection, localStrategyOn } from './doctor/users.js'
|
|
18
18
|
export { introspectDatabase } from './doctor/database.js'
|
|
19
19
|
export { runFromPayloadAuth, runVerify, runRollback, runFinalize } from './importer/run-import.js'
|
|
20
20
|
export { applyImport, summarizePlanRows } from './importer/apply.js'
|
|
21
|
+
export {
|
|
22
|
+
countTables,
|
|
23
|
+
findOrphans,
|
|
24
|
+
openSource,
|
|
25
|
+
probeSourceLayout,
|
|
26
|
+
readFlatRows,
|
|
27
|
+
readSourceUsers,
|
|
28
|
+
unverifiedEmails,
|
|
29
|
+
type OrphanScan,
|
|
30
|
+
type SourceLayoutIssue,
|
|
31
|
+
type SourceAccount,
|
|
32
|
+
type SourceRow,
|
|
33
|
+
type SourceUser,
|
|
34
|
+
} from './importer/source.js'
|
|
21
35
|
export {
|
|
22
36
|
readManifest,
|
|
23
37
|
writeManifest,
|
|
@@ -29,8 +43,16 @@ export {
|
|
|
29
43
|
type VerifyResult,
|
|
30
44
|
} from './importer/lifecycle.js'
|
|
31
45
|
export {
|
|
46
|
+
defaultSourceTables,
|
|
32
47
|
defaultTargetSlugs,
|
|
48
|
+
parseSourceMapJson,
|
|
49
|
+
presentTables,
|
|
50
|
+
resolveSourceTables,
|
|
51
|
+
sourceModelNames,
|
|
33
52
|
type CreatedRow,
|
|
53
|
+
type ResolvedSourceTables,
|
|
54
|
+
type SourceModel,
|
|
55
|
+
type SourceTableMap,
|
|
34
56
|
type TargetModel,
|
|
35
57
|
type TargetSlugMap,
|
|
36
58
|
type FieldWrite,
|
|
@@ -40,6 +62,7 @@ export {
|
|
|
40
62
|
type PlanRow,
|
|
41
63
|
type Unmappable,
|
|
42
64
|
} from './importer/types.js'
|
|
65
|
+
export { exportToJsonl, parseExportTables, exportTables } from './exporters.js'
|
|
43
66
|
export { cliMain } from './cli.js'
|
|
44
67
|
export type {
|
|
45
68
|
BehaviourChange,
|