From 8dfd429a61ffc6ab1959536dd91ed0975a986fb1 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Mon, 7 Sep 2026 18:10:13 +0200 Subject: [PATCH] refactor: return stable creation metadata for search definition audit --- .../search-definition-repository.ts | 84 ++++++++++++++----- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/src/repositories/search-definition-repository.ts b/src/repositories/search-definition-repository.ts index f0984a5..a5eaf34 100644 --- a/src/repositories/search-definition-repository.ts +++ b/src/repositories/search-definition-repository.ts @@ -3,8 +3,15 @@ import { SiaxError } from "../platform/errors"; import type { SearchIndexDefinition, SearchFieldType } from "../search/search-types"; import { getRepositorySql, repositoryDatabaseUrl, type RepositorySql } from "./db"; +export interface SearchDefinitionSaveResult { + definition: SearchIndexDefinition; + created: boolean; + created_at: string; + created_by?: string; +} + export interface SearchDefinitionRepository { - save(definition: SearchIndexDefinition, actorId?: string): Promise; + save(definition: SearchIndexDefinition, actorId?: string): Promise; latest(indexName: string): Promise; listLatest(): Promise; } @@ -14,6 +21,12 @@ interface DefinitionRow { version: number; fields: Record; default_search_fields: string[]; + created_at: string | Date; + created_by: string | null; +} + +function iso(value: string | Date): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } function toDefinition(row: DefinitionRow): SearchIndexDefinition { @@ -25,6 +38,15 @@ function toDefinition(row: DefinitionRow): SearchIndexDefinition { }; } +function toSaveResult(row: DefinitionRow, created: boolean): SearchDefinitionSaveResult { + return { + definition: toDefinition(row), + created, + created_at: iso(row.created_at), + ...(row.created_by ? { created_by: row.created_by } : {}), + }; +} + function fingerprint(definition: SearchIndexDefinition): string { return JSON.stringify({ index_name: definition.index_name, @@ -34,31 +56,55 @@ function fingerprint(definition: SearchIndexDefinition): string { }); } -export class InMemorySearchDefinitionRepository implements SearchDefinitionRepository { - private readonly versions = new Map(); +interface MemoryRecord { + definition: SearchIndexDefinition; + created_at: string; + created_by?: string; +} - async save(definition: SearchIndexDefinition): Promise { +export class InMemorySearchDefinitionRepository implements SearchDefinitionRepository { + private readonly versions = new Map(); + + async save(definition: SearchIndexDefinition, actorId?: string): Promise { const list = this.versions.get(definition.index_name) ?? []; - const existing = list.find((item) => item.version === definition.version); + const existing = list.find((item) => item.definition.version === definition.version); if (existing) { - if (fingerprint(existing) !== fingerprint(definition)) { + if (fingerprint(existing.definition) !== fingerprint(definition)) { throw new SiaxError("CONFLICT", "0pt.search", "search definition version reused with different schema"); } - return { ...existing, fields: { ...existing.fields }, default_search_fields: [...existing.default_search_fields] }; + return { + definition: { + ...existing.definition, + fields: { ...existing.definition.fields }, + default_search_fields: [...existing.definition.default_search_fields], + }, + created: false, + created_at: existing.created_at, + ...(existing.created_by ? { created_by: existing.created_by } : {}), + }; } - const expected = (list[list.length - 1]?.version ?? 0) + 1; + const expected = (list[list.length - 1]?.definition.version ?? 0) + 1; if (definition.version !== expected) { throw new SiaxError("CONFLICT", "0pt.search", `next search definition version must be ${expected}`); } - const copy = { ...definition, fields: { ...definition.fields }, default_search_fields: [...definition.default_search_fields] }; - list.push(copy); + const copy = { + ...definition, + fields: { ...definition.fields }, + default_search_fields: [...definition.default_search_fields], + }; + const record: MemoryRecord = { + definition: copy, + created_at: new Date().toISOString(), + ...(actorId ? { created_by: actorId } : {}), + }; + list.push(record); this.versions.set(definition.index_name, list); - return copy; + return { definition: copy, created: true, created_at: record.created_at, ...(actorId ? { created_by: actorId } : {}) }; } async latest(indexName: string): Promise { const list = this.versions.get(indexName) ?? []; - const item = list[list.length - 1]; + const item = list[list.length - 1]?.definition; return item ? { ...item, fields: { ...item.fields }, default_search_fields: [...item.default_search_fields] } : undefined; } @@ -75,11 +121,11 @@ export class InMemorySearchDefinitionRepository implements SearchDefinitionRepos export class PostgresSearchDefinitionRepository implements SearchDefinitionRepository { constructor(private readonly sql: RepositorySql) {} - async save(definition: SearchIndexDefinition, actorId?: string): Promise { + async save(definition: SearchIndexDefinition, actorId?: string): Promise { return this.sql.begin(async (tx) => { await tx`SELECT pg_advisory_xact_lock(hashtext(${`0pt-search:${definition.index_name}`}))`; const same = await tx` - SELECT index_name, version, fields, default_search_fields + SELECT index_name, version, fields, default_search_fields, created_at, created_by FROM opt_search_index_definitions WHERE index_name = ${definition.index_name} AND version = ${definition.version} LIMIT 1`; @@ -88,7 +134,7 @@ export class PostgresSearchDefinitionRepository implements SearchDefinitionRepos if (fingerprint(existing) !== fingerprint(definition)) { throw new SiaxError("CONFLICT", "0pt.search", "search definition version reused with different schema"); } - return existing; + return toSaveResult(same[0], false); } const latest = await tx<{ version: number }[]>` @@ -107,14 +153,14 @@ export class PostgresSearchDefinitionRepository implements SearchDefinitionRepos (${definition.index_name}, ${definition.version}, ${tx.json(definition.fields as Parameters[0])}, ${tx.array(definition.default_search_fields)}, ${actorId ?? null}) - RETURNING index_name, version, fields, default_search_fields`; - return toDefinition(rows[0]); + RETURNING index_name, version, fields, default_search_fields, created_at, created_by`; + return toSaveResult(rows[0], true); }); } async latest(indexName: string): Promise { const rows = await this.sql` - SELECT index_name, version, fields, default_search_fields + SELECT index_name, version, fields, default_search_fields, created_at, created_by FROM opt_search_index_definitions WHERE index_name = ${indexName} ORDER BY version DESC @@ -125,7 +171,7 @@ export class PostgresSearchDefinitionRepository implements SearchDefinitionRepos async listLatest(): Promise { const rows = await this.sql` SELECT DISTINCT ON (index_name) - index_name, version, fields, default_search_fields + index_name, version, fields, default_search_fields, created_at, created_by FROM opt_search_index_definitions ORDER BY index_name, version DESC`; return rows.map(toDefinition);