Initial Commit
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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<Contact> {
|
||||
try {
|
||||
// Build the CONTENT object with optional fields omitted when
|
||||
// unset — SurrealDB's `option<string>` rejects an explicit `null`.
|
||||
const content: Record<string, unknown> = {
|
||||
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<ContactRow>(
|
||||
"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<Contact | null> {
|
||||
const recordId = this.toRecordId(id);
|
||||
const result = await this.db.query<ContactRow>(
|
||||
"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<Contact> {
|
||||
try {
|
||||
const recordId = this.toRecordId(id);
|
||||
const result = await this.db.query<ContactRow>(
|
||||
"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<void> {
|
||||
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<ContactListResult> {
|
||||
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<ContactRow>(
|
||||
`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<Contact | null> {
|
||||
const result = await this.db.query<ContactRow>(
|
||||
`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<Contact[]> {
|
||||
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<ContactRow>(
|
||||
"SELECT * FROM type::record('contact', $p0)",
|
||||
[this.toRecordId(id)]
|
||||
)
|
||||
)
|
||||
);
|
||||
return results.flat().map((r) => this.mapContact(r));
|
||||
}
|
||||
|
||||
public async countByTenant(tenantId: string): Promise<number> {
|
||||
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<T>(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]!;
|
||||
}
|
||||
Reference in New Issue
Block a user