firstly 0.10.4 → 0.10.6
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 +16 -0
- package/esm/carbone/CarboneController.js +117 -103
- package/esm/feedback/FeedbackController.js +310 -289
- package/esm/mail/ui/LastMails.svelte +2 -1
- package/esm/sqlAdmin/SqlAdminController.d.ts +1 -0
- package/esm/sqlAdmin/SqlAdminController.js +33 -25
- package/esm/sqlAdmin/SqlDriftController.js +25 -13
- package/esm/sqlAdmin/server/drift/catalog.d.ts +4 -0
- package/esm/sqlAdmin/server/drift/catalog.js +25 -0
- package/esm/sqlAdmin/server/drift/createIndex.d.ts +2 -0
- package/esm/sqlAdmin/server/drift/createIndex.js +2 -1
- package/esm/sqlAdmin/server/drift/ident.d.ts +16 -0
- package/esm/sqlAdmin/server/drift/ident.js +22 -0
- package/esm/sqlAdmin/server/drift/nullable.js +11 -12
- package/esm/sqlAdmin/server/drift/orphanColumns.js +9 -8
- package/esm/sqlAdmin/server/drift/planPkSync.d.ts +4 -3
- package/esm/sqlAdmin/server/drift/planPkSync.js +8 -12
- package/esm/sqlAdmin/server/drift/primaryKeys.js +13 -10
- package/esm/sqlAdmin/server/drift/relationIndexes.d.ts +3 -3
- package/esm/sqlAdmin/server/drift/relationIndexes.js +23 -12
- package/esm/sqlAdmin/ui/SqlDrift.svelte +25 -19
- package/esm/sqlAdmin/ui/SqlTokens.svelte +5 -4
- package/package.json +2 -2
|
@@ -8,6 +8,8 @@ import { BackendMethod, remult, repo, SqlDatabase } from 'remult';
|
|
|
8
8
|
import { FF_Role } from '../core/common';
|
|
9
9
|
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
10
|
import { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
|
|
11
|
+
// Thrown when a server-only body runs on the client (its `import.meta.env.SSR` block is gone there).
|
|
12
|
+
export const SERVER_ONLY = 'sqlAdmin: server-only';
|
|
11
13
|
export const SQL_ADMINS = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
|
|
12
14
|
const CALL_LOG_CMD_MAX = 4000;
|
|
13
15
|
/** Retention is a housekeeping sweep, not something to pay for on every query. */
|
|
@@ -42,21 +44,24 @@ export class SqlAdminController {
|
|
|
42
44
|
return SqlAdminController.run(cmd, capabilities);
|
|
43
45
|
}
|
|
44
46
|
static async run(cmd, capabilities) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
if (import.meta.env.SSR) {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
try {
|
|
50
|
+
// Anything that is not an explicit list (null, a stray boolean) is a read.
|
|
51
|
+
if (Array.isArray(capabilities) && capabilities.includes('write')) {
|
|
52
|
+
const start = performance.now();
|
|
53
|
+
const rows = (await db.execute(cmd)).rows;
|
|
54
|
+
return { rows, rowCount: rows.length, took: performance.now() - start };
|
|
55
|
+
}
|
|
56
|
+
const { readOnlySql } = await import('./server/readOnlySql');
|
|
57
|
+
return await readOnlySql(db, SqlAdminController.options.tokens?.pool ?? poolFrom(db), cmd);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const { enrichSqlError } = await import('./server/sqlError');
|
|
61
|
+
throw await enrichSqlError(db, err, cmd);
|
|
52
62
|
}
|
|
53
|
-
const { readOnlySql } = await import('./server/readOnlySql');
|
|
54
|
-
return await readOnlySql(db, SqlAdminController.options.tokens?.pool ?? poolFrom(db), cmd);
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
const { enrichSqlError } = await import('./server/sqlError');
|
|
58
|
-
throw await enrichSqlError(db, err, cmd);
|
|
59
63
|
}
|
|
64
|
+
throw new Error(SERVER_ONLY);
|
|
60
65
|
}
|
|
61
66
|
static async execAsToken(token, cmd) {
|
|
62
67
|
const o = SqlAdminController.options.tokens;
|
|
@@ -115,18 +120,21 @@ export class SqlAdminController {
|
|
|
115
120
|
if (!o.capabilities.includes(c))
|
|
116
121
|
throw new Error(`${c} is not enabled`);
|
|
117
122
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
if (import.meta.env.SSR) {
|
|
124
|
+
const { newRawToken, hashToken, tokenHint, randomTokenName } = await import('./server/token');
|
|
125
|
+
const prefix = o.prefix ?? 'ffsql_';
|
|
126
|
+
const raw = newRawToken(prefix);
|
|
127
|
+
await repo(SqlToken).insert({
|
|
128
|
+
name: name.trim() || randomTokenName(),
|
|
129
|
+
hint: tokenHint(raw, prefix),
|
|
130
|
+
tokenHash: hashToken(raw),
|
|
131
|
+
userId,
|
|
132
|
+
capabilities: unique,
|
|
133
|
+
expiresAt: new Date(Date.now() + ttlMs),
|
|
134
|
+
});
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(SERVER_ONLY);
|
|
130
138
|
}
|
|
131
139
|
/**
|
|
132
140
|
* Deletes every token that can no longer be used (expired or revoked), and
|
|
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { BackendMethod, remult, SqlDatabase } from 'remult';
|
|
8
|
-
import { SQL_ADMINS } from './SqlAdminController';
|
|
8
|
+
import { SERVER_ONLY, SQL_ADMINS } from './SqlAdminController';
|
|
9
9
|
export class SqlDriftController {
|
|
10
10
|
/** Set by the `sqlAdmin()` module's `initApi`. */
|
|
11
11
|
static options;
|
|
@@ -27,24 +27,36 @@ export class SqlDriftController {
|
|
|
27
27
|
}
|
|
28
28
|
// Each apply shares the BackendMethod transaction: any failure rolls every statement back.
|
|
29
29
|
static async relationIndexes(opts) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (import.meta.env.SSR) {
|
|
31
|
+
const { db, entities } = SqlDriftController.ctx('relationIndexes');
|
|
32
|
+
const { createRelationIndexes } = await import('./server/drift/relationIndexes');
|
|
33
|
+
return createRelationIndexes(db, entities, opts);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(SERVER_ONLY);
|
|
33
36
|
}
|
|
34
37
|
static async primaryKeys(opts) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (import.meta.env.SSR) {
|
|
39
|
+
const { db, entities } = SqlDriftController.ctx('primaryKeys');
|
|
40
|
+
const { reindexPrimaryKeys } = await import('./server/drift/primaryKeys');
|
|
41
|
+
return reindexPrimaryKeys(db, entities, opts);
|
|
42
|
+
}
|
|
43
|
+
throw new Error(SERVER_ONLY);
|
|
38
44
|
}
|
|
39
45
|
static async nullable(opts) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
if (import.meta.env.SSR) {
|
|
47
|
+
const { db, entities } = SqlDriftController.ctx('nullable');
|
|
48
|
+
const { syncNullable } = await import('./server/drift/nullable');
|
|
49
|
+
return syncNullable(db, entities, opts);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(SERVER_ONLY);
|
|
43
52
|
}
|
|
44
53
|
static async orphanColumns(opts) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
if (import.meta.env.SSR) {
|
|
55
|
+
const { db, entities } = SqlDriftController.ctx('orphanColumns');
|
|
56
|
+
const { dropColumns } = await import('./server/drift/orphanColumns');
|
|
57
|
+
return dropColumns(db, entities, opts);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(SERVER_ONLY);
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
__decorate([
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SqlDatabase } from 'remult';
|
|
2
|
+
export declare function execStmt(db: SqlDatabase, sql: string): Promise<import("remult").SqlResult>;
|
|
3
|
+
/** Live tables as `tableKey`s: an entity's table may never have been created (no migration run yet). */
|
|
4
|
+
export declare function liveTables(db: SqlDatabase): Promise<Set<string>>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { enrichSqlError } from '../sqlError';
|
|
2
|
+
import { tableKeySql, USER_SCHEMA_SQL } from './ident';
|
|
3
|
+
// A drift apply runs many statements in one transaction: without the statement, a
|
|
4
|
+
// bare "could not create unique index" can't be traced back to its table.
|
|
5
|
+
export async function execStmt(db, sql) {
|
|
6
|
+
try {
|
|
7
|
+
return await db.createCommand().execute(sql);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
const enriched = await enrichSqlError(db, err, sql);
|
|
11
|
+
const msg = enriched instanceof Error ? enriched.message : String(enriched);
|
|
12
|
+
throw new Error(`${msg}\n\nWhile running: ${sql.trim()}\nNothing was applied (rolled back).`, {
|
|
13
|
+
cause: err,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Live tables as `tableKey`s: an entity's table may never have been created (no migration run yet). */
|
|
18
|
+
export async function liveTables(db) {
|
|
19
|
+
const res = await db.createCommand().execute(`
|
|
20
|
+
SELECT ${tableKeySql('table_schema', 'table_name')} AS table_key
|
|
21
|
+
FROM information_schema.tables
|
|
22
|
+
WHERE ${USER_SCHEMA_SQL('table_schema')} AND table_type = 'BASE TABLE';
|
|
23
|
+
`);
|
|
24
|
+
return new Set(res.rows.map((r) => r.table_key));
|
|
25
|
+
}
|
|
@@ -14,7 +14,8 @@ export function formatCreateIndex(o) {
|
|
|
14
14
|
parts.push('CONCURRENTLY');
|
|
15
15
|
if (o.ifNotExists)
|
|
16
16
|
parts.push('IF NOT EXISTS');
|
|
17
|
-
|
|
17
|
+
const table = o.schema ? `${wrap(o.schema)}.${wrap(o.table)}` : wrap(o.table);
|
|
18
|
+
parts.push(wrap(o.name), 'ON', table, `(${o.columns.map(wrap).join(', ')})`);
|
|
18
19
|
return parts.join(' ') + ';';
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
/** pg catalog / metadata names can come back quoted or schema-qualified. */
|
|
2
2
|
export declare const stripIdent: (name: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* Table key shared by metadata and catalog sides of every diff: bare name in `public`,
|
|
5
|
+
* `schema.name` elsewhere - so an entity in another schema is neither lost nor
|
|
6
|
+
* mistaken for a same-named `public` table.
|
|
7
|
+
*/
|
|
8
|
+
export declare function tableKey(dbName: string): string;
|
|
9
|
+
/** Same key, computed in SQL from a schema and a table column. */
|
|
10
|
+
export declare const tableKeySql: (schema: string, table: string) => string;
|
|
11
|
+
export declare function splitKey(key: string): {
|
|
12
|
+
schema?: string;
|
|
13
|
+
table: string;
|
|
14
|
+
};
|
|
15
|
+
/** Quoted, schema-qualified SQL reference for a `tableKey`. */
|
|
16
|
+
export declare function quoteTable(key: string): string;
|
|
17
|
+
/** Schemas the drift checks read: every user schema, not the system ones. */
|
|
18
|
+
export declare const USER_SCHEMA_SQL: (schema: string) => string;
|
|
@@ -1,2 +1,24 @@
|
|
|
1
1
|
/** pg catalog / metadata names can come back quoted or schema-qualified. */
|
|
2
2
|
export const stripIdent = (name) => name.replaceAll('"', '').split('.').pop();
|
|
3
|
+
/**
|
|
4
|
+
* Table key shared by metadata and catalog sides of every diff: bare name in `public`,
|
|
5
|
+
* `schema.name` elsewhere - so an entity in another schema is neither lost nor
|
|
6
|
+
* mistaken for a same-named `public` table.
|
|
7
|
+
*/
|
|
8
|
+
export function tableKey(dbName) {
|
|
9
|
+
const [name, schema] = dbName.replaceAll('"', '').split('.').toReversed();
|
|
10
|
+
return schema && schema !== 'public' ? `${schema}.${name}` : name;
|
|
11
|
+
}
|
|
12
|
+
/** Same key, computed in SQL from a schema and a table column. */
|
|
13
|
+
export const tableKeySql = (schema, table) => `CASE WHEN ${schema} = 'public' THEN ${table} ELSE ${schema} || '.' || ${table} END`;
|
|
14
|
+
export function splitKey(key) {
|
|
15
|
+
const dot = key.indexOf('.');
|
|
16
|
+
return dot === -1 ? { table: key } : { schema: key.slice(0, dot), table: key.slice(dot + 1) };
|
|
17
|
+
}
|
|
18
|
+
/** Quoted, schema-qualified SQL reference for a `tableKey`. */
|
|
19
|
+
export function quoteTable(key) {
|
|
20
|
+
const { schema, table } = splitKey(key);
|
|
21
|
+
return schema ? `"${schema}"."${table}"` : `"${table}"`;
|
|
22
|
+
}
|
|
23
|
+
/** Schemas the drift checks read: every user schema, not the system ones. */
|
|
24
|
+
export const USER_SCHEMA_SQL = (schema) => `${schema} NOT IN ('pg_catalog', 'information_schema') AND ${schema} NOT LIKE 'pg\\_toast%'`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { repo } from 'remult';
|
|
2
2
|
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
-
import {
|
|
3
|
+
import { execStmt } from './catalog';
|
|
4
|
+
import { quoteTable, stripIdent, tableKey, tableKeySql, USER_SCHEMA_SQL } from './ident';
|
|
4
5
|
import { planNullableDrift, } from './planNullableDrift';
|
|
5
6
|
// Only a text column has an empty value that is unambiguously "unset". A numeric or
|
|
6
7
|
// boolean default (0 / false) may be genuine data, so those columns get their
|
|
@@ -31,7 +32,7 @@ export async function syncNullable(db, entities, opts) {
|
|
|
31
32
|
const meta = repo(ent).metadata;
|
|
32
33
|
if (meta.options.sqlExpression)
|
|
33
34
|
continue; // views: no real table
|
|
34
|
-
const table =
|
|
35
|
+
const table = tableKey(meta.dbName);
|
|
35
36
|
for (const field of meta.fields.toArray()) {
|
|
36
37
|
if (getRelationFieldInfo(field))
|
|
37
38
|
continue; // FK scalars are declared by their own field
|
|
@@ -41,13 +42,13 @@ export async function syncNullable(db, entities, opts) {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
const res = await db.createCommand().execute(`
|
|
44
|
-
SELECT table_name, column_name, is_nullable, column_default, data_type
|
|
45
|
+
SELECT ${tableKeySql('table_schema', 'table_name')} AS table_key, column_name, is_nullable, column_default, data_type
|
|
45
46
|
FROM information_schema.columns
|
|
46
|
-
WHERE table_schema
|
|
47
|
-
ORDER BY
|
|
47
|
+
WHERE ${USER_SCHEMA_SQL('table_schema')}
|
|
48
|
+
ORDER BY table_key, ordinal_position;
|
|
48
49
|
`);
|
|
49
50
|
const current = res.rows.map((row) => ({
|
|
50
|
-
table: row.
|
|
51
|
+
table: row.table_key,
|
|
51
52
|
column: row.column_name,
|
|
52
53
|
isNullable: row.is_nullable === 'YES',
|
|
53
54
|
hasDefault: row.column_default != null,
|
|
@@ -63,15 +64,13 @@ export async function syncNullable(db, entities, opts) {
|
|
|
63
64
|
.map((c) => drifted.get(`${c.table}.${c.column}`))
|
|
64
65
|
.filter((p) => !!p);
|
|
65
66
|
for (const p of fixed) {
|
|
66
|
-
const col = `ALTER TABLE
|
|
67
|
+
const col = `ALTER TABLE ${quoteTable(p.table)} ALTER COLUMN "${p.column}"`;
|
|
67
68
|
if (p.dropDefault)
|
|
68
|
-
await db
|
|
69
|
+
await execStmt(db, `${col} DROP DEFAULT;`);
|
|
69
70
|
if (p.dropNotNull)
|
|
70
|
-
await db
|
|
71
|
+
await execStmt(db, `${col} DROP NOT NULL;`);
|
|
71
72
|
if (p.blankToNull) {
|
|
72
|
-
await db
|
|
73
|
-
.createCommand()
|
|
74
|
-
.execute(`UPDATE "${p.table}" SET "${p.column}" = NULL WHERE "${p.column}" = '';`);
|
|
73
|
+
await execStmt(db, `UPDATE ${quoteTable(p.table)} SET "${p.column}" = NULL WHERE "${p.column}" = '';`);
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { repo } from 'remult';
|
|
2
2
|
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
-
import {
|
|
3
|
+
import { execStmt } from './catalog';
|
|
4
|
+
import { quoteTable, stripIdent, tableKey, tableKeySql, USER_SCHEMA_SQL } from './ident';
|
|
4
5
|
import { planDropColumns } from './planDropColumns';
|
|
5
6
|
/**
|
|
6
7
|
* Audit - and optionally drop a selected subset of - orphan columns: live DB
|
|
@@ -45,20 +46,20 @@ export async function dropColumns(db, entities, opts) {
|
|
|
45
46
|
continue; // computed, no stored column
|
|
46
47
|
columns.push(stripIdent(field.dbName));
|
|
47
48
|
}
|
|
48
|
-
known.push({ table:
|
|
49
|
+
known.push({ table: tableKey(meta.dbName), columns });
|
|
49
50
|
}
|
|
50
51
|
// Live columns per table from the catalog, in ordinal order.
|
|
51
52
|
const res = await db.createCommand().execute(`
|
|
52
|
-
SELECT table_name, column_name
|
|
53
|
+
SELECT ${tableKeySql('table_schema', 'table_name')} AS table_key, column_name
|
|
53
54
|
FROM information_schema.columns
|
|
54
|
-
WHERE table_schema
|
|
55
|
-
ORDER BY
|
|
55
|
+
WHERE ${USER_SCHEMA_SQL('table_schema')}
|
|
56
|
+
ORDER BY table_key, ordinal_position;
|
|
56
57
|
`);
|
|
57
58
|
const liveByTable = new Map();
|
|
58
59
|
for (const row of res.rows) {
|
|
59
|
-
const list = liveByTable.get(row.
|
|
60
|
+
const list = liveByTable.get(row.table_key) ?? [];
|
|
60
61
|
list.push(row.column_name);
|
|
61
|
-
liveByTable.set(row.
|
|
62
|
+
liveByTable.set(row.table_key, list);
|
|
62
63
|
}
|
|
63
64
|
const current = Array.from(liveByTable, ([table, columns]) => ({ table, columns }));
|
|
64
65
|
const plans = planDropColumns(current, known);
|
|
@@ -69,7 +70,7 @@ export async function dropColumns(db, entities, opts) {
|
|
|
69
70
|
// raw client input, so the interpolated identifiers are trusted.
|
|
70
71
|
dropped = opts.columns.filter((c) => orphan.has(`${c.table}.${c.column}`));
|
|
71
72
|
for (const { table, column } of dropped) {
|
|
72
|
-
await db
|
|
73
|
+
await execStmt(db, `ALTER TABLE ${quoteTable(table)} DROP COLUMN "${column}";`);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
return { applied: opts?.apply ?? false, plans, dropped };
|
|
@@ -17,7 +17,7 @@ export type PkDesired = {
|
|
|
17
17
|
table: string;
|
|
18
18
|
cols: string[];
|
|
19
19
|
};
|
|
20
|
-
type PkAction = 'ok' | 'create' | 'migrate';
|
|
20
|
+
type PkAction = 'ok' | 'create' | 'migrate' | 'missing';
|
|
21
21
|
export type PkPlan = {
|
|
22
22
|
table: string;
|
|
23
23
|
before: string[];
|
|
@@ -30,7 +30,8 @@ export type PkPlan = {
|
|
|
30
30
|
* in `desired` (firstly infra, views, etc.) are left alone - we only manage what
|
|
31
31
|
* an entity's `id` config describes. Several entities can share one table (a
|
|
32
32
|
* light projection + the full row on the same `dbName`) - one plan per table,
|
|
33
|
-
* first declaration wins, so the UI can key rows by table.
|
|
33
|
+
* first declaration wins, so the UI can key rows by table. `tables` is every live
|
|
34
|
+
* table: an entity whose table was never created is `missing`, not a PK to add.
|
|
34
35
|
*/
|
|
35
|
-
export declare function planPkSync(current: PkCurrent[], desired: PkDesired[]): PkPlan[];
|
|
36
|
+
export declare function planPkSync(current: PkCurrent[], desired: PkDesired[], tables: ReadonlySet<string>): PkPlan[];
|
|
36
37
|
export {};
|
|
@@ -1,11 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
import { quoteTable } from './ident';
|
|
9
2
|
const ident = (name) => `"${name}"`;
|
|
10
3
|
const cols = (names) => names.map(ident).join(', ');
|
|
11
4
|
const sameOrder = (a, b) => a.length === b.length && a.every((c, i) => c === b[i]);
|
|
@@ -14,21 +7,24 @@ const sameOrder = (a, b) => a.length === b.length && a.every((c, i) => c === b[i
|
|
|
14
7
|
* in `desired` (firstly infra, views, etc.) are left alone - we only manage what
|
|
15
8
|
* an entity's `id` config describes. Several entities can share one table (a
|
|
16
9
|
* 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.
|
|
10
|
+
* first declaration wins, so the UI can key rows by table. `tables` is every live
|
|
11
|
+
* table: an entity whose table was never created is `missing`, not a PK to add.
|
|
18
12
|
*/
|
|
19
|
-
export function planPkSync(current, desired) {
|
|
13
|
+
export function planPkSync(current, desired, tables) {
|
|
20
14
|
const byTable = new Map(current.map((c) => [c.table, c]));
|
|
21
15
|
const seen = new Set();
|
|
22
16
|
return desired.flatMap(({ table, cols: after }) => {
|
|
23
17
|
if (seen.has(table))
|
|
24
18
|
return [];
|
|
25
19
|
seen.add(table);
|
|
20
|
+
if (!tables.has(table))
|
|
21
|
+
return [{ table, before: [], after, action: 'missing', sql: [] }];
|
|
26
22
|
return [planTable(byTable.get(table), table, after)];
|
|
27
23
|
});
|
|
28
24
|
}
|
|
29
25
|
function planTable(cur, table, after) {
|
|
30
26
|
const before = cur?.cols ?? [];
|
|
31
|
-
const addPk = `ALTER TABLE ${
|
|
27
|
+
const addPk = `ALTER TABLE ${quoteTable(table)} ADD PRIMARY KEY (${cols(after)});`;
|
|
32
28
|
if (!cur)
|
|
33
29
|
return { table, before, after, action: 'create', sql: [addPk] };
|
|
34
30
|
if (sameOrder(cur.cols, after))
|
|
@@ -38,6 +34,6 @@ function planTable(cur, table, after) {
|
|
|
38
34
|
before,
|
|
39
35
|
after,
|
|
40
36
|
action: 'migrate',
|
|
41
|
-
sql: [`ALTER TABLE ${
|
|
37
|
+
sql: [`ALTER TABLE ${quoteTable(table)} DROP CONSTRAINT ${ident(cur.constraintName)};`, addPk],
|
|
42
38
|
};
|
|
43
39
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { repo } from 'remult';
|
|
2
|
-
import {
|
|
2
|
+
import { execStmt, liveTables } from './catalog';
|
|
3
|
+
import { stripIdent, tableKey, tableKeySql, USER_SCHEMA_SQL } from './ident';
|
|
3
4
|
import { planPkSync } from './planPkSync';
|
|
4
5
|
/**
|
|
5
6
|
* Audit - and optionally fix - PRIMARY KEY drift between the live DB and each
|
|
@@ -19,21 +20,22 @@ import { planPkSync } from './planPkSync';
|
|
|
19
20
|
export async function reindexPrimaryKeys(db, entities, opts) {
|
|
20
21
|
// Live PKs from the catalog, columns kept in constraint order.
|
|
21
22
|
const res = await db.createCommand().execute(`
|
|
22
|
-
SELECT tc.
|
|
23
|
+
SELECT ${tableKeySql('tc.table_schema', 'tc.table_name')} AS table_key,
|
|
24
|
+
tc.constraint_name, kcu.column_name
|
|
23
25
|
FROM information_schema.table_constraints tc
|
|
24
26
|
JOIN information_schema.key_column_usage kcu
|
|
25
27
|
ON tc.constraint_name = kcu.constraint_name
|
|
26
28
|
AND tc.table_schema = kcu.table_schema
|
|
27
29
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
28
|
-
AND tc.table_schema
|
|
29
|
-
ORDER BY
|
|
30
|
+
AND ${USER_SCHEMA_SQL('tc.table_schema')}
|
|
31
|
+
ORDER BY table_key, kcu.ordinal_position;
|
|
30
32
|
`);
|
|
31
33
|
const byTable = new Map();
|
|
32
34
|
for (const row of res.rows) {
|
|
33
|
-
let cur = byTable.get(row.
|
|
35
|
+
let cur = byTable.get(row.table_key);
|
|
34
36
|
if (!cur) {
|
|
35
|
-
cur = { table: row.
|
|
36
|
-
byTable.set(row.
|
|
37
|
+
cur = { table: row.table_key, constraintName: row.constraint_name, cols: [] };
|
|
38
|
+
byTable.set(row.table_key, cur);
|
|
37
39
|
}
|
|
38
40
|
cur.cols.push(row.column_name);
|
|
39
41
|
}
|
|
@@ -46,14 +48,15 @@ export async function reindexPrimaryKeys(db, entities, opts) {
|
|
|
46
48
|
const cols = meta.idMetadata.fields.map((f) => stripIdent(f.dbName));
|
|
47
49
|
if (cols.length === 0)
|
|
48
50
|
continue;
|
|
49
|
-
desired.push({ table:
|
|
51
|
+
desired.push({ table: tableKey(meta.dbName), cols });
|
|
50
52
|
}
|
|
51
|
-
const
|
|
53
|
+
const tables = await liveTables(db);
|
|
54
|
+
const plans = planPkSync([...byTable.values()], desired, tables);
|
|
52
55
|
if (opts?.apply) {
|
|
53
56
|
for (const plan of plans) {
|
|
54
57
|
// Fail-fast: any throw aborts the shared transaction -> full rollback.
|
|
55
58
|
for (const stmt of plan.sql)
|
|
56
|
-
await db
|
|
59
|
+
await execStmt(db, stmt);
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
return { applied: opts?.apply ?? false, plans };
|
|
@@ -13,7 +13,7 @@ export type RelIndexPlan = {
|
|
|
13
13
|
table: string;
|
|
14
14
|
columns: string[];
|
|
15
15
|
name: string;
|
|
16
|
-
action: 'ok' | 'create';
|
|
16
|
+
action: 'ok' | 'create' | 'missing';
|
|
17
17
|
/** name of the index already covering these columns (when action is 'ok'). */
|
|
18
18
|
coveredBy: string | null;
|
|
19
19
|
sql: string | null;
|
|
@@ -22,9 +22,9 @@ export type RelIndexPlan = {
|
|
|
22
22
|
* Pure: for each (deduped) relation FK, decide whether an index is missing.
|
|
23
23
|
* A relation is already covered if any existing index - including the PRIMARY KEY -
|
|
24
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).
|
|
25
|
+
* (e.g. an `a` index on an `(a, b)` PK table). A table never created is `missing`.
|
|
26
26
|
*/
|
|
27
|
-
export declare function planRelationIndexes(desired: RelIndexDesired[], existing: Map<string, ExistingIndex[]>): RelIndexPlan[];
|
|
27
|
+
export declare function planRelationIndexes(desired: RelIndexDesired[], existing: Map<string, ExistingIndex[]>, tables: ReadonlySet<string>): RelIndexPlan[];
|
|
28
28
|
/** FK columns of each entity's `toOne` relations - the indexes we'd like to exist. */
|
|
29
29
|
export declare function relationIndexTargets(entities: ClassType<unknown>[]): Promise<RelIndexDesired[]>;
|
|
30
30
|
/**
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { dbNamesOf, repo } from 'remult';
|
|
2
2
|
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
+
import { execStmt, liveTables } from './catalog';
|
|
3
4
|
import { formatCreateIndex } from './createIndex';
|
|
4
|
-
import { stripIdent } from './ident';
|
|
5
|
+
import { splitKey, stripIdent, tableKey, tableKeySql, USER_SCHEMA_SQL } from './ident';
|
|
5
6
|
/** `want` is covered if it's a leftmost prefix of `have` (so the PK covers its own prefix). */
|
|
6
7
|
const isPrefix = (want, have) => have.length >= want.length && want.every((c, i) => have[i] === c);
|
|
7
8
|
/**
|
|
8
9
|
* Pure: for each (deduped) relation FK, decide whether an index is missing.
|
|
9
10
|
* A relation is already covered if any existing index - including the PRIMARY KEY -
|
|
10
11
|
* 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
|
+
* (e.g. an `a` index on an `(a, b)` PK table). A table never created is `missing`.
|
|
12
13
|
*/
|
|
13
|
-
export function planRelationIndexes(desired, existing) {
|
|
14
|
+
export function planRelationIndexes(desired, existing, tables) {
|
|
14
15
|
const seen = new Set();
|
|
15
16
|
const plans = [];
|
|
16
17
|
for (const d of desired) {
|
|
@@ -19,6 +20,10 @@ export function planRelationIndexes(desired, existing) {
|
|
|
19
20
|
continue;
|
|
20
21
|
seen.add(key);
|
|
21
22
|
const name = `FF_IX_${d.table}_${d.columns.join('_')}`;
|
|
23
|
+
if (!tables.has(d.table)) {
|
|
24
|
+
plans.push({ ...d, name, action: 'missing', coveredBy: null, sql: null });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
22
27
|
const cover = (existing.get(d.table) ?? []).find((e) => isPrefix(d.columns, e.cols));
|
|
23
28
|
plans.push(cover
|
|
24
29
|
? { table: d.table, columns: d.columns, name, action: 'ok', coveredBy: cover.name, sql: null }
|
|
@@ -28,7 +33,12 @@ export function planRelationIndexes(desired, existing) {
|
|
|
28
33
|
name,
|
|
29
34
|
action: 'create',
|
|
30
35
|
coveredBy: null,
|
|
31
|
-
sql: formatCreateIndex({
|
|
36
|
+
sql: formatCreateIndex({
|
|
37
|
+
name,
|
|
38
|
+
...splitKey(d.table),
|
|
39
|
+
columns: d.columns,
|
|
40
|
+
ifNotExists: true,
|
|
41
|
+
}),
|
|
32
42
|
});
|
|
33
43
|
}
|
|
34
44
|
return plans;
|
|
@@ -41,7 +51,7 @@ export async function relationIndexTargets(entities) {
|
|
|
41
51
|
if (meta.options.sqlExpression)
|
|
42
52
|
continue; // views: no real table
|
|
43
53
|
const names = await dbNamesOf(ent);
|
|
44
|
-
const table =
|
|
54
|
+
const table = tableKey(names.$entityName);
|
|
45
55
|
for (const field of meta.fields.toArray()) {
|
|
46
56
|
const fi = getRelationFieldInfo(field);
|
|
47
57
|
if (!fi || fi.type !== 'toOne')
|
|
@@ -75,28 +85,29 @@ export async function createRelationIndexes(db, entities, opts) {
|
|
|
75
85
|
const desired = await relationIndexTargets(entities);
|
|
76
86
|
// Every existing index (incl. PK), columns in order, per table.
|
|
77
87
|
const res = await db.createCommand().execute(`
|
|
78
|
-
SELECT c.relname AS
|
|
88
|
+
SELECT ${tableKeySql('ns.nspname', 'c.relname')} AS table_key, i.relname AS index_name,
|
|
79
89
|
array_agg(a.attname::text ORDER BY k.ord) AS cols
|
|
80
90
|
FROM pg_class c
|
|
81
|
-
JOIN pg_namespace ns ON ns.oid = c.relnamespace AND ns.nspname
|
|
91
|
+
JOIN pg_namespace ns ON ns.oid = c.relnamespace AND ${USER_SCHEMA_SQL('ns.nspname')}
|
|
82
92
|
JOIN pg_index ix ON ix.indrelid = c.oid
|
|
83
93
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
84
94
|
JOIN unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
85
95
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
|
|
86
96
|
WHERE c.relkind = 'r' AND k.attnum > 0
|
|
87
|
-
GROUP BY c.relname, i.relname;
|
|
97
|
+
GROUP BY ns.nspname, c.relname, i.relname;
|
|
88
98
|
`);
|
|
89
99
|
const existing = new Map();
|
|
90
100
|
for (const row of res.rows) {
|
|
91
|
-
const list = existing.get(row.
|
|
101
|
+
const list = existing.get(row.table_key) ?? [];
|
|
92
102
|
list.push({ name: row.index_name, cols: row.cols });
|
|
93
|
-
existing.set(row.
|
|
103
|
+
existing.set(row.table_key, list);
|
|
94
104
|
}
|
|
95
|
-
const
|
|
105
|
+
const tables = await liveTables(db);
|
|
106
|
+
const plans = planRelationIndexes(desired, existing, tables);
|
|
96
107
|
if (opts?.apply) {
|
|
97
108
|
for (const plan of plans)
|
|
98
109
|
if (plan.sql)
|
|
99
|
-
await db
|
|
110
|
+
await execStmt(db, plan.sql);
|
|
100
111
|
}
|
|
101
112
|
return { applied: opts?.apply ?? false, plans };
|
|
102
113
|
}
|