@stacksjs/database 0.72.49 → 0.72.51
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/datetime-columns.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/migration-ledger.js +1 -1
- package/dist/utc-defaults.d.ts +45 -0
- package/dist/utc-defaults.js +8 -0
- package/package.json +12 -10
package/dist/datetime-columns.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import process from"node:process";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{db}from"./utils";import{dialectCapabilities}from"./dialect";import{traitTableNames}from"./trait-tables";function getDbDriver(){return process.env.DB_CONNECTION||envVars.DB_CONNECTION||"sqlite"}export function frameworkDatetimeTables(){return[...traitTableNames(),"passkeys","password_resets","oauth_clients","oauth_access_tokens","oauth_refresh_tokens","two_factor_challenges","two_factor_pending_secrets","webauthn_challenges","roles","permissions","user_roles","user_permissions","role_permissions","notifications","notification_preferences","notification_deliveries"]}export async function findTimestampColumns(){const tables=frameworkDatetimeTables(),placeholders=tables.map(()=>"?").join(", ");return(await db.unsafe(`SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{db}from"./utils";import{dialectCapabilities}from"./dialect";import{traitTableNames}from"./trait-tables";function getDbDriver(){return process.env.DB_CONNECTION||envVars.DB_CONNECTION||"sqlite"}export function frameworkDatetimeTables(){return[...traitTableNames(),"passkeys","password_resets","email_verifications","oauth_clients","oauth_access_tokens","oauth_refresh_tokens","two_factor_challenges","two_factor_pending_secrets","webauthn_challenges","roles","permissions","user_roles","user_permissions","role_permissions","notifications","notification_preferences","notification_deliveries"]}export async function findTimestampColumns(){const tables=frameworkDatetimeTables(),placeholders=tables.map(()=>"?").join(", ");return(await db.unsafe(`SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
|
|
2
2
|
FROM information_schema.COLUMNS
|
|
3
3
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
4
4
|
AND TABLE_NAME IN (${placeholders})
|
package/dist/index.d.ts
CHANGED
|
@@ -93,6 +93,9 @@ export * from './drivers/index';
|
|
|
93
93
|
export * from './custom/index';
|
|
94
94
|
// Auth tables migration
|
|
95
95
|
export * from './auth-tables';
|
|
96
|
+
// Bringing an existing database's timestamp defaults up to UTC, which
|
|
97
|
+
// `CREATE TABLE IF NOT EXISTS` cannot do on its own
|
|
98
|
+
export * from './utc-defaults';
|
|
96
99
|
// uuid column guarantee for `useUuid` models (stacksjs/status#1 Phase 9)
|
|
97
100
|
export * from './uuid-columns';
|
|
98
101
|
// Schema-diff guards so trait-managed columns aren't proposed for dropping (stacksjs/stacks#2075)
|
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"./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,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";
|
package/dist/migration-ledger.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import process from"node:process";import{join}from"node:path";import{resolveMigrationDirectory}from"./migration-path";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)};for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase())}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":return schema.indexes.has(name);case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const candidates=(byLogical.get(logicalName(row))??[]).filter((f)=>!claimed.has(f));if(candidates.length===0){dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??await currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),entries=[];for(const file of files){let sql="";try{sql=readFileSync(join(dir,file),"utf8")}catch{continue}const effects=verifiableEffects(migrationEffects(sql),dialect),present=effects.filter((effect)=>effectPresent(effect,schema)),absent=effects.filter((effect)=>!effectPresent(effect,schema)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=entries.map((entry)=>entry.file),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){await run(`CREATE TABLE IF NOT EXISTS migrations (${dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT"}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${dialect==="postgres"?"TIMESTAMP":"DATETIME"} DEFAULT CURRENT_TIMESTAMP)`)}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run}),result={remapped:[],recorded:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const plan=audit.remapPlan;for(const row of plan.ambiguous)result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped)result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});const toRecord=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")result.skipped.push({file:entry.file,reason:`recorded, but ${entry.absent.length} effect(s) are missing from the schema`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file));if(options.dryRun){result.remapped=remapped;result.recorded=recordable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}return result}
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import process from"node:process";import{join}from"node:path";import{resolveMigrationDirectory}from"./migration-path";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)};for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase())}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":return schema.indexes.has(name);case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const candidates=(byLogical.get(logicalName(row))??[]).filter((f)=>!claimed.has(f));if(candidates.length===0){dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??await currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),entries=[];for(const file of files){let sql="";try{sql=readFileSync(join(dir,file),"utf8")}catch{continue}const effects=verifiableEffects(migrationEffects(sql),dialect),present=effects.filter((effect)=>effectPresent(effect,schema)),absent=effects.filter((effect)=>!effectPresent(effect,schema)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=entries.map((entry)=>entry.file),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){const id=dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT",timestamp=dialect==="postgres"?"TIMESTAMP":"DATETIME",utcNow=dialect==="postgres"?"(now() AT TIME ZONE 'utc')":dialect==="mysql"?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP";await run(`CREATE TABLE IF NOT EXISTS migrations (${id}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${timestamp} DEFAULT ${utcNow})`);if(dialect!=="sqlite")try{await run(`ALTER TABLE migrations ALTER COLUMN executed_at SET DEFAULT ${utcNow}`)}catch{}}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run}),result={remapped:[],recorded:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const plan=audit.remapPlan;for(const row of plan.ambiguous)result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped)result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});const toRecord=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")result.skipped.push({file:entry.file,reason:`recorded, but ${entry.absent.length} effect(s) are missing from the schema`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file));if(options.dryRun){result.remapped=remapped;result.recorded=recordable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}return result}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables this covers: everything `datetime-columns.ts` covers, and the
|
|
3
|
+
* migration ledger.
|
|
4
|
+
*
|
|
5
|
+
* One list rather than two, so a framework table added there is repaired here
|
|
6
|
+
* as well without anybody remembering. `migrations` is added because the ledger
|
|
7
|
+
* is created by its own runner rather than by a table-creator, and it had the
|
|
8
|
+
* same bare default.
|
|
9
|
+
*/
|
|
10
|
+
export declare function utcDefaultTables(): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Every framework column still defaulting to the session's clock.
|
|
13
|
+
*
|
|
14
|
+
* The schema predicate is the only part that differs between engines -
|
|
15
|
+
* `current_schema()` on Postgres, `DATABASE()` on MySQL - and the test for a
|
|
16
|
+
* bad default is the same on both: it mentions `current_timestamp` (or, on
|
|
17
|
+
* Postgres, the bare `now()`) and does not mention UTC. A default already
|
|
18
|
+
* pinned reads as `(now() AT TIME ZONE 'utc'::text)` or `utc_timestamp()`, and
|
|
19
|
+
* is left alone, as is a column with no default at all.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findSessionClockDefaults(driver?: string): Promise<SessionClockDefault[]>;
|
|
22
|
+
/**
|
|
23
|
+
* The `ALTER` that pins one column.
|
|
24
|
+
*
|
|
25
|
+
* Postgres and MySQL 8.0.13+ spell this identically; only the expression
|
|
26
|
+
* differs, which `sqlHelpers.utcNow` already handles.
|
|
27
|
+
*
|
|
28
|
+
* Identifiers come from `information_schema` and are re-validated here before
|
|
29
|
+
* being spliced into DDL, which cannot take a placeholder - the same rule
|
|
30
|
+
* `modifyToDatetimeSql` follows next door.
|
|
31
|
+
*/
|
|
32
|
+
export declare function utcDefaultSql(target: SessionClockDefault, driver?: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Pin every framework timestamp default that is still the session's clock.
|
|
35
|
+
*
|
|
36
|
+
* SQLite is skipped and needs nothing: its `CURRENT_TIMESTAMP` is already UTC,
|
|
37
|
+
* which is exactly what `utcNow` resolves to there, and it cannot alter a
|
|
38
|
+
* column default in any case.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ensureUtcTimestampDefaults(options?: { verbose?: boolean }): Promise<{ success: boolean, pinned: number, error?: string }>;
|
|
41
|
+
/** One column whose default is the session's clock rather than UTC. */
|
|
42
|
+
export declare interface SessionClockDefault {
|
|
43
|
+
table: string
|
|
44
|
+
column: string
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{db}from"./utils";import{dialectCapabilities}from"./dialect";import{frameworkDatetimeTables}from"./datetime-columns";import{sqlHelpers}from"./sql-helpers";function getDbDriver(){return process.env.DB_CONNECTION||envVars.DB_CONNECTION||"sqlite"}export function utcDefaultTables(){return[...frameworkDatetimeTables(),"migrations"]}export async function findSessionClockDefaults(driver=getDbDriver()){const schema=dialectCapabilities(driver).wire==="mysql"?"DATABASE()":"current_schema()",safe=/^[a-z_]\w*$/i,names=utcDefaultTables().filter((table)=>safe.test(table)).map((table)=>`'${table}'`).join(", ");return(await db.unsafe(`SELECT table_name, column_name
|
|
2
|
+
FROM information_schema.columns
|
|
3
|
+
WHERE table_schema = ${schema}
|
|
4
|
+
AND table_name IN (${names})
|
|
5
|
+
AND data_type LIKE 'timestamp%'
|
|
6
|
+
AND column_default IS NOT NULL
|
|
7
|
+
AND LOWER(column_default) NOT LIKE '%utc%'
|
|
8
|
+
AND (LOWER(column_default) LIKE '%current_timestamp%' OR LOWER(column_default) = 'now()')`).execute()).map((row)=>({table:String(row.table_name??row.TABLE_NAME),column:String(row.column_name??row.COLUMN_NAME)}))}export function utcDefaultSql(target,driver=getDbDriver()){const safe=/^[a-z_]\w*$/i;if(!safe.test(target.table)||!safe.test(target.column))throw Error(`[utc-defaults] Refusing to alter unsafe identifier: ${target.table}.${target.column}`);return`ALTER TABLE ${target.table} ALTER COLUMN ${target.column} SET DEFAULT ${sqlHelpers(driver).utcNow}`}export async function ensureUtcTimestampDefaults(options={}){const driver=getDbDriver();if(dialectCapabilities(driver).wire==="sqlite")return{success:!0,pinned:0};try{const targets=await findSessionClockDefaults(driver);if(targets.length===0){if(options.verbose)log.info("No timestamp defaults left to pin to UTC");return{success:!0,pinned:0}}for(const target of targets){if(options.verbose)log.info(`Pinning ${target.table}.${target.column} default to UTC...`);await db.unsafe(utcDefaultSql(target,driver)).execute()}log.debug(`[utc-defaults] Pinned ${targets.length} timestamp default(s) to UTC`);return{success:!0,pinned:targets.length}}catch(error){const message=error instanceof Error?error.message:String(error);log.debug(`[utc-defaults] Could not pin timestamp defaults: ${message}`);return{success:!1,pinned:0,error:message}}}
|
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.51",
|
|
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.51",
|
|
64
|
+
"@stacksjs/query-builder": "^0.72.51",
|
|
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.51",
|
|
71
|
+
"@stacksjs/config": "0.72.51",
|
|
72
|
+
"@stacksjs/logging": "0.72.51",
|
|
73
|
+
"@stacksjs/router": "0.72.51",
|
|
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.51",
|
|
76
|
+
"@stacksjs/query-builder": "0.72.51",
|
|
77
|
+
"@stacksjs/storage": "0.72.51",
|
|
78
|
+
"@stacksjs/strings": "0.72.51",
|
|
79
|
+
"@stacksjs/utils": "0.72.51"
|
|
78
80
|
}
|
|
79
81
|
}
|