@stacksjs/database 0.72.103 → 0.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/drivers/helpers.d.ts +3 -1
- package/dist/drivers/helpers.js +1 -1
- package/dist/drivers/mysql.d.ts +2 -2
- package/dist/drivers/mysql.js +10 -10
- package/dist/drivers/postgres.d.ts +2 -2
- package/dist/drivers/postgres.js +14 -14
- package/dist/drivers/sqlite.d.ts +2 -2
- package/dist/drivers/sqlite.js +11 -11
- package/dist/ensure-database.js +1 -1
- package/dist/framework-schema.d.ts +7 -7
- package/dist/migrations.js +2 -2
- package/dist/query-logger.js +1 -1
- package/dist/safe-migrations.js +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/utils.d.ts +70 -2
- package/dist/utils.js +1 -1
- package/dist/vschema.d.ts +1 -1
- package/dist/vschema.js +1 -1
- package/package.json +12 -12
|
@@ -15,7 +15,7 @@ export declare function checkPivotMigration(dynamicPart: string): Promise<boolea
|
|
|
15
15
|
export declare function pluckChanges(array1: string[], array2: string[]): { added: string[], removed: string[] } | null;
|
|
16
16
|
export declare function arrangeColumns(attributes: AttributesElements | undefined): Array<[string, Attribute]>;
|
|
17
17
|
export declare function isArrayEqual(arr1: (number | undefined)[], arr2: (number | undefined)[]): boolean;
|
|
18
|
-
export declare function findDifferingKeys(obj1:
|
|
18
|
+
export declare function findDifferingKeys(obj1: AttributesWithRules, obj2: AttributesWithRules): { key: string, max: number, min: number }[];
|
|
19
19
|
export declare function fetchTables(): Promise<string[]>;
|
|
20
20
|
export declare function getUpvoteTableName(model: Model, tableName: string): string | undefined;
|
|
21
21
|
/**
|
|
@@ -33,3 +33,5 @@ declare interface Range {
|
|
|
33
33
|
min: number
|
|
34
34
|
max: number
|
|
35
35
|
}
|
|
36
|
+
/** An attribute map, keyed by name, as a model definition carries it. */
|
|
37
|
+
declare type AttributesWithRules = Record<string, { validation: { rule: ValidationType } } | undefined>;
|
package/dist/drivers/helpers.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{log}from"@stacksjs/logging";import{getTableName}from"@stacksjs/orm";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)
|
|
1
|
+
import{log}from"@stacksjs/logging";import{getTableName}from"@stacksjs/orm";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()")}
|
package/dist/drivers/mysql.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function resetMysqlDatabase(): Promise<
|
|
1
|
+
import type { Result } from '@stacksjs/error-handling';
|
|
2
|
+
export declare function resetMysqlDatabase(): Promise<Result<string, never>>;
|
|
3
3
|
export declare function dropMysqlTables(): Promise<void>;
|
|
4
4
|
export declare function generateMysqlMigration(modelPath: string): Promise<void>;
|
|
5
5
|
export declare function createAlterTableMigration(modelPath: string): Promise<void>;
|
package/dist/drivers/mysql.js
CHANGED
|
@@ -2,7 +2,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
2
2
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
3
3
|
|
|
4
4
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
5
|
-
`;migrationContent+=` await
|
|
5
|
+
`;migrationContent+=` await db.schema
|
|
6
6
|
`;migrationContent+=` .createTable('${tableName}')
|
|
7
7
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
8
8
|
`;if(useUuid)migrationContent+=` .addColumn('uuid', 'varchar(255)')
|
|
@@ -21,7 +21,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
21
21
|
`;migrationContent+=` .execute()
|
|
22
22
|
`;migrationContent+=generatePrimaryKeyIndexSQL(tableName);if(useLikeable){const upvoteTable=getUpvoteTableName(model,tableName);if(upvoteTable){const foreignKey=getLikeableForeignKey(model,tableName);migrationContent+=`
|
|
23
23
|
// Create upvote table
|
|
24
|
-
`;migrationContent+=` await
|
|
24
|
+
`;migrationContent+=` await db.schema
|
|
25
25
|
`;migrationContent+=` .createTable('${upvoteTable}')
|
|
26
26
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
27
27
|
`;migrationContent+=` .addColumn('${foreignKey}', 'integer', col => col.notNull())
|
|
@@ -31,15 +31,15 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
31
31
|
`;migrationContent+=` .execute()
|
|
32
32
|
|
|
33
33
|
`;migrationContent+=` // Add indexes for upvote table
|
|
34
|
-
`;migrationContent+=` await
|
|
35
|
-
`;migrationContent+=` await
|
|
36
|
-
`;migrationContent+=` await
|
|
34
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_${foreignKey}_index').on('${upvoteTable}').column('${foreignKey}').execute()
|
|
35
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_user_${foreignKey}_unique').on('${upvoteTable}').columns(['user_id', '${foreignKey}']).unique().execute()
|
|
36
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_id_index').on('${upvoteTable}').column('id').execute()
|
|
37
37
|
`}}migrationContent+=`}
|
|
38
38
|
`;const migrationFileName=`${new Date().getTime().toString()}-create-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);log.debug(migrationFilePath);await Bun.write(migrationFilePath,migrationContent);log.success(`Created migration: ${italic(migrationFileName)}`)}async function createPivotTableMigration(model,modelPath){const pivotTables=await getPivotTables(model,modelPath),processedPivotTables=new Set;if(!pivotTables.length)return;for(const pivotTable of pivotTables){if(processedPivotTables.has(pivotTable.table))continue;if(await checkPivotMigration(pivotTable.table)){processedPivotTables.add(pivotTable.table);continue}let migrationContent=`import type { Database } from '@stacksjs/database'
|
|
39
39
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
40
40
|
|
|
41
41
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
42
|
-
`;migrationContent+=` await
|
|
42
|
+
`;migrationContent+=` await db.schema
|
|
43
43
|
`;migrationContent+=` .createTable('${pivotTable.table}')
|
|
44
44
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
45
45
|
`;migrationContent+=` .addColumn('${pivotTable.firstForeignKey}', 'integer')
|
|
@@ -51,14 +51,14 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
51
51
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
52
52
|
|
|
53
53
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
54
|
-
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await
|
|
54
|
+
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await db.schema.alterTable('${tableName}')
|
|
55
55
|
`}const fieldValidations=findDifferingKeys(lastFields,currentFields);for(const fieldValidation of fieldValidations){hasChanged=!0;const fieldNameFormatted=snakeCase(fieldValidation.key);migrationContent+=` .modifyColumn('${fieldNameFormatted}', 'varchar(${fieldValidation.max})')
|
|
56
56
|
`}const lastFieldOrder=Object.values(lastFields).map((attr)=>attr.order),currentFieldOrder=Object.values(currentFields).map((attr)=>attr.order);if(!isArrayEqual(lastFieldOrder,currentFieldOrder)){hasChanged=!0;migrationContent+=reArrangeColumns(model.attributes,tableName)}if(hasChanged){migrationContent+=` .execute()
|
|
57
57
|
`;migrationContent+=`}
|
|
58
58
|
`;const migrationFileName=`${new Date().getTime().toString()}-alter-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);await Bun.write(migrationFilePath,migrationContent);log.success(`Created alter migration: ${italic(migrationFileName)}`)}}export function generateIndexCreationSQL(tableName,index){if(index.unique||index.where){const unique=index.unique?"UNIQUE ":"",cols=index.columns.map((col)=>snakeCase(col)).join(", "),whereClause=index.where?` WHERE ${index.where}`:"";return` await db.unsafe(\`CREATE ${unique}INDEX IF NOT EXISTS \\\`${index.name}\\\` ON \\\`${tableName}\\\` (${cols})${whereClause}\`).execute()
|
|
59
|
-
`}const columnsStr=index.columns.map((col)=>`'${snakeCase(col)}'`).join(", ");return` await
|
|
60
|
-
`}function generatePrimaryKeyIndexSQL(tableName){return` await
|
|
61
|
-
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await
|
|
59
|
+
`}const columnsStr=index.columns.map((col)=>`'${snakeCase(col)}'`).join(", ");return` await db.schema.createIndex('${index.name}').on('${tableName}').columns([${columnsStr}]).execute()
|
|
60
|
+
`}function generatePrimaryKeyIndexSQL(tableName){return` await db.schema.createIndex('${tableName}_id_index').on('${tableName}').column('id').execute()
|
|
61
|
+
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await db.schema.createIndex('${tableName}_${foreignKey}_index').on('${tableName}').column('${foreignKey}').execute()
|
|
62
62
|
|
|
63
63
|
`}function reArrangeColumns(attributes,tableName){const fields=arrangeColumns(attributes);let migrationContent="",previousField="";for(const[fieldName]of fields){const fieldNameFormatted=snakeCase(fieldName);if(previousField)migrationContent+=`await sql\`
|
|
64
64
|
ALTER TABLE ${tableName}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Result } from '@stacksjs/error-handling';
|
|
2
2
|
export declare function dropPostgresTables(): Promise<void>;
|
|
3
|
-
export declare function resetPostgresDatabase(): Promise<
|
|
3
|
+
export declare function resetPostgresDatabase(): Promise<Result<string, never>>;
|
|
4
4
|
export declare function generatePostgresMigration(modelPath: string): Promise<void>;
|
|
5
5
|
export declare function generateIndexCreationSQL(tableName: string, index: { name: string, columns: string[], unique?: boolean, where?: string }): string;
|
|
6
6
|
export declare function fetchPostgresTables(): Promise<string[]>;
|
package/dist/drivers/postgres.js
CHANGED
|
@@ -2,7 +2,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
2
2
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
3
3
|
|
|
4
4
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
5
|
-
`;migrationContent+=` await
|
|
5
|
+
`;migrationContent+=` await db.schema
|
|
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()')))
|
|
@@ -22,7 +22,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
22
22
|
`;migrationContent+=` .execute()
|
|
23
23
|
`;migrationContent+=generatePrimaryKeyIndexSQL(tableName);if(useLikeable){const upvoteTable=getUpvoteTableName(model,tableName);if(upvoteTable){const foreignKey=getLikeableForeignKey(model,tableName);migrationContent+=`
|
|
24
24
|
// Create upvote table
|
|
25
|
-
`;migrationContent+=` await
|
|
25
|
+
`;migrationContent+=` await db.schema
|
|
26
26
|
`;migrationContent+=` .createTable('${upvoteTable}')
|
|
27
27
|
`;migrationContent+=` .addColumn('id', 'serial', (col) => col.primaryKey())
|
|
28
28
|
`;migrationContent+=` .addColumn('${foreignKey}', 'integer', (col) => col.notNull())
|
|
@@ -32,15 +32,15 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
32
32
|
`;migrationContent+=` .execute()
|
|
33
33
|
|
|
34
34
|
`;migrationContent+=` // Add indexes for upvote table
|
|
35
|
-
`;migrationContent+=` await
|
|
36
|
-
`;migrationContent+=` await
|
|
37
|
-
`;migrationContent+=` await
|
|
35
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_${foreignKey}_index').on('${upvoteTable}').column('${foreignKey}').execute()
|
|
36
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_user_${foreignKey}_unique').on('${upvoteTable}').columns(['user_id', '${foreignKey}']).unique().execute()
|
|
37
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_id_index').on('${upvoteTable}').column('id').execute()
|
|
38
38
|
`}}migrationContent+=`}
|
|
39
39
|
`;const migrationFileName=`${new Date().getTime().toString()}-create-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);await Bun.write(migrationFilePath,migrationContent);log.success(`Created migration: ${italic(migrationFileName)}`)}async function createPivotTableMigration(model,modelPath){const pivotTables=await getPivotTables(model,modelPath),processedPivotTables=new Set;if(!pivotTables.length)return;for(const pivotTable of pivotTables){if(processedPivotTables.has(pivotTable.table))continue;if(await checkPivotMigration(pivotTable.table)){processedPivotTables.add(pivotTable.table);continue}let migrationContent=`import type { Database } from '@stacksjs/database'
|
|
40
40
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
41
41
|
|
|
42
42
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
43
|
-
`;migrationContent+=` await
|
|
43
|
+
`;migrationContent+=` await db.schema
|
|
44
44
|
`;migrationContent+=` .createTable('${pivotTable.table}')
|
|
45
45
|
`;migrationContent+=` .addColumn('id', 'serial', (col) => col.primaryKey())
|
|
46
46
|
`;migrationContent+=` .addColumn('${pivotTable.firstForeignKey}', 'integer', (col) => col.notNull())
|
|
@@ -48,23 +48,23 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
48
48
|
`;migrationContent+=` .addColumn('created_at', 'timestamptz', col => col.notNull().defaultTo(sql.raw('CURRENT_TIMESTAMP')))
|
|
49
49
|
`;migrationContent+=` .execute()
|
|
50
50
|
|
|
51
|
-
`;migrationContent+=` await
|
|
51
|
+
`;migrationContent+=` await db.schema
|
|
52
52
|
`;migrationContent+=` .alterTable('${pivotTable.table}')
|
|
53
53
|
`;migrationContent+=` .addForeignKeyConstraint('${pivotTable.table}_${pivotTable.firstForeignKey}_fkey', ['${pivotTable.firstForeignKey}'], '${plural(pivotTable.firstForeignKey?.replace(/_id$/,"")||"")}', ['id'], (cb) => cb.onDelete('cascade'))
|
|
54
54
|
`;migrationContent+=` .execute()
|
|
55
55
|
|
|
56
|
-
`;migrationContent+=` await
|
|
56
|
+
`;migrationContent+=` await db.schema
|
|
57
57
|
`;migrationContent+=` .alterTable('${pivotTable.table}')
|
|
58
58
|
`;migrationContent+=` .addUniqueConstraint('${pivotTable.table}_unique', ['${pivotTable.firstForeignKey}', '${pivotTable.secondForeignKey}'])
|
|
59
59
|
`;migrationContent+=` .execute()
|
|
60
60
|
|
|
61
|
-
`;migrationContent+=` await
|
|
61
|
+
`;migrationContent+=` await db.schema
|
|
62
62
|
`;migrationContent+=` .createIndex('${pivotTable.table}_${pivotTable.firstForeignKey}_idx')
|
|
63
63
|
`;migrationContent+=` .on('${pivotTable.table}')
|
|
64
64
|
`;migrationContent+=` .column('${pivotTable.firstForeignKey}')
|
|
65
65
|
`;migrationContent+=` .execute()
|
|
66
66
|
|
|
67
|
-
`;migrationContent+=` await
|
|
67
|
+
`;migrationContent+=` await db.schema
|
|
68
68
|
`;migrationContent+=` .createIndex('${pivotTable.table}_${pivotTable.secondForeignKey}_idx')
|
|
69
69
|
`;migrationContent+=` .on('${pivotTable.table}')
|
|
70
70
|
`;migrationContent+=` .column('${pivotTable.secondForeignKey}')
|
|
@@ -74,15 +74,15 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
74
74
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
75
75
|
|
|
76
76
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
77
|
-
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await
|
|
77
|
+
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await db.schema.alterTable('${tableName}')
|
|
78
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+=`)
|
|
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()
|
|
82
82
|
`;migrationContent+=`}
|
|
83
83
|
`;const migrationFileName=`${new Date().getTime().toString()}-alter-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);await Bun.write(migrationFilePath,migrationContent);log.success(`Created alter migration: ${italic(migrationFileName)}`)}}export function generateIndexCreationSQL(tableName,index){if(index.unique||index.where){const unique=index.unique?"UNIQUE ":"",cols=index.columns.map((col)=>snakeCase(col)).join(", "),whereClause=index.where?` WHERE ${index.where}`:"";return` await db.unsafe(\`CREATE ${unique}INDEX IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})${whereClause}\`).execute()
|
|
84
|
-
`}const columnsStr=index.columns.map((col)=>`'${snakeCase(col)}'`).join(", ");return` await
|
|
85
|
-
`}function generatePrimaryKeyIndexSQL(tableName){return` await
|
|
86
|
-
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await
|
|
84
|
+
`}const columnsStr=index.columns.map((col)=>`'${snakeCase(col)}'`).join(", ");return` await db.schema.createIndex('${index.name}').on('${tableName}').columns([${columnsStr}]).execute()
|
|
85
|
+
`}function generatePrimaryKeyIndexSQL(tableName){return` await db.schema.createIndex('${tableName}_id_index').on('${tableName}').column('id').execute()
|
|
86
|
+
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await db.schema.createIndex('${tableName}_${foreignKey}_index').on('${tableName}').column('${foreignKey}').execute()
|
|
87
87
|
|
|
88
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}
|
package/dist/drivers/sqlite.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function resetSqliteDatabase(): Promise<
|
|
1
|
+
import type { Result } from '@stacksjs/error-handling';
|
|
2
|
+
export declare function resetSqliteDatabase(): Promise<Result<string, never>>;
|
|
3
3
|
/**
|
|
4
4
|
* Configure SQLite for the Stacks workload. Idempotent — the pragmas are
|
|
5
5
|
* cheap to re-apply, but each one is a no-op once set so repeated calls
|
package/dist/drivers/sqlite.js
CHANGED
|
@@ -2,7 +2,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
2
2
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
3
3
|
|
|
4
4
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
5
|
-
`;migrationContent+=` await
|
|
5
|
+
`;migrationContent+=` await db.schema
|
|
6
6
|
`;migrationContent+=` .createTable('${tableName}')
|
|
7
7
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
8
8
|
`;if(useUuid)migrationContent+=` .addColumn('uuid', 'text')
|
|
@@ -25,7 +25,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
25
25
|
`;if(otherModelRelations?.length)for(const modelRelation of otherModelRelations){if(!modelRelation.foreignKey)continue;migrationContent+=generateForeignKeyIndexSQL(tableName,modelRelation.foreignKey)}if(model.indexes?.length){migrationContent+=`
|
|
26
26
|
`;for(const index of model.indexes)migrationContent+=generateIndexCreationSQL(tableName,index)}migrationContent+=generatePrimaryKeyIndexSQL(tableName);if(useLikeable){const upvoteTable=getUpvoteTableName(model,tableName);if(upvoteTable){const foreignKey=getLikeableForeignKey(model,tableName);migrationContent+=`
|
|
27
27
|
// Create upvote table
|
|
28
|
-
`;migrationContent+=` await
|
|
28
|
+
`;migrationContent+=` await db.schema
|
|
29
29
|
`;migrationContent+=` .createTable('${upvoteTable}')
|
|
30
30
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
31
31
|
`;migrationContent+=` .addColumn('${foreignKey}', 'integer', col => col.notNull())
|
|
@@ -34,15 +34,15 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
34
34
|
`;migrationContent+=` .execute()
|
|
35
35
|
|
|
36
36
|
`;migrationContent+=` // Add indexes for upvote table
|
|
37
|
-
`;migrationContent+=` await
|
|
38
|
-
`;migrationContent+=` await
|
|
39
|
-
`;migrationContent+=` await
|
|
37
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_${foreignKey}_index').on('${upvoteTable}').column('${foreignKey}').execute()
|
|
38
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_user_${foreignKey}_unique').on('${upvoteTable}').columns(['user_id', '${foreignKey}']).unique().execute()
|
|
39
|
+
`;migrationContent+=` await db.schema.createIndex('${upvoteTable}_id_index').on('${upvoteTable}').column('id').execute()
|
|
40
40
|
`}}migrationContent+=`}
|
|
41
41
|
`;const migrationFileName=`${new Date().getTime().toString()}-create-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);await Bun.write(migrationFilePath,migrationContent);log.success(`Created migration: ${italic(migrationFileName)}`)}async function createPivotTableMigration(model,modelPath){const pivotTables=await getPivotTables(model,modelPath);if(!pivotTables.length)return;for(const pivotTable of pivotTables){if(await checkPivotMigration(pivotTable.table))return;let migrationContent=`import type { Database } from '@stacksjs/database'
|
|
42
42
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
43
43
|
|
|
44
44
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
45
|
-
`;migrationContent+=` await
|
|
45
|
+
`;migrationContent+=` await db.schema
|
|
46
46
|
`;migrationContent+=` .createTable('${pivotTable.table}')
|
|
47
47
|
`;migrationContent+=` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
48
48
|
`;migrationContent+=` .addColumn('${pivotTable.firstForeignKey}', 'integer')
|
|
@@ -54,7 +54,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
54
54
|
`;migrationContent+=`import { sql } from '@stacksjs/database'
|
|
55
55
|
|
|
56
56
|
`;migrationContent+=`export async function up(db: Database<any>) {
|
|
57
|
-
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await
|
|
57
|
+
`;if(fieldsToAdd.length||fieldsToRemove.length){hasChanged=!0;migrationContent+=` await db.schema.alterTable('${tableName}')
|
|
58
58
|
`}const fieldValidations=findDifferingKeys(lastFields,currentFields);for(const fieldValidation of fieldValidations){hasChanged=!0;const fieldNameFormatted=snakeCase(fieldValidation.key);migrationContent+=`await sql\`
|
|
59
59
|
ALTER TABLE ${tableName}
|
|
60
60
|
MODIFY COLUMN ${fieldNameFormatted} TEXT
|
|
@@ -64,7 +64,7 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
64
64
|
|
|
65
65
|
`}for(const fieldName of fieldsToRemove)migrationContent+=` .dropColumn('${fieldName}')
|
|
66
66
|
`;if(fieldsToAdd.length||fieldsToRemove.length)migrationContent+=` .execute();
|
|
67
|
-
`;const lastFieldOrder=Object.values(lastFields).map((attr)=>attr.order),currentFieldOrder=Object.values(currentFields).map((attr)=>attr.order);if(!isArrayEqual(lastFieldOrder,currentFieldOrder)){hasChanged=!0;migrationContent+=reArrangeColumns(model.attributes,tableName)}const oldIndexes=oldModel.indexes||[],newIndexes=model.indexes||[];for(const oldIndex of oldIndexes)if(!newIndexes.find((newIndex)=>newIndex.name===oldIndex.name)){hasChanged=!0;migrationContent+=` await
|
|
67
|
+
`;const lastFieldOrder=Object.values(lastFields).map((attr)=>attr.order),currentFieldOrder=Object.values(currentFields).map((attr)=>attr.order);if(!isArrayEqual(lastFieldOrder,currentFieldOrder)){hasChanged=!0;migrationContent+=reArrangeColumns(model.attributes,tableName)}const oldIndexes=oldModel.indexes||[],newIndexes=model.indexes||[];for(const oldIndex of oldIndexes)if(!newIndexes.find((newIndex)=>newIndex.name===oldIndex.name)){hasChanged=!0;migrationContent+=` await db.schema.dropIndex('${oldIndex.name}').execute()
|
|
68
68
|
`}for(const newIndex of newIndexes)if(!oldIndexes.find((oldIndex)=>oldIndex.name===newIndex.name)){hasChanged=!0;migrationContent+=generateIndexCreationSQL(tableName,newIndex)}migrationContent+=`}
|
|
69
69
|
`;const migrationFileName=`${new Date().getTime().toString()}-update-${tableName}-table.ts`,migrationFilePath=path.userMigrationsPath(migrationFileName);if(hasChanged){await Bun.write(migrationFilePath,migrationContent);log.success(`Created migration: ${italic(migrationFileName)}`)}}function reArrangeColumns(attributes,tableName){const fields=arrangeColumns(attributes);let migrationContent="",previousField="";for(const[fieldName]of fields){const fieldNameFormatted=snakeCase(fieldName);if(previousField)migrationContent+=`await sql\`
|
|
70
70
|
ALTER TABLE ${tableName}
|
|
@@ -72,8 +72,8 @@ import{log}from"@stacksjs/logging";function italic(str){return`\x1B[3m${str}\x1B
|
|
|
72
72
|
\`.execute(db)
|
|
73
73
|
|
|
74
74
|
`;previousField=fieldNameFormatted}return migrationContent}export function generateIndexCreationSQL(tableName,index){if(index.unique||index.where){const unique=index.unique?"UNIQUE ":"",cols=index.columns.map((col)=>snakeCase(col)).join(", "),whereClause=index.where?` WHERE ${index.where}`:"";return` await db.unsafe(\`CREATE ${unique}INDEX IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})${whereClause}\`).execute()
|
|
75
|
-
`}const columnsStr=index.columns.map((col)=>`\`${snakeCase(col)}\``).join(", ");return` await
|
|
76
|
-
`}function generatePrimaryKeyIndexSQL(tableName){return` await
|
|
77
|
-
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await
|
|
75
|
+
`}const columnsStr=index.columns.map((col)=>`\`${snakeCase(col)}\``).join(", ");return` await db.schema.createIndex('${index.name}').on('${tableName}').columns([${columnsStr}]).execute()
|
|
76
|
+
`}function generatePrimaryKeyIndexSQL(tableName){return` await db.schema.createIndex('${tableName}_id_index').on('${tableName}').column('id').execute()
|
|
77
|
+
`}function generateForeignKeyIndexSQL(tableName,foreignKey){return` await db.schema.createIndex('${tableName}_${foreignKey}_index').on('${tableName}').column(\`${foreignKey}\`).execute()
|
|
78
78
|
|
|
79
79
|
`}
|
package/dist/ensure-database.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{SQL}from"bun";import{env as envVars}from"@stacksjs/env";import{DB_HOST_DEFAULT,DB_NAMES,DB_PORTS,DB_USERS,getConnectionDefaults}from"./defaults";const DEFAULT_TIMEOUT_MS=1e4,UNSAFE_IDENTIFIER_CHARS=/["'`\\;\u0000\n\r]/,MAX_IDENTIFIER_LENGTH=63;export function isValidDatabaseIdentifier(name){if(!name||name.length>MAX_IDENTIFIER_LENGTH)return!1;return!UNSAFE_IDENTIFIER_CHARS.test(name)}export function quoteIdentifier(dialect,name){return dialect==="mysql"?`\`${name}\``:`"${name}"`}function stripWrappingQuotes(value){return value.replace(/^['"]|['"]$/g,"")}export function classifyConnectionError(error){const e=error,errno=e?.errno,code=typeof e?.code==="string"?e.code:"",message=typeof e?.message==="string"?e.message:String(error??""),sqlState=typeof errno==="string"?errno.toUpperCase():"",mysqlErrno=typeof errno==="number"?errno:Number.NaN;if(sqlState==="3D000")return"missing-database";if(sqlState==="28000"||sqlState==="28P01")return sqlState==="28P01"?"auth-failed":"missing-role";if(sqlState==="42501")return"permission-denied";if(mysqlErrno===1049)return"missing-database";if(mysqlErrno===1045)return"auth-failed";if(mysqlErrno===1044)return"permission-denied";if(mysqlErrno===2002||mysqlErrno===2003)return"server-unreachable";if(code.includes("CONNECTION_REFUSED")||code.includes("CONNECTION_CLOSED")||code==="ECONNREFUSED"||code==="ENOTFOUND"||code==="EHOSTUNREACH")return"server-unreachable";if(code==="ETIMEDOUT"||code.includes("TIMEOUT"))return"timeout";if(/database .* does not exist|unknown database/i.test(message))return"missing-database";if(/role .* does not exist|user .* does not exist/i.test(message))return"missing-role";if(/password authentication failed|access denied for user/i.test(message))return"auth-failed";if(/permission denied|insufficient privilege/i.test(message))return"permission-denied";if(/econnrefused|connection refused|can'?t connect/i.test(message))return"server-unreachable";return"unknown"}export function resolveConnectionTarget(envProxy=envVars){const driver=String(envProxy.DB_CONNECTION||"sqlite");if(driver!=="postgres"&&driver!=="mysql"&&driver!=="singlestore")return null;const dialect=driver==="postgres"?"postgres":"mysql",defaults=getConnectionDefaults(dialect,envProxy),database=stripWrappingQuotes(String(envProxy.DB_DATABASE||defaults.database||DB_NAMES.default)),host=String(envProxy.DB_HOST||defaults.host||DB_HOST_DEFAULT),port=Number(envProxy.DB_PORT||defaults.port||DB_PORTS[dialect]),username=String(envProxy.DB_USERNAME||defaults.username||DB_USERS[dialect]),password=String(envProxy.DB_PASSWORD??defaults.password??"");return{dialect,driver,database,host,port,username,password,maintenanceCandidates:dialect==="postgres"?["postgres","template1"]:["information_schema","mysql"]}}export function buildConnectionUrl(target,database){const scheme=target.dialect==="postgres"?"postgres":"mysql",auth=target.password?`${encodeURIComponent(target.username)}:${encodeURIComponent(target.password)}`:encodeURIComponent(target.username),sslEnv=process.env.DB_SSL,ssl=sslEnv==="true"||sslEnv==="1"?"?ssl=true":"";return`${scheme}://${auth}@${target.host}:${target.port}/${encodeURIComponent(database)}${ssl}`}function defaultConnect(url){return new SQL(url)}async function withConnection(target,database,deps,work){const connect=deps.connect??defaultConnect,timeoutMs=deps.timeoutMs??Number(process.env.DB_PREFLIGHT_TIMEOUT_MS||DEFAULT_TIMEOUT_MS),client=connect(buildConnectionUrl(target,database));let timer;try{const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>{const e=Error(`Timed out after ${timeoutMs}ms connecting to ${target.dialect} at ${target.host}:${target.port}`);e.code="ETIMEDOUT";reject(e)},timeoutMs)});return await Promise.race([work(client),timeout])}finally{if(timer)clearTimeout(timer);try{await client.close()}catch{}}}export async function probeTargetDatabase(target,deps={}){try{await withConnection(target,target.database,deps,async(client)=>client.unsafe("select 1"));return{ok:!0}}catch(error){return{ok:!1,kind:classifyConnectionError(error),error}}}export async function createDatabase(target,deps={}){if(!isValidDatabaseIdentifier(target.database))return{created:!1,kind:"unknown",error:Error(`Refusing to create a database named ${JSON.stringify(target.database)}. Database names must be 1 to 63 characters and may not contain quotes, backslashes, semicolons, or newlines.`)};const identifier=quoteIdentifier(target.dialect,target.database),sql=target.dialect==="mysql"?`CREATE DATABASE IF NOT EXISTS ${identifier}`:`CREATE DATABASE ${identifier}`;let lastError,lastKind="unknown";for(const candidate of target.maintenanceCandidates)try{await withConnection(target,candidate,deps,async(client)=>client.unsafe(sql));return{created:!0,via:candidate}}catch(error){const kind=classifyConnectionError(error),message=
|
|
1
|
+
import process from"node:process";import{SQL}from"bun";import{env as envVars}from"@stacksjs/env";import{DB_HOST_DEFAULT,DB_NAMES,DB_PORTS,DB_USERS,getConnectionDefaults}from"./defaults";const DEFAULT_TIMEOUT_MS=1e4,UNSAFE_IDENTIFIER_CHARS=/["'`\\;\u0000\n\r]/,MAX_IDENTIFIER_LENGTH=63;export function isValidDatabaseIdentifier(name){if(!name||name.length>MAX_IDENTIFIER_LENGTH)return!1;return!UNSAFE_IDENTIFIER_CHARS.test(name)}export function quoteIdentifier(dialect,name){return dialect==="mysql"?`\`${name}\``:`"${name}"`}function stripWrappingQuotes(value){return value.replace(/^['"]|['"]$/g,"")}export function classifyConnectionError(error){const e=error,errno=e?.errno,code=typeof e?.code==="string"?e.code:"",message=typeof e?.message==="string"?e.message:String(error??""),sqlState=typeof errno==="string"?errno.toUpperCase():"",mysqlErrno=typeof errno==="number"?errno:Number.NaN;if(sqlState==="3D000")return"missing-database";if(sqlState==="28000"||sqlState==="28P01")return sqlState==="28P01"?"auth-failed":"missing-role";if(sqlState==="42501")return"permission-denied";if(mysqlErrno===1049)return"missing-database";if(mysqlErrno===1045)return"auth-failed";if(mysqlErrno===1044)return"permission-denied";if(mysqlErrno===2002||mysqlErrno===2003)return"server-unreachable";if(code.includes("CONNECTION_REFUSED")||code.includes("CONNECTION_CLOSED")||code==="ECONNREFUSED"||code==="ENOTFOUND"||code==="EHOSTUNREACH")return"server-unreachable";if(code==="ETIMEDOUT"||code.includes("TIMEOUT"))return"timeout";if(/database .* does not exist|unknown database/i.test(message))return"missing-database";if(/role .* does not exist|user .* does not exist/i.test(message))return"missing-role";if(/password authentication failed|access denied for user/i.test(message))return"auth-failed";if(/permission denied|insufficient privilege/i.test(message))return"permission-denied";if(/econnrefused|connection refused|can'?t connect/i.test(message))return"server-unreachable";return"unknown"}export function resolveConnectionTarget(envProxy=envVars){const driver=String(envProxy.DB_CONNECTION||"sqlite");if(driver!=="postgres"&&driver!=="mysql"&&driver!=="singlestore")return null;const dialect=driver==="postgres"?"postgres":"mysql",defaults=getConnectionDefaults(dialect,envProxy),database=stripWrappingQuotes(String(envProxy.DB_DATABASE||defaults.database||DB_NAMES.default)),host=String(envProxy.DB_HOST||defaults.host||DB_HOST_DEFAULT),port=Number(envProxy.DB_PORT||defaults.port||DB_PORTS[dialect]),username=String(envProxy.DB_USERNAME||defaults.username||DB_USERS[dialect]),password=String(envProxy.DB_PASSWORD??defaults.password??"");return{dialect,driver,database,host,port,username,password,maintenanceCandidates:dialect==="postgres"?["postgres","template1"]:["information_schema","mysql"]}}export function buildConnectionUrl(target,database){const scheme=target.dialect==="postgres"?"postgres":"mysql",auth=target.password?`${encodeURIComponent(target.username)}:${encodeURIComponent(target.password)}`:encodeURIComponent(target.username),sslEnv=process.env.DB_SSL,ssl=sslEnv==="true"||sslEnv==="1"?"?ssl=true":"";return`${scheme}://${auth}@${target.host}:${target.port}/${encodeURIComponent(database)}${ssl}`}function defaultConnect(url){return new SQL(url)}async function withConnection(target,database,deps,work){const connect=deps.connect??defaultConnect,timeoutMs=deps.timeoutMs??Number(process.env.DB_PREFLIGHT_TIMEOUT_MS||DEFAULT_TIMEOUT_MS),client=connect(buildConnectionUrl(target,database));let timer;try{const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>{const e=Error(`Timed out after ${timeoutMs}ms connecting to ${target.dialect} at ${target.host}:${target.port}`);e.code="ETIMEDOUT";reject(e)},timeoutMs)});return await Promise.race([work(client),timeout])}finally{if(timer)clearTimeout(timer);try{await client.close()}catch{}}}export async function probeTargetDatabase(target,deps={}){try{await withConnection(target,target.database,deps,async(client)=>client.unsafe("select 1"));return{ok:!0}}catch(error){return{ok:!1,kind:classifyConnectionError(error),error}}}export async function createDatabase(target,deps={}){if(!isValidDatabaseIdentifier(target.database))return{created:!1,kind:"unknown",error:Error(`Refusing to create a database named ${JSON.stringify(target.database)}. Database names must be 1 to 63 characters and may not contain quotes, backslashes, semicolons, or newlines.`)};const identifier=quoteIdentifier(target.dialect,target.database),sql=target.dialect==="mysql"?`CREATE DATABASE IF NOT EXISTS ${identifier}`:`CREATE DATABASE ${identifier}`;let lastError,lastKind="unknown";for(const candidate of target.maintenanceCandidates)try{await withConnection(target,candidate,deps,async(client)=>client.unsafe(sql));return{created:!0,via:candidate}}catch(error){const kind=classifyConnectionError(error),failure=error,message=failure?.message??"",errno=failure?.errno;if(errno==="42P04"||errno===1007||/already exists|database exists/i.test(String(message)))return{created:!1,via:candidate};lastError=error;lastKind=kind;if(kind==="permission-denied"||kind==="auth-failed"||kind==="missing-role")break}return{created:!1,kind:lastKind,error:lastError}}export async function canCreateDatabases(target,deps={}){if(target.dialect!=="postgres")return null;for(const candidate of target.maintenanceCandidates)try{return await withConnection(target,candidate,deps,async(client)=>{const rows=await client.unsafe("select rolcreatedb, rolsuper from pg_roles where rolname = current_user"),row=Array.isArray(rows)?rows[0]:void 0;if(!row)return null;return Boolean(row.rolcreatedb||row.rolsuper)})}catch{}return null}export function manualCreateHint(target){if(target.dialect==="postgres")return`createdb -h ${target.host} -p ${target.port} -U ${target.username} ${target.database}`;return`mysql -h ${target.host} -P ${target.port} -u ${target.username} -e "CREATE DATABASE \\\`${target.database}\\\`"`}export function describeTarget(target){return`the ${target.driver} connection (${target.host}:${target.port}, user "${target.username}")`}
|
|
@@ -578,7 +578,7 @@ export declare interface FrameworkSchema {
|
|
|
578
578
|
uuid: string
|
|
579
579
|
created_at: string
|
|
580
580
|
updated_at: string | null
|
|
581
|
-
|
|
581
|
+
courier: string
|
|
582
582
|
vehicle: string
|
|
583
583
|
stops: number
|
|
584
584
|
delivery_time: number
|
|
@@ -587,7 +587,7 @@ export declare interface FrameworkSchema {
|
|
|
587
587
|
status: "planned" | "active" | "completed" | "cancelled"
|
|
588
588
|
started_at: string
|
|
589
589
|
completed_at: string
|
|
590
|
-
|
|
590
|
+
courier_id: number
|
|
591
591
|
createdAt: string
|
|
592
592
|
updatedAt: string | null
|
|
593
593
|
deliveryTime: number
|
|
@@ -595,7 +595,7 @@ export declare interface FrameworkSchema {
|
|
|
595
595
|
lastActive: number
|
|
596
596
|
startedAt: string
|
|
597
597
|
completedAt: string
|
|
598
|
-
|
|
598
|
+
courierId: number
|
|
599
599
|
}
|
|
600
600
|
delivery_stops: {
|
|
601
601
|
id: number
|
|
@@ -666,7 +666,7 @@ export declare interface FrameworkSchema {
|
|
|
666
666
|
requiresLogin: boolean
|
|
667
667
|
automaticDelivery: boolean
|
|
668
668
|
}
|
|
669
|
-
|
|
669
|
+
courier_pings: {
|
|
670
670
|
id: number
|
|
671
671
|
uuid: string
|
|
672
672
|
created_at: string
|
|
@@ -677,15 +677,15 @@ export declare interface FrameworkSchema {
|
|
|
677
677
|
speed: number
|
|
678
678
|
accuracy: number
|
|
679
679
|
recorded_at: string
|
|
680
|
-
|
|
680
|
+
courier_id: number
|
|
681
681
|
delivery_route_id: number
|
|
682
682
|
createdAt: string
|
|
683
683
|
updatedAt: string | null
|
|
684
684
|
recordedAt: string
|
|
685
|
-
|
|
685
|
+
courierId: number
|
|
686
686
|
deliveryRouteId: number
|
|
687
687
|
}
|
|
688
|
-
|
|
688
|
+
couriers: {
|
|
689
689
|
id: number
|
|
690
690
|
uuid: string
|
|
691
691
|
created_at: string
|
package/dist/migrations.js
CHANGED
|
@@ -15,7 +15,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
|
|
|
15
15
|
`}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){try{const row=await db.selectFrom("migrations").select((eb)=>eb.fn.count("id").as("n")).executeTakeFirst();if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
|
|
16
16
|
`)};
|
|
17
17
|
`}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}export async function runDatabaseMigration(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect(),lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb);if(dialect==="sqlite")preprocessSqliteMigrations();else if(dialect==="postgres")makeMigrationsIdempotent();const modelsDir=path.userModelsPath(),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
|
|
18
|
-
`)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();log.debug(`[migration] Running migrations from: ${modelsDir}`);await qbExecuteMigration(modelsDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const
|
|
18
|
+
`)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();log.debug(`[migration] Running migrations from: ${modelsDir}`);await qbExecuteMigration(modelsDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
|
|
19
19
|
SELECT t.typname AS name
|
|
20
20
|
FROM pg_type t
|
|
21
21
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
@@ -26,7 +26,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
|
|
|
26
26
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
27
27
|
WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
|
|
28
28
|
)
|
|
29
|
-
`).execute(),names=(Array.isArray(rows)?rows:rows?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect();let operations=(await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb})).operations??[];if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export const GENERATED_MIGRATION_MARKER=["qb:generated","@generated by `buddy migrate:regenerate` - edits will be overwritten"].map((line)=>`-- ${line}`).join(`
|
|
29
|
+
`).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect();let operations=(await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb})).operations??[];if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export const GENERATED_MIGRATION_MARKER=["qb:generated","@generated by `buddy migrate:regenerate` - edits will be overwritten"].map((line)=>`-- ${line}`).join(`
|
|
30
30
|
`);export function isGeneratedMigration(dir,file){try{return readFileSync(join(dir,file),"utf8").slice(0,200).includes("@generated by `buddy migrate:regenerate`")}catch{return!1}}export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function regenerateMigrationCorpus(options={}){try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?[]:historicallyRootedTables(dir,existing)),outOfScope=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),removed=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:preserved.reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(preserved.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir});mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
31
31
|
`)};
|
|
32
32
|
`;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
|
package/dist/query-logger.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import{sqlDateTime}from"./sql-helpers";import{memoryUsage}from"node:process";import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";import{parseQuery}from"./query-parser";import{db}from"./utils";let trackQuery=()=>{};export function setQueryTracker(fn){trackQuery=fn}let isLogging=!1;export async function logQuery(event){if(isLogging)return;try{const{query,durationMs,error,bindings}=extractQueryInfo(event);if(query)try{trackQuery(query,durationMs,config.database?.default||"unknown")}catch{}if(!config.database?.queryLogging?.enabled)return;const excludedQueries=config.database?.queryLogging?.excludedQueries;if(!query.trim()||isExcludedQuery(query,Array.isArray(excludedQueries)?excludedQueries:[]))return;const status=determineQueryStatus(durationMs,error),logRecord=await createQueryLogRecord(query,durationMs,status,error,bindings);if(config.database?.queryLogging?.analysis?.enabled&&(status==="slow"||config.database.queryLogging.analysis.analyzeAll))await enhanceWithQueryAnalysis(logRecord);isLogging=!0;try{await storeQueryLog(logRecord)}finally{isLogging=!1}if(status!=="completed")log[status==="failed"?"error":"warn"](`Query ${status}:`,{query:logRecord.query,duration:logRecord.duration,connection:logRecord.connection,...error&&{error}})}catch(err){log.error("Failed to log query:",err)}}export function isExcludedQuery(query,excludedQueries){const normalizedQuery=query.toLowerCase();return excludedQueries.some((pattern)=>{const normalizedPattern=pattern.trim().toLowerCase();return normalizedPattern.length>0&&normalizedQuery.includes(normalizedPattern)})}function queryText(sql){if(typeof sql==="string")return usableQueryText(sql);if(sql&&typeof sql==="object"&&typeof sql.then==="function")return"";if(sql&&typeof sql.toString==="function")return usableQueryText(String(sql));return""}function usableQueryText(text){return text.startsWith("[object ")?"":text}function extractQueryInfo(event){const query=queryText(event.query?.sql),durationMs=event.queryDurationMillis||0,error=event.error;let bindings;if(event.query?.parameters)try{bindings=JSON.stringify(event.query.parameters)}catch{bindings="[]"}return{query,durationMs,error,bindings}}function determineQueryStatus(durationMs,error){const slowThreshold=config.database?.queryLogging?.slowThreshold||100;if(error)return"failed";if(durationMs>slowThreshold)return"slow";return"completed"}async function createQueryLogRecord(query,durationMs,status,error,bindings){const connection=config.database.default||"unknown",normalizedQuery=parseQuery(query).normalized||query,{trace,caller}=extractTraceInfo();return{query,normalized_query:normalizedQuery,duration:durationMs,connection,status,error:error?String(error):void 0,executed_at:sqlDateTime(),bindings,trace,...caller,memory_usage:memoryUsage().heapUsed/1024/1024}}const SECRET_PATTERNS=[/\b(?:sk|pk|rk|api|secret|token|bearer|password|pwd|key)[_-]?[A-Za-z0-9]{12,}/gi,/\bsk_(?:live|test)_[A-Za-z0-9]{20,}/g,/\bAKIA[0-9A-Z]{16}/g,/\b[A-Za-z0-9_-]{40,}\b(?=\s|$|['"])/g];function sanitizeStackTrace(stack){let out=stack;for(const pattern of SECRET_PATTERNS)out=out.replace(pattern,"<redacted>");return out}function extractTraceInfo(){try{const stack=Error("Stack trace capture").stack||"",callerLine=stack.split(`
|
|
2
|
-
`).slice(1).find((line)=>!line.includes("query-logger.ts"));let caller={};if(callerLine){const methodMatch=callerLine.match(/at (.+?) \(/),fileMatch=callerLine.match(/\((.+?):(\d+):(\d+)\)/);if(methodMatch&&methodMatch[1]){const methodParts=methodMatch[1].split(".");caller={model:methodParts.length>1?methodParts[0]:void 0,method:methodParts.length>1?methodParts[1]:methodParts[0]}}if(fileMatch&&fileMatch[1]&&fileMatch[2])caller={...caller,file:fileMatch[1],line:Number.parseInt(fileMatch[2],10)}}return{trace:sanitizeStackTrace(stack),caller}}catch{return{trace:"",caller:{}}}}async function enhanceWithQueryAnalysis(logRecord){try{const{tables,type}=parseQuery(logRecord.query);logRecord.affected_tables=JSON.stringify(tables||[]);if(type==="SELECT"&&config.database?.queryLogging?.analysis?.explainPlan){const explainResult=await getExplainPlan(logRecord.query);if(explainResult){logRecord.explain_plan=explainResult.plan;logRecord.indexes_used=JSON.stringify(explainResult.indexesUsed||[]);logRecord.missing_indexes=JSON.stringify(explainResult.missingIndexes||[]);if(config.database?.queryLogging?.analysis?.suggestions)logRecord.optimization_suggestions=JSON.stringify(generateOptimizationSuggestions(explainResult,logRecord))}}const tags=[type];if(tables&&tables.length>0)tags.push(...tables.map((table)=>`table:${table}`));logRecord.tags=JSON.stringify(tags)}catch(error){log.debug("Error during query analysis:",error)}}const EXPLAIN_DIALECTS=new Set(["sqlite","mysql","postgres"]);async function getExplainPlan(query){try{const rawDriver=(await import("@stacksjs/config")).config?.database?.default,driver=typeof rawDriver==="string"?rawDriver:"sqlite";if(!EXPLAIN_DIALECTS.has(driver))return null;let sqlText;if(driver==="mysql")sqlText=`EXPLAIN FORMAT=JSON ${query}`;else if(driver==="postgres")sqlText=`EXPLAIN (FORMAT JSON) ${query}`;else sqlText=`EXPLAIN QUERY PLAN ${query}`;const result=await db.unsafe?.(sqlText);if(!result)return null;const rows=Array.isArray(
|
|
2
|
+
`).slice(1).find((line)=>!line.includes("query-logger.ts"));let caller={};if(callerLine){const methodMatch=callerLine.match(/at (.+?) \(/),fileMatch=callerLine.match(/\((.+?):(\d+):(\d+)\)/);if(methodMatch&&methodMatch[1]){const methodParts=methodMatch[1].split(".");caller={model:methodParts.length>1?methodParts[0]:void 0,method:methodParts.length>1?methodParts[1]:methodParts[0]}}if(fileMatch&&fileMatch[1]&&fileMatch[2])caller={...caller,file:fileMatch[1],line:Number.parseInt(fileMatch[2],10)}}return{trace:sanitizeStackTrace(stack),caller}}catch{return{trace:"",caller:{}}}}async function enhanceWithQueryAnalysis(logRecord){try{const{tables,type}=parseQuery(logRecord.query);logRecord.affected_tables=JSON.stringify(tables||[]);if(type==="SELECT"&&config.database?.queryLogging?.analysis?.explainPlan){const explainResult=await getExplainPlan(logRecord.query);if(explainResult){logRecord.explain_plan=explainResult.plan;logRecord.indexes_used=JSON.stringify(explainResult.indexesUsed||[]);logRecord.missing_indexes=JSON.stringify(explainResult.missingIndexes||[]);if(config.database?.queryLogging?.analysis?.suggestions)logRecord.optimization_suggestions=JSON.stringify(generateOptimizationSuggestions(explainResult,logRecord))}}const tags=[type];if(tables&&tables.length>0)tags.push(...tables.map((table)=>`table:${table}`));logRecord.tags=JSON.stringify(tags)}catch(error){log.debug("Error during query analysis:",error)}}const EXPLAIN_DIALECTS=new Set(["sqlite","mysql","postgres"]);async function getExplainPlan(query){try{const rawDriver=(await import("@stacksjs/config")).config?.database?.default,driver=typeof rawDriver==="string"?rawDriver:"sqlite";if(!EXPLAIN_DIALECTS.has(driver))return null;let sqlText;if(driver==="mysql")sqlText=`EXPLAIN FORMAT=JSON ${query}`;else if(driver==="postgres")sqlText=`EXPLAIN (FORMAT JSON) ${query}`;else sqlText=`EXPLAIN QUERY PLAN ${query}`;const result=await db.unsafe?.(sqlText);if(!result)return null;const raw=result,rows=Array.isArray(raw)?raw:raw?.rows??[],planText=JSON.stringify(rows),indexesUsed=[],missingIndexes=[];if(driver==="sqlite")for(const row of rows){const detail=row?.detail||"",idxMatch=detail.match(/USING (?:COVERING )?INDEX (\w+)/i);if(idxMatch&&idxMatch[1])indexesUsed.push(idxMatch[1]);else{const scanMatch=detail.match(/^SCAN\s+(\w+)/i);if(scanMatch&&scanMatch[1])missingIndexes.push(scanMatch[1])}}else if(driver==="mysql"){const text=planText;for(const m of text.matchAll(/"key"\s*:\s*"([^"]+)"/g))if(m[1])indexesUsed.push(m[1]);for(const m of text.matchAll(/"table_name"\s*:\s*"([^"]+)"[^}]*"access_type"\s*:\s*"ALL"/g))if(m[1])missingIndexes.push(m[1])}else if(driver==="postgres"){const text=planText;for(const m of text.matchAll(/"Index Name"\s*:\s*"([^"]+)"/g))if(m[1])indexesUsed.push(m[1]);for(const m of text.matchAll(/"Node Type"\s*:\s*"Seq Scan"[^}]*"Relation Name"\s*:\s*"([^"]+)"/g))if(m[1])missingIndexes.push(m[1])}return{plan:planText.length>4000?`${planText.slice(0,4000)}\u2026`:planText,indexesUsed:Array.from(new Set(indexesUsed)),missingIndexes:Array.from(new Set(missingIndexes))}}catch(err){log.debug("[query-logger] EXPLAIN failed (non-fatal):",err);return null}}function generateOptimizationSuggestions(explainResult,logRecord){const suggestions=[];if(explainResult.missingIndexes&&explainResult.missingIndexes.length>0)suggestions.push(`Consider adding index on ${explainResult.missingIndexes.join(", ")}`);if(logRecord.status==="slow"){suggestions.push("Consider optimizing this query to reduce execution time");if(logRecord.query.toLowerCase().includes("select *"))suggestions.push("Specify only needed columns instead of using SELECT *");if(!logRecord.query.toLowerCase().includes("limit"))suggestions.push("Consider adding LIMIT clause to reduce result set size")}return suggestions}async function storeQueryLog(logRecord){try{await db.insertInto("query_logs").values(logRecord).execute()}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|does not exist|doesn't exist/i.test(message)&&/query_logs/i.test(message))log.debug("Query logging will start after the query_logs table is migrated.");else log.error("Failed to store query log:",error)}}
|
package/dist/safe-migrations.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{db}from"./utils";
|
|
1
|
+
import{db}from"./utils";export async function addColumnSafely(db,tableName,columnName,options){const{type,defaultValue,notNull=!1,batchSize=1000}=options,dbAny=db,defaultSql=defaultValue===void 0?"":` DEFAULT ${formatDefault(defaultValue)}`;await execRaw(dbAny,`ALTER TABLE ${quote(tableName)} ADD COLUMN ${quote(columnName)} ${type}${defaultSql}`);if(defaultValue!==void 0)await backfillInBatches(db,tableName,columnName,defaultValue,batchSize);if(notNull)await execRaw(dbAny,`ALTER TABLE ${quote(tableName)} ALTER COLUMN ${quote(columnName)} SET NOT NULL`)}async function execRaw(dbAny,statement){if(typeof dbAny.unsafe==="function")return await dbAny.unsafe(statement)??{};throw TypeError(`This database connection exposes no \`unsafe()\`, which is the only way these safe-migration helpers can run raw DDL. Statement: ${statement}`)}export async function backfillInBatches(db,tableName,columnName,value,batchSize=1000){const dbAny=db;let updated=0,total=0;do{const batchSql=`
|
|
2
2
|
UPDATE ${quote(tableName)} SET ${quote(columnName)} = ${formatDefault(value)}
|
|
3
3
|
WHERE ${quote(columnName)} IS NULL
|
|
4
4
|
AND ${rowIdColumnFor(dbAny)} IN (
|
package/dist/types.d.ts
CHANGED
|
@@ -78,7 +78,7 @@ export declare interface AggregateExpression {
|
|
|
78
78
|
* `coalesce`, etc.) can be added here as call sites surface; we
|
|
79
79
|
* deliberately don't widen to "everything Kysely exposes" because
|
|
80
80
|
* that surface keeps growing and an `any`-typed escape hatch always
|
|
81
|
-
* exists (`eb.fn
|
|
81
|
+
* exists (`eb.fn).newThing(...)`) if a one-off bypass is
|
|
82
82
|
* genuinely needed.
|
|
83
83
|
*/
|
|
84
84
|
export declare interface ExpressionFunctions {
|
package/dist/utils.d.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { createQueryBuilder, setConfig } from '@stacksjs/query-builder';
|
|
|
2
2
|
import type { FrameworkSchema } from './framework-schema';
|
|
3
3
|
import type { QueryHooks } from '@stacksjs/query-builder';
|
|
4
4
|
export declare function acquireDbConfigLock(): Promise<() => void>;
|
|
5
|
-
|
|
6
|
-
export declare function initializeDbConfig(config: any): void;
|
|
5
|
+
export declare function initializeDbConfig(config: DbConfigSource | null | undefined): void;
|
|
7
6
|
/**
|
|
8
7
|
* The snapshot directory to hand to `setConfig`, resolved at call time.
|
|
9
8
|
*
|
|
@@ -107,6 +106,43 @@ export declare const db: Db;
|
|
|
107
106
|
* re-declaring every chain entry point.
|
|
108
107
|
*/
|
|
109
108
|
export declare const readDb: Omit<Db, 'read'>;
|
|
109
|
+
declare interface DbConnectionConfig {
|
|
110
|
+
database?: string
|
|
111
|
+
name?: string
|
|
112
|
+
host?: string
|
|
113
|
+
username?: string
|
|
114
|
+
password?: string
|
|
115
|
+
port?: number
|
|
116
|
+
prefix?: string
|
|
117
|
+
pool?: PoolConfig
|
|
118
|
+
replicas?: ReplicaConfig[]
|
|
119
|
+
sharded?: boolean
|
|
120
|
+
}
|
|
121
|
+
declare interface DbConfig {
|
|
122
|
+
default?: string
|
|
123
|
+
connections: {
|
|
124
|
+
sqlite: DbConnectionConfig
|
|
125
|
+
mysql: DbConnectionConfig
|
|
126
|
+
singlestore: DbConnectionConfig
|
|
127
|
+
vitess: DbConnectionConfig
|
|
128
|
+
postgres: DbConnectionConfig
|
|
129
|
+
}
|
|
130
|
+
reads?: ReadPolicyConfig
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The slice of the app config this reads, written out rather than left as
|
|
134
|
+
* `any`. It is called with the whole config object, so naming only what it
|
|
135
|
+
* consumes keeps it callable from anywhere while still checking the four
|
|
136
|
+
* paths it walks.
|
|
137
|
+
*/
|
|
138
|
+
export declare interface DbConfigSource {
|
|
139
|
+
app?: { env?: string }
|
|
140
|
+
database?: {
|
|
141
|
+
default?: string
|
|
142
|
+
connections?: Partial<DbConfig['connections']>
|
|
143
|
+
reads?: DbConfig['reads']
|
|
144
|
+
}
|
|
145
|
+
}
|
|
110
146
|
export declare interface DatabaseQueryLogEvent {
|
|
111
147
|
query: {
|
|
112
148
|
sql: string
|
|
@@ -115,6 +151,27 @@ export declare interface DatabaseQueryLogEvent {
|
|
|
115
151
|
queryDurationMillis: number
|
|
116
152
|
error?: unknown
|
|
117
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* What a write statement run through `db.unsafe` resolves to.
|
|
156
|
+
*
|
|
157
|
+
* `UnsafeReturn` above describes a SELECT - the rows. An UPDATE, INSERT or
|
|
158
|
+
* DELETE resolves to the driver's own result object instead, and every driver
|
|
159
|
+
* spells the affected-row count differently, which is why callers read all of
|
|
160
|
+
* these in turn. They were reaching for the fields off a value typed as
|
|
161
|
+
* `UnsafeRow[]`, which has none of them, behind a `(db)`.
|
|
162
|
+
*/
|
|
163
|
+
export declare interface DbWriteResult {
|
|
164
|
+
changes?: number
|
|
165
|
+
numUpdatedRows?: number | bigint
|
|
166
|
+
numAffectedRows?: number | bigint
|
|
167
|
+
numDeletedRows?: number | bigint
|
|
168
|
+
affectedRows?: number
|
|
169
|
+
rowsAffected?: number
|
|
170
|
+
rowCount?: number
|
|
171
|
+
lastInsertRowid?: number | bigint
|
|
172
|
+
insertId?: number | bigint
|
|
173
|
+
[index: number]: DbWriteResult | undefined
|
|
174
|
+
}
|
|
118
175
|
/**
|
|
119
176
|
* What an insert reports when it was not asked to return rows.
|
|
120
177
|
*
|
|
@@ -339,6 +396,17 @@ declare interface Db extends Pick<Required<RawQueryBuilder>, GenericPassthroughK
|
|
|
339
396
|
*/
|
|
340
397
|
declare type UnsafeRow = Record<string, unknown>;
|
|
341
398
|
declare type UnsafeReturn = Promise<UnsafeRow[]> & { execute: () => Promise<UnsafeRow[]> }
|
|
399
|
+
/**
|
|
400
|
+
* A raw result as the drivers actually hand it back.
|
|
401
|
+
*
|
|
402
|
+
* `UnsafeReturn` above says "the rows", and for most drivers that is true. Some
|
|
403
|
+
* answer `{ rows: [...] }` and some answer nothing at all, which is why callers
|
|
404
|
+
* across migrations, the query logger and the scheduler all write
|
|
405
|
+
* `Array.isArray(r) ? r : (r?.rows ?? [])`. Behind a `(db)` that read
|
|
406
|
+
* typechecked; without one the `.rows` branch narrows to `never`, because the
|
|
407
|
+
* declared type admits only the array. This names the shape they handle.
|
|
408
|
+
*/
|
|
409
|
+
export type UnsafeRowsResult = UnsafeRow[] | { rows?: UnsafeRow[] } | undefined;
|
|
342
410
|
/**
|
|
343
411
|
* The keys a row type actually declares, or `never` for a loose record.
|
|
344
412
|
*
|
package/dist/utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{AsyncLocalStorage}from"node:async_hooks";import{createQueryBuilder,registerPersistentQueryHooks,resetConnection as resetQueryBuilderConnection,setConfig}from"@stacksjs/query-builder";import{SQL}from"bun";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isMysqlWire,isVitessSharded,toQueryBuilderDialect}from"./dialect";import{relativeMigrationDirectory,resolveMigrationDirectory,snapshotDirForQueryBuilder}from"./migration-path";import{contextInTransaction,markContextWrote,resolveReplicaConnection,selectReplica,shouldRouteToReplica,withTransactionContext}from"./replicas";import{aggregateFunctions}from"./types";const sqliteDefaults=getConnectionDefaults("sqlite",envVars),mysqlDefaults=getConnectionDefaults("mysql",envVars),postgresDefaults=getConnectionDefaults("postgres",envVars);let appEnv=envVars.APP_ENV||"local",dbDriver=envVars.DB_CONNECTION||"sqlite",dbConfig={connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},vitess:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:15306,prefix:"",sharded:isVitessSharded()},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}},dbConfigLockTail=Promise.resolve();const DB_CONFIG_LOCK_MAX_HOLD_MS=60000;export function acquireDbConfigLock(){let release=()=>{};const held=new Promise((resolve)=>{release=resolve}),acquired=dbConfigLockTail.then(()=>{const watchdog=setTimeout(()=>{console.warn(`[database] the db config lock was held for over ${DB_CONFIG_LOCK_MAX_HOLD_MS/1000}s. Releasing it so the queue can proceed - a test file most likely failed before its afterAll ran.`);release()},DB_CONFIG_LOCK_MAX_HOLD_MS);watchdog.unref?.();held.then(()=>clearTimeout(watchdog));return release});dbConfigLockTail=dbConfigLockTail.then(()=>held).catch(()=>{});return acquired}export function initializeDbConfig(config){if(config?.app?.env)appEnv=config.app.env;if(config?.database?.default)dbDriver=config.database.default;if(config?.database)dbConfig=config.database;updateQueryBuilderConfig();_dbInstance=null;_replicaInstances=new Map}function getEnv(){return appEnv}function getDriver(){return dbDriver}function getDatabaseConfig(){return dbConfig}function getDialect(){return toQueryBuilderDialect(getDriver())}function getDbConfig(){const driver=getDriver(),database=getDatabaseConfig(),env=getEnv();if(driver==="sqlite"){const defaultName=env!=="testing"?"database/stacks.sqlite":"database/stacks_testing.sqlite";return{database:database.connections?.sqlite?.database??defaultName}}if(driver==="mysql")return{database:database.connections?.mysql?.name||"stacks",host:database.connections?.mysql?.host??"127.0.0.1",username:database.connections?.mysql?.username??"root",password:database.connections?.mysql?.password??"",port:database.connections?.mysql?.port??3306};if(driver==="singlestore")return{database:database.connections?.singlestore?.name||"stacks",host:database.connections?.singlestore?.host??"127.0.0.1",username:database.connections?.singlestore?.username??"root",password:database.connections?.singlestore?.password??"",port:database.connections?.singlestore?.port??3306};if(driver==="vitess")return{database:database.connections?.vitess?.name||"stacks",host:database.connections?.vitess?.host??"127.0.0.1",username:database.connections?.vitess?.username??"root",password:database.connections?.vitess?.password??"",port:database.connections?.vitess?.port??15306};if(driver==="postgres"){const dbName=database.connections?.postgres?.name??"stacks";return{database:env==="testing"?`${dbName}_testing`:dbName,host:database.connections?.postgres?.host??"127.0.0.1",username:database.connections?.postgres?.username??"",password:database.connections?.postgres?.password??"",port:database.connections?.postgres?.port??5432}}return{database:":memory:"}}export const QB_SNAPSHOT_DIR="storage/framework/database";export function qbSnapshotDir(){return snapshotDirForQueryBuilder()}export const RAW_QUERY_SOFT_DELETE_CONFIG={enabled:!1,column:"deleted_at",defaultFilter:!0};export function createDatabaseQueryHooks(dispatch){function forward(event){try{Promise.resolve(dispatch(event)).catch(()=>{})}catch{}}return{onQueryEnd:(event)=>forward({query:{sql:event.sql,parameters:event.params},queryDurationMillis:event.durationMs}),onQueryError:(event)=>forward({query:{sql:event.sql,parameters:event.params},queryDurationMillis:event.durationMs,error:event.error})}}function forwardDatabaseQuery(event){import("./query-logger").then(({logQuery})=>logQuery(event)).catch(()=>{})}registerPersistentQueryHooks(createDatabaseQueryHooks(forwardDatabaseQuery));function getPoolConfig(){const driver=getDriver();if(driver==="sqlite")return;return getDatabaseConfig().connections?.[driver]?.pool}function getReplicas(){const driver=getDriver();if(driver==="sqlite")return[];return getDatabaseConfig().connections?.[driver]?.replicas??[]}function getReadPolicy(){return getDatabaseConfig().reads??{}}function toBunPoolOptions(pool){if(!pool)return{};const options={};if(pool.max!==void 0)options.max=pool.max;if(pool.idleTimeoutMs!==void 0)options.idleTimeout=Math.round(pool.idleTimeoutMs/1000);if(pool.acquireTimeoutMs!==void 0)options.connectionTimeout=Math.round(pool.acquireTimeoutMs/1000);if(pool.maxLifetimeMs!==void 0)options.maxLifetime=Math.round(pool.maxLifetimeMs/1000);return options}function updateQueryBuilderConfig(){const dialect=getDialect(),dbConfigForQb=getDbConfig(),pool=getPoolConfig();setConfig({dialect,vitess:{sharded:isVitessSharded(dbConfig.connections.vitess?.sharded)},database:pool?{...dbConfigForQb,pool}:dbConfigForQb,verbose:getEnv()!=="production",snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(resolveMigrationDirectory(toQueryBuilderDialect(dialect),{snapshotDir:qbSnapshotDir()})),timestamps:{createdAt:"created_at",updatedAt:"updated_at",defaultOrderColumn:"created_at"},softDeletes:RAW_QUERY_SOFT_DELETE_CONFIG})}updateQueryBuilderConfig();let _dbInstance=null,_configInitPromise=null;function ensureConfigLoaded(){if(!_configInitPromise)_configInitPromise=(async()=>{try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;if(config){initializeDbConfig(config);_dbInstance=null}}catch{}})();return _configInitPromise}export async function ensureDatabaseConfigLoaded(){await ensureConfigLoaded()}export{applySqlitePragmas,SQLITE_BOOTSTRAP_PRAGMAS}from"@stacksjs/query-builder";const sqliteTxOwner=new AsyncLocalStorage;let sqliteTxTail=Promise.resolve();function serializeSqliteTransaction(run){if(sqliteTxOwner.getStore())return run();const result=sqliteTxTail.then(()=>sqliteTxOwner.run(!0,run));sqliteTxTail=result.then(()=>{return},()=>{return});return result}function applySqliteTransactionSerialization(instance){const original=instance.transaction.bind(instance);instance.transaction=(...args)=>serializeSqliteTransaction(()=>original(...args))}function applyTransactionRoutingContext(instance){const original=instance.transaction.bind(instance);instance.transaction=(...args)=>withTransactionContext(()=>original(...args))}function getDb(){if(!_dbInstance){updateQueryBuilderConfig();_dbInstance=createQueryBuilder();if(getDialect()==="sqlite")applySqliteTransactionSerialization(_dbInstance);applyTransactionRoutingContext(_dbInstance)}return _dbInstance}let _replicaInstances=new Map;export function resetDatabaseConnection(){resetQueryBuilderConnection();_dbInstance=null;_replicaInstances=new Map}function getReplicaDb(replica){const primary=getDbConfig(),resolved=resolveReplicaConnection(replica,primary),key=`${resolved.host}:${resolved.port??""}`,cached=_replicaInstances.get(key);if(cached)return cached;const scheme=isMysqlWire(getDriver())?"mysql":"postgres",auth=resolved.username?`${encodeURIComponent(resolved.username)}:${encodeURIComponent(resolved.password??"")}@`:"",url=`${scheme}://${auth}${resolved.host}:${resolved.port}/${resolved.database}`,sql=new SQL({url,...toBunPoolOptions(getPoolConfig())}),instance=createQueryBuilder({sql});_replicaInstances.set(key,instance);return instance}function getReadDb(){const replicas=getReplicas(),policy=getReadPolicy();if(!shouldRouteToReplica({policy,replicas}))return getDb();const replica=selectReplica(replicas,policy.strategy);return replica?getReplicaDb(replica):getDb()}function getExplicitReadDb(){const replicas=getReplicas();if(!replicas.length||contextInTransaction())return getDb();const replica=selectReplica(replicas,getReadPolicy().strategy);return replica?getReplicaDb(replica):getDb()}const WRITE_ENTRY_POINTS=new Set(["insertInto","updateTable","deleteFrom","create","createMany","insertOrIgnore","insertGetId","updateOrInsert","upsert"]),READ_ENTRY_POINTS=new Set(["selectFrom","selectFromSub","select"]);ensureConfigLoaded();export function asRows(rows){return rows}export function asRow(row){return row}export const db=new Proxy({},{get(_target,prop){if(prop==="fn")return aggregateFunctions;if(prop==="read")return readDb;if(typeof prop==="string"&&WRITE_ENTRY_POINTS.has(prop))markContextWrote();const instance=typeof prop==="string"&&READ_ENTRY_POINTS.has(prop)?getReadDb():getDb(),value=instance[prop];if(typeof value==="function")return value.bind(instance);return value}}),readDb=new Proxy({},{get(_target,prop){if(prop==="fn")return aggregateFunctions;const instance=getExplicitReadDb(),value=instance[prop];if(typeof value==="function")return value.bind(instance);return value}});export{setConfig};
|
|
1
|
+
import{AsyncLocalStorage}from"node:async_hooks";import{createQueryBuilder,registerPersistentQueryHooks,resetConnection as resetQueryBuilderConnection,setConfig}from"@stacksjs/query-builder";import{SQL}from"bun";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isMysqlWire,isVitessSharded,toQueryBuilderDialect}from"./dialect";import{relativeMigrationDirectory,resolveMigrationDirectory,snapshotDirForQueryBuilder}from"./migration-path";import{contextInTransaction,markContextWrote,resolveReplicaConnection,selectReplica,shouldRouteToReplica,withTransactionContext}from"./replicas";import{aggregateFunctions}from"./types";const sqliteDefaults=getConnectionDefaults("sqlite",envVars),mysqlDefaults=getConnectionDefaults("mysql",envVars),postgresDefaults=getConnectionDefaults("postgres",envVars);let appEnv=envVars.APP_ENV||"local",dbDriver=envVars.DB_CONNECTION||"sqlite",dbConfig={connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},vitess:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:15306,prefix:"",sharded:isVitessSharded()},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}},dbConfigLockTail=Promise.resolve();const DB_CONFIG_LOCK_MAX_HOLD_MS=60000;export function acquireDbConfigLock(){let release=()=>{};const held=new Promise((resolve)=>{release=resolve}),acquired=dbConfigLockTail.then(()=>{const watchdog=setTimeout(()=>{console.warn(`[database] the db config lock was held for over ${DB_CONFIG_LOCK_MAX_HOLD_MS/1000}s. Releasing it so the queue can proceed - a test file most likely failed before its afterAll ran.`);release()},DB_CONFIG_LOCK_MAX_HOLD_MS);watchdog.unref?.();held.then(()=>clearTimeout(watchdog));return release});dbConfigLockTail=dbConfigLockTail.then(()=>held).catch(()=>{});return acquired}export function initializeDbConfig(config){if(config?.app?.env)appEnv=config.app.env;if(config?.database?.default)dbDriver=config.database.default;if(config?.database?.connections)dbConfig=config.database;updateQueryBuilderConfig();_dbInstance=null;_replicaInstances=new Map}function getEnv(){return appEnv}function getDriver(){return dbDriver}function getDatabaseConfig(){return dbConfig}function getDialect(){return toQueryBuilderDialect(getDriver())}function getDbConfig(){const driver=getDriver(),database=getDatabaseConfig(),env=getEnv();if(driver==="sqlite"){const defaultName=env!=="testing"?"database/stacks.sqlite":"database/stacks_testing.sqlite";return{database:database.connections?.sqlite?.database??defaultName}}if(driver==="mysql")return{database:database.connections?.mysql?.name||"stacks",host:database.connections?.mysql?.host??"127.0.0.1",username:database.connections?.mysql?.username??"root",password:database.connections?.mysql?.password??"",port:database.connections?.mysql?.port??3306};if(driver==="singlestore")return{database:database.connections?.singlestore?.name||"stacks",host:database.connections?.singlestore?.host??"127.0.0.1",username:database.connections?.singlestore?.username??"root",password:database.connections?.singlestore?.password??"",port:database.connections?.singlestore?.port??3306};if(driver==="vitess")return{database:database.connections?.vitess?.name||"stacks",host:database.connections?.vitess?.host??"127.0.0.1",username:database.connections?.vitess?.username??"root",password:database.connections?.vitess?.password??"",port:database.connections?.vitess?.port??15306};if(driver==="postgres"){const dbName=database.connections?.postgres?.name??"stacks";return{database:env==="testing"?`${dbName}_testing`:dbName,host:database.connections?.postgres?.host??"127.0.0.1",username:database.connections?.postgres?.username??"",password:database.connections?.postgres?.password??"",port:database.connections?.postgres?.port??5432}}return{database:":memory:"}}export const QB_SNAPSHOT_DIR="storage/framework/database";export function qbSnapshotDir(){return snapshotDirForQueryBuilder()}export const RAW_QUERY_SOFT_DELETE_CONFIG={enabled:!1,column:"deleted_at",defaultFilter:!0};export function createDatabaseQueryHooks(dispatch){function forward(event){try{Promise.resolve(dispatch(event)).catch(()=>{})}catch{}}return{onQueryEnd:(event)=>forward({query:{sql:event.sql,parameters:event.params},queryDurationMillis:event.durationMs}),onQueryError:(event)=>forward({query:{sql:event.sql,parameters:event.params},queryDurationMillis:event.durationMs,error:event.error})}}function forwardDatabaseQuery(event){import("./query-logger").then(({logQuery})=>logQuery(event)).catch(()=>{})}registerPersistentQueryHooks(createDatabaseQueryHooks(forwardDatabaseQuery));function getPoolConfig(){const driver=getDriver();if(driver==="sqlite")return;return getDatabaseConfig().connections?.[driver]?.pool}function getReplicas(){const driver=getDriver();if(driver==="sqlite")return[];return getDatabaseConfig().connections?.[driver]?.replicas??[]}function getReadPolicy(){return getDatabaseConfig().reads??{}}function toBunPoolOptions(pool){if(!pool)return{};const options={};if(pool.max!==void 0)options.max=pool.max;if(pool.idleTimeoutMs!==void 0)options.idleTimeout=Math.round(pool.idleTimeoutMs/1000);if(pool.acquireTimeoutMs!==void 0)options.connectionTimeout=Math.round(pool.acquireTimeoutMs/1000);if(pool.maxLifetimeMs!==void 0)options.maxLifetime=Math.round(pool.maxLifetimeMs/1000);return options}function updateQueryBuilderConfig(){const dialect=getDialect(),dbConfigForQb=getDbConfig(),pool=getPoolConfig();setConfig({dialect,vitess:{sharded:isVitessSharded(dbConfig.connections.vitess?.sharded)},database:pool?{...dbConfigForQb,pool}:dbConfigForQb,verbose:getEnv()!=="production",snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(resolveMigrationDirectory(toQueryBuilderDialect(dialect),{snapshotDir:qbSnapshotDir()})),timestamps:{createdAt:"created_at",updatedAt:"updated_at",defaultOrderColumn:"created_at"},softDeletes:RAW_QUERY_SOFT_DELETE_CONFIG})}updateQueryBuilderConfig();let _dbInstance=null,_configInitPromise=null;function ensureConfigLoaded(){if(!_configInitPromise)_configInitPromise=(async()=>{try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;if(config){initializeDbConfig(config);_dbInstance=null}}catch{}})();return _configInitPromise}export async function ensureDatabaseConfigLoaded(){await ensureConfigLoaded()}export{applySqlitePragmas,SQLITE_BOOTSTRAP_PRAGMAS}from"@stacksjs/query-builder";const sqliteTxOwner=new AsyncLocalStorage;let sqliteTxTail=Promise.resolve();function serializeSqliteTransaction(run){if(sqliteTxOwner.getStore())return run();const result=sqliteTxTail.then(()=>sqliteTxOwner.run(!0,run));sqliteTxTail=result.then(()=>{return},()=>{return});return result}function applySqliteTransactionSerialization(instance){const original=instance.transaction.bind(instance);instance.transaction=(...args)=>serializeSqliteTransaction(()=>original(...args))}function applyTransactionRoutingContext(instance){const original=instance.transaction.bind(instance);instance.transaction=(...args)=>withTransactionContext(()=>original(...args))}function getDb(){if(!_dbInstance){updateQueryBuilderConfig();_dbInstance=createQueryBuilder();if(getDialect()==="sqlite")applySqliteTransactionSerialization(_dbInstance);applyTransactionRoutingContext(_dbInstance)}return _dbInstance}let _replicaInstances=new Map;export function resetDatabaseConnection(){resetQueryBuilderConnection();_dbInstance=null;_replicaInstances=new Map}function getReplicaDb(replica){const primary=getDbConfig(),resolved=resolveReplicaConnection(replica,primary),key=`${resolved.host}:${resolved.port??""}`,cached=_replicaInstances.get(key);if(cached)return cached;const scheme=isMysqlWire(getDriver())?"mysql":"postgres",auth=resolved.username?`${encodeURIComponent(resolved.username)}:${encodeURIComponent(resolved.password??"")}@`:"",url=`${scheme}://${auth}${resolved.host}:${resolved.port}/${resolved.database}`,sql=new SQL({url,...toBunPoolOptions(getPoolConfig())}),instance=createQueryBuilder({sql});_replicaInstances.set(key,instance);return instance}function getReadDb(){const replicas=getReplicas(),policy=getReadPolicy();if(!shouldRouteToReplica({policy,replicas}))return getDb();const replica=selectReplica(replicas,policy.strategy);return replica?getReplicaDb(replica):getDb()}function getExplicitReadDb(){const replicas=getReplicas();if(!replicas.length||contextInTransaction())return getDb();const replica=selectReplica(replicas,getReadPolicy().strategy);return replica?getReplicaDb(replica):getDb()}const WRITE_ENTRY_POINTS=new Set(["insertInto","updateTable","deleteFrom","create","createMany","insertOrIgnore","insertGetId","updateOrInsert","upsert"]),READ_ENTRY_POINTS=new Set(["selectFrom","selectFromSub","select"]);ensureConfigLoaded();export function asRows(rows){return rows}export function asRow(row){return row}export const db=new Proxy({},{get(_target,prop){if(prop==="fn")return aggregateFunctions;if(prop==="read")return readDb;if(typeof prop==="string"&&WRITE_ENTRY_POINTS.has(prop))markContextWrote();const instance=typeof prop==="string"&&READ_ENTRY_POINTS.has(prop)?getReadDb():getDb(),value=instance[prop];if(typeof value==="function")return value.bind(instance);return value}}),readDb=new Proxy({},{get(_target,prop){if(prop==="fn")return aggregateFunctions;const instance=getExplicitReadDb(),value=instance[prop];if(typeof value==="function")return value.bind(instance);return value}});export{setConfig};
|
package/dist/vschema.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare function deriveVSchema(models: ShardableModel[]): VSchemaResult;
|
|
|
31
31
|
* array of names, or an object keyed by model name), so this flattens them
|
|
32
32
|
* to the one form the derivation needs.
|
|
33
33
|
*/
|
|
34
|
-
export declare function toShardableModel(definition:
|
|
34
|
+
export declare function toShardableModel(definition: { name?: string, belongsTo?: unknown, traits?: { useUuid?: unknown, sharding?: ShardableModel['sharding'] } } | null | undefined, table: string): ShardableModel;
|
|
35
35
|
/**
|
|
36
36
|
* Human-readable summary of the sharding decisions.
|
|
37
37
|
*
|
package/dist/vschema.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export function foreignKeyForModel(modelName){return`${modelName.replace(/([a-z0-9])([A-Z])/g,"$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").toLowerCase()}_id`}export function decideSharding(model,tableByModel){const declared=model.sharding;if(declared?.unsharded)return{table:model.table,column:null,vindex:null,reason:"reference table"};if(declared?.column)return{table:model.table,column:declared.column,vindex:declared.vindex??"hash",reason:"explicit"};const parentModel=model.belongsTo[0];if(parentModel){const parentTable=tableByModel.get(parentModel);return{table:model.table,column:foreignKeyForModel(parentModel),vindex:declared?.vindex??"hash",reason:"co-located with parent",parent:parentTable??parentModel,warning:model.belongsTo.length>1?`belongs to ${model.belongsTo.length} parents; sharded by ${parentModel} only, so joins through ${model.belongsTo.slice(1).join(", ")} will scatter`:void 0}}return{table:model.table,column:"id",vindex:declared?.vindex??"hash",reason:"root entity"}}export function deriveVSchema(models){const tableByModel=new Map(models.map((m)=>[m.name,m.table])),decisions=models.map((model)=>decideSharding(model,tableByModel)),vindexes={},tables={};for(const[index,decision]of decisions.entries()){const model=models[index];if(decision.reason==="reference table"){tables[decision.table]={type:"reference"};continue}const vindexType=decision.vindex??"hash";vindexes[vindexType]={type:vindexType};const table={column_vindexes:[{column:decision.column,name:vindexType}]};if(!model.useUuid)table.auto_increment={column:"id",sequence:model.sharding?.sequence??`${model.table}_seq`};tables[decision.table]=table}return{vschema:{sharded:!0,vindexes,tables},decisions}}export function toShardableModel(definition,table){const raw=definition?.belongsTo;let belongsTo=[];if(typeof raw==="string")belongsTo=[raw];else if(Array.isArray(raw))belongsTo=raw.map((entry)=>typeof entry==="string"?entry:entry?.model).filter(Boolean);else if(raw&&typeof raw==="object")belongsTo=Object.keys(raw);return{name:definition?.name??table,table,belongsTo,useUuid:Boolean(definition?.traits?.useUuid),sharding:definition?.traits?.sharding}}export function formatShardingReport(decisions){const lines=[],byReason={explicit:decisions.filter((d)=>d.reason==="explicit"),"co-located with parent":decisions.filter((d)=>d.reason==="co-located with parent"),"root entity":decisions.filter((d)=>d.reason==="root entity"),"reference table":decisions.filter((d)=>d.reason==="reference table")};if(byReason["root entity"].length){lines.push("Root entities (sharded by their own id):");for(const d of byReason["root entity"])lines.push(` ${d.table} -> ${d.column} (${d.vindex})`);lines.push("")}if(byReason["co-located with parent"].length){lines.push("Co-located with a parent (joins to that parent stay on one shard):");for(const d of byReason["co-located with parent"])lines.push(` ${d.table} -> ${d.column} (${d.vindex}), with ${d.parent}`);lines.push("")}if(byReason.explicit.length){lines.push("Explicitly declared:");for(const d of byReason.explicit)lines.push(` ${d.table} -> ${d.column} (${d.vindex})`);lines.push("")}if(byReason["reference table"].length){lines.push("Reference tables (copied to every shard):");for(const d of byReason["reference table"])lines.push(` ${d.table}`);lines.push("")}const warnings=decisions.filter((d)=>d.warning);if(warnings.length){lines.push("Warnings:");for(const d of warnings)lines.push(` ${d.table}: ${d.warning}`);lines.push("")}return lines.join(`
|
|
1
|
+
export function foreignKeyForModel(modelName){return`${modelName.replace(/([a-z0-9])([A-Z])/g,"$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").toLowerCase()}_id`}export function decideSharding(model,tableByModel){const declared=model.sharding;if(declared?.unsharded)return{table:model.table,column:null,vindex:null,reason:"reference table"};if(declared?.column)return{table:model.table,column:declared.column,vindex:declared.vindex??"hash",reason:"explicit"};const parentModel=model.belongsTo[0];if(parentModel){const parentTable=tableByModel.get(parentModel);return{table:model.table,column:foreignKeyForModel(parentModel),vindex:declared?.vindex??"hash",reason:"co-located with parent",parent:parentTable??parentModel,warning:model.belongsTo.length>1?`belongs to ${model.belongsTo.length} parents; sharded by ${parentModel} only, so joins through ${model.belongsTo.slice(1).join(", ")} will scatter`:void 0}}return{table:model.table,column:"id",vindex:declared?.vindex??"hash",reason:"root entity"}}export function deriveVSchema(models){const tableByModel=new Map(models.map((m)=>[m.name,m.table])),decisions=models.map((model)=>decideSharding(model,tableByModel)),vindexes={},tables={};for(const[index,decision]of decisions.entries()){const model=models[index];if(decision.reason==="reference table"){tables[decision.table]={type:"reference"};continue}const vindexType=decision.vindex??"hash";vindexes[vindexType]={type:vindexType};const table={column_vindexes:[{column:decision.column,name:vindexType}]};if(!model.useUuid)table.auto_increment={column:"id",sequence:model.sharding?.sequence??`${model.table}_seq`};tables[decision.table]=table}return{vschema:{sharded:!0,vindexes,tables},decisions}}export function toShardableModel(definition,table){const raw=definition?.belongsTo;let belongsTo=[];if(typeof raw==="string")belongsTo=[raw];else if(Array.isArray(raw))belongsTo=raw.map((entry)=>typeof entry==="string"?entry:entry?.model).filter((entry)=>Boolean(entry));else if(raw&&typeof raw==="object")belongsTo=Object.keys(raw);return{name:definition?.name??table,table,belongsTo,useUuid:Boolean(definition?.traits?.useUuid),sharding:definition?.traits?.sharding}}export function formatShardingReport(decisions){const lines=[],byReason={explicit:decisions.filter((d)=>d.reason==="explicit"),"co-located with parent":decisions.filter((d)=>d.reason==="co-located with parent"),"root entity":decisions.filter((d)=>d.reason==="root entity"),"reference table":decisions.filter((d)=>d.reason==="reference table")};if(byReason["root entity"].length){lines.push("Root entities (sharded by their own id):");for(const d of byReason["root entity"])lines.push(` ${d.table} -> ${d.column} (${d.vindex})`);lines.push("")}if(byReason["co-located with parent"].length){lines.push("Co-located with a parent (joins to that parent stay on one shard):");for(const d of byReason["co-located with parent"])lines.push(` ${d.table} -> ${d.column} (${d.vindex}), with ${d.parent}`);lines.push("")}if(byReason.explicit.length){lines.push("Explicitly declared:");for(const d of byReason.explicit)lines.push(` ${d.table} -> ${d.column} (${d.vindex})`);lines.push("")}if(byReason["reference table"].length){lines.push("Reference tables (copied to every shard):");for(const d of byReason["reference table"])lines.push(` ${d.table}`);lines.push("")}const warnings=decisions.filter((d)=>d.warning);if(warnings.length){lines.push("Warnings:");for(const d of warnings)lines.push(` ${d.table}: ${d.warning}`);lines.push("")}return lines.join(`
|
|
2
2
|
`)}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.73.1",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -60,22 +60,22 @@
|
|
|
60
60
|
"prepublishOnly": "bun run build"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@stacksjs/faker": "^0.
|
|
64
|
-
"@stacksjs/query-builder": "^0.
|
|
63
|
+
"@stacksjs/faker": "^0.73.1",
|
|
64
|
+
"@stacksjs/query-builder": "^0.73.1",
|
|
65
65
|
"@stacksjs/ts-validation": "^0.5.6",
|
|
66
66
|
"bun-query-builder": "^0.2.53",
|
|
67
67
|
"dynamodb-tooling": "^0.3.2"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@stacksjs/cli": "0.
|
|
71
|
-
"@stacksjs/config": "0.
|
|
72
|
-
"@stacksjs/logging": "0.
|
|
73
|
-
"@stacksjs/router": "0.
|
|
70
|
+
"@stacksjs/cli": "0.73.1",
|
|
71
|
+
"@stacksjs/config": "0.73.1",
|
|
72
|
+
"@stacksjs/logging": "0.73.1",
|
|
73
|
+
"@stacksjs/router": "0.73.1",
|
|
74
74
|
"better-dx": "^0.2.24",
|
|
75
|
-
"@stacksjs/path": "0.
|
|
76
|
-
"@stacksjs/query-builder": "0.
|
|
77
|
-
"@stacksjs/storage": "0.
|
|
78
|
-
"@stacksjs/strings": "0.
|
|
79
|
-
"@stacksjs/utils": "0.
|
|
75
|
+
"@stacksjs/path": "0.73.1",
|
|
76
|
+
"@stacksjs/query-builder": "0.73.1",
|
|
77
|
+
"@stacksjs/storage": "0.73.1",
|
|
78
|
+
"@stacksjs/strings": "0.73.1",
|
|
79
|
+
"@stacksjs/utils": "0.73.1"
|
|
80
80
|
}
|
|
81
81
|
}
|