feat: implement soft delete functionality for lists, fields, and items with cascading updates
This commit is contained in:
@@ -122,6 +122,18 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
list.updatedAt,
|
||||
list.isDeleted ? 1 : 0
|
||||
]);
|
||||
// If a list was deleted, cascade the deletion to child records on server
|
||||
// - mark fields and items as deleted (tombstones) so other clients learn about them
|
||||
// - remove item_values belonging to items under this list (no tombstone support for values)
|
||||
if (list.isDeleted) {
|
||||
try {
|
||||
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
|
||||
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
|
||||
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]);
|
||||
} catch (err) {
|
||||
console.warn('[SYNC] Cascade delete for list failed:', String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +150,14 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
field.updatedAt,
|
||||
field.isDeleted ? 1 : 0
|
||||
]);
|
||||
// If a field was deleted, remove any item_values referencing it so clients converge
|
||||
if (field.isDeleted) {
|
||||
try {
|
||||
await tx.execute('DELETE FROM item_values WHERE fieldId = ?', [field.id]);
|
||||
} catch (err) {
|
||||
console.warn('[SYNC] Cascade delete for field failed:', String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +170,14 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
item.updatedAt,
|
||||
item.isDeleted ? 1 : 0
|
||||
]);
|
||||
// If an item was deleted, remove its values (no tombstone support for values)
|
||||
if (item.isDeleted) {
|
||||
try {
|
||||
await tx.execute('DELETE FROM item_values WHERE itemId = ?', [item.id]);
|
||||
} catch (err) {
|
||||
console.warn('[SYNC] Cascade delete for item failed:', String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ router.get('/', (req: Request, res: Response) => {
|
||||
</div>
|
||||
<div class="list-id">ID: \${list.id}</div>
|
||||
<div class="list-id">Created: \${fmtDate(list.createdAt)}</div>
|
||||
<div class="list-id">Updated: \${fmtDate(list.updatedAt)}</div>
|
||||
<div class="list-id">Updated: \${fmtDate(list.effectiveUpdatedAt ?? list.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,7 +368,7 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
||||
};
|
||||
// Normalize key casing across SQLite (camelCase) and Postgres (lowercase)
|
||||
const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b];
|
||||
const formattedLists = (lists as any[]).map(list => ({
|
||||
let formattedLists = (lists as any[]).map(list => ({
|
||||
...list,
|
||||
isDeleted: !!pick(list, 'isDeleted', 'isdeleted'),
|
||||
createdAt: toMillis(pick(list, 'createdAt', 'createdat')),
|
||||
@@ -394,6 +394,42 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
||||
fieldId: pick(v, 'fieldId', 'fieldid'),
|
||||
updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat'))
|
||||
}));
|
||||
// Compute effective updatedAt for lists based on latest change in the list itself or its children
|
||||
const itemIdToListId = new Map<string, string>();
|
||||
for (const it of formattedItems as any[]) {
|
||||
if (it && it.id && it.listId) itemIdToListId.set(it.id, it.listId);
|
||||
}
|
||||
const listToMaxField = new Map<string, number>();
|
||||
for (const f of formattedFields as any[]) {
|
||||
if (!f || !f.listId) continue;
|
||||
const t = typeof f.updatedAt === 'number' ? f.updatedAt : 0;
|
||||
const prev = listToMaxField.get(f.listId) || 0;
|
||||
if (t > prev) listToMaxField.set(f.listId, t);
|
||||
}
|
||||
const listToMaxItem = new Map<string, number>();
|
||||
for (const it of formattedItems as any[]) {
|
||||
if (!it || !it.listId) continue;
|
||||
const t = typeof it.updatedAt === 'number' ? it.updatedAt : 0;
|
||||
const prev = listToMaxItem.get(it.listId) || 0;
|
||||
if (t > prev) listToMaxItem.set(it.listId, t);
|
||||
}
|
||||
const listToMaxValue = new Map<string, number>();
|
||||
for (const val of formattedValues as any[]) {
|
||||
if (!val || !val.itemId) continue;
|
||||
const lId = itemIdToListId.get(val.itemId);
|
||||
if (!lId) continue;
|
||||
const t = typeof val.updatedAt === 'number' ? val.updatedAt : 0;
|
||||
const prev = listToMaxValue.get(lId) || 0;
|
||||
if (t > prev) listToMaxValue.set(lId, t);
|
||||
}
|
||||
formattedLists = formattedLists.map((l: any) => {
|
||||
const fMax = listToMaxField.get(l.id) || 0;
|
||||
const iMax = listToMaxItem.get(l.id) || 0;
|
||||
const vMax = listToMaxValue.get(l.id) || 0;
|
||||
const base = typeof l.updatedAt === 'number' ? l.updatedAt : 0;
|
||||
const eff = Math.max(base, fMax, iMax, vMax);
|
||||
return { ...l, effectiveUpdatedAt: eff === 0 ? l.updatedAt : eff };
|
||||
});
|
||||
|
||||
res.json({
|
||||
lists: formattedLists,
|
||||
|
||||
Reference in New Issue
Block a user