refactor: return stable creation metadata for search definition audit
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user