@happyvertical/smrt-core 0.45.3 → 0.46.0
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/AGENTS.md +1 -0
- package/agents/generators.md +0 -1
- package/dist/browser.d.ts +2 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +20 -1
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/generators/custom-action.d.ts +71 -39
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +71 -39
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/generators/index.d.ts +0 -2
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -3
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +1 -1
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/tenant-gate.d.ts +33 -9
- package/dist/generators/tenant-gate.d.ts.map +1 -1
- package/dist/generators/tenant-gate.js +2 -1
- package/dist/generators/tenant-gate.js.map +1 -1
- package/dist/generators.js +2 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/knowledge.js +28 -16
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/framework-base-tables.d.ts +148 -0
- package/dist/migrations/framework-base-tables.d.ts.map +1 -0
- package/dist/migrations/framework-base-tables.js +419 -0
- package/dist/migrations/framework-base-tables.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +2 -1
- package/dist/migrations.js +2 -1
- package/dist/postgres-permissions.d.ts +40 -0
- package/dist/postgres-permissions.d.ts.map +1 -0
- package/dist/postgres-permissions.js +353 -0
- package/dist/postgres-permissions.js.map +1 -0
- package/dist/registry/framework-base-classes.d.ts.map +1 -1
- package/dist/registry/framework-base-classes.js +4 -1
- package/dist/registry/framework-base-classes.js.map +1 -1
- package/dist/scripts/create-wrappers.js +0 -5
- package/dist/smrt-knowledge.json +7 -8
- package/dist/testing/database.d.ts +6 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +9 -1
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
- package/dist/vite-plugin/api-client-entries.js +16 -14
- package/dist/vite-plugin/api-client-entries.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +2 -166
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +3 -2
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +20 -24
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/templates/default-ui.ts +22 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +4 -9
- package/dist/generators/cli.d.ts +0 -155
- package/dist/generators/cli.d.ts.map +0 -1
- package/dist/generators/cli.js +0 -473
- package/dist/generators/cli.js.map +0 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { DatabaseEngine } from '../schema/ddl/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The exact five orphaned framework-base tables #2644 stopped planning.
|
|
5
|
+
*
|
|
6
|
+
* NEVER derive this list from `SchemaDiff.orphan_tables` or any other dynamic
|
|
7
|
+
* introspection — that would turn this narrow remediation into the same
|
|
8
|
+
* dangerous global orphan-drop that `includeDroppedTables` deliberately
|
|
9
|
+
* refuses to be. Every entry is a plain string literal, on purpose.
|
|
10
|
+
*/
|
|
11
|
+
export declare const FRAMEWORK_BASE_TABLE_NAMES: readonly ["smrt_objects", "smrt_classes", "smrt_collections", "smrt_hierarchicals", "smrt_polymorphic_associations"];
|
|
12
|
+
export type FrameworkBaseTableName = (typeof FRAMEWORK_BASE_TABLE_NAMES)[number];
|
|
13
|
+
/** Why a target table was refused. A table can carry more than one. */
|
|
14
|
+
export type FrameworkBaseTableRefusal = {
|
|
15
|
+
kind: 'not-empty';
|
|
16
|
+
rowCount: number;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'unexpected-shape';
|
|
19
|
+
actualColumns: string[];
|
|
20
|
+
missingColumns: string[];
|
|
21
|
+
extraColumns: string[];
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'unexpected-column-type';
|
|
24
|
+
mismatches: Array<{
|
|
25
|
+
column: string;
|
|
26
|
+
actualType: string;
|
|
27
|
+
expectedBuckets: string[];
|
|
28
|
+
}>;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'referenced-by-foreign-key';
|
|
31
|
+
references: Array<{
|
|
32
|
+
table: string;
|
|
33
|
+
column: string;
|
|
34
|
+
}>;
|
|
35
|
+
} | {
|
|
36
|
+
kind: 'introspection-unavailable';
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
/** Plan/refusal findings for one of the five candidate tables. */
|
|
40
|
+
export interface FrameworkBaseTableReport {
|
|
41
|
+
table: FrameworkBaseTableName;
|
|
42
|
+
/** Whether this table exists in the live database at all. */
|
|
43
|
+
exists: boolean;
|
|
44
|
+
/** `null` when the table does not exist or its row count was not read. */
|
|
45
|
+
rowCount: number | null;
|
|
46
|
+
/** Live index names on this table, enumerated from the schema — never guessed. */
|
|
47
|
+
indexNames: string[];
|
|
48
|
+
/** Empty when this table (if it exists) is safe to drop. */
|
|
49
|
+
refusals: FrameworkBaseTableRefusal[];
|
|
50
|
+
}
|
|
51
|
+
/** The full remediation plan: what would be dropped, and why it is or isn't safe. */
|
|
52
|
+
export interface FrameworkBaseTablesPlan {
|
|
53
|
+
engine: DatabaseEngine;
|
|
54
|
+
/** One entry per {@link FRAMEWORK_BASE_TABLE_NAMES}, in that order. */
|
|
55
|
+
tables: FrameworkBaseTableReport[];
|
|
56
|
+
/** True only when every existing target table has zero refusals. */
|
|
57
|
+
safe: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The plan for the `--dry-run` preview: `DROP INDEX` for every companion
|
|
60
|
+
* index (enumerated from the live schema, never guessed) followed by
|
|
61
|
+
* `DROP TABLE` for its table, per existing target table. Empty when
|
|
62
|
+
* `safe` is `false` or no target table exists.
|
|
63
|
+
*
|
|
64
|
+
* {@link dropFrameworkBaseTables} does not execute the `DROP INDEX`
|
|
65
|
+
* entries verbatim — see its doc comment for why — so this array is a
|
|
66
|
+
* complete and accurate *forecast* of what a real run does to the
|
|
67
|
+
* database, but is not literally replayed statement-by-statement.
|
|
68
|
+
*/
|
|
69
|
+
statements: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface PlanFrameworkBaseTableDropOptions {
|
|
72
|
+
/** Adapter hint when the database URL does not identify the engine. */
|
|
73
|
+
engineHint?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read-only preflight: is it safe to drop the five framework-base tables,
|
|
77
|
+
* and what exactly would that require?
|
|
78
|
+
*
|
|
79
|
+
* Never mutates the database. Safe to call for `--dry-run` and as the
|
|
80
|
+
* required first half of a real run.
|
|
81
|
+
*/
|
|
82
|
+
export declare function planFrameworkBaseTableDrop(db: DatabaseInterface, options?: PlanFrameworkBaseTableDropOptions): Promise<FrameworkBaseTablesPlan>;
|
|
83
|
+
export interface DropFrameworkBaseTablesOptions {
|
|
84
|
+
/** PostgreSQL lock timeout in milliseconds (defaults to 30 seconds). */
|
|
85
|
+
lockTimeout?: number;
|
|
86
|
+
/** PostgreSQL statement timeout in milliseconds (defaults to 60 seconds). */
|
|
87
|
+
statementTimeout?: number;
|
|
88
|
+
}
|
|
89
|
+
export interface DropFrameworkBaseTablesResult {
|
|
90
|
+
droppedTables: string[];
|
|
91
|
+
droppedIndexes: string[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Execute a plan produced by {@link planFrameworkBaseTableDrop}.
|
|
95
|
+
*
|
|
96
|
+
* Refuses outright when the plan is not `safe` — callers must resolve every
|
|
97
|
+
* refusal (by fixing the underlying data, or accepting that a table is not a
|
|
98
|
+
* genuine framework-base table) rather than forcing this function past them.
|
|
99
|
+
*
|
|
100
|
+
* On PostgreSQL the whole batch runs in one transaction bounded by
|
|
101
|
+
* `SET LOCAL lock_timeout` / `SET LOCAL statement_timeout` (#2362), so a
|
|
102
|
+
* batch that queues behind a long-running writer fails fast and rolls back
|
|
103
|
+
* instead of holding locks against every writer. Every target is then locked
|
|
104
|
+
* `IN ACCESS EXCLUSIVE MODE` — a plain `SELECT COUNT(*)` alone only takes an
|
|
105
|
+
* ACCESS SHARE lock, which would let a concurrent writer commit a row (or a
|
|
106
|
+
* concurrent DDL session replace the table entirely) between the check and
|
|
107
|
+
* the DROP; holding the exclusive lock first makes the check-then-drop
|
|
108
|
+
* sequence atomic.
|
|
109
|
+
*
|
|
110
|
+
* Immediately before dropping anything, this re-verifies column shape,
|
|
111
|
+
* column type, and emptiness inside that same transaction — not just the
|
|
112
|
+
* row count `planFrameworkBaseTableDrop()` already checked. This uses raw
|
|
113
|
+
* `information_schema.columns` (PostgreSQL) / `PRAGMA table_info`
|
|
114
|
+
* (SQLite/DuckDB) queries rather than `getTableSchema()`: `@happyvertical/sql`
|
|
115
|
+
* does not expose that richer introspection method on the transaction-scoped
|
|
116
|
+
* connection this callback receives, only `query()`. Foreign keys are
|
|
117
|
+
* deliberately **not** re-scanned here — doing so would need a fresh
|
|
118
|
+
* full-catalog scan on every drop.
|
|
119
|
+
*
|
|
120
|
+
* On PostgreSQL a foreign key that appeared after planning is still caught:
|
|
121
|
+
* its FK enforcement is dependency-based, so `DROP TABLE` itself refuses
|
|
122
|
+
* when a real dependent exists, empty parent or not. **This does not hold
|
|
123
|
+
* on SQLite** — verified directly: `DROP TABLE` there only checks FK
|
|
124
|
+
* enforcement against the rows actually being removed, so a parent with
|
|
125
|
+
* zero rows (exactly the state this function requires) drops cleanly even
|
|
126
|
+
* with a real, enforced foreign key pointing at it, leaving the referencing
|
|
127
|
+
* table with a dangling reference. On SQLite/DuckDB, the plan-time
|
|
128
|
+
* full-catalog scan is therefore the *only* gate against a foreign key on
|
|
129
|
+
* these two engines — narrower than PostgreSQL's, on top of the
|
|
130
|
+
* already-documented DuckDB introspection gap in
|
|
131
|
+
* {@link qualifyIdentifier}'s doc comment where that plan-time scan cannot
|
|
132
|
+
* see the reference at all. No data is lost either way (the target table is
|
|
133
|
+
* verified empty before every drop); the residual risk is a dangling
|
|
134
|
+
* reference in the very narrow window between planning and this
|
|
135
|
+
* transaction, on either engine, or an FK created after planning at all.
|
|
136
|
+
*
|
|
137
|
+
* Only `plan.statements`' `DROP TABLE` entries are executed — the
|
|
138
|
+
* `DROP INDEX` entries are not. An index name is unique per schema
|
|
139
|
+
* (PostgreSQL) / globally (SQLite), so nothing re-verifies it is still the
|
|
140
|
+
* same object between planning and execution the way the table itself now
|
|
141
|
+
* is; re-issuing a stale `DROP INDEX` by name could hit an unrelated index
|
|
142
|
+
* created under that name in the meantime. `DROP TABLE` cascades to every
|
|
143
|
+
* index actually owned by the table on every engine this module supports,
|
|
144
|
+
* resolved fresh from the database's own catalog at drop time — identity-safe
|
|
145
|
+
* by construction, unlike a second name-based statement would be.
|
|
146
|
+
*/
|
|
147
|
+
export declare function dropFrameworkBaseTables(db: DatabaseInterface, plan: FrameworkBaseTablesPlan, options?: DropFrameworkBaseTablesOptions): Promise<DropFrameworkBaseTablesResult>;
|
|
148
|
+
//# sourceMappingURL=framework-base-tables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"framework-base-tables.d.ts","sourceRoot":"","sources":["../../src/migrations/framework-base-tables.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,sHAM7B,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAChC,CAAC,OAAO,0BAA0B,CAAC,CAAC,MAAM,CAAC,CAAC;AAqG9C,uEAAuE;AACvE,MAAM,MAAM,yBAAyB,GACjC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACvC;IACE,IAAI,EAAE,kBAAkB,CAAC;IACzB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB,GACD;IACE,IAAI,EAAE,wBAAwB,CAAC;IAC/B,UAAU,EAAE,KAAK,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,eAAe,EAAE,MAAM,EAAE,CAAC;KAC3B,CAAC,CAAC;CACJ,GACD;IACE,IAAI,EAAE,2BAA2B,CAAC;IAClC,UAAU,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtD,GACD;IAAE,IAAI,EAAE,2BAA2B,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,kEAAkE;AAClE,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,sBAAsB,CAAC;IAC9B,6DAA6D;IAC7D,MAAM,EAAE,OAAO,CAAC;IAChB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,kFAAkF;IAClF,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,4DAA4D;IAC5D,QAAQ,EAAE,yBAAyB,EAAE,CAAC;CACvC;AAED,qFAAqF;AACrF,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,cAAc,CAAC;IACvB,uEAAuE;IACvE,MAAM,EAAE,wBAAwB,EAAE,CAAC;IACnC,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAC;IACd;;;;;;;;;;OAUG;IACH,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,iCAAiC;IAChD,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAgWD;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE,iCAAsC,GAC9C,OAAO,CAAC,uBAAuB,CAAC,CAoBlC;AAED,MAAM,WAAW,8BAA8B;IAC7C,wEAAwE;IACxE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,6BAA6B;IAC5C,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,iBAAiB,EACrB,IAAI,EAAE,uBAAuB,EAC7B,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CA2GxC"}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { toSafeInteger } from "../utils/safe-integer.js";
|
|
2
|
+
import { quoteIdentifier } from "../schema/sql-identifiers.js";
|
|
3
|
+
import { detectEngine } from "../schema/ddl/index.js";
|
|
4
|
+
//#region src/migrations/framework-base-tables.ts
|
|
5
|
+
/**
|
|
6
|
+
* The exact five orphaned framework-base tables #2644 stopped planning.
|
|
7
|
+
*
|
|
8
|
+
* NEVER derive this list from `SchemaDiff.orphan_tables` or any other dynamic
|
|
9
|
+
* introspection — that would turn this narrow remediation into the same
|
|
10
|
+
* dangerous global orphan-drop that `includeDroppedTables` deliberately
|
|
11
|
+
* refuses to be. Every entry is a plain string literal, on purpose.
|
|
12
|
+
*/
|
|
13
|
+
var FRAMEWORK_BASE_TABLE_NAMES = [
|
|
14
|
+
"smrt_objects",
|
|
15
|
+
"smrt_classes",
|
|
16
|
+
"smrt_collections",
|
|
17
|
+
"smrt_hierarchicals",
|
|
18
|
+
"smrt_polymorphic_associations"
|
|
19
|
+
];
|
|
20
|
+
function classifyColumnType(type) {
|
|
21
|
+
const normalized = type.toUpperCase().trim().replace(/\(\s*\d+\s*\)/g, "");
|
|
22
|
+
if (/^UUID$/.test(normalized)) return "uuid";
|
|
23
|
+
if (/^(TEXT|CLOB|STRING|VARCHAR|CHAR)/.test(normalized)) return "text";
|
|
24
|
+
if (/^(TIMESTAMP|DATETIME|DATE)/.test(normalized)) return "timestamp";
|
|
25
|
+
if (/^(INTEGER|INT|BIGINT|SMALLINT|TINYINT)$/.test(normalized)) return "integer";
|
|
26
|
+
return "other";
|
|
27
|
+
}
|
|
28
|
+
var UNIVERSAL_BASE_BUCKETS = {
|
|
29
|
+
id: ["text", "uuid"],
|
|
30
|
+
slug: ["text"],
|
|
31
|
+
context: ["text"],
|
|
32
|
+
created_at: ["timestamp"],
|
|
33
|
+
updated_at: ["timestamp"]
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The exact columns (and their expected type buckets) each of the five
|
|
37
|
+
* tables may have — the universal base for three of them, extended for
|
|
38
|
+
* `smrt_hierarchicals` (true parent-id tree: `parent_id`) and
|
|
39
|
+
* `smrt_polymorphic_associations` (generic association: `meta_type`,
|
|
40
|
+
* `meta_id`, `role`, `sort_order`), matching {@link SmrtHierarchical} /
|
|
41
|
+
* {@link SmrtPolymorphicAssociation}'s own real field declarations exactly.
|
|
42
|
+
* Anything more, or anything missing, on any of the five means the live
|
|
43
|
+
* table is not what this remediation expects — most likely a consumer's
|
|
44
|
+
* own unrelated table that happens to share the name — and must be refused
|
|
45
|
+
* rather than guessed about.
|
|
46
|
+
*/
|
|
47
|
+
var EXPECTED_COLUMN_BUCKETS_BY_TABLE = {
|
|
48
|
+
smrt_objects: UNIVERSAL_BASE_BUCKETS,
|
|
49
|
+
smrt_classes: UNIVERSAL_BASE_BUCKETS,
|
|
50
|
+
smrt_collections: UNIVERSAL_BASE_BUCKETS,
|
|
51
|
+
smrt_hierarchicals: {
|
|
52
|
+
...UNIVERSAL_BASE_BUCKETS,
|
|
53
|
+
parent_id: ["text", "uuid"]
|
|
54
|
+
},
|
|
55
|
+
smrt_polymorphic_associations: {
|
|
56
|
+
...UNIVERSAL_BASE_BUCKETS,
|
|
57
|
+
meta_type: ["text"],
|
|
58
|
+
meta_id: ["text", "uuid"],
|
|
59
|
+
role: ["text"],
|
|
60
|
+
sort_order: ["integer"]
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var DEFAULT_POSTGRES_LOCK_TIMEOUT_MS = 3e4;
|
|
64
|
+
var DEFAULT_POSTGRES_STATEMENT_TIMEOUT_MS = 6e4;
|
|
65
|
+
function resolveDatabaseUrl(db) {
|
|
66
|
+
const dbWithConfig = db;
|
|
67
|
+
return db.url || dbWithConfig.config?.url || "";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Qualify a table or index identifier so execution resolves to exactly the
|
|
71
|
+
* object inspection looked at.
|
|
72
|
+
*
|
|
73
|
+
* `getExistingTableNames()` and `getTableSchema()` both scope PostgreSQL
|
|
74
|
+
* discovery to the `public` schema, but a plain `quoteIdentifier(name)` in a
|
|
75
|
+
* `SELECT`/`DROP` statement resolves through the session's `search_path`
|
|
76
|
+
* instead — which can list another schema before `public`. An empty,
|
|
77
|
+
* unrelated same-named table or index earlier on that path would otherwise
|
|
78
|
+
* satisfy every safety check yet let the DROP hit a different object than
|
|
79
|
+
* the one just verified. SQLite and DuckDB have no equivalent search-path
|
|
80
|
+
* ambiguity for this module's purposes, so only PostgreSQL is schema-qualified.
|
|
81
|
+
*
|
|
82
|
+
* Known limitation, documented rather than fixed: this module (like
|
|
83
|
+
* `differ.ts` and `live-parity.ts` elsewhere in this package) only ever
|
|
84
|
+
* discovers PostgreSQL objects in the `public` schema — multi-schema
|
|
85
|
+
* PostgreSQL deployments are not a supported SMRT configuration anywhere in
|
|
86
|
+
* this package. A table in a *different* schema with a foreign key onto one
|
|
87
|
+
* of these five names is therefore invisible to the `referenced-by-foreign-key`
|
|
88
|
+
* check. It is not, however, an actual data-loss risk: PostgreSQL's own
|
|
89
|
+
* foreign-key enforcement refuses the `DROP TABLE` at execution time
|
|
90
|
+
* ("cannot drop table ... because other objects depend on it") inside this
|
|
91
|
+
* module's bounded transaction, so nothing is still dropped — exactly the
|
|
92
|
+
* same fail-safe shape as the documented DuckDB foreign-key gap below, just
|
|
93
|
+
* surfaced as a generic execution error instead of a curated refusal.
|
|
94
|
+
*/
|
|
95
|
+
function qualifyIdentifier(engine, name) {
|
|
96
|
+
return engine === "postgres" ? `"public".${quoteIdentifier(name)}` : quoteIdentifier(name);
|
|
97
|
+
}
|
|
98
|
+
async function getExistingTableNames(db, engine) {
|
|
99
|
+
const query = engine === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
|
100
|
+
const rows = (await db.query(query)).rows;
|
|
101
|
+
return new Set(rows.map((row) => row.name || row.table_name || "").filter(Boolean));
|
|
102
|
+
}
|
|
103
|
+
async function countRows(db, engine, table) {
|
|
104
|
+
return toSafeInteger((await db.query(`SELECT COUNT(*) AS row_count FROM ${qualifyIdentifier(engine, table)}`)).rows?.[0]?.row_count ?? 0, `Framework base-table row count for ${table}`);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Render a millisecond timeout as a PostgreSQL interval literal.
|
|
108
|
+
*
|
|
109
|
+
* Mirrors `formatPostgresTimeout` in `migrations/tracker.ts` (#2362) — kept
|
|
110
|
+
* as a tiny local copy rather than an import so this module stays a
|
|
111
|
+
* self-contained, easily audited remediation rather than reaching into the
|
|
112
|
+
* migration tracker's internals for one string helper.
|
|
113
|
+
*/
|
|
114
|
+
function formatPostgresTimeout(milliseconds) {
|
|
115
|
+
return `${Math.max(0, Math.trunc(milliseconds))}ms`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Column name → declared type, read with a query every engine supports
|
|
119
|
+
* *inside a transaction* — unlike `getTableSchema()`, which
|
|
120
|
+
* `@happyvertical/sql` does not expose on the connection object a
|
|
121
|
+
* `db.transaction()` callback receives (verified directly: `typeof
|
|
122
|
+
* tx.getTableSchema` is `undefined`). Used only for the execution-time
|
|
123
|
+
* re-check in {@link dropFrameworkBaseTables}; planning uses the richer
|
|
124
|
+
* `getTableSchema()` on the ordinary (non-transactional) connection.
|
|
125
|
+
*
|
|
126
|
+
* Returns `null` when the table cannot be described right now — SQLite
|
|
127
|
+
* returns zero rows for an unknown table, DuckDB instead throws a Catalog
|
|
128
|
+
* Error (caught below) — either way meaning "cannot confirm this is safe",
|
|
129
|
+
* which must fail closed exactly like a table that no longer exists.
|
|
130
|
+
*/
|
|
131
|
+
async function inspectColumnTypes(db, engine, table) {
|
|
132
|
+
try {
|
|
133
|
+
if (engine === "postgres") {
|
|
134
|
+
const rows = (await db.query(`SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, table)).rows;
|
|
135
|
+
if (rows.length === 0) return null;
|
|
136
|
+
const columns = {};
|
|
137
|
+
for (const row of rows) columns[row.column_name] = row.data_type;
|
|
138
|
+
return columns;
|
|
139
|
+
}
|
|
140
|
+
const rows = (await db.query(`PRAGMA table_info(${quoteIdentifier(table)})`)).rows;
|
|
141
|
+
if (rows.length === 0) return null;
|
|
142
|
+
const columns = {};
|
|
143
|
+
for (const row of rows) if (row.name) columns[row.name] = row.type ?? "";
|
|
144
|
+
return columns;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compare a freshly-read column map against `table`'s expected shape/type,
|
|
151
|
+
* returning a human-readable reason it is unsafe, or `null` when it matches
|
|
152
|
+
* exactly. Shared by the execution-time re-check so its comparison logic
|
|
153
|
+
* cannot drift from {@link assessTargets}'s own definitions
|
|
154
|
+
* ({@link EXPECTED_COLUMN_BUCKETS_BY_TABLE}, {@link classifyColumnType}).
|
|
155
|
+
*/
|
|
156
|
+
function describeColumnMismatch(table, columns) {
|
|
157
|
+
const expectedBuckets = EXPECTED_COLUMN_BUCKETS_BY_TABLE[table];
|
|
158
|
+
const expectedColumns = Object.keys(expectedBuckets);
|
|
159
|
+
const actualColumns = Object.keys(columns).sort();
|
|
160
|
+
const missingColumns = expectedColumns.filter((column) => !(column in columns));
|
|
161
|
+
const extraColumns = actualColumns.filter((column) => !(column in expectedBuckets));
|
|
162
|
+
if (missingColumns.length > 0 || extraColumns.length > 0) return `unexpected column shape (actual columns: ${actualColumns.join(", ")})`;
|
|
163
|
+
const typeMismatches = expectedColumns.map((column) => {
|
|
164
|
+
const actualType = columns[column] ?? "";
|
|
165
|
+
const bucket = classifyColumnType(actualType);
|
|
166
|
+
return expectedBuckets[column].includes(bucket) ? null : `${column} is "${actualType}"`;
|
|
167
|
+
}).filter((entry) => entry !== null);
|
|
168
|
+
if (typeMismatches.length > 0) return `unexpected column type (${typeMismatches.join(", ")})`;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Read-only assessment: for each of the five target names, is it safe to
|
|
173
|
+
* drop, and why or why not?
|
|
174
|
+
*
|
|
175
|
+
* Used only by {@link planFrameworkBaseTableDrop}'s unlocked preflight, via
|
|
176
|
+
* `getTableSchema()`. {@link dropFrameworkBaseTables}'s own execution-time
|
|
177
|
+
* re-check does **not** call this function or reuse its logic wholesale: it
|
|
178
|
+
* re-verifies shape and type from raw `information_schema.columns` /
|
|
179
|
+
* `PRAGMA table_info` instead, because `getTableSchema()` is not available
|
|
180
|
+
* on the transaction-scoped connection a `db.transaction()` callback
|
|
181
|
+
* receives (see that function's own doc comment for the full rationale,
|
|
182
|
+
* including why foreign keys are deliberately not re-scanned there). Never
|
|
183
|
+
* mutates the database.
|
|
184
|
+
*/
|
|
185
|
+
async function assessTargets(db, engine) {
|
|
186
|
+
if (typeof db.getTableSchema !== "function") {
|
|
187
|
+
const existingTableNames = await getExistingTableNames(db, engine);
|
|
188
|
+
const tables = FRAMEWORK_BASE_TABLE_NAMES.map((name) => existingTableNames.has(name) ? {
|
|
189
|
+
table: name,
|
|
190
|
+
exists: true,
|
|
191
|
+
rowCount: null,
|
|
192
|
+
indexNames: [],
|
|
193
|
+
refusals: [{
|
|
194
|
+
kind: "introspection-unavailable",
|
|
195
|
+
reason: "The configured database adapter cannot describe tables (`getTableSchema` is unavailable), so table shape cannot be verified."
|
|
196
|
+
}]
|
|
197
|
+
} : {
|
|
198
|
+
table: name,
|
|
199
|
+
exists: false,
|
|
200
|
+
rowCount: null,
|
|
201
|
+
indexNames: [],
|
|
202
|
+
refusals: []
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
tables,
|
|
206
|
+
safe: tables.every((table) => table.refusals.length === 0)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const existingTableNames = await getExistingTableNames(db, engine);
|
|
210
|
+
const inboundForeignKeys = /* @__PURE__ */ new Map();
|
|
211
|
+
for (const liveTable of existingTableNames) {
|
|
212
|
+
const liveSchema = await db.getTableSchema(liveTable);
|
|
213
|
+
for (const foreignKey of liveSchema?.foreignKeys ?? []) {
|
|
214
|
+
const references = inboundForeignKeys.get(foreignKey.referencesTable) ?? [];
|
|
215
|
+
references.push({
|
|
216
|
+
table: liveTable,
|
|
217
|
+
column: foreignKey.column
|
|
218
|
+
});
|
|
219
|
+
inboundForeignKeys.set(foreignKey.referencesTable, references);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const tables = [];
|
|
223
|
+
let safe = true;
|
|
224
|
+
for (const name of FRAMEWORK_BASE_TABLE_NAMES) {
|
|
225
|
+
if (!existingTableNames.has(name)) {
|
|
226
|
+
tables.push({
|
|
227
|
+
table: name,
|
|
228
|
+
exists: false,
|
|
229
|
+
rowCount: null,
|
|
230
|
+
indexNames: [],
|
|
231
|
+
refusals: []
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const schema = await db.getTableSchema(name);
|
|
236
|
+
const refusals = [];
|
|
237
|
+
if (!schema) {
|
|
238
|
+
refusals.push({
|
|
239
|
+
kind: "introspection-unavailable",
|
|
240
|
+
reason: `getTableSchema("${name}") returned no result even though the table exists.`
|
|
241
|
+
});
|
|
242
|
+
tables.push({
|
|
243
|
+
table: name,
|
|
244
|
+
exists: true,
|
|
245
|
+
rowCount: null,
|
|
246
|
+
indexNames: [],
|
|
247
|
+
refusals
|
|
248
|
+
});
|
|
249
|
+
safe = false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const expectedBucketsForTable = EXPECTED_COLUMN_BUCKETS_BY_TABLE[name];
|
|
253
|
+
const expectedColumnsForTable = Object.keys(expectedBucketsForTable);
|
|
254
|
+
const actualColumns = Object.keys(schema.columns).sort();
|
|
255
|
+
const missingColumns = expectedColumnsForTable.filter((column) => !schema.columns[column]);
|
|
256
|
+
const extraColumns = actualColumns.filter((column) => !(column in expectedBucketsForTable));
|
|
257
|
+
if (missingColumns.length > 0 || extraColumns.length > 0) refusals.push({
|
|
258
|
+
kind: "unexpected-shape",
|
|
259
|
+
actualColumns,
|
|
260
|
+
missingColumns,
|
|
261
|
+
extraColumns
|
|
262
|
+
});
|
|
263
|
+
else {
|
|
264
|
+
const mismatches = expectedColumnsForTable.map((column) => {
|
|
265
|
+
const actualType = schema.columns[column]?.type ?? "";
|
|
266
|
+
const bucket = classifyColumnType(actualType);
|
|
267
|
+
const expectedBuckets = expectedBucketsForTable[column];
|
|
268
|
+
return expectedBuckets.includes(bucket) ? null : {
|
|
269
|
+
column,
|
|
270
|
+
actualType,
|
|
271
|
+
expectedBuckets: [...expectedBuckets]
|
|
272
|
+
};
|
|
273
|
+
}).filter((mismatch) => mismatch !== null);
|
|
274
|
+
if (mismatches.length > 0) refusals.push({
|
|
275
|
+
kind: "unexpected-column-type",
|
|
276
|
+
mismatches
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const references = inboundForeignKeys.get(name) ?? [];
|
|
280
|
+
if (references.length > 0) refusals.push({
|
|
281
|
+
kind: "referenced-by-foreign-key",
|
|
282
|
+
references
|
|
283
|
+
});
|
|
284
|
+
const rowCount = await countRows(db, engine, name);
|
|
285
|
+
if (rowCount > 0) refusals.push({
|
|
286
|
+
kind: "not-empty",
|
|
287
|
+
rowCount
|
|
288
|
+
});
|
|
289
|
+
const indexNames = (schema.indexes ?? []).map((index) => index.name);
|
|
290
|
+
if (refusals.length > 0) safe = false;
|
|
291
|
+
tables.push({
|
|
292
|
+
table: name,
|
|
293
|
+
exists: true,
|
|
294
|
+
rowCount,
|
|
295
|
+
indexNames,
|
|
296
|
+
refusals
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
tables,
|
|
301
|
+
safe
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Read-only preflight: is it safe to drop the five framework-base tables,
|
|
306
|
+
* and what exactly would that require?
|
|
307
|
+
*
|
|
308
|
+
* Never mutates the database. Safe to call for `--dry-run` and as the
|
|
309
|
+
* required first half of a real run.
|
|
310
|
+
*/
|
|
311
|
+
async function planFrameworkBaseTableDrop(db, options = {}) {
|
|
312
|
+
const engine = detectEngine(resolveDatabaseUrl(db), options.engineHint);
|
|
313
|
+
const { tables, safe } = await assessTargets(db, engine);
|
|
314
|
+
const statements = [];
|
|
315
|
+
if (safe) for (const table of tables) {
|
|
316
|
+
if (!table.exists) continue;
|
|
317
|
+
for (const indexName of table.indexNames) statements.push(`DROP INDEX IF EXISTS ${qualifyIdentifier(engine, indexName)}`);
|
|
318
|
+
statements.push(`DROP TABLE IF EXISTS ${qualifyIdentifier(engine, table.table)}`);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
engine,
|
|
322
|
+
tables,
|
|
323
|
+
safe,
|
|
324
|
+
statements
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Execute a plan produced by {@link planFrameworkBaseTableDrop}.
|
|
329
|
+
*
|
|
330
|
+
* Refuses outright when the plan is not `safe` — callers must resolve every
|
|
331
|
+
* refusal (by fixing the underlying data, or accepting that a table is not a
|
|
332
|
+
* genuine framework-base table) rather than forcing this function past them.
|
|
333
|
+
*
|
|
334
|
+
* On PostgreSQL the whole batch runs in one transaction bounded by
|
|
335
|
+
* `SET LOCAL lock_timeout` / `SET LOCAL statement_timeout` (#2362), so a
|
|
336
|
+
* batch that queues behind a long-running writer fails fast and rolls back
|
|
337
|
+
* instead of holding locks against every writer. Every target is then locked
|
|
338
|
+
* `IN ACCESS EXCLUSIVE MODE` — a plain `SELECT COUNT(*)` alone only takes an
|
|
339
|
+
* ACCESS SHARE lock, which would let a concurrent writer commit a row (or a
|
|
340
|
+
* concurrent DDL session replace the table entirely) between the check and
|
|
341
|
+
* the DROP; holding the exclusive lock first makes the check-then-drop
|
|
342
|
+
* sequence atomic.
|
|
343
|
+
*
|
|
344
|
+
* Immediately before dropping anything, this re-verifies column shape,
|
|
345
|
+
* column type, and emptiness inside that same transaction — not just the
|
|
346
|
+
* row count `planFrameworkBaseTableDrop()` already checked. This uses raw
|
|
347
|
+
* `information_schema.columns` (PostgreSQL) / `PRAGMA table_info`
|
|
348
|
+
* (SQLite/DuckDB) queries rather than `getTableSchema()`: `@happyvertical/sql`
|
|
349
|
+
* does not expose that richer introspection method on the transaction-scoped
|
|
350
|
+
* connection this callback receives, only `query()`. Foreign keys are
|
|
351
|
+
* deliberately **not** re-scanned here — doing so would need a fresh
|
|
352
|
+
* full-catalog scan on every drop.
|
|
353
|
+
*
|
|
354
|
+
* On PostgreSQL a foreign key that appeared after planning is still caught:
|
|
355
|
+
* its FK enforcement is dependency-based, so `DROP TABLE` itself refuses
|
|
356
|
+
* when a real dependent exists, empty parent or not. **This does not hold
|
|
357
|
+
* on SQLite** — verified directly: `DROP TABLE` there only checks FK
|
|
358
|
+
* enforcement against the rows actually being removed, so a parent with
|
|
359
|
+
* zero rows (exactly the state this function requires) drops cleanly even
|
|
360
|
+
* with a real, enforced foreign key pointing at it, leaving the referencing
|
|
361
|
+
* table with a dangling reference. On SQLite/DuckDB, the plan-time
|
|
362
|
+
* full-catalog scan is therefore the *only* gate against a foreign key on
|
|
363
|
+
* these two engines — narrower than PostgreSQL's, on top of the
|
|
364
|
+
* already-documented DuckDB introspection gap in
|
|
365
|
+
* {@link qualifyIdentifier}'s doc comment where that plan-time scan cannot
|
|
366
|
+
* see the reference at all. No data is lost either way (the target table is
|
|
367
|
+
* verified empty before every drop); the residual risk is a dangling
|
|
368
|
+
* reference in the very narrow window between planning and this
|
|
369
|
+
* transaction, on either engine, or an FK created after planning at all.
|
|
370
|
+
*
|
|
371
|
+
* Only `plan.statements`' `DROP TABLE` entries are executed — the
|
|
372
|
+
* `DROP INDEX` entries are not. An index name is unique per schema
|
|
373
|
+
* (PostgreSQL) / globally (SQLite), so nothing re-verifies it is still the
|
|
374
|
+
* same object between planning and execution the way the table itself now
|
|
375
|
+
* is; re-issuing a stale `DROP INDEX` by name could hit an unrelated index
|
|
376
|
+
* created under that name in the meantime. `DROP TABLE` cascades to every
|
|
377
|
+
* index actually owned by the table on every engine this module supports,
|
|
378
|
+
* resolved fresh from the database's own catalog at drop time — identity-safe
|
|
379
|
+
* by construction, unlike a second name-based statement would be.
|
|
380
|
+
*/
|
|
381
|
+
async function dropFrameworkBaseTables(db, plan, options = {}) {
|
|
382
|
+
if (!plan.safe) throw new Error("Refusing to drop framework base tables: the plan reported at least one unsafe table. Re-run planFrameworkBaseTableDrop() and resolve every refusal first — nothing was dropped.");
|
|
383
|
+
const targets = plan.tables.filter((table) => table.exists);
|
|
384
|
+
if (targets.length === 0) return {
|
|
385
|
+
droppedTables: [],
|
|
386
|
+
droppedIndexes: []
|
|
387
|
+
};
|
|
388
|
+
if (!db.transaction) throw new Error("Dropping framework base tables requires a database adapter with transaction support.");
|
|
389
|
+
const lockTimeoutMs = options.lockTimeout ?? DEFAULT_POSTGRES_LOCK_TIMEOUT_MS;
|
|
390
|
+
const statementTimeoutMs = options.statementTimeout ?? DEFAULT_POSTGRES_STATEMENT_TIMEOUT_MS;
|
|
391
|
+
const isPostgres = plan.engine === "postgres";
|
|
392
|
+
await db.transaction(async (tx) => {
|
|
393
|
+
if (isPostgres) {
|
|
394
|
+
await tx.query(`SET LOCAL lock_timeout = '${formatPostgresTimeout(lockTimeoutMs)}'`);
|
|
395
|
+
await tx.query(`SET LOCAL statement_timeout = '${formatPostgresTimeout(statementTimeoutMs)}'`);
|
|
396
|
+
}
|
|
397
|
+
if (isPostgres) for (const table of targets) await tx.query(`LOCK TABLE ${qualifyIdentifier(plan.engine, table.table)} IN ACCESS EXCLUSIVE MODE`);
|
|
398
|
+
for (const target of targets) {
|
|
399
|
+
const liveColumns = await inspectColumnTypes(tx, plan.engine, target.table);
|
|
400
|
+
if (!liveColumns) throw new Error(`Refusing to drop "${target.table}": it could not be re-verified inside the transaction (it may no longer exist). Nothing was dropped.`);
|
|
401
|
+
const mismatch = describeColumnMismatch(target.table, liveColumns);
|
|
402
|
+
if (mismatch) throw new Error(`Refusing to drop "${target.table}": a fresh check inside the transaction found an ${mismatch}. Nothing was dropped.`);
|
|
403
|
+
const rowCount = await countRows(tx, plan.engine, target.table);
|
|
404
|
+
if (rowCount > 0) throw new Error(`Refusing to drop "${target.table}": it now has ${rowCount} row(s) though it was empty when planned. Nothing was dropped.`);
|
|
405
|
+
}
|
|
406
|
+
for (const statement of plan.statements) {
|
|
407
|
+
if (statement.startsWith("DROP INDEX")) continue;
|
|
408
|
+
await tx.query(statement);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
droppedTables: targets.map((table) => table.table),
|
|
413
|
+
droppedIndexes: targets.flatMap((table) => table.indexNames)
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
export { FRAMEWORK_BASE_TABLE_NAMES, dropFrameworkBaseTables, planFrameworkBaseTableDrop };
|
|
418
|
+
|
|
419
|
+
//# sourceMappingURL=framework-base-tables.js.map
|