@stacksjs/database 0.72.50 → 0.72.52
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/dist/fk-audit.d.ts +20 -0
- package/dist/fk-audit.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +12 -10
package/dist/fk-audit.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export declare function getDeclaredFKs(): Promise<DeclaredFK[]>;
|
|
|
26
26
|
* information_schema on MySQL / PostgreSQL.
|
|
27
27
|
*/
|
|
28
28
|
export declare function getLiveFKs(): Promise<LiveFK[]>;
|
|
29
|
+
/**
|
|
30
|
+
* The tables that exist in the live database, lowercased.
|
|
31
|
+
*
|
|
32
|
+
* Used to tell "this table has no such constraint" apart from "there is no
|
|
33
|
+
* such table", which are different findings with different fixes.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getLiveTables(): Promise<Set<string>>;
|
|
29
36
|
/**
|
|
30
37
|
* Diff declared FKs against live FKs. Returns the declared FKs that
|
|
31
38
|
* have no matching row in the live database — these are the silent
|
|
@@ -38,6 +45,18 @@ export declare function getLiveFKs(): Promise<LiveFK[]>;
|
|
|
38
45
|
* external tooling, both legitimate.
|
|
39
46
|
*/
|
|
40
47
|
export declare function auditForeignKeys(): Promise<FkAuditResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Split declared FKs into the ones a live table is missing and the ones whose
|
|
50
|
+
* table is not there at all. Pure, and exported, so the rule can be tested
|
|
51
|
+
* against inputs instead of against a database.
|
|
52
|
+
*
|
|
53
|
+
* `liveTables` empty means the catalog could not be read. Treating that as
|
|
54
|
+
* "no tables exist" would file every declared FK as absent-table and hide
|
|
55
|
+
* every real finding, so nothing is classified absent in that case.
|
|
56
|
+
*/
|
|
57
|
+
export declare function classifyDeclaredFKs(declared: DeclaredFK[], liveKeys: Set<string>, liveTables: Set<string>): { missing: DeclaredFK[], absentTable: DeclaredFK[] };
|
|
58
|
+
/** The match key `auditForeignKeys` compares on, for callers building one. */
|
|
59
|
+
export declare function fkKey(fk: { fromTable: string, fromColumn: string, toTable: string, toColumn: string }): string;
|
|
41
60
|
/**
|
|
42
61
|
* Scan the live database for rows whose foreign key references a
|
|
43
62
|
* parent row that doesn't exist. SQLite's `PRAGMA foreign_key_check`
|
|
@@ -81,6 +100,7 @@ export declare interface FkAuditResult {
|
|
|
81
100
|
declared: DeclaredFK[]
|
|
82
101
|
live: LiveFK[]
|
|
83
102
|
missing: DeclaredFK[]
|
|
103
|
+
absentTable: DeclaredFK[]
|
|
84
104
|
}
|
|
85
105
|
// stacksjs/stacks#1951 — FK orphan detection. FK enforcement flipped
|
|
86
106
|
// ON (utils.ts bootstrap pragmas) against databases that were written
|
package/dist/fk-audit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{dirname}from"node:path";import{plural,snakeCase}from"@stacksjs/strings";import{path}from"@stacksjs/path";import{globSync}from"@stacksjs/storage";import{dialectCapabilities,isKnownDialect,toSqlIntrospectionDialect}from"./dialect";export function safeGlob(pattern){const metaIdx=pattern.search(/[*?[]/),root=metaIdx===-1?dirname(pattern):dirname(pattern.slice(0,metaIdx));if(!existsSync(root))return[];try{return globSync(pattern,{absolute:!0})}catch{return[]}}function modelTable(model){return model.table||plural(snakeCase(model.name||""))}function modelNameForColumn(column){return column.replace(/_id$/,"").split("_").map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join("")}export function getDeclaredFKsFromModels(models){const meta=new Map(models.map((model)=>[String(model.name??""),{primaryKey:String(model.primaryKey??"id"),table:modelTable(model)}])),declared=[],seen=new Set,push=(fk)=>{const key=`${fk.fromTable}.${fk.fromColumn}\u2192${fk.toTable}.${fk.toColumn}`.toLowerCase();if(seen.has(key))return;seen.add(key);declared.push(fk)};for(const model of models){const fromTable=modelTable(model),modelName=String(model.name??""),attributes=model.attributes??{},belongsTo=model.belongsTo,relations=Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.keys(belongsTo).map((name)=>({model:name})):[],declaresBelongsTo=relations.length>0;for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1)continue;if(attribute.foreignKey&&typeof attribute.foreignKey==="object"){push({fromTable,fromColumn,toTable:attribute.foreignKey.table,toColumn:attribute.foreignKey.column??"id",model:modelName});continue}const declaredTarget=relations.some((entry)=>{const relatedName=typeof entry==="string"?entry:String(entry.model??"");return(typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`)===fromColumn});if(declaresBelongsTo&&!declaredTarget)continue;const related=meta.get(modelNameForColumn(fromColumn));if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}for(const entry of relations){const relatedName=typeof entry==="string"?entry:String(entry.model??"");if(!relatedName)continue;const fromColumn=typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`;if(Object.keys(attributes).some((attribute)=>snakeCase(attribute)===fromColumn))continue;const related=meta.get(relatedName);if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}}return declared}export async function getDeclaredFKs(){const userModelFiles=safeGlob(path.userModelsPath("*.ts")),modelFiles=userModelFiles.length>0?userModelFiles:safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts")),models=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(model&&typeof model==="object")models.push(model)}return getDeclaredFKsFromModels(models)}export async function getLiveFKs(){const{db}=await import("./utils"),dialect=await currentDialect();if(dialect==="sqlite")return getSqliteLiveFKs(db);if(dialect==="mysql")return getMysqlLiveFKs(db);if(dialect==="postgres")return getPostgresLiveFKs(db);return[]}export async function auditForeignKeys(){const declared=await getDeclaredFKs(),live=await getLiveFKs(),liveKeys=new Set(live.map((fk)=>`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`)),
|
|
1
|
+
import{existsSync}from"node:fs";import{dirname}from"node:path";import{plural,snakeCase}from"@stacksjs/strings";import{path}from"@stacksjs/path";import{globSync}from"@stacksjs/storage";import{dialectCapabilities,isKnownDialect,toSqlIntrospectionDialect}from"./dialect";export function safeGlob(pattern){const metaIdx=pattern.search(/[*?[]/),root=metaIdx===-1?dirname(pattern):dirname(pattern.slice(0,metaIdx));if(!existsSync(root))return[];try{return globSync(pattern,{absolute:!0})}catch{return[]}}function modelTable(model){return model.table||plural(snakeCase(model.name||""))}function modelNameForColumn(column){return column.replace(/_id$/,"").split("_").map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join("")}export function getDeclaredFKsFromModels(models){const meta=new Map(models.map((model)=>[String(model.name??""),{primaryKey:String(model.primaryKey??"id"),table:modelTable(model)}])),declared=[],seen=new Set,push=(fk)=>{const key=`${fk.fromTable}.${fk.fromColumn}\u2192${fk.toTable}.${fk.toColumn}`.toLowerCase();if(seen.has(key))return;seen.add(key);declared.push(fk)};for(const model of models){const fromTable=modelTable(model),modelName=String(model.name??""),attributes=model.attributes??{},belongsTo=model.belongsTo,relations=Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.keys(belongsTo).map((name)=>({model:name})):[],declaresBelongsTo=relations.length>0;for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1)continue;if(attribute.foreignKey&&typeof attribute.foreignKey==="object"){push({fromTable,fromColumn,toTable:attribute.foreignKey.table,toColumn:attribute.foreignKey.column??"id",model:modelName});continue}const declaredTarget=relations.some((entry)=>{const relatedName=typeof entry==="string"?entry:String(entry.model??"");return(typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`)===fromColumn});if(declaresBelongsTo&&!declaredTarget)continue;const related=meta.get(modelNameForColumn(fromColumn));if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}for(const entry of relations){const relatedName=typeof entry==="string"?entry:String(entry.model??"");if(!relatedName)continue;const fromColumn=typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`;if(Object.keys(attributes).some((attribute)=>snakeCase(attribute)===fromColumn))continue;const related=meta.get(relatedName);if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}}return declared}export async function getDeclaredFKs(){const userModelFiles=safeGlob(path.userModelsPath("*.ts")),modelFiles=userModelFiles.length>0?userModelFiles:safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts")),models=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(model&&typeof model==="object")models.push(model)}return getDeclaredFKsFromModels(models)}export async function getLiveFKs(){const{db}=await import("./utils"),dialect=await currentDialect();if(dialect==="sqlite")return getSqliteLiveFKs(db);if(dialect==="mysql")return getMysqlLiveFKs(db);if(dialect==="postgres")return getPostgresLiveFKs(db);return[]}export async function getLiveTables(){const{db}=await import("./utils"),dialect=await currentDialect(),query=dialect==="sqlite"?"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'":dialect==="mysql"?"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()":dialect==="postgres"?"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()":"";if(!query)return new Set;try{const rows=await db.unsafe(query);return new Set((Array.isArray(rows)?rows:[]).map((row)=>row.name).filter((name)=>Boolean(name)).map((name)=>name.toLowerCase()))}catch{return new Set}}export async function auditForeignKeys(){const declared=await getDeclaredFKs(),live=await getLiveFKs(),liveKeys=new Set(live.map((fk)=>`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`)),liveTables=await getLiveTables();return{declared,live,...classifyDeclaredFKs(declared,liveKeys,liveTables)}}export function classifyDeclaredFKs(declared,liveKeys,liveTables){const canClassify=liveTables.size>0,missing=[],absentTable=[];for(const d of declared){const key=`${d.fromTable.toLowerCase()}.${d.fromColumn.toLowerCase()}\u2192${d.toTable.toLowerCase()}.${d.toColumn.toLowerCase()}`;if(liveKeys.has(key))continue;if(canClassify&&!liveTables.has(d.fromTable.toLowerCase()))absentTable.push(d);else missing.push(d)}return{missing,absentTable}}export function fkKey(fk){return`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`}export async function findFkOrphans(dialect){if((dialect??await currentDialect())!=="sqlite")return{supported:!1,total:0,orphans:[]};const{db}=await import("./utils"),rows=await db.unsafe("PRAGMA foreign_key_check").execute(),checkRows=Array.isArray(rows)?rows:[],fkListCache=new Map;async function fkListFor(table){if(fkListCache.has(table))return fkListCache.get(table);if(!/^[a-z_]\w*$/i.test(table)){fkListCache.set(table,[]);return[]}const list=await db.unsafe(`PRAGMA foreign_key_list("${table}")`).execute(),arr=Array.isArray(list)?list:[];fkListCache.set(table,arr);return arr}const grouped=new Map;for(const raw of checkRows){const r=raw,table=String(r.table??""),parent=String(r.parent??"");if(!table||!parent)continue;const fkid=Number(r.fkid??0);let column="";const fkList=await fkListFor(table);for(const fk of fkList){const f=fk;if(Number(f.id)===fkid&&f.from){column=String(f.from);break}}const key=`${table}\x00${parent}\x00${fkid}`;let entry=grouped.get(key);if(!entry){entry={table,column,parent,count:0,sampleRowids:[]};grouped.set(key,entry)}entry.count++;if(entry.sampleRowids.length<5&&typeof r.rowid==="number")entry.sampleRowids.push(r.rowid)}const orphans=[...grouped.values()];return{supported:!0,total:orphans.reduce((sum,o)=>sum+o.count,0),orphans}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(!isKnownDialect(driver)||!dialectCapabilities(driver).supportsForeignKeys)return"other";return toSqlIntrospectionDialect(driver)}async function getSqliteLiveFKs(db){const tables=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"),rows=Array.isArray(tables)?tables:[],fks=[];for(const row of rows){const fromTable=row.name;if(!fromTable)continue;if(!/^[a-z_][\w]*$/i.test(fromTable))continue;const fkRows=await db.unsafe(`PRAGMA foreign_key_list("${fromTable}")`);for(const fk of Array.isArray(fkRows)?fkRows:[]){const r=fk;if((r.seq??0)!==0)continue;if(!r.from||!r.to||!r.table)continue;fks.push({fromTable,fromColumn:r.from,toTable:r.table,toColumn:r.to})}}return fks}async function getMysqlLiveFKs(db){const rows=await db.unsafe(`
|
|
2
2
|
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
3
3
|
FROM information_schema.KEY_COLUMN_USAGE
|
|
4
4
|
WHERE TABLE_SCHEMA = DATABASE()
|
package/dist/index.d.ts
CHANGED
|
@@ -147,7 +147,7 @@ export * from './shadowed-models';
|
|
|
147
147
|
export * from './ensure-database';
|
|
148
148
|
// Foreign-key audit (stacksjs/stacks#1916) — compare declared
|
|
149
149
|
// `belongsTo` relationships against live FKs.
|
|
150
|
-
export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from './fk-audit';
|
|
150
|
+
export { auditForeignKeys, classifyDeclaredFKs, findFkOrphans, fkKey, getDeclaredFKs, getLiveFKs, getLiveTables } from './fk-audit';
|
|
151
151
|
// Schema drift audit — compare live column types against what the models
|
|
152
152
|
// declare. `migrate` only tracks which files have run, so a database built from
|
|
153
153
|
// a wrong migration set reports "up to date" forever while its columns differ.
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./model-sources";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,findFkOrphans,getDeclaredFKs,getLiveFKs}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
|
|
1
|
+
export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./model-sources";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.52",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -60,20 +60,22 @@
|
|
|
60
60
|
"prepublishOnly": "bun run build"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
+
"@stacksjs/faker": "^0.72.52",
|
|
64
|
+
"@stacksjs/query-builder": "^0.72.52",
|
|
63
65
|
"@stacksjs/ts-validation": "^0.5.5",
|
|
64
66
|
"bun-query-builder": "^0.2.53",
|
|
65
67
|
"dynamodb-tooling": "^0.3.2"
|
|
66
68
|
},
|
|
67
69
|
"devDependencies": {
|
|
68
|
-
"@stacksjs/cli": "0.72.
|
|
69
|
-
"@stacksjs/config": "0.72.
|
|
70
|
-
"@stacksjs/logging": "0.72.
|
|
71
|
-
"@stacksjs/router": "0.72.
|
|
70
|
+
"@stacksjs/cli": "0.72.52",
|
|
71
|
+
"@stacksjs/config": "0.72.52",
|
|
72
|
+
"@stacksjs/logging": "0.72.52",
|
|
73
|
+
"@stacksjs/router": "0.72.52",
|
|
72
74
|
"better-dx": "^0.2.24",
|
|
73
|
-
"@stacksjs/path": "0.72.
|
|
74
|
-
"@stacksjs/query-builder": "0.72.
|
|
75
|
-
"@stacksjs/storage": "0.72.
|
|
76
|
-
"@stacksjs/strings": "0.72.
|
|
77
|
-
"@stacksjs/utils": "0.72.
|
|
75
|
+
"@stacksjs/path": "0.72.52",
|
|
76
|
+
"@stacksjs/query-builder": "0.72.52",
|
|
77
|
+
"@stacksjs/storage": "0.72.52",
|
|
78
|
+
"@stacksjs/strings": "0.72.52",
|
|
79
|
+
"@stacksjs/utils": "0.72.52"
|
|
78
80
|
}
|
|
79
81
|
}
|