Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/24964-orphan-detail-operator-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
'@objectstack/objectql': minor
---

feat(cli, objectql): `os db orphans` — report the detail rows whose required master-detail master does not exist, and delete them only behind `--delete`, after a pre-delete export

Clause-②: yes (widening)

**What was wrong.** A system-context write is exempt from the write-time reference check, so an elevated writer could store a master-detail id that names no master. Such a row's access derives from that master, so every user door answered 404 for it, the administrator's `GET` and `DELETE` included, and a list query left it out. The integrity audit reported the row and, by design, never rewrites it. No door existed to remove it, so it stayed until someone edited the database by hand.

**`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it.

- **The default run is a report** that writes no row or table and creates no file. It boots read-only, as `os migrate plan` does. It lists each detail row whose required `master_detail` master does not exist, with its object, record id, field and missing master id. It also lists the deeper detail rows that the delete's cascade removes with it. `--json` prints one document, `{ mode: 'report', plan }`.
- **What is never deleted, and is said.** A reference whose target object could not be checked (not registered, no driver, or a failed read) is counted as `undetermined`. Unknown is not missing. A dangling reference on the same objects through a field that is not a required master-detail is listed in `otherDangling`. An orphan whose cascade could not be read is listed as `withheld`, with the reason. A scan bounded by `--max-rows` (default 100000) names every object it could not read in full.
- **`--delete` requires `--export <path>`.** The export holds every record the run deletes, as the driver stores it. It is written owner-only, is created rather than overwritten, and is read back before the first delete. If it cannot be written or does not hold every record, the run refuses with nothing deleted. Just before deleting, the run reads the report again and skips an orphan whose master now exists, or whose cascade now reaches a record the export does not hold. Each delete is the engine's own system-context delete, so the master-detail's cascade removes the deeper details. A `restrict` dependent refuses the delete, and a cascade on one datasource is one unit of work, so nothing of that orphan is removed. A `set_null` reference to a deleted row is cleared. A roll-up on the missing master cannot be recomputed, and the run notes this rather than failing. Each delete is judged by reading its records back.
- **Refusals**, each exit 1 with nothing deleted, and the code under `--json` as `error`: `export_required`, `export_exists`, `export_failed`, `export_incomplete`, `confirmation_required` (`--json` without `--yes`), `boot_failed`, `no_engine` and `scan_failed`.

**`@objectstack/objectql`: `ObjectQL.inspectCascadeDelete(object, id)` (new method).** A read-only method that returns every record a by-id `delete()` of that record removes, root first. It is the cascade set the delete collects for itself, read through the same walk. It writes nothing, runs no hook, and files no reference-check elevation record, because no delete happens. It reads as the system with no organization. `restrict` and `set_null` relations are not listed, because neither removes a record. A dependents probe that fails propagates.

Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged.
43 changes: 43 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ predicate/schema/binding mistakes that fail silently at runtime), and `os dev --
| `os dev [package]` | | Start development mode with hot reload |
| `os serve [config]` | | Start the ObjectStack server with plugin auto-detection |
| `os db clean` | | Reclaim SQLite free space with a one-time `VACUUM` (ADR-0057) |
| `os db orphans` | | Report detail rows whose required master-detail master does not exist; delete them only with `--delete` |

#### `os init`

Expand Down Expand Up @@ -423,6 +424,48 @@ os db clean --database file:./data/app.db # explicit target
**Options:**
- `-d, --database <url>` — SQLite database URL/path (defaults to `$OS_DATABASE_URL`, then the per-project dev DB)

#### `os db orphans`

Reports the detail rows whose **required** `master_detail` master does not exist, and —
only behind `--delete` — deletes them. **Report-only by default: without `--delete` it
writes no row or table and creates no file.** It is never run for you; nothing on any
boot, upgrade, migrate or lifecycle path invokes it.

Such a row is written by an elevated (system-context) writer, which the write-time
reference check exempts. Its access derives from a master that does not exist, so no
user door reads or deletes it — an administrator's `GET` and `DELETE` answer 404 — and
the platform's integrity audit reports it without ever rewriting it. This command is
where an operator acts on that report.

The report lists each orphan's object, record id, field and missing master id, and the
deeper detail rows its delete removes with it (the master-detail's own cascade). It
also names, without ever deleting them, the dangling references on those objects that
are not a required master-detail, and counts the references whose target could not be
checked at all: unknown is not missing, and none of those rows is listed or deleted.

```bash
os db orphans # report (writes no rows)
os db orphans --json # the same report, machine-readable
os db orphans --delete --export ./orphans-backup.json
```

**Options:**
- `--delete` — delete the listed orphans and their cascade (default off)
- `--export <path>` — **mandatory with `--delete`**: every record the run deletes, as stored, written
owner-only and read back before the first delete; refuses to overwrite an existing file
- `--max-rows <n>` — rows read per object and in total (default 100000); the report names every object it
could not read in full
- `-y, --yes` — skip the confirmation prompt
- `--database-url <url>`, `--json`

`--delete` deletes only what the same run listed. Just before deleting it reads the
report again and skips an orphan whose master now exists, and it skips one whose cascade
now reaches a record the export does not hold. Each delete is the engine's own
system-context delete, so a `restrict` dependent refuses it (reported as failed, with
nothing of that orphan removed) and a `set_null` reference to a deleted row is cleared.
A roll-up on the missing master cannot be recomputed, and the run says so. Keep the
export until you are certain the sweep was right: it is the only copy.

#### Console UI

Launch the development server with the Console UI:
Expand Down
Loading
Loading