refactor: return stable creation metadata for search definition audit

This commit is contained in:
2026-09-07 18:10:13 +02:00
parent d45eb74772
commit 8dfd429a61
@@ -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<SearchIndexDefinition>;
save(definition: SearchIndexDefinition, actorId?: string): Promise<SearchDefinitionSaveResult>;
latest(indexName: string): Promise<SearchIndexDefinition | undefined>;
listLatest(): Promise<SearchIndexDefinition[]>;
}
@@ -14,6 +21,12 @@ interface DefinitionRow {
version: number;
fields: Record<string, SearchFieldType>;
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<string, SearchIndexDefinition[]>();
interface MemoryRecord {
definition: SearchIndexDefinition;
created_at: string;
created_by?: string;
}
async save(definition: SearchIndexDefinition): Promise<SearchIndexDefinition> {
export class InMemorySearchDefinitionRepository implements SearchDefinitionRepository {
private readonly versions = new Map<string, MemoryRecord[]>();
async save(definition: SearchIndexDefinition, actorId?: string): Promise<SearchDefinitionSaveResult> {
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<SearchIndexDefinition | undefined> {
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<SearchIndexDefinition> {
async save(definition: SearchIndexDefinition, actorId?: string): Promise<SearchDefinitionSaveResult> {
return this.sql.begin(async (tx) => {
await tx`SELECT pg_advisory_xact_lock(hashtext(${`0pt-search:${definition.index_name}`}))`;
const same = await tx<DefinitionRow[]>`
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<RepositorySql["json"]>[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<SearchIndexDefinition | undefined> {
const rows = await this.sql<DefinitionRow[]>`
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<SearchIndexDefinition[]> {
const rows = await this.sql<DefinitionRow[]>`
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);