@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.
Files changed (70) hide show
  1. package/AGENTS.md +1 -0
  2. package/agents/generators.md +0 -1
  3. package/dist/browser.d.ts +2 -1
  4. package/dist/browser.d.ts.map +1 -1
  5. package/dist/embedded-write-queue.d.ts.map +1 -1
  6. package/dist/embedded-write-queue.js +20 -1
  7. package/dist/embedded-write-queue.js.map +1 -1
  8. package/dist/generators/custom-action.d.ts +71 -39
  9. package/dist/generators/custom-action.d.ts.map +1 -1
  10. package/dist/generators/custom-action.js +71 -39
  11. package/dist/generators/custom-action.js.map +1 -1
  12. package/dist/generators/index.d.ts +0 -2
  13. package/dist/generators/index.d.ts.map +1 -1
  14. package/dist/generators/index.js +2 -3
  15. package/dist/generators/mcp.d.ts.map +1 -1
  16. package/dist/generators/mcp.js +1 -1
  17. package/dist/generators/mcp.js.map +1 -1
  18. package/dist/generators/tenant-gate.d.ts +33 -9
  19. package/dist/generators/tenant-gate.d.ts.map +1 -1
  20. package/dist/generators/tenant-gate.js +2 -1
  21. package/dist/generators/tenant-gate.js.map +1 -1
  22. package/dist/generators.js +2 -3
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -3
  26. package/dist/knowledge.js +28 -16
  27. package/dist/knowledge.js.map +1 -1
  28. package/dist/manifest/static-manifest.js +1 -1
  29. package/dist/manifest/static-manifest.js.map +1 -1
  30. package/dist/manifest/store.js +1 -1
  31. package/dist/manifest.json +1 -1
  32. package/dist/migrations/framework-base-tables.d.ts +148 -0
  33. package/dist/migrations/framework-base-tables.d.ts.map +1 -0
  34. package/dist/migrations/framework-base-tables.js +419 -0
  35. package/dist/migrations/framework-base-tables.js.map +1 -0
  36. package/dist/migrations/index.d.ts +1 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/migrations/index.js +2 -1
  39. package/dist/migrations.js +2 -1
  40. package/dist/postgres-permissions.d.ts +40 -0
  41. package/dist/postgres-permissions.d.ts.map +1 -0
  42. package/dist/postgres-permissions.js +353 -0
  43. package/dist/postgres-permissions.js.map +1 -0
  44. package/dist/registry/framework-base-classes.d.ts.map +1 -1
  45. package/dist/registry/framework-base-classes.js +4 -1
  46. package/dist/registry/framework-base-classes.js.map +1 -1
  47. package/dist/scripts/create-wrappers.js +0 -5
  48. package/dist/smrt-knowledge.json +7 -8
  49. package/dist/testing/database.d.ts +6 -1
  50. package/dist/testing/database.d.ts.map +1 -1
  51. package/dist/testing/database.js +9 -1
  52. package/dist/testing/database.js.map +1 -1
  53. package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
  54. package/dist/vite-plugin/api-client-entries.js +16 -14
  55. package/dist/vite-plugin/api-client-entries.js.map +1 -1
  56. package/dist/vite-plugin/index.d.ts.map +1 -1
  57. package/dist/vite-plugin/index.js +2 -166
  58. package/dist/vite-plugin/index.js.map +1 -1
  59. package/dist/vite-plugin/sveltekit-generator.d.ts +3 -2
  60. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  61. package/dist/vite-plugin/sveltekit-generator.js +20 -24
  62. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  63. package/dist/vite-plugin/templates/default-ui.ts +22 -1
  64. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  65. package/dist/vite-plugin/web-collections.js.map +1 -1
  66. package/package.json +4 -9
  67. package/dist/generators/cli.d.ts +0 -155
  68. package/dist/generators/cli.d.ts.map +0 -1
  69. package/dist/generators/cli.js +0 -473
  70. 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