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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner: diff the live PRIMARY KEYs against what the entity `id` config
|
|
3
|
+
* wants, and emit the minimal ALTER plan. No DB access here - the controller
|
|
4
|
+
* feeds it the catalog snapshot + the metadata-derived target, runs the SQL.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: Remult only sets a table's PK on first creation and never
|
|
7
|
+
* ALTERs it, so a later `id` change is silently ignored on long-lived DBs.
|
|
8
|
+
*/
|
|
9
|
+
const ident = (name) => `"${name}"`;
|
|
10
|
+
const cols = (names) => names.map(ident).join(', ');
|
|
11
|
+
const sameOrder = (a, b) => a.length === b.length && a.every((c, i) => c === b[i]);
|
|
12
|
+
/**
|
|
13
|
+
* One plan per *desired* (entity-backed) table. Tables present in the DB but not
|
|
14
|
+
* in `desired` (firstly infra, views, etc.) are left alone - we only manage what
|
|
15
|
+
* an entity's `id` config describes. Several entities can share one table (a
|
|
16
|
+
* light projection + the full row on the same `dbName`) - one plan per table,
|
|
17
|
+
* first declaration wins, so the UI can key rows by table.
|
|
18
|
+
*/
|
|
19
|
+
export function planPkSync(current, desired) {
|
|
20
|
+
const byTable = new Map(current.map((c) => [c.table, c]));
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
return desired.flatMap(({ table, cols: after }) => {
|
|
23
|
+
if (seen.has(table))
|
|
24
|
+
return [];
|
|
25
|
+
seen.add(table);
|
|
26
|
+
return [planTable(byTable.get(table), table, after)];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function planTable(cur, table, after) {
|
|
30
|
+
const before = cur?.cols ?? [];
|
|
31
|
+
const addPk = `ALTER TABLE ${ident(table)} ADD PRIMARY KEY (${cols(after)});`;
|
|
32
|
+
if (!cur)
|
|
33
|
+
return { table, before, after, action: 'create', sql: [addPk] };
|
|
34
|
+
if (sameOrder(cur.cols, after))
|
|
35
|
+
return { table, before, after, action: 'ok', sql: [] };
|
|
36
|
+
return {
|
|
37
|
+
table,
|
|
38
|
+
before,
|
|
39
|
+
after,
|
|
40
|
+
action: 'migrate',
|
|
41
|
+
sql: [`ALTER TABLE ${ident(table)} DROP CONSTRAINT ${ident(cur.constraintName)};`, addPk],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
|
+
import { type PkPlan } from './planPkSync';
|
|
3
|
+
/**
|
|
4
|
+
* Audit - and optionally fix - PRIMARY KEY drift between the live DB and each
|
|
5
|
+
* entity's `id` config. Default is a dry run: returns the plan, changes nothing.
|
|
6
|
+
* Pass `{ apply: true }` to run it.
|
|
7
|
+
*
|
|
8
|
+
* Why this is needed: Remult only sets a PK on first table creation and never
|
|
9
|
+
* ALTERs it, so a later `id` change is silently ignored on long-lived DBs.
|
|
10
|
+
*
|
|
11
|
+
* Atomic when called inside a BackendMethod: the whole thing shares that method's
|
|
12
|
+
* transaction, so any failure - e.g. PG 23505 when duplicate keys block a
|
|
13
|
+
* composite PK - rolls back every ALTER; the DB is never left half-migrated.
|
|
14
|
+
* That's also why it's plain `ADD PRIMARY KEY`, not `CONCURRENTLY` (which can't
|
|
15
|
+
* run in a transaction): it briefly locks the touched tables, so treat
|
|
16
|
+
* `apply: true` as a maintenance-window action.
|
|
17
|
+
*/
|
|
18
|
+
export declare function reindexPrimaryKeys(db: SqlDatabase, entities: ClassType<unknown>[], opts?: {
|
|
19
|
+
apply?: boolean;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
applied: boolean;
|
|
22
|
+
plans: PkPlan[];
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { repo } from 'remult';
|
|
2
|
+
import { stripIdent } from './ident';
|
|
3
|
+
import { planPkSync } from './planPkSync';
|
|
4
|
+
/**
|
|
5
|
+
* Audit - and optionally fix - PRIMARY KEY drift between the live DB and each
|
|
6
|
+
* entity's `id` config. Default is a dry run: returns the plan, changes nothing.
|
|
7
|
+
* Pass `{ apply: true }` to run it.
|
|
8
|
+
*
|
|
9
|
+
* Why this is needed: Remult only sets a PK on first table creation and never
|
|
10
|
+
* ALTERs it, so a later `id` change is silently ignored on long-lived DBs.
|
|
11
|
+
*
|
|
12
|
+
* Atomic when called inside a BackendMethod: the whole thing shares that method's
|
|
13
|
+
* transaction, so any failure - e.g. PG 23505 when duplicate keys block a
|
|
14
|
+
* composite PK - rolls back every ALTER; the DB is never left half-migrated.
|
|
15
|
+
* That's also why it's plain `ADD PRIMARY KEY`, not `CONCURRENTLY` (which can't
|
|
16
|
+
* run in a transaction): it briefly locks the touched tables, so treat
|
|
17
|
+
* `apply: true` as a maintenance-window action.
|
|
18
|
+
*/
|
|
19
|
+
export async function reindexPrimaryKeys(db, entities, opts) {
|
|
20
|
+
// Live PKs from the catalog, columns kept in constraint order.
|
|
21
|
+
const res = await db.createCommand().execute(`
|
|
22
|
+
SELECT tc.table_name, tc.constraint_name, kcu.column_name
|
|
23
|
+
FROM information_schema.table_constraints tc
|
|
24
|
+
JOIN information_schema.key_column_usage kcu
|
|
25
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
26
|
+
AND tc.table_schema = kcu.table_schema
|
|
27
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
28
|
+
AND tc.table_schema = 'public'
|
|
29
|
+
ORDER BY tc.table_name, kcu.ordinal_position;
|
|
30
|
+
`);
|
|
31
|
+
const byTable = new Map();
|
|
32
|
+
for (const row of res.rows) {
|
|
33
|
+
let cur = byTable.get(row.table_name);
|
|
34
|
+
if (!cur) {
|
|
35
|
+
cur = { table: row.table_name, constraintName: row.constraint_name, cols: [] };
|
|
36
|
+
byTable.set(row.table_name, cur);
|
|
37
|
+
}
|
|
38
|
+
cur.cols.push(row.column_name);
|
|
39
|
+
}
|
|
40
|
+
// Desired PK per entity, straight from its `id` config.
|
|
41
|
+
const desired = [];
|
|
42
|
+
for (const ent of entities) {
|
|
43
|
+
const meta = repo(ent).metadata;
|
|
44
|
+
if (meta.options.sqlExpression)
|
|
45
|
+
continue; // views: no real table
|
|
46
|
+
const cols = meta.idMetadata.fields.map((f) => stripIdent(f.dbName));
|
|
47
|
+
if (cols.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
desired.push({ table: stripIdent(meta.dbName), cols });
|
|
50
|
+
}
|
|
51
|
+
const plans = planPkSync([...byTable.values()], desired);
|
|
52
|
+
if (opts?.apply) {
|
|
53
|
+
for (const plan of plans) {
|
|
54
|
+
// Fail-fast: any throw aborts the shared transaction -> full rollback.
|
|
55
|
+
for (const stmt of plan.sql)
|
|
56
|
+
await db.createCommand().execute(stmt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { applied: opts?.apply ?? false, plans };
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
|
+
/** A toOne relation's local FK columns that ideally have a covering index. */
|
|
3
|
+
export type RelIndexDesired = {
|
|
4
|
+
table: string;
|
|
5
|
+
columns: string[];
|
|
6
|
+
};
|
|
7
|
+
/** An existing index from the catalog, columns in order. */
|
|
8
|
+
export type ExistingIndex = {
|
|
9
|
+
name: string;
|
|
10
|
+
cols: string[];
|
|
11
|
+
};
|
|
12
|
+
export type RelIndexPlan = {
|
|
13
|
+
table: string;
|
|
14
|
+
columns: string[];
|
|
15
|
+
name: string;
|
|
16
|
+
action: 'ok' | 'create';
|
|
17
|
+
/** name of the index already covering these columns (when action is 'ok'). */
|
|
18
|
+
coveredBy: string | null;
|
|
19
|
+
sql: string | null;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Pure: for each (deduped) relation FK, decide whether an index is missing.
|
|
23
|
+
* A relation is already covered if any existing index - including the PRIMARY KEY -
|
|
24
|
+
* has those columns as its leftmost prefix, so we never propose redundant indexes
|
|
25
|
+
* (e.g. an `a` index on an `(a, b)` PK table).
|
|
26
|
+
*/
|
|
27
|
+
export declare function planRelationIndexes(desired: RelIndexDesired[], existing: Map<string, ExistingIndex[]>): RelIndexPlan[];
|
|
28
|
+
/**
|
|
29
|
+
* Find - and optionally create - indexes for `toOne` relation FK columns that
|
|
30
|
+
* aren't already covered by an existing index. Default is a dry run: returns the
|
|
31
|
+
* plan, changes nothing. Pass `{ apply: true }` to create the missing ones.
|
|
32
|
+
*
|
|
33
|
+
* Postgres does NOT auto-index FK columns, and `toMany` relations are indexed
|
|
34
|
+
* from their owning `toOne` side, so only `toOne` is considered. Creates use
|
|
35
|
+
* `CREATE INDEX IF NOT EXISTS` (idempotent), so they join the BackendMethod's
|
|
36
|
+
* transaction - which is also why they can't be `CONCURRENTLY`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createRelationIndexes(db: SqlDatabase, entities: ClassType<unknown>[], opts?: {
|
|
39
|
+
apply?: boolean;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
applied: boolean;
|
|
42
|
+
plans: RelIndexPlan[];
|
|
43
|
+
}>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { dbNamesOf, repo } from 'remult';
|
|
2
|
+
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
+
import { formatCreateIndex } from './createIndex';
|
|
4
|
+
import { stripIdent } from './ident';
|
|
5
|
+
/** `want` is covered if it's a leftmost prefix of `have` (so the PK covers its own prefix). */
|
|
6
|
+
const isPrefix = (want, have) => have.length >= want.length && want.every((c, i) => have[i] === c);
|
|
7
|
+
/**
|
|
8
|
+
* Pure: for each (deduped) relation FK, decide whether an index is missing.
|
|
9
|
+
* A relation is already covered if any existing index - including the PRIMARY KEY -
|
|
10
|
+
* has those columns as its leftmost prefix, so we never propose redundant indexes
|
|
11
|
+
* (e.g. an `a` index on an `(a, b)` PK table).
|
|
12
|
+
*/
|
|
13
|
+
export function planRelationIndexes(desired, existing) {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const plans = [];
|
|
16
|
+
for (const d of desired) {
|
|
17
|
+
const key = `${d.table}(${d.columns.join(',')})`;
|
|
18
|
+
if (seen.has(key))
|
|
19
|
+
continue;
|
|
20
|
+
seen.add(key);
|
|
21
|
+
const name = `FF_IX_${d.table}_${d.columns.join('_')}`;
|
|
22
|
+
const cover = (existing.get(d.table) ?? []).find((e) => isPrefix(d.columns, e.cols));
|
|
23
|
+
plans.push(cover
|
|
24
|
+
? { table: d.table, columns: d.columns, name, action: 'ok', coveredBy: cover.name, sql: null }
|
|
25
|
+
: {
|
|
26
|
+
table: d.table,
|
|
27
|
+
columns: d.columns,
|
|
28
|
+
name,
|
|
29
|
+
action: 'create',
|
|
30
|
+
coveredBy: null,
|
|
31
|
+
sql: formatCreateIndex({ name, table: d.table, columns: d.columns, ifNotExists: true }),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return plans;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Find - and optionally create - indexes for `toOne` relation FK columns that
|
|
38
|
+
* aren't already covered by an existing index. Default is a dry run: returns the
|
|
39
|
+
* plan, changes nothing. Pass `{ apply: true }` to create the missing ones.
|
|
40
|
+
*
|
|
41
|
+
* Postgres does NOT auto-index FK columns, and `toMany` relations are indexed
|
|
42
|
+
* from their owning `toOne` side, so only `toOne` is considered. Creates use
|
|
43
|
+
* `CREATE INDEX IF NOT EXISTS` (idempotent), so they join the BackendMethod's
|
|
44
|
+
* transaction - which is also why they can't be `CONCURRENTLY`.
|
|
45
|
+
*/
|
|
46
|
+
export async function createRelationIndexes(db, entities, opts) {
|
|
47
|
+
// Desired FK indexes from each entity's toOne relations.
|
|
48
|
+
const desired = [];
|
|
49
|
+
for (const ent of entities) {
|
|
50
|
+
const meta = repo(ent).metadata;
|
|
51
|
+
if (meta.options.sqlExpression)
|
|
52
|
+
continue; // views: no real table
|
|
53
|
+
const names = await dbNamesOf(ent);
|
|
54
|
+
const table = stripIdent(names.$entityName);
|
|
55
|
+
for (const field of meta.fields.toArray()) {
|
|
56
|
+
const fi = getRelationFieldInfo(field);
|
|
57
|
+
if (!fi || fi.type !== 'toOne')
|
|
58
|
+
continue; // toMany is indexed from the toOne side
|
|
59
|
+
const localKeys = Object.values(fi.getFields().fields); // FK columns on THIS entity
|
|
60
|
+
if (localKeys.length === 0)
|
|
61
|
+
continue;
|
|
62
|
+
desired.push({ table, columns: localKeys.map((k) => stripIdent(names.$dbNameOf(k))) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Every existing index (incl. PK), columns in order, per table.
|
|
66
|
+
const res = await db.createCommand().execute(`
|
|
67
|
+
SELECT c.relname AS table_name, i.relname AS index_name,
|
|
68
|
+
array_agg(a.attname::text ORDER BY k.ord) AS cols
|
|
69
|
+
FROM pg_class c
|
|
70
|
+
JOIN pg_namespace ns ON ns.oid = c.relnamespace AND ns.nspname = 'public'
|
|
71
|
+
JOIN pg_index ix ON ix.indrelid = c.oid
|
|
72
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
73
|
+
JOIN unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
74
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
|
|
75
|
+
WHERE c.relkind = 'r' AND k.attnum > 0
|
|
76
|
+
GROUP BY c.relname, i.relname;
|
|
77
|
+
`);
|
|
78
|
+
const existing = new Map();
|
|
79
|
+
for (const row of res.rows) {
|
|
80
|
+
const list = existing.get(row.table_name) ?? [];
|
|
81
|
+
list.push({ name: row.index_name, cols: row.cols });
|
|
82
|
+
existing.set(row.table_name, list);
|
|
83
|
+
}
|
|
84
|
+
const plans = planRelationIndexes(desired, existing);
|
|
85
|
+
if (opts?.apply) {
|
|
86
|
+
for (const plan of plans)
|
|
87
|
+
if (plan.sql)
|
|
88
|
+
await db.createCommand().execute(plan.sql);
|
|
89
|
+
}
|
|
90
|
+
return { applied: opts?.apply ?? false, plans };
|
|
91
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { type SqlDatabase } from 'remult';
|
|
1
|
+
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
2
|
import { Module } from 'remult/server';
|
|
3
3
|
import { type SqlTokensOptions } from '../SqlAdminController';
|
|
4
|
+
import { type SqlDriftOptions } from '../SqlDriftController';
|
|
4
5
|
export type { SqlTokensOptions, SqlTokenPool } from '../SqlAdminController';
|
|
6
|
+
export type { SqlDriftFlag, SqlDriftOptions } from '../SqlDriftController';
|
|
7
|
+
export { formatCreateIndex, sqlCreateIndex, type IndexOptions } from './drift/createIndex';
|
|
8
|
+
/** Minimal shape of a remult `Module`, so any module list passes without a cast. */
|
|
9
|
+
type ModuleTree = {
|
|
10
|
+
entities?: ClassType<unknown>[];
|
|
11
|
+
modules?: ModuleTree[];
|
|
12
|
+
};
|
|
13
|
+
/** Every entity of a module list, incl. nested submodules - feed it to `drift.entities`. */
|
|
14
|
+
export declare const collectEntities: (mods: ModuleTree[]) => ClassType<unknown>[];
|
|
5
15
|
export type SqlAdminOptions = {
|
|
6
16
|
/**
|
|
7
17
|
* Override the SqlDatabase used to execute queries.
|
|
@@ -29,6 +39,13 @@ export type SqlAdminOptions = {
|
|
|
29
39
|
* no mint endpoint, no request hook. Needs `sqlAdmin: true`. See `<SqlTokens />`.
|
|
30
40
|
*/
|
|
31
41
|
tokens?: SqlTokensOptions | false;
|
|
42
|
+
/**
|
|
43
|
+
* Schema drift checks for `<SqlDrift />`: each one dry-runs first, then applies on demand.
|
|
44
|
+
* Every flag is opt-in, nothing is registered without at least one.
|
|
45
|
+
*
|
|
46
|
+
* @example drift: { entities: () => collectEntities(modules), relationIndexes: true }
|
|
47
|
+
*/
|
|
48
|
+
drift?: SqlDriftOptions;
|
|
32
49
|
};
|
|
33
50
|
/**
|
|
34
51
|
* Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
|
|
@@ -3,8 +3,12 @@ import { Module } from 'remult/server';
|
|
|
3
3
|
import { yellow } from '@kitql/helpers';
|
|
4
4
|
import { log } from '..';
|
|
5
5
|
import { SqlAdminController } from '../SqlAdminController';
|
|
6
|
+
import { SqlDriftController } from '../SqlDriftController';
|
|
6
7
|
import { isSqlTokenLive, SqlToken, sqlTokenEntities } from '../sqlTokenEntities';
|
|
7
8
|
import { hashToken } from './token';
|
|
9
|
+
export { formatCreateIndex, sqlCreateIndex } from './drift/createIndex';
|
|
10
|
+
/** Every entity of a module list, incl. nested submodules - feed it to `drift.entities`. */
|
|
11
|
+
export const collectEntities = (mods) => mods.flatMap((m) => [...(m.entities ?? []), ...collectEntities(m.modules ?? [])]);
|
|
8
12
|
// Framework-agnostic: remult hands initRequest the native request (SvelteKit
|
|
9
13
|
// RequestEvent, express req, a fetch Request...).
|
|
10
14
|
function readAuthorization(req) {
|
|
@@ -59,17 +63,21 @@ export const sqlAdmin = (opts) => {
|
|
|
59
63
|
}
|
|
60
64
|
const prefix = tokens?.prefix ?? 'ffsql_';
|
|
61
65
|
const execPath = `${tokens?.apiPath ?? '/api'}/ff/sqlAdmin/exec`;
|
|
66
|
+
const drift = opts?.drift;
|
|
67
|
+
const driftOn = !!drift && !!(drift.relationIndexes || drift.primaryKeys || drift.nullable || drift.orphanColumns);
|
|
62
68
|
return new Module({
|
|
63
69
|
key: 'sqlAdmin',
|
|
64
70
|
// Before the app's own initRequest, so a bearer is resolved before cookies would be.
|
|
65
71
|
priority: -900,
|
|
66
72
|
entities: tokens ? Object.values(sqlTokenEntities) : [],
|
|
67
|
-
controllers: enabled ? [SqlAdminController] : [],
|
|
73
|
+
controllers: [...(enabled ? [SqlAdminController] : []), ...(driftOn ? [SqlDriftController] : [])],
|
|
68
74
|
initApi: async () => {
|
|
69
75
|
if (opts?.dp) {
|
|
70
76
|
SqlAdminController.dp = await opts.dp();
|
|
77
|
+
SqlDriftController.dp = SqlAdminController.dp;
|
|
71
78
|
}
|
|
72
79
|
SqlAdminController.options = { tokens };
|
|
80
|
+
SqlDriftController.options = driftOn ? drift : undefined;
|
|
73
81
|
if (enabled)
|
|
74
82
|
log.info(`AI Hint: visit ${yellow(path)} to query raw SQL.`);
|
|
75
83
|
},
|