firstly 0.10.1 → 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 +13 -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/esm/svelte/ff.svelte.js +4 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.10.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#372](https://github.com/jycouet/firstly/pull/372) [`09cf999`](https://github.com/jycouet/firstly/commit/09cf999c92fde427c013e4bfc24136f512abd3f3) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: opt-in schema drift checks (`drift: { relationIndexes, primaryKeys, nullable, orphanColumns }`) with a `<SqlDrift />` UI to dry-run and apply, plus `sqlCreateIndex` / `collectEntities` helpers.
|
|
8
|
+
|
|
9
|
+
## 0.10.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#366](https://github.com/jycouet/firstly/pull/366) [`2c661f6`](https://github.com/jycouet/firstly/commit/2c661f6474b8614733e5494e7deda3cd069aed9c) Thanks [@jycouet](https://github.com/jycouet)! - `ff` handles: read failed writes through `errorMessage()`. Remult rejects with a plain object, not
|
|
14
|
+
an `Error`, so `handle.error` used to end up as the string `[object Object]` on a 403.
|
|
15
|
+
|
|
3
16
|
## 0.10.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SqlDatabase, type ClassType } from 'remult';
|
|
2
|
+
export type SqlDriftFlag = 'relationIndexes' | 'primaryKeys' | 'nullable' | 'orphanColumns';
|
|
3
|
+
export type SqlDriftOptions = {
|
|
4
|
+
/** Every registered entity. A getter, since the module list usually holds `sqlAdmin()` itself. */
|
|
5
|
+
entities: () => ClassType<unknown>[];
|
|
6
|
+
/** Index `toOne` FK columns not already covered by an index (the PK counts). */
|
|
7
|
+
relationIndexes?: boolean;
|
|
8
|
+
/** Re-align live PRIMARY KEYs with each entity `id` config. */
|
|
9
|
+
primaryKeys?: boolean;
|
|
10
|
+
/** Relax `not null default ''` columns whose field is now `allowNull` (text: `''` -> NULL). */
|
|
11
|
+
nullable?: boolean;
|
|
12
|
+
/** Drop live columns no entity field declares. */
|
|
13
|
+
orphanColumns?: boolean;
|
|
14
|
+
};
|
|
15
|
+
type Column = {
|
|
16
|
+
table: string;
|
|
17
|
+
column: string;
|
|
18
|
+
};
|
|
19
|
+
export declare class SqlDriftController {
|
|
20
|
+
/** Set by the `sqlAdmin()` module's `initApi`. */
|
|
21
|
+
static options?: SqlDriftOptions;
|
|
22
|
+
static dp?: SqlDatabase;
|
|
23
|
+
private static ctx;
|
|
24
|
+
static driftFlags(): Promise<Record<SqlDriftFlag, boolean>>;
|
|
25
|
+
static relationIndexes(opts?: {
|
|
26
|
+
apply?: boolean;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
applied: boolean;
|
|
29
|
+
plans: import("./server/drift/relationIndexes").RelIndexPlan[];
|
|
30
|
+
}>;
|
|
31
|
+
static primaryKeys(opts?: {
|
|
32
|
+
apply?: boolean;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
applied: boolean;
|
|
35
|
+
plans: import("./server/drift/planPkSync").PkPlan[];
|
|
36
|
+
}>;
|
|
37
|
+
static nullable(opts?: {
|
|
38
|
+
apply?: boolean;
|
|
39
|
+
columns?: Column[];
|
|
40
|
+
}): Promise<{
|
|
41
|
+
applied: boolean;
|
|
42
|
+
plans: import("./server/drift/planNullableDrift").NullablePlan[];
|
|
43
|
+
fixed: import("./server/drift/planNullableDrift").NullablePlan[];
|
|
44
|
+
}>;
|
|
45
|
+
static orphanColumns(opts?: {
|
|
46
|
+
apply?: boolean;
|
|
47
|
+
columns?: Column[];
|
|
48
|
+
}): Promise<{
|
|
49
|
+
applied: boolean;
|
|
50
|
+
plans: import("./server/drift/planDropColumns").DropPlan[];
|
|
51
|
+
dropped: import("./server/drift/planDropColumns").DropPlan[];
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { BackendMethod, remult, SqlDatabase } from 'remult';
|
|
8
|
+
import { SQL_ADMINS } from './SqlAdminController';
|
|
9
|
+
export class SqlDriftController {
|
|
10
|
+
/** Set by the `sqlAdmin()` module's `initApi`. */
|
|
11
|
+
static options;
|
|
12
|
+
static dp;
|
|
13
|
+
static ctx(flag) {
|
|
14
|
+
const o = SqlDriftController.options;
|
|
15
|
+
if (!o?.[flag])
|
|
16
|
+
throw new Error(`sqlAdmin({ drift: { ${flag}: true } }) is not enabled`);
|
|
17
|
+
return { db: SqlDriftController.dp ?? SqlDatabase.getDb(), entities: o.entities() };
|
|
18
|
+
}
|
|
19
|
+
static async driftFlags() {
|
|
20
|
+
const o = SqlDriftController.options;
|
|
21
|
+
return {
|
|
22
|
+
relationIndexes: !!o?.relationIndexes,
|
|
23
|
+
primaryKeys: !!o?.primaryKeys,
|
|
24
|
+
nullable: !!o?.nullable,
|
|
25
|
+
orphanColumns: !!o?.orphanColumns,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Each apply shares the BackendMethod transaction: any failure rolls every statement back.
|
|
29
|
+
static async relationIndexes(opts) {
|
|
30
|
+
const { db, entities } = SqlDriftController.ctx('relationIndexes');
|
|
31
|
+
const { createRelationIndexes } = await import('./server/drift/relationIndexes');
|
|
32
|
+
return createRelationIndexes(db, entities, opts);
|
|
33
|
+
}
|
|
34
|
+
static async primaryKeys(opts) {
|
|
35
|
+
const { db, entities } = SqlDriftController.ctx('primaryKeys');
|
|
36
|
+
const { reindexPrimaryKeys } = await import('./server/drift/primaryKeys');
|
|
37
|
+
return reindexPrimaryKeys(db, entities, opts);
|
|
38
|
+
}
|
|
39
|
+
static async nullable(opts) {
|
|
40
|
+
const { db, entities } = SqlDriftController.ctx('nullable');
|
|
41
|
+
const { syncNullable } = await import('./server/drift/nullable');
|
|
42
|
+
return syncNullable(db, entities, opts);
|
|
43
|
+
}
|
|
44
|
+
static async orphanColumns(opts) {
|
|
45
|
+
const { db, entities } = SqlDriftController.ctx('orphanColumns');
|
|
46
|
+
const { dropColumns } = await import('./server/drift/orphanColumns');
|
|
47
|
+
return dropColumns(db, entities, opts);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
__decorate([
|
|
51
|
+
BackendMethod({ allowed: () => remult.isAllowed(SQL_ADMINS), apiPrefix: 'ff/sqlAdmin' })
|
|
52
|
+
], SqlDriftController, "driftFlags", null);
|
|
53
|
+
__decorate([
|
|
54
|
+
BackendMethod({ allowed: () => remult.isAllowed(SQL_ADMINS), apiPrefix: 'ff/sqlAdmin' })
|
|
55
|
+
], SqlDriftController, "relationIndexes", null);
|
|
56
|
+
__decorate([
|
|
57
|
+
BackendMethod({ allowed: () => remult.isAllowed(SQL_ADMINS), apiPrefix: 'ff/sqlAdmin' })
|
|
58
|
+
], SqlDriftController, "primaryKeys", null);
|
|
59
|
+
__decorate([
|
|
60
|
+
BackendMethod({ allowed: () => remult.isAllowed(SQL_ADMINS), apiPrefix: 'ff/sqlAdmin' })
|
|
61
|
+
], SqlDriftController, "nullable", null);
|
|
62
|
+
__decorate([
|
|
63
|
+
BackendMethod({ allowed: () => remult.isAllowed(SQL_ADMINS), apiPrefix: 'ff/sqlAdmin' })
|
|
64
|
+
], SqlDriftController, "orphanColumns", null);
|
package/esm/sqlAdmin/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Log } from '@kitql/helpers';
|
|
2
2
|
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
3
|
export { SqlAdminController, type SqlResult, type SqlTokensOptions } from './SqlAdminController';
|
|
4
|
+
export { SqlDriftController, type SqlDriftFlag } from './SqlDriftController';
|
|
4
5
|
export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, type SqlCapability, type SqlTokenTtl, } from './sqlTokenEntities';
|
|
5
6
|
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
7
|
+
export { default as SqlDrift } from './ui/SqlDrift.svelte';
|
|
6
8
|
export { default as SqlTokens } from './ui/SqlTokens.svelte';
|
|
7
9
|
export declare const key = "sqlAdmin";
|
|
8
10
|
export declare const log: Log;
|
package/esm/sqlAdmin/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Log } from '@kitql/helpers';
|
|
2
2
|
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
3
|
export { SqlAdminController } from './SqlAdminController';
|
|
4
|
+
export { SqlDriftController } from './SqlDriftController';
|
|
4
5
|
export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
|
|
5
6
|
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
7
|
+
export { default as SqlDrift } from './ui/SqlDrift.svelte';
|
|
6
8
|
export { default as SqlTokens } from './ui/SqlTokens.svelte';
|
|
7
9
|
export const key = 'sqlAdmin';
|
|
8
10
|
export const log = new Log(key);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ClassType, type MembersOnly } from 'remult';
|
|
2
|
+
export type IndexOptions = {
|
|
3
|
+
unique?: boolean;
|
|
4
|
+
/** Cannot be combined with a transaction (BackendMethod default). */
|
|
5
|
+
concurrently?: boolean;
|
|
6
|
+
ifNotExists?: boolean;
|
|
7
|
+
/** Override the derived `FF_IX_<table>_<cols>` / `FF_UX_...` name. */
|
|
8
|
+
name?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Pure `CREATE INDEX` string builder. `name` / `table` / `columns` are raw
|
|
12
|
+
* identifiers; all are wrapped with `wrap` (defaults to PG double-quoting).
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatCreateIndex(o: {
|
|
15
|
+
name: string;
|
|
16
|
+
table: string;
|
|
17
|
+
columns: string[];
|
|
18
|
+
unique?: boolean;
|
|
19
|
+
concurrently?: boolean;
|
|
20
|
+
ifNotExists?: boolean;
|
|
21
|
+
wrap?: (name: string) => string;
|
|
22
|
+
}): string;
|
|
23
|
+
/**
|
|
24
|
+
* Build a `CREATE INDEX` for an entity + a typed list of its fields. Resolves
|
|
25
|
+
* the real table / column db-names (and the provider's identifier quoting) via
|
|
26
|
+
* `dbNamesOf`, so it stays refactor-safe when a field's `dbName` is overridden.
|
|
27
|
+
* Returns the SQL only - the caller runs it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sqlCreateIndex<T>(entity: ClassType<T>, fields: (keyof MembersOnly<T> & string)[], opts?: IndexOptions): Promise<string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { dbNamesOf } from 'remult';
|
|
2
|
+
import { stripIdent } from './ident';
|
|
3
|
+
/**
|
|
4
|
+
* Pure `CREATE INDEX` string builder. `name` / `table` / `columns` are raw
|
|
5
|
+
* identifiers; all are wrapped with `wrap` (defaults to PG double-quoting).
|
|
6
|
+
*/
|
|
7
|
+
export function formatCreateIndex(o) {
|
|
8
|
+
const wrap = o.wrap ?? ((s) => `"${s}"`);
|
|
9
|
+
const parts = ['CREATE'];
|
|
10
|
+
if (o.unique)
|
|
11
|
+
parts.push('UNIQUE');
|
|
12
|
+
parts.push('INDEX');
|
|
13
|
+
if (o.concurrently)
|
|
14
|
+
parts.push('CONCURRENTLY');
|
|
15
|
+
if (o.ifNotExists)
|
|
16
|
+
parts.push('IF NOT EXISTS');
|
|
17
|
+
parts.push(wrap(o.name), 'ON', wrap(o.table), `(${o.columns.map(wrap).join(', ')})`);
|
|
18
|
+
return parts.join(' ') + ';';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build a `CREATE INDEX` for an entity + a typed list of its fields. Resolves
|
|
22
|
+
* the real table / column db-names (and the provider's identifier quoting) via
|
|
23
|
+
* `dbNamesOf`, so it stays refactor-safe when a field's `dbName` is overridden.
|
|
24
|
+
* Returns the SQL only - the caller runs it.
|
|
25
|
+
*/
|
|
26
|
+
export async function sqlCreateIndex(entity, fields, opts = {}) {
|
|
27
|
+
const db = await dbNamesOf(entity);
|
|
28
|
+
const table = stripIdent(db.$entityName);
|
|
29
|
+
const columns = fields.map((f) => stripIdent(db.$dbNameOf(f)));
|
|
30
|
+
const name = opts.name ?? `${opts.unique ? 'FF_UX' : 'FF_IX'}_${table}_${columns.join('_')}`;
|
|
31
|
+
return formatCreateIndex({ name, table, columns, ...opts, wrap: db.wrapIdentifier });
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
|
+
import { type NullablePlan } from './planNullableDrift';
|
|
3
|
+
/**
|
|
4
|
+
* Audit - and optionally fix a selected subset of - nullability drift: columns whose
|
|
5
|
+
* entity field says `allowNull` while the live column is still `not null` and/or
|
|
6
|
+
* carries the schema builder's empty default. Default is a dry run.
|
|
7
|
+
*
|
|
8
|
+
* Why this is needed: `ensureSchema` writes `default '' not null` for a non-nullable
|
|
9
|
+
* field and never revisits it, so a column created before its field learned `allowNull`
|
|
10
|
+
* keeps feeding every pre-existing row an empty string the entity now types as null.
|
|
11
|
+
*
|
|
12
|
+
* Applying does up to three things per column, in this order: DROP DEFAULT, DROP NOT
|
|
13
|
+
* NULL, then (text only) rewrite the empty rows to NULL. The rewrite is the destructive
|
|
14
|
+
* part - it cannot distinguish a row that was never written from one deliberately set
|
|
15
|
+
* to '' - which is why the UI ticks nothing by default and the selection is re-checked
|
|
16
|
+
* against a freshly computed plan.
|
|
17
|
+
*
|
|
18
|
+
* Atomic inside a BackendMethod: every statement shares that method's transaction, so
|
|
19
|
+
* any failure rolls them all back.
|
|
20
|
+
*/
|
|
21
|
+
export declare function syncNullable(db: SqlDatabase, entities: ClassType<unknown>[], opts?: {
|
|
22
|
+
apply?: boolean;
|
|
23
|
+
columns?: {
|
|
24
|
+
table: string;
|
|
25
|
+
column: string;
|
|
26
|
+
}[];
|
|
27
|
+
}): Promise<{
|
|
28
|
+
applied: boolean;
|
|
29
|
+
plans: NullablePlan[];
|
|
30
|
+
fixed: NullablePlan[];
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { repo } from 'remult';
|
|
2
|
+
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
+
import { stripIdent } from './ident';
|
|
4
|
+
import { planNullableDrift, } from './planNullableDrift';
|
|
5
|
+
// Only a text column has an empty value that is unambiguously "unset". A numeric or
|
|
6
|
+
// boolean default (0 / false) may be genuine data, so those columns get their
|
|
7
|
+
// constraint relaxed and their rows left alone.
|
|
8
|
+
const TEXT_TYPES = new Set(['character varying', 'text', 'character']);
|
|
9
|
+
/**
|
|
10
|
+
* Audit - and optionally fix a selected subset of - nullability drift: columns whose
|
|
11
|
+
* entity field says `allowNull` while the live column is still `not null` and/or
|
|
12
|
+
* carries the schema builder's empty default. Default is a dry run.
|
|
13
|
+
*
|
|
14
|
+
* Why this is needed: `ensureSchema` writes `default '' not null` for a non-nullable
|
|
15
|
+
* field and never revisits it, so a column created before its field learned `allowNull`
|
|
16
|
+
* keeps feeding every pre-existing row an empty string the entity now types as null.
|
|
17
|
+
*
|
|
18
|
+
* Applying does up to three things per column, in this order: DROP DEFAULT, DROP NOT
|
|
19
|
+
* NULL, then (text only) rewrite the empty rows to NULL. The rewrite is the destructive
|
|
20
|
+
* part - it cannot distinguish a row that was never written from one deliberately set
|
|
21
|
+
* to '' - which is why the UI ticks nothing by default and the selection is re-checked
|
|
22
|
+
* against a freshly computed plan.
|
|
23
|
+
*
|
|
24
|
+
* Atomic inside a BackendMethod: every statement shares that method's transaction, so
|
|
25
|
+
* any failure rolls them all back.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncNullable(db, entities, opts) {
|
|
28
|
+
// What each entity declares about nullability, straight from metadata.
|
|
29
|
+
const known = [];
|
|
30
|
+
for (const ent of entities) {
|
|
31
|
+
const meta = repo(ent).metadata;
|
|
32
|
+
if (meta.options.sqlExpression)
|
|
33
|
+
continue; // views: no real table
|
|
34
|
+
const table = stripIdent(meta.dbName);
|
|
35
|
+
for (const field of meta.fields.toArray()) {
|
|
36
|
+
if (getRelationFieldInfo(field))
|
|
37
|
+
continue; // FK scalars are declared by their own field
|
|
38
|
+
if (field.options.sqlExpression)
|
|
39
|
+
continue; // computed, no stored column
|
|
40
|
+
known.push({ table, column: stripIdent(field.dbName), allowNull: !!field.options.allowNull });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const res = await db.createCommand().execute(`
|
|
44
|
+
SELECT table_name, column_name, is_nullable, column_default, data_type
|
|
45
|
+
FROM information_schema.columns
|
|
46
|
+
WHERE table_schema = 'public'
|
|
47
|
+
ORDER BY table_name, ordinal_position;
|
|
48
|
+
`);
|
|
49
|
+
const current = res.rows.map((row) => ({
|
|
50
|
+
table: row.table_name,
|
|
51
|
+
column: row.column_name,
|
|
52
|
+
isNullable: row.is_nullable === 'YES',
|
|
53
|
+
hasDefault: row.column_default != null,
|
|
54
|
+
emptyLiteral: TEXT_TYPES.has(row.data_type) ? "''" : null,
|
|
55
|
+
}));
|
|
56
|
+
const plans = planNullableDrift(current, known);
|
|
57
|
+
let fixed = [];
|
|
58
|
+
if (opts?.apply && opts.columns?.length) {
|
|
59
|
+
const drifted = new Map(plans.map((p) => [`${p.table}.${p.column}`, p]));
|
|
60
|
+
// Only fix what is still drifted - the names then come from the catalog, not raw
|
|
61
|
+
// client input, so the interpolated identifiers are trusted.
|
|
62
|
+
fixed = opts.columns
|
|
63
|
+
.map((c) => drifted.get(`${c.table}.${c.column}`))
|
|
64
|
+
.filter((p) => !!p);
|
|
65
|
+
for (const p of fixed) {
|
|
66
|
+
const col = `ALTER TABLE "${p.table}" ALTER COLUMN "${p.column}"`;
|
|
67
|
+
if (p.dropDefault)
|
|
68
|
+
await db.createCommand().execute(`${col} DROP DEFAULT;`);
|
|
69
|
+
if (p.dropNotNull)
|
|
70
|
+
await db.createCommand().execute(`${col} DROP NOT NULL;`);
|
|
71
|
+
if (p.blankToNull) {
|
|
72
|
+
await db
|
|
73
|
+
.createCommand()
|
|
74
|
+
.execute(`UPDATE "${p.table}" SET "${p.column}" = NULL WHERE "${p.column}" = '';`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { applied: opts?.apply ?? false, plans, fixed };
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ClassType, type SqlDatabase } from 'remult';
|
|
2
|
+
import { type DropPlan } from './planDropColumns';
|
|
3
|
+
/**
|
|
4
|
+
* Audit - and optionally drop a selected subset of - orphan columns: live DB
|
|
5
|
+
* columns on an entity-backed table that no entity field declares. Default is a
|
|
6
|
+
* dry run: returns every orphan, changes nothing.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the PK / relation flows (which apply the whole plan), this one is
|
|
9
|
+
* destructive, so apply takes the explicit `columns` the user ticked and drops
|
|
10
|
+
* only those - re-checking each against a freshly computed orphan set so a stale
|
|
11
|
+
* client can never drop a column that is no longer orphan.
|
|
12
|
+
*
|
|
13
|
+
* Why this is needed: Remult's auto-schema only ever ADDs columns, never drops
|
|
14
|
+
* them, so a field removed from an entity (e.g. a redundant `createdAt` now
|
|
15
|
+
* covered by the FF_Entity changelog) leaves its column behind forever.
|
|
16
|
+
*
|
|
17
|
+
* Atomic inside a BackendMethod: every ALTER shares that method's transaction, so
|
|
18
|
+
* any failure rolls them all back. Plain `DROP COLUMN` (no CASCADE) - a column
|
|
19
|
+
* with a dependency errors loudly rather than silently dropping dependents - which
|
|
20
|
+
* also briefly locks the touched tables, so treat `apply` as a maintenance action.
|
|
21
|
+
*/
|
|
22
|
+
export declare function dropColumns(db: SqlDatabase, entities: ClassType<unknown>[], opts?: {
|
|
23
|
+
apply?: boolean;
|
|
24
|
+
columns?: DropPlan[];
|
|
25
|
+
}): Promise<{
|
|
26
|
+
applied: boolean;
|
|
27
|
+
plans: DropPlan[];
|
|
28
|
+
dropped: DropPlan[];
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { repo } from 'remult';
|
|
2
|
+
import { getRelationFieldInfo } from 'remult/internals';
|
|
3
|
+
import { stripIdent } from './ident';
|
|
4
|
+
import { planDropColumns } from './planDropColumns';
|
|
5
|
+
/**
|
|
6
|
+
* Audit - and optionally drop a selected subset of - orphan columns: live DB
|
|
7
|
+
* columns on an entity-backed table that no entity field declares. Default is a
|
|
8
|
+
* dry run: returns every orphan, changes nothing.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the PK / relation flows (which apply the whole plan), this one is
|
|
11
|
+
* destructive, so apply takes the explicit `columns` the user ticked and drops
|
|
12
|
+
* only those - re-checking each against a freshly computed orphan set so a stale
|
|
13
|
+
* client can never drop a column that is no longer orphan.
|
|
14
|
+
*
|
|
15
|
+
* Why this is needed: Remult's auto-schema only ever ADDs columns, never drops
|
|
16
|
+
* them, so a field removed from an entity (e.g. a redundant `createdAt` now
|
|
17
|
+
* covered by the FF_Entity changelog) leaves its column behind forever.
|
|
18
|
+
*
|
|
19
|
+
* Atomic inside a BackendMethod: every ALTER shares that method's transaction, so
|
|
20
|
+
* any failure rolls them all back. Plain `DROP COLUMN` (no CASCADE) - a column
|
|
21
|
+
* with a dependency errors loudly rather than silently dropping dependents - which
|
|
22
|
+
* also briefly locks the touched tables, so treat `apply` as a maintenance action.
|
|
23
|
+
*/
|
|
24
|
+
export async function dropColumns(db, entities, opts) {
|
|
25
|
+
// Declared columns per entity-backed table, straight from metadata.
|
|
26
|
+
const known = [];
|
|
27
|
+
for (const ent of entities) {
|
|
28
|
+
const meta = repo(ent).metadata;
|
|
29
|
+
if (meta.options.sqlExpression)
|
|
30
|
+
continue; // views: no real table
|
|
31
|
+
const fields = meta.fields.toArray();
|
|
32
|
+
const dbNameByKey = new Map(fields.map((f) => [f.key, stripIdent(f.dbName)]));
|
|
33
|
+
const columns = [];
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
const fi = getRelationFieldInfo(field);
|
|
36
|
+
if (fi) {
|
|
37
|
+
// A toOne relation's FK columns are real; toMany owns none. The FK keys
|
|
38
|
+
// are usually separate scalar fields too, but resolve them defensively.
|
|
39
|
+
if (fi.type === 'toOne')
|
|
40
|
+
for (const k of Object.values(fi.getFields().fields))
|
|
41
|
+
columns.push(dbNameByKey.get(k) ?? stripIdent(String(k)));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (field.options.sqlExpression)
|
|
45
|
+
continue; // computed, no stored column
|
|
46
|
+
columns.push(stripIdent(field.dbName));
|
|
47
|
+
}
|
|
48
|
+
known.push({ table: stripIdent(meta.dbName), columns });
|
|
49
|
+
}
|
|
50
|
+
// Live columns per table from the catalog, in ordinal order.
|
|
51
|
+
const res = await db.createCommand().execute(`
|
|
52
|
+
SELECT table_name, column_name
|
|
53
|
+
FROM information_schema.columns
|
|
54
|
+
WHERE table_schema = 'public'
|
|
55
|
+
ORDER BY table_name, ordinal_position;
|
|
56
|
+
`);
|
|
57
|
+
const liveByTable = new Map();
|
|
58
|
+
for (const row of res.rows) {
|
|
59
|
+
const list = liveByTable.get(row.table_name) ?? [];
|
|
60
|
+
list.push(row.column_name);
|
|
61
|
+
liveByTable.set(row.table_name, list);
|
|
62
|
+
}
|
|
63
|
+
const current = Array.from(liveByTable, ([table, columns]) => ({ table, columns }));
|
|
64
|
+
const plans = planDropColumns(current, known);
|
|
65
|
+
let dropped = [];
|
|
66
|
+
if (opts?.apply && opts.columns?.length) {
|
|
67
|
+
const orphan = new Set(plans.map((p) => `${p.table}.${p.column}`));
|
|
68
|
+
// Only drop what is still orphan - the names then come from the catalog, not
|
|
69
|
+
// raw client input, so the interpolated identifiers are trusted.
|
|
70
|
+
dropped = opts.columns.filter((c) => orphan.has(`${c.table}.${c.column}`));
|
|
71
|
+
for (const { table, column } of dropped) {
|
|
72
|
+
await db.createCommand().execute(`ALTER TABLE "${table}" DROP COLUMN "${column}";`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { applied: opts?.apply ?? false, plans, dropped };
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner: diff each entity-backed table's live columns against the columns
|
|
3
|
+
* its entity actually declares, and list the orphans - live columns with no
|
|
4
|
+
* matching field. No DB access here; the executor feeds it the catalog snapshot +
|
|
5
|
+
* the metadata-derived target, and runs the (user-selected subset of) drops.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: Remult's auto-schema only ever ADDs columns, never drops them,
|
|
8
|
+
* so a field removed from an entity leaves its column behind forever. This is the
|
|
9
|
+
* inverse of `planPkSync` / `planRelationIndexes` - and it's destructive, so the
|
|
10
|
+
* UI selects nothing by default and applies only the checked subset.
|
|
11
|
+
*/
|
|
12
|
+
/** A table's columns - live (from the catalog) or declared (from metadata). */
|
|
13
|
+
export type TableColumns = {
|
|
14
|
+
table: string;
|
|
15
|
+
columns: string[];
|
|
16
|
+
};
|
|
17
|
+
/** One orphan column to (optionally) drop. */
|
|
18
|
+
export type DropPlan = {
|
|
19
|
+
table: string;
|
|
20
|
+
column: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* One entry per orphan column. Only tables present in `known` (i.e. backed by an
|
|
24
|
+
* entity) are considered - DB-only tables (firstly infra, views) are left alone,
|
|
25
|
+
* same scoping as the other two flows. Sorted by table, then column.
|
|
26
|
+
*/
|
|
27
|
+
export declare function planDropColumns(current: TableColumns[], known: TableColumns[]): DropPlan[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner: diff each entity-backed table's live columns against the columns
|
|
3
|
+
* its entity actually declares, and list the orphans - live columns with no
|
|
4
|
+
* matching field. No DB access here; the executor feeds it the catalog snapshot +
|
|
5
|
+
* the metadata-derived target, and runs the (user-selected subset of) drops.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: Remult's auto-schema only ever ADDs columns, never drops them,
|
|
8
|
+
* so a field removed from an entity leaves its column behind forever. This is the
|
|
9
|
+
* inverse of `planPkSync` / `planRelationIndexes` - and it's destructive, so the
|
|
10
|
+
* UI selects nothing by default and applies only the checked subset.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* One entry per orphan column. Only tables present in `known` (i.e. backed by an
|
|
14
|
+
* entity) are considered - DB-only tables (firstly infra, views) are left alone,
|
|
15
|
+
* same scoping as the other two flows. Sorted by table, then column.
|
|
16
|
+
*/
|
|
17
|
+
export function planDropColumns(current, known) {
|
|
18
|
+
// Union, not overwrite: several entities can map to one table (e.g. a light
|
|
19
|
+
// projection + the full row sharing a `dbName`), and each declares only its
|
|
20
|
+
// own subset of the columns.
|
|
21
|
+
const knownByTable = new Map();
|
|
22
|
+
for (const { table, columns } of known) {
|
|
23
|
+
let set = knownByTable.get(table);
|
|
24
|
+
if (!set)
|
|
25
|
+
knownByTable.set(table, (set = new Set()));
|
|
26
|
+
for (const c of columns)
|
|
27
|
+
set.add(c);
|
|
28
|
+
}
|
|
29
|
+
const plans = [];
|
|
30
|
+
for (const { table, columns } of current) {
|
|
31
|
+
const declared = knownByTable.get(table);
|
|
32
|
+
if (!declared)
|
|
33
|
+
continue; // table has no backing entity - not ours to manage
|
|
34
|
+
for (const column of columns)
|
|
35
|
+
if (!declared.has(column))
|
|
36
|
+
plans.push({ table, column });
|
|
37
|
+
}
|
|
38
|
+
return plans.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner: find columns whose entity says `allowNull` but whose live column is
|
|
3
|
+
* still `not null default <empty>`. No DB access here; the executor feeds it the
|
|
4
|
+
* catalog snapshot + the metadata-derived target, and runs the selected subset.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: `ensureSchema` emits `default '' not null` (or `0` / `false`) for a
|
|
7
|
+
* field that is not `allowNull`, and never relaxes that constraint afterwards. So a
|
|
8
|
+
* column added before the field learned `allowNull` keeps handing every pre-existing
|
|
9
|
+
* row a value outside its own domain, forever - and the entity now claims those rows
|
|
10
|
+
* are null. This is the inverse of `planDropColumns`: nothing is deleted, but rewriting
|
|
11
|
+
* values is destructive enough that the UI selects nothing by default.
|
|
12
|
+
*/
|
|
13
|
+
/** A live column's constraint state, from the catalog. */
|
|
14
|
+
export type LiveColumn = {
|
|
15
|
+
table: string;
|
|
16
|
+
column: string;
|
|
17
|
+
isNullable: boolean;
|
|
18
|
+
hasDefault: boolean;
|
|
19
|
+
/** `''` for text-ish columns, null when the empty value cannot be rewritten safely. */
|
|
20
|
+
emptyLiteral: string | null;
|
|
21
|
+
};
|
|
22
|
+
/** What an entity field declares about nullability. */
|
|
23
|
+
export type DeclaredColumn = {
|
|
24
|
+
table: string;
|
|
25
|
+
column: string;
|
|
26
|
+
allowNull: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** One column to relax, and whether its empty rows can be blanked to NULL. */
|
|
29
|
+
export type NullablePlan = {
|
|
30
|
+
table: string;
|
|
31
|
+
column: string;
|
|
32
|
+
dropNotNull: boolean;
|
|
33
|
+
dropDefault: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* false when the column's empty value is a real one (`0`, `false`): the constraint
|
|
36
|
+
* is relaxed so future writes can be null, but existing rows are left alone rather
|
|
37
|
+
* than guessing that a zero meant "unset".
|
|
38
|
+
*/
|
|
39
|
+
blankToNull: boolean;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One entry per drifted column. Only columns declared by an entity are considered -
|
|
43
|
+
* a live column with no field is the orphan flow's business, not this one. Sorted by
|
|
44
|
+
* table, then column.
|
|
45
|
+
*/
|
|
46
|
+
export declare function planNullableDrift(current: LiveColumn[], known: DeclaredColumn[]): NullablePlan[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner: find columns whose entity says `allowNull` but whose live column is
|
|
3
|
+
* still `not null default <empty>`. No DB access here; the executor feeds it the
|
|
4
|
+
* catalog snapshot + the metadata-derived target, and runs the selected subset.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: `ensureSchema` emits `default '' not null` (or `0` / `false`) for a
|
|
7
|
+
* field that is not `allowNull`, and never relaxes that constraint afterwards. So a
|
|
8
|
+
* column added before the field learned `allowNull` keeps handing every pre-existing
|
|
9
|
+
* row a value outside its own domain, forever - and the entity now claims those rows
|
|
10
|
+
* are null. This is the inverse of `planDropColumns`: nothing is deleted, but rewriting
|
|
11
|
+
* values is destructive enough that the UI selects nothing by default.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* One entry per drifted column. Only columns declared by an entity are considered -
|
|
15
|
+
* a live column with no field is the orphan flow's business, not this one. Sorted by
|
|
16
|
+
* table, then column.
|
|
17
|
+
*/
|
|
18
|
+
export function planNullableDrift(current, known) {
|
|
19
|
+
const nullableByKey = new Map(known.filter((k) => k.allowNull).map((k) => [`${k.table}.${k.column}`, true]));
|
|
20
|
+
const plans = [];
|
|
21
|
+
for (const col of current) {
|
|
22
|
+
if (!nullableByKey.has(`${col.table}.${col.column}`))
|
|
23
|
+
continue;
|
|
24
|
+
if (col.isNullable && !col.hasDefault)
|
|
25
|
+
continue; // already clean
|
|
26
|
+
plans.push({
|
|
27
|
+
table: col.table,
|
|
28
|
+
column: col.column,
|
|
29
|
+
dropNotNull: !col.isNullable,
|
|
30
|
+
dropDefault: col.hasDefault,
|
|
31
|
+
blankToNull: col.emptyLiteral !== null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return plans.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/** A table's current PK, as read from the pg catalog. */
|
|
10
|
+
export type PkCurrent = {
|
|
11
|
+
table: string;
|
|
12
|
+
constraintName: string;
|
|
13
|
+
cols: string[];
|
|
14
|
+
};
|
|
15
|
+
/** A table's desired PK, derived from `repo(Entity).metadata.idMetadata`. */
|
|
16
|
+
export type PkDesired = {
|
|
17
|
+
table: string;
|
|
18
|
+
cols: string[];
|
|
19
|
+
};
|
|
20
|
+
type PkAction = 'ok' | 'create' | 'migrate';
|
|
21
|
+
export type PkPlan = {
|
|
22
|
+
table: string;
|
|
23
|
+
before: string[];
|
|
24
|
+
after: string[];
|
|
25
|
+
action: PkAction;
|
|
26
|
+
sql: string[];
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One plan per *desired* (entity-backed) table. Tables present in the DB but not
|
|
30
|
+
* in `desired` (firstly infra, views, etc.) are left alone - we only manage what
|
|
31
|
+
* an entity's `id` config describes. Several entities can share one table (a
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
export declare function planPkSync(current: PkCurrent[], desired: PkDesired[]): PkPlan[];
|
|
36
|
+
export {};
|