Skip to content

feat(core): add undelete API to reverse soft deletes#76

Merged
cuibonobo merged 1 commit into
mainfrom
claude/issue-60-undelete-api
Jul 9, 2026
Merged

feat(core): add undelete API to reverse soft deletes#76
cuibonobo merged 1 commit into
mainfrom
claude/issue-60-undelete-api

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Closes #60.

The spec promises soft-delete recovery — it's half the stated purpose of record versioning — but nothing implements it. Stack/ScopedStack expose delete() and nothing else. #59 sharpened this from "missing polish" to a prerequisite: its trust model for the coarse write bit ("anything a write-holder does, the owner can undo") is false for every deletion until this exists.

What's added

  • StackRecordAdapter.undeleteRecord(id): Promise<StackRecord> — a dedicated adapter method, not routed through the generic updateRecord(id, changes: Partial<StackRecord>) grab-bag. That contract can't express "clear deletedAt": every adapter treats an undefined field in the patch as "leave untouched" (confirmed in record-adapter-sqljs, which checks changes.deletedAt !== undefined before including it in the UPDATE), so there's no way to signal removal through it. Implemented in MemoryAdapter, record-adapter-sqljs, and adapter-api (POST /records/:id/undelete), and wired through combineAdapters() and adapter-local's LocalAdapter.
  • Stack.undelete(id) — idempotent: undeleting a record that isn't deleted returns it unchanged, so a retried call after a network blip never fails. Missing record throws StackNotFoundError; a hard-deleted record is simply missing, so it throws the same way.
  • ScopedStack.undelete(id) — gated by requireDeletable, the same write-bit-or-delete-grant check as delete(). Undelete is the inverse of soft delete, so the same capability governs both directions — granting one without the other would be backwards. Hard delete's owner-only carve-out (Record-level write stays one bit — but everything it permits must be recoverable, and irreversible verbs get carved out #59, already merged) is untouched; it has no inverse.
  • migrateAll() now sweeps soft-deleted records too (includeDeleted: true, unconditionally), so a record migrated while deleted comes back current on undelete instead of stranded on a stale schema.
  • Spec: docs/spec.md §Deletion documents the API and idempotency contract; the wire endpoint table gains POST /records/:id/undelete; StackClient's method list gains undelete.

Out of scope (tracked separately, per #60's own framing)

Test plan

  • Added tests across packages/core (Stack.undelete, ScopedStack.undelete, migrateAll sweep), record-adapter-sqljs, and adapter-api
  • pnpm -r build — clean
  • pnpm -r typecheck — clean across all 6 packages
  • pnpm -r lint — clean
  • pnpm -r test — 348 tests passing workspace-wide
  • prettier --check on changed files — clean

Refs #60, #59, #61, #52, #47


Generated by Claude Code

Closes #60. The spec promises soft-delete recovery, but nothing
implemented it — Stack/ScopedStack only exposed delete(). Adds:

- StackRecordAdapter.undeleteRecord(id): Promise<StackRecord>, a
  dedicated adapter method (not routed through the generic
  updateRecord Partial<StackRecord> merge, which can't express
  "clear deletedAt" — every adapter treats an undefined field as
  "leave untouched," so there's no way to signal removal through it).
  Implemented in MemoryAdapter, record-adapter-sqljs, adapter-api
  (POST /records/:id/undelete), and wired through combineAdapters()
  and adapter-local's LocalAdapter.
- Stack.undelete(id): idempotent (a non-deleted record returns
  unchanged), StackNotFoundError for missing/hard-deleted records.
- ScopedStack.undelete(id): gated by requireDeletable, the same
  write-bit-or-delete-grant check as delete() — undelete is delete's
  inverse, so the same capability governs both directions. Hard
  delete's owner-only carve-out (#59) is untouched; it has no
  inverse.
- migrateAll() now sweeps soft-deleted records too, so a record
  migrated while deleted comes back current on undelete instead of
  stranded on a stale schema.
- Spec: §Deletion documents the API and idempotency contract; wire
  table gains the endpoint.

Out of scope, per #60's own framing: version-bump-on-delete/undelete
(tracked in #61, which hasn't landed — delete() doesn't bump version
either yet, so undelete() stays symmetric with it) and the shared
conformance-fixture framework (#52, doesn't exist in this repo yet;
covered here with regular adapter-level tests instead).
@cuibonobo cuibonobo merged commit 41a6e2c into main Jul 9, 2026
5 checks passed
@cuibonobo cuibonobo deleted the claude/issue-60-undelete-api branch July 9, 2026 18:41
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.

Undelete API: the spec promises soft-delete recovery, nothing implements it

2 participants