firstly 0.10.5 → 0.10.7
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 +12 -0
- package/bin/args.d.ts +2 -1
- package/bin/args.js +3 -3
- package/bin/ff-sql.js +13 -3
- package/esm/mail/ui/LastMails.svelte +2 -1
- package/esm/sqlAdmin/SqlAdminController.d.ts +8 -3
- package/esm/sqlAdmin/SqlAdminController.js +35 -7
- package/esm/sqlAdmin/index.d.ts +1 -0
- package/esm/sqlAdmin/index.js +1 -0
- 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/server/index.d.ts +11 -1
- package/esm/sqlAdmin/server/index.js +26 -2
- package/esm/sqlAdmin/server/readOnlyRole.d.ts +15 -0
- package/esm/sqlAdmin/server/readOnlyRole.js +123 -0
- package/esm/sqlAdmin/sqlWire.d.ts +11 -0
- package/esm/sqlAdmin/sqlWire.js +28 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +23 -13
- package/esm/sqlAdmin/ui/SqlDrift.svelte +25 -19
- package/esm/sqlAdmin/ui/SqlTokens.svelte +5 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.10.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#388](https://github.com/jycouet/firstly/pull/388) [`7eca18a`](https://github.com/jycouet/firstly/commit/7eca18af4a55e2481dc81d38ba9e4f7f0d0f01a0) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: reads now run as a read-only Postgres role upserted at boot (`readPool`, default `'auto'`, `false` to opt out, or your own pool); `tokens.pool` is removed. SQL and results now travel encoded (`ffsql1:`) from `<SqlAdmin />` and `ff-sql`, so WAF rules stop blocking them; plain SQL still works.
|
|
8
|
+
|
|
9
|
+
## 0.10.6
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#383](https://github.com/jycouet/firstly/pull/383) [`3d0353b`](https://github.com/jycouet/firstly/commit/3d0353b176afd765883c50f6bc10c352b752df28) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin drift: entities in a non-`public` schema (e.g. `ff_auth.ba-accounts`) are checked in their own schema, a table not created yet is reported as `missing` instead of failing the whole apply, and errors show the real message and failing statement (no more `[object Object]`).
|
|
14
|
+
|
|
3
15
|
## 0.10.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/bin/args.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {string[]} argv
|
|
3
|
-
* @returns {{ help: boolean, json: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
|
|
3
|
+
* @returns {{ help: boolean, json: boolean, raw: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
|
|
4
4
|
*/
|
|
5
5
|
export function parseArgs(argv: string[]): {
|
|
6
6
|
help: boolean;
|
|
7
7
|
json: boolean;
|
|
8
|
+
raw: boolean;
|
|
8
9
|
origin?: string;
|
|
9
10
|
apiPath?: string;
|
|
10
11
|
sql: string;
|
package/bin/args.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the cli. Plain JS like its caller: bin/ ships unbuilt.
|
|
3
3
|
|
|
4
4
|
/** Flags that take no value. */
|
|
5
|
-
const BOOLEAN = ['json', 'help']
|
|
5
|
+
const BOOLEAN = ['json', 'help', 'raw']
|
|
6
6
|
/** Flags that need one, as `--x=y` or `--x y`. */
|
|
7
7
|
const VALUED = ['origin', 'api-path']
|
|
8
8
|
|
|
@@ -13,11 +13,11 @@ const FLAG = /^--[a-z][\w-]*(=|$)/
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {string[]} argv
|
|
16
|
-
* @returns {{ help: boolean, json: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
|
|
16
|
+
* @returns {{ help: boolean, json: boolean, raw: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
|
|
17
17
|
*/
|
|
18
18
|
export function parseArgs(argv) {
|
|
19
19
|
/** @type {any} */
|
|
20
|
-
const out = { help: false, json: false, sql: '' }
|
|
20
|
+
const out = { help: false, json: false, raw: false, sql: '' }
|
|
21
21
|
const positional = []
|
|
22
22
|
const seen = new Set()
|
|
23
23
|
|
package/bin/ff-sql.js
CHANGED
|
@@ -15,6 +15,7 @@ const HELP = `ff-sql - run SQL through a firstly sql token
|
|
|
15
15
|
SQL
|
|
16
16
|
|
|
17
17
|
--json raw JSON instead of a table
|
|
18
|
+
--raw send plain SQL, not ffsql1: (servers older than the encoding)
|
|
18
19
|
-- end of flags, everything after is SQL
|
|
19
20
|
--origin=URL the app to query (or FF_SQL_ORIGIN)
|
|
20
21
|
--api-path=P remult api root (default /api)
|
|
@@ -27,6 +28,13 @@ camelCase and need double quotes: "createdAt", not created_at. A heredoc avoids
|
|
|
27
28
|
fighting the shell over single quotes, and keeps the token out of the line.`
|
|
28
29
|
|
|
29
30
|
const TRAILING_SLASH = /\/$/
|
|
31
|
+
// Same scheme as `sqlWire.ts`: keeps WAF rules from matching SQL in transit.
|
|
32
|
+
const WIRE = 'ffsql1:'
|
|
33
|
+
const encode = (s) => WIRE + Buffer.from(s, 'utf8').toString('base64')
|
|
34
|
+
const decode = (s) =>
|
|
35
|
+
typeof s === 'string' && s.startsWith(WIRE)
|
|
36
|
+
? Buffer.from(s.slice(WIRE.length), 'base64').toString('utf8')
|
|
37
|
+
: s
|
|
30
38
|
|
|
31
39
|
const cli = parseArgs(process.argv.slice(2))
|
|
32
40
|
|
|
@@ -65,7 +73,7 @@ try {
|
|
|
65
73
|
res = await fetch(`${origin.replace(TRAILING_SLASH, '')}${apiPath}/ff/sqlAdmin/exec`, {
|
|
66
74
|
method: 'POST',
|
|
67
75
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
68
|
-
body: JSON.stringify({ args: [cmd] }),
|
|
76
|
+
body: JSON.stringify({ args: [cli.raw ? cmd : encode(cmd)] }),
|
|
69
77
|
})
|
|
70
78
|
} catch (err) {
|
|
71
79
|
console.error(`${origin} unreachable: ${err.message}`)
|
|
@@ -75,11 +83,13 @@ try {
|
|
|
75
83
|
const body = await res.json().catch(() => null)
|
|
76
84
|
if (!res.ok) {
|
|
77
85
|
// 403 here is almost always a dead token: expired, revoked, or minted elsewhere.
|
|
78
|
-
console.error(`${res.status} ${body?.message ?? res.statusText}`)
|
|
86
|
+
console.error(`${res.status} ${decode(body?.message) ?? res.statusText}`)
|
|
79
87
|
process.exit(1)
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
90
|
+
let data = body.data ?? body
|
|
91
|
+
if (typeof data === 'string') data = JSON.parse(decode(data))
|
|
92
|
+
const { rows, rowCount, took } = data
|
|
83
93
|
// Table and rows on stdout, the summary on stderr, so `| jq` and `> file` stay clean.
|
|
84
94
|
const out = new Console(process.stdout)
|
|
85
95
|
if (cli.json) out.log(JSON.stringify(rows, null, 2))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { remult, repo } from 'remult'
|
|
5
5
|
|
|
6
|
+
import { errorMessage } from '../../core/helper.js'
|
|
6
7
|
import { Mail } from '../Mail'
|
|
7
8
|
import { Roles_Mail } from '../Roles_Mail'
|
|
8
9
|
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
try {
|
|
29
30
|
mails = await repo(Mail).find({ limit })
|
|
30
31
|
} catch (e) {
|
|
31
|
-
error =
|
|
32
|
+
error = errorMessage(e)
|
|
32
33
|
} finally {
|
|
33
34
|
isLoading = false
|
|
34
35
|
}
|
|
@@ -47,17 +47,19 @@ export type SqlTokensOptions = {
|
|
|
47
47
|
apiPath?: string;
|
|
48
48
|
/** @default 30 */
|
|
49
49
|
callLogRetentionDays?: number;
|
|
50
|
-
/** Override the pool used by `read` tokens. Defaults to the Postgres pool behind the data provider. */
|
|
51
|
-
pool?: SqlTokenPool;
|
|
52
50
|
};
|
|
53
51
|
export declare const SERVER_ONLY = "sqlAdmin: server-only";
|
|
54
52
|
export declare const SQL_ADMINS: ("FF_Role.Admin" | "SqlAdmin.Admin")[];
|
|
53
|
+
export declare function getDb(): SqlDatabase;
|
|
54
|
+
/** The pg pool remult wraps, if any. `_getSourceSql` is internal but has been stable since remult 1. */
|
|
55
|
+
export declare function poolFrom(db: SqlDatabase): SqlTokenPool | undefined;
|
|
55
56
|
export declare class SqlAdminController {
|
|
56
57
|
/** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
|
|
57
58
|
static dp?: SqlDatabase;
|
|
58
59
|
/** Set by the `sqlAdmin()` module's `initApi`. */
|
|
59
60
|
static options: {
|
|
60
61
|
tokens?: SqlTokensOptions;
|
|
62
|
+
readPool?: SqlTokenPool;
|
|
61
63
|
};
|
|
62
64
|
/**
|
|
63
65
|
* @param cmd SQL to run.
|
|
@@ -67,8 +69,11 @@ export declare class SqlAdminController {
|
|
|
67
69
|
* want to mutate - the UI gates this behind an explicit checkbox.
|
|
68
70
|
*
|
|
69
71
|
* With a sql token the token decides, not the caller.
|
|
72
|
+
*
|
|
73
|
+
* A `cmd` sent through `encodeSqlWire` gets its result (or error message)
|
|
74
|
+
* back encoded the same way, as a string; plain SQL gets plain JSON.
|
|
70
75
|
*/
|
|
71
|
-
static exec(cmd: string, capabilities?: SqlCapability[]): Promise<SqlResult>;
|
|
76
|
+
static exec(cmd: string, capabilities?: SqlCapability[]): Promise<SqlResult | string>;
|
|
72
77
|
private static run;
|
|
73
78
|
private static execAsToken;
|
|
74
79
|
/**
|
|
@@ -5,21 +5,25 @@ 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, repo, SqlDatabase } from 'remult';
|
|
8
|
+
import { Log } from '@kitql/helpers';
|
|
8
9
|
import { FF_Role } from '../core/common';
|
|
9
10
|
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
11
|
import { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
|
|
12
|
+
import { decodeSqlWire, encodeSqlWire, isSqlWire } from './sqlWire';
|
|
11
13
|
// Thrown when a server-only body runs on the client (its `import.meta.env.SSR` block is gone there).
|
|
12
14
|
export const SERVER_ONLY = 'sqlAdmin: server-only';
|
|
13
15
|
export const SQL_ADMINS = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
|
|
14
16
|
const CALL_LOG_CMD_MAX = 4000;
|
|
17
|
+
// Not `log` from '../index': that barrel pulls the svelte components into the server.
|
|
18
|
+
const log = new Log('sqlAdmin');
|
|
15
19
|
/** Retention is a housekeeping sweep, not something to pay for on every query. */
|
|
16
20
|
const CALL_LOG_SWEEP_EVERY_MS = 3_600_000;
|
|
17
21
|
let lastCallLogSweep = 0;
|
|
18
|
-
function getDb() {
|
|
22
|
+
export function getDb() {
|
|
19
23
|
return SqlAdminController.dp ?? SqlDatabase.getDb();
|
|
20
24
|
}
|
|
21
25
|
/** The pg pool remult wraps, if any. `_getSourceSql` is internal but has been stable since remult 1. */
|
|
22
|
-
function poolFrom(db) {
|
|
26
|
+
export function poolFrom(db) {
|
|
23
27
|
const pool = db._getSourceSql?.()?.pool;
|
|
24
28
|
return typeof pool?.connect === 'function' ? pool : undefined;
|
|
25
29
|
}
|
|
@@ -36,12 +40,35 @@ export class SqlAdminController {
|
|
|
36
40
|
* want to mutate - the UI gates this behind an explicit checkbox.
|
|
37
41
|
*
|
|
38
42
|
* With a sql token the token decides, not the caller.
|
|
43
|
+
*
|
|
44
|
+
* A `cmd` sent through `encodeSqlWire` gets its result (or error message)
|
|
45
|
+
* back encoded the same way, as a string; plain SQL gets plain JSON.
|
|
39
46
|
*/
|
|
40
47
|
static async exec(cmd, capabilities = ['read']) {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
const wire = isSqlWire(cmd);
|
|
49
|
+
if (wire)
|
|
50
|
+
cmd = decodeSqlWire(cmd);
|
|
51
|
+
try {
|
|
52
|
+
const token = remult.context.sqlToken;
|
|
53
|
+
const res = token
|
|
54
|
+
? await SqlAdminController.execAsToken(token, cmd)
|
|
55
|
+
: await SqlAdminController.run(cmd, capabilities);
|
|
56
|
+
return wire ? encodeSqlWire(JSON.stringify(res)) : res;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (!wire)
|
|
60
|
+
throw err;
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
// remult logs the message it sends, so the clear one goes to the server log here.
|
|
63
|
+
log.error(msg);
|
|
64
|
+
// No stack: it repeats the message in clear. Status flags kept, or a 403 turns into a 400.
|
|
65
|
+
const { httpStatusCode, isForbiddenError } = (err ?? {});
|
|
66
|
+
throw Object.assign(new Error(encodeSqlWire(msg)), {
|
|
67
|
+
stack: undefined,
|
|
68
|
+
httpStatusCode,
|
|
69
|
+
isForbiddenError,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
45
72
|
}
|
|
46
73
|
static async run(cmd, capabilities) {
|
|
47
74
|
if (import.meta.env.SSR) {
|
|
@@ -54,7 +81,8 @@ export class SqlAdminController {
|
|
|
54
81
|
return { rows, rowCount: rows.length, took: performance.now() - start };
|
|
55
82
|
}
|
|
56
83
|
const { readOnlySql } = await import('./server/readOnlySql');
|
|
57
|
-
|
|
84
|
+
const o = SqlAdminController.options;
|
|
85
|
+
return await readOnlySql(db, o.readPool ?? poolFrom(db), cmd);
|
|
58
86
|
}
|
|
59
87
|
catch (err) {
|
|
60
88
|
const { enrichSqlError } = await import('./server/sqlError');
|
package/esm/sqlAdmin/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
|
3
3
|
export { SqlAdminController, type SqlResult, type SqlTokensOptions } from './SqlAdminController';
|
|
4
4
|
export { SqlDriftController, type SqlDriftFlag } from './SqlDriftController';
|
|
5
5
|
export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, type SqlCapability, type SqlTokenTtl, } from './sqlTokenEntities';
|
|
6
|
+
export { decodeSqlWire, encodeSqlWire, isSqlWire, SQL_WIRE_PREFIX } from './sqlWire';
|
|
6
7
|
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
7
8
|
export { default as SqlDrift } from './ui/SqlDrift.svelte';
|
|
8
9
|
export { default as SqlTokens } from './ui/SqlTokens.svelte';
|
package/esm/sqlAdmin/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
|
3
3
|
export { SqlAdminController } from './SqlAdminController';
|
|
4
4
|
export { SqlDriftController } from './SqlDriftController';
|
|
5
5
|
export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
|
|
6
|
+
export { decodeSqlWire, encodeSqlWire, isSqlWire, SQL_WIRE_PREFIX } from './sqlWire';
|
|
6
7
|
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
7
8
|
export { default as SqlDrift } from './ui/SqlDrift.svelte';
|
|
8
9
|
export { default as SqlTokens } from './ui/SqlTokens.svelte';
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
2
|
import { Module } from 'remult/server';
|
|
3
|
-
import { type SqlTokensOptions } from '../SqlAdminController';
|
|
3
|
+
import { type SqlTokenPool, type SqlTokensOptions } from '../SqlAdminController';
|
|
4
4
|
import { type SqlDriftOptions } from '../SqlDriftController';
|
|
5
5
|
export type { SqlTokensOptions, SqlTokenPool } from '../SqlAdminController';
|
|
6
6
|
export type { SqlDriftFlag, SqlDriftOptions } from '../SqlDriftController';
|
|
@@ -33,6 +33,16 @@ export type SqlAdminOptions = {
|
|
|
33
33
|
* @default true
|
|
34
34
|
*/
|
|
35
35
|
sqlAdmin?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Run `read` queries (console and tokens) as a read-only role instead of the
|
|
38
|
+
* app role. `'auto'` upserts `ff_readonly_<db>` at boot through the app's own
|
|
39
|
+
* pool (needs CREATEROLE), or pass a pool you connected yourself. `false`, or a
|
|
40
|
+
* database other than Postgres, leaves reads to the READ ONLY transaction alone.
|
|
41
|
+
*
|
|
42
|
+
* @default 'auto'
|
|
43
|
+
* @example readPool: new pg.Pool({ connectionString: env.DATABASE_URL_READONLY, max: 2 })
|
|
44
|
+
*/
|
|
45
|
+
readPool?: 'auto' | false | SqlTokenPool;
|
|
36
46
|
/**
|
|
37
47
|
* Bearer tokens that can run SQL from outside a browser session (a script,
|
|
38
48
|
* an AI on a dev machine). Nothing is registered unless set - no entities,
|
|
@@ -2,7 +2,7 @@ import { remult, repo } from 'remult';
|
|
|
2
2
|
import { Module } from 'remult/server';
|
|
3
3
|
import { yellow } from '@kitql/helpers';
|
|
4
4
|
import { log } from '..';
|
|
5
|
-
import { SqlAdminController } from '../SqlAdminController';
|
|
5
|
+
import { getDb, poolFrom, SqlAdminController, } from '../SqlAdminController';
|
|
6
6
|
import { SqlDriftController } from '../SqlDriftController';
|
|
7
7
|
import { isSqlTokenLive, SqlToken, sqlTokenEntities } from '../sqlTokenEntities';
|
|
8
8
|
import { hashToken } from './token';
|
|
@@ -30,6 +30,27 @@ function readPathname(req) {
|
|
|
30
30
|
return '';
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
async function resolveReadPool(opt = 'auto') {
|
|
34
|
+
if (opt !== 'auto')
|
|
35
|
+
return opt || undefined;
|
|
36
|
+
const app = poolFrom(getDb());
|
|
37
|
+
// Not Postgres (or a custom `dp`): nothing to create, the transaction guard applies.
|
|
38
|
+
if (!app)
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const { ensureReadOnlyPool } = await import('./readOnlyRole');
|
|
42
|
+
const ro = await ensureReadOnlyPool(app);
|
|
43
|
+
if (ro.updated)
|
|
44
|
+
log.info(`reads run as ${yellow(ro.role)} (role created or updated).`);
|
|
45
|
+
return ro.pool;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// Boot must survive: the READ ONLY transaction still guards reads.
|
|
49
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
50
|
+
log.error(`readPool: 'auto' failed (${msg}) - reads use the app role. ${yellow('readPool: false')} silences this.`);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
33
54
|
/**
|
|
34
55
|
* Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
|
|
35
56
|
*
|
|
@@ -76,7 +97,10 @@ export const sqlAdmin = (opts) => {
|
|
|
76
97
|
SqlAdminController.dp = await opts.dp();
|
|
77
98
|
SqlDriftController.dp = SqlAdminController.dp;
|
|
78
99
|
}
|
|
79
|
-
SqlAdminController.options = {
|
|
100
|
+
SqlAdminController.options = {
|
|
101
|
+
tokens,
|
|
102
|
+
readPool: enabled ? await resolveReadPool(opts?.readPool) : undefined,
|
|
103
|
+
};
|
|
80
104
|
SqlDriftController.options = driftOn ? drift : undefined;
|
|
81
105
|
if (enabled)
|
|
82
106
|
log.info(`AI Hint: visit ${yellow(path)} to query raw SQL.`);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SqlTokenPool } from '../SqlAdminController';
|
|
2
|
+
export declare const readOnlyRoleName: (db: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* Returns a pool connected as a login role that can only SELECT in this
|
|
5
|
+
* database. Per-database grants rather than `pg_read_all_data`: that one
|
|
6
|
+
* reaches every database of the cluster. A boot where the role is already
|
|
7
|
+
* right costs one login and one catalog query; anything off (new schema,
|
|
8
|
+
* rotated app password, missing grant) re-runs the full setup. Needs
|
|
9
|
+
* CREATEROLE on the app role.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureReadOnlyPool(app: SqlTokenPool): Promise<{
|
|
12
|
+
pool: SqlTokenPool;
|
|
13
|
+
role: string;
|
|
14
|
+
updated: boolean;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
2
|
+
const NON_IDENT = /[^a-z0-9_]/g;
|
|
3
|
+
const MAX_IDENT = 63;
|
|
4
|
+
const DUPLICATE_OBJECT = '42710';
|
|
5
|
+
const STATEMENT_TIMEOUT = '30s';
|
|
6
|
+
/** Schemas the app can grant on: it owns them, or is a member of their owner. */
|
|
7
|
+
const GRANTABLE_SCHEMA = `n.nspname !~ '^pg_' and n.nspname <> 'information_schema' and pg_has_role(n.nspowner, 'USAGE')`;
|
|
8
|
+
/** One round trip: is the role still exactly what setup would leave behind? */
|
|
9
|
+
const STATE_SQL = `
|
|
10
|
+
select
|
|
11
|
+
r.rolsuper or r.rolcreaterole or r.rolcreatedb or r.rolreplication or r.rolbypassrls as extra,
|
|
12
|
+
coalesce(r.rolconfig @> array['default_transaction_read_only=on', 'statement_timeout=${STATEMENT_TIMEOUT}'], false)
|
|
13
|
+
and not exists (
|
|
14
|
+
select 1 from pg_namespace n
|
|
15
|
+
where ${GRANTABLE_SCHEMA}
|
|
16
|
+
and (not has_schema_privilege(r.oid, n.oid, 'USAGE')
|
|
17
|
+
or not exists (
|
|
18
|
+
select 1 from pg_default_acl d, aclexplode(d.defaclacl) a
|
|
19
|
+
where d.defaclnamespace = n.oid and d.defaclobjtype = 'r'
|
|
20
|
+
and d.defaclrole = (select oid from pg_roles where rolname = current_user)
|
|
21
|
+
and a.grantee = r.oid and a.privilege_type = 'SELECT')))
|
|
22
|
+
and not exists (
|
|
23
|
+
select 1 from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
24
|
+
where ${GRANTABLE_SCHEMA} and c.relkind in ('r', 'v', 'm', 'p', 'f')
|
|
25
|
+
and pg_has_role(c.relowner, 'USAGE')
|
|
26
|
+
and not has_table_privilege(r.oid, c.oid, 'SELECT')) as ready
|
|
27
|
+
from pg_roles r where r.rolname = $1`;
|
|
28
|
+
const quote = (s) => `"${s.replaceAll('"', '""')}"`;
|
|
29
|
+
export const readOnlyRoleName = (db) => `ff_readonly_${db.toLowerCase().replace(NON_IDENT, '_')}`.slice(0, MAX_IDENT);
|
|
30
|
+
/** The app's own password: every instance derives the same role password, nothing to store. */
|
|
31
|
+
function appSecret(options) {
|
|
32
|
+
if (options.connectionString) {
|
|
33
|
+
const url = new URL(options.connectionString);
|
|
34
|
+
const pw = url.searchParams.get('password') || decodeURIComponent(url.password);
|
|
35
|
+
if (pw)
|
|
36
|
+
return pw;
|
|
37
|
+
}
|
|
38
|
+
if (typeof options.password === 'string' && options.password)
|
|
39
|
+
return options.password;
|
|
40
|
+
return process.env.PGPASSWORD || undefined;
|
|
41
|
+
}
|
|
42
|
+
function withCredentials(options, user, password) {
|
|
43
|
+
const { connectionString, ...rest } = options;
|
|
44
|
+
// pg lets the connection string win over `user`/`password`, so rewrite the URL itself.
|
|
45
|
+
// Query params, not userinfo: a hostless URL (unix socket) silently drops userinfo, and pg reads params first.
|
|
46
|
+
if (connectionString) {
|
|
47
|
+
const url = new URL(connectionString);
|
|
48
|
+
url.username = '';
|
|
49
|
+
url.password = '';
|
|
50
|
+
url.searchParams.set('user', user);
|
|
51
|
+
url.searchParams.set('password', password);
|
|
52
|
+
return { ...rest, connectionString: url.toString(), max: 2 };
|
|
53
|
+
}
|
|
54
|
+
return { ...rest, user, password, max: 2 };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns a pool connected as a login role that can only SELECT in this
|
|
58
|
+
* database. Per-database grants rather than `pg_read_all_data`: that one
|
|
59
|
+
* reaches every database of the cluster. A boot where the role is already
|
|
60
|
+
* right costs one login and one catalog query; anything off (new schema,
|
|
61
|
+
* rotated app password, missing grant) re-runs the full setup. Needs
|
|
62
|
+
* CREATEROLE on the app role.
|
|
63
|
+
*/
|
|
64
|
+
export async function ensureReadOnlyPool(app) {
|
|
65
|
+
const pool = app;
|
|
66
|
+
if (typeof pool.query !== 'function' || !pool.options)
|
|
67
|
+
throw new Error('not a pg pool');
|
|
68
|
+
const { rows } = await pool.query('select current_database() as db');
|
|
69
|
+
const db = rows[0].db;
|
|
70
|
+
const role = readOnlyRoleName(db);
|
|
71
|
+
const secret = appSecret(pool.options);
|
|
72
|
+
const password = secret
|
|
73
|
+
? createHmac('sha256', secret).update(role).digest('hex')
|
|
74
|
+
: randomBytes(32).toString('hex');
|
|
75
|
+
const Pool = pool.constructor;
|
|
76
|
+
const ro = new Pool(withCredentials(pool.options, role, password));
|
|
77
|
+
const state = await readState(pool, ro, role);
|
|
78
|
+
// A role with the same name made by someone else must not become our reader.
|
|
79
|
+
if (state?.extra)
|
|
80
|
+
throw new Error(`role ${role} has more than read rights, refusing it`);
|
|
81
|
+
if (state?.ready)
|
|
82
|
+
return { pool: ro, role, updated: false };
|
|
83
|
+
await setup(pool, db, role, password);
|
|
84
|
+
// Handing back a pool that cannot log in would break every read instead of falling back.
|
|
85
|
+
if (!(await connectsAs(ro, role))) {
|
|
86
|
+
await ro.end?.().catch(() => { });
|
|
87
|
+
throw new Error(`role ${role} was set up but cannot log in (pg_hba, pooler user naming?)`);
|
|
88
|
+
}
|
|
89
|
+
return { pool: ro, role, updated: true };
|
|
90
|
+
}
|
|
91
|
+
async function readState(app, ro, role) {
|
|
92
|
+
const row = (await app.query(STATE_SQL, [role])).rows[0];
|
|
93
|
+
if (!row)
|
|
94
|
+
return undefined;
|
|
95
|
+
// Logging in proves the derived password still matches (the app's may have rotated).
|
|
96
|
+
const loggedIn = !row.extra && (await connectsAs(ro, role));
|
|
97
|
+
return { extra: row.extra, ready: loggedIn && row.ready === true };
|
|
98
|
+
}
|
|
99
|
+
/** As `role`, not merely connected: a dropped credential would log in as the app. */
|
|
100
|
+
const connectsAs = (ro, role) => ro.query('select current_user as u').then((r) => r.rows[0]?.u === role, () => false);
|
|
101
|
+
async function setup(pool, db, role, password) {
|
|
102
|
+
const r = quote(role);
|
|
103
|
+
try {
|
|
104
|
+
await pool.query(`CREATE ROLE ${r} LOGIN`);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err?.code !== DUPLICATE_OBJECT)
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
// Only a superuser may set NOSUPERUSER & co, hence the `extra` check instead.
|
|
111
|
+
await pool.query(`ALTER ROLE ${r} WITH LOGIN PASSWORD '${password}'`);
|
|
112
|
+
await pool.query(`ALTER ROLE ${r} SET default_transaction_read_only = on`);
|
|
113
|
+
await pool.query(`ALTER ROLE ${r} SET statement_timeout = '${STATEMENT_TIMEOUT}'`);
|
|
114
|
+
await pool.query(`GRANT CONNECT ON DATABASE ${quote(db)} TO ${r}`);
|
|
115
|
+
const schemas = await pool.query(`select n.nspname from pg_namespace n where ${GRANTABLE_SCHEMA}`);
|
|
116
|
+
for (const { nspname } of schemas.rows) {
|
|
117
|
+
const s = quote(nspname);
|
|
118
|
+
await pool.query(`GRANT USAGE ON SCHEMA ${s} TO ${r}`);
|
|
119
|
+
await pool.query(`GRANT SELECT ON ALL TABLES IN SCHEMA ${s} TO ${r}`);
|
|
120
|
+
// Tables the app creates later (remult ensureSchema, migrations).
|
|
121
|
+
await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT SELECT ON TABLES TO ${r}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reversible wire encoding for SQL and its results. Not a secret (TLS + roles
|
|
3
|
+
* are the security): it only keeps WAF rule sets from pattern-matching
|
|
4
|
+
* `SELECT ... FROM` or Postgres error text in requests and responses.
|
|
5
|
+
* The version suffix leaves room for another scheme if a WAF learns to decode this one.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SQL_WIRE_PREFIX = "ffsql1:";
|
|
8
|
+
export declare const isSqlWire: (s: unknown) => s is string;
|
|
9
|
+
export declare function encodeSqlWire(s: string): string;
|
|
10
|
+
/** Anything without the prefix passes through: plain SQL keeps working. */
|
|
11
|
+
export declare function decodeSqlWire(s: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reversible wire encoding for SQL and its results. Not a secret (TLS + roles
|
|
3
|
+
* are the security): it only keeps WAF rule sets from pattern-matching
|
|
4
|
+
* `SELECT ... FROM` or Postgres error text in requests and responses.
|
|
5
|
+
* The version suffix leaves room for another scheme if a WAF learns to decode this one.
|
|
6
|
+
*/
|
|
7
|
+
export const SQL_WIRE_PREFIX = 'ffsql1:';
|
|
8
|
+
/** Spreading a huge array into fromCharCode overflows the stack. */
|
|
9
|
+
const CHUNK = 0x8000;
|
|
10
|
+
export const isSqlWire = (s) => typeof s === 'string' && s.startsWith(SQL_WIRE_PREFIX);
|
|
11
|
+
export function encodeSqlWire(s) {
|
|
12
|
+
const bytes = new TextEncoder().encode(s);
|
|
13
|
+
let bin = '';
|
|
14
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
15
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
16
|
+
}
|
|
17
|
+
return SQL_WIRE_PREFIX + btoa(bin);
|
|
18
|
+
}
|
|
19
|
+
/** Anything without the prefix passes through: plain SQL keeps working. */
|
|
20
|
+
export function decodeSqlWire(s) {
|
|
21
|
+
if (!isSqlWire(s))
|
|
22
|
+
return s;
|
|
23
|
+
const bin = atob(s.slice(SQL_WIRE_PREFIX.length));
|
|
24
|
+
const bytes = new Uint8Array(bin.length);
|
|
25
|
+
for (let i = 0; i < bin.length; i++)
|
|
26
|
+
bytes[i] = bin.charCodeAt(i);
|
|
27
|
+
return new TextDecoder().decode(bytes);
|
|
28
|
+
}
|
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
* can read them with `list_console_messages`.
|
|
12
12
|
*/
|
|
13
13
|
import { log } from '../index'
|
|
14
|
-
import { SqlAdminController } from '../SqlAdminController'
|
|
14
|
+
import { SqlAdminController, type SqlResult } from '../SqlAdminController'
|
|
15
|
+
import { decodeSqlWire, encodeSqlWire } from '../sqlWire'
|
|
15
16
|
|
|
16
17
|
const defaultQuery = `SELECT *
|
|
17
18
|
FROM "public"."users"
|
|
18
19
|
LIMIT 10`
|
|
19
20
|
|
|
20
21
|
let sqlInput = $state(defaultQuery)
|
|
21
|
-
let result:
|
|
22
|
+
let result: SqlResult | undefined = $state()
|
|
22
23
|
let error = $state('')
|
|
23
24
|
let isLoading = $state(false)
|
|
24
25
|
let allowWrites = $state(false)
|
|
@@ -64,10 +65,19 @@ ORDER BY tablename, indexname`,
|
|
|
64
65
|
try {
|
|
65
66
|
error = ''
|
|
66
67
|
isLoading = true
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// Encoded both ways: WAF rules block SQL text in requests and Postgres errors in responses.
|
|
69
|
+
const res = await SqlAdminController.exec(
|
|
70
|
+
encodeSqlWire(sqlInput),
|
|
71
|
+
allowWrites ? ['read', 'write'] : ['read'],
|
|
72
|
+
)
|
|
73
|
+
const r: SqlResult = typeof res === 'string' ? JSON.parse(decodeSqlWire(res)) : res
|
|
74
|
+
result = r
|
|
75
|
+
log.info('for AI:', JSON.stringify(r.rows))
|
|
76
|
+
log.info('for humans:', r)
|
|
77
|
+
} catch (e: any) {
|
|
78
|
+
if (typeof e?.message === 'string') e.message = decodeSqlWire(e.message)
|
|
79
|
+
// A stale table under a new error reads as this query's result.
|
|
80
|
+
result = undefined
|
|
71
81
|
error = JSON.stringify(e, null, 2)
|
|
72
82
|
} finally {
|
|
73
83
|
isLoading = false
|
|
@@ -171,13 +181,6 @@ ORDER BY tablename, indexname`,
|
|
|
171
181
|
Allow writes
|
|
172
182
|
</label>
|
|
173
183
|
|
|
174
|
-
{#if error}
|
|
175
|
-
<pre
|
|
176
|
-
class="flex-1 overflow-auto border border-destructive bg-destructive/10 p-3 text-sm text-destructive">{error.replaceAll(
|
|
177
|
-
'\\n',
|
|
178
|
-
'\n',
|
|
179
|
-
)}</pre>
|
|
180
|
-
{/if}
|
|
181
184
|
{#if result}
|
|
182
185
|
<div
|
|
183
186
|
class="flex flex-1 items-center justify-between gap-3 border border-border bg-muted p-3 text-sm text-muted-foreground"
|
|
@@ -187,6 +190,13 @@ ORDER BY tablename, indexname`,
|
|
|
187
190
|
</div>
|
|
188
191
|
{/if}
|
|
189
192
|
</div>
|
|
193
|
+
{#if error}
|
|
194
|
+
<pre
|
|
195
|
+
class="overflow-auto border border-destructive bg-destructive/10 p-3 text-sm text-destructive">{error.replaceAll(
|
|
196
|
+
'\\n',
|
|
197
|
+
'\n',
|
|
198
|
+
)}</pre>
|
|
199
|
+
{/if}
|
|
190
200
|
</form>
|
|
191
201
|
{#if result}
|
|
192
202
|
{#if result.rows && result.rows.length > 0}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { onMount } from 'svelte'
|
|
9
9
|
import { SvelteSet } from 'svelte/reactivity'
|
|
10
10
|
|
|
11
|
+
import { errorMessage } from '../../core/helper.js'
|
|
11
12
|
import { SqlDriftController, type SqlDriftFlag } from '../SqlDriftController'
|
|
12
13
|
|
|
13
14
|
// Inferred from the BackendMethods, so this never imports from `server/`.
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
try {
|
|
23
24
|
flags = await SqlDriftController.driftFlags()
|
|
24
25
|
} catch (e) {
|
|
25
|
-
flagsError =
|
|
26
|
+
flagsError = errorMessage(e)
|
|
26
27
|
}
|
|
27
28
|
})
|
|
28
29
|
|
|
@@ -31,14 +32,15 @@
|
|
|
31
32
|
let pkError = $state('')
|
|
32
33
|
let pkResult = $state<PkResult | null>(null)
|
|
33
34
|
|
|
35
|
+
const PK_ORDER = { migrate: 0, create: 0, missing: 1, ok: 2 } as const
|
|
34
36
|
const pkPlans = $derived(
|
|
35
|
-
(pkResult?.plans ?? []).toSorted(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
(pkResult?.plans ?? []).toSorted(
|
|
38
|
+
(a, b) => PK_ORDER[a.action] - PK_ORDER[b.action] || a.table.localeCompare(b.table),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
const pkDrift = $derived(
|
|
42
|
+
pkPlans.filter((p) => p.action === 'migrate' || p.action === 'create').length,
|
|
40
43
|
)
|
|
41
|
-
const pkDrift = $derived(pkPlans.filter((p) => p.action !== 'ok').length)
|
|
42
44
|
|
|
43
45
|
async function runPk(apply: boolean) {
|
|
44
46
|
if (
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
pkResult = await SqlDriftController.primaryKeys({ apply })
|
|
53
55
|
console.info('for AI:', JSON.stringify(pkResult))
|
|
54
56
|
} catch (e) {
|
|
55
|
-
pkError =
|
|
57
|
+
pkError = errorMessage(e)
|
|
56
58
|
} finally {
|
|
57
59
|
pkLoading = false
|
|
58
60
|
}
|
|
@@ -63,12 +65,11 @@
|
|
|
63
65
|
let relError = $state('')
|
|
64
66
|
let relResult = $state<RelResult | null>(null)
|
|
65
67
|
|
|
68
|
+
const REL_ORDER = { create: 0, missing: 1, ok: 2 } as const
|
|
66
69
|
const relPlans = $derived(
|
|
67
|
-
(relResult?.plans ?? []).toSorted(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return a.name.localeCompare(b.name)
|
|
71
|
-
}),
|
|
70
|
+
(relResult?.plans ?? []).toSorted(
|
|
71
|
+
(a, b) => REL_ORDER[a.action] - REL_ORDER[b.action] || a.name.localeCompare(b.name),
|
|
72
|
+
),
|
|
72
73
|
)
|
|
73
74
|
const relMissing = $derived(relPlans.filter((p) => p.action === 'create').length)
|
|
74
75
|
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
relResult = await SqlDriftController.relationIndexes({ apply })
|
|
81
82
|
console.info('for AI:', JSON.stringify(relResult))
|
|
82
83
|
} catch (e) {
|
|
83
|
-
relError =
|
|
84
|
+
relError = errorMessage(e)
|
|
84
85
|
} finally {
|
|
85
86
|
relLoading = false
|
|
86
87
|
}
|
|
@@ -127,7 +128,7 @@
|
|
|
127
128
|
selected.clear()
|
|
128
129
|
console.info('for AI:', JSON.stringify(dropResult))
|
|
129
130
|
} catch (e) {
|
|
130
|
-
dropError =
|
|
131
|
+
dropError = errorMessage(e)
|
|
131
132
|
} finally {
|
|
132
133
|
dropLoading = false
|
|
133
134
|
}
|
|
@@ -175,7 +176,7 @@
|
|
|
175
176
|
nullSelected.clear()
|
|
176
177
|
console.info('for AI:', JSON.stringify(nullResult))
|
|
177
178
|
} catch (e) {
|
|
178
|
-
nullError =
|
|
179
|
+
nullError = errorMessage(e)
|
|
179
180
|
} finally {
|
|
180
181
|
nullLoading = false
|
|
181
182
|
}
|
|
@@ -282,9 +283,10 @@
|
|
|
282
283
|
</td>
|
|
283
284
|
<td
|
|
284
285
|
class="border-border border-b px-3 py-2 font-mono"
|
|
285
|
-
class:text-primary={p.action
|
|
286
|
+
class:text-primary={p.action === 'migrate' || p.action === 'create'}
|
|
287
|
+
class:text-muted-foreground={p.action === 'missing'}
|
|
286
288
|
>
|
|
287
|
-
{p.action}
|
|
289
|
+
{p.action === 'missing' ? 'table missing · run migrations first' : p.action}
|
|
288
290
|
</td>
|
|
289
291
|
</tr>
|
|
290
292
|
{/each}
|
|
@@ -339,7 +341,11 @@
|
|
|
339
341
|
class="border-border border-b px-3 py-2 font-mono"
|
|
340
342
|
class:text-primary={p.action === 'create'}
|
|
341
343
|
>
|
|
342
|
-
{p.action === '
|
|
344
|
+
{p.action === 'ok'
|
|
345
|
+
? `covered · ${p.coveredBy}`
|
|
346
|
+
: p.action === 'missing'
|
|
347
|
+
? 'table missing · run migrations first'
|
|
348
|
+
: 'create'}
|
|
343
349
|
</td>
|
|
344
350
|
</tr>
|
|
345
351
|
{/each}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { repo } from 'remult'
|
|
8
8
|
|
|
9
|
+
import { errorMessage } from '../../core/helper.js'
|
|
9
10
|
import { log } from '../index'
|
|
10
11
|
import { SqlAdminController } from '../SqlAdminController'
|
|
11
12
|
import {
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
name = ''
|
|
74
75
|
await refresh()
|
|
75
76
|
} catch (err) {
|
|
76
|
-
error =
|
|
77
|
+
error = errorMessage(err)
|
|
77
78
|
} finally {
|
|
78
79
|
busy = false
|
|
79
80
|
}
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
await repo(SqlToken).update(t.id, { revokedAt: new Date() })
|
|
87
88
|
await refresh()
|
|
88
89
|
} catch (err) {
|
|
89
|
-
error =
|
|
90
|
+
error = errorMessage(err)
|
|
90
91
|
} finally {
|
|
91
92
|
busy = false
|
|
92
93
|
}
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
await repo(SqlToken).delete(t)
|
|
101
102
|
await refresh()
|
|
102
103
|
} catch (err) {
|
|
103
|
-
error =
|
|
104
|
+
error = errorMessage(err)
|
|
104
105
|
} finally {
|
|
105
106
|
busy = false
|
|
106
107
|
}
|
|
@@ -114,7 +115,7 @@
|
|
|
114
115
|
await SqlAdminController.purgeTokens()
|
|
115
116
|
await refresh()
|
|
116
117
|
} catch (err) {
|
|
117
|
-
error =
|
|
118
|
+
error = errorMessage(err)
|
|
118
119
|
} finally {
|
|
119
120
|
busy = false
|
|
120
121
|
}
|