Skip to content
NewComposite primary keys5 min read

Soft Delete

Soft-delete marks a record as deleted instead of “physically” removing the row, which is what keeps an audit trail readable, a mistaken delete recoverable, and the rows pointing at it from going dangling.

Mark one field with softDelete in its @Field options. Its presence makes the entity soft-deletable - there’s no separate @Entity flag. The value controls what gets stamped on delete: pass true to stamp the current timestamp (new Date()), or a callback (e.g. () => Date.now() for an epoch-millis column) to stamp anything else. An entity may have at most one soft-delete field.

import { Entity, Id, Field } from 'uql-orm';
@Entity()
export class User {
@Id({ type: Number })
id?: number;
@Field({ type: String })
name?: string;
/**
* `softDelete` marks this as the field stamped on delete. `softDelete: true` stamps the
* current timestamp (`new Date()`) - so for this column it's equivalent to the callback below;
* pass a callback only when you need a different value (e.g. `() => Date.now()`).
*/
@Field({
type: 'timestamptz',
softDelete: true,
})
deletedAt?: Date;
}

When soft-delete is enabled, UQL automatically transforms delete operations into update operations and adds filters to all find operations.

You write
await pool.deleteOneById(User, 1);
-- Only already-live rows are stamped
UPDATE "User" SET "deletedAt" = $1 WHERE "id" = $2 AND "deletedAt" IS NULL

By default, soft-deleted records are excluded from all queries.

You write
const users = await pool.findMany(User, { $select: { id: true, name: true } });
SELECT "id", "name" FROM "User" WHERE "deletedAt" IS NULL

Under the hood, soft-delete is the built-in softDelete query filter (auto-registered from the @Field({ softDelete }) above), so it composes with any other filters you define.

Only trashed - just constrain the field in your $where. The soft-delete filter steps aside for any key you set, so this is a plain, serializable query (no helper, no special option):

const trashed = await pool.findMany(User, {
$where: { deletedAt: { $ne: null } },
});
// generates: ... WHERE deletedAt IS NOT NULL

Live + trashed - this fully turns the filter off, so it’s a server-side option (withDeleted()), not part of the serializable query - the same reason filter bypass never crosses the HTTP wire:

import { withDeleted } from 'uql-orm';
const all = await pool.findMany(User, { $where: {/* ... */} }, withDeleted());

restore un-deletes rows by setting the soft-delete field back to null (it finds trashed rows even though the filter is normally on):

await pool.restoreOneById(User, 1);
await pool.restoreMany(User, { $where: { companyId: 5 } });

Pass { hardDelete: true } to permanently remove rows instead of stamping them (this ignores the soft-delete filter, so already-deleted rows are removed too):

await pool.deleteOneById(User, 1, { hardDelete: true });
await pool.deleteMany(User, { $where: { companyId: 5 } }, { hardDelete: true });

Soft-delete restore does not cascade - restore related entities explicitly if needed.