# Database Lifecycle Hooks (https://v2.s3.dimah.dev/docs/db/lifecycle-hooks)

The [`db()`](https://v2.s3.dimah.dev/docs/db/setup) plugin attaches guards and lifecycle hooks to
every route that does not set `plugins: { db: false }`. They keep
`storage_object` in step with S3 and check that each call acts on rows of
its own scope.

## Hook order [#hook-order]

Guards run instance, then route, then feature. Within each hook, the
plugin runs first and your hook runs last. This holds for lifecycle `on*`
hooks too, so your `upload.onStart` sees the pending row, and your
`upload.onConfirmed` sees the row already `active`. See the
[plugin hook order](https://v2.s3.dimah.dev/docs/server/plugins#hook-execution-order).

## Lifecycle by operation [#lifecycle-by-operation]

| Hook                       | What the plugin does                                                                                                                                 |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Upload ticket              | Binds the ticket to the caller's scope. A ticket from another scope is `UPLOAD_TICKET_INVALID`.                                                      |
| `upload.guard`             | Checks `clientMetadata.folder`, runs `authorize` with that folder, checks who owns the key, then checks the [quota](https://v2.s3.dimah.dev/docs/db/api#quota).             |
| `upload.onStart`           | Writes the `pending` row with the upload's token, its confirm deadline, and its folder, then creates the folder and its ancestors when missing.      |
| `upload.confirmGuard`      | Accepts only the row of this ticket's upload, owned by the caller, or a confirm it already finished (a retry).                                       |
| `upload.onConfirmed`       | Checks the quota against the verified size, marks the row `active` with the size, type, and ETag from HeadObject, and retires the `previousKey` row. |
| `upload.multipart.guard`   | Part, list, and complete accept only the row of this ticket's upload. Abort checks the row only while it is still this upload's.                     |
| `upload.multipart.onAbort` | Removes the pending row, or ends the overwrite and keeps the previous object.                                                                        |
| `download.guard`           | Requires an `active` row owned by the caller, then runs `authorize` with it.                                                                         |
| `delete.guard`             | Requires a row owned by the caller (a soft-deleted one deletes again), then runs `authorize` with it.                                                |
| `delete.onDeleted`         | Aborts a multipart upload in flight, then soft- or hard-deletes the row (`deleteMode`).                                                              |

`onStart` runs for presigned POST and PUT uploads, multipart init, and
`s3.put` (`kind: "server"`). The row's `expiresAt` is the `expiresAt` of the
onStart context: the upload ticket's expiry, or 15 minutes for `s3.put`. A
pending row past that deadline stops counting toward the quota, and
[purge](https://v2.s3.dimah.dev/docs/db/purge) cleans it up. The deadline comes from the server's
upload ticket settings, `upload.confirmWindow` and
`upload.multipart.ticketExpiresIn`. There is no separate option.

## Confirm replays [#confirm-replays]

Confirm is stateless, and `onConfirmed` runs at least once. The plugin marks
the row active with a compare-and-set on its status and upload token. A
retried confirm or complete for an upload that is already confirmed returns
the stored row, and the quota is not checked again. Your own `onConfirmed`
can dedupe on `uploadToken`.

When the quota check at confirm fails, the plugin removes the row, and the
server deletes the object and returns `QUOTA_EXCEEDED`.

## Overwrites [#overwrites]

An upload to a key that already has an active object fails with
`OBJECT_EXISTS` (`409`), unless the route sets
`plugins: { db: { replace: "overwrite" } }`. With it:

- The active row stays active, with the previous object's metadata, until
  the new upload is confirmed. S3 replaces the bytes as soon as the new PUT
  lands, and a rejected confirm deletes the key.
- The row keeps its folder, attributes, and `createdAt`. The overwrite's
  `clientMetadata.folder` is ignored.
- An abort keeps the previous object. An expired overwrite is cleaned up
  by [purge](https://v2.s3.dimah.dev/docs/db/purge).

An upload to a key that purge is cleaning up gets `CONFLICT`. A retry after
purge finishes goes through.

## Errors [#errors]

| Situation                                                      | Status | Code                    |
| -------------------------------------------------------------- | ------ | ----------------------- |
| `resolveScope` returns `null`                                  | `401`  | `UNAUTHORIZED`          |
| An upload key belongs to another scope, or `authorize` says no | `403`  | `FORBIDDEN`             |
| The upload ticket was issued to another scope                  | `403`  | `UPLOAD_TICKET_INVALID` |
| The row is missing, another scope's, or not this upload's      | `404`  | `OBJECT_NOT_FOUND`      |
| An active object is at the key                                 | `409`  | `OBJECT_EXISTS`         |
| Purge is cleaning the key, or a concurrent write won           | `409`  | `CONFLICT`              |
| `clientMetadata.folder` is not a valid folder path             | `400`  | `FOLDER_INVALID`        |
| The upload would exceed the quota                              | `403`  | `QUOTA_EXCEEDED`        |

Route membership is still the server `keyPrefix` check (`KEY_INVALID`). The
plugin looks rows up by `(bucket, key)` and compares the **scope**. It does
not compare the stored `route` column to the request route. See
[route namespace vs ownership](https://v2.s3.dimah.dev/docs/server/security#route-namespace-vs-ownership).

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="What happens on a route with plugins: { db: false }?">

None of these hooks run: no rows, no ownership checks, and upload tickets
are not bound to a scope. Add your own `download.guard` and `delete.guard`.
The plugin warns at startup when such a route enables download or delete
with no route or feature guard.

</Accordion>

<Accordion title="Can authorize allow access to another scope's objects?">

No. The ownership check runs first, and `authorize` can only narrow it. To
share objects, resolve both callers to the same scope, for example
`org:acme`.

</Accordion>

</Accordions>
