graphile-presigned-url-plugin 1.13.1 → 1.14.1
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/custom-key.d.ts +11 -0
- package/custom-key.js +38 -0
- package/esm/custom-key.d.ts +11 -0
- package/esm/custom-key.js +35 -0
- package/esm/file-lifecycle.d.ts +20 -0
- package/esm/file-lifecycle.js +23 -0
- package/esm/index.d.ts +1 -0
- package/esm/index.js +1 -0
- package/esm/managed-upload.js +18 -2
- package/esm/plugin.js +53 -44
- package/esm/storage-module-cache.js +3 -0
- package/esm/types.d.ts +6 -0
- package/file-lifecycle.d.ts +20 -0
- package/file-lifecycle.js +28 -0
- package/index.d.ts +1 -0
- package/index.js +3 -1
- package/managed-upload.js +18 -2
- package/package.json +5 -5
- package/plugin.js +54 -45
- package/storage-module-cache.js +3 -0
- package/types.d.ts +6 -0
package/custom-key.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for a caller-supplied ("custom") object key.
|
|
3
|
+
*
|
|
4
|
+
* A custom key is the one place a client names an S3 object directly, so what is
|
|
5
|
+
* enforced here is containment: the key must land inside the bucket's namespace
|
|
6
|
+
* and mean the same thing to S3 as it does to the gateway that later serves it.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Returns an error string describing why `key` is unusable, or null if it is fine.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateCustomKey(key: string): string | null;
|
package/custom-key.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Validation for a caller-supplied ("custom") object key.
|
|
4
|
+
*
|
|
5
|
+
* A custom key is the one place a client names an S3 object directly, so what is
|
|
6
|
+
* enforced here is containment: the key must land inside the bucket's namespace
|
|
7
|
+
* and mean the same thing to S3 as it does to the gateway that later serves it.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.validateCustomKey = validateCustomKey;
|
|
11
|
+
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
12
|
+
/**
|
|
13
|
+
* The key alphabet. A leading underscore is legal — a static export puts its
|
|
14
|
+
* hashed assets under `_next/static/**` — and containment is enforced by the
|
|
15
|
+
* `..`, leading-slash and NUL checks below rather than by the first character.
|
|
16
|
+
*/
|
|
17
|
+
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9_][a-zA-Z0-9_.\-/]*$/;
|
|
18
|
+
/**
|
|
19
|
+
* Returns an error string describing why `key` is unusable, or null if it is fine.
|
|
20
|
+
*/
|
|
21
|
+
function validateCustomKey(key) {
|
|
22
|
+
if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
|
|
23
|
+
return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
|
|
24
|
+
}
|
|
25
|
+
if (key.includes('..')) {
|
|
26
|
+
return 'INVALID_KEY: path traversal (..) not allowed';
|
|
27
|
+
}
|
|
28
|
+
if (key.startsWith('/')) {
|
|
29
|
+
return 'INVALID_KEY: leading slash not allowed';
|
|
30
|
+
}
|
|
31
|
+
if (key.includes('\0')) {
|
|
32
|
+
return 'INVALID_KEY: null bytes not allowed';
|
|
33
|
+
}
|
|
34
|
+
if (!CUSTOM_KEY_REGEX.test(key)) {
|
|
35
|
+
return 'INVALID_KEY: must start with alphanumeric or underscore and contain only alphanumeric, dots, hyphens, underscores, and slashes';
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for a caller-supplied ("custom") object key.
|
|
3
|
+
*
|
|
4
|
+
* A custom key is the one place a client names an S3 object directly, so what is
|
|
5
|
+
* enforced here is containment: the key must land inside the bucket's namespace
|
|
6
|
+
* and mean the same thing to S3 as it does to the gateway that later serves it.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Returns an error string describing why `key` is unusable, or null if it is fine.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateCustomKey(key: string): string | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for a caller-supplied ("custom") object key.
|
|
3
|
+
*
|
|
4
|
+
* A custom key is the one place a client names an S3 object directly, so what is
|
|
5
|
+
* enforced here is containment: the key must land inside the bucket's namespace
|
|
6
|
+
* and mean the same thing to S3 as it does to the gateway that later serves it.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
9
|
+
/**
|
|
10
|
+
* The key alphabet. A leading underscore is legal — a static export puts its
|
|
11
|
+
* hashed assets under `_next/static/**` — and containment is enforced by the
|
|
12
|
+
* `..`, leading-slash and NUL checks below rather than by the first character.
|
|
13
|
+
*/
|
|
14
|
+
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9_][a-zA-Z0-9_.\-/]*$/;
|
|
15
|
+
/**
|
|
16
|
+
* Returns an error string describing why `key` is unusable, or null if it is fine.
|
|
17
|
+
*/
|
|
18
|
+
export function validateCustomKey(key) {
|
|
19
|
+
if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
|
|
20
|
+
return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
|
|
21
|
+
}
|
|
22
|
+
if (key.includes('..')) {
|
|
23
|
+
return 'INVALID_KEY: path traversal (..) not allowed';
|
|
24
|
+
}
|
|
25
|
+
if (key.startsWith('/')) {
|
|
26
|
+
return 'INVALID_KEY: leading slash not allowed';
|
|
27
|
+
}
|
|
28
|
+
if (key.includes('\0')) {
|
|
29
|
+
return 'INVALID_KEY: null bytes not allowed';
|
|
30
|
+
}
|
|
31
|
+
if (!CUSTOM_KEY_REGEX.test(key)) {
|
|
32
|
+
return 'INVALID_KEY: must start with alphanumeric or underscore and contain only alphanumeric, dots, hyphens, underscores, and slashes';
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StorageModuleConfig } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The statuses in which a files row stands for bytes a reader can actually GET.
|
|
4
|
+
* A `requested` row is a claim, not an object — its presigned PUT may never have
|
|
5
|
+
* run — and `rejected`/`expired` are settled failures.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LIVE_FILE_STATUSES: string[];
|
|
8
|
+
/**
|
|
9
|
+
* The `status` column, when the module has one, for splicing into a select list.
|
|
10
|
+
*/
|
|
11
|
+
export declare function statusSelectFragment(storageConfig: StorageModuleConfig): string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether an existing row may be handed back as a dedup hit.
|
|
14
|
+
*
|
|
15
|
+
* Modules without the confirm-upload lifecycle have no `status` column, so there
|
|
16
|
+
* is nothing to read and every row is presumed live, as before.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isLiveFileRow(storageConfig: StorageModuleConfig, row: {
|
|
19
|
+
status?: string;
|
|
20
|
+
}): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The statuses in which a files row stands for bytes a reader can actually GET.
|
|
3
|
+
* A `requested` row is a claim, not an object — its presigned PUT may never have
|
|
4
|
+
* run — and `rejected`/`expired` are settled failures.
|
|
5
|
+
*/
|
|
6
|
+
export const LIVE_FILE_STATUSES = ['uploaded', 'processed'];
|
|
7
|
+
/**
|
|
8
|
+
* The `status` column, when the module has one, for splicing into a select list.
|
|
9
|
+
*/
|
|
10
|
+
export function statusSelectFragment(storageConfig) {
|
|
11
|
+
return storageConfig.hasConfirmUpload ? ', status' : '';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether an existing row may be handed back as a dedup hit.
|
|
15
|
+
*
|
|
16
|
+
* Modules without the confirm-upload lifecycle have no `status` column, so there
|
|
17
|
+
* is nothing to read and every row is presumed live, as before.
|
|
18
|
+
*/
|
|
19
|
+
export function isLiveFileRow(storageConfig, row) {
|
|
20
|
+
if (!storageConfig.hasConfirmUpload)
|
|
21
|
+
return true;
|
|
22
|
+
return LIVE_FILE_STATUSES.includes(row.status);
|
|
23
|
+
}
|
package/esm/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
|
|
30
|
+
export { validateCustomKey } from './custom-key';
|
|
30
31
|
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
31
32
|
export { resolveDefaultBucket } from './default-bucket';
|
|
32
33
|
export { createDownloadUrlPlugin } from './download-url-field';
|
package/esm/index.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, } from './confirm-upload';
|
|
30
|
+
export { validateCustomKey } from './custom-key';
|
|
30
31
|
export { resolveDefaultBucket } from './default-bucket';
|
|
31
32
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
32
33
|
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
package/esm/managed-upload.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { resolveDefaultBucket } from './default-bucket';
|
|
22
|
+
import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
|
|
22
23
|
import { getFileRefFieldBinding } from './file-ref-registry';
|
|
23
24
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
24
25
|
import { withRequestPgClient } from './request-pg-client';
|
|
@@ -192,7 +193,7 @@ export async function finalizeStagedUpload(args) {
|
|
|
192
193
|
const finalKey = staged.contentHash;
|
|
193
194
|
const existing = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
194
195
|
const result = await pgClient.query({
|
|
195
|
-
text: `SELECT id, key, mime_type, size, filename
|
|
196
|
+
text: `SELECT id, key, mime_type, size, filename${statusSelectFragment(storageConfig)}
|
|
196
197
|
FROM ${storageConfig.filesQualifiedName}
|
|
197
198
|
WHERE content_hash = $1 AND bucket_id = $2
|
|
198
199
|
LIMIT 1`,
|
|
@@ -200,7 +201,22 @@ export async function finalizeStagedUpload(args) {
|
|
|
200
201
|
});
|
|
201
202
|
return result.rows[0];
|
|
202
203
|
});
|
|
203
|
-
|
|
204
|
+
// Only a row that already stands for stored bytes may absorb this upload. One
|
|
205
|
+
// that never received them is dropped, and the staged object is promoted as a
|
|
206
|
+
// fresh file below — which is also what keeps the insert possible, since the
|
|
207
|
+
// final key is the content hash and (bucket_id, key) is unique. The GC job the
|
|
208
|
+
// delete enqueues re-takes the reference count when it runs, by which point
|
|
209
|
+
// the replacement row exists, so it no-ops.
|
|
210
|
+
if (existing && !isLiveFileRow(storageConfig, existing)) {
|
|
211
|
+
log.info(`Restarting upload of hash ${staged.contentHash}: file ${existing.id} is ${existing.status}, so it carries no bytes`);
|
|
212
|
+
await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
213
|
+
await pgClient.query({
|
|
214
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
215
|
+
values: [existing.id],
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
else if (existing) {
|
|
204
220
|
log.info(`Dedup hit: file ${existing.id} already carries hash ${staged.contentHash}`);
|
|
205
221
|
await deleteS3Object(s3, staged.stagingKey);
|
|
206
222
|
return {
|
package/esm/plugin.js
CHANGED
|
@@ -20,7 +20,9 @@ import 'graphile-build';
|
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
22
|
import { checkTypeAgreement } from 'mime-bytes';
|
|
23
|
+
import { validateCustomKey } from './custom-key';
|
|
23
24
|
import { resolveDefaultBucket } from './default-bucket';
|
|
25
|
+
import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
|
|
24
26
|
import { buildFileProjection } from './managed-upload';
|
|
25
27
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
26
28
|
import { withRequestPgClient } from './request-pg-client';
|
|
@@ -30,9 +32,7 @@ const log = new Logger('graphile-presigned-url:plugin');
|
|
|
30
32
|
// --- Protocol-level constants (not configurable) ---
|
|
31
33
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
32
34
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
33
|
-
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
34
35
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
35
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
36
36
|
// --- Helpers ---
|
|
37
37
|
function isValidSha256(hash) {
|
|
38
38
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -40,24 +40,6 @@ function isValidSha256(hash) {
|
|
|
40
40
|
function buildS3Key(contentHash) {
|
|
41
41
|
return contentHash;
|
|
42
42
|
}
|
|
43
|
-
function validateCustomKey(key) {
|
|
44
|
-
if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
|
|
45
|
-
return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
|
|
46
|
-
}
|
|
47
|
-
if (key.includes('..')) {
|
|
48
|
-
return 'INVALID_KEY: path traversal (..) not allowed';
|
|
49
|
-
}
|
|
50
|
-
if (key.startsWith('/')) {
|
|
51
|
-
return 'INVALID_KEY: leading slash not allowed';
|
|
52
|
-
}
|
|
53
|
-
if (key.includes('\0')) {
|
|
54
|
-
return 'INVALID_KEY: null bytes not allowed';
|
|
55
|
-
}
|
|
56
|
-
if (!CUSTOM_KEY_REGEX.test(key)) {
|
|
57
|
-
return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes';
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
43
|
function derivePathFromKey(key) {
|
|
62
44
|
const lastSlash = key.lastIndexOf('/');
|
|
63
45
|
if (lastSlash <= 0)
|
|
@@ -560,9 +542,20 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
560
542
|
}
|
|
561
543
|
// Dedup / versioning check
|
|
562
544
|
let previousVersionId = null;
|
|
545
|
+
// A row whose bytes never landed must not be reported as a dedup hit: the
|
|
546
|
+
// caller would store a reference to an object that is not in S3. Such a row is
|
|
547
|
+
// dropped instead, and the upload proceeds as a fresh one below — the insert is
|
|
548
|
+
// what enqueues the confirm-upload job, so restarting the lifecycle is the only
|
|
549
|
+
// way the retry can ever leave `requested`. Dropping it is also what keeps the
|
|
550
|
+
// retry insertable at all for a content-addressed key, where the key *is* the
|
|
551
|
+
// hash and a second row would collide on (bucket_id, key). The GC job the
|
|
552
|
+
// delete enqueues re-takes the reference count when it runs (≥5s later), by
|
|
553
|
+
// which point the replacement row exists, so it no-ops.
|
|
554
|
+
const statusColumn = statusSelectFragment(storageConfig);
|
|
555
|
+
let staleFileId = null;
|
|
563
556
|
if (isCustomKey) {
|
|
564
557
|
const existingResult = await txClient.query({
|
|
565
|
-
text: `SELECT id, content_hash
|
|
558
|
+
text: `SELECT id, content_hash${statusColumn}
|
|
566
559
|
FROM ${storageConfig.filesQualifiedName}
|
|
567
560
|
WHERE key = $1
|
|
568
561
|
AND bucket_id = $2
|
|
@@ -573,24 +566,30 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
573
566
|
if (existingResult.rows.length > 0) {
|
|
574
567
|
const existing = existingResult.rows[0];
|
|
575
568
|
if (existing.content_hash === contentHash) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
569
|
+
if (isLiveFileRow(storageConfig, existing)) {
|
|
570
|
+
log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
|
|
571
|
+
return {
|
|
572
|
+
uploadUrl: null,
|
|
573
|
+
fileId: existing.id,
|
|
574
|
+
key: s3Key,
|
|
575
|
+
deduplicated: true,
|
|
576
|
+
expiresAt: null,
|
|
577
|
+
previousVersionId: null,
|
|
578
|
+
file: projectFile(existing.id, s3Key),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
staleFileId = existing.id;
|
|
582
|
+
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
previousVersionId = existing.id;
|
|
586
|
+
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
586
587
|
}
|
|
587
|
-
previousVersionId = existing.id;
|
|
588
|
-
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
589
588
|
}
|
|
590
589
|
}
|
|
591
590
|
else {
|
|
592
591
|
const dedupResult = await txClient.query({
|
|
593
|
-
text: `SELECT id
|
|
592
|
+
text: `SELECT id${statusColumn}
|
|
594
593
|
FROM ${storageConfig.filesQualifiedName}
|
|
595
594
|
WHERE content_hash = $1
|
|
596
595
|
AND bucket_id = $2
|
|
@@ -599,18 +598,28 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
599
598
|
});
|
|
600
599
|
if (dedupResult.rows.length > 0) {
|
|
601
600
|
const existingFile = dedupResult.rows[0];
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
601
|
+
if (isLiveFileRow(storageConfig, existingFile)) {
|
|
602
|
+
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
603
|
+
return {
|
|
604
|
+
uploadUrl: null,
|
|
605
|
+
fileId: existingFile.id,
|
|
606
|
+
key: s3Key,
|
|
607
|
+
deduplicated: true,
|
|
608
|
+
expiresAt: null,
|
|
609
|
+
previousVersionId: null,
|
|
610
|
+
file: projectFile(existingFile.id, s3Key),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
staleFileId = existingFile.id;
|
|
614
|
+
log.info(`Restarting upload of hash ${contentHash}: file ${staleFileId} is ${existingFile.status}, so it carries no bytes`);
|
|
612
615
|
}
|
|
613
616
|
}
|
|
617
|
+
if (staleFileId !== null) {
|
|
618
|
+
await txClient.query({
|
|
619
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
620
|
+
values: [staleFileId],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
614
623
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
615
624
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
616
625
|
// Create file record
|
|
@@ -56,6 +56,7 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
56
56
|
sm.max_bulk_files,
|
|
57
57
|
sm.max_bulk_total_size,
|
|
58
58
|
sm.has_path_shares,
|
|
59
|
+
sm.has_confirm_upload,
|
|
59
60
|
NULL AS entity_schema,
|
|
60
61
|
NULL AS entity_table
|
|
61
62
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -94,6 +95,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
94
95
|
sm.max_bulk_files,
|
|
95
96
|
sm.max_bulk_total_size,
|
|
96
97
|
sm.has_path_shares,
|
|
98
|
+
sm.has_confirm_upload,
|
|
97
99
|
es.schema_name AS entity_schema,
|
|
98
100
|
et.name AS entity_table
|
|
99
101
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -132,6 +134,7 @@ function buildConfig(row) {
|
|
|
132
134
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
133
135
|
cacheTtlSeconds,
|
|
134
136
|
hasPathShares: row.has_path_shares ?? false,
|
|
137
|
+
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
135
138
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
136
139
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
|
137
140
|
};
|
package/esm/types.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export interface StorageModuleConfig {
|
|
|
61
61
|
cacheTtlSeconds: number;
|
|
62
62
|
/** Whether this storage module uses ltree path + path shares (determines if path column exists on files) */
|
|
63
63
|
hasPathShares: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the files table carries the confirm-upload lifecycle (`status`,
|
|
66
|
+
* `promoted_at`). Only then can a row be told apart from the bytes it claims:
|
|
67
|
+
* without it every row is treated as live, because there is nothing to read.
|
|
68
|
+
*/
|
|
69
|
+
hasConfirmUpload: boolean;
|
|
64
70
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
65
71
|
maxBulkFiles: number;
|
|
66
72
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StorageModuleConfig } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The statuses in which a files row stands for bytes a reader can actually GET.
|
|
4
|
+
* A `requested` row is a claim, not an object — its presigned PUT may never have
|
|
5
|
+
* run — and `rejected`/`expired` are settled failures.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LIVE_FILE_STATUSES: string[];
|
|
8
|
+
/**
|
|
9
|
+
* The `status` column, when the module has one, for splicing into a select list.
|
|
10
|
+
*/
|
|
11
|
+
export declare function statusSelectFragment(storageConfig: StorageModuleConfig): string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether an existing row may be handed back as a dedup hit.
|
|
14
|
+
*
|
|
15
|
+
* Modules without the confirm-upload lifecycle have no `status` column, so there
|
|
16
|
+
* is nothing to read and every row is presumed live, as before.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isLiveFileRow(storageConfig: StorageModuleConfig, row: {
|
|
19
|
+
status?: string;
|
|
20
|
+
}): boolean;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LIVE_FILE_STATUSES = void 0;
|
|
4
|
+
exports.statusSelectFragment = statusSelectFragment;
|
|
5
|
+
exports.isLiveFileRow = isLiveFileRow;
|
|
6
|
+
/**
|
|
7
|
+
* The statuses in which a files row stands for bytes a reader can actually GET.
|
|
8
|
+
* A `requested` row is a claim, not an object — its presigned PUT may never have
|
|
9
|
+
* run — and `rejected`/`expired` are settled failures.
|
|
10
|
+
*/
|
|
11
|
+
exports.LIVE_FILE_STATUSES = ['uploaded', 'processed'];
|
|
12
|
+
/**
|
|
13
|
+
* The `status` column, when the module has one, for splicing into a select list.
|
|
14
|
+
*/
|
|
15
|
+
function statusSelectFragment(storageConfig) {
|
|
16
|
+
return storageConfig.hasConfirmUpload ? ', status' : '';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether an existing row may be handed back as a dedup hit.
|
|
20
|
+
*
|
|
21
|
+
* Modules without the confirm-upload lifecycle have no `status` column, so there
|
|
22
|
+
* is nothing to read and every row is presumed live, as before.
|
|
23
|
+
*/
|
|
24
|
+
function isLiveFileRow(storageConfig, row) {
|
|
25
|
+
if (!storageConfig.hasConfirmUpload)
|
|
26
|
+
return true;
|
|
27
|
+
return exports.LIVE_FILE_STATUSES.includes(row.status);
|
|
28
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
|
|
30
|
+
export { validateCustomKey } from './custom-key';
|
|
30
31
|
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
31
32
|
export { resolveDefaultBucket } from './default-bucket';
|
|
32
33
|
export { createDownloadUrlPlugin } from './download-url-field';
|
package/index.js
CHANGED
|
@@ -28,10 +28,12 @@
|
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
-
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
|
|
31
|
+
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.validateCustomKey = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
|
|
32
32
|
var confirm_upload_1 = require("./confirm-upload");
|
|
33
33
|
Object.defineProperty(exports, "CONFIRM_PREFIX_BYTES", { enumerable: true, get: function () { return confirm_upload_1.CONFIRM_PREFIX_BYTES; } });
|
|
34
34
|
Object.defineProperty(exports, "confirmUploadedBytes", { enumerable: true, get: function () { return confirm_upload_1.confirmUploadedBytes; } });
|
|
35
|
+
var custom_key_1 = require("./custom-key");
|
|
36
|
+
Object.defineProperty(exports, "validateCustomKey", { enumerable: true, get: function () { return custom_key_1.validateCustomKey; } });
|
|
35
37
|
var default_bucket_1 = require("./default-bucket");
|
|
36
38
|
Object.defineProperty(exports, "resolveDefaultBucket", { enumerable: true, get: function () { return default_bucket_1.resolveDefaultBucket; } });
|
|
37
39
|
var download_url_field_1 = require("./download-url-field");
|
package/managed-upload.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.assertUploadAllowedByBucket = assertUploadAllowedByBucket;
|
|
|
25
25
|
exports.finalizeStagedUpload = finalizeStagedUpload;
|
|
26
26
|
const logger_1 = require("@pgpmjs/logger");
|
|
27
27
|
const default_bucket_1 = require("./default-bucket");
|
|
28
|
+
const file_lifecycle_1 = require("./file-lifecycle");
|
|
28
29
|
const file_ref_registry_1 = require("./file-ref-registry");
|
|
29
30
|
const physical_bucket_1 = require("./physical-bucket");
|
|
30
31
|
const request_pg_client_1 = require("./request-pg-client");
|
|
@@ -198,7 +199,7 @@ async function finalizeStagedUpload(args) {
|
|
|
198
199
|
const finalKey = staged.contentHash;
|
|
199
200
|
const existing = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
200
201
|
const result = await pgClient.query({
|
|
201
|
-
text: `SELECT id, key, mime_type, size, filename
|
|
202
|
+
text: `SELECT id, key, mime_type, size, filename${(0, file_lifecycle_1.statusSelectFragment)(storageConfig)}
|
|
202
203
|
FROM ${storageConfig.filesQualifiedName}
|
|
203
204
|
WHERE content_hash = $1 AND bucket_id = $2
|
|
204
205
|
LIMIT 1`,
|
|
@@ -206,7 +207,22 @@ async function finalizeStagedUpload(args) {
|
|
|
206
207
|
});
|
|
207
208
|
return result.rows[0];
|
|
208
209
|
});
|
|
209
|
-
|
|
210
|
+
// Only a row that already stands for stored bytes may absorb this upload. One
|
|
211
|
+
// that never received them is dropped, and the staged object is promoted as a
|
|
212
|
+
// fresh file below — which is also what keeps the insert possible, since the
|
|
213
|
+
// final key is the content hash and (bucket_id, key) is unique. The GC job the
|
|
214
|
+
// delete enqueues re-takes the reference count when it runs, by which point
|
|
215
|
+
// the replacement row exists, so it no-ops.
|
|
216
|
+
if (existing && !(0, file_lifecycle_1.isLiveFileRow)(storageConfig, existing)) {
|
|
217
|
+
log.info(`Restarting upload of hash ${staged.contentHash}: file ${existing.id} is ${existing.status}, so it carries no bytes`);
|
|
218
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
219
|
+
await pgClient.query({
|
|
220
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
221
|
+
values: [existing.id],
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
else if (existing) {
|
|
210
226
|
log.info(`Dedup hit: file ${existing.id} already carries hash ${staged.contentHash}`);
|
|
211
227
|
await (0, s3_signer_1.deleteS3Object)(s3, staged.stagingKey);
|
|
212
228
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@aws-sdk/client-s3": "^3.1052.0",
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
|
-
"@pgpmjs/logger": "^2.
|
|
45
|
+
"@pgpmjs/logger": "^2.25.0",
|
|
46
46
|
"@pgsql/quotes": "^18.2.4",
|
|
47
47
|
"lru-cache": "^11.2.7",
|
|
48
|
-
"mime-bytes": "^0.
|
|
48
|
+
"mime-bytes": "^0.32.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"grafast": "^1.1.1",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"postgraphile": "^5.1.3"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@constructive-io/s3-utils": "^2.
|
|
60
|
+
"@constructive-io/s3-utils": "^2.32.0",
|
|
61
61
|
"@types/node": "^22.19.11",
|
|
62
62
|
"makage": "^0.3.0"
|
|
63
63
|
},
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "8d77dfe57bb328cbdfe10732b10ca24865adf9c0"
|
|
65
65
|
}
|
package/plugin.js
CHANGED
|
@@ -24,7 +24,9 @@ require("graphile-build");
|
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
26
|
const mime_bytes_1 = require("mime-bytes");
|
|
27
|
+
const custom_key_1 = require("./custom-key");
|
|
27
28
|
const default_bucket_1 = require("./default-bucket");
|
|
29
|
+
const file_lifecycle_1 = require("./file-lifecycle");
|
|
28
30
|
const managed_upload_1 = require("./managed-upload");
|
|
29
31
|
const physical_bucket_1 = require("./physical-bucket");
|
|
30
32
|
const request_pg_client_1 = require("./request-pg-client");
|
|
@@ -34,9 +36,7 @@ const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
|
34
36
|
// --- Protocol-level constants (not configurable) ---
|
|
35
37
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
36
38
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
37
|
-
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
38
39
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
39
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
40
40
|
// --- Helpers ---
|
|
41
41
|
function isValidSha256(hash) {
|
|
42
42
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -44,24 +44,6 @@ function isValidSha256(hash) {
|
|
|
44
44
|
function buildS3Key(contentHash) {
|
|
45
45
|
return contentHash;
|
|
46
46
|
}
|
|
47
|
-
function validateCustomKey(key) {
|
|
48
|
-
if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
|
|
49
|
-
return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
|
|
50
|
-
}
|
|
51
|
-
if (key.includes('..')) {
|
|
52
|
-
return 'INVALID_KEY: path traversal (..) not allowed';
|
|
53
|
-
}
|
|
54
|
-
if (key.startsWith('/')) {
|
|
55
|
-
return 'INVALID_KEY: leading slash not allowed';
|
|
56
|
-
}
|
|
57
|
-
if (key.includes('\0')) {
|
|
58
|
-
return 'INVALID_KEY: null bytes not allowed';
|
|
59
|
-
}
|
|
60
|
-
if (!CUSTOM_KEY_REGEX.test(key)) {
|
|
61
|
-
return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes';
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
47
|
function derivePathFromKey(key) {
|
|
66
48
|
const lastSlash = key.lastIndexOf('/');
|
|
67
49
|
if (lastSlash <= 0)
|
|
@@ -552,7 +534,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
552
534
|
if (!bucket.allow_custom_keys) {
|
|
553
535
|
throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys');
|
|
554
536
|
}
|
|
555
|
-
const keyError = validateCustomKey(customKey);
|
|
537
|
+
const keyError = (0, custom_key_1.validateCustomKey)(customKey);
|
|
556
538
|
if (keyError) {
|
|
557
539
|
throw new Error(keyError);
|
|
558
540
|
}
|
|
@@ -564,9 +546,20 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
564
546
|
}
|
|
565
547
|
// Dedup / versioning check
|
|
566
548
|
let previousVersionId = null;
|
|
549
|
+
// A row whose bytes never landed must not be reported as a dedup hit: the
|
|
550
|
+
// caller would store a reference to an object that is not in S3. Such a row is
|
|
551
|
+
// dropped instead, and the upload proceeds as a fresh one below — the insert is
|
|
552
|
+
// what enqueues the confirm-upload job, so restarting the lifecycle is the only
|
|
553
|
+
// way the retry can ever leave `requested`. Dropping it is also what keeps the
|
|
554
|
+
// retry insertable at all for a content-addressed key, where the key *is* the
|
|
555
|
+
// hash and a second row would collide on (bucket_id, key). The GC job the
|
|
556
|
+
// delete enqueues re-takes the reference count when it runs (≥5s later), by
|
|
557
|
+
// which point the replacement row exists, so it no-ops.
|
|
558
|
+
const statusColumn = (0, file_lifecycle_1.statusSelectFragment)(storageConfig);
|
|
559
|
+
let staleFileId = null;
|
|
567
560
|
if (isCustomKey) {
|
|
568
561
|
const existingResult = await txClient.query({
|
|
569
|
-
text: `SELECT id, content_hash
|
|
562
|
+
text: `SELECT id, content_hash${statusColumn}
|
|
570
563
|
FROM ${storageConfig.filesQualifiedName}
|
|
571
564
|
WHERE key = $1
|
|
572
565
|
AND bucket_id = $2
|
|
@@ -577,24 +570,30 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
577
570
|
if (existingResult.rows.length > 0) {
|
|
578
571
|
const existing = existingResult.rows[0];
|
|
579
572
|
if (existing.content_hash === contentHash) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
573
|
+
if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existing)) {
|
|
574
|
+
log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
|
|
575
|
+
return {
|
|
576
|
+
uploadUrl: null,
|
|
577
|
+
fileId: existing.id,
|
|
578
|
+
key: s3Key,
|
|
579
|
+
deduplicated: true,
|
|
580
|
+
expiresAt: null,
|
|
581
|
+
previousVersionId: null,
|
|
582
|
+
file: projectFile(existing.id, s3Key),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
staleFileId = existing.id;
|
|
586
|
+
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
previousVersionId = existing.id;
|
|
590
|
+
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
590
591
|
}
|
|
591
|
-
previousVersionId = existing.id;
|
|
592
|
-
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
593
592
|
}
|
|
594
593
|
}
|
|
595
594
|
else {
|
|
596
595
|
const dedupResult = await txClient.query({
|
|
597
|
-
text: `SELECT id
|
|
596
|
+
text: `SELECT id${statusColumn}
|
|
598
597
|
FROM ${storageConfig.filesQualifiedName}
|
|
599
598
|
WHERE content_hash = $1
|
|
600
599
|
AND bucket_id = $2
|
|
@@ -603,18 +602,28 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
603
602
|
});
|
|
604
603
|
if (dedupResult.rows.length > 0) {
|
|
605
604
|
const existingFile = dedupResult.rows[0];
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
605
|
+
if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existingFile)) {
|
|
606
|
+
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
607
|
+
return {
|
|
608
|
+
uploadUrl: null,
|
|
609
|
+
fileId: existingFile.id,
|
|
610
|
+
key: s3Key,
|
|
611
|
+
deduplicated: true,
|
|
612
|
+
expiresAt: null,
|
|
613
|
+
previousVersionId: null,
|
|
614
|
+
file: projectFile(existingFile.id, s3Key),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
staleFileId = existingFile.id;
|
|
618
|
+
log.info(`Restarting upload of hash ${contentHash}: file ${staleFileId} is ${existingFile.status}, so it carries no bytes`);
|
|
616
619
|
}
|
|
617
620
|
}
|
|
621
|
+
if (staleFileId !== null) {
|
|
622
|
+
await txClient.query({
|
|
623
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
624
|
+
values: [staleFileId],
|
|
625
|
+
});
|
|
626
|
+
}
|
|
618
627
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
619
628
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
620
629
|
// Create file record
|
package/storage-module-cache.js
CHANGED
|
@@ -69,6 +69,7 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
69
69
|
sm.max_bulk_files,
|
|
70
70
|
sm.max_bulk_total_size,
|
|
71
71
|
sm.has_path_shares,
|
|
72
|
+
sm.has_confirm_upload,
|
|
72
73
|
NULL AS entity_schema,
|
|
73
74
|
NULL AS entity_table
|
|
74
75
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -107,6 +108,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
107
108
|
sm.max_bulk_files,
|
|
108
109
|
sm.max_bulk_total_size,
|
|
109
110
|
sm.has_path_shares,
|
|
111
|
+
sm.has_confirm_upload,
|
|
110
112
|
es.schema_name AS entity_schema,
|
|
111
113
|
et.name AS entity_table
|
|
112
114
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -145,6 +147,7 @@ function buildConfig(row) {
|
|
|
145
147
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
146
148
|
cacheTtlSeconds,
|
|
147
149
|
hasPathShares: row.has_path_shares ?? false,
|
|
150
|
+
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
148
151
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
149
152
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
|
150
153
|
};
|
package/types.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export interface StorageModuleConfig {
|
|
|
61
61
|
cacheTtlSeconds: number;
|
|
62
62
|
/** Whether this storage module uses ltree path + path shares (determines if path column exists on files) */
|
|
63
63
|
hasPathShares: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the files table carries the confirm-upload lifecycle (`status`,
|
|
66
|
+
* `promoted_at`). Only then can a row be told apart from the bytes it claims:
|
|
67
|
+
* without it every row is treated as live, because there is nothing to read.
|
|
68
|
+
*/
|
|
69
|
+
hasConfirmUpload: boolean;
|
|
64
70
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
65
71
|
maxBulkFiles: number;
|
|
66
72
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|