@warlock.js/cascade 5.21.0 → 5.22.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.
@@ -1 +1 @@
1
- {"version":3,"file":"database-restorer.mjs","names":[],"sources":["../../../../../../../cascade/src/restorer/database-restorer.ts"],"sourcesContent":["import type { DriverContract, UpdateOperations } from \"../contracts/database-driver.contract\";\nimport type {\n RestorerContract,\n RestorerOptions,\n RestorerResult,\n} from \"../contracts/database-restorer.contract\";\nimport type { DataSource } from \"../data-source/data-source\";\nimport type { Model } from \"../model/model\";\nimport type { DeleteStrategy } from \"../types\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport { DatabaseWriter } from \"../writer/database-writer\";\n\n/**\n * Database restorer service that orchestrates model restoration.\n *\n * Handles the complete restoration pipeline:\n * 1. Strategy detection (trash vs soft delete)\n * 2. Record retrieval from trash table or soft-deleted records\n * 3. ID conflict resolution\n * 4. Event emission (restoring, restored)\n * 5. Driver execution (insert back to original table, remove from trash/clear deletedAt)\n *\n * @example\n * ```typescript\n * const restorer = new DatabaseRestorer(User);\n * const result = await restorer.restore(123);\n *\n * console.log(result.success); // true\n * console.log(result.strategy); // \"trash\" | \"soft\"\n * ```\n */\nexport class DatabaseRestorer implements RestorerContract {\n /** Model constructor reference */\n private readonly ctor: typeof Model;\n\n /** Data source containing driver */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /**\n * Create a new restorer instance for a model class.\n *\n * @param modelClass - The model class (static context)\n *\n * @example\n * ```typescript\n * const restorer = new DatabaseRestorer(User);\n * await restorer.restore(123);\n * ```\n */\n public constructor(modelClass: typeof Model) {\n this.ctor = modelClass;\n this.dataSource = modelClass.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = modelClass.table;\n this.primaryKey = modelClass.primaryKey;\n }\n\n /**\n * Restore a single deleted record by its ID.\n *\n * @param id - The primary key value of the record to restore\n * @param options - Restorer options\n * @returns Result containing success status, strategy used, and restored record\n * @throws {Error} If record not found in trash or soft-deleted records\n * @throws {Error} If ID conflict and onIdConflict is \"fail\"\n */\n public async restore(\n id: string | number,\n options: RestorerOptions = {},\n ): Promise<RestorerResult> {\n const onIdConflict = options.onIdConflict ?? \"assignNew\";\n const skipEvents = options.skipEvents ?? false;\n\n // 1. Resolve strategy (options → model static → data source default)\n const strategy = this.resolveStrategy(options.strategy);\n\n // 2. Validate strategy (cannot restore permanent deletes)\n if (strategy === \"permanent\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: permanently deleted records cannot be restored.`,\n );\n }\n\n // 3. Fetch record based on strategy\n const recordData = await this.fetchRecordByStrategy(id, strategy);\n\n if (!recordData) {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: record not found in ${strategy === \"trash\" ? \"trash table\" : \"soft-deleted records\"}.`,\n );\n }\n\n // 4. Prepare record data (remove metadata fields)\n const restoredData = { ...recordData };\n delete restoredData.deletedAt;\n delete restoredData.originalTable;\n\n // 5. Check for ID conflict and handle. A soft-deleted row never left the\n // table, so it would \"conflict\" with itself: it keeps its id and is\n // updated in place.\n const finalData =\n strategy === \"trash\"\n ? await this.handleIdConflict(restoredData, id, onIdConflict)\n : restoredData;\n\n // 6. Create temporary model instance for event emission\n // Note: Model is abstract, but at runtime this.ctor is a concrete subclass\n const model = new (this.ctor as any)(finalData) as Model;\n\n // 7. Emit restoring event (unless skipEvents)\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // 8. Restore based on strategy\n if (strategy === \"trash\") {\n // Insert record back into original table (was moved to trash)\n await this.driver.insert(this.table, finalData);\n model.isNew = false;\n\n // Remove from trash table\n await this.driver.delete(this.resolveTrashTable(), {\n [this.primaryKey]: id,\n originalTable: this.table,\n });\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt (don't insert - would create duplicate!)\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n // 10. Emit restored event (unless skipEvents)\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n\n return {\n success: true,\n restoredCount: 1,\n strategy,\n restoredRecord: model,\n };\n }\n\n /**\n * Restore all deleted records for the model's table.\n *\n * @param options - Restorer options\n * @returns Result containing success status, strategy used, and aggregate counts\n */\n public async restoreAll(options: RestorerOptions = {}): Promise<RestorerResult> {\n const onIdConflict = options.onIdConflict ?? \"assignNew\";\n const skipEvents = options.skipEvents ?? false;\n\n // 1. Resolve strategy (options → model static → data source default)\n const strategy = this.resolveStrategy(options.strategy);\n\n // 2. Validate strategy (cannot restore permanent deletes)\n if (strategy === \"permanent\") {\n throw new Error(\n `Cannot restore all ${this.ctor.name} records: permanently deleted records cannot be restored.`,\n );\n }\n\n // 3. Fetch all records based on strategy\n const recordsToRestore = await this.fetchAllRecordsByStrategy(strategy);\n\n if (recordsToRestore.length === 0) {\n return {\n success: true,\n restoredCount: 0,\n strategy,\n };\n }\n\n // 4. Restore each record\n let restoredCount = 0;\n const conflicts: Array<{ id: string | number; reason: string }> = [];\n\n const restoredRecords: Model[] = [];\n\n for (const recordData of recordsToRestore) {\n const id = recordData[this.primaryKey] as string | number;\n\n try {\n // Prepare record data (remove metadata fields)\n const restoredData = { ...recordData };\n delete restoredData.deletedAt;\n delete restoredData.originalTable;\n\n // Check for ID conflict\n // Soft rows never left the table; only trash restores can conflict.\n const idExists = strategy === \"trash\" && (await this.checkIdExists(id));\n if (idExists) {\n if (onIdConflict === \"fail\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: ID already exists in target table.`,\n );\n }\n\n // Assign new ID\n const finalData = await this.assignNewId(restoredData);\n conflicts.push({\n id,\n reason: `ID ${id} already exists, assigned new ID ${finalData[this.primaryKey]}`,\n });\n\n // Create temporary model for events\n const model = new (this.ctor as any)(finalData) as Model;\n\n // Emit restoring event\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // Restore based on strategy\n if (strategy === \"trash\") {\n // Insert with new ID (was moved to trash)\n await this.driver.insert(this.table, finalData);\n model.isNew = false;\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n restoredRecords.push(model);\n\n // Emit restored event\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n } else {\n // No conflict, restore with original ID\n const model = new (this.ctor as any)(restoredData) as Model;\n\n // Emit restoring event\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // Restore based on strategy\n if (strategy === \"trash\") {\n // Insert with original ID (was moved to trash)\n await this.driver.insert(this.table, restoredData);\n model.isNew = false;\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n restoredRecords.push(model);\n\n // Emit restored event\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n }\n\n // Remove from trash (only for trash strategy)\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n const trashFilter = { [this.primaryKey]: id, originalTable: this.table };\n await this.driver.delete(trashTable, trashFilter);\n }\n\n restoredCount++;\n } catch (error) {\n if (onIdConflict === \"fail\") {\n throw error;\n }\n // Continue with next record if assignNew mode\n conflicts.push({\n id,\n reason: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n return {\n success: true,\n restoredCount,\n restoredRecords,\n strategy,\n conflicts: conflicts.length > 0 ? conflicts : undefined,\n };\n }\n\n /**\n * Resolve the delete strategy.\n *\n * Priority: options → model static → data source default → \"permanent\"\n *\n * @param strategyOption - Optional strategy override from options\n * @returns The resolved delete strategy\n * @private\n */\n private resolveStrategy(strategyOption?: \"trash\" | \"soft\"): DeleteStrategy {\n return (\n strategyOption ??\n this.ctor.deleteStrategy ??\n this.dataSource.defaultDeleteStrategy ??\n \"permanent\"\n );\n }\n\n /**\n * Fetch a record by ID based on the delete strategy.\n *\n * @param id - The primary key value\n * @param strategy - The delete strategy to use\n * @returns The record data, or null if not found\n * @private\n */\n private async fetchRecordByStrategy(id: string | number, strategy: \"trash\" | \"soft\") {\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n try {\n const trashQuery = await this.driver\n .queryBuilder(trashTable)\n .where(this.primaryKey, id)\n .where(\"originalTable\", this.table)\n .first<Record<string, unknown>>();\n\n return trashQuery;\n } catch {\n return null;\n }\n } else if (strategy === \"soft\") {\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (!deletedAtColumn) {\n return null;\n }\n\n try {\n const softDeletedQuery = await this.driver\n .queryBuilder(this.table)\n .where(this.primaryKey, id)\n .whereNotNull(deletedAtColumn)\n .first<Record<string, unknown>>();\n\n return softDeletedQuery;\n } catch {\n return null;\n }\n }\n\n return null;\n }\n\n /**\n * Fetch all records based on the delete strategy.\n *\n * @param strategy - The delete strategy to use\n * @returns Array of record data\n * @private\n */\n private async fetchAllRecordsByStrategy(\n strategy: \"trash\" | \"soft\",\n ): Promise<Record<string, unknown>[]> {\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n try {\n const trashQuery = this.driver\n .queryBuilder(trashTable)\n .where(\"originalTable\", this.table)\n .get();\n\n return (await trashQuery) as Record<string, unknown>[];\n } catch {\n return [];\n }\n } else if (strategy === \"soft\") {\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (!deletedAtColumn) {\n return [];\n }\n\n try {\n const softDeletedQuery = this.driver\n .queryBuilder(this.table)\n .whereNotNull(deletedAtColumn)\n .get();\n\n return (await softDeletedQuery) as Record<string, unknown>[];\n } catch {\n return [];\n }\n }\n\n return [];\n }\n\n /**\n * Handle ID conflict by checking if ID exists and assigning new one if needed.\n *\n * @param recordData - The record data to restore\n * @param originalId - The original ID value\n * @param onIdConflict - Conflict resolution strategy\n * @returns Record data with potentially new ID\n * @private\n */\n private async handleIdConflict(\n recordData: Record<string, unknown>,\n originalId: string | number,\n onIdConflict: \"fail\" | \"assignNew\",\n ): Promise<Record<string, unknown>> {\n const idExists = await this.checkIdExists(originalId);\n\n if (idExists) {\n if (onIdConflict === \"fail\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${originalId}: ID already exists in target table.`,\n );\n }\n\n // Assign new ID\n return await this.assignNewId(recordData);\n }\n\n return recordData;\n }\n\n /**\n * Check if an ID already exists in the target table.\n *\n * @param id - The ID to check\n * @returns True if ID exists, false otherwise\n * @private\n */\n private async checkIdExists(id: string | number): Promise<boolean> {\n try {\n const query = this.driver.queryBuilder(this.table).where(this.primaryKey, id).exists();\n\n return await query;\n } catch {\n return false;\n }\n }\n\n /**\n * Assign a new ID to the record data.\n *\n * For MongoDB: drops the old `_id` and `id`, then runs the record through the\n * writer's insert preparation so the new `id` comes from the same generator\n * a normal save uses (validation and events are skipped; this is a restore)\n * For SQL: Removes the primary key to let database auto-increment\n *\n * @param recordData - The record data\n * @returns Record data with new ID assigned\n * @private\n */\n private async assignNewId(recordData: Record<string, unknown>): Promise<Record<string, unknown>> {\n const newData = { ...recordData };\n\n if (this.driver.name !== \"mongodb\") {\n // SQL: Remove primary key to let database auto-increment\n delete newData[this.primaryKey];\n\n return newData;\n }\n\n // MongoDB: let the driver mint a new _id and the writer generate a new id.\n // The generator skips records that already carry an `id`, so drop both.\n delete newData._id;\n delete newData.id;\n\n const model = new (this.ctor as any)(newData) as Model;\n\n return await new DatabaseWriter(model).prepareForInsert({\n skipEvents: true,\n skipValidation: true,\n });\n }\n\n /**\n * Resolve the trash table/collection name.\n *\n * Priority:\n * 1. Model.trashTable (if set)\n * 2. Data source defaultTrashTable (e.g., \"RecycleBin\" for MongoDB)\n * 3. Default pattern: `{table}Trash`\n *\n * @returns The trash table/collection name\n * @private\n */\n private resolveTrashTable(): string {\n if (this.ctor.trashTable) {\n return this.ctor.trashTable;\n }\n\n if (this.dataSource.defaultTrashTable) {\n return this.dataSource.defaultTrashTable;\n }\n\n return `${this.table}Trash`;\n }\n\n /**\n * Re-run sync for a restored record so embedded copies come back up to date.\n * Failures are swallowed: the restore itself already succeeded.\n * TODO(5.22, K2:B10): move this fan-out to a retry/queue-backed path.\n *\n * @private\n */\n private async triggerSync(model: Model): Promise<void> {\n try {\n await triggerModelEvent(\n this.ctor as any,\n \"updated\",\n model,\n Object.keys(model.data as Record<string, unknown>),\n );\n } catch {\n // sync failures never fail a restore\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database-restorer.mjs","names":[],"sources":["../../../../../../../cascade/src/restorer/database-restorer.ts"],"sourcesContent":["import type { DriverContract, UpdateOperations } from \"../contracts/database-driver.contract\";\nimport type {\n RestorerContract,\n RestorerOptions,\n RestorerResult,\n} from \"../contracts/database-restorer.contract\";\nimport type { DataSource } from \"../data-source/data-source\";\nimport type { Model } from \"../model/model\";\nimport type { DeleteStrategy } from \"../types\";\nimport { enqueueSyncFanout } from \"../sync/sync-fanout\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport { DatabaseWriter } from \"../writer/database-writer\";\n\n/**\n * Database restorer service that orchestrates model restoration.\n *\n * Handles the complete restoration pipeline:\n * 1. Strategy detection (trash vs soft delete)\n * 2. Record retrieval from trash table or soft-deleted records\n * 3. ID conflict resolution\n * 4. Event emission (restoring, restored)\n * 5. Driver execution (insert back to original table, remove from trash/clear deletedAt)\n *\n * @example\n * ```typescript\n * const restorer = new DatabaseRestorer(User);\n * const result = await restorer.restore(123);\n *\n * console.log(result.success); // true\n * console.log(result.strategy); // \"trash\" | \"soft\"\n * ```\n */\nexport class DatabaseRestorer implements RestorerContract {\n /** Model constructor reference */\n private readonly ctor: typeof Model;\n\n /** Data source containing driver */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /**\n * Create a new restorer instance for a model class.\n *\n * @param modelClass - The model class (static context)\n *\n * @example\n * ```typescript\n * const restorer = new DatabaseRestorer(User);\n * await restorer.restore(123);\n * ```\n */\n public constructor(modelClass: typeof Model) {\n this.ctor = modelClass;\n this.dataSource = modelClass.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = modelClass.table;\n this.primaryKey = modelClass.primaryKey;\n }\n\n /**\n * Restore a single deleted record by its ID.\n *\n * @param id - The primary key value of the record to restore\n * @param options - Restorer options\n * @returns Result containing success status, strategy used, and restored record\n * @throws {Error} If record not found in trash or soft-deleted records\n * @throws {Error} If ID conflict and onIdConflict is \"fail\"\n */\n public async restore(\n id: string | number,\n options: RestorerOptions = {},\n ): Promise<RestorerResult> {\n const onIdConflict = options.onIdConflict ?? \"assignNew\";\n const skipEvents = options.skipEvents ?? false;\n\n // 1. Resolve strategy (options → model static → data source default)\n const strategy = this.resolveStrategy(options.strategy);\n\n // 2. Validate strategy (cannot restore permanent deletes)\n if (strategy === \"permanent\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: permanently deleted records cannot be restored.`,\n );\n }\n\n // 3. Fetch record based on strategy\n const recordData = await this.fetchRecordByStrategy(id, strategy);\n\n if (!recordData) {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: record not found in ${strategy === \"trash\" ? \"trash table\" : \"soft-deleted records\"}.`,\n );\n }\n\n // 4. Prepare record data (remove metadata fields)\n const restoredData = { ...recordData };\n delete restoredData.deletedAt;\n delete restoredData.originalTable;\n\n // 5. Check for ID conflict and handle. A soft-deleted row never left the\n // table, so it would \"conflict\" with itself: it keeps its id and is\n // updated in place.\n const finalData =\n strategy === \"trash\"\n ? await this.handleIdConflict(restoredData, id, onIdConflict)\n : restoredData;\n\n // 6. Create temporary model instance for event emission\n // Note: Model is abstract, but at runtime this.ctor is a concrete subclass\n const model = new (this.ctor as any)(finalData) as Model;\n\n // 7. Emit restoring event (unless skipEvents)\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // 8. Restore based on strategy\n if (strategy === \"trash\") {\n // Insert record back into original table (was moved to trash)\n await this.driver.insert(this.table, finalData);\n model.isNew = false;\n\n // Remove from trash table\n await this.driver.delete(this.resolveTrashTable(), {\n [this.primaryKey]: id,\n originalTable: this.table,\n });\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt (don't insert - would create duplicate!)\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n // 10. Emit restored event (unless skipEvents)\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n\n return {\n success: true,\n restoredCount: 1,\n strategy,\n restoredRecord: model,\n };\n }\n\n /**\n * Restore all deleted records for the model's table.\n *\n * @param options - Restorer options\n * @returns Result containing success status, strategy used, and aggregate counts\n */\n public async restoreAll(options: RestorerOptions = {}): Promise<RestorerResult> {\n const onIdConflict = options.onIdConflict ?? \"assignNew\";\n const skipEvents = options.skipEvents ?? false;\n\n // 1. Resolve strategy (options → model static → data source default)\n const strategy = this.resolveStrategy(options.strategy);\n\n // 2. Validate strategy (cannot restore permanent deletes)\n if (strategy === \"permanent\") {\n throw new Error(\n `Cannot restore all ${this.ctor.name} records: permanently deleted records cannot be restored.`,\n );\n }\n\n // 3. Fetch all records based on strategy\n const recordsToRestore = await this.fetchAllRecordsByStrategy(strategy);\n\n if (recordsToRestore.length === 0) {\n return {\n success: true,\n restoredCount: 0,\n strategy,\n };\n }\n\n // 4. Restore each record\n let restoredCount = 0;\n const conflicts: Array<{ id: string | number; reason: string }> = [];\n\n const restoredRecords: Model[] = [];\n\n for (const recordData of recordsToRestore) {\n const id = recordData[this.primaryKey] as string | number;\n\n try {\n // Prepare record data (remove metadata fields)\n const restoredData = { ...recordData };\n delete restoredData.deletedAt;\n delete restoredData.originalTable;\n\n // Check for ID conflict\n // Soft rows never left the table; only trash restores can conflict.\n const idExists = strategy === \"trash\" && (await this.checkIdExists(id));\n if (idExists) {\n if (onIdConflict === \"fail\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${id}: ID already exists in target table.`,\n );\n }\n\n // Assign new ID\n const finalData = await this.assignNewId(restoredData);\n conflicts.push({\n id,\n reason: `ID ${id} already exists, assigned new ID ${finalData[this.primaryKey]}`,\n });\n\n // Create temporary model for events\n const model = new (this.ctor as any)(finalData) as Model;\n\n // Emit restoring event\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // Restore based on strategy\n if (strategy === \"trash\") {\n // Insert with new ID (was moved to trash)\n await this.driver.insert(this.table, finalData);\n model.isNew = false;\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n restoredRecords.push(model);\n\n // Emit restored event\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n } else {\n // No conflict, restore with original ID\n const model = new (this.ctor as any)(restoredData) as Model;\n\n // Emit restoring event\n if (!skipEvents) {\n await model.emitEvent(\"restoring\");\n }\n\n // Restore based on strategy\n if (strategy === \"trash\") {\n // Insert with original ID (was moved to trash)\n await this.driver.insert(this.table, restoredData);\n model.isNew = false;\n } else if (strategy === \"soft\") {\n // Record still exists, just unset deletedAt\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (deletedAtColumn) {\n const filter = { [this.primaryKey]: id };\n const updateOperations: UpdateOperations = {\n $unset: { [deletedAtColumn]: 1 },\n };\n await this.driver.update(this.table, filter, updateOperations);\n model.isNew = false;\n }\n }\n\n restoredRecords.push(model);\n\n // Emit restored event\n if (!skipEvents) {\n await model.emitEvent(\"restored\");\n }\n\n await this.triggerSync(model);\n }\n\n // Remove from trash (only for trash strategy)\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n const trashFilter = { [this.primaryKey]: id, originalTable: this.table };\n await this.driver.delete(trashTable, trashFilter);\n }\n\n restoredCount++;\n } catch (error) {\n if (onIdConflict === \"fail\") {\n throw error;\n }\n // Continue with next record if assignNew mode\n conflicts.push({\n id,\n reason: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n return {\n success: true,\n restoredCount,\n restoredRecords,\n strategy,\n conflicts: conflicts.length > 0 ? conflicts : undefined,\n };\n }\n\n /**\n * Resolve the delete strategy.\n *\n * Priority: options → model static → data source default → \"permanent\"\n *\n * @param strategyOption - Optional strategy override from options\n * @returns The resolved delete strategy\n * @private\n */\n private resolveStrategy(strategyOption?: \"trash\" | \"soft\"): DeleteStrategy {\n return (\n strategyOption ??\n this.ctor.deleteStrategy ??\n this.dataSource.defaultDeleteStrategy ??\n \"permanent\"\n );\n }\n\n /**\n * Fetch a record by ID based on the delete strategy.\n *\n * @param id - The primary key value\n * @param strategy - The delete strategy to use\n * @returns The record data, or null if not found\n * @private\n */\n private async fetchRecordByStrategy(id: string | number, strategy: \"trash\" | \"soft\") {\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n try {\n const trashQuery = await this.driver\n .queryBuilder(trashTable)\n .where(this.primaryKey, id)\n .where(\"originalTable\", this.table)\n .first<Record<string, unknown>>();\n\n return trashQuery;\n } catch {\n return null;\n }\n } else if (strategy === \"soft\") {\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (!deletedAtColumn) {\n return null;\n }\n\n try {\n const softDeletedQuery = await this.driver\n .queryBuilder(this.table)\n .where(this.primaryKey, id)\n .whereNotNull(deletedAtColumn)\n .first<Record<string, unknown>>();\n\n return softDeletedQuery;\n } catch {\n return null;\n }\n }\n\n return null;\n }\n\n /**\n * Fetch all records based on the delete strategy.\n *\n * @param strategy - The delete strategy to use\n * @returns Array of record data\n * @private\n */\n private async fetchAllRecordsByStrategy(\n strategy: \"trash\" | \"soft\",\n ): Promise<Record<string, unknown>[]> {\n if (strategy === \"trash\") {\n const trashTable = this.resolveTrashTable();\n try {\n const trashQuery = this.driver\n .queryBuilder(trashTable)\n .where(\"originalTable\", this.table)\n .get();\n\n return (await trashQuery) as Record<string, unknown>[];\n } catch {\n return [];\n }\n } else if (strategy === \"soft\") {\n const deletedAtColumn = this.ctor.deletedAtColumn ?? \"deletedAt\";\n if (!deletedAtColumn) {\n return [];\n }\n\n try {\n const softDeletedQuery = this.driver\n .queryBuilder(this.table)\n .whereNotNull(deletedAtColumn)\n .get();\n\n return (await softDeletedQuery) as Record<string, unknown>[];\n } catch {\n return [];\n }\n }\n\n return [];\n }\n\n /**\n * Handle ID conflict by checking if ID exists and assigning new one if needed.\n *\n * @param recordData - The record data to restore\n * @param originalId - The original ID value\n * @param onIdConflict - Conflict resolution strategy\n * @returns Record data with potentially new ID\n * @private\n */\n private async handleIdConflict(\n recordData: Record<string, unknown>,\n originalId: string | number,\n onIdConflict: \"fail\" | \"assignNew\",\n ): Promise<Record<string, unknown>> {\n const idExists = await this.checkIdExists(originalId);\n\n if (idExists) {\n if (onIdConflict === \"fail\") {\n throw new Error(\n `Cannot restore ${this.ctor.name} with ${this.primaryKey} ${originalId}: ID already exists in target table.`,\n );\n }\n\n // Assign new ID\n return await this.assignNewId(recordData);\n }\n\n return recordData;\n }\n\n /**\n * Check if an ID already exists in the target table.\n *\n * @param id - The ID to check\n * @returns True if ID exists, false otherwise\n * @private\n */\n private async checkIdExists(id: string | number): Promise<boolean> {\n try {\n const query = this.driver.queryBuilder(this.table).where(this.primaryKey, id).exists();\n\n return await query;\n } catch {\n return false;\n }\n }\n\n /**\n * Assign a new ID to the record data.\n *\n * For MongoDB: drops the old `_id` and `id`, then runs the record through the\n * writer's insert preparation so the new `id` comes from the same generator\n * a normal save uses (validation and events are skipped; this is a restore)\n * For SQL: Removes the primary key to let database auto-increment\n *\n * @param recordData - The record data\n * @returns Record data with new ID assigned\n * @private\n */\n private async assignNewId(recordData: Record<string, unknown>): Promise<Record<string, unknown>> {\n const newData = { ...recordData };\n\n if (this.driver.name !== \"mongodb\") {\n // SQL: Remove primary key to let database auto-increment\n delete newData[this.primaryKey];\n\n return newData;\n }\n\n // MongoDB: let the driver mint a new _id and the writer generate a new id.\n // The generator skips records that already carry an `id`, so drop both.\n delete newData._id;\n delete newData.id;\n\n const model = new (this.ctor as any)(newData) as Model;\n\n return await new DatabaseWriter(model).prepareForInsert({\n skipEvents: true,\n skipValidation: true,\n });\n }\n\n /**\n * Resolve the trash table/collection name.\n *\n * Priority:\n * 1. Model.trashTable (if set)\n * 2. Data source defaultTrashTable (e.g., \"RecycleBin\" for MongoDB)\n * 3. Default pattern: `{table}Trash`\n *\n * @returns The trash table/collection name\n * @private\n */\n private resolveTrashTable(): string {\n if (this.ctor.trashTable) {\n return this.ctor.trashTable;\n }\n\n if (this.dataSource.defaultTrashTable) {\n return this.dataSource.defaultTrashTable;\n }\n\n return `${this.table}Trash`;\n }\n\n /**\n * Queue sync for a restored record so embedded copies come back up to date.\n * The restore has already succeeded, so queued failures are retried separately.\n *\n * @private\n */\n private triggerSync(model: Model): void {\n enqueueSyncFanout({\n sourceModel: this.ctor.name,\n operation: \"restore\",\n execute: () =>\n triggerModelEvent(\n this.ctor as any,\n \"updated\",\n model,\n Object.keys(model.data as Record<string, unknown>),\n ),\n });\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import { log } from "@warlock.js/logger";
2
+
3
+ //#region ../cascade/src/sync/sync-fanout.ts
4
+ /**
5
+ * Serializes post-commit sync fan-out and retries transient failures without
6
+ * making the already-committed write, delete, or restore fail.
7
+ */
8
+ var SyncFanoutQueue = class {
9
+ jobs = [];
10
+ maxAttempts;
11
+ retryDelay;
12
+ reportFailure;
13
+ draining = false;
14
+ idleResolvers = [];
15
+ constructor(options = {}) {
16
+ this.maxAttempts = options.maxAttempts ?? 3;
17
+ this.retryDelay = options.retryDelay ?? ((attempt) => attempt * 50);
18
+ this.reportFailure = options.reportFailure ?? ((job, error) => {
19
+ log.error("database", "sync.failed", `[cascade] ${job.operation} sync failed for ${job.sourceModel} after ${this.maxAttempts} attempts: ${error}`);
20
+ });
21
+ }
22
+ /** Add a committed lifecycle fan-out job without awaiting its execution. */
23
+ enqueue(job) {
24
+ this.jobs.push(job);
25
+ if (!this.draining) {
26
+ this.draining = true;
27
+ this.drain();
28
+ }
29
+ }
30
+ /** Wait until the currently queued fan-out jobs have settled. */
31
+ async flush() {
32
+ if (!this.draining && this.jobs.length === 0) return;
33
+ await new Promise((resolve) => this.idleResolvers.push(resolve));
34
+ }
35
+ async drain() {
36
+ while (this.jobs.length > 0) {
37
+ const job = this.jobs.shift();
38
+ if (job) await this.execute(job);
39
+ }
40
+ this.draining = false;
41
+ this.idleResolvers.splice(0).forEach((resolve) => resolve());
42
+ }
43
+ async execute(job) {
44
+ let error;
45
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) try {
46
+ await job.execute();
47
+ return;
48
+ } catch (caught) {
49
+ error = caught;
50
+ if (attempt < this.maxAttempts) await this.wait(this.retryDelay(attempt));
51
+ }
52
+ this.reportFailure(job, error);
53
+ }
54
+ async wait(milliseconds) {
55
+ if (milliseconds <= 0) return;
56
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
57
+ }
58
+ };
59
+ const syncFanoutQueue = new SyncFanoutQueue();
60
+ /** Queue a lifecycle sync after its database change has committed. */
61
+ function enqueueSyncFanout(job) {
62
+ syncFanoutQueue.enqueue(job);
63
+ }
64
+
65
+ //#endregion
66
+ export { enqueueSyncFanout };
67
+ //# sourceMappingURL=sync-fanout.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-fanout.mjs","names":[],"sources":["../../../../../../../cascade/src/sync/sync-fanout.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\n\ntype SyncFanoutJob = {\n sourceModel: string;\n operation: string;\n execute: () => Promise<void>;\n};\n\ntype SyncFanoutQueueOptions = {\n maxAttempts?: number;\n retryDelay?: (attempt: number) => number;\n reportFailure?: (job: SyncFanoutJob, error: unknown) => void;\n};\n\n/**\n * Serializes post-commit sync fan-out and retries transient failures without\n * making the already-committed write, delete, or restore fail.\n */\nexport class SyncFanoutQueue {\n private readonly jobs: SyncFanoutJob[] = [];\n private readonly maxAttempts: number;\n private readonly retryDelay: (attempt: number) => number;\n private readonly reportFailure: (job: SyncFanoutJob, error: unknown) => void;\n private draining = false;\n private idleResolvers: Array<() => void> = [];\n\n public constructor(options: SyncFanoutQueueOptions = {}) {\n this.maxAttempts = options.maxAttempts ?? 3;\n this.retryDelay = options.retryDelay ?? ((attempt) => attempt * 50);\n this.reportFailure = options.reportFailure ?? ((job, error) => {\n log.error(\n \"database\",\n \"sync.failed\",\n `[cascade] ${job.operation} sync failed for ${job.sourceModel} after ${this.maxAttempts} attempts: ${error}`,\n );\n });\n }\n\n /** Add a committed lifecycle fan-out job without awaiting its execution. */\n public enqueue(job: SyncFanoutJob): void {\n this.jobs.push(job);\n\n if (!this.draining) {\n this.draining = true;\n void this.drain();\n }\n }\n\n /** Wait until the currently queued fan-out jobs have settled. */\n public async flush(): Promise<void> {\n if (!this.draining && this.jobs.length === 0) {\n return;\n }\n\n await new Promise<void>((resolve) => this.idleResolvers.push(resolve));\n }\n\n private async drain(): Promise<void> {\n while (this.jobs.length > 0) {\n const job = this.jobs.shift();\n\n if (job) {\n await this.execute(job);\n }\n }\n\n this.draining = false;\n this.idleResolvers.splice(0).forEach((resolve) => resolve());\n }\n\n private async execute(job: SyncFanoutJob): Promise<void> {\n let error: unknown;\n\n for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {\n try {\n await job.execute();\n return;\n } catch (caught) {\n error = caught;\n\n if (attempt < this.maxAttempts) {\n await this.wait(this.retryDelay(attempt));\n }\n }\n }\n\n this.reportFailure(job, error);\n }\n\n private async wait(milliseconds: number): Promise<void> {\n if (milliseconds <= 0) {\n return;\n }\n\n await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));\n }\n}\n\nconst syncFanoutQueue = new SyncFanoutQueue();\n\n/** Queue a lifecycle sync after its database change has committed. */\nexport function enqueueSyncFanout(job: SyncFanoutJob): void {\n syncFanoutQueue.enqueue(job);\n}\n"],"mappings":";;;;;;;AAkBA,IAAa,kBAAb,MAA6B;CAC3B,AAAiB,OAAwB,CAAC;CAC1C,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CACjB,AAAQ,WAAW;CACnB,AAAQ,gBAAmC,CAAC;CAE5C,AAAO,YAAY,UAAkC,CAAC,GAAG;EACvD,KAAK,cAAc,QAAQ,eAAe;EAC1C,KAAK,aAAa,QAAQ,gBAAgB,YAAY,UAAU;EAChE,KAAK,gBAAgB,QAAQ,mBAAmB,KAAK,UAAU;GAC7D,IAAI,MACF,YACA,eACA,aAAa,IAAI,UAAU,mBAAmB,IAAI,YAAY,SAAS,KAAK,YAAY,aAAa,OACvG;EACF;CACF;;CAGA,AAAO,QAAQ,KAA0B;EACvC,KAAK,KAAK,KAAK,GAAG;EAElB,IAAI,CAAC,KAAK,UAAU;GAClB,KAAK,WAAW;GAChB,AAAK,KAAK,MAAM;EAClB;CACF;;CAGA,MAAa,QAAuB;EAClC,IAAI,CAAC,KAAK,YAAY,KAAK,KAAK,WAAW,GACzC;EAGF,MAAM,IAAI,SAAe,YAAY,KAAK,cAAc,KAAK,OAAO,CAAC;CACvE;CAEA,MAAc,QAAuB;EACnC,OAAO,KAAK,KAAK,SAAS,GAAG;GAC3B,MAAM,MAAM,KAAK,KAAK,MAAM;GAE5B,IAAI,KACF,MAAM,KAAK,QAAQ,GAAG;EAE1B;EAEA,KAAK,WAAW;EAChB,KAAK,cAAc,OAAO,CAAC,CAAC,CAAC,SAAS,YAAY,QAAQ,CAAC;CAC7D;CAEA,MAAc,QAAQ,KAAmC;EACvD,IAAI;EAEJ,KAAK,IAAI,UAAU,GAAG,WAAW,KAAK,aAAa,WACjD,IAAI;GACF,MAAM,IAAI,QAAQ;GAClB;EACF,SAAS,QAAQ;GACf,QAAQ;GAER,IAAI,UAAU,KAAK,aACjB,MAAM,KAAK,KAAK,KAAK,WAAW,OAAO,CAAC;EAE5C;EAGF,KAAK,cAAc,KAAK,KAAK;CAC/B;CAEA,MAAc,KAAK,cAAqC;EACtD,IAAI,gBAAgB,GAClB;EAGF,MAAM,IAAI,SAAe,YAAY,WAAW,SAAS,YAAY,CAAC;CACxE;AACF;AAEA,MAAM,kBAAkB,IAAI,gBAAgB;;AAG5C,SAAgB,kBAAkB,KAA0B;CAC1D,gBAAgB,QAAQ,GAAG;AAC7B"}
@@ -1 +1 @@
1
- {"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;AAwDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,cAAA;EA+HvB;EAAA,iBA7HG,KAAA;EAkPgB;EAAA,iBA/OhB,kBAAA;EA8RJ;EAAA,iBA3RI,IAAA;EA2Ra;EAAA,iBAxRb,UAAA;EAwRmD;EAAA,iBArRnD,MAAA;EAqXT;EAAA,iBAlXS,KAAA;EA8aJ;EAAA,iBA3aI,UAAA;EAwdT;EAAA,iBArdS,MAAA;EAiiBT;EAAA,iBA9hBS,UAAA;EAikBH;;AAAW;;;;;;;;;;cAnjBN,KAAA,EAAO,KAAA;;;;;;;;EAkBb,IAAA,CAAK,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;;UAqE1C,eAAA;;;;;;;EAqHD,gBAAA,IAAoB,OAAA;;;;;;;;UAYnB,aAAA;;;;;;;;;;;;EAmCD,gBAAA,CAAiB,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,MAAA;;;;;;;;UA4CtD,aAAA;;;;;;UAoDN,kBAAA;;;;;;;;;;;;UAmDA,qBAAA;;;;;;EASK,cAAA,IAAkB,OAAA;;;;;;;;;;;;;;;;;;;;;;UA6CvB,qBAAA;;;;;;;;;;;;UAmDA,gBAAA;;;;;;;;;;;;UAyBA,kBAAA;;;;;;;;;UAsBA,SAAA;;;;;;;;;;UAaM,WAAA;AAAA"}
1
+ {"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;AAwDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,cAAA;EA+HvB;EAAA,iBA7HG,KAAA;EAkPgB;EAAA,iBA/OhB,kBAAA;EA8RJ;EAAA,iBA3RI,IAAA;EA2Ra;EAAA,iBAxRb,UAAA;EAwRmD;EAAA,iBArRnD,MAAA;EAqXT;EAAA,iBAlXS,KAAA;EA8aJ;EAAA,iBA3aI,UAAA;EAwdT;EAAA,iBArdS,MAAA;EAiiBT;EAAA,iBA9hBS,UAAA;EAikBT;;AAAW;;;;;;;;;;cAnjBA,KAAA,EAAO,KAAA;;;;;;;;EAkBb,IAAA,CAAK,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;;UAqE1C,eAAA;;;;;;;EAqHD,gBAAA,IAAoB,OAAA;;;;;;;;UAYnB,aAAA;;;;;;;;;;;;EAmCD,gBAAA,CAAiB,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,MAAA;;;;;;;;UA4CtD,aAAA;;;;;;UAoDN,kBAAA;;;;;;;;;;;;UAmDA,qBAAA;;;;;;EASK,cAAA,IAAkB,OAAA;;;;;;;;;;;;;;;;;;;;;;UA6CvB,qBAAA;;;;;;;;;;;;UAmDA,gBAAA;;;;;;;;;;;;UAyBA,kBAAA;;;;;;;;;UAsBA,SAAA;;;;;;;;;;UAaA,WAAA;AAAA"}
@@ -1,9 +1,9 @@
1
1
  import { afterCommit } from "../transactions/after-commit.mjs";
2
2
  import { triggerModelEvent } from "../sync/model-events.mjs";
3
3
  import { mergeDriverFields } from "../model/methods/accessor-methods.mjs";
4
+ import { enqueueSyncFanout } from "../sync/sync-fanout.mjs";
4
5
  import { DatabaseWriterValidationError } from "../validation/database-writer-validation-error.mjs";
5
6
  import "../validation/index.mjs";
6
- import { log } from "@warlock.js/logger";
7
7
  import { when } from "@mongez/reinforcements";
8
8
  import { getSealConfig, v } from "@warlock.js/seal";
9
9
 
@@ -393,12 +393,12 @@ var DatabaseWriter = class {
393
393
  * @param changedFields - Fields that were changed (for filtering)
394
394
  * @private
395
395
  */
396
- async triggerSync(changedFields) {
397
- try {
398
- await triggerModelEvent(this.ctor, "updated", this.model, changedFields);
399
- } catch (error) {
400
- log.error("database", "sync.failed", `[cascade] sync failed for ${this.ctor.name}: ${error}`);
401
- }
396
+ triggerSync(changedFields) {
397
+ enqueueSyncFanout({
398
+ sourceModel: this.ctor.name,
399
+ operation: "update",
400
+ execute: () => triggerModelEvent(this.ctor, "updated", this.model, changedFields)
401
+ });
402
402
  }
403
403
  };
404
404
 
@@ -1 +1 @@
1
- {"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import { when } from \"@mongez/reinforcements\";\nimport { log } from \"@warlock.js/logger\";\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\nimport type {\n DriverContract,\n InsertResult,\n UpdateOperations,\n UpdateResult,\n} from \"../contracts/database-driver.contract\";\nimport type {\n WriterContract,\n WriterOptions,\n WriterResult,\n} from \"../contracts/database-writer.contract\";\nimport { mergeDriverFields } from \"../model/methods/accessor-methods\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport type { StrictMode } from \"../types\";\nimport { DatabaseWriterValidationError } from \"../validation\";\nimport { afterCommit } from \"../transactions/after-commit\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database writer service that orchestrates model persistence.\n *\n * Handles the complete save pipeline:\n * 1. Check for changes (skip if no changes and not new)\n * 2. Emit `saving` event (for data enrichment)\n * 3. Emit `validating` event\n * 4. Validate and cast data via @warlock.js/seal schema\n * 5. Emit `validated` event\n * 6. Generate ID (for new NoSQL records)\n * 7. Emit `creating`/`updating` events\n * 8. Execute insert or update via driver\n * 9. Merge returned data into model\n * 10. Reset dirty tracker and update `isNew` flag\n * 11. Emit `saved` and `created`/`updated` events\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n *\n * console.log(user.get(\"id\")); // 1 (auto-generated)\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\n *\n * // Update existing record\n * user.set(\"name\", \"Alice Smith\");\n * await writer.save();\n * // Only updates the \"name\" field (partial update)\n *\n * // Silent save (no events)\n * await writer.save({ skipEvents: true });\n * ```\n */\nexport class DatabaseWriter implements WriterContract {\n /** The model instance being persisted */\n private readonly model: Model;\n\n /** Dirty columns an update validation dropped (strip mode); never written. */\n private readonly unvalidatedColumns = new Set<string>();\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver and ID generator */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /** Validation schema (if defined) */\n private readonly schema?: ObjectValidator;\n\n /** Strict mode configuration */\n private readonly strictMode: StrictMode;\n\n /**\n * Create a new writer instance for a model.\n *\n * @param model - The model instance to persist\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n * ```\n */\n public constructor(model: Model) {\n this.model = model;\n this.ctor = model.constructor as ChildModel<Model>;\n this.dataSource = this.ctor.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = this.ctor.table;\n this.primaryKey = this.ctor.primaryKey;\n this.schema = this.ctor.schema;\n this.strictMode = this.ctor.strictMode;\n }\n\n /**\n * Save the model instance to the database.\n *\n * @param options - Save options\n * @returns Result with success status, document, and metadata\n * @throws {ValidationError} If validation fails\n */\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\n const isInsert = this.model.isNew;\n\n // 1. Check if model has changes (skip if no changes and not new)\n if (!isInsert && !this.model.hasChanges()) {\n return {\n success: true,\n document: this.model.data,\n isNew: false,\n modifiedCount: 0,\n };\n }\n\n // 2. Emit saving event (before validation for data enrichment)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saving\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // 3-4. Execute insert or update (validation happens inside each path)\n let result: InsertResult | UpdateResult;\n\n if (isInsert) {\n result = await this.performInsert(options);\n } else {\n await this.validateAndCast(false, options);\n result = await this.performUpdate(options);\n }\n\n // 5. Reset dirty tracker and update isNew flag\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\n this.model.dirtyTracker.reset();\n this.model.isNew = false;\n\n // 6. Emit post-save events\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saved\");\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\n }\n\n // 7. Trigger sync operations after COMMIT (inside a transaction they are\n // queued, so a rolled-back write never fans out). Failures are logged.\n if (!options.skipSync && !isInsert) {\n afterCommit(() => this.triggerSync(changedFields));\n }\n\n return {\n success: true,\n document: this.model.data,\n isNew: isInsert,\n modifiedCount: isInsert\n ? undefined\n : (result as UpdateResult).modifiedCount,\n };\n }\n\n /**\n * Validate and cast model data using the schema.\n *\n * Updates the model's data in-place with validated/casted values.\n *\n * @param isInsert - Whether this is an insert operation\n * @param options - Save options\n * @throws {ValidationError} If validation fails\n * @private\n */\n private async validateAndCast(\n isInsert: boolean,\n options: WriterOptions,\n ): Promise<void> {\n // Emit validating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validating\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // Skip validation if requested or no schema defined\n if (options.skipValidation || !this.schema) {\n return;\n }\n\n // Whitelist the framework-managed system columns so a model carrying them\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\n // `when(...)` adds each timestamp/soft-delete column only when configured\n // (truthy) — the lazy factory means a disabled column (`false`) never even\n // builds a bogus schema key.\n //\n // The whitelist must apply to BOTH insert and update: on insert a caller may\n // supply a backdated createdAt (e.g. data migrations / imports), and without\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\n // writer, while \"fail\" rejects the whole insert.\n const systemColumns = {\n id: v.scalar().optional(),\n _id: v.any().optional(),\n ...when(this.ctor.createdAtColumn, () => ({\n [this.ctor.createdAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.updatedAtColumn, () => ({\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.deletedAtColumn, () => ({\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\n })),\n };\n\n // Clone full schema for insert, partial (dirty keys only) for updates.\n //\n // An update validates ONLY the dirty columns and merges back only those\n // keys: columns the schema does not list (migration-added, withCount, ...)\n // are never touched, so they can no longer be turned into `$unset`.\n const dataToValidate: Record<string, unknown> = isInsert\n ? this.model.data\n : Object.fromEntries(\n this.model\n .getDirtyColumns()\n .filter(column => column in this.model.data)\n .map(column => [column, this.model.data[column]]),\n );\n\n const validationSchema = isInsert\n ? this.schema.clone().extend(systemColumns)\n : this.schema.clone(Object.keys(dataToValidate)).extend(systemColumns);\n\n // Apply strict mode\n if (this.strictMode === \"strip\") {\n validationSchema.stripUnknown();\n } else if (this.strictMode === \"fail\") {\n validationSchema.allowUnknown(false);\n } else if (this.strictMode === \"allow\") {\n validationSchema.allowUnknown(true);\n }\n\n // Run validation\n const result = await v.validate(validationSchema, dataToValidate, {\n context: {\n model: this.model,\n },\n ...getSealConfig(),\n });\n\n if (!result.isValid) {\n const error = new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n result.errors,\n );\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result, error });\n }\n throw error;\n }\n\n // Update model data with validated/casted data\n if (isInsert) {\n this.model.replaceData(result.data);\n } else {\n const validated = result.data as Record<string, unknown>;\n\n for (const column of Object.keys(dataToValidate)) {\n if (column in validated) {\n this.model.set(column, validated[column]);\n } else {\n // Dropped by strictMode \"strip\": never write an unvalidated column.\n this.unvalidatedColumns.add(column);\n }\n }\n }\n\n // Emit validated event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result });\n }\n }\n\n /**\n * Validate, cast and id-generate a not-yet-persisted model for `upsert()`.\n *\n * Runs the insert-mode pipeline (schema, casts, strictMode, Mongo `id`) but\n * does not write; the caller sends the resulting data through the driver.\n */\n public async prepareForUpsert(): Promise<void> {\n await this.validateAndCast(true, {});\n await this.generateNextId();\n }\n\n /**\n * Perform an insert operation.\n *\n * @param options - Save options\n * @returns Insert result\n * @private\n */\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\n const dataToInsert = await this.prepareForInsert(options);\n\n // INSERT: use full validated data\n let result: InsertResult;\n\n try {\n result = await this.driver.insert(this.table, dataToInsert);\n } catch (error) {\n throw this.mapUniqueViolation(error, true);\n }\n\n // Merge returned data (e.g., generated _id, timestamps)\n // Note: We use merge here because the result might not include all fields\n // (e.g., our generated 'id' field), and we don't want to lose them\n mergeDriverFields(this.model, result.document as Record<string, unknown>);\n\n // Reset dirty tracker immediately after merge to prevent\n // database-generated fields (like _id) from being marked as dirty\n this.model.dirtyTracker.reset();\n\n return result;\n }\n\n /**\n * Run the insert pipeline (validation, casting, id generation, timestamps,\n * `validating`/`validated`/`creating` hooks) WITHOUT writing.\n *\n * `save()` uses this for its insert path, so bulk inserts and single inserts\n * cannot drift. The caller is responsible for sending the returned document\n * to the driver.\n *\n * @param options - Save options (e.g. `skipEvents`, `skipValidation`)\n * @returns The prepared document to insert\n */\n public async prepareForInsert(options: WriterOptions = {}): Promise<Record<string, unknown>> {\n // Validate and cast data\n await this.validateAndCast(true, options);\n\n // Generate ID if needed (NoSQL only)\n await this.generateNextId();\n\n // Get data to insert (already validated and casted)\n const dataToInsert = this.model.data;\n\n // Add createdAt and updatedAt to the data (using resolved column names)\n // The column names are already resolved through the hierarchy:\n // Model static property > Database config > Driver defaults > undefined\n //\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\n // value (e.g. data migrations / imports) is honored instead of overwritten.\n // This mirrors the upsert path's guard. updatedAt is always stamped to\n // reflect the moment the record is persisted.\n const createdAtColumn = this.ctor.createdAtColumn;\n\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\n dataToInsert[createdAtColumn] = new Date();\n }\n\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n dataToInsert[updatedAtColumn] = new Date();\n }\n\n // Emit creating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"creating\");\n }\n\n return dataToInsert;\n }\n\n /**\n * Perform an update operation.\n *\n * @param options - Save options\n * @returns Update result\n * @private\n */\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\n // Emit updating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"updating\");\n }\n\n // Update the updatedAt timestamp (using resolved column name)\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n this.model.set(updatedAtColumn, new Date());\n }\n\n if (options.replace) {\n const document = await this.driver.replace(\n this.table,\n this.buildPrimaryKeyFilter(),\n this.model.data,\n );\n\n if (document) {\n this.model.replaceData(document as Record<string, unknown>);\n }\n\n return { modifiedCount: document ? 1 : 0 };\n }\n\n // Build operations from dirty tracker\n const operations = this.buildUpdateOperations();\n\n // Nothing left to write once identity columns are excluded — don't hand the\n // driver an empty update document (MongoDB rejects one).\n if (Object.keys(operations).length === 0) {\n return { modifiedCount: 0 };\n }\n\n // Execute update with operations\n try {\n return await this.driver.update(\n this.table,\n this.buildPrimaryKeyFilter(),\n operations,\n );\n } catch (error) {\n throw this.mapUniqueViolation(error, false);\n }\n }\n\n /**\n * Map a driver unique-constraint violation (Postgres 23505, MongoDB 11000)\n * to a field validation error, so a lost `unique` race never surfaces as a\n * 500. Any other error is returned untouched.\n */\n private mapUniqueViolation(error: unknown, isInsert: boolean): unknown {\n const err = error as\n | {\n code?: string | number;\n detail?: string;\n keyPattern?: Record<string, unknown>;\n message?: string;\n }\n | undefined;\n\n if (err?.code !== \"23505\" && err?.code !== 11000 && err?.code !== \"11000\") {\n return error;\n }\n\n let field: string | undefined;\n\n if (err.keyPattern) {\n field = Object.keys(err.keyPattern)[0];\n } else {\n // Postgres: \"Key (email)=(x) already exists.\"\n field = /Key \\(([^)]+)\\)=/.exec(err.detail ?? \"\")?.[1]?.split(\",\")[0]?.trim();\n }\n\n if (!field) {\n // Mongo: \"index: email_1 dup key\"\n field = /index: (\\S+?)_-?1\\b/.exec(err.message ?? \"\")?.[1];\n }\n\n return new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n [\n {\n type: \"unique\",\n error: `The ${field ?? \"value\"} must be unique`,\n input: field ?? \"id\",\n },\n ],\n );\n }\n\n /**\n * Build the filter that pins a write to the row this model was loaded from.\n *\n * It reads `model.trustedPrimaryKey` — the value captured when the instance\n * became persisted — NOT the current value in `model.data`. The current value\n * is reachable by mass assignment (`model.merge(req.body)`), so deriving the\n * filter from it let a request body redirect the UPDATE to another document.\n *\n * @returns Filter matching the originally loaded record\n * @private\n */\n private buildPrimaryKeyFilter(): Record<string, unknown> {\n return { [this.primaryKey]: this.model.trustedPrimaryKey };\n }\n\n /**\n * Generate ID for the model if auto-generation is enabled.\n *\n * @private\n */\n public async generateNextId(): Promise<void> {\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\n return;\n }\n\n const idGenerator = this.dataSource.idGenerator;\n if (!idGenerator) {\n return;\n }\n\n // Resolve ID generation options from model configuration\n const initialId = this.resolveInitialId();\n\n const incrementIdBy = this.resolveIncrementBy();\n\n const id = await idGenerator.generateNextId({\n table: this.table,\n initialId,\n incrementIdBy,\n });\n\n this.model.set(\"id\", id);\n }\n\n /**\n * Build update operations from the model's dirty tracker.\n *\n * Handles both modified fields ($set) and removed fields ($unset).\n *\n * @returns Update operations for the driver\n * @private\n *\n * @example\n * ```typescript\n * // Model with changes\n * user.set(\"name\", \"Alice\");\n * user.unset(\"tempField\");\n *\n * const operations = this.buildUpdateOperations();\n * // {\n * // $set: { name: \"Alice\" },\n * // $unset: { tempField: 1 }\n * // }\n * ```\n */\n private buildUpdateOperations(): UpdateOperations {\n const operations: UpdateOperations = {};\n\n // Identity columns are never written by an update. The filter pins the row\n // by its captured primary key, so a dirty `id`/`_id` can only come from\n // mass assignment or an explicit set() — either way, rewriting the key of\n // an existing row corrupts identity (and `_id` is immutable in MongoDB).\n // Changing a primary key is a deliberate operation: use the atomic/raw APIs.\n const identityColumns = new Set([this.primaryKey, \"id\", \"_id\"]);\n\n // Get dirty columns (modified fields)\n const dirtyColumns = this.model\n .getDirtyColumns()\n .filter(column => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));\n\n if (dirtyColumns.length > 0) {\n operations.$set = {};\n for (const column of dirtyColumns) {\n const value = this.model.get(column);\n if (value === undefined) continue;\n\n operations.$set[column] = this.model.get(column);\n }\n }\n\n // Get removed columns\n const removedColumns = this.model\n .getRemovedColumns()\n .filter(column => !identityColumns.has(column));\n\n if (removedColumns.length > 0) {\n operations.$unset = {};\n for (const column of removedColumns) {\n operations.$unset[column] = 1;\n }\n }\n\n return operations;\n }\n\n /**\n * Resolve the initial ID from model configuration.\n *\n * Priority:\n * 1. Model.initialId (explicit value)\n * 2. Model.randomInitialId (random or function)\n * 3. Default: 1\n *\n * @returns The initial ID value\n * @private\n */\n private resolveInitialId(): number {\n if (this.ctor.initialId) {\n return this.ctor.initialId;\n }\n\n if (this.ctor.randomInitialId) {\n return typeof this.ctor.randomInitialId === \"function\"\n ? this.ctor.randomInitialId()\n : this.randomInt(10000, 499999);\n }\n\n return 1; // Default initial ID\n }\n\n /**\n * Resolve the increment value from model configuration.\n *\n * Priority:\n * 1. Model.incrementIdBy (explicit value)\n * 2. Model.randomIncrement (random or function)\n * 3. Default: 1\n *\n * @returns The increment value\n * @private\n */\n private resolveIncrementBy(): number {\n if (this.ctor.randomIncrement) {\n return typeof this.ctor.randomIncrement === \"function\"\n ? this.ctor.randomIncrement()\n : this.randomInt(1, 10);\n }\n\n if (this.ctor.incrementIdBy) {\n return this.ctor.incrementIdBy;\n }\n\n return 1; // Default increment\n }\n\n /**\n * Generate a random integer between min and max (inclusive).\n *\n * @param min - Minimum value\n * @param max - Maximum value\n * @returns Random integer\n * @private\n */\n private randomInt(min: number, max: number): number {\n return Math.floor(Math.random() * (max - min + 1)) + min;\n }\n\n /**\n * Trigger sync operations after successful save.\n *\n * Emits a model.updated event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @param changedFields - Fields that were changed (for filtering)\n * @private\n */\n private async triggerSync(changedFields: string[]): Promise<void> {\n try {\n // Emit model.updated event - ModelSyncOperation listens to these\n await triggerModelEvent(this.ctor, \"updated\", this.model, changedFields);\n } catch (error) {\n log.error(\"database\", \"sync.failed\", `[cascade] sync failed for ${this.ctor.name}: ${error}`);\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import { when } from \"@mongez/reinforcements\";\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\nimport type {\n DriverContract,\n InsertResult,\n UpdateOperations,\n UpdateResult,\n} from \"../contracts/database-driver.contract\";\nimport type {\n WriterContract,\n WriterOptions,\n WriterResult,\n} from \"../contracts/database-writer.contract\";\nimport { mergeDriverFields } from \"../model/methods/accessor-methods\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { enqueueSyncFanout } from \"../sync/sync-fanout\";\nimport { triggerModelEvent } from \"../sync/model-events\";\nimport type { StrictMode } from \"../types\";\nimport { DatabaseWriterValidationError } from \"../validation\";\nimport { afterCommit } from \"../transactions/after-commit\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database writer service that orchestrates model persistence.\n *\n * Handles the complete save pipeline:\n * 1. Check for changes (skip if no changes and not new)\n * 2. Emit `saving` event (for data enrichment)\n * 3. Emit `validating` event\n * 4. Validate and cast data via @warlock.js/seal schema\n * 5. Emit `validated` event\n * 6. Generate ID (for new NoSQL records)\n * 7. Emit `creating`/`updating` events\n * 8. Execute insert or update via driver\n * 9. Merge returned data into model\n * 10. Reset dirty tracker and update `isNew` flag\n * 11. Emit `saved` and `created`/`updated` events\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n *\n * console.log(user.get(\"id\")); // 1 (auto-generated)\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\n *\n * // Update existing record\n * user.set(\"name\", \"Alice Smith\");\n * await writer.save();\n * // Only updates the \"name\" field (partial update)\n *\n * // Silent save (no events)\n * await writer.save({ skipEvents: true });\n * ```\n */\nexport class DatabaseWriter implements WriterContract {\n /** The model instance being persisted */\n private readonly model: Model;\n\n /** Dirty columns an update validation dropped (strip mode); never written. */\n private readonly unvalidatedColumns = new Set<string>();\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver and ID generator */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /** Validation schema (if defined) */\n private readonly schema?: ObjectValidator;\n\n /** Strict mode configuration */\n private readonly strictMode: StrictMode;\n\n /**\n * Create a new writer instance for a model.\n *\n * @param model - The model instance to persist\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n * ```\n */\n public constructor(model: Model) {\n this.model = model;\n this.ctor = model.constructor as ChildModel<Model>;\n this.dataSource = this.ctor.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = this.ctor.table;\n this.primaryKey = this.ctor.primaryKey;\n this.schema = this.ctor.schema;\n this.strictMode = this.ctor.strictMode;\n }\n\n /**\n * Save the model instance to the database.\n *\n * @param options - Save options\n * @returns Result with success status, document, and metadata\n * @throws {ValidationError} If validation fails\n */\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\n const isInsert = this.model.isNew;\n\n // 1. Check if model has changes (skip if no changes and not new)\n if (!isInsert && !this.model.hasChanges()) {\n return {\n success: true,\n document: this.model.data,\n isNew: false,\n modifiedCount: 0,\n };\n }\n\n // 2. Emit saving event (before validation for data enrichment)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saving\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // 3-4. Execute insert or update (validation happens inside each path)\n let result: InsertResult | UpdateResult;\n\n if (isInsert) {\n result = await this.performInsert(options);\n } else {\n await this.validateAndCast(false, options);\n result = await this.performUpdate(options);\n }\n\n // 5. Reset dirty tracker and update isNew flag\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\n this.model.dirtyTracker.reset();\n this.model.isNew = false;\n\n // 6. Emit post-save events\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saved\");\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\n }\n\n // 7. Trigger sync operations after COMMIT (inside a transaction they are\n // queued, so a rolled-back write never fans out). Failures are logged.\n if (!options.skipSync && !isInsert) {\n afterCommit(() => this.triggerSync(changedFields));\n }\n\n return {\n success: true,\n document: this.model.data,\n isNew: isInsert,\n modifiedCount: isInsert\n ? undefined\n : (result as UpdateResult).modifiedCount,\n };\n }\n\n /**\n * Validate and cast model data using the schema.\n *\n * Updates the model's data in-place with validated/casted values.\n *\n * @param isInsert - Whether this is an insert operation\n * @param options - Save options\n * @throws {ValidationError} If validation fails\n * @private\n */\n private async validateAndCast(\n isInsert: boolean,\n options: WriterOptions,\n ): Promise<void> {\n // Emit validating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validating\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // Skip validation if requested or no schema defined\n if (options.skipValidation || !this.schema) {\n return;\n }\n\n // Whitelist the framework-managed system columns so a model carrying them\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\n // `when(...)` adds each timestamp/soft-delete column only when configured\n // (truthy) — the lazy factory means a disabled column (`false`) never even\n // builds a bogus schema key.\n //\n // The whitelist must apply to BOTH insert and update: on insert a caller may\n // supply a backdated createdAt (e.g. data migrations / imports), and without\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\n // writer, while \"fail\" rejects the whole insert.\n const systemColumns = {\n id: v.scalar().optional(),\n _id: v.any().optional(),\n ...when(this.ctor.createdAtColumn, () => ({\n [this.ctor.createdAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.updatedAtColumn, () => ({\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.deletedAtColumn, () => ({\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\n })),\n };\n\n // Clone full schema for insert, partial (dirty keys only) for updates.\n //\n // An update validates ONLY the dirty columns and merges back only those\n // keys: columns the schema does not list (migration-added, withCount, ...)\n // are never touched, so they can no longer be turned into `$unset`.\n const dataToValidate: Record<string, unknown> = isInsert\n ? this.model.data\n : Object.fromEntries(\n this.model\n .getDirtyColumns()\n .filter(column => column in this.model.data)\n .map(column => [column, this.model.data[column]]),\n );\n\n const validationSchema = isInsert\n ? this.schema.clone().extend(systemColumns)\n : this.schema.clone(Object.keys(dataToValidate)).extend(systemColumns);\n\n // Apply strict mode\n if (this.strictMode === \"strip\") {\n validationSchema.stripUnknown();\n } else if (this.strictMode === \"fail\") {\n validationSchema.allowUnknown(false);\n } else if (this.strictMode === \"allow\") {\n validationSchema.allowUnknown(true);\n }\n\n // Run validation\n const result = await v.validate(validationSchema, dataToValidate, {\n context: {\n model: this.model,\n },\n ...getSealConfig(),\n });\n\n if (!result.isValid) {\n const error = new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n result.errors,\n );\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result, error });\n }\n throw error;\n }\n\n // Update model data with validated/casted data\n if (isInsert) {\n this.model.replaceData(result.data);\n } else {\n const validated = result.data as Record<string, unknown>;\n\n for (const column of Object.keys(dataToValidate)) {\n if (column in validated) {\n this.model.set(column, validated[column]);\n } else {\n // Dropped by strictMode \"strip\": never write an unvalidated column.\n this.unvalidatedColumns.add(column);\n }\n }\n }\n\n // Emit validated event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result });\n }\n }\n\n /**\n * Validate, cast and id-generate a not-yet-persisted model for `upsert()`.\n *\n * Runs the insert-mode pipeline (schema, casts, strictMode, Mongo `id`) but\n * does not write; the caller sends the resulting data through the driver.\n */\n public async prepareForUpsert(): Promise<void> {\n await this.validateAndCast(true, {});\n await this.generateNextId();\n }\n\n /**\n * Perform an insert operation.\n *\n * @param options - Save options\n * @returns Insert result\n * @private\n */\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\n const dataToInsert = await this.prepareForInsert(options);\n\n // INSERT: use full validated data\n let result: InsertResult;\n\n try {\n result = await this.driver.insert(this.table, dataToInsert);\n } catch (error) {\n throw this.mapUniqueViolation(error, true);\n }\n\n // Merge returned data (e.g., generated _id, timestamps)\n // Note: We use merge here because the result might not include all fields\n // (e.g., our generated 'id' field), and we don't want to lose them\n mergeDriverFields(this.model, result.document as Record<string, unknown>);\n\n // Reset dirty tracker immediately after merge to prevent\n // database-generated fields (like _id) from being marked as dirty\n this.model.dirtyTracker.reset();\n\n return result;\n }\n\n /**\n * Run the insert pipeline (validation, casting, id generation, timestamps,\n * `validating`/`validated`/`creating` hooks) WITHOUT writing.\n *\n * `save()` uses this for its insert path, so bulk inserts and single inserts\n * cannot drift. The caller is responsible for sending the returned document\n * to the driver.\n *\n * @param options - Save options (e.g. `skipEvents`, `skipValidation`)\n * @returns The prepared document to insert\n */\n public async prepareForInsert(options: WriterOptions = {}): Promise<Record<string, unknown>> {\n // Validate and cast data\n await this.validateAndCast(true, options);\n\n // Generate ID if needed (NoSQL only)\n await this.generateNextId();\n\n // Get data to insert (already validated and casted)\n const dataToInsert = this.model.data;\n\n // Add createdAt and updatedAt to the data (using resolved column names)\n // The column names are already resolved through the hierarchy:\n // Model static property > Database config > Driver defaults > undefined\n //\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\n // value (e.g. data migrations / imports) is honored instead of overwritten.\n // This mirrors the upsert path's guard. updatedAt is always stamped to\n // reflect the moment the record is persisted.\n const createdAtColumn = this.ctor.createdAtColumn;\n\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\n dataToInsert[createdAtColumn] = new Date();\n }\n\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n dataToInsert[updatedAtColumn] = new Date();\n }\n\n // Emit creating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"creating\");\n }\n\n return dataToInsert;\n }\n\n /**\n * Perform an update operation.\n *\n * @param options - Save options\n * @returns Update result\n * @private\n */\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\n // Emit updating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"updating\");\n }\n\n // Update the updatedAt timestamp (using resolved column name)\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n this.model.set(updatedAtColumn, new Date());\n }\n\n if (options.replace) {\n const document = await this.driver.replace(\n this.table,\n this.buildPrimaryKeyFilter(),\n this.model.data,\n );\n\n if (document) {\n this.model.replaceData(document as Record<string, unknown>);\n }\n\n return { modifiedCount: document ? 1 : 0 };\n }\n\n // Build operations from dirty tracker\n const operations = this.buildUpdateOperations();\n\n // Nothing left to write once identity columns are excluded — don't hand the\n // driver an empty update document (MongoDB rejects one).\n if (Object.keys(operations).length === 0) {\n return { modifiedCount: 0 };\n }\n\n // Execute update with operations\n try {\n return await this.driver.update(\n this.table,\n this.buildPrimaryKeyFilter(),\n operations,\n );\n } catch (error) {\n throw this.mapUniqueViolation(error, false);\n }\n }\n\n /**\n * Map a driver unique-constraint violation (Postgres 23505, MongoDB 11000)\n * to a field validation error, so a lost `unique` race never surfaces as a\n * 500. Any other error is returned untouched.\n */\n private mapUniqueViolation(error: unknown, isInsert: boolean): unknown {\n const err = error as\n | {\n code?: string | number;\n detail?: string;\n keyPattern?: Record<string, unknown>;\n message?: string;\n }\n | undefined;\n\n if (err?.code !== \"23505\" && err?.code !== 11000 && err?.code !== \"11000\") {\n return error;\n }\n\n let field: string | undefined;\n\n if (err.keyPattern) {\n field = Object.keys(err.keyPattern)[0];\n } else {\n // Postgres: \"Key (email)=(x) already exists.\"\n field = /Key \\(([^)]+)\\)=/.exec(err.detail ?? \"\")?.[1]?.split(\",\")[0]?.trim();\n }\n\n if (!field) {\n // Mongo: \"index: email_1 dup key\"\n field = /index: (\\S+?)_-?1\\b/.exec(err.message ?? \"\")?.[1];\n }\n\n return new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n [\n {\n type: \"unique\",\n error: `The ${field ?? \"value\"} must be unique`,\n input: field ?? \"id\",\n },\n ],\n );\n }\n\n /**\n * Build the filter that pins a write to the row this model was loaded from.\n *\n * It reads `model.trustedPrimaryKey` — the value captured when the instance\n * became persisted — NOT the current value in `model.data`. The current value\n * is reachable by mass assignment (`model.merge(req.body)`), so deriving the\n * filter from it let a request body redirect the UPDATE to another document.\n *\n * @returns Filter matching the originally loaded record\n * @private\n */\n private buildPrimaryKeyFilter(): Record<string, unknown> {\n return { [this.primaryKey]: this.model.trustedPrimaryKey };\n }\n\n /**\n * Generate ID for the model if auto-generation is enabled.\n *\n * @private\n */\n public async generateNextId(): Promise<void> {\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\n return;\n }\n\n const idGenerator = this.dataSource.idGenerator;\n if (!idGenerator) {\n return;\n }\n\n // Resolve ID generation options from model configuration\n const initialId = this.resolveInitialId();\n\n const incrementIdBy = this.resolveIncrementBy();\n\n const id = await idGenerator.generateNextId({\n table: this.table,\n initialId,\n incrementIdBy,\n });\n\n this.model.set(\"id\", id);\n }\n\n /**\n * Build update operations from the model's dirty tracker.\n *\n * Handles both modified fields ($set) and removed fields ($unset).\n *\n * @returns Update operations for the driver\n * @private\n *\n * @example\n * ```typescript\n * // Model with changes\n * user.set(\"name\", \"Alice\");\n * user.unset(\"tempField\");\n *\n * const operations = this.buildUpdateOperations();\n * // {\n * // $set: { name: \"Alice\" },\n * // $unset: { tempField: 1 }\n * // }\n * ```\n */\n private buildUpdateOperations(): UpdateOperations {\n const operations: UpdateOperations = {};\n\n // Identity columns are never written by an update. The filter pins the row\n // by its captured primary key, so a dirty `id`/`_id` can only come from\n // mass assignment or an explicit set() — either way, rewriting the key of\n // an existing row corrupts identity (and `_id` is immutable in MongoDB).\n // Changing a primary key is a deliberate operation: use the atomic/raw APIs.\n const identityColumns = new Set([this.primaryKey, \"id\", \"_id\"]);\n\n // Get dirty columns (modified fields)\n const dirtyColumns = this.model\n .getDirtyColumns()\n .filter(column => !identityColumns.has(column) && !this.unvalidatedColumns.has(column));\n\n if (dirtyColumns.length > 0) {\n operations.$set = {};\n for (const column of dirtyColumns) {\n const value = this.model.get(column);\n if (value === undefined) continue;\n\n operations.$set[column] = this.model.get(column);\n }\n }\n\n // Get removed columns\n const removedColumns = this.model\n .getRemovedColumns()\n .filter(column => !identityColumns.has(column));\n\n if (removedColumns.length > 0) {\n operations.$unset = {};\n for (const column of removedColumns) {\n operations.$unset[column] = 1;\n }\n }\n\n return operations;\n }\n\n /**\n * Resolve the initial ID from model configuration.\n *\n * Priority:\n * 1. Model.initialId (explicit value)\n * 2. Model.randomInitialId (random or function)\n * 3. Default: 1\n *\n * @returns The initial ID value\n * @private\n */\n private resolveInitialId(): number {\n if (this.ctor.initialId) {\n return this.ctor.initialId;\n }\n\n if (this.ctor.randomInitialId) {\n return typeof this.ctor.randomInitialId === \"function\"\n ? this.ctor.randomInitialId()\n : this.randomInt(10000, 499999);\n }\n\n return 1; // Default initial ID\n }\n\n /**\n * Resolve the increment value from model configuration.\n *\n * Priority:\n * 1. Model.incrementIdBy (explicit value)\n * 2. Model.randomIncrement (random or function)\n * 3. Default: 1\n *\n * @returns The increment value\n * @private\n */\n private resolveIncrementBy(): number {\n if (this.ctor.randomIncrement) {\n return typeof this.ctor.randomIncrement === \"function\"\n ? this.ctor.randomIncrement()\n : this.randomInt(1, 10);\n }\n\n if (this.ctor.incrementIdBy) {\n return this.ctor.incrementIdBy;\n }\n\n return 1; // Default increment\n }\n\n /**\n * Generate a random integer between min and max (inclusive).\n *\n * @param min - Minimum value\n * @param max - Maximum value\n * @returns Random integer\n * @private\n */\n private randomInt(min: number, max: number): number {\n return Math.floor(Math.random() * (max - min + 1)) + min;\n }\n\n /**\n * Trigger sync operations after successful save.\n *\n * Emits a model.updated event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @param changedFields - Fields that were changed (for filtering)\n * @private\n */\n private triggerSync(changedFields: string[]): void {\n enqueueSyncFanout({\n sourceModel: this.ctor.name,\n operation: \"update\",\n execute: () => triggerModelEvent(this.ctor, \"updated\", this.model, changedFields),\n });\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -8,14 +8,15 @@
8
8
  "cascade": "bin/cascade.js"
9
9
  },
10
10
  "dependencies": {
11
- "@mongez/copper": "^2.1.2",
12
- "@mongez/dotenv": "^1.3.2",
13
- "@mongez/events": "^2.2.7",
11
+ "@mongez/copper": "^2.1.3",
12
+ "@mongez/dotenv": "^1.3.3",
13
+ "@mongez/events": "^2.2.8",
14
14
  "@mongez/reinforcements": "^4.0.2",
15
15
  "@mongez/supportive-is": "^2.1.5",
16
- "@warlock.js/context": "5.21.0",
17
- "@warlock.js/logger": "5.21.0",
18
- "@warlock.js/seal": "5.21.0",
16
+ "@warlock.js/context": "5.22.1",
17
+ "@warlock.js/logger": "5.22.1",
18
+ "@warlock.js/scheduler": "5.22.1",
19
+ "@warlock.js/seal": "5.22.1",
19
20
  "citty": "^0.2.2",
20
21
  "fast-glob": "^3.3.3"
21
22
  },
@@ -51,7 +52,7 @@
51
52
  ],
52
53
  "author": "hassanzohdy",
53
54
  "license": "MIT",
54
- "version": "5.21.0",
55
+ "version": "5.22.1",
55
56
  "main": "./cjs/index.cjs",
56
57
  "module": "./esm/index.mjs",
57
58
  "types": "./esm/index.d.mts",