fix: race condition and delete checking for templates

This commit is contained in:
2026-02-27 20:45:56 +01:00
parent 6c2d2c48bf
commit 696b79c5d7
18 changed files with 334 additions and 51 deletions

View File

@@ -6,7 +6,7 @@ import { app } from 'electron';
import { and, desc, eq } from 'drizzle-orm';
import { Liquid } from 'liquidjs';
import { getDatabase } from '../database';
import { templates, type NewTemplate, type Template } from '../database/schema';
import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema';
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
@@ -60,6 +60,11 @@ export interface TemplateValidationResult {
errors: string[];
}
export interface TemplateDeleteResult {
deleted: boolean;
references?: { postIds: string[]; tagIds: string[] };
}
interface ParsedTemplateFile {
metadata: {
id?: string;
@@ -139,18 +144,13 @@ export class TemplateEngine extends EventEmitter {
const nextFilePath = this.getTemplateFilePath(nextSlug);
const now = new Date();
if (existing.filePath !== nextFilePath) {
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
await fs.rename(existing.filePath, nextFilePath);
}
const nextTitle = updates.title ?? existing.title;
const nextKind = updates.kind ?? existing.kind;
const nextEnabled = updates.enabled ?? existing.enabled;
const nextVersion = existing.version + 1;
const nextContent = typeof updates.content === 'string'
? updates.content
: await this.readTemplateBody(nextFilePath);
: await this.readTemplateBody(existing.filePath);
const nextRow = {
...existing,
@@ -163,21 +163,58 @@ export class TemplateEngine extends EventEmitter {
updatedAt: now,
};
await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8');
const dbUpdates = {
title: nextTitle,
slug: nextSlug,
kind: nextKind,
enabled: nextEnabled,
filePath: nextFilePath,
version: nextVersion,
updatedAt: now,
};
// DB-first: update the database row before touching the filesystem
await getDatabase().getLocal()
.update(templates)
.set({
title: nextTitle,
slug: nextSlug,
kind: nextKind,
enabled: nextEnabled,
filePath: nextFilePath,
version: nextVersion,
updatedAt: now,
})
.set(dbUpdates)
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
try {
if (existing.filePath !== nextFilePath) {
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
await fs.rename(existing.filePath, nextFilePath);
}
await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8');
} catch (fileError) {
// Roll back the DB row to previous values on file operation failure
await getDatabase().getLocal()
.update(templates)
.set({
title: existing.title,
slug: existing.slug,
kind: existing.kind,
enabled: existing.enabled,
filePath: existing.filePath,
version: existing.version,
updatedAt: existing.updatedAt,
})
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
throw fileError;
}
// Cascade slug updates to referencing posts and tags
if (existing.slug !== nextSlug) {
await getDatabase().getLocal()
.update(posts)
.set({ templateSlug: nextSlug })
.where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId)));
await getDatabase().getLocal()
.update(tags)
.set({ postTemplateSlug: nextSlug })
.where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId)));
}
const updatedRow = await this.getTemplateRow(existing.id);
if (!updatedRow) {
return null;
@@ -188,10 +225,47 @@ export class TemplateEngine extends EventEmitter {
return updated;
}
async deleteTemplate(id: string): Promise<boolean> {
async getTemplateReferences(slug: string): Promise<{ postIds: string[]; tagIds: string[] }> {
const db = getDatabase().getLocal();
const referencingPosts = await db
.select({ id: posts.id })
.from(posts)
.where(and(eq(posts.templateSlug, slug), eq(posts.projectId, this.currentProjectId)))
.all();
const referencingTags = await db
.select({ id: tags.id })
.from(tags)
.where(and(eq(tags.postTemplateSlug, slug), eq(tags.projectId, this.currentProjectId)))
.all();
return {
postIds: referencingPosts.map((row) => row.id),
tagIds: referencingTags.map((row) => row.id),
};
}
async deleteTemplate(id: string, options?: { force?: boolean }): Promise<TemplateDeleteResult> {
const existing = await this.getTemplateRow(id);
if (!existing) {
return false;
return { deleted: false };
}
const refs = await this.getTemplateReferences(existing.slug);
const hasReferences = refs.postIds.length > 0 || refs.tagIds.length > 0;
if (hasReferences && !options?.force) {
return { deleted: false, references: refs };
}
if (hasReferences && options?.force) {
const db = getDatabase().getLocal();
await db
.update(posts)
.set({ templateSlug: null })
.where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId)));
await db
.update(tags)
.set({ postTemplateSlug: null })
.where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId)));
}
await getDatabase().getLocal()
@@ -208,7 +282,7 @@ export class TemplateEngine extends EventEmitter {
}
this.emit('templateDeleted', id);
return true;
return { deleted: true };
}
async getTemplate(id: string): Promise<TemplateData | null> {

View File

@@ -817,9 +817,9 @@ export function registerIpcHandlers(): void {
return engine.updateTemplate(id, data);
});
safeHandle('templates:delete', async (_, id: string) => {
safeHandle('templates:delete', async (_, id: string, options?: { force?: boolean }) => {
const engine = getTemplateEngine();
return engine.deleteTemplate(id);
return engine.deleteTemplate(id, options);
});
safeHandle('templates:get', async (_, id: string) => {

View File

@@ -116,7 +116,7 @@ export const electronAPI: ElectronAPI = {
templates: {
create: (data: { title: string; kind: import('./shared/electronApi').TemplateKind; content: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:create', data),
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').TemplateKind; content?: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:update', id, data),
delete: (id: string) => ipcRenderer.invoke('templates:delete', id),
delete: (id: string, options?: { force?: boolean }) => ipcRenderer.invoke('templates:delete', id, options),
get: (id: string) => ipcRenderer.invoke('templates:get', id),
getAll: () => ipcRenderer.invoke('templates:getAll'),
getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind),

View File

@@ -177,6 +177,11 @@ export interface TemplateData {
updatedAt: string;
}
export interface TemplateDeleteResult {
deleted: boolean;
references?: { postIds: string[]; tagIds: string[] };
}
export interface TaskProgress {
taskId: string;
name: string;
@@ -623,7 +628,7 @@ export interface ElectronAPI {
slug?: string;
enabled?: boolean;
}) => Promise<TemplateData | null>;
delete: (id: string) => Promise<boolean>;
delete: (id: string, options?: { force?: boolean }) => Promise<TemplateDeleteResult>;
get: (id: string) => Promise<TemplateData | null>;
getAll: () => Promise<TemplateData[]>;
getEnabledByKind: (kind: TemplateKind) => Promise<TemplateData[]>;

View File

@@ -131,7 +131,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('templates.create', 'Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).', [requiredObject('data')], 'TemplateData'),
method('templates.update', 'Update template by id. data may include any of: title, kind, content, slug, enabled.', [requiredString('id'), requiredObject('data')], 'TemplateData | null'),
method('templates.delete', 'Delete template by id.', [requiredString('id')], 'boolean'),
method('templates.delete', 'Delete template by id. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete.', [requiredString('id'), optionalObject('options')], 'TemplateDeleteResult'),
method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'),
method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'),
method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'),
@@ -302,6 +302,14 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TemplateDeleteResult',
description: 'Result of a template delete operation. If the template is referenced by posts or tags, deleted is false and references lists the referencing IDs.',
fields: [
{ name: 'deleted', type: 'boolean', required: true, description: 'Whether the template was deleted.' },
{ name: 'references', type: '{ postIds: string[]; tagIds: string[] }', required: false, description: 'Post and tag IDs referencing this template (present when deleted is false and references exist).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
@@ -396,7 +404,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.8.0',
version: '1.9.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -1752,11 +1752,30 @@ const TemplatesList: React.FC = () => {
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
event.stopPropagation();
try {
const deleted = await window.electronAPI?.templates.delete(templateId);
if (!deleted) {
const result = await window.electronAPI?.templates.delete(templateId);
if (!result) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
if (!result.deleted && result.references) {
const { postIds, tagIds } = result.references;
const confirmed = window.confirm(
t('sidebar.templates.deleteConfirmWithRefs', {
postCount: String(postIds.length),
tagCount: String(tagIds.length),
}),
);
if (!confirmed) {
return;
}
const forceResult = await window.electronAPI?.templates.delete(templateId, { force: true });
if (!forceResult?.deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
}
setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId));
closeTab(templateId);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);

View File

@@ -182,11 +182,30 @@ export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
}
try {
const deleted = await window.electronAPI?.templates.delete(template.id);
if (!deleted) {
const result = await window.electronAPI?.templates.delete(template.id);
if (!result) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
if (!result.deleted && result.references) {
const { postIds, tagIds } = result.references;
const confirmed = window.confirm(
t('sidebar.templates.deleteConfirmWithRefs', {
postCount: String(postIds.length),
tagCount: String(tagIds.length),
}),
);
if (!confirmed) {
return;
}
const forceResult = await window.electronAPI?.templates.delete(template.id, { force: true });
if (!forceResult?.deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
}
closeTab(template.id);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} catch (error) {

View File

@@ -780,6 +780,7 @@
"sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden",
"sidebar.templates.deleteTemplate": "Vorlage löschen",
"sidebar.templates.deleteFailed": "Vorlage konnte nicht gelöscht werden",
"sidebar.templates.deleteConfirmWithRefs": "Diese Vorlage wird von {postCount} Beitrag/Beiträgen und {tagCount} Tag(s) referenziert. Trotzdem löschen? Die Referenzen werden entfernt.",
"sidebar.import.none": "Noch keine Importdefinitionen",
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
"sidebar.import.deleteDefinition": "Importdefinition löschen",

View File

@@ -780,6 +780,7 @@
"sidebar.templates.createFailed": "Failed to create template",
"sidebar.templates.deleteTemplate": "Delete template",
"sidebar.templates.deleteFailed": "Failed to delete template",
"sidebar.templates.deleteConfirmWithRefs": "This template is referenced by {postCount} post(s) and {tagCount} tag(s). Delete anyway? References will be cleared.",
"sidebar.import.none": "No import definitions yet",
"sidebar.import.createDefinition": "Create an import definition",
"sidebar.import.deleteDefinition": "Delete import definition",

View File

@@ -780,6 +780,7 @@
"sidebar.templates.createFailed": "No se pudo crear la plantilla",
"sidebar.templates.deleteTemplate": "Eliminar plantilla",
"sidebar.templates.deleteFailed": "No se pudo eliminar la plantilla",
"sidebar.templates.deleteConfirmWithRefs": "Esta plantilla está referenciada por {postCount} entrada(s) y {tagCount} etiqueta(s). ¿Eliminar de todos modos? Las referencias serán eliminadas.",
"sidebar.import.none": "Sin definiciones de importación",
"sidebar.import.createDefinition": "Crear definición",
"sidebar.import.deleteDefinition": "Eliminar definición",

View File

@@ -778,6 +778,7 @@
"sidebar.templates.createFailed": "Impossible de créer le modèle",
"sidebar.templates.deleteTemplate": "Supprimer le modèle",
"sidebar.templates.deleteFailed": "Impossible de supprimer le modèle",
"sidebar.templates.deleteConfirmWithRefs": "Ce modèle est référencé par {postCount} article(s) et {tagCount} tag(s). Supprimer quand même ? Les références seront supprimées.",
"sidebar.import.none": "Aucune définition dimport",
"sidebar.import.createDefinition": "Créer une définition",
"sidebar.import.deleteDefinition": "Supprimer la définition",

View File

@@ -778,6 +778,7 @@
"sidebar.templates.createFailed": "Impossibile creare il modello",
"sidebar.templates.deleteTemplate": "Elimina modello",
"sidebar.templates.deleteFailed": "Impossibile eliminare il modello",
"sidebar.templates.deleteConfirmWithRefs": "Questo modello è referenziato da {postCount} articolo/i e {tagCount} tag. Eliminare comunque? I riferimenti verranno rimossi.",
"sidebar.import.none": "Nessuna definizione di importazione",
"sidebar.import.createDefinition": "Crea definizione",
"sidebar.import.deleteDefinition": "Elimina definizione",