graphile-presigned-url-plugin 1.20.0 → 1.21.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/esm/plugin.d.ts +11 -1
- package/esm/plugin.js +24 -4
- package/esm/storage-module-cache.js +2 -0
- package/esm/types.d.ts +5 -0
- package/package.json +3 -3
- package/plugin.d.ts +11 -1
- package/plugin.js +24 -3
- package/storage-module-cache.js +2 -0
- package/types.d.ts +5 -0
package/esm/plugin.d.ts
CHANGED
|
@@ -20,7 +20,17 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import 'graphile-build';
|
|
22
22
|
import type { GraphileConfig } from 'graphile-config';
|
|
23
|
-
import type
|
|
23
|
+
import { type FileProjection } from './managed-upload';
|
|
24
|
+
import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
|
|
24
25
|
export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
26
|
+
export declare function processSingleFile(options: PresignedUrlPluginOptions, txClient: any, storageConfig: StorageModuleConfig, databaseId: string, bucket: BucketConfig, s3ForDb: S3Config, input: any): Promise<{
|
|
27
|
+
uploadUrl: string | null;
|
|
28
|
+
fileId: string;
|
|
29
|
+
key: string;
|
|
30
|
+
deduplicated: boolean;
|
|
31
|
+
expiresAt: string | null;
|
|
32
|
+
previousVersionId: string | null;
|
|
33
|
+
file: FileProjection;
|
|
34
|
+
}>;
|
|
25
35
|
export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
|
|
26
36
|
export default PresignedUrlPlugin;
|
package/esm/plugin.js
CHANGED
|
@@ -458,7 +458,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
458
458
|
};
|
|
459
459
|
}
|
|
460
460
|
// --- Shared upload logic ---
|
|
461
|
-
async function processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input) {
|
|
461
|
+
export async function processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input) {
|
|
462
462
|
const { contentHash, contentType, size, filename, key: customKey } = input;
|
|
463
463
|
if (!contentHash || typeof contentHash !== 'string' || contentHash.length > MAX_CONTENT_HASH_LENGTH) {
|
|
464
464
|
throw new Error('INVALID_CONTENT_HASH');
|
|
@@ -570,10 +570,24 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
570
570
|
staleFileId = existing.id;
|
|
571
571
|
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
572
572
|
}
|
|
573
|
-
else {
|
|
573
|
+
else if (storageConfig.hasVersioning) {
|
|
574
574
|
previousVersionId = existing.id;
|
|
575
575
|
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
576
576
|
}
|
|
577
|
+
else if (storageConfig.hasContentHash) {
|
|
578
|
+
// GC of the deleted row counts references by its content hash, which the
|
|
579
|
+
// replacement does not share, so it would delete the object at this key
|
|
580
|
+
// after the new bytes land.
|
|
581
|
+
throw new Error(`STORAGE_REPLACE_UNSUPPORTED: key ${s3Key} already holds file ${existing.id}; ` +
|
|
582
|
+
'a content-addressed module needs versioning to replace a custom key');
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
// Without versioning a key names one row: the new bytes replace the old
|
|
586
|
+
// row in this transaction, and the PUT overwrites the object at the key.
|
|
587
|
+
// GC of the old row counts references by key, so the replacement keeps it.
|
|
588
|
+
staleFileId = existing.id;
|
|
589
|
+
log.info(`Replacing key ${s3Key}: file ${staleFileId} is superseded (module has no versioning)`);
|
|
590
|
+
}
|
|
577
591
|
}
|
|
578
592
|
}
|
|
579
593
|
else {
|
|
@@ -604,10 +618,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
604
618
|
}
|
|
605
619
|
}
|
|
606
620
|
if (staleFileId !== null) {
|
|
607
|
-
|
|
608
|
-
|
|
621
|
+
// The DELETE runs under the request role, so RLS decides whether this caller
|
|
622
|
+
// may drop the row. A row the caller can see but not delete stays, and the
|
|
623
|
+
// upload fails here rather than colliding on (bucket_id, key) below.
|
|
624
|
+
const deleted = await txClient.query({
|
|
625
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1 RETURNING id`,
|
|
609
626
|
values: [staleFileId],
|
|
610
627
|
});
|
|
628
|
+
if (deleted.rows.length === 0) {
|
|
629
|
+
throw new Error(`FILE_NOT_REPLACEABLE: file ${staleFileId} at key ${s3Key} cannot be replaced by this caller`);
|
|
630
|
+
}
|
|
611
631
|
}
|
|
612
632
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
613
633
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
@@ -55,6 +55,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
55
55
|
sm.max_bulk_total_size,
|
|
56
56
|
sm.has_path_shares,
|
|
57
57
|
sm.has_versioning,
|
|
58
|
+
sm.has_content_hash,
|
|
58
59
|
sm.has_confirm_upload,
|
|
59
60
|
es.schema_name AS entity_schema,
|
|
60
61
|
et.name AS entity_table
|
|
@@ -99,6 +100,7 @@ function buildConfig(row) {
|
|
|
99
100
|
cacheTtlSeconds,
|
|
100
101
|
hasPathShares: row.has_path_shares ?? false,
|
|
101
102
|
hasVersioning: row.has_versioning ?? false,
|
|
103
|
+
hasContentHash: row.has_content_hash ?? false,
|
|
102
104
|
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
103
105
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
104
106
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
package/esm/types.d.ts
CHANGED
|
@@ -70,6 +70,11 @@ export interface StorageModuleConfig {
|
|
|
70
70
|
hasConfirmUpload: boolean;
|
|
71
71
|
/** Whether the files table carries the versioning chain. */
|
|
72
72
|
hasVersioning: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the module is content-addressed: GC counts an object's references by
|
|
75
|
+
* `(bucket_id, content_hash)` rather than by `(bucket_id, key)`.
|
|
76
|
+
*/
|
|
77
|
+
hasContentHash: boolean;
|
|
73
78
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
74
79
|
maxBulkFiles: number;
|
|
75
80
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
45
|
"@pgpmjs/logger": "^2.26.0",
|
|
46
46
|
"@pgsql/quotes": "^18.2.4",
|
|
47
|
-
"graphile-plugin-utils": "^1.
|
|
47
|
+
"graphile-plugin-utils": "^1.13.0",
|
|
48
48
|
"graphile-storage-registry": "^0.2.0",
|
|
49
49
|
"lru-cache": "^11.2.7",
|
|
50
50
|
"mime-bytes": "^0.33.0"
|
|
@@ -63,5 +63,5 @@
|
|
|
63
63
|
"@types/node": "^22.19.11",
|
|
64
64
|
"makage": "^0.8.0"
|
|
65
65
|
},
|
|
66
|
-
"gitHead": "
|
|
66
|
+
"gitHead": "ee458dba426522b0d06e315f6ef010938ed71e0e"
|
|
67
67
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -20,7 +20,17 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import 'graphile-build';
|
|
22
22
|
import type { GraphileConfig } from 'graphile-config';
|
|
23
|
-
import type
|
|
23
|
+
import { type FileProjection } from './managed-upload';
|
|
24
|
+
import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
|
|
24
25
|
export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
26
|
+
export declare function processSingleFile(options: PresignedUrlPluginOptions, txClient: any, storageConfig: StorageModuleConfig, databaseId: string, bucket: BucketConfig, s3ForDb: S3Config, input: any): Promise<{
|
|
27
|
+
uploadUrl: string | null;
|
|
28
|
+
fileId: string;
|
|
29
|
+
key: string;
|
|
30
|
+
deduplicated: boolean;
|
|
31
|
+
expiresAt: string | null;
|
|
32
|
+
previousVersionId: string | null;
|
|
33
|
+
file: FileProjection;
|
|
34
|
+
}>;
|
|
25
35
|
export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
|
|
26
36
|
export default PresignedUrlPlugin;
|
package/plugin.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
23
|
exports.PresignedUrlPlugin = void 0;
|
|
24
24
|
exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
25
|
+
exports.processSingleFile = processSingleFile;
|
|
25
26
|
require("graphile-build");
|
|
26
27
|
const logger_1 = require("@pgpmjs/logger");
|
|
27
28
|
const grafast_1 = require("grafast");
|
|
@@ -574,10 +575,24 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
574
575
|
staleFileId = existing.id;
|
|
575
576
|
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
576
577
|
}
|
|
577
|
-
else {
|
|
578
|
+
else if (storageConfig.hasVersioning) {
|
|
578
579
|
previousVersionId = existing.id;
|
|
579
580
|
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
580
581
|
}
|
|
582
|
+
else if (storageConfig.hasContentHash) {
|
|
583
|
+
// GC of the deleted row counts references by its content hash, which the
|
|
584
|
+
// replacement does not share, so it would delete the object at this key
|
|
585
|
+
// after the new bytes land.
|
|
586
|
+
throw new Error(`STORAGE_REPLACE_UNSUPPORTED: key ${s3Key} already holds file ${existing.id}; ` +
|
|
587
|
+
'a content-addressed module needs versioning to replace a custom key');
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
// Without versioning a key names one row: the new bytes replace the old
|
|
591
|
+
// row in this transaction, and the PUT overwrites the object at the key.
|
|
592
|
+
// GC of the old row counts references by key, so the replacement keeps it.
|
|
593
|
+
staleFileId = existing.id;
|
|
594
|
+
log.info(`Replacing key ${s3Key}: file ${staleFileId} is superseded (module has no versioning)`);
|
|
595
|
+
}
|
|
581
596
|
}
|
|
582
597
|
}
|
|
583
598
|
else {
|
|
@@ -608,10 +623,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
608
623
|
}
|
|
609
624
|
}
|
|
610
625
|
if (staleFileId !== null) {
|
|
611
|
-
|
|
612
|
-
|
|
626
|
+
// The DELETE runs under the request role, so RLS decides whether this caller
|
|
627
|
+
// may drop the row. A row the caller can see but not delete stays, and the
|
|
628
|
+
// upload fails here rather than colliding on (bucket_id, key) below.
|
|
629
|
+
const deleted = await txClient.query({
|
|
630
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1 RETURNING id`,
|
|
613
631
|
values: [staleFileId],
|
|
614
632
|
});
|
|
633
|
+
if (deleted.rows.length === 0) {
|
|
634
|
+
throw new Error(`FILE_NOT_REPLACEABLE: file ${staleFileId} at key ${s3Key} cannot be replaced by this caller`);
|
|
635
|
+
}
|
|
615
636
|
}
|
|
616
637
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
617
638
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
package/storage-module-cache.js
CHANGED
|
@@ -64,6 +64,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
64
64
|
sm.max_bulk_total_size,
|
|
65
65
|
sm.has_path_shares,
|
|
66
66
|
sm.has_versioning,
|
|
67
|
+
sm.has_content_hash,
|
|
67
68
|
sm.has_confirm_upload,
|
|
68
69
|
es.schema_name AS entity_schema,
|
|
69
70
|
et.name AS entity_table
|
|
@@ -108,6 +109,7 @@ function buildConfig(row) {
|
|
|
108
109
|
cacheTtlSeconds,
|
|
109
110
|
hasPathShares: row.has_path_shares ?? false,
|
|
110
111
|
hasVersioning: row.has_versioning ?? false,
|
|
112
|
+
hasContentHash: row.has_content_hash ?? false,
|
|
111
113
|
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
112
114
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
113
115
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
package/types.d.ts
CHANGED
|
@@ -70,6 +70,11 @@ export interface StorageModuleConfig {
|
|
|
70
70
|
hasConfirmUpload: boolean;
|
|
71
71
|
/** Whether the files table carries the versioning chain. */
|
|
72
72
|
hasVersioning: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the module is content-addressed: GC counts an object's references by
|
|
75
|
+
* `(bucket_id, content_hash)` rather than by `(bucket_id, key)`.
|
|
76
|
+
*/
|
|
77
|
+
hasContentHash: boolean;
|
|
73
78
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
74
79
|
maxBulkFiles: number;
|
|
75
80
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|