Skip to content

feat(gqlize): soft delete — read deleted rows, restore them, gate both - #57

Merged
Azerothian merged 1 commit into
mainfrom
feat/paranoid-soft-delete
Aug 28, 2026
Merged

feat(gqlize): soft delete — read deleted rows, restore them, gate both#57
Azerothian merged 1 commit into
mainfrom
feat/paranoid-soft-delete

Conversation

@Azerothian

Copy link
Copy Markdown
Owner

Closes #56. Stacked on #55 — review that one first; this PR's diff is only the soft-delete work.

The gap

options: {paranoid: true} already reached sequelize.define untouched, so soft delete happened — the root delete mutation goes through instance destroy(). Nothing ever set paranoid on a read options bag, so a soft-deleted row was unreachable through GraphQL by any means, and there was no root-level way to bring it back.

Reading deleted rows

A deleted: GQLTDeletedFilter argument on every list field of a model the adapter reports as soft-deleting — root lists, nested relationship connections, and each entry in the include input.

Value Rows
EXCLUDE live only — the default
INCLUDE live and deleted together
ONLY deleted only — a trash view
query { models { Comment(deleted: ONLY, orderBy: [deletedAtDESC]) { total edges { node { body deletedAt } } } } }

An enum rather than includeDeleted: Boolean because ONLY is otherwise only expressible by naming the deletedAt column, which is renameable and so not something a caller can rely on.

The flag applies per query node, not per request. A top-level paranoid: false does not propagate into includes — confirmed empirically, and the reason the overlay is applied at the root, on every include entry, on countOptions, and inside countRelationship, which builds its own options bag from scratch. Miss one and the failure is a silently wrong answer rather than an error: total counting live rows while edges return deleted ones. The generated SQL was read back to confirm deletedAt IS NULL leaves the main query and the JOIN together.

Restoring

A root restore argument reusing the delete filter type rather than minting a second identical [filterType]:

mutation { models { Comment(restore: { body: { eq: "oops" } }) { id body } } }

Restore is an update everywhere it has to be classified — scope operation, the new beforeRestore entry in INSTANCE_HOOKS, Events.MUTATION_UPDATE for after — rather than gaining an Events member every exhaustive hook would have to learn. That is already this codebase's position in VERB_OPERATIONS.

Permissions

Two new build-time keys, queryDeleted and mutationRestore. Denied means the element is not in the schema, so a query naming it fails validation rather than at resolution — which keeps the sync/async boundary permission.test.ts enforces structurally. That test also asserts every build-time key is consulted during the fixture build, which is why the shared fixture gains a paranoid Memo model.

Row-level visibility stays scope's: a caller allowed deleted rows sees only the deleted rows inside their scope, asserted on both read and restore.

Enabling paranoid for all or some models

No new configuration. The Sequelize adapter's defaultModel is merged under each definition's own options, which is already "global default, per-model override, per-model opt out" — it was simply undocumented, with no test and no mention in docs or examples. So it is documented, pinned by tests, and hardened:

  • {paranoid: true, timestamps: false} defines silently and is a lie — Sequelize reports paranoid === true and defines no column, so deletes are hard. Reachable in normal use, because generated join models are defined with timestamps: false and would inherit a global paranoid: true.
  • softDeletes therefore derives from deletedAtColumn, which requires both halves; generated join models pin paranoid: false; and createModel warns.
  • _timestampAttributes.deletedAt is undocumented Sequelize internals and the only source of a renamed column's name, so it gets a canary next to the define-model.test.ts one.

Adapters opt in: softDeletes and getRestoreFunction are optional on OrmAdapter exactly as computedOrderableFields is, so valkey and any third-party adapter need no change and generate neither surface.

Two pre-existing defects, both of which restore would have inherited

Multi-row mutations returned one row. update/delete/select used waterfall — a reduce that threads each step's result into the next — as if it were a map, so a filter matching n rows returned only the last, and a zero match returned [null] rather than []. The rows written were always correct; only the return value was truncated. The existing delete - multiple test deleted two rows and asserted a length of one, which is how it survived. Fixed in all four branches; the three scope tests asserting [null] now assert [], with a migration note for callers who read the array.

Count-only lost the flag. total selected without edges rebuilt its options bag from a three-key whitelist, dropping any adapter-specific key that decides which rows match. M(deleted: ONLY) { total } answered 0 while the same query with edges answered 1. It now derives the bag by removing the keys a count cannot use, so a key it has never heard of survives instead of being silently lost.

Out of scope

Recorded in specifications §13: belongsTo/hasOne fields take only a required argument, so a soft-deleted single-relation target still reads as null; node(id:) still does not resolve a deleted row; and update/delete keep their implicit live-rows-only predicate — restore first, then write.

Tests

  • packages/gqlize/__tests__/paranoid.test.ts — 18 cases: all three values at root and nested depth, total vs edges agreement including both count-only paths, explicit and separate includes, both permission gates, scope interaction on read and restore, delete → restore round-trip.
  • packages/ormize-adapter-sequelize/__tests__/paranoid.test.ts — 11 cases: the _timestampAttributes canary including a renamed column, defaultModel merge order in both directions, join-model isolation under a global default, the misconfiguration warning.
  • A snapshot round-trip assertion that GQLTDeletedFilter survives as one shared instance, and a regenerated golden SDL.

pnpm build, pnpm typecheck, pnpm test (1505 tests across 9 packages) and pnpm lint --max-warnings 0 all pass.

Docs

guide §3/§6/§7/§8, specifications §5/§7/§9/§13, a migration entry for each of the new feature and the mutation return shape, and the gqlize README.

🤖 Generated with Claude Code

Closes #56. `options: {paranoid: true}` already reached `sequelize.define`
untouched, so soft delete *happened* — the root delete mutation goes through
instance `destroy()`. Nothing ever set `paranoid` on a *read* options bag,
though, so a soft-deleted row was unreachable through GraphQL by any means and
there was no root-level way to bring it back. That is the whole of the gap.

The read side is a `deleted: GQLTDeletedFilter` argument — EXCLUDE (the default)
/ INCLUDE / ONLY — on every list field of a model the adapter reports as soft
deleting: root lists, nested relationship connections, and each entry in the
`include` input. An enum rather than an `includeDeleted: Boolean` because ONLY,
the trash view, is otherwise only expressible by naming the `deletedAt` column,
which is renameable and so not something a caller can rely on.

The flag is applied per *query node*, not per request. A top-level
`paranoid: false` does not propagate into includes — confirmed empirically, and
the reason `deletedOverlay` is applied at the root, on every include entry, on
`countOptions`, and inside `countRelationship`, which builds its own options bag
from scratch. Miss one and the failure is a silently wrong answer rather than an
error: `total` counting live rows while `edges` return deleted ones. That
agreement is what most of the new test file asserts, and the generated SQL was
read back to confirm `deletedAt IS NULL` leaves the main query and the JOIN
together.

The write side is a root `restore` argument reusing the `delete` filter type
rather than minting a second identical `[filterType]`. Restore is an *update*
everywhere it has to be classified — `scope` operation, the new
`beforeRestore` entry in `INSTANCE_HOOKS`, `Events.MUTATION_UPDATE` for `after`
— rather than gaining an `Events` member every exhaustive hook would have to
learn. That is already this codebase's position in `VERB_OPERATIONS`.

Both surfaces are gated build-time, by two new keys: `queryDeleted` and
`mutationRestore`. Denied means the element is not in the schema, so a query
naming it fails validation rather than at resolution — which keeps the
sync/async boundary `permission.test.ts` enforces structurally, and is why the
shared fixture gains a paranoid `Memo` model: that test asserts every
build-time key is consulted during the fixture build.

Turning paranoid on for all or some models needed no new configuration. The
Sequelize adapter's `defaultModel` is merged *under* each definition's own
options, which is already "global default, per-model override, per-model opt
out" — it was simply undocumented, with no test and no mention in docs or
examples. So it is documented, pinned by tests, and hardened: `{paranoid: true,
timestamps: false}` defines silently and is a lie (Sequelize reports
`paranoid === true` and defines no column, so deletes are hard), and it is
reachable in normal use because generated join models are defined with
`timestamps: false` and would inherit a global `paranoid: true`. Hence
`softDeletes` derives from `deletedAtColumn`, which requires both halves;
generated join models now pin `paranoid: false`; and `createModel` warns.
`_timestampAttributes.deletedAt` is undocumented Sequelize internals and the
only source of a renamed column's name, so it gets a canary next to the
`define-model.test.ts` one.

Adapters opt in: `softDeletes` and `getRestoreFunction` are optional on
`OrmAdapter` exactly as `computedOrderableFields` is, so valkey and any
third-party adapter need no change and simply generate neither surface.

Two pre-existing defects surfaced while building this, both of which restore
would have inherited:

`update`/`delete`/`select` used `waterfall` — a reduce that threads each step's
result into the next — as if it were a map, so a filter matching n rows returned
only the *last*, and a zero match returned `[null]` rather than `[]`. The rows
written were always correct; only the mutation's own return value was truncated.
The existing `delete - multiple` test deleted two rows and asserted a length of
one, which is how it survived. Fixed in all four branches, with the three scope
tests that asserted `[null]` updated to `[]` and a migration note for callers who
read the array.

The count-only path — `total` selected without `edges` — rebuilt its options bag
from a three-key whitelist, dropping any adapter-specific key that decides which
rows match. `M(deleted: ONLY) { total }` answered 0 while the same query with
`edges` answered 1. It now derives the bag by removing the keys a count cannot
use, so a key it has never heard of survives instead of being silently lost.

Out of scope, and recorded in specifications §13: `belongsTo`/`hasOne` fields
take only a `required` argument, so a soft-deleted single-relation target still
reads as null; `node(id:)` still does not resolve a deleted row; and `update` /
`delete` keep their implicit live-rows-only predicate — restore first, then
write.

Docs: guide §3/§6/§7/§8, specifications §5/§7/§9/§13, a migration entry for each
of the new feature and the mutation return shape, and the gqlize README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Azerothian
Azerothian changed the base branch from feat/graphql-deprecated-validate-connection to main August 28, 2026 10:31
@Azerothian
Azerothian merged commit f3536cb into main Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support paranoid from sequelize

1 participant