firstly 0.10.2 → 0.10.3
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/CHANGELOG.md +6 -0
- package/esm/sqlAdmin/SqlDriftController.d.ts +54 -0
- package/esm/sqlAdmin/SqlDriftController.js +64 -0
- package/esm/sqlAdmin/index.d.ts +2 -0
- package/esm/sqlAdmin/index.js +2 -0
- package/esm/sqlAdmin/server/drift/createIndex.d.ts +29 -0
- package/esm/sqlAdmin/server/drift/createIndex.js +32 -0
- package/esm/sqlAdmin/server/drift/ident.d.ts +2 -0
- package/esm/sqlAdmin/server/drift/ident.js +2 -0
- package/esm/sqlAdmin/server/drift/nullable.d.ts +31 -0
- package/esm/sqlAdmin/server/drift/nullable.js +79 -0
- package/esm/sqlAdmin/server/drift/orphanColumns.d.ts +29 -0
- package/esm/sqlAdmin/server/drift/orphanColumns.js +76 -0
- package/esm/sqlAdmin/server/drift/planDropColumns.d.ts +27 -0
- package/esm/sqlAdmin/server/drift/planDropColumns.js +39 -0
- package/esm/sqlAdmin/server/drift/planNullableDrift.d.ts +46 -0
- package/esm/sqlAdmin/server/drift/planNullableDrift.js +35 -0
- package/esm/sqlAdmin/server/drift/planPkSync.d.ts +36 -0
- package/esm/sqlAdmin/server/drift/planPkSync.js +43 -0
- package/esm/sqlAdmin/server/drift/primaryKeys.d.ts +23 -0
- package/esm/sqlAdmin/server/drift/primaryKeys.js +60 -0
- package/esm/sqlAdmin/server/drift/relationIndexes.d.ts +43 -0
- package/esm/sqlAdmin/server/drift/relationIndexes.js +91 -0
- package/esm/sqlAdmin/server/index.d.ts +18 -1
- package/esm/sqlAdmin/server/index.js +9 -1
- package/esm/sqlAdmin/ui/SqlDrift.svelte +480 -0
- package/esm/sqlAdmin/ui/SqlDrift.svelte.d.ts +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Schema drift UI: one section per flag enabled in `sqlAdmin({ drift })`.
|
|
4
|
+
*
|
|
5
|
+
* Styled against the host app's semantic theme tokens, like `<SqlAdmin />`.
|
|
6
|
+
* Results are logged to the console as `for AI:` after each run.
|
|
7
|
+
*/
|
|
8
|
+
import { onMount } from 'svelte'
|
|
9
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
10
|
+
|
|
11
|
+
import { SqlDriftController, type SqlDriftFlag } from '../SqlDriftController'
|
|
12
|
+
|
|
13
|
+
// Inferred from the BackendMethods, so this never imports from `server/`.
|
|
14
|
+
type PkResult = Awaited<ReturnType<typeof SqlDriftController.primaryKeys>>
|
|
15
|
+
type RelResult = Awaited<ReturnType<typeof SqlDriftController.relationIndexes>>
|
|
16
|
+
type DropResult = Awaited<ReturnType<typeof SqlDriftController.orphanColumns>>
|
|
17
|
+
type NullResult = Awaited<ReturnType<typeof SqlDriftController.nullable>>
|
|
18
|
+
|
|
19
|
+
let flags = $state<Record<SqlDriftFlag, boolean> | null>(null)
|
|
20
|
+
let flagsError = $state('')
|
|
21
|
+
onMount(async () => {
|
|
22
|
+
try {
|
|
23
|
+
flags = await SqlDriftController.driftFlags()
|
|
24
|
+
} catch (e) {
|
|
25
|
+
flagsError = e instanceof Error ? e.message : String(e)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// --- Flow 1: primary key drift (dry run & upsert) ---
|
|
30
|
+
let pkLoading = $state(false)
|
|
31
|
+
let pkError = $state('')
|
|
32
|
+
let pkResult = $state<PkResult | null>(null)
|
|
33
|
+
|
|
34
|
+
const pkPlans = $derived(
|
|
35
|
+
(pkResult?.plans ?? []).toSorted((a, b) => {
|
|
36
|
+
if (a.action === 'ok' && b.action !== 'ok') return 1
|
|
37
|
+
if (a.action !== 'ok' && b.action === 'ok') return -1
|
|
38
|
+
return a.table.localeCompare(b.table)
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
const pkDrift = $derived(pkPlans.filter((p) => p.action !== 'ok').length)
|
|
42
|
+
|
|
43
|
+
async function runPk(apply: boolean) {
|
|
44
|
+
if (
|
|
45
|
+
apply &&
|
|
46
|
+
!window.confirm(`Apply ${pkDrift} migration(s)? This briefly locks the affected tables.`)
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
pkLoading = true
|
|
50
|
+
pkError = ''
|
|
51
|
+
try {
|
|
52
|
+
pkResult = await SqlDriftController.primaryKeys({ apply })
|
|
53
|
+
console.info('for AI:', JSON.stringify(pkResult))
|
|
54
|
+
} catch (e) {
|
|
55
|
+
pkError = e instanceof Error ? e.message : String(e)
|
|
56
|
+
} finally {
|
|
57
|
+
pkLoading = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Flow 2: relation indexes (dry run & create) ---
|
|
62
|
+
let relLoading = $state(false)
|
|
63
|
+
let relError = $state('')
|
|
64
|
+
let relResult = $state<RelResult | null>(null)
|
|
65
|
+
|
|
66
|
+
const relPlans = $derived(
|
|
67
|
+
(relResult?.plans ?? []).toSorted((a, b) => {
|
|
68
|
+
if (a.action === 'ok' && b.action !== 'ok') return 1
|
|
69
|
+
if (a.action !== 'ok' && b.action === 'ok') return -1
|
|
70
|
+
return a.name.localeCompare(b.name)
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
const relMissing = $derived(relPlans.filter((p) => p.action === 'create').length)
|
|
74
|
+
|
|
75
|
+
async function runRel(apply: boolean) {
|
|
76
|
+
if (apply && !window.confirm(`Create ${relMissing} relation index(es)?`)) return
|
|
77
|
+
relLoading = true
|
|
78
|
+
relError = ''
|
|
79
|
+
try {
|
|
80
|
+
relResult = await SqlDriftController.relationIndexes({ apply })
|
|
81
|
+
console.info('for AI:', JSON.stringify(relResult))
|
|
82
|
+
} catch (e) {
|
|
83
|
+
relError = e instanceof Error ? e.message : String(e)
|
|
84
|
+
} finally {
|
|
85
|
+
relLoading = false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Flow 3: drop orphan columns (dry run & selective drop) ---
|
|
90
|
+
// Destructive, so unlike flows 1 & 2 the dry run lists every orphan, nothing is
|
|
91
|
+
// selected by default, and apply drops only the ticked subset.
|
|
92
|
+
let dropLoading = $state(false)
|
|
93
|
+
let dropError = $state('')
|
|
94
|
+
let dropResult = $state<DropResult | null>(null)
|
|
95
|
+
const selected = new SvelteSet<string>()
|
|
96
|
+
|
|
97
|
+
const keyOf = (p: { table: string; column: string }) => `${p.table}.${p.column}`
|
|
98
|
+
|
|
99
|
+
const dropPlans = $derived(
|
|
100
|
+
(dropResult?.plans ?? []).toSorted(
|
|
101
|
+
(a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
function toggle(key: string) {
|
|
106
|
+
if (selected.has(key)) selected.delete(key)
|
|
107
|
+
else selected.add(key)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runDrop(apply: boolean) {
|
|
111
|
+
const cols = dropPlans.filter((p) => selected.has(keyOf(p)))
|
|
112
|
+
if (apply) {
|
|
113
|
+
if (cols.length === 0) return
|
|
114
|
+
if (
|
|
115
|
+
!window.confirm(
|
|
116
|
+
`Drop ${cols.length} column${cols.length === 1 ? '' : 's'}? This briefly locks the affected tables and cannot be undone.\n\n${cols.map(keyOf).join('\n')}`,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
dropLoading = true
|
|
122
|
+
dropError = ''
|
|
123
|
+
try {
|
|
124
|
+
dropResult = await SqlDriftController.orphanColumns(
|
|
125
|
+
apply ? { apply: true, columns: cols } : { apply: false },
|
|
126
|
+
)
|
|
127
|
+
selected.clear()
|
|
128
|
+
console.info('for AI:', JSON.stringify(dropResult))
|
|
129
|
+
} catch (e) {
|
|
130
|
+
dropError = e instanceof Error ? e.message : String(e)
|
|
131
|
+
} finally {
|
|
132
|
+
dropLoading = false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// --- Flow 4: nullability drift (dry run & selective fix) ---
|
|
136
|
+
// Same shape as flow 3 and for the same reason: rewriting '' to NULL cannot be
|
|
137
|
+
// undone, so nothing is ticked by default.
|
|
138
|
+
let nullLoading = $state(false)
|
|
139
|
+
let nullError = $state('')
|
|
140
|
+
let nullResult = $state<NullResult | null>(null)
|
|
141
|
+
const nullSelected = new SvelteSet<string>()
|
|
142
|
+
|
|
143
|
+
const nullPlans = $derived(
|
|
144
|
+
(nullResult?.plans ?? []).toSorted(
|
|
145
|
+
(a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
function toggleNull(key: string) {
|
|
150
|
+
if (nullSelected.has(key)) nullSelected.delete(key)
|
|
151
|
+
else nullSelected.add(key)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runNull(apply: boolean) {
|
|
155
|
+
const cols = nullPlans.filter((p) => nullSelected.has(keyOf(p)))
|
|
156
|
+
if (apply) {
|
|
157
|
+
if (cols.length === 0) return
|
|
158
|
+
const blanking = cols.filter((p) => p.blankToNull)
|
|
159
|
+
if (
|
|
160
|
+
!window.confirm(
|
|
161
|
+
`Relax ${cols.length} column${cols.length === 1 ? '' : 's'}?` +
|
|
162
|
+
(blanking.length
|
|
163
|
+
? `\n\n${blanking.length} of them will also rewrite every '' row to NULL, which cannot be undone:\n${blanking.map(keyOf).join('\n')}`
|
|
164
|
+
: ''),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
nullLoading = true
|
|
170
|
+
nullError = ''
|
|
171
|
+
try {
|
|
172
|
+
nullResult = await SqlDriftController.nullable(
|
|
173
|
+
apply ? { apply: true, columns: cols } : { apply: false },
|
|
174
|
+
)
|
|
175
|
+
nullSelected.clear()
|
|
176
|
+
console.info('for AI:', JSON.stringify(nullResult))
|
|
177
|
+
} catch (e) {
|
|
178
|
+
nullError = e instanceof Error ? e.message : String(e)
|
|
179
|
+
} finally {
|
|
180
|
+
nullLoading = false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
{#snippet runButton(label: string, loading: boolean, onclick: () => void)}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
{onclick}
|
|
189
|
+
disabled={loading}
|
|
190
|
+
class="bg-secondary text-secondary-foreground hover:bg-secondary/80 px-3 py-1.5 text-sm font-medium disabled:opacity-50"
|
|
191
|
+
>
|
|
192
|
+
{loading ? 'Running…' : label}
|
|
193
|
+
</button>
|
|
194
|
+
{/snippet}
|
|
195
|
+
|
|
196
|
+
{#snippet applyButton(label: string, disabled: boolean, onclick: () => void)}
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
{onclick}
|
|
200
|
+
{disabled}
|
|
201
|
+
class="bg-primary text-primary-foreground hover:bg-primary/90 px-3 py-1.5 text-sm font-medium disabled:opacity-50"
|
|
202
|
+
>
|
|
203
|
+
{label}
|
|
204
|
+
</button>
|
|
205
|
+
{/snippet}
|
|
206
|
+
|
|
207
|
+
{#snippet resultTable(headers: string[], children: import('svelte').Snippet)}
|
|
208
|
+
<div class="border-border overflow-auto border">
|
|
209
|
+
<table class="w-full border-collapse text-sm">
|
|
210
|
+
<thead class="bg-muted">
|
|
211
|
+
<tr>
|
|
212
|
+
{#each headers as h (h)}
|
|
213
|
+
<th class="border-border border-b px-3 py-2 text-left font-semibold">{h}</th>
|
|
214
|
+
{/each}
|
|
215
|
+
</tr>
|
|
216
|
+
</thead>
|
|
217
|
+
<tbody>
|
|
218
|
+
{@render children()}
|
|
219
|
+
</tbody>
|
|
220
|
+
</table>
|
|
221
|
+
</div>
|
|
222
|
+
{/snippet}
|
|
223
|
+
|
|
224
|
+
<div class="border-border bg-card text-card-foreground border">
|
|
225
|
+
<header class="px-5 py-4">
|
|
226
|
+
<h2 class="text-lg font-semibold">Schema drift</h2>
|
|
227
|
+
<p class="text-muted-foreground mt-1 text-sm">
|
|
228
|
+
Remult creates tables and adds columns, but never revisits them. Each check below is a dry run
|
|
229
|
+
first; applying runs in one transaction.
|
|
230
|
+
</p>
|
|
231
|
+
{#if flagsError}
|
|
232
|
+
<pre
|
|
233
|
+
class="border-destructive/40 bg-destructive/10 text-destructive mt-3 overflow-auto border p-3 text-sm">{flagsError}</pre>
|
|
234
|
+
{:else if flags && !Object.values(flags).some(Boolean)}
|
|
235
|
+
<p class="text-muted-foreground mt-3 text-sm">
|
|
236
|
+
No check enabled. Opt in with <code class="font-mono"
|
|
237
|
+
>sqlAdmin({ drift: { entities, relationIndexes: true, ... } })</code
|
|
238
|
+
>.
|
|
239
|
+
</p>
|
|
240
|
+
{/if}
|
|
241
|
+
</header>
|
|
242
|
+
|
|
243
|
+
<!-- Flow 1: primary keys -->
|
|
244
|
+
{#if flags?.primaryKeys}
|
|
245
|
+
<section class="border-border border-t p-5">
|
|
246
|
+
<h3 class="text-sm font-semibold">Primary key drift</h3>
|
|
247
|
+
<p class="text-muted-foreground mt-1 mb-4 max-w-[80ch] text-sm">
|
|
248
|
+
Postgres only sets a PK on first table creation and never ALTERs it, so a later
|
|
249
|
+
<code class="font-mono">id</code> change is silently ignored. Applying runs every migration atomically.
|
|
250
|
+
</p>
|
|
251
|
+
|
|
252
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
253
|
+
{@render runButton('Audit (dry run)', pkLoading, () => runPk(false))}
|
|
254
|
+
{@render applyButton(
|
|
255
|
+
`Apply ${pkDrift} migration${pkDrift === 1 ? '' : 's'}`,
|
|
256
|
+
pkLoading || pkDrift === 0,
|
|
257
|
+
() => runPk(true),
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{#if pkError}
|
|
262
|
+
<pre
|
|
263
|
+
class="border-destructive/40 bg-destructive/10 text-destructive mb-4 overflow-auto border p-3 text-sm">{pkError}</pre>
|
|
264
|
+
{/if}
|
|
265
|
+
|
|
266
|
+
{#if pkResult}
|
|
267
|
+
{#if pkResult.applied}
|
|
268
|
+
<p class="border-primary/30 bg-primary/10 text-primary mb-3 border p-2 text-sm">
|
|
269
|
+
Applied · {pkDrift} table{pkDrift === 1 ? '' : 's'} migrated
|
|
270
|
+
</p>
|
|
271
|
+
{/if}
|
|
272
|
+
|
|
273
|
+
{#snippet pkRows()}
|
|
274
|
+
{#each pkPlans as p (p.table)}
|
|
275
|
+
<tr class="even:bg-muted/40" class:opacity-50={p.action === 'ok'}>
|
|
276
|
+
<td class="border-border border-b px-3 py-2 font-mono">{p.table}</td>
|
|
277
|
+
<td class="border-border text-muted-foreground border-b px-3 py-2 font-mono">
|
|
278
|
+
{p.before.join(', ') || '-'}
|
|
279
|
+
</td>
|
|
280
|
+
<td class="border-border text-muted-foreground border-b px-3 py-2 font-mono">
|
|
281
|
+
{p.after.join(', ')}
|
|
282
|
+
</td>
|
|
283
|
+
<td
|
|
284
|
+
class="border-border border-b px-3 py-2 font-mono"
|
|
285
|
+
class:text-primary={p.action !== 'ok'}
|
|
286
|
+
>
|
|
287
|
+
{p.action}
|
|
288
|
+
</td>
|
|
289
|
+
</tr>
|
|
290
|
+
{/each}
|
|
291
|
+
{/snippet}
|
|
292
|
+
{@render resultTable(['Table', 'Current PK', 'Desired PK', 'Action'], pkRows)}
|
|
293
|
+
{/if}
|
|
294
|
+
</section>
|
|
295
|
+
{/if}
|
|
296
|
+
|
|
297
|
+
<!-- Flow 2: relation indexes -->
|
|
298
|
+
{#if flags?.relationIndexes}
|
|
299
|
+
<section class="border-border border-t p-5">
|
|
300
|
+
<h3 class="text-sm font-semibold">Relation indexes</h3>
|
|
301
|
+
<p class="text-muted-foreground mt-1 mb-4 max-w-[80ch] text-sm">
|
|
302
|
+
Postgres doesn't auto-index foreign keys. This indexes the FK columns of
|
|
303
|
+
<code class="font-mono">toOne</code> relations - unless they're already a leftmost prefix of an
|
|
304
|
+
existing index (the PK counts), so <code class="font-mono">a</code> on a
|
|
305
|
+
<code class="font-mono">(a, b)</code> table is skipped. Creates are idempotent (<code
|
|
306
|
+
class="font-mono">FF_IX_</code
|
|
307
|
+
>).
|
|
308
|
+
</p>
|
|
309
|
+
|
|
310
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
311
|
+
{@render runButton('Check (dry run)', relLoading, () => runRel(false))}
|
|
312
|
+
{@render applyButton(
|
|
313
|
+
`Create ${relMissing} index${relMissing === 1 ? '' : 'es'}`,
|
|
314
|
+
relLoading || relMissing === 0,
|
|
315
|
+
() => runRel(true),
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{#if relError}
|
|
320
|
+
<pre
|
|
321
|
+
class="border-destructive/40 bg-destructive/10 text-destructive mb-4 overflow-auto border p-3 text-sm">{relError}</pre>
|
|
322
|
+
{/if}
|
|
323
|
+
|
|
324
|
+
{#if relResult}
|
|
325
|
+
{#if relResult.applied}
|
|
326
|
+
<p class="border-primary/30 bg-primary/10 text-primary mb-3 border p-2 text-sm">
|
|
327
|
+
Created · {relMissing} index{relMissing === 1 ? '' : 'es'}
|
|
328
|
+
</p>
|
|
329
|
+
{/if}
|
|
330
|
+
|
|
331
|
+
{#snippet relRows()}
|
|
332
|
+
{#each relPlans as p (p.name)}
|
|
333
|
+
<tr class="even:bg-muted/40" class:opacity-50={p.action === 'ok'}>
|
|
334
|
+
<td class="border-border border-b px-3 py-2 font-mono">{p.name}</td>
|
|
335
|
+
<td class="border-border text-muted-foreground border-b px-3 py-2 font-mono">
|
|
336
|
+
{p.table} ({p.columns.join(', ')})
|
|
337
|
+
</td>
|
|
338
|
+
<td
|
|
339
|
+
class="border-border border-b px-3 py-2 font-mono"
|
|
340
|
+
class:text-primary={p.action === 'create'}
|
|
341
|
+
>
|
|
342
|
+
{p.action === 'create' ? 'create' : `covered · ${p.coveredBy}`}
|
|
343
|
+
</td>
|
|
344
|
+
</tr>
|
|
345
|
+
{/each}
|
|
346
|
+
{/snippet}
|
|
347
|
+
{@render resultTable(['Index', 'Columns', 'Status'], relRows)}
|
|
348
|
+
{/if}
|
|
349
|
+
</section>
|
|
350
|
+
{/if}
|
|
351
|
+
|
|
352
|
+
<!-- Flow 3: drop orphan columns -->
|
|
353
|
+
{#if flags?.orphanColumns}
|
|
354
|
+
<section class="border-border border-t p-5">
|
|
355
|
+
<h3 class="text-sm font-semibold">Orphan columns</h3>
|
|
356
|
+
<p class="text-muted-foreground mt-1 mb-4 max-w-[80ch] text-sm">
|
|
357
|
+
Remult only ever ADDs columns, never drops them, so a field removed from an entity leaves its
|
|
358
|
+
column behind. This lists live columns no entity field declares - e.g. a
|
|
359
|
+
<code class="font-mono">createdAt</code> now covered by the changelog. Destructive, so nothing
|
|
360
|
+
is selected by default: tick the columns to <code class="font-mono">DROP</code>.
|
|
361
|
+
</p>
|
|
362
|
+
|
|
363
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
364
|
+
{@render runButton('Check (dry run)', dropLoading, () => runDrop(false))}
|
|
365
|
+
{@render applyButton(
|
|
366
|
+
`Drop ${selected.size} column${selected.size === 1 ? '' : 's'}`,
|
|
367
|
+
dropLoading || selected.size === 0,
|
|
368
|
+
() => runDrop(true),
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
{#if dropError}
|
|
373
|
+
<pre
|
|
374
|
+
class="border-destructive/40 bg-destructive/10 text-destructive mb-4 overflow-auto border p-3 text-sm">{dropError}</pre>
|
|
375
|
+
{/if}
|
|
376
|
+
|
|
377
|
+
{#if dropResult}
|
|
378
|
+
{#if dropResult.applied}
|
|
379
|
+
<p class="border-primary/30 bg-primary/10 text-primary mb-3 border p-2 text-sm">
|
|
380
|
+
Dropped · {dropResult.dropped.length} column{dropResult.dropped.length === 1 ? '' : 's'}
|
|
381
|
+
</p>
|
|
382
|
+
{/if}
|
|
383
|
+
|
|
384
|
+
{#if dropPlans.length === 0}
|
|
385
|
+
<p class="text-muted-foreground text-sm">No orphan columns - every column maps to a field.</p>
|
|
386
|
+
{:else}
|
|
387
|
+
{#snippet dropRows()}
|
|
388
|
+
{#each dropPlans as p (keyOf(p))}
|
|
389
|
+
<tr class="even:bg-muted/40">
|
|
390
|
+
<td class="border-border border-b px-3 py-2">
|
|
391
|
+
<input
|
|
392
|
+
type="checkbox"
|
|
393
|
+
checked={selected.has(keyOf(p))}
|
|
394
|
+
onchange={() => toggle(keyOf(p))}
|
|
395
|
+
aria-label={`Drop ${keyOf(p)}`}
|
|
396
|
+
/>
|
|
397
|
+
</td>
|
|
398
|
+
<td class="border-border border-b px-3 py-2 font-mono">{p.table}</td>
|
|
399
|
+
<td class="border-border text-primary border-b px-3 py-2 font-mono">{p.column}</td>
|
|
400
|
+
</tr>
|
|
401
|
+
{/each}
|
|
402
|
+
{/snippet}
|
|
403
|
+
{@render resultTable(['', 'Table', 'Column'], dropRows)}
|
|
404
|
+
{/if}
|
|
405
|
+
{/if}
|
|
406
|
+
</section>
|
|
407
|
+
{/if}
|
|
408
|
+
|
|
409
|
+
<!-- Flow 4: nullability drift -->
|
|
410
|
+
{#if flags?.nullable}
|
|
411
|
+
<section class="border-border border-t p-5">
|
|
412
|
+
<h3 class="text-sm font-semibold">Nullability drift</h3>
|
|
413
|
+
<p class="text-muted-foreground mt-1 mb-4 max-w-[80ch] text-sm">
|
|
414
|
+
A field that is not <code class="font-mono">allowNull</code> makes Remult create the column
|
|
415
|
+
<code class="font-mono">default '' not null</code>, and it never relaxes that afterwards. So a
|
|
416
|
+
column added before its field learned <code class="font-mono">allowNull</code> still hands every
|
|
417
|
+
pre-existing row an empty string the entity now types as <code class="font-mono">null</code>.
|
|
418
|
+
This lists those columns: applying drops the default and the NOT NULL, and - for text columns
|
|
419
|
+
only - rewrites <code class="font-mono">''</code> to <code class="font-mono">NULL</code>. A
|
|
420
|
+
numeric or boolean empty value (<code class="font-mono">0</code>,
|
|
421
|
+
<code class="font-mono">false</code>) may be real data, so those keep their rows and only lose
|
|
422
|
+
the constraint. Destructive, so nothing is selected by default.
|
|
423
|
+
</p>
|
|
424
|
+
|
|
425
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
426
|
+
{@render runButton('Check (dry run)', nullLoading, () => runNull(false))}
|
|
427
|
+
{@render applyButton(
|
|
428
|
+
`Fix ${nullSelected.size} column${nullSelected.size === 1 ? '' : 's'}`,
|
|
429
|
+
nullLoading || nullSelected.size === 0,
|
|
430
|
+
() => runNull(true),
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{#if nullError}
|
|
435
|
+
<pre
|
|
436
|
+
class="border-destructive/40 bg-destructive/10 text-destructive mb-4 overflow-auto border p-3 text-sm">{nullError}</pre>
|
|
437
|
+
{/if}
|
|
438
|
+
|
|
439
|
+
{#if nullResult}
|
|
440
|
+
{#if nullResult.applied}
|
|
441
|
+
<p class="border-primary/30 bg-primary/10 text-primary mb-3 border p-2 text-sm">
|
|
442
|
+
Fixed · {nullResult.fixed.length} column{nullResult.fixed.length === 1 ? '' : 's'}
|
|
443
|
+
</p>
|
|
444
|
+
{/if}
|
|
445
|
+
|
|
446
|
+
{#if nullPlans.length === 0}
|
|
447
|
+
<p class="text-muted-foreground text-sm">
|
|
448
|
+
No drift - every nullable field has a plain nullable column.
|
|
449
|
+
</p>
|
|
450
|
+
{:else}
|
|
451
|
+
{#snippet nullRows()}
|
|
452
|
+
{#each nullPlans as p (keyOf(p))}
|
|
453
|
+
<tr class="even:bg-muted/40">
|
|
454
|
+
<td class="border-border border-b px-3 py-2">
|
|
455
|
+
<input
|
|
456
|
+
type="checkbox"
|
|
457
|
+
checked={nullSelected.has(keyOf(p))}
|
|
458
|
+
onchange={() => toggleNull(keyOf(p))}
|
|
459
|
+
aria-label={`Fix ${keyOf(p)}`}
|
|
460
|
+
/>
|
|
461
|
+
</td>
|
|
462
|
+
<td class="border-border border-b px-3 py-2 font-mono">{p.table}</td>
|
|
463
|
+
<td class="border-border text-primary border-b px-3 py-2 font-mono">{p.column}</td>
|
|
464
|
+
<td class="border-border border-b px-3 py-2 text-sm">
|
|
465
|
+
{[p.dropDefault && 'drop default', p.dropNotNull && 'drop not null']
|
|
466
|
+
.filter(Boolean)
|
|
467
|
+
.join(' · ')}
|
|
468
|
+
</td>
|
|
469
|
+
<td class="border-border border-b px-3 py-2 text-sm">
|
|
470
|
+
{p.blankToNull ? "'' → NULL" : 'rows kept'}
|
|
471
|
+
</td>
|
|
472
|
+
</tr>
|
|
473
|
+
{/each}
|
|
474
|
+
{/snippet}
|
|
475
|
+
{@render resultTable(['', 'Table', 'Column', 'Constraint', 'Rows'], nullRows)}
|
|
476
|
+
{/if}
|
|
477
|
+
{/if}
|
|
478
|
+
</section>
|
|
479
|
+
{/if}
|
|
480
|
+
</div>
|