import { markInvariantsSatisfied, type CustomerAccount, type CustomerAccountHeader, type CustomerAddressUsage, type CustomerBankAccountUsage, } from "../domain/customerAccount"; import type { Insertable, Selectable, Transaction, Updateable } from "../generated/kysely-tailordb"; export interface CustomerAccountRepository { findById(id: string, opts?: { forUpdate?: boolean }): Promise; findByCompanyAndCode( companyId: string, code: string, opts?: { forUpdate?: boolean }, ): Promise; save(account: CustomerAccount): Promise; } // ===== Columns ===== /** The domain-owned columns of each table. Everything else round-trips as custom fields. */ const CUSTOMER_ACCOUNT_HEADER_COLUMNS = [ "partnerId", "companyId", "code", "name", "preferredCurrencyId", "status", ] as const satisfies readonly Exclude[]; const CUSTOMER_ADDRESS_USAGE_COLUMNS = [ "id", "addressId", "purpose", "isDefault", ] as const satisfies readonly Exclude[]; const CUSTOMER_BANK_ACCOUNT_USAGE_COLUMNS = [ "id", "bankAccountId", "purpose", "isDefault", ] as const satisfies readonly Exclude[]; const HEADER_ROW_ONLY_COLUMNS = ["id", "createdAt", "updatedAt"] as const; const USAGE_ROW_ONLY_COLUMNS = ["accountId", "createdAt", "updatedAt"] as const; // ===== Row helpers ===== function pick(row: T, keys: readonly K[]): Pick { const picked = {} as Pick; for (const key of keys) picked[key] = row[key]; return picked; } /** Runtime rows carry app-extension columns the generated schema types do not know. */ function extractCustomFields(row: object, knownColumns: readonly string[]) { const known = new Set(knownColumns); const customFields: Record = {}; for (const [key, value] of Object.entries(row)) { if (!known.has(key)) customFields[key] = value; } return customFields; } function isSameColumnValue(a: unknown, b: unknown) { if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); } return a === b; } function changedColumns(before: Record, after: Record) { const changes: Record = {}; for (const [column, value] of Object.entries(after)) { if (!isSameColumnValue(before[column], value)) changes[column] = value; } return changes; } // ===== Mapping ===== export function toCustomerAccount( headerRow: Selectable<"CustomerAccount">, addressUsageRows: Selectable<"CustomerAddressUsage">[], bankAccountUsageRows: Selectable<"CustomerBankAccountUsage">[], ): CustomerAccount { const header: CustomerAccountHeader = { ...pick(headerRow, CUSTOMER_ACCOUNT_HEADER_COLUMNS), customFields: extractCustomFields(headerRow, [ ...CUSTOMER_ACCOUNT_HEADER_COLUMNS, ...HEADER_ROW_ONLY_COLUMNS, ]), }; const addressUsages = addressUsageRows.map((row): CustomerAddressUsage => ({ ...pick(row, CUSTOMER_ADDRESS_USAGE_COLUMNS), customFields: extractCustomFields(row, [ ...CUSTOMER_ADDRESS_USAGE_COLUMNS, ...USAGE_ROW_ONLY_COLUMNS, ]), })); const bankAccountUsages = bankAccountUsageRows.map((row): CustomerBankAccountUsage => ({ ...pick(row, CUSTOMER_BANK_ACCOUNT_USAGE_COLUMNS), customFields: extractCustomFields(row, [ ...CUSTOMER_BANK_ACCOUNT_USAGE_COLUMNS, ...USAGE_ROW_ONLY_COLUMNS, ]), })); return markInvariantsSatisfied({ id: headerRow.id, header, addressUsages, bankAccountUsages, }); } /** Custom fields first so domain-owned columns always win. */ function toCustomerAccountRow(account: CustomerAccount) { const row: Record = { ...account.header.customFields, id: account.id }; for (const column of CUSTOMER_ACCOUNT_HEADER_COLUMNS) row[column] = account.header[column]; return row; } /** Custom fields first so domain-owned columns always win. */ function toCustomerAddressUsageRow(usage: CustomerAddressUsage, accountId: string) { const row: Record = { ...usage.customFields, accountId }; for (const column of CUSTOMER_ADDRESS_USAGE_COLUMNS) row[column] = usage[column]; return row; } /** Custom fields first so domain-owned columns always win. */ function toCustomerBankAccountUsageRow(usage: CustomerBankAccountUsage, accountId: string) { const row: Record = { ...usage.customFields, accountId }; for (const column of CUSTOMER_BANK_ACCOUNT_USAGE_COLUMNS) row[column] = usage[column]; return row; } async function loadUsages(db: Transaction, accountId: string) { const addressUsageRows = await db .selectFrom("CustomerAddressUsage") .selectAll() .where("accountId", "=", accountId) .execute(); const bankAccountUsageRows = await db .selectFrom("CustomerBankAccountUsage") .selectAll() .where("accountId", "=", accountId) .execute(); return { addressUsageRows, bankAccountUsageRows }; } async function upsertAddressUsages( db: Transaction, account: CustomerAccount, storedRows: Selectable<"CustomerAddressUsage">[], ) { const storedById = new Map(storedRows.map((row) => [row.id, row])); for (const usage of account.addressUsages) { const stored = storedById.get(usage.id); if (!stored) continue; const changes = changedColumns(stored, toCustomerAddressUsageRow(usage, account.id)); if (Object.keys(changes).length > 0) { await db .updateTable("CustomerAddressUsage") .set(changes as Updateable<"CustomerAddressUsage">) .where("id", "=", usage.id) .execute(); } } const keptIds = new Set(account.addressUsages.map((usage) => usage.id)); const removedIds = storedRows.filter((row) => !keptIds.has(row.id)).map((row) => row.id); if (removedIds.length > 0) { await db.deleteFrom("CustomerAddressUsage").where("id", "in", removedIds).execute(); } const addedRows = account.addressUsages .filter((usage) => !storedById.has(usage.id)) .map( (usage) => toCustomerAddressUsageRow(usage, account.id) as Insertable<"CustomerAddressUsage">, ); if (addedRows.length > 0) { await db.insertInto("CustomerAddressUsage").values(addedRows).execute(); } } async function upsertBankAccountUsages( db: Transaction, account: CustomerAccount, storedRows: Selectable<"CustomerBankAccountUsage">[], ) { const storedById = new Map(storedRows.map((row) => [row.id, row])); for (const usage of account.bankAccountUsages) { const stored = storedById.get(usage.id); if (!stored) continue; const changes = changedColumns(stored, toCustomerBankAccountUsageRow(usage, account.id)); if (Object.keys(changes).length > 0) { await db .updateTable("CustomerBankAccountUsage") .set(changes as Updateable<"CustomerBankAccountUsage">) .where("id", "=", usage.id) .execute(); } } const keptIds = new Set(account.bankAccountUsages.map((usage) => usage.id)); const removedIds = storedRows.filter((row) => !keptIds.has(row.id)).map((row) => row.id); if (removedIds.length > 0) { await db.deleteFrom("CustomerBankAccountUsage").where("id", "in", removedIds).execute(); } const addedRows = account.bankAccountUsages .filter((usage) => !storedById.has(usage.id)) .map( (usage) => toCustomerBankAccountUsageRow(usage, account.id) as Insertable<"CustomerBankAccountUsage">, ); if (addedRows.length > 0) { await db.insertInto("CustomerBankAccountUsage").values(addedRows).execute(); } } // ===== Repository ===== export function createCustomerAccountRepository(db: Transaction): CustomerAccountRepository { async function loadFromHeader( headerRow: Selectable<"CustomerAccount"> | undefined, ): Promise { if (!headerRow) return null; const { addressUsageRows, bankAccountUsageRows } = await loadUsages(db, headerRow.id); return toCustomerAccount(headerRow, addressUsageRows, bankAccountUsageRows); } return { async findById(id, opts) { let query = db.selectFrom("CustomerAccount").selectAll().where("id", "=", id); if (opts?.forUpdate) query = query.forUpdate(); return loadFromHeader(await query.executeTakeFirst()); }, async findByCompanyAndCode(companyId, code, opts) { let query = db .selectFrom("CustomerAccount") .selectAll() .where("companyId", "=", companyId) .where("code", "=", code); if (opts?.forUpdate) query = query.forUpdate(); return loadFromHeader(await query.executeTakeFirst()); }, // Diffing against the stored rows keeps a no-op save from bumping updatedAt. async save(account) { const storedHeader = await db .selectFrom("CustomerAccount") .selectAll() .where("id", "=", account.id) .forUpdate() .executeTakeFirst(); if (!storedHeader) { await db .insertInto("CustomerAccount") .values(toCustomerAccountRow(account) as Insertable<"CustomerAccount">) .execute(); if (account.addressUsages.length > 0) { await db .insertInto("CustomerAddressUsage") .values( account.addressUsages.map( (usage) => toCustomerAddressUsageRow( usage, account.id, ) as Insertable<"CustomerAddressUsage">, ), ) .execute(); } if (account.bankAccountUsages.length > 0) { await db .insertInto("CustomerBankAccountUsage") .values( account.bankAccountUsages.map( (usage) => toCustomerBankAccountUsageRow( usage, account.id, ) as Insertable<"CustomerBankAccountUsage">, ), ) .execute(); } return; } const { addressUsageRows, bankAccountUsageRows } = await loadUsages(db, account.id); await upsertAddressUsages(db, account, addressUsageRows); await upsertBankAccountUsages(db, account, bankAccountUsageRows); const headerChanges = changedColumns(storedHeader, toCustomerAccountRow(account)); if (Object.keys(headerChanges).length > 0) { await db .updateTable("CustomerAccount") .set(headerChanges as Updateable<"CustomerAccount">) .where("id", "=", account.id) .execute(); } }, }; }