Skip to content

Commit 809883c

Browse files
Merge pull request #403 from geturbackend/feat/permanent-delete
feat: implement permanent deletion of documents
2 parents 54d34ee + 5c14eea commit 809883c

11 files changed

Lines changed: 297 additions & 50 deletions

File tree

‎apps/dashboard-api/src/controllers/project.controller.js‎

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,13 +1368,13 @@ module.exports.insertData = async (req, res) => {
13681368
delete incomingData.isDeleted;
13691369
delete incomingData.deletedAt;
13701370

1371-
let docSize = 0;
1371+
let estimatedDocSize = 0;
13721372
if (!project.resources.db.isExternal) {
1373-
docSize = Buffer.byteLength(JSON.stringify(incomingData));
1373+
estimatedDocSize = Buffer.byteLength(JSON.stringify(incomingData));
13741374

13751375
const limit = project.databaseLimit || 20 * 1024 * 1024;
13761376

1377-
if ((project.databaseUsed || 0) + docSize > limit) {
1377+
if ((project.databaseUsed || 0) + estimatedDocSize > limit) {
13781378
return res
13791379
.status(403)
13801380
.json({ error: "Database limit exceeded. Delete some data." });
@@ -1392,7 +1392,8 @@ module.exports.insertData = async (req, res) => {
13921392
const result = await model.create(incomingData);
13931393

13941394
if (!project.resources.db.isExternal) {
1395-
project.databaseUsed = (project.databaseUsed || 0) + docSize;
1395+
const actualDocSize = mongoose.mongo.BSON.calculateObjectSize(result.toObject());
1396+
project.databaseUsed = (project.databaseUsed || 0) + actualDocSize;
13961397
}
13971398
await project.save();
13981399

@@ -1443,30 +1444,55 @@ module.exports.deleteRow = async (req, res, next) => {
14431444
project.resources.db.isExternal,
14441445
);
14451446

1446-
const result = await Model.findOneAndUpdate(
1447-
{ _id: id, isDeleted: { $ne: true } },
1448-
{
1449-
$set: {
1450-
isDeleted: true,
1451-
deletedAt: new Date()
1452-
}
1453-
},
1454-
{ new: false }
1455-
).lean();
1447+
const isPermanent = req.query.permanent === 'true';
1448+
1449+
let result;
1450+
if (isPermanent) {
1451+
result = await Model.findOneAndDelete({ _id: id }).lean();
1452+
} else {
1453+
result = await Model.findOneAndUpdate(
1454+
{ _id: id, isDeleted: { $ne: true } },
1455+
{
1456+
$set: {
1457+
isDeleted: true,
1458+
deletedAt: new Date()
1459+
}
1460+
},
1461+
{ new: false }
1462+
).lean();
1463+
}
14561464

14571465
if (!result) {
14581466
return next(new AppError(404, "Document not found."));
14591467
}
14601468

1461-
// We don't decrement databaseUsed here because the document still occupies space.
1462-
// It will be decremented during hard delete in the background worker.
1463-
try {
1464-
await enqueueCollectionCleanup(projectId, collectionName);
1465-
} catch (err) {
1466-
console.error("Failed to enqueue trash cleanup job", { projectId, collectionName, err });
1469+
if (isPermanent) {
1470+
if (!project.resources.db.isExternal) {
1471+
try {
1472+
const docSize = mongoose.mongo.BSON.calculateObjectSize(result);
1473+
await Project.updateOne(
1474+
{ _id: project._id },
1475+
{ $inc: { databaseUsed: -docSize } }
1476+
);
1477+
} catch (sizeErr) {
1478+
console.warn("Failed to calculate document size on permanent delete", sizeErr);
1479+
}
1480+
}
1481+
} else {
1482+
// We don't decrement databaseUsed here because the document still occupies space.
1483+
// It will be decremented during hard delete in the background worker.
1484+
try {
1485+
await enqueueCollectionCleanup(projectId, collectionName);
1486+
} catch (err) {
1487+
console.error("Failed to enqueue trash cleanup job", { projectId, collectionName, err });
1488+
}
14671489
}
14681490

1469-
res.json({ success: true, data: { id: result._id }, message: "Document moved to trash" });
1491+
res.json({
1492+
success: true,
1493+
data: { id: result._id },
1494+
message: isPermanent ? "Document permanently deleted" : "Document moved to trash"
1495+
});
14701496
} catch (err) {
14711497
console.error("Delete Error:", err);
14721498
next(new AppError(500, "Failed to delete document"));
@@ -1593,11 +1619,11 @@ module.exports.editRow = async (req, res) => {
15931619
delete req.body.isDeleted;
15941620
delete req.body.deletedAt;
15951621

1596-
const oldSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject()));
1622+
const oldSize = mongoose.mongo.BSON.calculateObjectSize(docToEdit.toObject());
15971623

15981624
docToEdit.set(req.body);
15991625

1600-
const newSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject()));
1626+
const newSize = mongoose.mongo.BSON.calculateObjectSize(docToEdit.toObject());
16011627
const sizeDiff = newSize - oldSize;
16021628

16031629
if (!project.resources.db.isExternal) {

‎apps/web-dashboard/src/components/CollectionTable.jsx‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -226,21 +226,33 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi
226226
</button>
227227
{activeCollection?.name !== 'users' && !isViewer && (
228228
(record.isDeleted || recoveringIds.has(record._id)) ? (
229-
<button
230-
className={`btn-icon ${recoveringIds.has(record._id) ? 'loading' : ''}`}
231-
onClick={() => onRecover(record._id)}
232-
onPointerDown={e => e.stopPropagation()}
233-
aria-label={`Restore record ${record._id}`}
234-
disabled={recoveringIds.has(record._id)}
235-
title={getDeletionTooltip(record.deletedAt, now)}
236-
style={{ width: '22px', height: '22px', padding: 0 }}
237-
>
238-
{recoveringIds.has(record._id) ? (
239-
<div className="spinner-small" style={{ width: '11px', height: '11px' }}></div>
240-
) : (
241-
<RotateCcw size={12} color="var(--color-primary)" />
242-
)}
243-
</button>
229+
<>
230+
<button
231+
className={`btn-icon ${recoveringIds.has(record._id) ? 'loading' : ''}`}
232+
onClick={() => onRecover(record._id)}
233+
onPointerDown={e => e.stopPropagation()}
234+
aria-label={`Restore record ${record._id}`}
235+
disabled={recoveringIds.has(record._id)}
236+
title={getDeletionTooltip(record.deletedAt, now)}
237+
style={{ width: '22px', height: '22px', padding: 0 }}
238+
>
239+
{recoveringIds.has(record._id) ? (
240+
<div className="spinner-small" style={{ width: '11px', height: '11px' }}></div>
241+
) : (
242+
<RotateCcw size={12} color="var(--color-primary)" />
243+
)}
244+
</button>
245+
<button
246+
className="btn-icon danger-hover"
247+
onClick={() => onDelete(record._id, true)}
248+
onPointerDown={e => e.stopPropagation()}
249+
aria-label="Permanently Delete Record"
250+
title="Permanently Delete"
251+
style={{ width: '22px', height: '22px', padding: 0 }}
252+
>
253+
<Trash2 size={12} color="var(--color-danger)" />
254+
</button>
255+
</>
244256
) : (
245257
<>
246258
<button
@@ -255,7 +267,7 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi
255267
</button>
256268
<button
257269
className="btn-icon danger-hover"
258-
onClick={() => onDelete(record._id)}
270+
onClick={() => onDelete(record._id, false)}
259271
onPointerDown={e => e.stopPropagation()}
260272
aria-label="Delete Record"
261273
title="Delete"

‎apps/web-dashboard/src/pages/Database.jsx‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default function Database() {
3333
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
3434
const [showModal, setShowModal] = useState(false);
3535
const [selectedId, setSelectedId] = useState(null);
36+
const [isPermanentDelete, setIsPermanentDelete] = useState(false);
3637
const [showDeleted, setShowDeleted] = useState(false);
3738
const [collectionToDelete, setCollectionToDelete] = useState(null);
3839
const [selectedRecord, setSelectedRecord] = useState(null);
@@ -160,11 +161,22 @@ export default function Database() {
160161
} catch { toast.error("Failed to save RLS"); return false; }
161162
};
162163

163-
const handleDeleteRecord = async (id) => {
164+
const handleDeleteRecord = async (id, isPermanent) => {
164165
try {
165-
await api.delete(`/api/projects/${projectId}/collections/${activeCollection.name}/data/${id}`);
166-
setData(prev => prev.filter(item => item._id !== id));
167-
toast.success("Document deleted");
166+
await api.delete(`/api/projects/${projectId}/collections/${activeCollection.name}/data/${id}${isPermanent ? '?permanent=true' : ''}`);
167+
if (isPermanent || !showDeleted) {
168+
setData(prev => {
169+
const newData = prev.filter(item => item._id !== id);
170+
if (newData.length === 0 && queryParams.page > 1) {
171+
setQueryParams(q => ({ ...q, page: q.page - 1 }));
172+
}
173+
return newData;
174+
});
175+
setTotalRecords(prev => Math.max(0, prev - 1));
176+
} else {
177+
setData(prev => prev.map(item => item._id === id ? { ...item, isDeleted: true, deletedAt: new Date().toISOString() } : item));
178+
}
179+
toast.success(isPermanent ? "Document permanently deleted" : "Document deleted");
168180
} catch { toast.error("Failed to delete document"); }
169181
};
170182

@@ -371,7 +383,7 @@ export default function Database() {
371383
<CollectionTable
372384
data={data}
373385
activeCollection={activeCollection}
374-
onDelete={(id) => { setSelectedId(id); setShowModal(true); }}
386+
onDelete={(id, isPermanent) => { setSelectedId(id); setIsPermanentDelete(isPermanent); setShowModal(true); }}
375387
onView={setSelectedRecord}
376388
onEdit={(rec) => { if (activeCollection?.name === 'users') return; setEditingRecord(rec); setIsAddModalOpen(true); }}
377389
onRecover={handleRecoverRecord}
@@ -446,7 +458,7 @@ export default function Database() {
446458
)}
447459

448460
{/* Confirmation Modals */}
449-
{showModal && <ConfirmationModal open={showModal} title="Delete Record" message="Confirm delete?" onConfirm={() => { handleDeleteRecord(selectedId); setShowModal(false); }} onCancel={() => setShowModal(false)} />}
461+
{showModal && <ConfirmationModal open={showModal} title={isPermanentDelete ? "Permanently Delete Record" : "Delete Record"} message={isPermanentDelete ? "This action cannot be undone. Confirm permanent delete?" : "Confirm delete?"} onConfirm={() => { handleDeleteRecord(selectedId, isPermanentDelete); setShowModal(false); }} onCancel={() => setShowModal(false)} />}
450462
{collectionToDelete && <ConfirmationModal open={!!collectionToDelete} title="Delete Collection" message={`Delete ${collectionToDelete.name}?`} onConfirm={async () => { await api.delete(`/api/projects/${projectId}/collections/${collectionToDelete.name}`); setCollections(c => c.filter(x => x.name !== collectionToDelete.name)); setCollectionToDelete(null); }} onCancel={() => setCollectionToDelete(null)} />}
451463

452464
{/* RLS Dialog */}

‎check_db2.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const mongoose = require('mongoose');
2+
require('dotenv').config();
3+
4+
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017/urbackend-testV18';
5+
6+
async function run() {
7+
try {
8+
await mongoose.connect(MONGO_URL);
9+
const Project = require('./packages/common/src/models/Project.js');
10+
11+
const project = await Project.findOne({});
12+
if (project && project.collections.length > 0) {
13+
const col = project.collections[project.collections.length - 1];
14+
console.log("Last collection:", col.name);
15+
console.log("Model:", JSON.stringify(col.model, null, 2));
16+
} else {
17+
console.log("No projects");
18+
}
19+
} catch (err) {
20+
console.error(err);
21+
process.exitCode = 1;
22+
} finally {
23+
await mongoose.disconnect();
24+
}
25+
}
26+
run();

‎scratch.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const mongoose = require('mongoose');
2+
const { Schema } = mongoose;
3+
4+
const TestSchema = new Schema({
5+
name: String,
6+
isDeleted: Boolean
7+
});
8+
9+
const TestModel = mongoose.model('Test', TestSchema);
10+
11+
let q = TestModel.find();
12+
q = q.find({ isDeleted: { $ne: true } });
13+
q = q.and([{ name: 'foo' }]);
14+
15+
console.log(JSON.stringify(q.getQuery(), null, 2));

‎sdks/urbackend-python/src/urbackend/db.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,13 +419,15 @@ def delete(
419419
collection: str,
420420
doc_id: str,
421421
token: Optional[str] = None,
422+
permanent: bool = False,
422423
) -> Dict[str, bool]:
423424
"""Delete a document by its ``_id``.
424425
425426
Args:
426427
collection: Collection name.
427428
doc_id: MongoDB ObjectId string of the document to delete.
428429
token: Bearer token for RLS ownership check.
430+
permanent: True to permanently delete instead of soft-deleting.
429431
430432
Returns:
431433
``{"deleted": True}``
@@ -435,12 +437,14 @@ def delete(
435437
436438
Example:
437439
>>> result = client.db.delete("posts", post_id,
438-
... token=client.auth.get_token())
440+
... token=client.auth.get_token(),
441+
... permanent=True)
439442
>>> assert result["deleted"] is True
440443
"""
441444
# http.request raises on non-2xx, so reaching this point means the
442445
# delete succeeded.
446+
query = "?permanent=true" if permanent else ""
443447
self._http.request(
444-
"DELETE", f"/api/data/{collection}/{doc_id}", token=token
448+
"DELETE", f"/api/data/{collection}/{doc_id}{query}", token=token
445449
)
446450
return {"deleted": True}

‎sdks/urbackend-sdk/src/modules/database.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,17 +371,29 @@ export class DatabaseModule {
371371
* console.log('User deleted');
372372
* }
373373
*/
374-
public async delete(collection: string, id: string, token?: string): Promise<{ deleted: boolean }> {
374+
public async delete(collection: string, id: string, optionsOrToken?: { token?: string, permanent?: boolean } | string): Promise<{ deleted: boolean }> {
375+
let token: string | undefined;
376+
let permanent = false;
377+
378+
if (typeof optionsOrToken === 'string') {
379+
token = optionsOrToken;
380+
} else if (optionsOrToken) {
381+
token = optionsOrToken.token;
382+
permanent = !!optionsOrToken.permanent;
383+
}
384+
385+
const qs = permanent ? '?permanent=true' : '';
386+
375387
const result = await this.client.request<{ message?: string; id?: string } | null>(
376388
'DELETE',
377-
`/api/data/${collection}/${id}`,
389+
`/api/data/${collection}/${id}${qs}`,
378390
{ token },
379391
);
380392

381393
const deleted =
382394
typeof result === 'object' &&
383395
result !== null &&
384-
(result.id === id || result.message === 'Document deleted');
396+
(result.id === id || result.message === 'Document deleted' || result.message === 'Document permanently deleted');
385397

386398
return { deleted };
387399
}

‎test_full.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const mongoose = require('mongoose');
2+
3+
async function test() {
4+
await mongoose.connect('mongodb://127.0.0.1:27017/test_isdeleted2');
5+
6+
const schemaDef = { name: String, isDeleted: { type: Boolean, default: false } };
7+
const schema = new mongoose.Schema(schemaDef, { timestamps: true, strict: false });
8+
const Model = mongoose.model('TestDoc2', schema);
9+
10+
await Model.deleteMany({});
11+
12+
await Model.create({ name: 'doc1', isDeleted: false });
13+
await Model.create({ name: 'doc2', isDeleted: true });
14+
15+
const QueryEngine = require('./packages/common/src/utils/queryEngine.js');
16+
const reqQuery = {};
17+
const baseFilter = {}; // simulating req.rlsFilter
18+
19+
const features = new QueryEngine(Model.find(), reqQuery).filter();
20+
if (Object.keys(baseFilter).length > 0) {
21+
features.query = features.query.and([baseFilter]);
22+
}
23+
24+
features.sort().limitFields(); // not populate for now
25+
features.paginate();
26+
27+
const docs = await features.query.lean();
28+
console.log("Returned docs:", docs);
29+
console.log("Query was:", JSON.stringify(features.query.getQuery()));
30+
}
31+
32+
test()
33+
.catch(err => {
34+
console.error(err);
35+
process.exitCode = 1;
36+
})
37+
.finally(() => mongoose.disconnect());

0 commit comments

Comments
 (0)