Database Lifecycle Hooks
The guards and lifecycle hooks db() attaches to every tracked route.
The db() 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
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.
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. |
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 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 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
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'sclientMetadata.folderis ignored. - An abort keeps the previous object. An expired overwrite is cleaned up by purge.
An upload to a key that purge is cleaning up gets CONFLICT. A retry after
purge finishes goes through.
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.
Frequently asked questions
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.
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.