@ultimat3/storage 27.0.0 → 27.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/attachment.ts +11 -7
- package/src/errors-promote.ts +44 -0
- package/src/errors.ts +2 -26
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -370,7 +370,7 @@ An upload happens **before** the row it belongs to exists, so it lands at
|
|
|
370
370
|
| `pendingKey(orgId, uploadName(id, filename))` | `org/o1/pending/u-1.png` |
|
|
371
371
|
| `quarantineKey(orgId, name)` | `org/o1/pending/quarantine/u-1.png` |
|
|
372
372
|
| `attachmentKey(orgId, { entity, id, field }, name)` | `org/o1/post/p-1/cover/u-1.png` |
|
|
373
|
-
| `promoteAttachment({ disk, key, orgId, target, policy })` | `stat`, `copy`, then `delete` — never the reverse. **`policy` is required**: the object is measured here (`stat().size` over `policy.maxBytes` is `X_STORAGE_TOO_LARGE`, and it stays under `pending/` for the sweep), because an s3 presign bounds no size. A
|
|
373
|
+
| `promoteAttachment({ disk, key, orgId, target, policy })` | `stat`, `copy`, then `delete` — never the reverse. **`policy` is required**: the object is measured here (`stat().size` over `policy.maxBytes` is `X_STORAGE_TOO_LARGE`, and it stays under `pending/` for the sweep), because an s3 presign bounds no size. A call after the source is gone, with the attached key present, is `X_STORAGE_ALREADY_PROMOTED` (409, `meta.attachedKey`): an earlier call moved the bytes, so this one answers the row that holds them and never deletes that key. The key must be a **pending** one (`isPendingKey`): an attached key a client sent back is another row's file, and moving it would delete the victim's copy (`X_STORAGE_NOT_PENDING`) |
|
|
374
374
|
| `releaseQuarantine({ disk, key, orgId })` | quarantine → pending; returns the released key |
|
|
375
375
|
| `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys. `olderThanMs` is a whole number of 0 or more, refused otherwise (`X_INVARIANT`) — `NaN` read as "everything is old enough" and deleted an upload made a moment ago |
|
|
376
376
|
|
|
@@ -421,6 +421,7 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
|
|
|
421
421
|
| `X_STORAGE_LIST_FAILED` | the disk REFUSED a listing — denied `s3:ListBucket`, a throttle, an unreadable root. An **empty** disk is still not an error |
|
|
422
422
|
| `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
|
|
423
423
|
| `X_STORAGE_NOT_PENDING` | `promoteAttachment` on a key outside the org's `pending/` prefix — most often another row's attached key |
|
|
424
|
+
| `X_STORAGE_ALREADY_PROMOTED` | `promoteAttachment` on a pending key an earlier call already moved (a retried or doubled confirm); `meta.attachedKey` names where the bytes are |
|
|
424
425
|
| `X_STORAGE_PUT_FAILED` | the disk REFUSED a `put()`/`copy()` — `EACCES`, `ENOSPC`, `EROFS`, a provider's refused PUT |
|
|
425
426
|
| `X_STORAGE_OBJECT_LOCKED` | local, memory: a delete, overwrite or copy onto a key under retention or a legal hold; the cause names the key, the mode and `retainUntil` or the hold, and the bytes are unchanged. Never raised by s3, where the provider keeps the locked version |
|
|
426
427
|
| `X_STORAGE_READ_FAILED` | the disk REFUSED a `get`/`stat`/`exists`/`stream` or the read half of a `copy` — a denied `s3:GetObject`, a throttle, an unreadable file. An **absent** object is still `X_STORAGE_NOT_FOUND`, including one deleted between the existence check and the read |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.2.0",
|
|
4
4
|
"description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "27.
|
|
35
|
+
"@ultimat3/core": "27.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/attachment.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
8
|
import { assert, finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { StorageDriver, StorageListEntry, StorageObject } from './driver';
|
|
10
|
-
import {
|
|
10
|
+
import { objectNotFound, orgMismatch, tooLarge } from './errors';
|
|
11
|
+
import { alreadyPromoted, notPending, quarantined } from './errors-promote';
|
|
11
12
|
import { isWithinOrg, orgPrefix, scopedKey } from './path';
|
|
12
13
|
import type { UploadPolicy } from './upload';
|
|
13
14
|
|
|
@@ -141,10 +142,12 @@ export interface PromoteAttachmentInput {
|
|
|
141
142
|
* object stays under `pending/` for `sweepOrphans` — never at a key a row points to. `stat` and
|
|
142
143
|
* not `get`: this is the call that learns the size, so it cannot be one that buffers it.
|
|
143
144
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
145
|
+
* A source that is gone with the destination already there is `X_STORAGE_ALREADY_PROMOTED`
|
|
146
|
+
* (409, `meta.attachedKey`), never the destination answered as if this call had moved it. 27.1.0
|
|
147
|
+
* answered it: a confirm sent twice then inserted a second row for one key, that insert failed,
|
|
148
|
+
* and its cleanup deleted the FIRST row's bytes. Only the call that moved the bytes may hand them
|
|
149
|
+
* back; a retry is told where they are and answers the row that holds them. A source that is gone
|
|
150
|
+
* with NO destination is still `X_STORAGE_NOT_FOUND`.
|
|
148
151
|
*
|
|
149
152
|
* The copy is `disk.copy`, not `get` + `put`: promotion used to download the whole object into
|
|
150
153
|
* this process and upload it again, so attaching a 500MB file moved a gigabyte through the pod
|
|
@@ -168,8 +171,9 @@ export async function promoteAttachment(input: PromoteAttachmentInput): Promise<
|
|
|
168
171
|
const attached = attachmentKey(input.orgId, input.target, name);
|
|
169
172
|
const pending = await disk.stat(input.key);
|
|
170
173
|
if (pending === undefined) {
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
if ((await disk.stat(attached)) !== undefined) {
|
|
175
|
+
throw alreadyPromoted(input.key, attached, input.orgId);
|
|
176
|
+
}
|
|
173
177
|
throw objectNotFound(disk.name, input.key);
|
|
174
178
|
}
|
|
175
179
|
if (pending.size > input.policy.maxBytes) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The refusals of the pending → attached move (`promoteAttachment`, `releaseQuarantine`): a key
|
|
2
|
+
// still in quarantine, a key that is not a pending upload, and one an earlier call already moved.
|
|
3
|
+
// Their codes are registered with the rest of the package's in `errors.ts`.
|
|
4
|
+
|
|
5
|
+
import { StorageError } from './errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A key still under the quarantine prefix. The framework never scans bytes — that is the app's
|
|
9
|
+
* job — so the only thing it can enforce is that nothing leaves quarantine without the app
|
|
10
|
+
* saying so, which is what `promoteAttachment` refusing this key means.
|
|
11
|
+
*/
|
|
12
|
+
export const quarantined = (key: string, orgId: string): StorageError =>
|
|
13
|
+
new StorageError({
|
|
14
|
+
code: 'X_STORAGE_QUARANTINED',
|
|
15
|
+
cause: `"${key}" is still under the quarantine prefix, so nothing has cleared it for use`,
|
|
16
|
+
fix: `scan the bytes, then releaseQuarantine({ disk, key: '${key}', orgId: '${orgId}' }) — promote the key it returns`,
|
|
17
|
+
meta: { key, orgId },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A key inside the org and outside its `pending/` prefix — most often another row's attached key
|
|
22
|
+
* a client sent back. Promotion copies then deletes, so accepting it moved the victim's file onto
|
|
23
|
+
* this row and removed it from theirs.
|
|
24
|
+
*/
|
|
25
|
+
export const notPending = (key: string, orgId: string): StorageError =>
|
|
26
|
+
new StorageError({
|
|
27
|
+
code: 'X_STORAGE_NOT_PENDING',
|
|
28
|
+
cause: `"${key}" is not under org "${orgId}"'s pending/ prefix, so it is not an upload waiting for a row — it may already belong to one`,
|
|
29
|
+
fix: 'promote the key grantUpload returned with no target: promoteAttachment({ disk, key: pendingKey(orgId, name), orgId, target })',
|
|
30
|
+
meta: { key, orgId },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The pending key is gone and the attached one is there: an earlier call moved these bytes. Never
|
|
35
|
+
* answered as a success — only the call that moved them may hand them back, because a caller's
|
|
36
|
+
* cleanup of its own failed write deletes whatever key it was handed, and this one is a row's.
|
|
37
|
+
*/
|
|
38
|
+
export const alreadyPromoted = (key: string, attachedKey: string, orgId: string): StorageError =>
|
|
39
|
+
new StorageError({
|
|
40
|
+
code: 'X_STORAGE_ALREADY_PROMOTED',
|
|
41
|
+
cause: `"${key}" was already promoted to "${attachedKey}" by an earlier call (a retried or doubled confirm), so this call moved nothing`,
|
|
42
|
+
fix: `await disk.stat('${attachedKey}') answers the moved object: answer the row that references that key if one exists, write it with that key if none does, and never delete the key on a failure of your own`,
|
|
43
|
+
meta: { key, attachedKey, orgId },
|
|
44
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -31,6 +31,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
31
31
|
'X_STORAGE_READ_FAILED',
|
|
32
32
|
'X_STORAGE_OBJECT_LOCKED',
|
|
33
33
|
'X_STORAGE_KEY_UNSHARED',
|
|
34
|
+
'X_STORAGE_ALREADY_PROMOTED',
|
|
34
35
|
] as const;
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -72,6 +73,7 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
72
73
|
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
73
74
|
X_STORAGE_OBJECT_LOCKED: 'the object is under retention or a legal hold',
|
|
74
75
|
X_STORAGE_KEY_UNSHARED: 'object key is outside every tenant and every shared prefix',
|
|
76
|
+
X_STORAGE_ALREADY_PROMOTED: 'the upload was already promoted onto its row',
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -396,32 +398,6 @@ export const listFailed = (
|
|
|
396
398
|
});
|
|
397
399
|
};
|
|
398
400
|
|
|
399
|
-
/**
|
|
400
|
-
* A key still under the quarantine prefix. The framework never scans bytes — that is the app's
|
|
401
|
-
* job — so the only thing it can enforce is that nothing leaves quarantine without the app
|
|
402
|
-
* saying so, which is what `promoteAttachment` refusing this key means.
|
|
403
|
-
*/
|
|
404
|
-
export const quarantined = (key: string, orgId: string): StorageError =>
|
|
405
|
-
new StorageError({
|
|
406
|
-
code: 'X_STORAGE_QUARANTINED',
|
|
407
|
-
cause: `"${key}" is still under the quarantine prefix, so nothing has cleared it for use`,
|
|
408
|
-
fix: `scan the bytes, then releaseQuarantine({ disk, key: '${key}', orgId: '${orgId}' }) — promote the key it returns`,
|
|
409
|
-
meta: { key, orgId },
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* A key inside the org and outside its `pending/` prefix — most often another row's attached key
|
|
414
|
-
* a client sent back. Promotion copies then deletes, so accepting it moved the victim's file onto
|
|
415
|
-
* this row and removed it from theirs.
|
|
416
|
-
*/
|
|
417
|
-
export const notPending = (key: string, orgId: string): StorageError =>
|
|
418
|
-
new StorageError({
|
|
419
|
-
code: 'X_STORAGE_NOT_PENDING',
|
|
420
|
-
cause: `"${key}" is not under org "${orgId}"'s pending/ prefix, so it is not an upload waiting for a row — it may already belong to one`,
|
|
421
|
-
fix: 'promote the key grantUpload returned with no target: promoteAttachment({ disk, key: pendingKey(orgId, name), orgId, target })',
|
|
422
|
-
meta: { key, orgId },
|
|
423
|
-
});
|
|
424
|
-
|
|
425
401
|
/** The key is well-formed and unforged, and still belongs to somebody else. */
|
|
426
402
|
export const orgMismatch = (key: string, orgId: string): StorageError =>
|
|
427
403
|
new StorageError({
|
package/src/index.ts
CHANGED
|
@@ -84,7 +84,6 @@ export {
|
|
|
84
84
|
pathUnsafe,
|
|
85
85
|
putFailed,
|
|
86
86
|
putTooLarge,
|
|
87
|
-
quarantined,
|
|
88
87
|
readFailed,
|
|
89
88
|
STORAGE_ERROR_CODES,
|
|
90
89
|
STORAGE_ERROR_TITLES,
|
|
@@ -97,6 +96,7 @@ export {
|
|
|
97
96
|
uploadFailed,
|
|
98
97
|
xmlBodyUnreadable,
|
|
99
98
|
} from './errors';
|
|
99
|
+
export { alreadyPromoted, quarantined } from './errors-promote';
|
|
100
100
|
export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
|
|
101
101
|
export { grantUpload } from './grant';
|
|
102
102
|
export type {
|