feat: enhance sync route to validate item and field existence, preventing FK violations

This commit is contained in:
2025-11-08 20:12:25 +01:00
parent 8d0e6a91b5
commit d7a2befc8b
@@ -181,8 +181,65 @@ router.post('/sync', async (req: Request, res: Response) => {
} }
} }
// Before inserting item values, verify parent item and field existence to avoid FK violations (PG strictness)
const orphanValues: any[] = [];
if (itemValues && itemValues.length > 0) { if (itemValues && itemValues.length > 0) {
// Build distinct parent id lists
const distinctItemIds = [...new Set(itemValues.map(v => v.itemId).filter(Boolean))];
const distinctFieldIds = [...new Set(itemValues.map(v => v.fieldId).filter(Boolean))];
// Helper to build IN clause placeholders (?,?,?) matching adapter's ? substitution
const makeIn = (arr: string[]) => arr.map(() => '?').join(', ');
let existingItemIds = new Set<string>();
let existingFieldIds = new Set<string>();
const fieldToList: Record<string, string> = {};
try {
if (distinctItemIds.length > 0) {
const rows = await tx.queryAll(`SELECT id FROM items WHERE id IN (${makeIn(distinctItemIds)})`, distinctItemIds);
existingItemIds = new Set(rows.map((r: any) => r.id));
}
if (distinctFieldIds.length > 0) {
const rows = await tx.queryAll(`SELECT id, listId FROM fields WHERE id IN (${makeIn(distinctFieldIds)})`, distinctFieldIds);
existingFieldIds = new Set(rows.map((r: any) => r.id));
rows.forEach((r: any) => { fieldToList[r.id] = r.listid ?? r.listId; });
}
} catch (e) {
console.warn('[SYNC] Failed preloading parent ids for values:', e);
}
for (const value of itemValues) { for (const value of itemValues) {
const hasItem = existingItemIds.has(value.itemId);
const hasField = existingFieldIds.has(value.fieldId);
if (!hasField) {
// Missing field -> skip value
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'missingField' });
continue;
}
// If item missing but field exists, synthesize a stub item to preserve referential integrity
if (!hasItem) {
const inferredListId = fieldToList[value.fieldId];
if (!inferredListId) {
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'missingItemNoList' });
continue;
}
const ts = value.updatedAt ?? Date.now();
try {
await tx.execute(UPSERT_ITEM, [
value.itemId,
inferredListId,
ts,
ts,
0
]);
existingItemIds.add(value.itemId);
console.warn('[SYNC] Created stub item for orphan value:', value.itemId, 'in list', inferredListId);
} catch (e) {
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'failedCreateStubItem' });
continue;
}
}
try {
await tx.execute(UPSERT_ITEM_VALUE, [ await tx.execute(UPSERT_ITEM_VALUE, [
value.id, value.id,
value.itemId, value.itemId,
@@ -190,7 +247,23 @@ router.post('/sync', async (req: Request, res: Response) => {
value.value, value.value,
value.updatedAt value.updatedAt
]); ]);
} catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') {
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'fkViolation' });
console.warn('[SYNC] Skipped value due to FK violation:', value.id, '->', err.detail || err.message);
continue;
} }
throw err; // rethrow other errors
}
}
if (orphanValues.length > 0) {
console.warn(`[SYNC] Skipped ${orphanValues.length} orphan item_value record(s); will inform client.`);
}
// Attach orphanValues to request context (not ideal, but simplest without altering adapter signature)
// We'll include them in response below.
(req as any)._orphanValues = orphanValues;
} }
}); });
@@ -284,6 +357,7 @@ router.post('/sync', async (req: Request, res: Response) => {
fields: normFields, fields: normFields,
items: normItems, items: normItems,
itemValues: normValues, itemValues: normValues,
orphanItemValues: (req as any)._orphanValues || [],
serverTimestamp serverTimestamp
}); });
} catch (error) { } catch (error) {