@stacksjs/database 0.75.255 → 0.75.257

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.
@@ -27,7 +27,13 @@ export declare function getLikeableForeignKey(model: Model, tableName: string):
27
27
  export declare function prepareNumberColumnType(validator: NumberValidatorType, driver?: string): string;
28
28
  // Add new function for enum column types
29
29
  export declare function prepareEnumColumnType(validator: EnumValidatorType, driver?: string): string;
30
- export declare function mapFieldTypeToColumnType(validator: ValidationType, driver?: string): string;
30
+ /**
31
+ * @param declaredType the attribute's own `type`, which wins over the validator.
32
+ * `QueryLog` declares `type: 'text'` on its analysis columns, naming in a
33
+ * comment the very error Postgres raised without it, and nothing read it:
34
+ * the mapper saw only the validation rule (stacksjs/stacks#2965).
35
+ */
36
+ export declare function mapFieldTypeToColumnType(validator: ValidationType, driver?: string, declaredType?: string): string;
31
37
  export declare function checkIsRequired(rule: string): boolean;
32
38
  declare interface Range {
33
39
  min: number
@@ -1 +1 @@
1
- import{log}from"@stacksjs/logging";import{getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{plural,snakeCase}from"@stacksjs/strings";import{db}from"../utils";import{enumValidator,isBooleanValidator,isDatetimeValidator,isDateValidator,isFloatValidator,isNumberValidator,isStringValidator,isTimestampValidator,isUnixValidator}from"../validators";export async function deleteMigrationFiles(){const files=await fs.promises.readdir(path.userMigrationsPath());if(files.length){for(const file of files)if(file.endsWith(".ts")){const migrationPath=path.userMigrationsPath(`${file}`);if(fs.existsSync(migrationPath))await Bun.$`rm ${migrationPath}`}}}export async function deleteFrameworkModels(){const cacheDir=path.frameworkPath("cache/models");if(!fs.existsSync(cacheDir))return;const modelFiles=await fs.promises.readdir(cacheDir);if(modelFiles.length){for(const modelFile of modelFiles)if(modelFile.endsWith(".ts")){const modelPath=path.frameworkPath(`cache/models/${modelFile}`);if(fs.existsSync(modelPath))await Bun.$`rm ${modelPath}`}}}export async function getLastMigrationFields(modelName){const model=(await import(path.frameworkPath(`cache/models/${modelName}`))).default;let fields={};if(typeof model.attributes==="object")fields=model.attributes;else try{fields=JSON.parse(model.attributes||"{}")}catch{fields={}}return fields}export async function modelTableName(model){if(typeof model==="string")model=(await import(model)).default;return model.table??snakeCase(plural(model?.name||""))}export async function hasTableBeenMigrated(tableName){log.debug(`hasTableBeenMigrated for table: ${tableName}`);return(await getExecutedMigrations()).some((migration)=>migration.name.includes(tableName))}export async function hasMigrationBeenCreated(tableName){log.debug(`hasTableBeenMigrated for table: ${tableName}`);return globSync([path.userMigrationsPath("*.ts")],{absolute:!0}).some((path)=>path.includes(`create-${tableName}`))}export async function getExecutedMigrations(){try{return await db.selectFrom("migrations").select("name").execute()}catch(error){if(error?.message.includes("no such table: migrations")){console.warn("Migrations table does not exist, returning empty list.");return[]}return[]}}function findCharacterLength(validator){if("getRules"in validator){const maxLengthRule=validator.getRules().find((rule)=>rule.name==="max");return maxLengthRule?.params?.length||maxLengthRule?.params?.max||255}return 255}export function prepareTextColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";return`'varchar(${findCharacterLength(validator)})'`}export function prepareDateTimeColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const name=validator.name;if(name==="unix")return"'bigint'";return name||"date"}export function compareRanges(range1,range2){return range1.min===range2.min&&range1.max===range2.max}export async function checkPivotMigration(dynamicPart){return(await fs.promises.readdir(path.userMigrationsPath())).some((migrationFile)=>{const escapedDynamicPart=dynamicPart.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(-${escapedDynamicPart}-)`).test(migrationFile)})}export function pluckChanges(array1,array2){const removed=array1.filter((item)=>!array2.includes(item)),added=array2.filter((item)=>!array1.includes(item));if(removed.length===0&&added.length===0)return null;return{added,removed}}export function arrangeColumns(attributes){if(!attributes)return[];const entries=Object.entries(attributes);entries.sort(([_keyA,valueA],[_keyB,valueB])=>{const orderA=valueA.order??Number.POSITIVE_INFINITY,orderB=valueB.order??Number.POSITIVE_INFINITY;return orderA-orderB});return entries}export function isArrayEqual(arr1,arr2){if(!arr1||!arr2)return!1;if(arr1.length!==arr2.length)return!1;for(let i=0;i<arr1.length;i++)if(arr1[i]!==arr2[i])return!1;return!0}export function findDifferingKeys(obj1,obj2){const differingKeys=[];for(const key in obj1){const before=obj1[key],after=obj2[key];if(before&&after){const lastCharacterLength=findCharacterLength(before.validation.rule),latestCharacterLength=findCharacterLength(after.validation.rule);if(lastCharacterLength!==void 0&&latestCharacterLength!==void 0){if(lastCharacterLength!==latestCharacterLength)differingKeys.push({key,max:latestCharacterLength,min:latestCharacterLength})}}}return differingKeys}export async function fetchTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),tables=[];for(const modelPath of modelFiles){const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),upvoteTable=getUpvoteTableName(model,tableName);if(upvoteTable)tables.push(upvoteTable);tables.push(tableName)}return tables}export function getUpvoteTableName(model,tableName){const defaultTable=`${tableName}_likes`,traits=model.traits;if(!traits?.likeable)return;return typeof traits.likeable==="object"?traits.likeable.table||defaultTable:defaultTable}export function getLikeableForeignKey(model,tableName){const likeable=model.traits?.likeable;if(likeable&&typeof likeable==="object"&&!Array.isArray(likeable)&&likeable.foreignKey)return likeable.foreignKey;return`${tableName.replace(/s$/,"")}_id`}export function prepareNumberColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'integer'";if("getRules"in validator){const minRule=validator.getRules().find((rule)=>rule.name==="min"),maxRule=validator.getRules().find((rule)=>rule.name==="max"),min=minRule?.params?.min??-2147483648,max=maxRule?.params?.max??2147483647;return min>=-2147483648&&max<=2147483647?"'integer'":"'bigint'"}return"'integer'"}export function prepareEnumColumnType(validator,driver="mysql"){const allowedValues=validator.getAllowedValues();if(!allowedValues)throw Error("Enum rule found but no allowedValues defined");const enumStructure=allowedValues.map((value)=>`'${value}'`).join(", ");if(driver==="postgres")return"'varchar(255)'";if(driver==="sqlite")return"'text'";return`sql\`enum(${enumStructure})\``}export function mapFieldTypeToColumnType(validator,driver="mysql"){if(enumValidator(validator))return prepareEnumColumnType(validator,driver);if(isStringValidator(validator))return prepareTextColumnType(validator,driver);if(isNumberValidator(validator))return prepareNumberColumnType(validator,driver);if(isBooleanValidator(validator))return"'boolean'";if(isDateValidator(validator))return"'date'";if(isDatetimeValidator(validator))return driver==="postgres"?"'timestamp'":"'datetime'";if(isUnixValidator(validator))return"'bigint'";if(isTimestampValidator(validator))return"'timestamp'";if(isFloatValidator(validator))return"'float4'";if(["array","object"].includes(validator.name))return driver==="sqlite"?"'text'":"'json'";if(driver==="sqlite")return"'text'";return driver==="mysql"?"'varchar(255)'":"'text'"}export function checkIsRequired(rule){return rule.includes(".required()")}
1
+ import{log}from"@stacksjs/logging";import{getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{plural,snakeCase}from"@stacksjs/strings";import{db}from"../utils";import{enumValidator,isBooleanValidator,isDatetimeValidator,isDateValidator,isFloatValidator,isNumberValidator,isStringValidator,isTimestampValidator,isUnixValidator}from"../validators";export async function deleteMigrationFiles(){const files=await fs.promises.readdir(path.userMigrationsPath());if(files.length){for(const file of files)if(file.endsWith(".ts")){const migrationPath=path.userMigrationsPath(`${file}`);if(fs.existsSync(migrationPath))await Bun.$`rm ${migrationPath}`}}}export async function deleteFrameworkModels(){const cacheDir=path.frameworkPath("cache/models");if(!fs.existsSync(cacheDir))return;const modelFiles=await fs.promises.readdir(cacheDir);if(modelFiles.length){for(const modelFile of modelFiles)if(modelFile.endsWith(".ts")){const modelPath=path.frameworkPath(`cache/models/${modelFile}`);if(fs.existsSync(modelPath))await Bun.$`rm ${modelPath}`}}}export async function getLastMigrationFields(modelName){const model=(await import(path.frameworkPath(`cache/models/${modelName}`))).default;let fields={};if(typeof model.attributes==="object")fields=model.attributes;else try{fields=JSON.parse(model.attributes||"{}")}catch{fields={}}return fields}export async function modelTableName(model){if(typeof model==="string")model=(await import(model)).default;return model.table??snakeCase(plural(model?.name||""))}export async function hasTableBeenMigrated(tableName){log.debug(`hasTableBeenMigrated for table: ${tableName}`);return(await getExecutedMigrations()).some((migration)=>migration.name.includes(tableName))}export async function hasMigrationBeenCreated(tableName){log.debug(`hasTableBeenMigrated for table: ${tableName}`);return globSync([path.userMigrationsPath("*.ts")],{absolute:!0}).some((path)=>path.includes(`create-${tableName}`))}export async function getExecutedMigrations(){try{return await db.selectFrom("migrations").select("name").execute()}catch(error){if(error?.message.includes("no such table: migrations")){console.warn("Migrations table does not exist, returning empty list.");return[]}return[]}}function declaredCharacterLength(validator){if("getRules"in validator){const maxLengthRule=validator.getRules().find((rule)=>rule.name==="max"),declared=maxLengthRule?.params?.length??maxLengthRule?.params?.max;return typeof declared==="number"?declared:null}return null}function findCharacterLength(validator){return declaredCharacterLength(validator)??255}export function prepareTextColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const maxLength=declaredCharacterLength(validator);if(maxLength===null)return driver==="postgres"?"'text'":"'varchar(255)'";return`'varchar(${maxLength})'`}export function prepareDateTimeColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const name=validator.name;if(name==="unix")return"'bigint'";return name||"date"}export function compareRanges(range1,range2){return range1.min===range2.min&&range1.max===range2.max}export async function checkPivotMigration(dynamicPart){return(await fs.promises.readdir(path.userMigrationsPath())).some((migrationFile)=>{const escapedDynamicPart=dynamicPart.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(-${escapedDynamicPart}-)`).test(migrationFile)})}export function pluckChanges(array1,array2){const removed=array1.filter((item)=>!array2.includes(item)),added=array2.filter((item)=>!array1.includes(item));if(removed.length===0&&added.length===0)return null;return{added,removed}}export function arrangeColumns(attributes){if(!attributes)return[];const entries=Object.entries(attributes);entries.sort(([_keyA,valueA],[_keyB,valueB])=>{const orderA=valueA.order??Number.POSITIVE_INFINITY,orderB=valueB.order??Number.POSITIVE_INFINITY;return orderA-orderB});return entries}export function isArrayEqual(arr1,arr2){if(!arr1||!arr2)return!1;if(arr1.length!==arr2.length)return!1;for(let i=0;i<arr1.length;i++)if(arr1[i]!==arr2[i])return!1;return!0}export function findDifferingKeys(obj1,obj2){const differingKeys=[];for(const key in obj1){const before=obj1[key],after=obj2[key];if(before&&after){const lastCharacterLength=findCharacterLength(before.validation.rule),latestCharacterLength=findCharacterLength(after.validation.rule);if(lastCharacterLength!==void 0&&latestCharacterLength!==void 0){if(lastCharacterLength!==latestCharacterLength)differingKeys.push({key,max:latestCharacterLength,min:latestCharacterLength})}}}return differingKeys}export async function fetchTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),tables=[];for(const modelPath of modelFiles){const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),upvoteTable=getUpvoteTableName(model,tableName);if(upvoteTable)tables.push(upvoteTable);tables.push(tableName)}return tables}export function getUpvoteTableName(model,tableName){const defaultTable=`${tableName}_likes`,traits=model.traits;if(!traits?.likeable)return;return typeof traits.likeable==="object"?traits.likeable.table||defaultTable:defaultTable}export function getLikeableForeignKey(model,tableName){const likeable=model.traits?.likeable;if(likeable&&typeof likeable==="object"&&!Array.isArray(likeable)&&likeable.foreignKey)return likeable.foreignKey;return`${tableName.replace(/s$/,"")}_id`}export function prepareNumberColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'integer'";if("getRules"in validator){const minRule=validator.getRules().find((rule)=>rule.name==="min"),maxRule=validator.getRules().find((rule)=>rule.name==="max"),min=minRule?.params?.min??-2147483648,max=maxRule?.params?.max??2147483647;return min>=-2147483648&&max<=2147483647?"'integer'":"'bigint'"}return"'integer'"}export function prepareEnumColumnType(validator,driver="mysql"){const allowedValues=validator.getAllowedValues();if(!allowedValues)throw Error("Enum rule found but no allowedValues defined");const enumStructure=allowedValues.map((value)=>`'${value}'`).join(", ");if(driver==="postgres")return"'varchar(255)'";if(driver==="sqlite")return"'text'";return`sql\`enum(${enumStructure})\``}const DECLARED_COLUMN_TYPES=new Set(["text","json","jsonb","uuid"]);export function mapFieldTypeToColumnType(validator,driver="mysql",declaredType){const declared=declaredType?.trim().toLowerCase();if(declared&&DECLARED_COLUMN_TYPES.has(declared))return`'${declared}'`;if(enumValidator(validator))return prepareEnumColumnType(validator,driver);if(isStringValidator(validator))return prepareTextColumnType(validator,driver);if(isNumberValidator(validator))return prepareNumberColumnType(validator,driver);if(isBooleanValidator(validator))return"'boolean'";if(isDateValidator(validator))return"'date'";if(isDatetimeValidator(validator))return driver==="postgres"?"'timestamp'":"'datetime'";if(isUnixValidator(validator))return"'bigint'";if(isTimestampValidator(validator))return"'timestamp'";if(isFloatValidator(validator))return"'float4'";if(["array","object"].includes(validator.name))return driver==="sqlite"?"'text'":"'json'";if(driver==="sqlite")return"'text'";return driver==="mysql"?"'varchar(255)'":"'text'"}export function checkIsRequired(rule){return rule.includes(".required()")}
@@ -1,4 +1,4 @@
1
- import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{db}from"../utils";import{ok}from"@stacksjs/error-handling";import{getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,fetchTables,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,hasTableBeenMigrated,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables}from"./defaults/traits";export async function resetMysqlDatabase(){await dropMysqlTables();await deleteFrameworkModels();await deleteMigrationFiles();return ok("All tables dropped successfully!")}export async function dropMysqlTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),tables=await fetchTables();for(const table of tables){if(!/^[a-z_][\w]*$/i.test(table))throw Error(`[mysql] Refusing to drop table with unsafe name: ${table}`);await db.unsafe(`DROP TABLE IF EXISTS \`${table}\``).execute()}await dropCommonTables();for(const userModel of modelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables){if(!/^[a-z_][\w]*$/i.test(pivotTable.table))throw Error(`[mysql] Refusing to drop pivot table with unsafe name: ${pivotTable.table}`);await db.unsafe(`DROP TABLE IF EXISTS \`${pivotTable.table}\``).execute()}}}export async function generateMysqlMigration(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.info(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=await hasTableBeenMigrated(tableName);log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const useTimestamps=model?.traits?.useTimestamps??model?.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model?.traits?.useSoftDeletes??model?.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;if(useBillable&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));let migrationContent=`import type { Database } from '@stacksjs/database'
1
+ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{db}from"../utils";import{ok}from"@stacksjs/error-handling";import{getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,fetchTables,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,hasTableBeenMigrated,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables}from"./defaults/traits";export async function resetMysqlDatabase(){await dropMysqlTables();await deleteFrameworkModels();await deleteMigrationFiles();return ok("All tables dropped successfully!")}export async function dropMysqlTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),tables=await fetchTables();for(const table of tables){if(!/^[a-z_][\w]*$/i.test(table))throw Error(`[mysql] Refusing to drop table with unsafe name: ${table}`);await db.unsafe(`DROP TABLE IF EXISTS \`${table}\``).execute()}await dropCommonTables();for(const userModel of modelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables){if(!/^[a-z_][\w]*$/i.test(pivotTable.table))throw Error(`[mysql] Refusing to drop pivot table with unsafe name: ${pivotTable.table}`);await db.unsafe(`DROP TABLE IF EXISTS \`${pivotTable.table}\``).execute()}}}export async function generateMysqlMigration(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.info(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=await hasTableBeenMigrated(tableName);log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const useTimestamps=model?.traits?.useTimestamps??model?.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model?.traits?.useSoftDeletes??model?.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;if(useBillable&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));let migrationContent=`import type { Database } from '@stacksjs/database'
2
2
  `;migrationContent+=`import { sql } from '@stacksjs/database'
3
3
 
4
4
  `;migrationContent+=`export async function up(db: Database<any>) {
@@ -11,7 +11,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
11
11
  `;if(socials.includes("apple"))migrationContent+=` .addColumn('apple_id', 'varchar(255)')
12
12
  `;if(socials.includes("twitter"))migrationContent+=` .addColumn('twitter_id', 'varchar(255)')
13
13
  `;if(socials.includes("facebook"))migrationContent+=` .addColumn('facebook_id', 'varchar(255)')
14
- `}for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule);migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;const isRequired="isRequired"in(fieldOptions.validation?.rule??{})?(fieldOptions.validation?.rule).isRequired:!1;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default.replace(/'/g,"\\'")}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
14
+ `}for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule,"mysql",fieldOptions.type);migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;const isRequired="isRequired"in(fieldOptions.validation?.rule??{})?(fieldOptions.validation?.rule).isRequired:!1;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default.replace(/'/g,"\\'")}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
15
15
  `}if(twoFactorEnabled!==!1&&twoFactorEnabled)migrationContent+=` .addColumn('two_factor_secret', 'varchar(255)')
16
16
  `;if(useBillable)migrationContent+=` .addColumn('stripe_id', 'varchar(255)')
17
17
  `;if(usePasskey)migrationContent+=` .addColumn('public_passkey', 'varchar(255)')
@@ -1,4 +1,4 @@
1
- import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{db}from"../utils";import{ok}from"@stacksjs/error-handling";import{getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{plural,snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,hasTableBeenMigrated,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables,dropMigrationTables}from"./defaults/traits";export async function dropPostgresTables(){const tables=await fetchPostgresTables(),userModelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0});await dropMigrationTables();for(const table of tables)await db.unsafe(`DROP TABLE IF EXISTS "${table}" CASCADE`).execute();await dropCommonTables();for(const userModel of userModelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables)await db.unsafe(`DROP TABLE IF EXISTS "${pivotTable.table}" CASCADE`).execute()}}export async function resetPostgresDatabase(){await dropPostgresTables();await deleteFrameworkModels();await deleteMigrationFiles();await db.unsafe('CREATE TABLE IF NOT EXISTS "migrations" (id SERIAL PRIMARY KEY)').execute();await db.unsafe('CREATE TABLE IF NOT EXISTS "migration_locks" (id SERIAL PRIMARY KEY)').execute();await db.unsafe('CREATE TABLE IF NOT EXISTS "activities" (id SERIAL PRIMARY KEY)').execute();return ok("All tables dropped successfully!")}export async function generatePostgresMigration(modelPath){if((await fs.promises.readdir(path.userMigrationsPath(""))).length===0){log.debug("No migrations found in the database folder, clearing the model snapshot cache...");const cacheDir=path.frameworkPath("cache/models");if(fs.existsSync(cacheDir)){const modelFiles=await fs.promises.readdir(cacheDir);if(modelFiles.length){for(const file of modelFiles)if(file.endsWith(".ts"))await fs.promises.unlink(path.frameworkPath(`cache/models/${file}`))}}}const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.info(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=await hasTableBeenMigrated(tableName);log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if((model.traits?.billable||!1)&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const useTimestamps=model.traits?.useTimestamps??model.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model.traits?.useSoftDeletes??model.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;if(useBillable&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));let migrationContent=`import type { Database } from '@stacksjs/database'
1
+ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{db}from"../utils";import{ok}from"@stacksjs/error-handling";import{getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{plural,snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,hasTableBeenMigrated,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables,dropMigrationTables}from"./defaults/traits";export async function dropPostgresTables(){const tables=await fetchPostgresTables(),userModelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0});await dropMigrationTables();for(const table of tables)await db.unsafe(`DROP TABLE IF EXISTS "${table}" CASCADE`).execute();await dropCommonTables();for(const userModel of userModelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables)await db.unsafe(`DROP TABLE IF EXISTS "${pivotTable.table}" CASCADE`).execute()}}export async function resetPostgresDatabase(){await dropPostgresTables();await deleteFrameworkModels();await deleteMigrationFiles();await db.unsafe('CREATE TABLE IF NOT EXISTS "migrations" (id SERIAL PRIMARY KEY)').execute();await db.unsafe('CREATE TABLE IF NOT EXISTS "migration_locks" (id SERIAL PRIMARY KEY)').execute();await db.unsafe('CREATE TABLE IF NOT EXISTS "activities" (id SERIAL PRIMARY KEY)').execute();return ok("All tables dropped successfully!")}export async function generatePostgresMigration(modelPath){if((await fs.promises.readdir(path.userMigrationsPath(""))).length===0){log.debug("No migrations found in the database folder, clearing the model snapshot cache...");const cacheDir=path.frameworkPath("cache/models");if(fs.existsSync(cacheDir)){const modelFiles=await fs.promises.readdir(cacheDir);if(modelFiles.length){for(const file of modelFiles)if(file.endsWith(".ts"))await fs.promises.unlink(path.frameworkPath(`cache/models/${file}`))}}}const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.info(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=await hasTableBeenMigrated(tableName);log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if((model.traits?.billable||!1)&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const useTimestamps=model.traits?.useTimestamps??model.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model.traits?.useSoftDeletes??model.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;if(useBillable&&tableName==="users")await createTableMigration(path.frameworkPath("defaults/app/Models/Subscription.ts"));let migrationContent=`import type { Database } from '@stacksjs/database'
2
2
  `;migrationContent+=`import { sql } from '@stacksjs/database'
3
3
 
4
4
  `;migrationContent+=`export async function up(db: Database<any>) {
@@ -6,7 +6,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
6
6
  `;migrationContent+=` .createTable('${tableName}')
7
7
  `;migrationContent+=` .addColumn('id', 'serial', (col) => col.primaryKey())
8
8
  `;if(useUuid)migrationContent+=` .addColumn('uuid', 'uuid', (col) => col.defaultTo(sql.raw('gen_random_uuid()')))
9
- `;for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule,"postgres"),isRequired="isRequired"in(fieldOptions.validation?.rule??{})?(fieldOptions.validation?.rule).isRequired:!1;migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default.replace(/'/g,"\\'")}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
9
+ `;for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule,"postgres",fieldOptions.type),isRequired="isRequired"in(fieldOptions.validation?.rule??{})?(fieldOptions.validation?.rule).isRequired:!1;migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default.replace(/'/g,"\\'")}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
10
10
  `}if(useSocials){const socials=model.traits?.useSocials||[];if(socials.includes("google"))migrationContent+=` .addColumn('google_id', 'text')
11
11
  `;if(socials.includes("github"))migrationContent+=` .addColumn('github_id', 'text')
12
12
  `;if(socials.includes("apple"))migrationContent+=` .addColumn('apple_id', 'text')
@@ -75,7 +75,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
75
75
 
76
76
  `;migrationContent+=`export async function up(db: Database<any>) {
77
77
  `;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await db.schema.alterTable('${tableName}')
78
- `}for(const fieldName of fieldsToAdd){const options=currentFields[fieldName],columnType=mapFieldTypeToColumnType(options.validation?.rule,"postgres"),formattedFieldName=snakeCase(fieldName),isRequired="isRequired"in(options.validation?.rule??{})?(options.validation?.rule).isRequired:!1;migrationContent+=` .addColumn('${formattedFieldName}', '${columnType}'`;if(isRequired||options.unique||options.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(options.unique)migrationContent+=".unique()";if(options.default!==void 0)if(typeof options.default==="string")migrationContent+=`.defaultTo('${options.default}')`;else if(options.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${options.default})`;migrationContent+=""}migrationContent+=`)
78
+ `}for(const fieldName of fieldsToAdd){const options=currentFields[fieldName],columnType=mapFieldTypeToColumnType(options.validation?.rule,"postgres",options.type),formattedFieldName=snakeCase(fieldName),isRequired="isRequired"in(options.validation?.rule??{})?(options.validation?.rule).isRequired:!1;migrationContent+=` .addColumn('${formattedFieldName}', '${columnType}'`;if(isRequired||options.unique||options.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(options.unique)migrationContent+=".unique()";if(options.default!==void 0)if(typeof options.default==="string")migrationContent+=`.defaultTo('${options.default}')`;else if(options.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${options.default})`;migrationContent+=""}migrationContent+=`)
79
79
  `}for(const fieldName of fieldsToRemove)migrationContent+=` .dropColumn('${fieldName}')
80
80
  `;const fieldValidations=findDifferingKeys(lastFields,currentFields);for(const fieldValidation of fieldValidations){hasChanged=!0;const fieldNameFormatted=snakeCase(fieldValidation.key);migrationContent+=` .alterColumn('${fieldNameFormatted}', (col) => col.setDataType('text'))
81
81
  `}const lastFieldOrder=Object.values(lastFields).map((attr)=>attr.order),currentFieldOrder=Object.values(currentFields).map((attr)=>attr.order);if(!isArrayEqual(lastFieldOrder,currentFieldOrder))hasChanged=!0;if(hasChanged){migrationContent+=` .execute()
@@ -85,4 +85,4 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
85
85
  `}function generatePrimaryKeyIndexSQL(tableName){return` await db.schema.createIndex('${tableName}_id_index').on('${tableName}').column('id').execute()
86
86
  `}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await db.schema.createIndex('${tableName}_${foreignKey}_index').on('${tableName}').column('${foreignKey}').execute()
87
87
 
88
- `}export async function fetchPostgresTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),tables=[];for(const modelPath of modelFiles){const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath);tables.push(tableName)}return tables}
88
+ `}export async function fetchPostgresTables(){const modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),tables=[];for(const modelPath of modelFiles){const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath);tables.push(tableName)}return tables}
@@ -1,4 +1,4 @@
1
- import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{app}from"@stacksjs/config";import{db,SQLITE_BOOTSTRAP_PRAGMAS}from"../utils";import{ok}from"@stacksjs/error-handling";import{fetchOtherModelRelations,getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,fetchTables,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables}from"./defaults/traits";export async function resetSqliteDatabase(){await deleteFrameworkModels();await deleteMigrationFiles();await dropSqliteTables();return ok("All tables dropped successfully!")}export async function configureSqlitePragmas(){try{for(const pragma of SQLITE_BOOTSTRAP_PRAGMAS)await db.unsafe(pragma).execute()}catch(err){log.debug(`[sqlite] Failed to apply pragmas: ${err.message}`)}}export async function dropSqliteTables(){const userModelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),tables=await fetchTables(),safeName=/^[a-z_][\w]*$/i;await db.unsafe("PRAGMA foreign_keys = OFF").execute();try{for(const table of tables){if(!safeName.test(table))throw Error(`[sqlite] Refusing to drop table with unsafe name: ${table}`);await db.unsafe(`DROP TABLE IF EXISTS "${table}"`).execute()}await db.unsafe('DROP TABLE IF EXISTS "migrations"').execute();await dropCommonTables();for(const userModel of userModelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables){if(!safeName.test(pivotTable.table))throw Error(`[sqlite] Refusing to drop pivot table with unsafe name: ${pivotTable.table}`);await db.unsafe(`DROP TABLE IF EXISTS "${pivotTable.table}"`).execute()}}}finally{await db.unsafe("PRAGMA foreign_keys = ON").execute()}}export function fetchSqliteFile(){if(app.env==="testing")return fetchTestSqliteFile();return path.userDatabasePath("stacks.sqlite")}export function fetchTestSqliteFile(){return path.userDatabasePath("stacks_testing.sqlite")}export async function generateSqliteMigration(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=await getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.debug(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=!1;log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}export async function copyModelFiles(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=await getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);if(fs.existsSync(copiedModelPath)){log.debug(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}}await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),modelName=getModelName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const otherModelRelations=await fetchOtherModelRelations(modelName),useTimestamps=model?.traits?.useTimestamps??model?.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model?.traits?.useSoftDeletes??model?.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;let migrationContent=`import type { Database } from '@stacksjs/database'
1
+ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B[23m`}import{app}from"@stacksjs/config";import{db,SQLITE_BOOTSTRAP_PRAGMAS}from"../utils";import{ok}from"@stacksjs/error-handling";import{fetchOtherModelRelations,getModelName,getPivotTables,getTableName}from"@stacksjs/model-meta";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{snakeCase}from"@stacksjs/strings";import{arrangeColumns,checkPivotMigration,deleteFrameworkModels,deleteMigrationFiles,fetchTables,findDifferingKeys,getLastMigrationFields,getLikeableForeignKey,getUpvoteTableName,isArrayEqual,mapFieldTypeToColumnType,pluckChanges}from"./helpers";import{dropCommonTables}from"./defaults/traits";export async function resetSqliteDatabase(){await deleteFrameworkModels();await deleteMigrationFiles();await dropSqliteTables();return ok("All tables dropped successfully!")}export async function configureSqlitePragmas(){try{for(const pragma of SQLITE_BOOTSTRAP_PRAGMAS)await db.unsafe(pragma).execute()}catch(err){log.debug(`[sqlite] Failed to apply pragmas: ${err.message}`)}}export async function dropSqliteTables(){const userModelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),tables=await fetchTables(),safeName=/^[a-z_][\w]*$/i;await db.unsafe("PRAGMA foreign_keys = OFF").execute();try{for(const table of tables){if(!safeName.test(table))throw Error(`[sqlite] Refusing to drop table with unsafe name: ${table}`);await db.unsafe(`DROP TABLE IF EXISTS "${table}"`).execute()}await db.unsafe('DROP TABLE IF EXISTS "migrations"').execute();await dropCommonTables();for(const userModel of userModelFiles){const userModelPath=(await import(userModel)).default,pivotTables=await getPivotTables(userModelPath,userModel);for(const pivotTable of pivotTables){if(!safeName.test(pivotTable.table))throw Error(`[sqlite] Refusing to drop pivot table with unsafe name: ${pivotTable.table}`);await db.unsafe(`DROP TABLE IF EXISTS "${pivotTable.table}"`).execute()}}}finally{await db.unsafe("PRAGMA foreign_keys = ON").execute()}}export function fetchSqliteFile(){if(app.env==="testing")return fetchTestSqliteFile();return path.userDatabasePath("stacks.sqlite")}export function fetchTestSqliteFile(){return path.userDatabasePath("stacks_testing.sqlite")}export async function generateSqliteMigration(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=await getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);let haveFieldsChanged=!1;if(fs.existsSync(copiedModelPath)){log.debug(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}haveFieldsChanged=!0;log.debug(`Fields have changed for ${tableName}`)}else log.debug(`Fields have not been generated for ${tableName}`);await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;const hasBeenMigrated=!1;log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);if(haveFieldsChanged)await createAlterTableMigration(modelPath);else await createTableMigration(modelPath)}export async function copyModelFiles(modelPath){const model=(await import(modelPath)).default,fileName=path.basename(modelPath),tableName=await getTableName(model,modelPath),fieldsString=JSON.stringify(model.attributes,null,2),copiedModelPath=path.frameworkPath(`cache/models/${fileName}`);if(fs.existsSync(copiedModelPath)){log.debug(`Fields have already been generated for ${tableName}`);const previousFields=await getLastMigrationFields(fileName);if(JSON.stringify(previousFields,null,2)===fieldsString){log.debug(`Fields have not changed for ${tableName}`);return}}await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`}async function createTableMigration(modelPath){log.debug("createTableMigration modelPath:",modelPath);const model=(await import(modelPath)).default,tableName=getTableName(model,modelPath),modelName=getModelName(model,modelPath),twoFactorEnabled=model.traits?.useAuth&&typeof model.traits.useAuth!=="boolean"?model.traits.useAuth.useTwoFactor:!1;await createPivotTableMigration(model,modelPath);const otherModelRelations=await fetchOtherModelRelations(modelName),useTimestamps=model?.traits?.useTimestamps??model?.traits?.timestampable??!0,useSocials=model?.traits?.useSocials&&Array.isArray(model.traits.useSocials)&&model.traits.useSocials.length>0,useLikeable=Array.isArray(model?.traits?.likeable)?model.traits.likeable.length>0:Boolean(model?.traits?.likeable),useSoftDeletes=model?.traits?.useSoftDeletes??model?.traits?.softDeletable??!1,usePasskey=(typeof model.traits?.useAuth==="object"&&model.traits.useAuth.usePasskey)??!1,useBillable=model.traits?.billable||!1,useUuid=model.traits?.useUuid||!1;let migrationContent=`import type { Database } from '@stacksjs/database'
2
2
  `;migrationContent+=`import { sql } from '@stacksjs/database'
3
3
 
4
4
  `;migrationContent+=`export async function up(db: Database<any>) {
@@ -11,7 +11,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
11
11
  `;if(socials.includes("apple"))migrationContent+=` .addColumn('apple_id', 'text')
12
12
  `;if(socials.includes("twitter"))migrationContent+=` .addColumn('twitter_id', 'text')
13
13
  `;if(socials.includes("facebook"))migrationContent+=` .addColumn('facebook_id', 'text')
14
- `}for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule,"sqlite");migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;const isRequired=fieldOptions.validation?.rule.isRequired;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
14
+ `}for(const[fieldName,options]of arrangeColumns(model.attributes)){const fieldOptions=options,fieldNameFormatted=snakeCase(fieldName),columnType=mapFieldTypeToColumnType(fieldOptions.validation?.rule,"sqlite",fieldOptions.type);migrationContent+=` .addColumn('${fieldNameFormatted}', ${columnType}`;const isRequired=fieldOptions.validation?.rule.isRequired;if(isRequired||fieldOptions.unique||fieldOptions.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(fieldOptions.unique)migrationContent+=".unique()";if(fieldOptions.default!==void 0)if(typeof fieldOptions.default==="string")migrationContent+=`.defaultTo('${fieldOptions.default}')`;else if(fieldOptions.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${fieldOptions.default})`;migrationContent+=""}migrationContent+=`)
15
15
  `}if(twoFactorEnabled!==!1&&twoFactorEnabled)migrationContent+=` .addColumn('two_factor_secret', 'text')
16
16
  `;if(useBillable)migrationContent+=` .addColumn('stripe_id', 'text')
17
17
  `;if(useSoftDeletes)migrationContent+=` .addColumn('deleted_at', 'timestamp')
@@ -60,7 +60,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
60
60
  MODIFY COLUMN ${fieldNameFormatted} TEXT
61
61
  \`.execute(db)
62
62
 
63
- `}for(const fieldName of fieldsToAdd){const options=currentFields[fieldName],columnType=mapFieldTypeToColumnType(options.validation?.rule,"sqlite"),formattedFieldName=snakeCase(fieldName),isRequired=options.validation?.rule.isRequired;migrationContent+=` .addColumn('${formattedFieldName}', ${columnType}`;if(isRequired||options.unique||options.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(options.unique)migrationContent+=".unique()";if(options.default!==void 0)if(typeof options.default==="string")migrationContent+=`.defaultTo('${options.default}')`;else if(options.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${options.default})`;migrationContent+=""}migrationContent+=`)
63
+ `}for(const fieldName of fieldsToAdd){const options=currentFields[fieldName],columnType=mapFieldTypeToColumnType(options.validation?.rule,"sqlite",options.type),formattedFieldName=snakeCase(fieldName),isRequired=options.validation?.rule.isRequired;migrationContent+=` .addColumn('${formattedFieldName}', ${columnType}`;if(isRequired||options.unique||options.default!==void 0){migrationContent+=", col => col";if(isRequired)migrationContent+=".notNull()";if(options.unique)migrationContent+=".unique()";if(options.default!==void 0)if(typeof options.default==="string")migrationContent+=`.defaultTo('${options.default}')`;else if(options.default===null)migrationContent+=".defaultTo(null)";else migrationContent+=`.defaultTo(${options.default})`;migrationContent+=""}migrationContent+=`)
64
64
 
65
65
  `}for(const fieldName of fieldsToRemove)migrationContent+=` .dropColumn('${fieldName}')
66
66
  `;if(fieldsToAdd.length||fieldsToRemove.length)migrationContent+=` .execute();
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,resolvedDatabaseDriver,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,columns=new Set(Object.keys(attributes).map((name)=>snakeCase(name))),polymorphic=(column)=>columns.has(`${column.slice(0,-3)}_type`);for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1||polymorphic(fromColumn))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)||polymorphic(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=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=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??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}}function currentDialect(){const driver=resolvedDatabaseDriver().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(`
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,resolvedDatabaseDriver,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,columns=new Set(Object.keys(attributes).map((name)=>snakeCase(name))),polymorphic=(column)=>columns.has(`${column.slice(0,-3)}_type`);for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1||polymorphic(fromColumn))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)||polymorphic(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.defaultsPath("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=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=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??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}}function currentDialect(){const driver=resolvedDatabaseDriver().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()
@@ -19,6 +19,8 @@ export declare function classifyMigrationSql(sql: string, file: string): Dialect
19
19
  export declare function auditMigrationCorpus(options: {
20
20
  dir: string
21
21
  target: MigrationDialect
22
+
23
+ only?: readonly string[]
22
24
  }): MigrationCorpusAudit;
23
25
  /**
24
26
  * The user-facing explanation. Long on purpose: this replaces a raw
@@ -1,4 +1,4 @@
1
1
  import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";const MARKERS=[{dialect:"sqlite",pattern:/\bAUTOINCREMENT\b/i,label:"AUTOINCREMENT"},{dialect:"sqlite",pattern:/\bWITHOUT\s+ROWID\b/i,label:"WITHOUT ROWID"},{dialect:"sqlite",pattern:/\bPRAGMA\b/i,label:"PRAGMA"},{dialect:"mysql",pattern:/\bAUTO_INCREMENT\b/i,label:"AUTO_INCREMENT"},{dialect:"mysql",pattern:/\bENGINE\s*=/i,label:"ENGINE="},{dialect:"postgres",pattern:/\bBIGSERIAL\b/i,label:"BIGSERIAL"},{dialect:"postgres",pattern:/\bSERIAL\b/i,label:"SERIAL"},{dialect:"postgres",pattern:/\bCREATE\s+TYPE\b/i,label:"CREATE TYPE"},{dialect:"postgres",pattern:/\bGENERATED\s+(?:ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY\b/i,label:"GENERATED AS IDENTITY"}];export function stripSqlNoise(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}const quote=rest[0];if(quote==='"'||quote==="'"||quote==="`"){let j=1;while(j<rest.length&&rest[j]!==quote)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}export function classifyMigrationSql(sql,file){const lines=stripSqlNoise(sql).split(`
2
2
  `),rawLines=sql.split(`
3
- `),found=[];for(let index=0;index<lines.length;index++)for(const{dialect,pattern,label}of MARKERS)if(pattern.test(lines[index]??""))found.push({dialect,marker:label,file,line:index+1,snippet:(rawLines[index]??"").trim().slice(0,120)});return found}export function auditMigrationCorpus(options){const{dir,target}=options;if(!existsSync(dir))return{total:0,inferred:null,incompatible:[],empty:!0};let files;try{files=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{return{total:0,inferred:null,incompatible:[],empty:!0}}const all=[];for(const file of files){let sql;try{sql=readFileSync(join(dir,file),"utf8")}catch{continue}all.push(...classifyMigrationSql(sql,file))}const dialects=new Set(all.map((m)=>m.dialect));return{total:files.length,inferred:dialects.size===1?[...dialects][0]??null:null,incompatible:all.filter((m)=>m.dialect!==target),empty:files.length===0}}export const DIALECT_OVERRIDE_ENV="STACKS_ALLOW_DIALECT_MISMATCH";export function formatMigrationDialectError(audit,target,dir){const affected=new Set(audit.incompatible.map((m)=>m.file)),sample=audit.incompatible.slice(0,3),inferred=audit.inferred?`${audit.inferred}-flavoured`:"written for a different database";return[`The migration files in ${dir} are ${inferred} and cannot run on ${target}.`,`${affected.size} of ${audit.total} files use SQL that ${target} does not accept. For example:`,...sample.map((m)=>` ${m.file}:${m.line} ${m.marker}`),"","Nothing was migrated, so the database is unchanged.","","Stacks ships one set of migration files, and they are emitted for a single database.","Regenerate them from your models:",` ./buddy migrate:regenerate ${target}`,"or point DB_CONNECTION back at the database they were written for.","",`If you know this corpus is correct, re-run with ${DIALECT_OVERRIDE_ENV}=1 to proceed anyway.`].join(`
3
+ `),found=[];for(let index=0;index<lines.length;index++)for(const{dialect,pattern,label}of MARKERS)if(pattern.test(lines[index]??""))found.push({dialect,marker:label,file,line:index+1,snippet:(rawLines[index]??"").trim().slice(0,120)});return found}export function auditMigrationCorpus(options){const{dir,target}=options;if(!existsSync(dir))return{total:0,inferred:null,incompatible:[],empty:!0};let files;try{files=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{return{total:0,inferred:null,incompatible:[],empty:!0}}if(options.only){const judged=new Set(options.only);files=files.filter((file)=>judged.has(file))}const all=[];for(const file of files){let sql;try{sql=readFileSync(join(dir,file),"utf8")}catch{continue}all.push(...classifyMigrationSql(sql,file))}const dialects=new Set(all.map((m)=>m.dialect));return{total:files.length,inferred:dialects.size===1?[...dialects][0]??null:null,incompatible:all.filter((m)=>m.dialect!==target),empty:files.length===0}}export const DIALECT_OVERRIDE_ENV="STACKS_ALLOW_DIALECT_MISMATCH";export function formatMigrationDialectError(audit,target,dir){const affected=new Set(audit.incompatible.map((m)=>m.file)),sample=audit.incompatible.slice(0,3),inferred=audit.inferred?`${audit.inferred}-flavoured`:"written for a different database";return[`The migration files in ${dir} are ${inferred} and cannot run on ${target}.`,`${affected.size} of ${audit.total} files use SQL that ${target} does not accept. For example:`,...sample.map((m)=>` ${m.file}:${m.line} ${m.marker}`),"","Nothing was migrated, so the database is unchanged.","","Stacks ships one set of migration files, and they are emitted for a single database.","Regenerate them from your models:",` ./buddy migrate:regenerate ${target}`,"or point DB_CONNECTION back at the database they were written for.","",`If you know this corpus is correct, re-run with ${DIALECT_OVERRIDE_ENV}=1 to proceed anyway.`].join(`
4
4
  `)}
@@ -39,6 +39,14 @@ export declare function readPostgresAlignSchema(run: AlignSqlRunner): Promise<Li
39
39
  * multi-statement string, which Postgres executes as a single implicit
40
40
  * transaction - all of it lands, or none of it does.
41
41
  */
42
+ /**
43
+ * Run each action's pre-flight check and sort the survivors from the refused.
44
+ *
45
+ * Separate from `alignSchema` so it can be exercised without a database: the
46
+ * decisions here are about what a probe's answer means, including the case
47
+ * where there is no answer to have.
48
+ */
49
+ export declare function evaluateAlignChecks(plan: AlignmentPlan, run: AlignSqlRunner): Promise<AlignAction[]>;
42
50
  export declare function alignSchema(options?: {
43
51
  apply?: boolean
44
52
  widenIntegers?: boolean
@@ -111,7 +119,7 @@ export declare interface AlignAction {
111
119
  name: string
112
120
  detail: string
113
121
  sql: string
114
- check?: { kind: 'duplicates', sql: string } | { kind: 'orphans', sql: string }
122
+ check?: ({ kind: 'duplicates', sql: string } | { kind: 'orphans', sql: string }) & { dependsOn?: string[] }
115
123
  }
116
124
  export declare interface AlignReport {
117
125
  kind: AlignReportKind
@@ -132,7 +140,7 @@ export declare interface AlignResult {
132
140
  }
133
141
  /** Runs one SQL string and returns its rows (see `migration-ledger.ts`). */
134
142
  export type AlignSqlRunner = (sql: string) => Promise<any[]>;
135
- export type AlignActionKind = 'add-enum-value' | 'add-column' | 'widen-column' | 'rename-index' | 'create-index' | 'add-foreign-key';
143
+ export type AlignActionKind = 'create-enum-type' | 'add-enum-value' | 'add-column' | 'widen-column' | 'rename-index' | 'create-index' | 'add-foreign-key';
136
144
  export type AlignReportKind = | 'missing-table'
137
145
  | 'missing-column'
138
146
  | 'extra-column'
@@ -144,4 +152,5 @@ export type AlignReportKind = | 'missing-table'
144
152
  | 'duplicate-index'
145
153
  | 'name-conflict'
146
154
  | 'enum'
147
- | 'foreign-key';
155
+ | 'foreign-key'
156
+ | 'unchecked';
@@ -1,4 +1,4 @@
1
- import{comparableIndexKey,indexShape,postgresIndexStatement}from"./index-shape";import{sqlStatementsOf}from"./sql-statements";const quote=(name)=>`"${name.replace(/"/g,'""')}"`,sqlString=(value)=>`'${value.replace(/'/g,"''")}'`;export function postgresIdentifier(name,max=63){if(name.length<=max)return name;let hash=2166136261;for(let i=0;i<name.length;i++){hash^=name.charCodeAt(i);hash=Math.imul(hash,16777619)>>>0}const suffix=`_${hash.toString(36).padStart(7,"0").slice(-7)}`;return name.slice(0,max-suffix.length)+suffix}function topLevelParts(body){const parts=[];let depth=0,current="",quoted=null;for(const char of body){if(quoted){current+=char;if(char===quoted)quoted=null;continue}if(char==="'"||char==='"'){quoted=char;current+=char;continue}if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);return parts.map((part)=>part.trim()).filter(Boolean)}export function columnDefinitionsOf(createStatement){const definitions=new Map,body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return definitions;const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const part of topLevelParts(body)){if(constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}return definitions}function indexColumnsAndPredicate(statement){const open=/\bON\s+\S+\s*\(/i.exec(statement);if(!open)return;const start=open.index+open[0].length;let depth=1,end=start;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;return[statement,statement.slice(start,end-1),statement.slice(end).trim()]}export function desiredSchemaFrom(plan,statements){const creates=new Map,indexes=[],enums=new Map,visit=(statement)=>{const table=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i.exec(statement)?.[1];if(table){creates.set(table.toLowerCase(),statement);return}const enumType=/^\s*CREATE\s+TYPE\s+["]?([\w$]+)["]?\s+AS\s+ENUM\s*\(([\s\S]*)\)\s*;?\s*$/i.exec(statement);if(enumType?.[1]){const labels=[...(enumType[2]??"").matchAll(/'((?:[^']|'')*)'/g)].map((m)=>(m[1]??"").replace(/''/g,"'"));enums.set(enumType[1].toLowerCase(),labels);return}const guarded=/^\s*DO\s+\$(\w*)\$\s*BEGIN\s+([\s\S]*?);\s*EXCEPTION\b/i.exec(statement);if(guarded?.[2]){visit(guarded[2]);return}const shape=indexShape(statement),key=comparableIndexKey(statement);if(shape&&key){const head=indexColumnsAndPredicate(statement),parts=topLevelParts(head?.[1]??""),plain=parts.every((part)=>/^["`]?\w+["`]?$/.test(part));indexes.push({name:shape.name,table:shape.table,unique:/^\s*CREATE\s+UNIQUE\b/i.test(statement),...plain?{columns:parts.map((part)=>part.replace(/["`]/g,"").toLowerCase())}:{},key,where:(head?.[2]??"").replace(/;\s*$/,"").trim(),statement:statement.trim().replace(/;\s*$/,"")})}};for(const statement of statements)for(const part of sqlStatementsOf(statement.trim().endsWith(";")?statement:`${statement};`))visit(part);const tables=new Map;for(const table of plan.tables){const name=table.table.toLowerCase(),definitions=creates.has(name)?columnDefinitionsOf(creates.get(name)):new Map;tables.set(name,table.columns.map((column)=>({name:column.name.toLowerCase(),type:column.type,...column.maxLength!==void 0?{maxLength:column.maxLength}:{},isPrimaryKey:column.isPrimaryKey,isNullable:column.isNullable,hasDefault:column.hasDefault,...column.defaultValue!==void 0?{defaultValue:column.defaultValue}:{},...definitions.get(column.name.toLowerCase())?{definition:definitions.get(column.name.toLowerCase())}:{},...column.references?{references:{...column.references}}:{}})))}return{tables,indexes,enums}}const INTEGER_RANK={smallint:1,integer:2,bigint:3};function declaredIntegerRank(type){if(type==="integer")return INTEGER_RANK.integer;if(type==="bigint")return INTEGER_RANK.bigint;return}function liveIntegerRank(column){return INTEGER_RANK[column.dataType]}export function comparableDefault(value){if(value===void 0||value===null)return;let text=String(value).trim();if(/^nextval\(/i.test(text))return;if(/\bnow\(\)|\b(?:current_timestamp|utc_timestamp|localtimestamp)\b/i.test(text))return"clock";text=text.replace(/::\s*(?:"[^"]+"|[a-z_]\w*)(?:\s+varying|\s+precision|\s+with(?:out)?\s+time\s+zone)?(?:\[\])?/gi,"");while(/^\(.*\)$/s.test(text))text=text.slice(1,-1).trim();text=text.replace(/^'(.*)'$/s,"$1").replace(/''/g,"'");return text.toLowerCase()}function declaredDefault(column){if(!column.hasDefault||column.defaultValue===void 0||column.defaultValue===null)return;const value=column.defaultValue;if(value instanceof Date)return comparableDefault(value.toISOString());return comparableDefault(typeof value==="string"?value:String(value))}function foreignKeyAction(action){return(action??"no action").toUpperCase()}const PG_ACTION={a:"NO ACTION",r:"RESTRICT",c:"CASCADE",n:"SET NULL",d:"SET DEFAULT"};export function planSchemaAlignment(desired,live,options={}){const actions=[],reports=[],added=new Set;for(const[type,labels]of desired.enums){const existing=live.enums.get(type);if(!existing)continue;for(const label of labels.filter((label)=>!existing.includes(label)))actions.push({kind:"add-enum-value",table:type,name:label,detail:`add '${label}' to enum type ${type}`,sql:`ALTER TYPE ${quote(type)} ADD VALUE IF NOT EXISTS ${sqlString(label)}`});const extra=existing.filter((label)=>!labels.includes(label));if(extra.length>0)reports.push({kind:"enum",table:type,detail:`enum type ${type} has label(s) the model does not declare: ${extra.join(", ")}`})}for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns){reports.push({kind:"missing-table",table,detail:`table ${table} does not exist - \`buddy migrate\` creates it`});continue}const declared=new Set(columns.map((column)=>column.name));for(const name of liveColumns.keys())if(!declared.has(name))reports.push({kind:"extra-column",table,name,detail:`${table}.${name} exists but no model declares it`});for(const column of columns){const current=liveColumns.get(column.name);if(!current){if(!column.definition||/\bPRIMARY\s+KEY\b|\bUNIQUE\b/i.test(column.definition)){reports.push({kind:"missing-column",table,name:column.name,detail:`${table}.${column.name} is missing and cannot be added in place`});continue}const strict=/\bNOT\s+NULL\b/i.test(column.definition)&&!/\bDEFAULT\b/i.test(column.definition),populated=live.populated?.has(table)??!0,definition=strict&&populated?column.definition.replace(/\s*\bNOT\s+NULL\b/i,""):column.definition;actions.push({kind:"add-column",table,name:column.name,detail:`add ${table}.${column.name}${definition!==column.definition?" (nullable: the table has rows and the column has no default)":""}`,sql:`ALTER TABLE ${quote(table)} ADD COLUMN IF NOT EXISTS ${definition}`});added.add(`${table}.${column.name}`);if(definition!==column.definition)reports.push({kind:"nullability",table,name:column.name,detail:`${table}.${column.name} is added nullable; backfill it, then make it NOT NULL`});continue}if(column.isPrimaryKey)continue;const liveSql=current.maxLength?`${current.dataType}(${current.maxLength})`:current.dataType;if(column.type==="string"||column.type==="text"){const wanted=column.type==="text"?void 0:column.maxLength??255,isVarchar=current.dataType==="character varying",isText=current.dataType==="text"||isVarchar&&!current.maxLength;if(isVarchar&&current.maxLength&&(wanted===void 0||wanted>current.maxLength)){const to=wanted===void 0?"text":`varchar(${wanted})`;actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from varchar(${current.maxLength}) to ${to}`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE ${to}`})}else if(isVarchar&&current.maxLength&&wanted!==void 0&&wanted<current.maxLength)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is varchar(${current.maxLength}); the model declares varchar(${wanted}) - narrowing could truncate data`});else if(isText&&wanted!==void 0)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is unbounded text; the model declares varchar(${wanted}) - narrowing could truncate data`});else if(!isVarchar&&!isText&&current.dataType!=="USER-DEFINED")reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type==="text"?"text":`varchar(${wanted})`}`})}else if(declaredIntegerRank(column.type)!==void 0&&liveIntegerRank(current)!==void 0){const want=declaredIntegerRank(column.type),have=liveIntegerRank(current);if(want>have)if(options.widenIntegers)actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from ${current.dataType} to bigint (rewrites the table)`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE bigint`});else reports.push({kind:"optional-widening",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares bigint - pass --widen-integers to convert it (rewrites the table)`});else if(want<have)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares ${column.type} - narrowing could overflow`})}else if(column.type!=="enum"&&options.typeSatisfies&&!options.typeSatisfies(column.type,current.dataType))reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type}`});const wantNullable=column.isNullable;if(wantNullable!==current.nullable)reports.push({kind:"nullability",table,name:column.name,detail:wantNullable?`${table}.${column.name} is NOT NULL; the model allows NULL`:`${table}.${column.name} allows NULL; the model declares NOT NULL`});const want=declaredDefault(column),have=comparableDefault(current.default);if((want??"")!==(have??"")&&!(want===void 0&&have==="null"))reports.push({kind:"default",table,name:column.name,detail:`${table}.${column.name} defaults to ${current.default??"nothing"}; the model declares ${want===void 0?"no default":String(column.defaultValue)}`})}}const desiredNames=new Set(desired.indexes.map((index)=>index.name)),liveByName=new Map(live.indexes.map((index)=>[index.name,index])),claimed=new Set,renames=[],creates=[];for(const index of desired.indexes){const liveColumns=live.tables.get(index.table);if(!liveColumns)continue;const existing=liveByName.get(index.name);if(existing){claimed.add(existing.name);if(existing.table!==index.table||existing.key&&existing.key!==index.key)reports.push({kind:"index-definition",table:index.table,name:index.name,detail:`index ${index.name} exists but differs from the model (${existing.definition}) - drop and recreate it by hand if the model is right`});continue}const twins=live.indexes.filter((candidate)=>candidate.table===index.table&&!candidate.primary&&candidate.key===index.key&&!desiredNames.has(candidate.name)&&!claimed.has(candidate.name));if(twins.length>0){const[twin,...extra]=[...twins].sort((a,b)=>Number(b.name.includes(index.name))-Number(a.name.includes(index.name)));claimed.add(twin.name);if(live.names.has(index.name))reports.push({kind:"name-conflict",table:index.table,name:index.name,detail:`cannot rename ${twin.name} to ${index.name}: that name is taken by another object`});else renames.push({kind:"rename-index",table:index.table,name:index.name,detail:`rename index ${twin.name} to ${index.name}${twin.constraint?" (and the constraint it backs)":""}`,sql:`ALTER INDEX ${quote(twin.name)} RENAME TO ${quote(index.name)}`});for(const duplicate of extra){claimed.add(duplicate.name);reports.push({kind:"duplicate-index",table:index.table,name:duplicate.name,detail:`${duplicate.name} duplicates ${index.name} - drop it by hand once nothing depends on it`})}continue}const missingColumn=index.columns?.find((column)=>!liveColumns.has(column)&&!added.has(`${index.table}.${column}`));if(missingColumn){reports.push({kind:"missing-column",table:index.table,name:missingColumn,detail:`index ${index.name} needs ${index.table}.${missingColumn}, which cannot be added here`});continue}const statement=index.statement.replace(/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)/i,(_m,unique)=>`CREATE ${unique??""}INDEX IF NOT EXISTS `),action={kind:"create-index",table:index.table,name:index.name,detail:`create ${index.unique?"unique ":""}index ${index.name}`,sql:statement};if(index.unique&&index.columns){const columns=index.columns.map(quote).join(", "),where=[...index.columns.map((column)=>`${quote(column)} IS NOT NULL`),...index.where?[`(${index.where.replace(/^WHERE\s+/i,"")})`]:[]].join(" AND ");action.check={kind:"duplicates",sql:`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${quote(index.table)} WHERE ${where} GROUP BY ${columns} HAVING COUNT(*) > 1) AS duplicates`}}creates.push(action)}const foreignKeys=[];for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns)continue;for(const column of columns){const ref=column.references;if(!ref)continue;const refTable=ref.table.toLowerCase(),refColumn=ref.column.toLowerCase();if(added.has(`${table}.${column.name}`)||!liveColumns.has(column.name))continue;const present=live.foreignKeys.find((fk)=>fk.table===table&&fk.column===column.name&&fk.refTable===refTable&&fk.refColumn===refColumn);if(present){const wantDelete=foreignKeyAction(ref.onDelete),wantUpdate=foreignKeyAction(ref.onUpdate);if(present.onDelete!==wantDelete||present.onUpdate!==wantUpdate)reports.push({kind:"foreign-key",table,name:present.name,detail:`${present.name} is ON DELETE ${present.onDelete} ON UPDATE ${present.onUpdate}; the model declares ON DELETE ${wantDelete} ON UPDATE ${wantUpdate}`});continue}if(!live.tables.get(refTable)?.has(refColumn)){reports.push({kind:"foreign-key",table,name:column.name,detail:`${table}.${column.name} references ${refTable}.${refColumn}, which does not exist`});continue}const name=postgresIdentifier(ref.constraintNames?.[0]??`${table}_${column.name}_fkey`);if(live.names.has(name)){reports.push({kind:"name-conflict",table,name,detail:`cannot add the foreign key for ${table}.${column.name} as ${name}: that name is taken`});continue}const actionsSql=[ref.onDelete?` ON DELETE ${foreignKeyAction(ref.onDelete)}`:"",ref.onUpdate?` ON UPDATE ${foreignKeyAction(ref.onUpdate)}`:""].join("");foreignKeys.push({kind:"add-foreign-key",table,name,detail:`add foreign key ${table}.${column.name} -> ${refTable}.${refColumn}`,sql:`ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} FOREIGN KEY (${quote(column.name)}) REFERENCES ${quote(refTable)} (${quote(refColumn)})${actionsSql}`,check:{kind:"orphans",sql:`SELECT COUNT(*) AS n FROM ${quote(table)} AS child WHERE child.${quote(column.name)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quote(refTable)} AS parent WHERE parent.${quote(refColumn)} = child.${quote(column.name)})`}})}}return{actions:[...actions,...renames,...creates,...foreignKeys],reports,refused:[]}}function rows(result){return Array.isArray(result)?result:[]}function text(row,key){const value=row?.[key];return value===null||value===void 0?"":String(value)}export async function readPostgresAlignSchema(run){const tables=new Map;for(const row of rows(await run("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")))tables.set(text(row,"table_name").toLowerCase(),new Map);for(const row of rows(await run("SELECT table_name, column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema()"))){const columns=tables.get(text(row,"table_name").toLowerCase());if(!columns)continue;const length=Number(row.character_maximum_length);columns.set(text(row,"column_name").toLowerCase(),{name:text(row,"column_name").toLowerCase(),dataType:text(row,"data_type").toLowerCase()==="user-defined"?"USER-DEFINED":text(row,"data_type").toLowerCase(),udtName:text(row,"udt_name").toLowerCase(),...Number.isFinite(length)&&length>0?{maxLength:length}:{},nullable:text(row,"is_nullable").toUpperCase()==="YES",...row.column_default!==null&&row.column_default!==void 0?{default:String(row.column_default)}:{}})}const indexes=[];for(const row of rows(await run(`
1
+ import{comparableIndexKey,indexShape,postgresIndexStatement}from"./index-shape";import{sqlStatementsOf}from"./sql-statements";const quote=(name)=>`"${name.replace(/"/g,'""')}"`,sqlString=(value)=>`'${value.replace(/'/g,"''")}'`;export function postgresIdentifier(name,max=63){if(name.length<=max)return name;let hash=2166136261;for(let i=0;i<name.length;i++){hash^=name.charCodeAt(i);hash=Math.imul(hash,16777619)>>>0}const suffix=`_${hash.toString(36).padStart(7,"0").slice(-7)}`;return name.slice(0,max-suffix.length)+suffix}function topLevelParts(body){const parts=[];let depth=0,current="",quoted=null;for(const char of body){if(quoted){current+=char;if(char===quoted)quoted=null;continue}if(char==="'"||char==='"'){quoted=char;current+=char;continue}if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);return parts.map((part)=>part.trim()).filter(Boolean)}export function columnDefinitionsOf(createStatement){const definitions=new Map,body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return definitions;const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const part of topLevelParts(body)){if(constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}return definitions}function indexColumnsAndPredicate(statement){const open=/\bON\s+\S+\s*\(/i.exec(statement);if(!open)return;const start=open.index+open[0].length;let depth=1,end=start;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;return[statement,statement.slice(start,end-1),statement.slice(end).trim()]}export function desiredSchemaFrom(plan,statements){const creates=new Map,indexes=[],enums=new Map,visit=(statement)=>{const table=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i.exec(statement)?.[1];if(table){creates.set(table.toLowerCase(),statement);return}const enumType=/^\s*CREATE\s+TYPE\s+["]?([\w$]+)["]?\s+AS\s+ENUM\s*\(([\s\S]*)\)\s*;?\s*$/i.exec(statement);if(enumType?.[1]){const labels=[...(enumType[2]??"").matchAll(/'((?:[^']|'')*)'/g)].map((m)=>(m[1]??"").replace(/''/g,"'"));enums.set(enumType[1].toLowerCase(),labels);return}const guarded=/^\s*DO\s+\$(\w*)\$\s*BEGIN\s+([\s\S]*?);\s*EXCEPTION\b/i.exec(statement);if(guarded?.[2]){visit(guarded[2]);return}const shape=indexShape(statement),key=comparableIndexKey(statement);if(shape&&key){const head=indexColumnsAndPredicate(statement),parts=topLevelParts(head?.[1]??""),plain=parts.every((part)=>/^["`]?\w+["`]?$/.test(part));indexes.push({name:shape.name,table:shape.table,unique:/^\s*CREATE\s+UNIQUE\b/i.test(statement),...plain?{columns:parts.map((part)=>part.replace(/["`]/g,"").toLowerCase())}:{},key,where:(head?.[2]??"").replace(/;\s*$/,"").trim(),statement:statement.trim().replace(/;\s*$/,"")})}};for(const statement of statements)for(const part of sqlStatementsOf(statement.trim().endsWith(";")?statement:`${statement};`))visit(part);const tables=new Map;for(const table of plan.tables){const name=table.table.toLowerCase(),definitions=creates.has(name)?columnDefinitionsOf(creates.get(name)):new Map;tables.set(name,table.columns.map((column)=>({name:column.name.toLowerCase(),type:column.type,...column.maxLength!==void 0?{maxLength:column.maxLength}:{},isPrimaryKey:column.isPrimaryKey,isNullable:column.isNullable,hasDefault:column.hasDefault,...column.defaultValue!==void 0?{defaultValue:column.defaultValue}:{},...definitions.get(column.name.toLowerCase())?{definition:definitions.get(column.name.toLowerCase())}:{},...column.references?{references:{...column.references}}:{}})))}return{tables,indexes,enums}}const INTEGER_RANK={smallint:1,integer:2,bigint:3};function declaredIntegerRank(type){if(type==="integer")return INTEGER_RANK.integer;if(type==="bigint")return INTEGER_RANK.bigint;return}function liveIntegerRank(column){return INTEGER_RANK[column.dataType]}export function comparableDefault(value){if(value===void 0||value===null)return;let text=String(value).trim();if(/^nextval\(/i.test(text))return;if(/\bnow\(\)|\b(?:current_timestamp|utc_timestamp|localtimestamp)\b/i.test(text))return"clock";text=text.replace(/::\s*(?:"[^"]+"|[a-z_]\w*)(?:\s+varying|\s+precision|\s+with(?:out)?\s+time\s+zone)?(?:\[\])?/gi,"");while(/^\(.*\)$/s.test(text))text=text.slice(1,-1).trim();text=text.replace(/^'(.*)'$/s,"$1").replace(/''/g,"'");return text.toLowerCase()}function declaredDefault(column){if(!column.hasDefault||column.defaultValue===void 0||column.defaultValue===null)return;const value=column.defaultValue;if(value instanceof Date)return comparableDefault(value.toISOString());return comparableDefault(typeof value==="string"?value:String(value))}function foreignKeyAction(action){return(action??"no action").toUpperCase()}const PG_ACTION={a:"NO ACTION",r:"RESTRICT",c:"CASCADE",n:"SET NULL",d:"SET DEFAULT"};export function planSchemaAlignment(desired,live,options={}){const actions=[],reports=[],added=new Set;for(const[type,labels]of desired.enums){const existing=live.enums.get(type);if(!existing){actions.push({kind:"create-enum-type",table:type,name:type,detail:`create enum type ${type} (${labels.join(", ")})`,sql:`CREATE TYPE ${quote(type)} AS ENUM (${labels.map(sqlString).join(", ")})`});continue}for(const label of labels.filter((label)=>!existing.includes(label)))actions.push({kind:"add-enum-value",table:type,name:label,detail:`add '${label}' to enum type ${type}`,sql:`ALTER TYPE ${quote(type)} ADD VALUE IF NOT EXISTS ${sqlString(label)}`});const extra=existing.filter((label)=>!labels.includes(label));if(extra.length>0)reports.push({kind:"enum",table:type,detail:`enum type ${type} has label(s) the model does not declare: ${extra.join(", ")}`})}for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns){reports.push({kind:"missing-table",table,detail:`table ${table} does not exist - \`buddy migrate\` creates it`});continue}const declared=new Set(columns.map((column)=>column.name));for(const name of liveColumns.keys())if(!declared.has(name))reports.push({kind:"extra-column",table,name,detail:`${table}.${name} exists but no model declares it`});for(const column of columns){const current=liveColumns.get(column.name);if(!current){if(!column.definition||/\bPRIMARY\s+KEY\b|\bUNIQUE\b/i.test(column.definition)){reports.push({kind:"missing-column",table,name:column.name,detail:`${table}.${column.name} is missing and cannot be added in place`});continue}const inlineReference=/\bREFERENCES\s+"([^"]+)"/i.exec(column.definition)?.[1]?.toLowerCase();if(inlineReference&&!live.tables.has(inlineReference)){reports.push({kind:"foreign-key",table,name:column.name,detail:`${table}.${column.name} references ${inlineReference}, which does not exist - \`buddy migrate\` creates that table`});continue}const strict=/\bNOT\s+NULL\b/i.test(column.definition)&&!/\bDEFAULT\b/i.test(column.definition),populated=live.populated?.has(table)??!0,definition=strict&&populated?column.definition.replace(/\s*\bNOT\s+NULL\b/i,""):column.definition;actions.push({kind:"add-column",table,name:column.name,detail:`add ${table}.${column.name}${definition!==column.definition?" (nullable: the table has rows and the column has no default)":""}`,sql:`ALTER TABLE ${quote(table)} ADD COLUMN IF NOT EXISTS ${definition}`});added.add(`${table}.${column.name}`);if(definition!==column.definition)reports.push({kind:"nullability",table,name:column.name,detail:`${table}.${column.name} is added nullable; backfill it, then make it NOT NULL`});continue}if(column.isPrimaryKey)continue;const liveSql=current.maxLength?`${current.dataType}(${current.maxLength})`:current.dataType;if(column.type==="string"||column.type==="text"){const wanted=column.type==="text"?void 0:column.maxLength??255,isVarchar=current.dataType==="character varying",isText=current.dataType==="text"||isVarchar&&!current.maxLength;if(isVarchar&&current.maxLength&&(wanted===void 0||wanted>current.maxLength)){const to=wanted===void 0?"text":`varchar(${wanted})`;actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from varchar(${current.maxLength}) to ${to}`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE ${to}`})}else if(isVarchar&&current.maxLength&&wanted!==void 0&&wanted<current.maxLength)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is varchar(${current.maxLength}); the model declares varchar(${wanted}) - narrowing could truncate data`});else if(isText&&wanted!==void 0)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is unbounded text; the model declares varchar(${wanted}) - narrowing could truncate data`});else if(!isVarchar&&!isText&&current.dataType!=="USER-DEFINED")reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type==="text"?"text":`varchar(${wanted})`}`})}else if(declaredIntegerRank(column.type)!==void 0&&liveIntegerRank(current)!==void 0){const want=declaredIntegerRank(column.type),have=liveIntegerRank(current);if(want>have)if(options.widenIntegers)actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from ${current.dataType} to bigint (rewrites the table)`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE bigint`});else reports.push({kind:"optional-widening",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares bigint - pass --widen-integers to convert it (rewrites the table)`});else if(want<have)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares ${column.type} - narrowing could overflow`})}else if(column.type!=="enum"&&options.typeSatisfies&&!options.typeSatisfies(column.type,current.dataType))reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type}`});const wantNullable=column.isNullable;if(wantNullable!==current.nullable)reports.push({kind:"nullability",table,name:column.name,detail:wantNullable?`${table}.${column.name} is NOT NULL; the model allows NULL`:`${table}.${column.name} allows NULL; the model declares NOT NULL`});const want=declaredDefault(column),have=comparableDefault(current.default);if((want??"")!==(have??"")&&!(want===void 0&&have==="null"))reports.push({kind:"default",table,name:column.name,detail:`${table}.${column.name} defaults to ${current.default??"nothing"}; the model declares ${want===void 0?"no default":String(column.defaultValue)}`})}}const desiredNames=new Set(desired.indexes.map((index)=>index.name)),liveByName=new Map(live.indexes.map((index)=>[index.name,index])),claimed=new Set,renames=[],creates=[];for(const index of desired.indexes){const liveColumns=live.tables.get(index.table);if(!liveColumns)continue;const existing=liveByName.get(index.name);if(existing){claimed.add(existing.name);if(existing.table!==index.table||existing.key&&existing.key!==index.key)reports.push({kind:"index-definition",table:index.table,name:index.name,detail:`index ${index.name} exists but differs from the model (${existing.definition}) - drop and recreate it by hand if the model is right`});continue}const twins=live.indexes.filter((candidate)=>candidate.table===index.table&&!candidate.primary&&candidate.key===index.key&&!desiredNames.has(candidate.name)&&!claimed.has(candidate.name));if(twins.length>0){const[twin,...extra]=[...twins].sort((a,b)=>Number(b.name.includes(index.name))-Number(a.name.includes(index.name)));claimed.add(twin.name);if(live.names.has(index.name))reports.push({kind:"name-conflict",table:index.table,name:index.name,detail:`cannot rename ${twin.name} to ${index.name}: that name is taken by another object`});else renames.push({kind:"rename-index",table:index.table,name:index.name,detail:`rename index ${twin.name} to ${index.name}${twin.constraint?" (and the constraint it backs)":""}`,sql:`ALTER INDEX ${quote(twin.name)} RENAME TO ${quote(index.name)}`});for(const duplicate of extra){claimed.add(duplicate.name);reports.push({kind:"duplicate-index",table:index.table,name:duplicate.name,detail:`${duplicate.name} duplicates ${index.name} - drop it by hand once nothing depends on it`})}continue}const missingColumn=index.columns?.find((column)=>!liveColumns.has(column)&&!added.has(`${index.table}.${column}`));if(missingColumn){reports.push({kind:"missing-column",table:index.table,name:missingColumn,detail:`index ${index.name} needs ${index.table}.${missingColumn}, which cannot be added here`});continue}const statement=index.statement.replace(/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)/i,(_m,unique)=>`CREATE ${unique??""}INDEX IF NOT EXISTS `),action={kind:"create-index",table:index.table,name:index.name,detail:`create ${index.unique?"unique ":""}index ${index.name}`,sql:statement};if(index.unique&&index.columns){const columns=index.columns.map(quote).join(", "),where=[...index.columns.map((column)=>`${quote(column)} IS NOT NULL`),...index.where?[`(${index.where.replace(/^WHERE\s+/i,"")})`]:[]].join(" AND ");action.check={kind:"duplicates",sql:`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${quote(index.table)} WHERE ${where} GROUP BY ${columns} HAVING COUNT(*) > 1) AS duplicates`,dependsOn:[...index.columns]}}creates.push(action)}const foreignKeys=[];for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns)continue;for(const column of columns){const ref=column.references;if(!ref)continue;const refTable=ref.table.toLowerCase(),refColumn=ref.column.toLowerCase();if(added.has(`${table}.${column.name}`)||!liveColumns.has(column.name))continue;const present=live.foreignKeys.find((fk)=>fk.table===table&&fk.column===column.name&&fk.refTable===refTable&&fk.refColumn===refColumn);if(present){const wantDelete=foreignKeyAction(ref.onDelete),wantUpdate=foreignKeyAction(ref.onUpdate);if(present.onDelete!==wantDelete||present.onUpdate!==wantUpdate)reports.push({kind:"foreign-key",table,name:present.name,detail:`${present.name} is ON DELETE ${present.onDelete} ON UPDATE ${present.onUpdate}; the model declares ON DELETE ${wantDelete} ON UPDATE ${wantUpdate}`});continue}if(!live.tables.get(refTable)?.has(refColumn)){reports.push({kind:"foreign-key",table,name:column.name,detail:`${table}.${column.name} references ${refTable}.${refColumn}, which does not exist`});continue}const name=postgresIdentifier(ref.constraintNames?.[0]??`${table}_${column.name}_fkey`);if(live.names.has(name)){reports.push({kind:"name-conflict",table,name,detail:`cannot add the foreign key for ${table}.${column.name} as ${name}: that name is taken`});continue}const actionsSql=[ref.onDelete?` ON DELETE ${foreignKeyAction(ref.onDelete)}`:"",ref.onUpdate?` ON UPDATE ${foreignKeyAction(ref.onUpdate)}`:""].join("");foreignKeys.push({kind:"add-foreign-key",table,name,detail:`add foreign key ${table}.${column.name} -> ${refTable}.${refColumn}`,sql:`ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} FOREIGN KEY (${quote(column.name)}) REFERENCES ${quote(refTable)} (${quote(refColumn)})${actionsSql}`,check:{kind:"orphans",sql:`SELECT COUNT(*) AS n FROM ${quote(table)} AS child WHERE child.${quote(column.name)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quote(refTable)} AS parent WHERE parent.${quote(refColumn)} = child.${quote(column.name)})`,dependsOn:[column.name]}})}}return{actions:[...actions,...renames,...creates,...foreignKeys],reports,refused:[]}}function rows(result){return Array.isArray(result)?result:[]}function text(row,key){const value=row?.[key];return value===null||value===void 0?"":String(value)}export async function readPostgresAlignSchema(run){const tables=new Map;for(const row of rows(await run("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")))tables.set(text(row,"table_name").toLowerCase(),new Map);for(const row of rows(await run("SELECT table_name, column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema()"))){const columns=tables.get(text(row,"table_name").toLowerCase());if(!columns)continue;const length=Number(row.character_maximum_length);columns.set(text(row,"column_name").toLowerCase(),{name:text(row,"column_name").toLowerCase(),dataType:text(row,"data_type").toLowerCase()==="user-defined"?"USER-DEFINED":text(row,"data_type").toLowerCase(),udtName:text(row,"udt_name").toLowerCase(),...Number.isFinite(length)&&length>0?{maxLength:length}:{},nullable:text(row,"is_nullable").toUpperCase()==="YES",...row.column_default!==null&&row.column_default!==void 0?{default:String(row.column_default)}:{}})}const indexes=[];for(const row of rows(await run(`
2
2
  SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique, ix.indisprimary AS is_primary,
3
3
  pg_get_indexdef(ix.indexrelid) AS definition, con.conname AS constraint_name
4
4
  FROM pg_index ix
@@ -20,5 +20,5 @@ import{comparableIndexKey,indexShape,postgresIndexStatement}from"./index-shape";
20
20
  JOIN pg_enum e ON e.enumtypid = t.oid
21
21
  JOIN pg_namespace n ON n.oid = t.typnamespace
22
22
  WHERE n.nspname = current_schema()
23
- ORDER BY t.typname, e.enumsortorder`))){const type=text(row,"typname").toLowerCase();if(!enums.has(type))enums.set(type,[]);enums.get(type).push(text(row,"enumlabel"))}const names=new Set;for(const row of rows(await run("SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());for(const row of rows(await run("SELECT con.conname AS name FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());return{tables,indexes,foreignKeys,enums,names}}async function populatedTables(run,tables){const populated=new Set;for(const table of tables)if(rows(await run(`SELECT 1 AS present FROM ${quote(table)} LIMIT 1`)).length>0)populated.add(table);return populated}export async function alignSchema(options={}){const{ensureDatabaseConfigLoaded,resolvedDatabaseConfig}=await import("./utils");await ensureDatabaseConfigLoaded();const dialect=options.dialect??resolvedDatabaseConfig().driver,empty={actions:[],reports:[],refused:[]};if(dialect!=="postgres")return{supported:!1,dialect,plan:empty,applied:[]};const{generateFullModelMigration}=await import("./migrations"),generated=await generateFullModelMigration({dialect});if(!generated.isOk)throw generated.error;const run=options.run??(async(sql)=>{const{db}=await import("./utils");return rows(await db.unsafe(sql).execute())}),desired=desiredSchemaFrom(generated.value.plan,generated.value.statements),live=await readPostgresAlignSchema(run),candidates=[...desired.tables.keys()].filter((table)=>live.tables.has(table));live.populated=await populatedTables(run,candidates);let typeSatisfies;try{const qb=await import("bun-query-builder");if(qb.familyOfDeclared&&qb.familyOfSqlType&&qb.satisfies){const{familyOfDeclared,familyOfSqlType,satisfies}=qb;typeSatisfies=(declared,sqlType)=>satisfies(familyOfDeclared(declared),familyOfSqlType(sqlType),"postgres")}}catch{}const plan=planSchemaAlignment(desired,live,{widenIntegers:options.widenIntegers,typeSatisfies}),kept=[];for(const action of plan.actions){if(!action.check){kept.push(action);continue}const count=Number(rows(await run(action.check.sql))[0]?.n??0);if(count>0){plan.refused.push({...action,reason:action.check.kind==="orphans"?`${count} row(s) in ${action.table} reference a missing parent - fix or delete them, then re-run`:`${count} duplicate value(s) in ${action.table} would violate it - de-duplicate them, then re-run`});continue}kept.push(action)}plan.actions=kept;if(!options.apply||kept.length===0)return{supported:!0,dialect,plan,applied:[]};const statements=kept.map((action)=>action.sql);await run(`${statements.join(`;
23
+ ORDER BY t.typname, e.enumsortorder`))){const type=text(row,"typname").toLowerCase();if(!enums.has(type))enums.set(type,[]);enums.get(type).push(text(row,"enumlabel"))}const names=new Set;for(const row of rows(await run("SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());for(const row of rows(await run("SELECT con.conname AS name FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());return{tables,indexes,foreignKeys,enums,names}}async function populatedTables(run,tables){const populated=new Set;for(const table of tables)if(rows(await run(`SELECT 1 AS present FROM ${quote(table)} LIMIT 1`)).length>0)populated.add(table);return populated}export async function evaluateAlignChecks(plan,run){const pending=new Set(plan.actions.filter((action)=>action.kind==="add-column").map((action)=>`${action.table}.${action.name}`)),kept=[];for(const action of plan.actions){if(!action.check){kept.push(action);continue}const unmet=action.check.dependsOn?.filter((column)=>pending.has(`${action.table}.${column}`))??[];if(unmet.length>0){plan.reports.push({kind:"unchecked",table:action.table,name:action.name,detail:`${action.detail}: not checked for ${action.check.kind==="orphans"?"orphan rows":"duplicate values"} first, because the probe reads ${unmet.map((column)=>`${action.table}.${column}`).join(", ")}, which this plan adds`});kept.push(action);continue}const count=Number(rows(await run(action.check.sql))[0]?.n??0);if(count>0){plan.refused.push({...action,reason:action.check.kind==="orphans"?`${count} row(s) in ${action.table} reference a missing parent - fix or delete them, then re-run`:`${count} duplicate value(s) in ${action.table} would violate it - de-duplicate them, then re-run`});continue}kept.push(action)}return kept}export async function alignSchema(options={}){const{ensureDatabaseConfigLoaded,resolvedDatabaseConfig}=await import("./utils");await ensureDatabaseConfigLoaded();const dialect=options.dialect??resolvedDatabaseConfig().driver,empty={actions:[],reports:[],refused:[]};if(dialect!=="postgres")return{supported:!1,dialect,plan:empty,applied:[]};const{generateFullModelMigration}=await import("./migrations"),generated=await generateFullModelMigration({dialect});if(!generated.isOk)throw generated.error;const run=options.run??(async(sql)=>{const{db}=await import("./utils");return rows(await db.unsafe(sql).execute())}),desired=desiredSchemaFrom(generated.value.plan,generated.value.statements),live=await readPostgresAlignSchema(run),candidates=[...desired.tables.keys()].filter((table)=>live.tables.has(table));live.populated=await populatedTables(run,candidates);let typeSatisfies;try{const qb=await import("bun-query-builder");if(qb.familyOfDeclared&&qb.familyOfSqlType&&qb.satisfies){const{familyOfDeclared,familyOfSqlType,satisfies}=qb;typeSatisfies=(declared,sqlType)=>satisfies(familyOfDeclared(declared),familyOfSqlType(sqlType),"postgres")}}catch{}const plan=planSchemaAlignment(desired,live,{widenIntegers:options.widenIntegers,typeSatisfies}),kept=await evaluateAlignChecks(plan,run);plan.actions=kept;if(!options.apply||kept.length===0)return{supported:!0,dialect,plan,applied:[]};const statements=kept.map((action)=>action.sql);await run(`${statements.join(`;
24
24
  `)};`);return{supported:!0,dialect,plan,applied:statements}}
@@ -60,7 +60,7 @@ import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDataba
60
60
  upvoteable_id INTEGER NOT NULL,
61
61
  upvoteable_type VARCHAR(255) NOT NULL,
62
62
  ${createdAt(sql)}
63
- )`}export function traitTableColumnGuarantees(sql){return[{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS chat_messages_thread_index ON chat_messages (conversation_id, id)","CREATE INDEX IF NOT EXISTS chat_conversations_first_index ON chat_conversations (scope, participant_type, first_id)","CREATE INDEX IF NOT EXISTS chat_conversations_second_index ON chat_conversations (scope, participant_type, second_id)","CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_owner_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_owner_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export function messagingTableSql(sql){return[`CREATE TABLE IF NOT EXISTS chat_conversations (
63
+ )`}export function traitTableColumnGuarantees(sql){return[{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS chat_messages_thread_index ON chat_messages (conversation_id, id)","CREATE INDEX IF NOT EXISTS chat_conversations_first_index ON chat_conversations (scope, participant_type, first_id)","CREATE INDEX IF NOT EXISTS chat_conversations_second_index ON chat_conversations (scope, participant_type, second_id)","CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_owner_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_owner_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export function messagingTableSql(sql){return[`CREATE TABLE IF NOT EXISTS chat_conversations (
64
64
  id VARCHAR(64) PRIMARY KEY,
65
65
  scope VARCHAR(255) NOT NULL,
66
66
  participant_type VARCHAR(255) NOT NULL,
@@ -1,4 +1,4 @@
1
- import{path}from"@stacksjs/path";import{plural,snakeCase}from"@stacksjs/strings";import{resolvedDatabaseDriver,toSqlIntrospectionDialect}from"./dialect";import{safeGlob}from"./fk-audit";export async function getDeclaredUniques(){const modelFiles=[...safeGlob(path.userModelsPath("*.ts")),...safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts"))],declared=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model||typeof model!=="object")continue;const table=model.table||plural(snakeCase(model.name||"")),modelName=model.name||"",attributes=model.attributes;if(attributes&&typeof attributes==="object"){for(const[field,attr]of Object.entries(attributes))if(attr&&typeof attr==="object"&&attr.unique===!0)declared.push({table,columns:[snakeCase(field)],model:modelName,source:"attribute"})}const indexes=model.indexes;if(Array.isArray(indexes)){for(const index of indexes)if(index&&index.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)declared.push({table,columns:index.columns.map((c)=>snakeCase(c)),model:modelName,source:"index",indexName:index.name})}}return declared}export async function getLiveUniqueIndexes(dialect){const{db}=await import("./utils"),d=dialect??currentDialect();if(d==="sqlite")return getSqliteLiveUniques(db);if(d==="mysql")return getMysqlLiveUniques(db);if(d==="postgres")return getPostgresLiveUniques(db);return[]}export async function auditUniqueIndexes(dialect){const d=dialect??currentDialect();if(d!=="sqlite"&&d!=="mysql"&&d!=="postgres")return{supported:!1,declared:[],live:[],missing:[],skippedTables:[]};const declared=await getDeclaredUniques(),live=await getLiveUniqueIndexes(d),liveTables=await getLiveTables(d),liveByTable=new Map;for(const idx of live){const t=idx.table.toLowerCase(),key=columnSetKey(idx.columns);if(!liveByTable.has(t))liveByTable.set(t,new Set);liveByTable.get(t).add(key)}const missing=[],skippedTables=new Set;for(const decl of declared){const t=decl.table.toLowerCase();if(!liveTables.has(t)){skippedTables.add(decl.table);continue}const key=columnSetKey(decl.columns);if(!(liveByTable.get(t)?.has(key)??!1))missing.push(decl)}return{supported:!0,declared,live,missing,skippedTables:[...skippedTables]}}async function getLiveTables(dialect){const{db}=await import("./utils");let rows=[];if(dialect==="sqlite"){const r=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name)}else if(dialect==="mysql"){const r=await db.unsafe("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name??x.TABLE_NAME)}else if(dialect==="postgres"){const r=await db.unsafe("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name??x.tablename)}return new Set(rows.filter((n)=>typeof n==="string"&&n.length>0).map((n)=>n.toLowerCase()))}function columnSetKey(columns){return[...columns].map((c)=>c.toLowerCase()).sort().join(",")}function currentDialect(){return toSqlIntrospectionDialect(resolvedDatabaseDriver().toLowerCase())}async function getSqliteLiveUniques(db){const tables=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute(),rows=Array.isArray(tables)?tables:[],out=[];for(const row of rows){const table=row.name;if(!table)continue;if(!/^[a-z_]\w*$/i.test(table))continue;const indexRows=await db.unsafe(`PRAGMA index_list("${table}")`).execute();for(const idx of Array.isArray(indexRows)?indexRows:[]){const r=idx;if(Number(r.unique)!==1||!r.name)continue;if(!/^[a-z_]\w*$/i.test(r.name))continue;const colRows=await db.unsafe(`PRAGMA index_info("${r.name}")`).execute(),columns=(Array.isArray(colRows)?colRows:[]).map((c)=>String(c.name??"")).filter((c)=>c.length>0);if(columns.length>0)out.push({table,name:r.name,columns})}}return out}async function getMysqlLiveUniques(db){const rows=await db.unsafe(`
1
+ import{path}from"@stacksjs/path";import{plural,snakeCase}from"@stacksjs/strings";import{resolvedDatabaseDriver,toSqlIntrospectionDialect}from"./dialect";import{safeGlob}from"./fk-audit";export async function getDeclaredUniques(){const modelFiles=[...safeGlob(path.userModelsPath("*.ts")),...safeGlob(path.defaultsPath("app/Models/**/*.ts"))],declared=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model||typeof model!=="object")continue;const table=model.table||plural(snakeCase(model.name||"")),modelName=model.name||"",attributes=model.attributes;if(attributes&&typeof attributes==="object"){for(const[field,attr]of Object.entries(attributes))if(attr&&typeof attr==="object"&&attr.unique===!0)declared.push({table,columns:[snakeCase(field)],model:modelName,source:"attribute"})}const indexes=model.indexes;if(Array.isArray(indexes)){for(const index of indexes)if(index&&index.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)declared.push({table,columns:index.columns.map((c)=>snakeCase(c)),model:modelName,source:"index",indexName:index.name})}}return declared}export async function getLiveUniqueIndexes(dialect){const{db}=await import("./utils"),d=dialect??currentDialect();if(d==="sqlite")return getSqliteLiveUniques(db);if(d==="mysql")return getMysqlLiveUniques(db);if(d==="postgres")return getPostgresLiveUniques(db);return[]}export async function auditUniqueIndexes(dialect){const d=dialect??currentDialect();if(d!=="sqlite"&&d!=="mysql"&&d!=="postgres")return{supported:!1,declared:[],live:[],missing:[],skippedTables:[]};const declared=await getDeclaredUniques(),live=await getLiveUniqueIndexes(d),liveTables=await getLiveTables(d),liveByTable=new Map;for(const idx of live){const t=idx.table.toLowerCase(),key=columnSetKey(idx.columns);if(!liveByTable.has(t))liveByTable.set(t,new Set);liveByTable.get(t).add(key)}const missing=[],skippedTables=new Set;for(const decl of declared){const t=decl.table.toLowerCase();if(!liveTables.has(t)){skippedTables.add(decl.table);continue}const key=columnSetKey(decl.columns);if(!(liveByTable.get(t)?.has(key)??!1))missing.push(decl)}return{supported:!0,declared,live,missing,skippedTables:[...skippedTables]}}async function getLiveTables(dialect){const{db}=await import("./utils");let rows=[];if(dialect==="sqlite"){const r=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name)}else if(dialect==="mysql"){const r=await db.unsafe("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name??x.TABLE_NAME)}else if(dialect==="postgres"){const r=await db.unsafe("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'").execute();rows=(Array.isArray(r)?r:[]).map((x)=>x.name??x.tablename)}return new Set(rows.filter((n)=>typeof n==="string"&&n.length>0).map((n)=>n.toLowerCase()))}function columnSetKey(columns){return[...columns].map((c)=>c.toLowerCase()).sort().join(",")}function currentDialect(){return toSqlIntrospectionDialect(resolvedDatabaseDriver().toLowerCase())}async function getSqliteLiveUniques(db){const tables=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute(),rows=Array.isArray(tables)?tables:[],out=[];for(const row of rows){const table=row.name;if(!table)continue;if(!/^[a-z_]\w*$/i.test(table))continue;const indexRows=await db.unsafe(`PRAGMA index_list("${table}")`).execute();for(const idx of Array.isArray(indexRows)?indexRows:[]){const r=idx;if(Number(r.unique)!==1||!r.name)continue;if(!/^[a-z_]\w*$/i.test(r.name))continue;const colRows=await db.unsafe(`PRAGMA index_info("${r.name}")`).execute(),columns=(Array.isArray(colRows)?colRows:[]).map((c)=>String(c.name??"")).filter((c)=>c.length>0);if(columns.length>0)out.push({table,name:r.name,columns})}}return out}async function getMysqlLiveUniques(db){const rows=await db.unsafe(`
2
2
  SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
3
3
  FROM information_schema.STATISTICS
4
4
  WHERE TABLE_SCHEMA = DATABASE()
@@ -1 +1 @@
1
- export function isStringValidator(v){return v.name==="string"}export function isNumberValidator(v){return v.name==="number"}export function enumValidator(v){return v.name==="enum"}export function isBooleanValidator(v){return v.name==="boolean"}export function isDateValidator(v){return v.name==="date"}export function isUnixValidator(v){return v.name==="unix"}export function isFloatValidator(v){return v.name==="float"}export function isDatetimeValidator(v){return v.name==="datetime"}export function isTimestampValidator(v){return v.name==="timestamp"}export function isTimestampTzValidator(v){return v.name==="timestampTz"}export function isDecimalValidator(v){return v.name==="decimal"}export function isSmallintValidator(v){return v.name==="smallint"}export function isIntegerValidator(v){return v.name==="integer"}export function isBigintValidator(v){return v.name==="bigint"}export function isBinaryValidator(v){return v.name==="binary"}export function isBlobValidator(v){return v.name==="blob"}export function isJsonValidator(v){return v.name==="json"}export function checkValidator(validator,driver){if(enumValidator(validator))return prepareEnumColumnType(validator,driver);if(isStringValidator(validator))return prepareTextColumnType(validator,driver);if(isNumberValidator(validator))return prepareNumberColumnType(validator,driver);if(isBooleanValidator(validator))return"'boolean'";if(isDateValidator(validator))return"'date'";if(isDatetimeValidator(validator))return"'datetime'";if(isUnixValidator(validator))return"'bigint'";if(isTimestampValidator(validator))return"'timestamp'";if(isTimestampTzValidator(validator))return"'timestamp'";if(isFloatValidator(validator))return"'float'";if(isSmallintValidator(validator))return"'smallint'";if(isDecimalValidator(validator))return"'decimal'";if(isIntegerValidator(validator))return"'integer'";if(isBigintValidator(validator))return"'bigint'";if(isBinaryValidator(validator))return"'binary'";return""}export function prepareNumberColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'integer'";if("getRules"in validator){const minRule=validator.getRules().find((rule)=>rule.name==="min"),maxRule=validator.getRules().find((rule)=>rule.name==="max"),min=minRule?.params?.min??-2147483648,max=maxRule?.params?.max??2147483647;return min>=-2147483648&&max<=2147483647?"'integer'":"'bigint'"}return"'integer'"}export function prepareEnumColumnType(validator,driver="mysql"){const allowedValues=validator.getAllowedValues();if(!allowedValues)throw Error("Enum rule found but no allowedValues defined");const enumStructure=allowedValues.map((value)=>`'${value}'`).join(", ");if(driver==="sqlite")return"'text'";return`sql\`enum(${enumStructure})\``}export function prepareTextColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";return`'varchar(${findCharacterLength(validator)})'`}export function prepareDateTimeColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const name=validator.name;if(name==="unix")return"'bigint'";return name||"date"}export function findCharacterLength(validator){if("getRules"in validator){const maxLengthRule=validator.getRules().find((rule)=>rule.name==="max");return maxLengthRule?.params?.length||maxLengthRule?.params?.max||255}return 255}
1
+ export function isStringValidator(v){return v.name==="string"}export function isNumberValidator(v){return v.name==="number"}export function enumValidator(v){return v.name==="enum"}export function isBooleanValidator(v){return v.name==="boolean"}export function isDateValidator(v){return v.name==="date"}export function isUnixValidator(v){return v.name==="unix"}export function isFloatValidator(v){return v.name==="float"}export function isDatetimeValidator(v){return v.name==="datetime"}export function isTimestampValidator(v){return v.name==="timestamp"}export function isTimestampTzValidator(v){return v.name==="timestampTz"}export function isDecimalValidator(v){return v.name==="decimal"}export function isSmallintValidator(v){return v.name==="smallint"}export function isIntegerValidator(v){return v.name==="integer"}export function isBigintValidator(v){return v.name==="bigint"}export function isBinaryValidator(v){return v.name==="binary"}export function isBlobValidator(v){return v.name==="blob"}export function isJsonValidator(v){return v.name==="json"}export function checkValidator(validator,driver){if(enumValidator(validator))return prepareEnumColumnType(validator,driver);if(isStringValidator(validator))return prepareTextColumnType(validator,driver);if(isNumberValidator(validator))return prepareNumberColumnType(validator,driver);if(isBooleanValidator(validator))return"'boolean'";if(isDateValidator(validator))return"'date'";if(isDatetimeValidator(validator))return"'datetime'";if(isUnixValidator(validator))return"'bigint'";if(isTimestampValidator(validator))return"'timestamp'";if(isTimestampTzValidator(validator))return"'timestamp'";if(isFloatValidator(validator))return"'float'";if(isSmallintValidator(validator))return"'smallint'";if(isDecimalValidator(validator))return"'decimal'";if(isIntegerValidator(validator))return"'integer'";if(isBigintValidator(validator))return"'bigint'";if(isBinaryValidator(validator))return"'binary'";return""}export function prepareNumberColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'integer'";if("getRules"in validator){const minRule=validator.getRules().find((rule)=>rule.name==="min"),maxRule=validator.getRules().find((rule)=>rule.name==="max"),min=minRule?.params?.min??-2147483648,max=maxRule?.params?.max??2147483647;return min>=-2147483648&&max<=2147483647?"'integer'":"'bigint'"}return"'integer'"}export function prepareEnumColumnType(validator,driver="mysql"){const allowedValues=validator.getAllowedValues();if(!allowedValues)throw Error("Enum rule found but no allowedValues defined");const enumStructure=allowedValues.map((value)=>`'${value}'`).join(", ");if(driver==="sqlite")return"'text'";return`sql\`enum(${enumStructure})\``}export function prepareTextColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const maxLength=declaredCharacterLength(validator);if(maxLength===null)return driver==="postgres"?"'text'":"'varchar(255)'";return`'varchar(${maxLength})'`}export function prepareDateTimeColumnType(validator,driver="mysql"){if(driver==="sqlite")return"'text'";const name=validator.name;if(name==="unix")return"'bigint'";return name||"date"}function declaredCharacterLength(validator){if("getRules"in validator){const maxLengthRule=validator.getRules().find((rule)=>rule.name==="max"),declared=maxLengthRule?.params?.length??maxLengthRule?.params?.max;return typeof declared==="number"?declared:null}return null}export function findCharacterLength(validator){if("getRules"in validator){const maxLengthRule=validator.getRules().find((rule)=>rule.name==="max");return maxLengthRule?.params?.length||maxLengthRule?.params?.max||255}return 255}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/database",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.255",
5
+ "version": "0.75.257",
6
6
  "description": "The Stacks database integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -66,26 +66,26 @@
66
66
  "prepublishOnly": "bun run build"
67
67
  },
68
68
  "dependencies": {
69
- "@stacksjs/config": "0.75.255",
70
- "@stacksjs/env": "0.75.255",
71
- "@stacksjs/error-handling": "0.75.255",
72
- "@stacksjs/faker": "0.75.255",
73
- "@stacksjs/features": "0.75.255",
74
- "@stacksjs/logging": "0.75.255",
75
- "@stacksjs/model-meta": "0.75.255",
76
- "@stacksjs/path": "0.75.255",
77
- "@stacksjs/query-builder": "0.75.255",
78
- "@stacksjs/security": "0.75.255",
79
- "@stacksjs/storage": "0.75.255",
80
- "@stacksjs/strings": "0.75.255",
69
+ "@stacksjs/config": "0.75.257",
70
+ "@stacksjs/env": "0.75.257",
71
+ "@stacksjs/error-handling": "0.75.257",
72
+ "@stacksjs/faker": "0.75.257",
73
+ "@stacksjs/features": "0.75.257",
74
+ "@stacksjs/logging": "0.75.257",
75
+ "@stacksjs/model-meta": "0.75.257",
76
+ "@stacksjs/path": "0.75.257",
77
+ "@stacksjs/query-builder": "0.75.257",
78
+ "@stacksjs/security": "0.75.257",
79
+ "@stacksjs/storage": "0.75.257",
80
+ "@stacksjs/strings": "0.75.257",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.9",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.255",
87
- "@stacksjs/router": "0.75.255",
88
- "@stacksjs/utils": "0.75.255",
86
+ "@stacksjs/cli": "0.75.257",
87
+ "@stacksjs/router": "0.75.257",
88
+ "@stacksjs/utils": "0.75.257",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }