@stacksjs/database 0.74.36 → 0.74.38

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/erd.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The column type a validation rule implies.
3
+ *
4
+ * The first rule names the base type - `schema.string().max(100)` is
5
+ * `[{ name: 'string' }, { name: 'max' }]` - so the head of the list is the
6
+ * answer and the rest are constraints a diagram does not show.
7
+ */
8
+ export declare function columnType(attribute: unknown): string;
9
+ /** Every model name a `belongsTo` / `hasMany` / `hasOne` declaration names. */
10
+ export declare function relatedModelNames(raw: unknown): string[];
11
+ /**
12
+ * Reduce a model definition to the diagram's view of it.
13
+ *
14
+ * Trait columns are included because they are real columns: a diagram that
15
+ * omits `created_at` describes a table that does not exist. `belongsTo`
16
+ * foreign keys are added for the same reason - they are the columns the
17
+ * relationships are actually made of, and an ERD without them is a picture of
18
+ * arrows with nothing underneath.
19
+ */
20
+ export declare function toDiagrammableModel(definition: {
21
+ name?: string
22
+ table?: string
23
+ primaryKey?: string
24
+ attributes?: Record<string, unknown>
25
+ belongsTo?: unknown
26
+ hasOne?: unknown
27
+ hasMany?: unknown
28
+ traits?: Record<string, unknown>
29
+ } | null | undefined, table: string): DiagrammableModel;
30
+ /**
31
+ * The relationship lines, deduplicated and ordered.
32
+ *
33
+ * One relationship is usually declared twice - `User hasMany Post` and
34
+ * `Post belongsTo User` are the same edge - and drawing both puts two arrows
35
+ * between the same pair of boxes. The parent-to-child direction wins, since
36
+ * that is how an ERD is read.
37
+ *
38
+ * A relation naming a model that is not in the set is dropped rather than
39
+ * drawn: an edge to a table that does not exist is worse than a missing edge,
40
+ * because it reads as schema rather than as a typo.
41
+ */
42
+ export declare function relationEdges(models: DiagrammableModel[]): DiagramEdge[];
43
+ /**
44
+ * A Mermaid `erDiagram` for these models.
45
+ *
46
+ * Relationships first, then entities, which is the order Mermaid's own
47
+ * examples use and the order that reads well: the shape of the schema before
48
+ * the detail of each table.
49
+ */
50
+ export declare function renderErd(models: DiagrammableModel[], options?: RenderErdOptions): string;
51
+ /** One model, reduced to what a diagram needs. */
52
+ export declare interface DiagrammableModel {
53
+ name: string
54
+ table: string
55
+ primaryKey: string
56
+ columns: DiagramColumn[]
57
+ belongsTo: string[]
58
+ hasOne: string[]
59
+ hasMany: string[]
60
+ }
61
+ export declare interface DiagramColumn {
62
+ name: string
63
+ type: string
64
+ key?: 'PK' | 'FK' | 'UK'
65
+ }
66
+ /** One line of the relationship section. */
67
+ export declare interface DiagramEdge {
68
+ from: string
69
+ to: string
70
+ cardinality: string
71
+ label: string
72
+ }
73
+ export declare interface RenderErdOptions {
74
+ columns?: boolean
75
+ only?: string[]
76
+ }
package/dist/erd.js ADDED
@@ -0,0 +1,3 @@
1
+ import{foreignKeyForModel}from"./vschema";const SAFE_TYPE=/^[a-z][a-z0-9_]*$/;export function columnType(attribute){const rules=attribute?.validation?.rule?.rules,head=Array.isArray(rules)?rules[0]?.name:void 0;if(typeof head==="string"&&SAFE_TYPE.test(head))return head;const fallback=attribute?.default;if(typeof fallback==="boolean")return"boolean";if(typeof fallback==="number")return"number";if(typeof fallback==="string")return"string";return"unknown"}export function relatedModelNames(raw){if(typeof raw==="string")return[raw];if(Array.isArray(raw))return raw.map((entry)=>typeof entry==="string"?entry:entry?.model).filter((entry)=>Boolean(entry));if(raw&&typeof raw==="object")return Object.keys(raw);return[]}export function toDiagrammableModel(definition,table){const primaryKey=definition?.primaryKey||"id",traits=definition?.traits??{},columns=[{name:primaryKey,type:"number",key:"PK"}];if(traits.useUuid)columns.push({name:"uuid",type:"string",key:"UK"});for(const[name,attribute]of Object.entries(definition?.attributes??{})){const unique=Boolean(attribute?.unique);columns.push({name,type:columnType(attribute),...unique?{key:"UK"}:{}})}const belongsTo=relatedModelNames(definition?.belongsTo);for(const parent of belongsTo){const column=foreignKeyForModel(parent);if(!columns.some((existing)=>existing.name===column))columns.push({name:column,type:"number",key:"FK"})}if(traits.useTimestamps){columns.push({name:"created_at",type:"datetime"});columns.push({name:"updated_at",type:"datetime"})}if(traits.useSoftDeletes)columns.push({name:"deleted_at",type:"datetime"});return{name:definition?.name??table,table,primaryKey,columns,belongsTo,hasOne:relatedModelNames(definition?.hasOne),hasMany:relatedModelNames(definition?.hasMany)}}export function relationEdges(models){const tableByModel=new Map(models.map((model)=>[model.name,model.table])),seen=new Set,edges=[],add=(from,to,cardinality,label)=>{const key=[from,to].sort().join("\x00");if(seen.has(key))return;seen.add(key);edges.push({from,to,cardinality,label})},sorted=[...models].sort((a,b)=>a.table.localeCompare(b.table));for(const model of sorted){for(const child of[...model.hasMany].sort()){const table=tableByModel.get(child);if(table)add(model.table,table,"||--o{","has many")}for(const child of[...model.hasOne].sort()){const table=tableByModel.get(child);if(table)add(model.table,table,"||--o|","has one")}}for(const model of sorted)for(const parent of[...model.belongsTo].sort()){const table=tableByModel.get(parent);if(table)add(table,model.table,"||--o{","has many")}return edges}export function renderErd(models,options={}){const sorted=[...options.only&&options.only.length>0?models.filter((model)=>options.only.includes(model.table)||options.only.includes(model.name)):models].sort((a,b)=>a.table.localeCompare(b.table)),lines=["erDiagram"];for(const edge of relationEdges(sorted))lines.push(` ${edge.from} ${edge.cardinality} ${edge.to} : "${edge.label}"`);if(options.columns===!1)return lines.join(`
2
+ `);for(const model of sorted){lines.push(` ${model.table} {`);for(const column of model.columns)lines.push(` ${column.type} ${column.name}${column.key?` ${column.key}`:""}`);lines.push(" }")}return lines.join(`
3
+ `)}
package/dist/index.d.ts CHANGED
@@ -125,6 +125,7 @@ export * from './ddl-constraints';
125
125
  // VSchema derivation — turns the model relationship graph into a Vitess
126
126
  // keyspace topology, co-locating child tables with their parents so joins
127
127
  // between them do not scatter across shards.
128
+ export * from './erd';
128
129
  export * from './vschema';
129
130
  // SQL dialect helpers & connection defaults
130
131
  export * from './sql-helpers';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
1
+ export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./erd";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
package/dist/seeder.d.ts CHANGED
@@ -57,6 +57,24 @@ export declare function parentRelations(model: SeederModel): ParentRelation[];
57
57
  * This is what `./buddy seed` runs.
58
58
  */
59
59
  export declare function seed(config?: SeederConfig): Promise<SeedSummary>;
60
+ /**
61
+ * Rows for `modelName`, built from its declared attribute factories but NOT
62
+ * written anywhere.
63
+ *
64
+ * This is `seedModel$` with the insert removed, and it exists because a test
65
+ * wants what a seeder produces without wanting what a seeder does. Rebuilding
66
+ * it in the test package would mean reimplementing the parts that are easy to
67
+ * get wrong and invisible when wrong: password hashing, unique-column
68
+ * disambiguation across a batch, and relation columns. A hand-rolled test
69
+ * factory that skips the hash writes a password nobody can sign in with.
70
+ *
71
+ * `overrides` are applied last and are taken verbatim - a test setting
72
+ * `{ email: 'known@example.com' }` gets exactly that, uniqueness logic
73
+ * included, because it has already decided what the value is.
74
+ *
75
+ * Added for the model factories `@stacksjs/testing` exposes (stacksjs/stacks#2581).
76
+ */
77
+ export declare function makeModelRecords(modelName: string, count?: number, overrides?: Record<string, unknown>, options?: { verbose?: boolean }): Promise<Record<string, unknown>[]>;
60
78
  /**
61
79
  * Seed a specific model by name
62
80
  * Searches both default and user models
package/dist/seeder.js CHANGED
@@ -1 +1 @@
1
- import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0;loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}async function generateRecord(attributes,modelName,report=!1){const record={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker)}catch(err){const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationColumns(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;if(isAccountModel(parent)&&!options.allowProtected)continue;const rows=await existingRows(parentTable(parent));if(rows.length>0)pools.push({column,rows})}return chooseRelations(pools,model.count)}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function claimUniqueValues(record,columns,used,suffix){let distinct=!0;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}distinct=!1;if(suffix>0){const disambiguated=typeof value==="number"?value+suffix:`${String(value)} ${suffix}`;record[column]=disambiguated;seen.add(disambiguated)}}return distinct}async function generateRecords(model,options={}){const records=[],relations=await relationColumns(model,options),unique=uniqueColumns(model.attributes),used=new Map;for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1);claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],relation=relations[i]??{},withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;records.push(fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations)}return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of parentModels(model)){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}}const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
1
+ import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0;loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}async function generateRecord(attributes,modelName,report=!1){const record={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker)}catch(err){const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationColumns(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;if(isAccountModel(parent)&&!options.allowProtected)continue;const rows=await existingRows(parentTable(parent));if(rows.length>0)pools.push({column,rows})}return chooseRelations(pools,model.count)}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function claimUniqueValues(record,columns,used,suffix){let distinct=!0;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}distinct=!1;if(suffix>0){const disambiguated=typeof value==="number"?value+suffix:`${String(value)} ${suffix}`;record[column]=disambiguated;seen.add(disambiguated)}}return distinct}async function generateRecords(model,options={}){const records=[],relations=await relationColumns(model,options),unique=uniqueColumns(model.attributes),used=new Map;for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1);claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],relation=relations[i]??{},withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;records.push(fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations)}return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of parentModels(model)){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}}const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function makeModelRecords(modelName,count=1,overrides={},options={}){if(!Number.isInteger(count)||count<1)throw TypeError(`makeModelRecords(${modelName}) needs a positive integer count; got ${count}`);const models=await loadAllModels(path.userModelsPath(),options.verbose??!1,!0),model=models.find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}. Known models: ${models.map((m)=>m.name).sort().join(", ")||"(none loaded)"}`);return(await generateRecords({...model,count},{verbose:options.verbose??!1})).map((record)=>({...record,...snakeCaseKeys(overrides)}))}function snakeCaseKeys(input){return Object.fromEntries(Object.entries(input).map(([key,value])=>[snakeCase(key),value]))}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/database",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.36",
5
+ "version": "0.74.38",
6
6
  "description": "The Stacks database integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,26 +60,26 @@
60
60
  "prepublishOnly": "bun run build"
61
61
  },
62
62
  "dependencies": {
63
- "@stacksjs/config": "0.74.36",
64
- "@stacksjs/env": "0.74.36",
65
- "@stacksjs/error-handling": "0.74.36",
66
- "@stacksjs/faker": "^0.74.36",
67
- "@stacksjs/features": "0.74.36",
68
- "@stacksjs/logging": "0.74.36",
69
- "@stacksjs/model-meta": "0.74.36",
70
- "@stacksjs/path": "0.74.36",
71
- "@stacksjs/query-builder": "^0.74.36",
72
- "@stacksjs/security": "0.74.36",
73
- "@stacksjs/storage": "0.74.36",
74
- "@stacksjs/strings": "0.74.36",
63
+ "@stacksjs/config": "0.74.38",
64
+ "@stacksjs/env": "0.74.38",
65
+ "@stacksjs/error-handling": "0.74.38",
66
+ "@stacksjs/faker": "^0.74.38",
67
+ "@stacksjs/features": "0.74.38",
68
+ "@stacksjs/logging": "0.74.38",
69
+ "@stacksjs/model-meta": "0.74.38",
70
+ "@stacksjs/path": "0.74.38",
71
+ "@stacksjs/query-builder": "^0.74.38",
72
+ "@stacksjs/security": "0.74.38",
73
+ "@stacksjs/storage": "0.74.38",
74
+ "@stacksjs/strings": "0.74.38",
75
75
  "@stacksjs/ts-validation": "^0.5.6",
76
76
  "bun-query-builder": "^0.2.69",
77
77
  "dynamodb-tooling": "^0.3.2"
78
78
  },
79
79
  "devDependencies": {
80
- "@stacksjs/cli": "0.74.36",
81
- "@stacksjs/router": "0.74.36",
82
- "@stacksjs/utils": "0.74.36",
80
+ "@stacksjs/cli": "0.74.38",
81
+ "@stacksjs/router": "0.74.38",
82
+ "@stacksjs/utils": "0.74.38",
83
83
  "better-dx": "^0.2.24"
84
84
  }
85
85
  }