/** * SurrealDB-backed implementation of the genesis `@suaveplan/contacts` * `ContactStore` interface. Wraps a connected `SurrealAdapter` and maps * row shapes onto the canonical `Contact` entity (parsing ISO date * strings into `Date` instances). No mutation of the adapter lifecycle * — connect / disconnect is the host's responsibility. */ import type { Contact, ContactChannel, ContactListResult, ContactQuery, ContactStatus, ContactStore, CreateContactInput, UpdateContactInput, } from "@suaveplan/contacts"; import type { SurrealAdapter } from "@suaveplan/surrealdb"; import { CONTACTS_API_CODES, ContactsApiError, } from "../error-codes/error-codes.js"; interface ContactRow { readonly id: unknown; readonly tenantId: string; readonly displayName: string; readonly firstName: string | null; readonly lastName: string | null; readonly channels: readonly ContactChannel[]; readonly tags: readonly string[]; readonly status: ContactStatus; readonly metadata: Record; readonly createdAt: string | Date; readonly updatedAt: string | Date; } /** SurrealDB-backed contact repository implementing `ContactStore`. */ export class SurrealContactRepository implements ContactStore { public constructor(private readonly db: SurrealAdapter) {} public async create(input: CreateContactInput): Promise { try { // Build the CONTENT object with optional fields omitted when // unset — SurrealDB's `option` rejects an explicit `null`. const content: Record = { tenantId: input.tenantId, displayName: input.displayName, channels: input.channels, tags: input.tags ?? [], status: input.status ?? "active", metadata: input.metadata ?? {}, }; if (input.firstName !== undefined) content["firstName"] = input.firstName; if (input.lastName !== undefined) content["lastName"] = input.lastName; const result = await this.db.query( "CREATE contact CONTENT $p0", [content] ); return this.mapContact(pickFirst(result)); } catch (cause) { if (cause instanceof ContactsApiError) throw cause; throw new ContactsApiError( CONTACTS_API_CODES.CREATE_FAILED, "failed to create contact", { cause: cause as Error } ); } } public async getById(id: string): Promise { const recordId = this.toRecordId(id); const result = await this.db.query( "SELECT * FROM type::record('contact', $p0)", [recordId] ); if (result.length === 0) return null; return this.mapContact(result[0]!); } public async update( id: string, input: UpdateContactInput ): Promise { try { const recordId = this.toRecordId(id); const result = await this.db.query( "UPDATE type::record('contact', $p0) MERGE $p1", [recordId, input] ); return this.mapContact(pickFirst(result)); } catch (cause) { if (cause instanceof ContactsApiError) throw cause; throw new ContactsApiError( CONTACTS_API_CODES.UPDATE_FAILED, `failed to update contact ${id}`, { cause: cause as Error, context: { id } } ); } } public async delete(id: string): Promise { try { const recordId = this.toRecordId(id); await this.db.query("DELETE type::record('contact', $p0)", [ recordId, ]); } catch (cause) { throw new ContactsApiError( CONTACTS_API_CODES.DELETE_FAILED, `failed to delete contact ${id}`, { cause: cause as Error, context: { id } } ); } } public async list(query: ContactQuery): Promise { const params: unknown[] = [query.tenantId]; let where = "tenantId = $p0"; if (query.status !== undefined) { params.push(query.status); where += ` AND status = $p${params.length - 1}`; } if (query.channelType !== undefined) { params.push(query.channelType); where += ` AND channels[WHERE type = $p${params.length - 1}].value != NONE`; } if (query.search !== undefined) { params.push(query.search); const idx = params.length - 1; where += ` AND (string::contains(displayName, $p${idx})` + ` OR string::contains(firstName, $p${idx})` + ` OR string::contains(lastName, $p${idx}))`; } const limit = query.limit ?? 50; const offset = query.offset ?? 0; params.push(limit); const limitIdx = params.length - 1; params.push(offset); const offsetIdx = params.length - 1; const countResult = await this.db.query<{ count: number }>( `SELECT count() AS count FROM contact WHERE ${where} GROUP ALL`, params ); const total = countResult[0]?.count ?? 0; const rows = await this.db.query( `SELECT * FROM contact WHERE ${where} ORDER BY createdAt DESC LIMIT $p${limitIdx} START $p${offsetIdx}`, params ); return { contacts: rows.map((r) => this.mapContact(r)), total, }; } public async findByChannelValue( tenantId: string, channelType: string, channelValue: string ): Promise { const result = await this.db.query( `SELECT * FROM contact WHERE tenantId = $p0 AND channels[WHERE type = $p1 AND value = $p2] != NONE LIMIT 1`, [tenantId, channelType, channelValue] ); if (result.length === 0) return null; return this.mapContact(result[0]!); } public async findByIds(ids: readonly string[]): Promise { if (ids.length === 0) return []; // SurrealDB v3 `type::record` accepts table + id tail; we resolve each id // individually via parallel queries so the contract works with bare // ids ("abc") and full record-strings ("contact:abc") alike. const results = await Promise.all( ids.map((id) => this.db.query( "SELECT * FROM type::record('contact', $p0)", [this.toRecordId(id)] ) ) ); return results.flat().map((r) => this.mapContact(r)); } public async countByTenant(tenantId: string): Promise { const result = await this.db.query<{ count: number }>( "SELECT count() AS count FROM contact WHERE tenantId = $p0 GROUP ALL", [tenantId] ); return result[0]?.count ?? 0; } private toRecordId(id: string): string { // type::record('contact', $tail) — strip any leading "contact:" prefix // so callers can pass either form. const idx = id.indexOf(":"); return idx === -1 ? id : id.slice(idx + 1); } private mapContact(row: ContactRow): Contact { const contact: Contact = { id: String(row.id), tenantId: row.tenantId, displayName: row.displayName, ...(row.firstName !== null && row.firstName !== undefined ? { firstName: row.firstName } : {}), ...(row.lastName !== null && row.lastName !== undefined ? { lastName: row.lastName } : {}), channels: row.channels, tags: row.tags, status: row.status, metadata: row.metadata, createdAt: row.createdAt instanceof Date ? row.createdAt : new Date(row.createdAt), updatedAt: row.updatedAt instanceof Date ? row.updatedAt : new Date(row.updatedAt), }; return contact; } } function pickFirst(rows: readonly T[] | null | undefined): T { if (!rows || rows.length === 0) { throw new ContactsApiError( CONTACTS_API_CODES.CREATE_FAILED, "SurrealDB returned no row for write operation" ); } return rows[0]!; }