@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 @@
|
|
|
1
|
+
{"version":3,"file":"framework-base-tables.js","names":[],"sources":["../../src/migrations/framework-base-tables.ts"],"sourcesContent":["/**\n * One-time remediation for the five orphaned framework-base tables (#2647).\n *\n * #2644 stopped `getAllSchemasAsDefinitions()` from planning a table for\n * SMRT's abstract framework-base classes (`SmrtObject`, `SmrtClass`,\n * `SmrtCollection`, `SmrtHierarchical`, `SmrtPolymorphicAssociation`), but it\n * does not remove the ones a pre-fix `db:migrate` already created. Any\n * deployment that ran `db:migrate` before that fix has all five tables, and\n * the differ now reports them as permanent orphans (#2369's\n * `includeDroppedTables` stays `false` on purpose — see below).\n *\n * This module is a narrow, named remediation, deliberately **not** a change\n * to the general differ: `SchemaDiff.orphan_tables` already reports orphan\n * tables, and `includeDroppedTables` (default `false`, both call sites pass\n * it explicitly) is the opt-in for the executable `DROP TABLE`. That default\n * must stay `false` — flipping it globally would make `db:migrate` drop *any*\n * table absent from the manifest, which for a consumer with hand-managed or\n * legacy tables is unacceptable data loss. Dropping only these five specific,\n * hand-typed names is safe *because* nothing but the retired base-class\n * planning path could ever have created them; a general orphan drop is not.\n *\n * Safety model, in order:\n * 1. {@link FRAMEWORK_BASE_TABLE_NAMES} is a literal, hand-typed list. It is\n * never derived from a differ orphan report or any other dynamic source —\n * doing so would recreate the dangerous global-drop path by another route.\n * 2. {@link planFrameworkBaseTableDrop} is read-only. For every name in the\n * list that exists live, it verifies the table has *only* that table's\n * expected columns — the universal `id`/`slug`/`context`/`created_at`/\n * `updated_at` base for three of the five, extended with the real fields\n * `SmrtHierarchical`/`SmrtPolymorphicAssociation` themselves declare for\n * `smrt_hierarchicals`/`smrt_polymorphic_associations` (see\n * {@link EXPECTED_COLUMN_BUCKETS_BY_TABLE}) — with a plausible type for\n * each, is referenced by no foreign key anywhere in the live database,\n * and is empty. Any live table missing that shape — e.g. a consumer's\n * own, unrelated table that happens to share one of these five names —\n * is reported as unsafe and dropped nothing.\n * 3. {@link dropFrameworkBaseTables} refuses to run against a plan that is\n * not `safe`, locks every PostgreSQL target `IN ACCESS EXCLUSIVE MODE`\n * (a plain `SELECT COUNT(*)` alone would not block a concurrent writer),\n * and then re-verifies shape, type, and emptiness — not just the row\n * count — inside that same bounded transaction immediately before\n * dropping anything. A table rewritten or replaced between planning and\n * execution therefore still stops the whole batch, not just a row-count\n * race. Foreign keys are checked at planning time only. On PostgreSQL, a\n * foreign key added afterward is still caught — its FK enforcement is\n * dependency-based, so `DROP TABLE` refuses when a real dependent\n * exists. This does **not** hold on SQLite/DuckDB: SQLite's `DROP TABLE`\n * only enforces against the rows actually being removed, so an empty\n * parent (exactly this function's precondition) drops cleanly past a\n * real foreign key, and DuckDB's adapter cannot even see the reference\n * at plan time (see {@link qualifyIdentifier}'s doc comment). No data is\n * lost either way — the residual risk on those two engines is a\n * dangling reference, not data loss, confined to a narrow window a\n * one-time operator-run command makes unlikely in practice.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\nimport { quoteIdentifier } from '../schema/sql-identifiers.js';\nimport { toSafeInteger } from '../utils/safe-integer.js';\n\n/**\n * The exact five orphaned framework-base tables #2644 stopped planning.\n *\n * NEVER derive this list from `SchemaDiff.orphan_tables` or any other dynamic\n * introspection — that would turn this narrow remediation into the same\n * dangerous global orphan-drop that `includeDroppedTables` deliberately\n * refuses to be. Every entry is a plain string literal, on purpose.\n */\nexport const FRAMEWORK_BASE_TABLE_NAMES = [\n 'smrt_objects',\n 'smrt_classes',\n 'smrt_collections',\n 'smrt_hierarchicals',\n 'smrt_polymorphic_associations',\n] as const;\n\nexport type FrameworkBaseTableName =\n (typeof FRAMEWORK_BASE_TABLE_NAMES)[number];\n\n/**\n * Every one of the five tables carries this universal base — but two of\n * them are not plain aliases of it. `SmrtHierarchical` and\n * `SmrtPolymorphicAssociation` are abstract classes that *extend* the\n * universal shape with their own real fields (true parent-id trees; the\n * meta/role/sort columns generic associations need), exactly like any\n * concrete class extending them would inherit those fields. Verified\n * directly against a genuine pre-#2644 `db:migrate` run (a real installed\n * multi-package consumer, not hand-typed DDL): `smrt_hierarchicals` and\n * `smrt_polymorphic_associations` are generated with the extra columns\n * below every time, on every engine. A per-table expectation is therefore\n * required — treating all five as the plain 5-column baseline would refuse\n * to ever drop these two genuine framework-base tables, reporting their own\n * real columns as \"unexpected\".\n */\nconst UNIVERSAL_BASE_COLUMNS = [\n 'id',\n 'slug',\n 'context',\n 'created_at',\n 'updated_at',\n] as const;\n\n/**\n * Coarse, dialect-tolerant type buckets. A live column with a matching\n * *name* but an unrelated *type* (`id INTEGER`, `context BOOLEAN`, ...) is\n * not a genuine framework-base table — it is a consumer's own table that\n * happens to share every column name — and a name-only check alone cannot\n * see that.\n *\n * Buckets, not exact strings: PostgreSQL may report `TIMESTAMP` or\n * `TIMESTAMPTZ` depending on configuration, DuckDB normalizes `TEXT` to\n * `VARCHAR`, and SQLite's generator emits `DATETIME` — all legitimate for a\n * real framework-base table on their engine. Only a column outside its\n * expected bucket entirely (an integer where text was expected, a boolean,\n * a float) is refused.\n */\ntype ColumnTypeBucket = 'text' | 'uuid' | 'timestamp' | 'integer' | 'other';\n\nfunction classifyColumnType(type: string): ColumnTypeBucket {\n const normalized = type\n .toUpperCase()\n .trim()\n .replace(/\\(\\s*\\d+\\s*\\)/g, '');\n if (/^UUID$/.test(normalized)) return 'uuid';\n if (/^(TEXT|CLOB|STRING|VARCHAR|CHAR)/.test(normalized)) return 'text';\n if (/^(TIMESTAMP|DATETIME|DATE)/.test(normalized)) return 'timestamp';\n if (/^(INTEGER|INT|BIGINT|SMALLINT|TINYINT)$/.test(normalized)) {\n return 'integer';\n }\n return 'other';\n}\n\nconst UNIVERSAL_BASE_BUCKETS: Record<\n (typeof UNIVERSAL_BASE_COLUMNS)[number],\n readonly ColumnTypeBucket[]\n> = {\n id: ['text', 'uuid'],\n slug: ['text'],\n context: ['text'],\n created_at: ['timestamp'],\n updated_at: ['timestamp'],\n};\n\n/**\n * The exact columns (and their expected type buckets) each of the five\n * tables may have — the universal base for three of them, extended for\n * `smrt_hierarchicals` (true parent-id tree: `parent_id`) and\n * `smrt_polymorphic_associations` (generic association: `meta_type`,\n * `meta_id`, `role`, `sort_order`), matching {@link SmrtHierarchical} /\n * {@link SmrtPolymorphicAssociation}'s own real field declarations exactly.\n * Anything more, or anything missing, on any of the five means the live\n * table is not what this remediation expects — most likely a consumer's\n * own unrelated table that happens to share the name — and must be refused\n * rather than guessed about.\n */\nconst EXPECTED_COLUMN_BUCKETS_BY_TABLE: Record<\n FrameworkBaseTableName,\n Readonly<Record<string, readonly ColumnTypeBucket[]>>\n> = {\n smrt_objects: UNIVERSAL_BASE_BUCKETS,\n smrt_classes: UNIVERSAL_BASE_BUCKETS,\n smrt_collections: UNIVERSAL_BASE_BUCKETS,\n smrt_hierarchicals: {\n ...UNIVERSAL_BASE_BUCKETS,\n parent_id: ['text', 'uuid'],\n },\n smrt_polymorphic_associations: {\n ...UNIVERSAL_BASE_BUCKETS,\n meta_type: ['text'],\n meta_id: ['text', 'uuid'],\n role: ['text'],\n sort_order: ['integer'],\n },\n};\n\nconst DEFAULT_POSTGRES_LOCK_TIMEOUT_MS = 30_000;\nconst DEFAULT_POSTGRES_STATEMENT_TIMEOUT_MS = 60_000;\n\n/** Why a target table was refused. A table can carry more than one. */\nexport type FrameworkBaseTableRefusal =\n | { kind: 'not-empty'; rowCount: number }\n | {\n kind: 'unexpected-shape';\n actualColumns: string[];\n missingColumns: string[];\n extraColumns: string[];\n }\n | {\n kind: 'unexpected-column-type';\n mismatches: Array<{\n column: string;\n actualType: string;\n expectedBuckets: string[];\n }>;\n }\n | {\n kind: 'referenced-by-foreign-key';\n references: Array<{ table: string; column: string }>;\n }\n | { kind: 'introspection-unavailable'; reason: string };\n\n/** Plan/refusal findings for one of the five candidate tables. */\nexport interface FrameworkBaseTableReport {\n table: FrameworkBaseTableName;\n /** Whether this table exists in the live database at all. */\n exists: boolean;\n /** `null` when the table does not exist or its row count was not read. */\n rowCount: number | null;\n /** Live index names on this table, enumerated from the schema — never guessed. */\n indexNames: string[];\n /** Empty when this table (if it exists) is safe to drop. */\n refusals: FrameworkBaseTableRefusal[];\n}\n\n/** The full remediation plan: what would be dropped, and why it is or isn't safe. */\nexport interface FrameworkBaseTablesPlan {\n engine: DatabaseEngine;\n /** One entry per {@link FRAMEWORK_BASE_TABLE_NAMES}, in that order. */\n tables: FrameworkBaseTableReport[];\n /** True only when every existing target table has zero refusals. */\n safe: boolean;\n /**\n * The plan for the `--dry-run` preview: `DROP INDEX` for every companion\n * index (enumerated from the live schema, never guessed) followed by\n * `DROP TABLE` for its table, per existing target table. Empty when\n * `safe` is `false` or no target table exists.\n *\n * {@link dropFrameworkBaseTables} does not execute the `DROP INDEX`\n * entries verbatim — see its doc comment for why — so this array is a\n * complete and accurate *forecast* of what a real run does to the\n * database, but is not literally replayed statement-by-statement.\n */\n statements: string[];\n}\n\nexport interface PlanFrameworkBaseTableDropOptions {\n /** Adapter hint when the database URL does not identify the engine. */\n engineHint?: string;\n}\n\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & { config?: { url?: string } };\n return db.url || dbWithConfig.config?.url || '';\n}\n\n/**\n * Qualify a table or index identifier so execution resolves to exactly the\n * object inspection looked at.\n *\n * `getExistingTableNames()` and `getTableSchema()` both scope PostgreSQL\n * discovery to the `public` schema, but a plain `quoteIdentifier(name)` in a\n * `SELECT`/`DROP` statement resolves through the session's `search_path`\n * instead — which can list another schema before `public`. An empty,\n * unrelated same-named table or index earlier on that path would otherwise\n * satisfy every safety check yet let the DROP hit a different object than\n * the one just verified. SQLite and DuckDB have no equivalent search-path\n * ambiguity for this module's purposes, so only PostgreSQL is schema-qualified.\n *\n * Known limitation, documented rather than fixed: this module (like\n * `differ.ts` and `live-parity.ts` elsewhere in this package) only ever\n * discovers PostgreSQL objects in the `public` schema — multi-schema\n * PostgreSQL deployments are not a supported SMRT configuration anywhere in\n * this package. A table in a *different* schema with a foreign key onto one\n * of these five names is therefore invisible to the `referenced-by-foreign-key`\n * check. It is not, however, an actual data-loss risk: PostgreSQL's own\n * foreign-key enforcement refuses the `DROP TABLE` at execution time\n * (\"cannot drop table ... because other objects depend on it\") inside this\n * module's bounded transaction, so nothing is still dropped — exactly the\n * same fail-safe shape as the documented DuckDB foreign-key gap below, just\n * surfaced as a generic execution error instead of a curated refusal.\n */\nfunction qualifyIdentifier(engine: DatabaseEngine, name: string): string {\n return engine === 'postgres'\n ? `\"public\".${quoteIdentifier(name)}`\n : quoteIdentifier(name);\n}\n\nasync function getExistingTableNames(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n): Promise<Set<string>> {\n const query =\n engine === 'postgres'\n ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`\n : // SQLite and DuckDB both expose the SQLite-compatible `sqlite_master`\n // catalog (differ.ts's `getExistingTables()` verified this against a\n // live DuckDB build; see that comment for detail).\n `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;\n\n const result = await db.query(query);\n const rows = result.rows as { name?: string; table_name?: string }[];\n return new Set(\n rows.map((row) => row.name || row.table_name || '').filter(Boolean),\n );\n}\n\nasync function countRows(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n table: string,\n): Promise<number> {\n const result = await db.query(\n `SELECT COUNT(*) AS row_count FROM ${qualifyIdentifier(engine, table)}`,\n );\n return toSafeInteger(\n result.rows?.[0]?.row_count ?? 0,\n `Framework base-table row count for ${table}`,\n );\n}\n\n/**\n * Render a millisecond timeout as a PostgreSQL interval literal.\n *\n * Mirrors `formatPostgresTimeout` in `migrations/tracker.ts` (#2362) — kept\n * as a tiny local copy rather than an import so this module stays a\n * self-contained, easily audited remediation rather than reaching into the\n * migration tracker's internals for one string helper.\n */\nfunction formatPostgresTimeout(milliseconds: number): string {\n return `${Math.max(0, Math.trunc(milliseconds))}ms`;\n}\n\n/**\n * Column name → declared type, read with a query every engine supports\n * *inside a transaction* — unlike `getTableSchema()`, which\n * `@happyvertical/sql` does not expose on the connection object a\n * `db.transaction()` callback receives (verified directly: `typeof\n * tx.getTableSchema` is `undefined`). Used only for the execution-time\n * re-check in {@link dropFrameworkBaseTables}; planning uses the richer\n * `getTableSchema()` on the ordinary (non-transactional) connection.\n *\n * Returns `null` when the table cannot be described right now — SQLite\n * returns zero rows for an unknown table, DuckDB instead throws a Catalog\n * Error (caught below) — either way meaning \"cannot confirm this is safe\",\n * which must fail closed exactly like a table that no longer exists.\n */\nasync function inspectColumnTypes(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n table: string,\n): Promise<Record<string, string> | null> {\n try {\n if (engine === 'postgres') {\n const result = await db.query(\n `SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,\n table,\n );\n const rows = result.rows as { column_name: string; data_type: string }[];\n if (rows.length === 0) return null;\n const columns: Record<string, string> = {};\n for (const row of rows) columns[row.column_name] = row.data_type;\n return columns;\n }\n\n const result = await db.query(\n `PRAGMA table_info(${quoteIdentifier(table)})`,\n );\n const rows = result.rows as { name?: string; type?: string }[];\n if (rows.length === 0) return null;\n const columns: Record<string, string> = {};\n for (const row of rows) {\n if (row.name) columns[row.name] = row.type ?? '';\n }\n return columns;\n } catch {\n return null;\n }\n}\n\n/**\n * Compare a freshly-read column map against `table`'s expected shape/type,\n * returning a human-readable reason it is unsafe, or `null` when it matches\n * exactly. Shared by the execution-time re-check so its comparison logic\n * cannot drift from {@link assessTargets}'s own definitions\n * ({@link EXPECTED_COLUMN_BUCKETS_BY_TABLE}, {@link classifyColumnType}).\n */\nfunction describeColumnMismatch(\n table: FrameworkBaseTableName,\n columns: Record<string, string>,\n): string | null {\n const expectedBuckets = EXPECTED_COLUMN_BUCKETS_BY_TABLE[table];\n const expectedColumns = Object.keys(expectedBuckets);\n const actualColumns = Object.keys(columns).sort();\n const missingColumns = expectedColumns.filter(\n (column) => !(column in columns),\n );\n const extraColumns = actualColumns.filter(\n (column) => !(column in expectedBuckets),\n );\n if (missingColumns.length > 0 || extraColumns.length > 0) {\n return `unexpected column shape (actual columns: ${actualColumns.join(', ')})`;\n }\n\n const typeMismatches = expectedColumns\n .map((column) => {\n const actualType = columns[column] ?? '';\n const bucket = classifyColumnType(actualType);\n return expectedBuckets[column].includes(bucket)\n ? null\n : `${column} is \"${actualType}\"`;\n })\n .filter((entry): entry is string => entry !== null);\n if (typeMismatches.length > 0) {\n return `unexpected column type (${typeMismatches.join(', ')})`;\n }\n\n return null;\n}\n\n/**\n * Read-only assessment: for each of the five target names, is it safe to\n * drop, and why or why not?\n *\n * Used only by {@link planFrameworkBaseTableDrop}'s unlocked preflight, via\n * `getTableSchema()`. {@link dropFrameworkBaseTables}'s own execution-time\n * re-check does **not** call this function or reuse its logic wholesale: it\n * re-verifies shape and type from raw `information_schema.columns` /\n * `PRAGMA table_info` instead, because `getTableSchema()` is not available\n * on the transaction-scoped connection a `db.transaction()` callback\n * receives (see that function's own doc comment for the full rationale,\n * including why foreign keys are deliberately not re-scanned there). Never\n * mutates the database.\n */\nasync function assessTargets(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n): Promise<{ tables: FrameworkBaseTableReport[]; safe: boolean }> {\n if (typeof db.getTableSchema !== 'function') {\n // Fail closed for any target that actually exists: without shape\n // introspection we cannot tell a genuine framework-base table apart\n // from a consumer's own unrelated table of the same name. But existence\n // itself only needs a raw catalog query (`getExistingTableNames()`,\n // which never calls `getTableSchema()`), so a database with none of the\n // five tables present is still a safe, clean no-op even on an adapter\n // this degraded — refusing unconditionally here would report five\n // nonexistent tables as unsafe instead of nothing to do.\n const existingTableNames = await getExistingTableNames(db, engine);\n const tables: FrameworkBaseTableReport[] = FRAMEWORK_BASE_TABLE_NAMES.map(\n (name) =>\n existingTableNames.has(name)\n ? {\n table: name,\n exists: true,\n rowCount: null,\n indexNames: [],\n refusals: [\n {\n kind: 'introspection-unavailable' as const,\n reason:\n 'The configured database adapter cannot describe tables (`getTableSchema` is unavailable), so table shape cannot be verified.',\n },\n ],\n }\n : {\n table: name,\n exists: false,\n rowCount: null,\n indexNames: [],\n refusals: [],\n },\n );\n return {\n tables,\n safe: tables.every((table) => table.refusals.length === 0),\n };\n }\n\n const existingTableNames = await getExistingTableNames(db, engine);\n\n // Build one reverse foreign-key index across every live table, not just\n // the five candidates: a real, unrelated table anywhere in the database\n // could reference one of these names, and only a full-catalog scan catches\n // that (mirrors differ.ts's own full-catalog scan for the #2608 uuid\n // convergence plan).\n //\n // Known gap on DuckDB: `@happyvertical/sql`'s DuckDB adapter never\n // populates `getTableSchema().foreignKeys` (verified directly — even a\n // table-level `FOREIGN KEY (...) REFERENCES ...` constraint comes back\n // empty), so this scan cannot see a real inbound reference there and the\n // plan will report the table safe. DuckDB itself still refuses the DROP\n // TABLE with a catalog error inside the bounded transaction in that case,\n // so nothing unsafe is actually dropped — the refusal just surfaces at\n // execution instead of at planning, with DuckDB's own error text instead\n // of `referenced-by-foreign-key`.\n const inboundForeignKeys = new Map<\n string,\n Array<{ table: string; column: string }>\n >();\n for (const liveTable of existingTableNames) {\n const liveSchema = await db.getTableSchema(liveTable);\n for (const foreignKey of liveSchema?.foreignKeys ?? []) {\n const references =\n inboundForeignKeys.get(foreignKey.referencesTable) ?? [];\n references.push({ table: liveTable, column: foreignKey.column });\n inboundForeignKeys.set(foreignKey.referencesTable, references);\n }\n }\n\n const tables: FrameworkBaseTableReport[] = [];\n let safe = true;\n\n for (const name of FRAMEWORK_BASE_TABLE_NAMES) {\n if (!existingTableNames.has(name)) {\n tables.push({\n table: name,\n exists: false,\n rowCount: null,\n indexNames: [],\n refusals: [],\n });\n continue;\n }\n\n const schema = await db.getTableSchema(name);\n const refusals: FrameworkBaseTableRefusal[] = [];\n\n if (!schema) {\n refusals.push({\n kind: 'introspection-unavailable',\n reason: `getTableSchema(\"${name}\") returned no result even though the table exists.`,\n });\n tables.push({\n table: name,\n exists: true,\n rowCount: null,\n indexNames: [],\n refusals,\n });\n safe = false;\n continue;\n }\n\n const expectedBucketsForTable = EXPECTED_COLUMN_BUCKETS_BY_TABLE[name];\n const expectedColumnsForTable = Object.keys(expectedBucketsForTable);\n const actualColumns = Object.keys(schema.columns).sort();\n const missingColumns = expectedColumnsForTable.filter(\n (column) => !schema.columns[column],\n );\n const extraColumns = actualColumns.filter(\n (column) => !(column in expectedBucketsForTable),\n );\n if (missingColumns.length > 0 || extraColumns.length > 0) {\n refusals.push({\n kind: 'unexpected-shape',\n actualColumns,\n missingColumns,\n extraColumns,\n });\n } else {\n // The column *set* matches exactly — only meaningful to type-check\n // when every expected column is actually present and nothing extra\n // is there to confuse the comparison.\n const mismatches = expectedColumnsForTable\n .map((column) => {\n const actualType = schema.columns[column]?.type ?? '';\n const bucket = classifyColumnType(actualType);\n const expectedBuckets = expectedBucketsForTable[column];\n return expectedBuckets.includes(bucket)\n ? null\n : { column, actualType, expectedBuckets: [...expectedBuckets] };\n })\n .filter(\n (mismatch): mismatch is NonNullable<typeof mismatch> =>\n mismatch !== null,\n );\n if (mismatches.length > 0) {\n refusals.push({ kind: 'unexpected-column-type', mismatches });\n }\n }\n\n const references = inboundForeignKeys.get(name) ?? [];\n if (references.length > 0) {\n refusals.push({ kind: 'referenced-by-foreign-key', references });\n }\n\n const rowCount = await countRows(db, engine, name);\n if (rowCount > 0) {\n refusals.push({ kind: 'not-empty', rowCount });\n }\n\n const indexNames = (schema.indexes ?? []).map((index) => index.name);\n\n if (refusals.length > 0) safe = false;\n\n tables.push({ table: name, exists: true, rowCount, indexNames, refusals });\n }\n\n return { tables, safe };\n}\n\n/**\n * Read-only preflight: is it safe to drop the five framework-base tables,\n * and what exactly would that require?\n *\n * Never mutates the database. Safe to call for `--dry-run` and as the\n * required first half of a real run.\n */\nexport async function planFrameworkBaseTableDrop(\n db: DatabaseInterface,\n options: PlanFrameworkBaseTableDropOptions = {},\n): Promise<FrameworkBaseTablesPlan> {\n const engine = detectEngine(resolveDatabaseUrl(db), options.engineHint);\n const { tables, safe } = await assessTargets(db, engine);\n\n const statements: string[] = [];\n if (safe) {\n for (const table of tables) {\n if (!table.exists) continue;\n for (const indexName of table.indexNames) {\n statements.push(\n `DROP INDEX IF EXISTS ${qualifyIdentifier(engine, indexName)}`,\n );\n }\n statements.push(\n `DROP TABLE IF EXISTS ${qualifyIdentifier(engine, table.table)}`,\n );\n }\n }\n\n return { engine, tables, safe, statements };\n}\n\nexport interface DropFrameworkBaseTablesOptions {\n /** PostgreSQL lock timeout in milliseconds (defaults to 30 seconds). */\n lockTimeout?: number;\n /** PostgreSQL statement timeout in milliseconds (defaults to 60 seconds). */\n statementTimeout?: number;\n}\n\nexport interface DropFrameworkBaseTablesResult {\n droppedTables: string[];\n droppedIndexes: string[];\n}\n\n/**\n * Execute a plan produced by {@link planFrameworkBaseTableDrop}.\n *\n * Refuses outright when the plan is not `safe` — callers must resolve every\n * refusal (by fixing the underlying data, or accepting that a table is not a\n * genuine framework-base table) rather than forcing this function past them.\n *\n * On PostgreSQL the whole batch runs in one transaction bounded by\n * `SET LOCAL lock_timeout` / `SET LOCAL statement_timeout` (#2362), so a\n * batch that queues behind a long-running writer fails fast and rolls back\n * instead of holding locks against every writer. Every target is then locked\n * `IN ACCESS EXCLUSIVE MODE` — a plain `SELECT COUNT(*)` alone only takes an\n * ACCESS SHARE lock, which would let a concurrent writer commit a row (or a\n * concurrent DDL session replace the table entirely) between the check and\n * the DROP; holding the exclusive lock first makes the check-then-drop\n * sequence atomic.\n *\n * Immediately before dropping anything, this re-verifies column shape,\n * column type, and emptiness inside that same transaction — not just the\n * row count `planFrameworkBaseTableDrop()` already checked. This uses raw\n * `information_schema.columns` (PostgreSQL) / `PRAGMA table_info`\n * (SQLite/DuckDB) queries rather than `getTableSchema()`: `@happyvertical/sql`\n * does not expose that richer introspection method on the transaction-scoped\n * connection this callback receives, only `query()`. Foreign keys are\n * deliberately **not** re-scanned here — doing so would need a fresh\n * full-catalog scan on every drop.\n *\n * On PostgreSQL a foreign key that appeared after planning is still caught:\n * its FK enforcement is dependency-based, so `DROP TABLE` itself refuses\n * when a real dependent exists, empty parent or not. **This does not hold\n * on SQLite** — verified directly: `DROP TABLE` there only checks FK\n * enforcement against the rows actually being removed, so a parent with\n * zero rows (exactly the state this function requires) drops cleanly even\n * with a real, enforced foreign key pointing at it, leaving the referencing\n * table with a dangling reference. On SQLite/DuckDB, the plan-time\n * full-catalog scan is therefore the *only* gate against a foreign key on\n * these two engines — narrower than PostgreSQL's, on top of the\n * already-documented DuckDB introspection gap in\n * {@link qualifyIdentifier}'s doc comment where that plan-time scan cannot\n * see the reference at all. No data is lost either way (the target table is\n * verified empty before every drop); the residual risk is a dangling\n * reference in the very narrow window between planning and this\n * transaction, on either engine, or an FK created after planning at all.\n *\n * Only `plan.statements`' `DROP TABLE` entries are executed — the\n * `DROP INDEX` entries are not. An index name is unique per schema\n * (PostgreSQL) / globally (SQLite), so nothing re-verifies it is still the\n * same object between planning and execution the way the table itself now\n * is; re-issuing a stale `DROP INDEX` by name could hit an unrelated index\n * created under that name in the meantime. `DROP TABLE` cascades to every\n * index actually owned by the table on every engine this module supports,\n * resolved fresh from the database's own catalog at drop time — identity-safe\n * by construction, unlike a second name-based statement would be.\n */\nexport async function dropFrameworkBaseTables(\n db: DatabaseInterface,\n plan: FrameworkBaseTablesPlan,\n options: DropFrameworkBaseTablesOptions = {},\n): Promise<DropFrameworkBaseTablesResult> {\n if (!plan.safe) {\n throw new Error(\n '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.',\n );\n }\n\n const targets = plan.tables.filter((table) => table.exists);\n if (targets.length === 0) {\n return { droppedTables: [], droppedIndexes: [] };\n }\n\n if (!db.transaction) {\n throw new Error(\n 'Dropping framework base tables requires a database adapter with transaction support.',\n );\n }\n\n const lockTimeoutMs = options.lockTimeout ?? DEFAULT_POSTGRES_LOCK_TIMEOUT_MS;\n const statementTimeoutMs =\n options.statementTimeout ?? DEFAULT_POSTGRES_STATEMENT_TIMEOUT_MS;\n const isPostgres = plan.engine === 'postgres';\n\n await db.transaction(async (tx) => {\n if (isPostgres) {\n await tx.query(\n `SET LOCAL lock_timeout = '${formatPostgresTimeout(lockTimeoutMs)}'`,\n );\n await tx.query(\n `SET LOCAL statement_timeout = '${formatPostgresTimeout(statementTimeoutMs)}'`,\n );\n }\n\n // On PostgreSQL, a plain `SELECT COUNT(*)` takes only an ACCESS SHARE\n // lock, which does not block a concurrent writer or a concurrent DDL\n // session dropping and recreating the same name as an unrelated table:\n // either could land after this check passes but before the DROP below\n // acquires its own exclusive lock. Acquire ACCESS EXCLUSIVE on every\n // target *before* re-assessing it so the check and the drop are atomic —\n // once held, no other session can read, write, or alter the table until\n // this transaction ends, and `SET LOCAL lock_timeout` above bounds the\n // wait for it exactly as it bounds the DROP itself.\n if (isPostgres) {\n for (const table of targets) {\n await tx.query(\n `LOCK TABLE ${qualifyIdentifier(plan.engine, table.table)} IN ACCESS EXCLUSIVE MODE`,\n );\n }\n }\n\n // Re-verify shape, type, and emptiness inside this transaction (locked,\n // on PostgreSQL) immediately before dropping anything — a table could\n // have been rewritten, or (on PostgreSQL, before the locks above were\n // acquired) dropped and replaced by an unrelated same-named table, since\n // the read-only plan. Trusting the original plan's findings past that\n // point would let the drop proceed against a table the plan never\n // actually verified. See this function's own doc comment for why\n // foreign keys are not re-scanned here.\n for (const target of targets) {\n const liveColumns = await inspectColumnTypes(\n tx,\n plan.engine,\n target.table,\n );\n if (!liveColumns) {\n throw new Error(\n `Refusing to drop \"${target.table}\": it could not be re-verified inside the transaction (it may no longer exist). Nothing was dropped.`,\n );\n }\n\n const mismatch = describeColumnMismatch(target.table, liveColumns);\n if (mismatch) {\n throw new Error(\n `Refusing to drop \"${target.table}\": a fresh check inside the transaction found an ${mismatch}. Nothing was dropped.`,\n );\n }\n\n const rowCount = await countRows(tx, plan.engine, target.table);\n if (rowCount > 0) {\n throw new Error(\n `Refusing to drop \"${target.table}\": it now has ${rowCount} row(s) though it was empty when planned. Nothing was dropped.`,\n );\n }\n }\n\n // Execute only the `DROP TABLE` statements, never the `DROP INDEX`\n // statements `plan.statements` also carries for the dry-run preview.\n // An index name is unique per schema (PostgreSQL) / globally (SQLite),\n // so a live name collision with the one enumerated at plan time\n // necessarily means the object is not the one this re-check just\n // verified — the same \"swapped between planning and execution\" risk\n // the table-side re-check above closes, but for indexes there is no\n // cheap re-verification to run. `DROP TABLE` itself cascades to every\n // index actually owned by the table on every engine this module\n // supports, which is identity-safe by construction: the database\n // resolves \"this table's indexes\" fresh at drop time from its own\n // catalog, never by matching the stale planned name.\n for (const statement of plan.statements) {\n if (statement.startsWith('DROP INDEX')) continue;\n await tx.query(statement);\n }\n });\n\n return {\n droppedTables: targets.map((table) => table.table),\n droppedIndexes: targets.flatMap((table) => table.indexNames),\n };\n}\n"],"mappings":";;;;;;;;;;;;AAsEA,IAAa,6BAA6B;CACxC;CACA;CACA;CACA;CACA;AACF;AA4CA,SAAS,mBAAmB,MAAgC;CAC1D,MAAM,aAAa,KAChB,YAAY,CAAC,CACb,KAAK,CAAC,CACN,QAAQ,kBAAkB,EAAE;CAC/B,IAAI,SAAS,KAAK,UAAU,GAAG,OAAO;CACtC,IAAI,mCAAmC,KAAK,UAAU,GAAG,OAAO;CAChE,IAAI,6BAA6B,KAAK,UAAU,GAAG,OAAO;CAC1D,IAAI,0CAA0C,KAAK,UAAU,GAC3D,OAAO;CAET,OAAO;AACT;AAEA,IAAM,yBAGF;CACF,IAAI,CAAC,QAAQ,MAAM;CACnB,MAAM,CAAC,MAAM;CACb,SAAS,CAAC,MAAM;CAChB,YAAY,CAAC,WAAW;CACxB,YAAY,CAAC,WAAW;AAC1B;;;;;;;;;;;;;AAcA,IAAM,mCAGF;CACF,cAAc;CACd,cAAc;CACd,kBAAkB;CAClB,oBAAoB;EAClB,GAAG;EACH,WAAW,CAAC,QAAQ,MAAM;CAC5B;CACA,+BAA+B;EAC7B,GAAG;EACH,WAAW,CAAC,MAAM;EAClB,SAAS,CAAC,QAAQ,MAAM;EACxB,MAAM,CAAC,MAAM;EACb,YAAY,CAAC,SAAS;CACxB;AACF;AAEA,IAAM,mCAAmC;AACzC,IAAM,wCAAwC;AAgE9C,SAAS,mBAAmB,IAA+B;CACzD,MAAM,eAAe;CACrB,OAAO,GAAG,OAAO,aAAa,QAAQ,OAAO;AAC/C;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAS,kBAAkB,QAAwB,MAAsB;CACvE,OAAO,WAAW,aACd,YAAY,gBAAgB,IAAI,MAChC,gBAAgB,IAAI;AAC1B;AAEA,eAAe,sBACb,IACA,QACsB;CACtB,MAAM,QACJ,WAAW,aACP,mFAIA;CAGN,MAAM,QAAO,MADQ,GAAG,MAAM,KAAK,EAAA,CACf;CACpB,OAAO,IAAI,IACT,KAAK,KAAK,QAAQ,IAAI,QAAQ,IAAI,cAAc,EAAE,CAAC,CAAC,OAAO,OAAO,CACpE;AACF;AAEA,eAAe,UACb,IACA,QACA,OACiB;CAIjB,OAAO,eACL,MAJmB,GAAG,MACtB,qCAAqC,kBAAkB,QAAQ,KAAK,GACtE,EAAA,CAES,OAAO,EAAE,EAAE,aAAa,GAC/B,sCAAsC,OACxC;AACF;;;;;;;;;AAUA,SAAS,sBAAsB,cAA8B;CAC3D,OAAO,GAAG,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,CAAC,EAAE;AAClD;;;;;;;;;;;;;;;AAgBA,eAAe,mBACb,IACA,QACA,OACwC;CACxC,IAAI;EACF,IAAI,WAAW,YAAY;GAKzB,MAAM,QAAO,MAJQ,GAAG,MACtB,mHACA,KACF,EAAA,CACoB;GACpB,IAAI,KAAK,WAAW,GAAG,OAAO;GAC9B,MAAM,UAAkC,CAAC;GACzC,KAAK,MAAM,OAAO,MAAM,QAAQ,IAAI,eAAe,IAAI;GACvD,OAAO;EACT;EAKA,MAAM,QAAO,MAHQ,GAAG,MACtB,qBAAqB,gBAAgB,KAAK,EAAE,EAC9C,EAAA,CACoB;EACpB,IAAI,KAAK,WAAW,GAAG,OAAO;EAC9B,MAAM,UAAkC,CAAC;EACzC,KAAK,MAAM,OAAO,MAChB,IAAI,IAAI,MAAM,QAAQ,IAAI,QAAQ,IAAI,QAAQ;EAEhD,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;AASA,SAAS,uBACP,OACA,SACe;CACf,MAAM,kBAAkB,iCAAiC;CACzD,MAAM,kBAAkB,OAAO,KAAK,eAAe;CACnD,MAAM,gBAAgB,OAAO,KAAK,OAAO,CAAC,CAAC,KAAK;CAChD,MAAM,iBAAiB,gBAAgB,QACpC,WAAW,EAAE,UAAU,QAC1B;CACA,MAAM,eAAe,cAAc,QAChC,WAAW,EAAE,UAAU,gBAC1B;CACA,IAAI,eAAe,SAAS,KAAK,aAAa,SAAS,GACrD,OAAO,4CAA4C,cAAc,KAAK,IAAI,EAAE;CAG9E,MAAM,iBAAiB,gBACpB,KAAK,WAAW;EACf,MAAM,aAAa,QAAQ,WAAW;EACtC,MAAM,SAAS,mBAAmB,UAAU;EAC5C,OAAO,gBAAgB,OAAO,CAAC,SAAS,MAAM,IAC1C,OACA,GAAG,OAAO,OAAO,WAAW;CAClC,CAAC,CAAC,CACD,QAAQ,UAA2B,UAAU,IAAI;CACpD,IAAI,eAAe,SAAS,GAC1B,OAAO,2BAA2B,eAAe,KAAK,IAAI,EAAE;CAG9D,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,eAAe,cACb,IACA,QACgE;CAChE,IAAI,OAAO,GAAG,mBAAmB,YAAY;EAS3C,MAAM,qBAAqB,MAAM,sBAAsB,IAAI,MAAM;EACjE,MAAM,SAAqC,2BAA2B,KACnE,SACC,mBAAmB,IAAI,IAAI,IACvB;GACE,OAAO;GACP,QAAQ;GACR,UAAU;GACV,YAAY,CAAC;GACb,UAAU,CACR;IACE,MAAM;IACN,QACE;GACJ,CACF;EACF,IACA;GACE,OAAO;GACP,QAAQ;GACR,UAAU;GACV,YAAY,CAAC;GACb,UAAU,CAAC;EACb,CACR;EACA,OAAO;GACL;GACA,MAAM,OAAO,OAAO,UAAU,MAAM,SAAS,WAAW,CAAC;EAC3D;CACF;CAEA,MAAM,qBAAqB,MAAM,sBAAsB,IAAI,MAAM;CAiBjE,MAAM,qCAAqB,IAAI,IAG7B;CACF,KAAK,MAAM,aAAa,oBAAoB;EAC1C,MAAM,aAAa,MAAM,GAAG,eAAe,SAAS;EACpD,KAAK,MAAM,cAAc,YAAY,eAAe,CAAC,GAAG;GACtD,MAAM,aACJ,mBAAmB,IAAI,WAAW,eAAe,KAAK,CAAC;GACzD,WAAW,KAAK;IAAE,OAAO;IAAW,QAAQ,WAAW;GAAO,CAAC;GAC/D,mBAAmB,IAAI,WAAW,iBAAiB,UAAU;EAC/D;CACF;CAEA,MAAM,SAAqC,CAAC;CAC5C,IAAI,OAAO;CAEX,KAAK,MAAM,QAAQ,4BAA4B;EAC7C,IAAI,CAAC,mBAAmB,IAAI,IAAI,GAAG;GACjC,OAAO,KAAK;IACV,OAAO;IACP,QAAQ;IACR,UAAU;IACV,YAAY,CAAC;IACb,UAAU,CAAC;GACb,CAAC;GACD;EACF;EAEA,MAAM,SAAS,MAAM,GAAG,eAAe,IAAI;EAC3C,MAAM,WAAwC,CAAC;EAE/C,IAAI,CAAC,QAAQ;GACX,SAAS,KAAK;IACZ,MAAM;IACN,QAAQ,mBAAmB,KAAK;GAClC,CAAC;GACD,OAAO,KAAK;IACV,OAAO;IACP,QAAQ;IACR,UAAU;IACV,YAAY,CAAC;IACb;GACF,CAAC;GACD,OAAO;GACP;EACF;EAEA,MAAM,0BAA0B,iCAAiC;EACjE,MAAM,0BAA0B,OAAO,KAAK,uBAAuB;EACnE,MAAM,gBAAgB,OAAO,KAAK,OAAO,OAAO,CAAC,CAAC,KAAK;EACvD,MAAM,iBAAiB,wBAAwB,QAC5C,WAAW,CAAC,OAAO,QAAQ,OAC9B;EACA,MAAM,eAAe,cAAc,QAChC,WAAW,EAAE,UAAU,wBAC1B;EACA,IAAI,eAAe,SAAS,KAAK,aAAa,SAAS,GACrD,SAAS,KAAK;GACZ,MAAM;GACN;GACA;GACA;EACF,CAAC;OACI;GAIL,MAAM,aAAa,wBAChB,KAAK,WAAW;IACf,MAAM,aAAa,OAAO,QAAQ,OAAO,EAAE,QAAQ;IACnD,MAAM,SAAS,mBAAmB,UAAU;IAC5C,MAAM,kBAAkB,wBAAwB;IAChD,OAAO,gBAAgB,SAAS,MAAM,IAClC,OACA;KAAE;KAAQ;KAAY,iBAAiB,CAAC,GAAG,eAAe;IAAE;GAClE,CAAC,CAAC,CACD,QACE,aACC,aAAa,IACjB;GACF,IAAI,WAAW,SAAS,GACtB,SAAS,KAAK;IAAE,MAAM;IAA0B;GAAW,CAAC;EAEhE;EAEA,MAAM,aAAa,mBAAmB,IAAI,IAAI,KAAK,CAAC;EACpD,IAAI,WAAW,SAAS,GACtB,SAAS,KAAK;GAAE,MAAM;GAA6B;EAAW,CAAC;EAGjE,MAAM,WAAW,MAAM,UAAU,IAAI,QAAQ,IAAI;EACjD,IAAI,WAAW,GACb,SAAS,KAAK;GAAE,MAAM;GAAa;EAAS,CAAC;EAG/C,MAAM,cAAc,OAAO,WAAW,CAAC,EAAA,CAAG,KAAK,UAAU,MAAM,IAAI;EAEnE,IAAI,SAAS,SAAS,GAAG,OAAO;EAEhC,OAAO,KAAK;GAAE,OAAO;GAAM,QAAQ;GAAM;GAAU;GAAY;EAAS,CAAC;CAC3E;CAEA,OAAO;EAAE;EAAQ;CAAK;AACxB;;;;;;;;AASA,eAAsB,2BACpB,IACA,UAA6C,CAAC,GACZ;CAClC,MAAM,SAAS,aAAa,mBAAmB,EAAE,GAAG,QAAQ,UAAU;CACtE,MAAM,EAAE,QAAQ,SAAS,MAAM,cAAc,IAAI,MAAM;CAEvD,MAAM,aAAuB,CAAC;CAC9B,IAAI,MACF,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,CAAC,MAAM,QAAQ;EACnB,KAAK,MAAM,aAAa,MAAM,YAC5B,WAAW,KACT,wBAAwB,kBAAkB,QAAQ,SAAS,GAC7D;EAEF,WAAW,KACT,wBAAwB,kBAAkB,QAAQ,MAAM,KAAK,GAC/D;CACF;CAGF,OAAO;EAAE;EAAQ;EAAQ;EAAM;CAAW;AAC5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoEA,eAAsB,wBACpB,IACA,MACA,UAA0C,CAAC,GACH;CACxC,IAAI,CAAC,KAAK,MACR,MAAM,IAAI,MACR,iLACF;CAGF,MAAM,UAAU,KAAK,OAAO,QAAQ,UAAU,MAAM,MAAM;CAC1D,IAAI,QAAQ,WAAW,GACrB,OAAO;EAAE,eAAe,CAAC;EAAG,gBAAgB,CAAC;CAAE;CAGjD,IAAI,CAAC,GAAG,aACN,MAAM,IAAI,MACR,sFACF;CAGF,MAAM,gBAAgB,QAAQ,eAAe;CAC7C,MAAM,qBACJ,QAAQ,oBAAoB;CAC9B,MAAM,aAAa,KAAK,WAAW;CAEnC,MAAM,GAAG,YAAY,OAAO,OAAO;EACjC,IAAI,YAAY;GACd,MAAM,GAAG,MACP,6BAA6B,sBAAsB,aAAa,EAAE,EACpE;GACA,MAAM,GAAG,MACP,kCAAkC,sBAAsB,kBAAkB,EAAE,EAC9E;EACF;EAWA,IAAI,YACF,KAAK,MAAM,SAAS,SAClB,MAAM,GAAG,MACP,cAAc,kBAAkB,KAAK,QAAQ,MAAM,KAAK,EAAE,0BAC5D;EAYJ,KAAK,MAAM,UAAU,SAAS;GAC5B,MAAM,cAAc,MAAM,mBACxB,IACA,KAAK,QACL,OAAO,KACT;GACA,IAAI,CAAC,aACH,MAAM,IAAI,MACR,qBAAqB,OAAO,MAAM,qGACpC;GAGF,MAAM,WAAW,uBAAuB,OAAO,OAAO,WAAW;GACjE,IAAI,UACF,MAAM,IAAI,MACR,qBAAqB,OAAO,MAAM,mDAAmD,SAAS,uBAChG;GAGF,MAAM,WAAW,MAAM,UAAU,IAAI,KAAK,QAAQ,OAAO,KAAK;GAC9D,IAAI,WAAW,GACb,MAAM,IAAI,MACR,qBAAqB,OAAO,MAAM,gBAAgB,SAAS,+DAC7D;EAEJ;EAcA,KAAK,MAAM,aAAa,KAAK,YAAY;GACvC,IAAI,UAAU,WAAW,YAAY,GAAG;GACxC,MAAM,GAAG,MAAM,SAAS;EAC1B;CACF,CAAC;CAED,OAAO;EACL,eAAe,QAAQ,KAAK,UAAU,MAAM,KAAK;EACjD,gBAAgB,QAAQ,SAAS,UAAU,MAAM,UAAU;CAC7D;AACF"}
|
|
@@ -12,6 +12,7 @@ export { type BackfillRecord, BackfillTableUnavailableError, BackfillTracker, ty
|
|
|
12
12
|
export { computeChecksum, normalizeSQL, shortChecksum, verifyChecksum, } from './checksum.js';
|
|
13
13
|
export type { DiffOptions } from './differ.js';
|
|
14
14
|
export { canonicalizeDefault, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, SchemaComparer, uniqueColumnIndexName, } from './differ.js';
|
|
15
|
+
export { type DropFrameworkBaseTablesOptions, type DropFrameworkBaseTablesResult, dropFrameworkBaseTables, FRAMEWORK_BASE_TABLE_NAMES, type FrameworkBaseTableName, type FrameworkBaseTableRefusal, type FrameworkBaseTableReport, type FrameworkBaseTablesPlan, type PlanFrameworkBaseTableDropOptions, planFrameworkBaseTableDrop, } from './framework-base-tables.js';
|
|
15
16
|
export type { GeneratedMigration, GenerateMigrationOptions, } from './generator.js';
|
|
16
17
|
export { createMigrationDefinition, generateMigrationSequence, generateMigrationTimestamp, MigrationGenerator, } from './generator.js';
|
|
17
18
|
export { buildIntegerWidthStatements, buildIntegerWidthTableStatements, collectIntegerWidthTargets, type IntegerWidthColumnReport, type IntegerWidthColumnState, type IntegerWidthOptions, type IntegerWidthPreflightResult, type IntegerWidthTableReport, type IntegerWidthTarget, type IntegerWidthWidenOptions, type IntegerWidthWidenResult, preflightIntegerWidthWidening, widenIntegerColumnsToBigInt, } from './integer-width.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EACL,KAAK,cAAc,EACnB,6BAA6B,EAC7B,eAAe,EACf,KAAK,sBAAsB,GAC5B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,eAAe,EACf,YAAY,EACZ,aAAa,EACb,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,wBAAwB,EACxB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EACL,KAAK,cAAc,EACnB,6BAA6B,EAC7B,eAAe,EACf,KAAK,sBAAsB,GAC5B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,eAAe,EACf,YAAY,EACZ,aAAa,EACb,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,wBAAwB,EACxB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,KAAK,8BAA8B,EACnC,KAAK,6BAA6B,EAClC,uBAAuB,EACvB,0BAA0B,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,iCAAiC,EACtC,0BAA0B,GAC3B,MAAM,4BAA4B,CAAC;AACpC,YAAY,EACV,kBAAkB,EAClB,wBAAwB,GACzB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,yBAAyB,EACzB,yBAAyB,EACzB,0BAA0B,EAC1B,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,2BAA2B,EAC3B,gCAAgC,EAChC,0BAA0B,EAC1B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,2BAA2B,EAChC,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,6BAA6B,EAC7B,2BAA2B,GAC5B,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EACtB,0BAA0B,EAC1B,+BAA+B,GAChC,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,0BAA0B,EAC1B,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,KAAK,6BAA6B,GACnC,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,KAAK,iCAAiC,EACtC,4BAA4B,EAC5B,0BAA0B,EAC1B,KAAK,8BAA8B,EACnC,uBAAuB,EACvB,wBAAwB,EACxB,2BAA2B,EAC3B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACvB,gBAAgB,EAChB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,cAAc,CAAC;AAGtB,YAAY,EACV,sBAAsB,EACtB,wBAAwB,EACxB,UAAU,EACV,cAAc,EACd,iBAAiB,EAEjB,aAAa,EACb,mBAAmB,EACnB,sBAAsB,EACtB,WAAW,EACX,cAAc,EACd,eAAe,EACf,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,eAAe,EAEf,eAAe,EACf,gBAAgB,EAEhB,uBAAuB,EACvB,mBAAmB,EACnB,qBAAqB,EACrB,eAAe,EACf,YAAY,EACZ,UAAU,EACV,qBAAqB,EACrB,yBAAyB,EACzB,eAAe,GAChB,MAAM,YAAY,CAAC"}
|
package/dist/migrations/index.js
CHANGED
|
@@ -4,8 +4,9 @@ import { SchemaComparer, canonicalizeDefault, generateSchemaDiff, getSQLFromDiff
|
|
|
4
4
|
import { BackfillTableUnavailableError, BackfillTracker } from "./backfill-tracker.js";
|
|
5
5
|
import { buildIntegerWidthStatements, buildIntegerWidthTableStatements, collectIntegerWidthTargets, preflightIntegerWidthWidening, widenIntegerColumnsToBigInt } from "./integer-width.js";
|
|
6
6
|
import { computeChecksum, normalizeSQL, shortChecksum, verifyChecksum } from "./checksum.js";
|
|
7
|
+
import { FRAMEWORK_BASE_TABLE_NAMES, dropFrameworkBaseTables, planFrameworkBaseTableDrop } from "./framework-base-tables.js";
|
|
7
8
|
import { MigrationGenerator, createMigrationDefinition, generateMigrationSequence, generateMigrationTimestamp } from "./generator.js";
|
|
8
9
|
import { MinorUnitsPreflightError, buildMinorUnitsStatements, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits } from "./minor-units.js";
|
|
9
10
|
import { MigrationTracker, buildConcurrentIndexPlan, extractCreatedIndexName, planPostgresStatements } from "./tracker.js";
|
|
10
11
|
import { getPendingSchemaStatements, migrateSmrtSchemas } from "./orchestrate.js";
|
|
11
|
-
export { BackfillTableUnavailableError, BackfillTracker, MigrationGenerator, MigrationTracker, MinorUnitsPreflightError, SQLITE_REBUILD_TABLE_PREFIX, SchemaComparer, buildConcurrentIndexPlan, buildIntegerWidthStatements, buildIntegerWidthTableStatements, buildMinorUnitsStatements, buildSqliteRebuildStatements, canonicalizeDefault, collectIntegerWidthTargets, computeChecksum, createMigrationDefinition, extractCreatedIndexName, generateMigrationSequence, generateMigrationTimestamp, generateSchemaDiff, getPendingSchemaStatements, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, isSqliteRebuildPlaceholder, migrateSmrtSchemas, normalizeSQL, parsePostgresTimeoutMs, planPostgresStatements, planSqliteTableRebuilds, preflightIntegerWidthWidening, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits, rewriteSqliteCreateTable, shortChecksum, sqliteRebuildPlaceholderSql, uniqueColumnIndexName, verifyChecksum, widenIntegerColumnsToBigInt };
|
|
12
|
+
export { BackfillTableUnavailableError, BackfillTracker, FRAMEWORK_BASE_TABLE_NAMES, MigrationGenerator, MigrationTracker, MinorUnitsPreflightError, SQLITE_REBUILD_TABLE_PREFIX, SchemaComparer, buildConcurrentIndexPlan, buildIntegerWidthStatements, buildIntegerWidthTableStatements, buildMinorUnitsStatements, buildSqliteRebuildStatements, canonicalizeDefault, collectIntegerWidthTargets, computeChecksum, createMigrationDefinition, dropFrameworkBaseTables, extractCreatedIndexName, generateMigrationSequence, generateMigrationTimestamp, generateSchemaDiff, getPendingSchemaStatements, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, isSqliteRebuildPlaceholder, migrateSmrtSchemas, normalizeSQL, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, planPostgresStatements, planSqliteTableRebuilds, preflightIntegerWidthWidening, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits, rewriteSqliteCreateTable, shortChecksum, sqliteRebuildPlaceholderSql, uniqueColumnIndexName, verifyChecksum, widenIntegerColumnsToBigInt };
|
package/dist/migrations.js
CHANGED
|
@@ -4,9 +4,10 @@ import { SchemaComparer, canonicalizeDefault, generateSchemaDiff, getSQLFromDiff
|
|
|
4
4
|
import { BackfillTableUnavailableError, BackfillTracker } from "./migrations/backfill-tracker.js";
|
|
5
5
|
import { buildIntegerWidthStatements, buildIntegerWidthTableStatements, collectIntegerWidthTargets, preflightIntegerWidthWidening, widenIntegerColumnsToBigInt } from "./migrations/integer-width.js";
|
|
6
6
|
import { computeChecksum, normalizeSQL, shortChecksum, verifyChecksum } from "./migrations/checksum.js";
|
|
7
|
+
import { FRAMEWORK_BASE_TABLE_NAMES, dropFrameworkBaseTables, planFrameworkBaseTableDrop } from "./migrations/framework-base-tables.js";
|
|
7
8
|
import { MigrationGenerator, createMigrationDefinition, generateMigrationSequence, generateMigrationTimestamp } from "./migrations/generator.js";
|
|
8
9
|
import { MinorUnitsPreflightError, buildMinorUnitsStatements, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits } from "./migrations/minor-units.js";
|
|
9
10
|
import { MigrationTracker, buildConcurrentIndexPlan, extractCreatedIndexName, planPostgresStatements } from "./migrations/tracker.js";
|
|
10
11
|
import { getPendingSchemaStatements, migrateSmrtSchemas } from "./migrations/orchestrate.js";
|
|
11
12
|
import "./migrations/index.js";
|
|
12
|
-
export { BackfillTableUnavailableError, BackfillTracker, MigrationGenerator, MigrationTracker, MinorUnitsPreflightError, SQLITE_REBUILD_TABLE_PREFIX, SchemaComparer, buildConcurrentIndexPlan, buildIntegerWidthStatements, buildIntegerWidthTableStatements, buildMinorUnitsStatements, buildSqliteRebuildStatements, canonicalizeDefault, collectIntegerWidthTargets, computeChecksum, createMigrationDefinition, extractCreatedIndexName, generateMigrationSequence, generateMigrationTimestamp, generateSchemaDiff, getPendingSchemaStatements, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, isSqliteRebuildPlaceholder, migrateSmrtSchemas, normalizeSQL, parsePostgresTimeoutMs, planPostgresStatements, planSqliteTableRebuilds, preflightIntegerWidthWidening, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits, rewriteSqliteCreateTable, shortChecksum, sqliteRebuildPlaceholderSql, uniqueColumnIndexName, verifyChecksum, widenIntegerColumnsToBigInt };
|
|
13
|
+
export { BackfillTableUnavailableError, BackfillTracker, FRAMEWORK_BASE_TABLE_NAMES, MigrationGenerator, MigrationTracker, MinorUnitsPreflightError, SQLITE_REBUILD_TABLE_PREFIX, SchemaComparer, buildConcurrentIndexPlan, buildIntegerWidthStatements, buildIntegerWidthTableStatements, buildMinorUnitsStatements, buildSqliteRebuildStatements, canonicalizeDefault, collectIntegerWidthTargets, computeChecksum, createMigrationDefinition, dropFrameworkBaseTables, extractCreatedIndexName, generateMigrationSequence, generateMigrationTimestamp, generateSchemaDiff, getPendingSchemaStatements, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, isSqliteRebuildPlaceholder, migrateSmrtSchemas, normalizeSQL, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, planPostgresStatements, planSqliteTableRebuilds, preflightIntegerWidthWidening, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits, rewriteSqliteCreateTable, shortChecksum, sqliteRebuildPlaceholderSql, uniqueColumnIndexName, verifyChecksum, widenIntegerColumnsToBigInt };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
/** PostgreSQL ACLs only; this is not application authorization or RLS. */
|
|
3
|
+
export interface PostgresPermissionContract {
|
|
4
|
+
schema: string;
|
|
5
|
+
/** Defaults affect every future object created by migrationOwner in schema. */
|
|
6
|
+
schemaExclusive: true;
|
|
7
|
+
migrationOwner: string;
|
|
8
|
+
runtimeRole: string;
|
|
9
|
+
managedTables: string[];
|
|
10
|
+
monitor?: {
|
|
11
|
+
role: string;
|
|
12
|
+
tables: Record<string, string[]>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface PostgresPermissionDiagnostic {
|
|
16
|
+
code: string;
|
|
17
|
+
severity: 'missing' | 'excessive' | 'unsupported';
|
|
18
|
+
role?: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PostgresPermissionPlan {
|
|
23
|
+
contract: PostgresPermissionContract;
|
|
24
|
+
diagnostics: PostgresPermissionDiagnostic[];
|
|
25
|
+
statements: string[];
|
|
26
|
+
canApply: boolean;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
limitations: string[];
|
|
29
|
+
}
|
|
30
|
+
type Executor = Pick<DatabaseInterface, 'query'>;
|
|
31
|
+
/** Validate configuration before opening a connection; unknown options fail closed. */
|
|
32
|
+
export declare function validatePostgresPermissionContract(value: unknown): PostgresPermissionContract;
|
|
33
|
+
/** Read-only catalog inspection. No application records or credentials are read. */
|
|
34
|
+
export declare function planPostgresPermissions(db: Executor, contract: PostgresPermissionContract): Promise<PostgresPermissionPlan>;
|
|
35
|
+
/** Apply the reviewed contract atomically, rejecting stale catalog snapshots. */
|
|
36
|
+
export declare function applyPostgresPermissions(db: DatabaseInterface, contract: PostgresPermissionContract, options: {
|
|
37
|
+
expectedFingerprint: string;
|
|
38
|
+
}): Promise<PostgresPermissionPlan>;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=postgres-permissions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-permissions.d.ts","sourceRoot":"","sources":["../src/postgres-permissions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,0EAA0E;AAC1E,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,eAAe,EAAE,IAAI,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAAC;CAC9D;AAED,MAAM,WAAW,4BAA4B;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,SAAS,GAAG,WAAW,GAAG,aAAa,CAAC;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,0BAA0B,CAAC;IACrC,WAAW,EAAE,4BAA4B,EAAE,CAAC;IAC5C,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,KAAK,QAAQ,GAAG,IAAI,CAAC,iBAAiB,EAAE,OAAO,CAAC,CAAC;AA8DjD,uFAAuF;AACvF,wBAAgB,kCAAkC,CAChD,KAAK,EAAE,OAAO,GACb,0BAA0B,CA4E5B;AAssBD,oFAAoF;AACpF,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,QAAQ,EACZ,QAAQ,EAAE,0BAA0B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAEjC;AAED,iFAAiF;AACjF,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,0BAA0B,EACpC,OAAO,EAAE;IAAE,mBAAmB,EAAE,MAAM,CAAA;CAAE,GACvC,OAAO,CAAC,sBAAsB,CAAC,CA2CjC"}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/postgres-permissions.ts
|
|
3
|
+
var bookkeeping = /* @__PURE__ */ new Set([
|
|
4
|
+
"_smrt_migrations",
|
|
5
|
+
"_smrt_schema_migrations",
|
|
6
|
+
"_smrt_backfills"
|
|
7
|
+
]);
|
|
8
|
+
var identifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
|
9
|
+
var literal = (value) => `E'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
|
|
10
|
+
var qualified = (schema, name) => `${identifier(schema)}.${identifier(name)}`;
|
|
11
|
+
function object(value, keys, label) {
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
13
|
+
for (const key of Object.keys(value)) if (!keys.includes(key)) throw new Error(`Unknown ${label} property: ${key}.`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function name(value, label) {
|
|
17
|
+
if (typeof value !== "string" || !value.length || value.includes("\0") || Buffer.byteLength(value) > 63) throw new Error(`${label} must be a nonempty PostgreSQL identifier (at most 63 bytes).`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/** Validate configuration before opening a connection; unknown options fail closed. */
|
|
21
|
+
function validatePostgresPermissionContract(value) {
|
|
22
|
+
const input = object(value, [
|
|
23
|
+
"schema",
|
|
24
|
+
"schemaExclusive",
|
|
25
|
+
"migrationOwner",
|
|
26
|
+
"runtimeRole",
|
|
27
|
+
"managedTables",
|
|
28
|
+
"monitor"
|
|
29
|
+
], "postgresPermissions");
|
|
30
|
+
if (input.schemaExclusive !== true) throw new Error("postgresPermissions.schemaExclusive must explicitly be true.");
|
|
31
|
+
const schema = name(input.schema, "schema");
|
|
32
|
+
if (schema === "information_schema" || schema.startsWith("pg_")) throw new Error("A dedicated application schema is required.");
|
|
33
|
+
const migrationOwner = name(input.migrationOwner, "migrationOwner");
|
|
34
|
+
const runtimeRole = name(input.runtimeRole, "runtimeRole");
|
|
35
|
+
if (!Array.isArray(input.managedTables)) throw new Error("managedTables must be an array.");
|
|
36
|
+
const managedTables = [...new Set(input.managedTables.map((table) => name(table, "managedTables entry")))].sort();
|
|
37
|
+
let monitor;
|
|
38
|
+
if (input.monitor !== void 0) {
|
|
39
|
+
const entry = object(input.monitor, ["role", "tables"], "monitor");
|
|
40
|
+
const role = name(entry.role, "monitor.role");
|
|
41
|
+
if (!entry.tables || typeof entry.tables !== "object" || Array.isArray(entry.tables)) throw new Error("monitor.tables must map tables to column arrays.");
|
|
42
|
+
const tables = Object.create(null);
|
|
43
|
+
for (const [table, columns] of Object.entries(entry.tables).sort(([a], [b]) => a.localeCompare(b))) {
|
|
44
|
+
name(table, "monitor table");
|
|
45
|
+
if (!Array.isArray(columns) || !columns.length) throw new Error(`monitor table ${table} requires a nonempty column array.`);
|
|
46
|
+
tables[table] = [...new Set(columns.map((column) => name(column, "monitor column")))].sort();
|
|
47
|
+
}
|
|
48
|
+
monitor = {
|
|
49
|
+
role,
|
|
50
|
+
tables
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const roles = [
|
|
54
|
+
migrationOwner,
|
|
55
|
+
runtimeRole,
|
|
56
|
+
...monitor ? [monitor.role] : []
|
|
57
|
+
];
|
|
58
|
+
if (new Set(roles).size !== roles.length || roles.some((role) => role.toUpperCase() === "PUBLIC" || role.startsWith("pg_"))) throw new Error("Migration, runtime and monitor roles must be distinct non-system roles.");
|
|
59
|
+
return {
|
|
60
|
+
schema,
|
|
61
|
+
schemaExclusive: true,
|
|
62
|
+
migrationOwner,
|
|
63
|
+
runtimeRole,
|
|
64
|
+
managedTables,
|
|
65
|
+
...monitor ? { monitor } : {}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
var acl = (expression) => `COALESCE((SELECT json_agg(json_build_object('grantee', CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, 'grantor', pg_get_userbyid(a.grantor), 'privilege', a.privilege_type, 'grantable', a.is_grantable) ORDER BY a.grantee, a.privilege_type, a.grantor) FROM aclexplode(${expression}) a), '[]'::json)`;
|
|
69
|
+
async function snapshot(db, contract) {
|
|
70
|
+
const roleNames = [
|
|
71
|
+
contract.migrationOwner,
|
|
72
|
+
contract.runtimeRole,
|
|
73
|
+
...contract.monitor ? [contract.monitor.role] : []
|
|
74
|
+
].map(literal).join(",");
|
|
75
|
+
const restrictedRoleNames = [contract.runtimeRole, ...contract.monitor ? [contract.monitor.role] : []].map(literal).join(",");
|
|
76
|
+
const informationSchemaPublicSelect = "'administrable_role_authorizations','applicable_roles','attributes','character_sets','check_constraint_routine_usage','check_constraints','collation_character_set_applicability','collations','column_column_usage','column_domain_usage','column_options','column_privileges','column_udt_usage','columns','constraint_column_usage','constraint_table_usage','data_type_privileges','domain_constraints','domain_udt_usage','domains','element_types','enabled_roles','foreign_data_wrapper_options','foreign_data_wrappers','foreign_server_options','foreign_servers','foreign_table_options','foreign_tables','information_schema_catalog_name','key_column_usage','parameters','referential_constraints','role_column_grants','role_routine_grants','role_table_grants','role_udt_grants','role_usage_grants','routine_column_usage','routine_privileges','routine_routine_usage','routine_sequence_usage','routine_table_usage','routines','schemata','sequences','sql_features','sql_implementation_info','sql_sizing','table_constraints','table_privileges','tables','triggered_update_columns','triggers','udt_privileges','usage_privileges','user_defined_types','user_mapping_options','user_mappings','view_column_usage','view_routine_usage','view_table_usage','views'";
|
|
77
|
+
const initialAcl = (fallback) => `CASE WHEN i.privtype='i' THEN i.initprivs WHEN i.privtype='e' THEN NULL::aclitem[] ELSE ${fallback} END`;
|
|
78
|
+
const queries = {
|
|
79
|
+
server: `SELECT current_setting('server_version_num') AS version, current_database() AS database, current_user AS executor, (SELECT rolsuper FROM pg_roles WHERE rolname=current_user) AS superuser`,
|
|
80
|
+
roles: `SELECT oid::text, rolname, rolsuper, rolcreaterole, rolcreatedb, rolreplication, rolbypassrls FROM pg_roles WHERE rolname IN (${roleNames}) ORDER BY rolname`,
|
|
81
|
+
memberships: `SELECT pg_get_userbyid(member) AS member, pg_get_userbyid(roleid) AS parent FROM pg_auth_members WHERE member IN (SELECT oid FROM pg_roles WHERE rolname IN (${roleNames})) OR roleid IN (SELECT oid FROM pg_roles WHERE rolname IN (${roleNames})) ORDER BY member, roleid`,
|
|
82
|
+
database: `SELECT datname AS name, pg_get_userbyid(datdba) AS owner, ${acl("COALESCE(datacl, acldefault('d',datdba))")} AS acl FROM pg_database WHERE datname=current_database()`,
|
|
83
|
+
schemas: `SELECT nspname AS name, pg_get_userbyid(nspowner) AS owner, ${acl("COALESCE(nspacl, acldefault('n',nspowner))")} AS acl FROM pg_namespace WHERE left(nspname,3) <> 'pg_' AND nspname <> 'information_schema' ORDER BY nspname`,
|
|
84
|
+
relations: `SELECT c.oid::text, n.nspname AS schema, c.relname AS name, c.relkind AS kind, pg_get_userbyid(c.relowner) AS owner, c.relrowsecurity AS rls, ${acl("COALESCE(c.relacl, acldefault(CASE WHEN c.relkind='S' THEN 's'::\"char\" ELSE 'r'::\"char\" END,c.relowner))")} AS acl FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE left(n.nspname,3) <> 'pg_' AND n.nspname <> 'information_schema' AND c.relkind IN ('r','p','v','m','f','S') ORDER BY n.nspname,c.relname`,
|
|
85
|
+
columns: `SELECT c.oid::text AS relation, a.attname AS name, ${acl("a.attacl")} AS acl FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE left(n.nspname,3) <> 'pg_' AND n.nspname <> 'information_schema' AND c.relkind IN ('r','p','v','m','f') AND a.attnum>0 AND NOT a.attisdropped ORDER BY c.oid,a.attnum`,
|
|
86
|
+
routines: `SELECT n.nspname AS schema, p.oid::text, p.proname AS name, pg_get_userbyid(p.proowner) AS owner, ${acl("COALESCE(p.proacl, acldefault('f',p.proowner))")} AS acl FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE left(n.nspname,3) <> 'pg_' AND n.nspname <> 'information_schema' ORDER BY n.nspname,p.oid`,
|
|
87
|
+
types: `SELECT n.nspname AS schema, t.typname AS name, pg_get_userbyid(t.typowner) AS owner, ${acl("COALESCE(t.typacl, acldefault('T',t.typowner))")} AS acl FROM pg_type t JOIN pg_namespace n ON n.oid=t.typnamespace LEFT JOIN pg_class c ON c.oid=t.typrelid WHERE left(n.nspname,3) <> 'pg_' AND n.nspname <> 'information_schema' AND NOT EXISTS (SELECT 1 FROM pg_type element WHERE element.typarray=t.oid) AND (t.typrelid=0 OR c.relkind='c') ORDER BY n.nspname,t.typname`,
|
|
88
|
+
systemSchemas: `SELECT nspname AS name, pg_get_userbyid(nspowner) AS owner, ${acl("COALESCE(nspacl, acldefault('n',nspowner))")} AS acl FROM pg_namespace WHERE left(nspname,3)='pg_' OR nspname='information_schema' ORDER BY nspname`,
|
|
89
|
+
systemPrivileges: `WITH resources AS (
|
|
90
|
+
SELECT n.nspname AS schema, c.relname AS name, c.oid::text AS oid, CASE WHEN c.relkind='S' THEN 'sequence' ELSE 'relation' END AS kind, pg_get_userbyid(c.relowner) AS owner,
|
|
91
|
+
COALESCE(c.relacl,acldefault(CASE WHEN c.relkind='S' THEN 's'::"char" ELSE 'r'::"char" END,c.relowner)) AS current_acl,
|
|
92
|
+
CASE WHEN i.objoid IS NULL AND n.nspname='information_schema' AND c.oid<16384 AND c.relname IN (${informationSchemaPublicSelect}) THEN ARRAY[makeaclitem(0,c.relowner,'SELECT',false)] ELSE ${initialAcl("acldefault(CASE WHEN c.relkind='S' THEN 's'::\"char\" ELSE 'r'::\"char\" END,c.relowner)")} END AS initial_acl
|
|
93
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
94
|
+
LEFT JOIN pg_init_privs i ON i.objoid=c.oid AND i.classoid='pg_class'::regclass AND i.objsubid=0
|
|
95
|
+
WHERE (left(n.nspname,3)='pg_' OR n.nspname='information_schema') AND c.relkind IN ('r','p','v','m','f','S')
|
|
96
|
+
UNION ALL
|
|
97
|
+
SELECT n.nspname, c.relname || '.' || a.attname, c.oid::text || ':' || a.attnum::text, 'column', pg_get_userbyid(c.relowner),
|
|
98
|
+
a.attacl, ${initialAcl("NULL::aclitem[]")}
|
|
99
|
+
FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
100
|
+
LEFT JOIN pg_init_privs i ON i.objoid=c.oid AND i.classoid='pg_class'::regclass AND i.objsubid=a.attnum
|
|
101
|
+
WHERE (left(n.nspname,3)='pg_' OR n.nspname='information_schema') AND a.attnum>0 AND NOT a.attisdropped
|
|
102
|
+
UNION ALL
|
|
103
|
+
SELECT n.nspname, p.proname, p.oid::text, 'routine', pg_get_userbyid(p.proowner), COALESCE(p.proacl,acldefault('f',p.proowner)), ${initialAcl("CASE WHEN p.oid<16384 THEN acldefault('f',p.proowner) ELSE NULL::aclitem[] END")}
|
|
104
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
|
|
105
|
+
LEFT JOIN pg_init_privs i ON i.objoid=p.oid AND i.classoid='pg_proc'::regclass AND i.objsubid=0
|
|
106
|
+
WHERE left(n.nspname,3)='pg_' OR n.nspname='information_schema'
|
|
107
|
+
UNION ALL
|
|
108
|
+
SELECT n.nspname, t.typname, t.oid::text, 'type', pg_get_userbyid(t.typowner), COALESCE(t.typacl,acldefault('T',t.typowner)), ${initialAcl("CASE WHEN t.oid<16384 THEN acldefault('T',t.typowner) ELSE NULL::aclitem[] END")}
|
|
109
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid=t.typnamespace LEFT JOIN pg_class c ON c.oid=t.typrelid
|
|
110
|
+
LEFT JOIN pg_init_privs i ON i.objoid=t.oid AND i.classoid='pg_type'::regclass AND i.objsubid=0
|
|
111
|
+
WHERE (left(n.nspname,3)='pg_' OR n.nspname='information_schema') AND NOT EXISTS (SELECT 1 FROM pg_type element WHERE element.typarray=t.oid) AND (t.typrelid=0 OR c.relkind='c')
|
|
112
|
+
) SELECT r.schema,r.name,r.oid,r.kind,r.owner,COALESCE(d.acl,'[]'::json) AS acl FROM resources r
|
|
113
|
+
CROSS JOIN LATERAL (
|
|
114
|
+
SELECT json_agg(json_build_object('grantee', CASE WHEN a.grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
|
|
115
|
+
'grantor',pg_get_userbyid(a.grantor),'privilege',a.privilege_type,'grantable',a.is_grantable)
|
|
116
|
+
ORDER BY a.grantee,a.privilege_type,a.grantor) AS acl
|
|
117
|
+
FROM aclexplode(r.current_acl) a
|
|
118
|
+
WHERE a.grantee IN (SELECT oid FROM pg_roles WHERE rolname IN (${restrictedRoleNames}))
|
|
119
|
+
OR (a.grantee=0 AND NOT EXISTS (SELECT 1 FROM aclexplode(r.initial_acl) b WHERE b.grantee=0 AND b.privilege_type=a.privilege_type AND (b.is_grantable OR NOT a.is_grantable)))
|
|
120
|
+
) d WHERE d.acl IS NOT NULL OR (r.kind<>'column' AND r.owner IN (${restrictedRoleNames})) ORDER BY r.schema,r.kind,r.name,r.oid`,
|
|
121
|
+
largeObjects: `SELECT oid::text, pg_get_userbyid(lomowner) AS owner, ${acl("COALESCE(lomacl, acldefault('L',lomowner))")} AS acl FROM pg_largeobject_metadata ORDER BY oid`,
|
|
122
|
+
largeObjectSettings: `SELECT setting,reset_val,source FROM pg_settings WHERE name='lo_compat_privileges'`,
|
|
123
|
+
largeObjectRoleSettings: `SELECT s.setdatabase::text AS database, CASE WHEN s.setrole=0 THEN 'ALL ROLES' ELSE pg_get_userbyid(s.setrole) END AS role, split_part(config.value,'=',2) AS setting FROM pg_db_role_setting s CROSS JOIN LATERAL unnest(s.setconfig) config(value) WHERE s.setdatabase IN (0,(SELECT oid FROM pg_database WHERE datname=current_database())) AND (s.setrole=0 OR s.setrole IN (SELECT oid FROM pg_roles WHERE rolname IN (${restrictedRoleNames}))) AND split_part(config.value,'=',1)='lo_compat_privileges' ORDER BY s.setdatabase,s.setrole,config.value`,
|
|
124
|
+
parameters: `SELECT parname AS name, ${acl("paracl")} AS acl FROM pg_parameter_acl ORDER BY parname`,
|
|
125
|
+
defaults: `SELECT pg_get_userbyid(d.defaclrole) AS owner, COALESCE(n.nspname,'') AS schema, d.defaclobjtype AS kind, ${acl("d.defaclacl")} AS acl FROM pg_default_acl d LEFT JOIN pg_namespace n ON n.oid=d.defaclnamespace ORDER BY d.defaclrole,d.defaclnamespace,d.defaclobjtype`
|
|
126
|
+
};
|
|
127
|
+
const result = {};
|
|
128
|
+
for (const [key, query] of Object.entries(queries)) {
|
|
129
|
+
if (key === "parameters" && Number(result.server[0].version) < 15e4) {
|
|
130
|
+
result[key] = [];
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
result[key] = (await db.query(query)).rows;
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
async function plan(db, input) {
|
|
138
|
+
const contract = validatePostgresPermissionContract(input);
|
|
139
|
+
const state = await snapshot(db, contract);
|
|
140
|
+
const diagnostics = [];
|
|
141
|
+
const statements = [];
|
|
142
|
+
const roles = [contract.runtimeRole, ...contract.monitor ? [contract.monitor.role] : []];
|
|
143
|
+
const add = (severity, code, resource, message, role) => diagnostics.push({
|
|
144
|
+
severity,
|
|
145
|
+
code,
|
|
146
|
+
resource,
|
|
147
|
+
message,
|
|
148
|
+
...role ? { role } : {}
|
|
149
|
+
});
|
|
150
|
+
const unsupported = (code, resource, message, role) => add("unsupported", code, resource, message, role);
|
|
151
|
+
if (Number(state.server[0].version) < 14e4) unsupported("server-version", "database", "PostgreSQL 14 or later is required.");
|
|
152
|
+
for (const role of [contract.migrationOwner, ...roles]) {
|
|
153
|
+
const row = state.roles.find((entry) => entry.rolname === role);
|
|
154
|
+
if (!row) {
|
|
155
|
+
unsupported("missing-role", role, "Infrastructure must create this role.", role);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (roles.includes(role) && [
|
|
159
|
+
"rolsuper",
|
|
160
|
+
"rolcreaterole",
|
|
161
|
+
"rolcreatedb",
|
|
162
|
+
"rolreplication",
|
|
163
|
+
"rolbypassrls"
|
|
164
|
+
].some((key) => row[key])) unsupported("privileged-role", role, "Remove SUPERUSER, CREATEROLE, CREATEDB, REPLICATION and BYPASSRLS authority using infrastructure role management.", role);
|
|
165
|
+
}
|
|
166
|
+
for (const membership of state.memberships) unsupported("role-membership", membership.member, `Role ${membership.member} is a member of ${membership.parent}; inbound and outbound membership, inherited privileges, and SET ROLE authority require infrastructure review. Memberships are never modified.`, membership.member);
|
|
167
|
+
const schema = state.schemas.find((entry) => entry.name === contract.schema);
|
|
168
|
+
if (!schema) unsupported("missing-schema", contract.schema, "Create the dedicated schema as the migration owner before reconciliation.");
|
|
169
|
+
else if (schema.owner !== contract.migrationOwner) unsupported("schema-owner", contract.schema, `Schema must be owned by ${contract.migrationOwner}; ownership is never changed.`);
|
|
170
|
+
const database = state.database[0];
|
|
171
|
+
if (roles.includes(database.owner)) unsupported("database-owner", database.name, "Runtime and monitor must not own the database.", database.owner);
|
|
172
|
+
const relevant = (entries, role) => entries.filter((entry) => entry.grantee === role || entry.grantee === "PUBLIC");
|
|
173
|
+
function reconcile(kind, resource, entries, role, desired, suffix = "") {
|
|
174
|
+
for (const entry of entries) if (entry.grantor === role && entry.grantee !== role) unsupported("dependent-grant", resource, `Configured role granted ${entry.privilege} to ${entry.grantee}; grant chains require infrastructure review to preserve other roles.`, role);
|
|
175
|
+
for (const entry of entries) if (entry.grantee === "PUBLIC" && !(kind === "DATABASE" && entry.privilege === "CONNECT" && !entry.grantable)) unsupported("public-privilege", resource, `PUBLIC ${entry.privilege} is outside the explicit role contract; infrastructure must review it without changing unrelated access.`, role);
|
|
176
|
+
const excess = relevant(entries, role).filter((entry) => !desired.includes(entry.privilege) || entry.grantable);
|
|
177
|
+
const missing = desired.filter((privilege) => !entries.some((entry) => entry.grantee === role && entry.privilege === privilege));
|
|
178
|
+
for (const entry of excess) {
|
|
179
|
+
add("excessive", "excessive-privilege", resource, `${entry.privilege}${entry.grantable ? " WITH GRANT OPTION" : ""} is not declared (source: ${entry.grantee}).`, role);
|
|
180
|
+
if (entry.grantee === "PUBLIC") unsupported("public-privilege", resource, `PUBLIC ${entry.privilege} requires infrastructure review; revoking it would affect other roles.`, role);
|
|
181
|
+
}
|
|
182
|
+
for (const privilege of missing) add("missing", "missing-privilege", resource, `${privilege} is required.`, role);
|
|
183
|
+
if (!excess.length && !missing.length) return;
|
|
184
|
+
statements.push(`REVOKE ALL PRIVILEGES${suffix} ON ${kind} ${resource} FROM ${identifier(role)}`);
|
|
185
|
+
if (desired.length) statements.push(`GRANT ${desired.join(", ")}${suffix} ON ${kind} ${resource} TO ${identifier(role)}`);
|
|
186
|
+
}
|
|
187
|
+
for (const role of roles) {
|
|
188
|
+
reconcile("DATABASE", identifier(database.name), database.acl, role, ["CONNECT"]);
|
|
189
|
+
for (const entry of state.schemas) {
|
|
190
|
+
if (entry.owner === role) unsupported("resource-owner", entry.name, "Configured role owns a schema and has DDL authority.", role);
|
|
191
|
+
if (entry.name === contract.schema) reconcile("SCHEMA", identifier(entry.name), entry.acl, role, ["USAGE"]);
|
|
192
|
+
else if (relevant(entry.acl, role).some((grant) => grant.privilege === "CREATE")) unsupported("outside-schema-create", entry.name, "Effective CREATE outside the dedicated schema requires infrastructure repair.", role);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const schema of state.systemSchemas) for (const role of roles) {
|
|
196
|
+
if (schema.owner === role) unsupported("system-schema-owner", schema.name, "Configured roles must not own system schemas; infrastructure must restore separate ownership. System schema ownership is never modified.", role);
|
|
197
|
+
for (const grant of relevant(schema.acl, role)) if (grant.privilege === "CREATE") unsupported("system-schema-authority", schema.name, `Effective CREATE on a system schema (source: ${grant.grantee}) gives DDL authority and requires infrastructure repair. System schema ACLs are never modified.`, role);
|
|
198
|
+
}
|
|
199
|
+
for (const resource of state.systemPrivileges) {
|
|
200
|
+
if (resource.kind !== "column" && roles.includes(resource.owner)) unsupported("system-object-owner", qualified(resource.schema, resource.name), `Configured role owns a system ${resource.kind}; ownership retains implicit alteration and grant authority even when its ordinary ACLs are revoked. Infrastructure must restore ownership; SMRT never changes it.`, resource.owner);
|
|
201
|
+
for (const grant of resource.acl) unsupported("system-privilege-delta", qualified(resource.schema, resource.name), `${grant.grantee} has exceptional ${grant.privilege}${grant.grantable ? " WITH GRANT OPTION" : ""} on a system ${resource.kind}; review infrastructure grants against PostgreSQL initial ACLs. System ACLs are never modified.`, grant.grantee === "PUBLIC" ? void 0 : grant.grantee);
|
|
202
|
+
}
|
|
203
|
+
for (const resource of state.largeObjects) {
|
|
204
|
+
if (roles.includes(resource.owner)) unsupported("large-object-owner", `large object ${resource.oid}`, "Configured role owns a large object outside the declared table/column contract. Infrastructure must review ownership; SMRT never changes it.", resource.owner);
|
|
205
|
+
for (const grant of resource.acl) if (roles.includes(grant.grantee) || grant.grantee === "PUBLIC") unsupported("large-object-privilege", `large object ${resource.oid}`, `${grant.grantee} has ${grant.privilege}${grant.grantable ? " WITH GRANT OPTION" : ""} outside the declared table/column contract. Infrastructure must review this ACL; SMRT neither reads payloads nor changes large-object permissions.`, grant.grantee === "PUBLIC" ? void 0 : grant.grantee);
|
|
206
|
+
}
|
|
207
|
+
const explicitlyOff = (value) => [
|
|
208
|
+
"off",
|
|
209
|
+
"false",
|
|
210
|
+
"no",
|
|
211
|
+
"0"
|
|
212
|
+
].includes(String(value).toLowerCase());
|
|
213
|
+
if (state.largeObjectSettings.length !== 1) unsupported("large-object-compatibility", "lo_compat_privileges", "The large-object ACL compatibility setting could not be established; qualification requires an observable disabled setting.");
|
|
214
|
+
for (const settings of state.largeObjectSettings) if (!explicitlyOff(settings.setting) || !explicitlyOff(settings.reset_val) || ![
|
|
215
|
+
"default",
|
|
216
|
+
"configuration file",
|
|
217
|
+
"command line",
|
|
218
|
+
"database"
|
|
219
|
+
].includes(String(settings.source))) unsupported("large-object-compatibility", "lo_compat_privileges", "Large-object ACL checks must be enabled for every configured role. An enabled or operator-specific lo_compat_privileges override cannot establish that baseline; review deployment settings and reconnect without an operator-specific override.");
|
|
220
|
+
for (const settings of state.largeObjectRoleSettings) if (!explicitlyOff(settings.setting)) unsupported("large-object-compatibility", "lo_compat_privileges", `Role/database defaults enable or do not establish large-object ACL checks (${String(settings.role)}, database OID ${String(settings.database)}). Infrastructure must disable compatibility mode before qualification.`, settings.role === "ALL ROLES" ? void 0 : String(settings.role));
|
|
221
|
+
for (const parameter of state.parameters) for (const role of roles) for (const grant of relevant(parameter.acl, role)) unsupported("parameter-privilege", parameter.name, `Effective ${grant.privilege} on a PostgreSQL configuration parameter (source: ${grant.grantee}) requires infrastructure review; parameter ACLs are never modified.`, role);
|
|
222
|
+
const tables = state.relations.filter((entry) => entry.schema === contract.schema && entry.kind !== "S");
|
|
223
|
+
for (const table of bookkeeping) if (!tables.some((entry) => entry.name === table)) unsupported("missing-bookkeeping-table", qualified(contract.schema, table), "Bootstrap all framework bookkeeping tables as the migration owner before permission setup. Stop runtime and monitoring access during migrations or restores, then reconcile before reactivation.");
|
|
224
|
+
const declared = new Set(contract.managedTables);
|
|
225
|
+
for (const table of tables) if (table.name.startsWith("_smrt_")) declared.add(table.name);
|
|
226
|
+
for (const table of declared) if (!tables.some((entry) => entry.name === table)) unsupported("missing-table", qualified(contract.schema, table), "Run supported schema migrations before reconciling permissions.");
|
|
227
|
+
for (const [table, columns] of Object.entries(contract.monitor?.tables ?? {})) {
|
|
228
|
+
const relation = tables.find((entry) => entry.name === table);
|
|
229
|
+
if (!declared.has(table) || !relation) unsupported("monitor-table", table, "Monitor tables must exist and belong to managedTables or framework tables.");
|
|
230
|
+
for (const column of columns) if (!relation || !state.columns.some((entry) => entry.relation === relation.oid && entry.name === column)) unsupported("monitor-column", `${table}.${column}`, "The declared monitoring column does not exist.");
|
|
231
|
+
}
|
|
232
|
+
for (const relation of state.relations) {
|
|
233
|
+
const resource = qualified(relation.schema, relation.name);
|
|
234
|
+
const columns = state.columns.filter((entry) => entry.relation === relation.oid);
|
|
235
|
+
if (relation.schema !== contract.schema) {
|
|
236
|
+
for (const role of roles) if (relation.owner === role || relevant(relation.acl, role).length || columns.some((column) => relevant(column.acl, role).length)) unsupported("outside-resource-access", resource, "Effective access to an unrelated relation requires infrastructure review; unrelated ACLs are preserved.", role);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (relation.owner !== contract.migrationOwner) unsupported("resource-owner", resource, `Managed resources must be owned by ${contract.migrationOwner}; ownership is never changed.`);
|
|
240
|
+
if (relation.kind !== "S" && !declared.has(relation.name)) unsupported("undeclared-table", resource, "The dedicated schema contains an undeclared table; defaults cannot safely cover a mixed schema.");
|
|
241
|
+
if (![
|
|
242
|
+
"r",
|
|
243
|
+
"p",
|
|
244
|
+
"S"
|
|
245
|
+
].includes(relation.kind)) unsupported("unsupported-relation", resource, `Relation kind ${relation.kind} requires a separate security review.`);
|
|
246
|
+
if (relation.rls) unsupported("row-level-security", resource, "RLS policies require a separate application authorization review; ACL verification cannot establish allowed row operations.");
|
|
247
|
+
for (const role of roles) {
|
|
248
|
+
const isRuntime = role === contract.runtimeRole;
|
|
249
|
+
const desired = isRuntime ? relation.kind === "S" ? ["USAGE"] : bookkeeping.has(relation.name) ? ["SELECT"] : [
|
|
250
|
+
"SELECT",
|
|
251
|
+
"INSERT",
|
|
252
|
+
"UPDATE",
|
|
253
|
+
"DELETE"
|
|
254
|
+
] : [];
|
|
255
|
+
reconcile(relation.kind === "S" ? "SEQUENCE" : "TABLE", resource, relation.acl, role, desired);
|
|
256
|
+
for (const column of columns) {
|
|
257
|
+
const wanted = !isRuntime && (contract.monitor?.tables[relation.name] ?? []).includes(column.name) ? ["SELECT"] : [];
|
|
258
|
+
reconcile("TABLE", resource, column.acl, role, wanted, ` (${identifier(column.name)})`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const category of ["routines", "types"]) for (const resource of state[category]) for (const role of roles) if (resource.schema === contract.schema || resource.owner === role || relevant(resource.acl, role).length) unsupported(`unsupported-${category}`, qualified(resource.schema, resource.name), `User-defined ${category} are outside the supported table/sequence contract; review their effective privileges explicitly.`, role);
|
|
263
|
+
for (const defaults of state.defaults) {
|
|
264
|
+
const resource = `DEFAULT ${defaults.kind} (${defaults.schema || "global"})`;
|
|
265
|
+
if (defaults.owner !== contract.migrationOwner && defaults.acl.some((entry) => roles.includes(entry.grantee))) unsupported("other-creator-access", resource, `Creator ${defaults.owner} grants configured roles future access outside this contract.`);
|
|
266
|
+
if (defaults.schema !== contract.schema && defaults.schema !== "" && defaults.acl.some((entry) => entry.grantee === "PUBLIC" || roles.includes(entry.grantee))) unsupported("outside-default-privilege", resource, "Defaults outside the managed schema grant future access and require infrastructure review.");
|
|
267
|
+
if (defaults.schema === "" && defaults.acl.some((entry) => roles.includes(entry.grantee) || entry.grantee === "PUBLIC" && [
|
|
268
|
+
"r",
|
|
269
|
+
"S",
|
|
270
|
+
"n"
|
|
271
|
+
].includes(defaults.kind))) unsupported("global-default-privilege", resource, "Global creator defaults grant configured roles or PUBLIC access and cannot be repaired within this schema.");
|
|
272
|
+
if (defaults.schema === contract.schema && defaults.owner !== contract.migrationOwner) unsupported("other-creator-default", resource, `Defaults for creator ${defaults.owner} require separate ownership review.`);
|
|
273
|
+
if (defaults.schema === contract.schema && !["r", "S"].includes(defaults.kind) && defaults.acl.some((entry) => roles.includes(entry.grantee) || entry.grantee === "PUBLIC")) unsupported("unsupported-default", resource, "Only table and sequence schema defaults are supported.");
|
|
274
|
+
}
|
|
275
|
+
for (const [kind, sqlKind, runtime] of [[
|
|
276
|
+
"r",
|
|
277
|
+
"TABLES",
|
|
278
|
+
[
|
|
279
|
+
"SELECT",
|
|
280
|
+
"INSERT",
|
|
281
|
+
"UPDATE",
|
|
282
|
+
"DELETE"
|
|
283
|
+
]
|
|
284
|
+
], [
|
|
285
|
+
"S",
|
|
286
|
+
"SEQUENCES",
|
|
287
|
+
["USAGE"]
|
|
288
|
+
]]) {
|
|
289
|
+
const entry = state.defaults.find((item) => item.schema === contract.schema && item.owner === contract.migrationOwner && item.kind === kind);
|
|
290
|
+
if (entry?.acl.some((grant) => grant.grantee === "PUBLIC")) unsupported("public-default", `${contract.schema} ${sqlKind}`, "PUBLIC creator defaults are outside the explicit role contract and require infrastructure review.");
|
|
291
|
+
for (const role of roles) {
|
|
292
|
+
const desired = role === contract.runtimeRole ? runtime : [];
|
|
293
|
+
const extra = relevant(entry?.acl ?? [], role).filter((grant) => !desired.includes(grant.privilege) || grant.grantable);
|
|
294
|
+
const missing = desired.filter((privilege) => !(entry?.acl ?? []).some((grant) => grant.grantee === role && grant.privilege === privilege));
|
|
295
|
+
if (!extra.length && !missing.length) continue;
|
|
296
|
+
for (const grant of extra) {
|
|
297
|
+
add("excessive", "default-excessive", `${contract.schema} ${sqlKind}`, `${grant.privilege} default from ${grant.grantee} is not declared.`, role);
|
|
298
|
+
if (grant.grantee === "PUBLIC") unsupported("public-default", contract.schema, "PUBLIC defaults affect other roles and require infrastructure repair.");
|
|
299
|
+
}
|
|
300
|
+
for (const privilege of missing) add("missing", "default-missing", `${contract.schema} ${sqlKind}`, `Creator ${contract.migrationOwner} must default ${privilege}.`, role);
|
|
301
|
+
const prefix = `ALTER DEFAULT PRIVILEGES FOR ROLE ${identifier(contract.migrationOwner)} IN SCHEMA ${identifier(contract.schema)}`;
|
|
302
|
+
statements.push(`${prefix} REVOKE ALL PRIVILEGES ON ${sqlKind} FROM ${identifier(role)}`);
|
|
303
|
+
if (desired.length) statements.push(`${prefix} GRANT ${desired.join(", ")} ON ${sqlKind} TO ${identifier(role)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const executor = state.server[0];
|
|
307
|
+
if (statements.length && !executor.superuser && executor.executor !== contract.migrationOwner) unsupported("execution-authority", "database", "Apply requires the migration owner or a superuser; plan and apply must use the same actor.");
|
|
308
|
+
if (statements.some((sql) => sql.includes(" ON DATABASE ")) && !executor.superuser && database.owner !== executor.executor) unsupported("database-authority", database.name, "Database ACL remediation requires its owner or a superuser.");
|
|
309
|
+
return {
|
|
310
|
+
contract,
|
|
311
|
+
diagnostics,
|
|
312
|
+
limitations: [
|
|
313
|
+
"Only PostgreSQL table, column, sequence, schema and database ACLs are qualified; application authorization and RLS are separate. Large-object ownership/access is unsupported, and lo_compat_privileges must remain off for deployed roles.",
|
|
314
|
+
"Future user-defined routines and types are unsupported. PostgreSQL implicit global defaults grant PUBLIC EXECUTE/USAGE; rerun diagnostics after every migration before activating runtime roles.",
|
|
315
|
+
"Pause migrations and external ACL/role writers during planning and apply. The advisory lock coordinates SMRT permission writers only.",
|
|
316
|
+
"All framework bookkeeping tables must exist before setup. Stop runtime and monitor access throughout migrations, restores and repair: recreated bookkeeping tables temporarily receive creator CRUD defaults and require explicit reconciliation before either role is reactivated."
|
|
317
|
+
],
|
|
318
|
+
statements: [...new Set(statements)],
|
|
319
|
+
canApply: !diagnostics.some((entry) => entry.severity === "unsupported"),
|
|
320
|
+
fingerprint: createHash("sha256").update(JSON.stringify({
|
|
321
|
+
contract,
|
|
322
|
+
state
|
|
323
|
+
})).digest("hex")
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Read-only catalog inspection. No application records or credentials are read. */
|
|
327
|
+
async function planPostgresPermissions(db, contract) {
|
|
328
|
+
return plan(db, contract);
|
|
329
|
+
}
|
|
330
|
+
/** Apply the reviewed contract atomically, rejecting stale catalog snapshots. */
|
|
331
|
+
async function applyPostgresPermissions(db, contract, options) {
|
|
332
|
+
validatePostgresPermissionContract(contract);
|
|
333
|
+
if (!options || !/^[a-f0-9]{64}$/.test(options.expectedFingerprint)) throw new Error("An expectedFingerprint from a reviewed permission plan is required.");
|
|
334
|
+
if (!db.transaction) throw new Error("PostgreSQL permissions require a transaction-capable database.");
|
|
335
|
+
return db.transaction(async (tx) => {
|
|
336
|
+
await tx.query("SET LOCAL lock_timeout = '5s'");
|
|
337
|
+
await tx.query("SET LOCAL statement_timeout = '30s'");
|
|
338
|
+
await tx.query(`SELECT pg_advisory_xact_lock(hashtext(${literal(`smrt-permissions:${contract.schema}`)}))`);
|
|
339
|
+
const before = await plan(tx, contract);
|
|
340
|
+
if (before.fingerprint !== options.expectedFingerprint) throw new Error("Permission plan is stale; generate and review a new plan.");
|
|
341
|
+
if (!before.canApply) throw new Error("Permission plan has unsupported privilege sources or missing prerequisites; resolve its diagnostics first.");
|
|
342
|
+
const executor = (await tx.query("SELECT current_user AS name, rolsuper FROM pg_roles WHERE rolname=current_user")).rows[0];
|
|
343
|
+
if (!executor.rolsuper && executor.name !== contract.migrationOwner) throw new Error("Apply must execute as the migration owner or a PostgreSQL superuser.");
|
|
344
|
+
for (const statement of before.statements) await tx.query(statement);
|
|
345
|
+
const after = await plan(tx, contract);
|
|
346
|
+
if (!after.canApply || after.diagnostics.length) throw new Error("Permission verification failed; all permission changes have been rolled back.");
|
|
347
|
+
return after;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
export { applyPostgresPermissions, planPostgresPermissions, validatePostgresPermissionContract };
|
|
352
|
+
|
|
353
|
+
//# sourceMappingURL=postgres-permissions.js.map
|