graphile-presigned-url-plugin 1.14.0 → 1.15.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/custom-key.d.ts +11 -0
- package/custom-key.js +38 -0
- package/download-url-field.js +2 -1
- package/esm/custom-key.d.ts +11 -0
- package/esm/custom-key.js +35 -0
- package/esm/download-url-field.js +2 -1
- package/esm/file-lifecycle.d.ts +20 -0
- package/esm/file-lifecycle.js +23 -0
- package/esm/index.d.ts +2 -1
- package/esm/index.js +2 -1
- package/esm/managed-upload.js +20 -4
- package/esm/plugin.d.ts +2 -0
- package/esm/plugin.js +77 -77
- package/esm/storage-module-cache.d.ts +0 -41
- package/esm/storage-module-cache.js +8 -141
- package/esm/types.d.ts +6 -0
- package/file-lifecycle.d.ts +20 -0
- package/file-lifecycle.js +28 -0
- package/index.d.ts +2 -1
- package/index.js +3 -3
- package/managed-upload.js +20 -4
- package/package.json +3 -2
- package/plugin.d.ts +2 -0
- package/plugin.js +78 -78
- package/storage-module-cache.d.ts +0 -41
- package/storage-module-cache.js +8 -143
- package/types.d.ts +6 -0
|
@@ -28,50 +28,9 @@ const storageModuleCache = new LRUCache({
|
|
|
28
28
|
updateAgeOnGet: true,
|
|
29
29
|
});
|
|
30
30
|
/**
|
|
31
|
-
* SQL query to resolve
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Filters to app-level (scope = 'app') by default.
|
|
35
|
-
*
|
|
36
|
-
* Requires the multi-scope schema (scope column on storage_module).
|
|
37
|
-
*/
|
|
38
|
-
const APP_STORAGE_MODULE_QUERY = `
|
|
39
|
-
SELECT
|
|
40
|
-
sm.id,
|
|
41
|
-
sm.scope,
|
|
42
|
-
sm.entity_table_id,
|
|
43
|
-
bs.schema_name AS buckets_schema,
|
|
44
|
-
bt.name AS buckets_table,
|
|
45
|
-
fs.schema_name AS files_schema,
|
|
46
|
-
ft.name AS files_table,
|
|
47
|
-
sm.endpoint,
|
|
48
|
-
sm.public_url_prefix,
|
|
49
|
-
sm.provider,
|
|
50
|
-
sm.allowed_origins,
|
|
51
|
-
sm.upload_url_expiry_seconds,
|
|
52
|
-
sm.download_url_expiry_seconds,
|
|
53
|
-
sm.default_max_file_size,
|
|
54
|
-
sm.max_filename_length,
|
|
55
|
-
sm.cache_ttl_seconds,
|
|
56
|
-
sm.max_bulk_files,
|
|
57
|
-
sm.max_bulk_total_size,
|
|
58
|
-
sm.has_path_shares,
|
|
59
|
-
NULL AS entity_schema,
|
|
60
|
-
NULL AS entity_table
|
|
61
|
-
FROM metaschema_modules_public.storage_module sm
|
|
62
|
-
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
|
|
63
|
-
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
64
|
-
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
65
|
-
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
66
|
-
WHERE sm.database_id = $1
|
|
67
|
-
AND sm.scope = 'app'
|
|
68
|
-
LIMIT 1
|
|
69
|
-
`;
|
|
70
|
-
/**
|
|
71
|
-
* SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
|
|
72
|
-
*
|
|
73
|
-
* Returns all storage modules with their entity table names for ownerId resolution.
|
|
74
|
-
* Requires the multi-scope schema.
|
|
31
|
+
* SQL query to resolve ALL storage modules for a database, whatever their
|
|
32
|
+
* scope. Returns each module with its entity table names so callers can
|
|
33
|
+
* classify entity-keyed planes and resolve owners.
|
|
75
34
|
*/
|
|
76
35
|
const ALL_STORAGE_MODULES_QUERY = `
|
|
77
36
|
SELECT
|
|
@@ -94,6 +53,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
94
53
|
sm.max_bulk_files,
|
|
95
54
|
sm.max_bulk_total_size,
|
|
96
55
|
sm.has_path_shares,
|
|
56
|
+
sm.has_confirm_upload,
|
|
97
57
|
es.schema_name AS entity_schema,
|
|
98
58
|
et.name AS entity_table
|
|
99
59
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -132,99 +92,11 @@ function buildConfig(row) {
|
|
|
132
92
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
133
93
|
cacheTtlSeconds,
|
|
134
94
|
hasPathShares: row.has_path_shares ?? false,
|
|
95
|
+
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
135
96
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
136
97
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
|
137
98
|
};
|
|
138
99
|
}
|
|
139
|
-
/**
|
|
140
|
-
* Resolve the app-level storage module config for a database, using the LRU cache.
|
|
141
|
-
*
|
|
142
|
-
* This is the default path when no ownerId is provided. It returns the
|
|
143
|
-
* storage module with scope = 'app' (app-level / database-wide).
|
|
144
|
-
*
|
|
145
|
-
* @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
|
|
146
|
-
* @param databaseId - The metaschema database UUID
|
|
147
|
-
* @returns StorageModuleConfig or null if no storage module is provisioned
|
|
148
|
-
*/
|
|
149
|
-
export async function getStorageModuleConfig(pgClient, databaseId) {
|
|
150
|
-
const cacheKey = `storage:${databaseId}:app`;
|
|
151
|
-
const cached = storageModuleCache.get(cacheKey);
|
|
152
|
-
if (cached) {
|
|
153
|
-
return cached;
|
|
154
|
-
}
|
|
155
|
-
log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
|
|
156
|
-
const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
|
|
157
|
-
if (result.rows.length === 0) {
|
|
158
|
-
log.warn(`No app-level storage module found for database ${databaseId}`);
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
const config = buildConfig(result.rows[0]);
|
|
162
|
-
storageModuleCache.set(cacheKey, config);
|
|
163
|
-
log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
|
|
164
|
-
return config;
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Resolve the storage module config for a specific owner entity.
|
|
168
|
-
*
|
|
169
|
-
* When ownerId is provided, this function:
|
|
170
|
-
* 1. Loads ALL storage modules for the database (cached)
|
|
171
|
-
* 2. Finds which entity-scoped module contains the ownerId in its entity table
|
|
172
|
-
* 3. Returns that module's config
|
|
173
|
-
*
|
|
174
|
-
* This is the core of Option C — the ownerId tells us which scope to use.
|
|
175
|
-
*
|
|
176
|
-
* @param pgClient - A pg client from the Graphile context
|
|
177
|
-
* @param databaseId - The metaschema database UUID
|
|
178
|
-
* @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
|
|
179
|
-
* @returns StorageModuleConfig or null if no matching module found
|
|
180
|
-
*/
|
|
181
|
-
export async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
|
|
182
|
-
// Check if we already have a cached mapping for this ownerId
|
|
183
|
-
const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
|
|
184
|
-
const cachedOwner = storageModuleCache.get(ownerCacheKey);
|
|
185
|
-
if (cachedOwner) {
|
|
186
|
-
return cachedOwner;
|
|
187
|
-
}
|
|
188
|
-
// Load all storage modules for this database
|
|
189
|
-
const allModulesCacheKey = `storage:${databaseId}:all`;
|
|
190
|
-
let allConfigs;
|
|
191
|
-
const cachedAll = storageModuleCache.get(allModulesCacheKey);
|
|
192
|
-
if (cachedAll) {
|
|
193
|
-
// We stored a sentinel; re-derive from individual caches
|
|
194
|
-
// Actually, let's just query fresh — this is the cache-miss path
|
|
195
|
-
allConfigs = [];
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
allConfigs = [];
|
|
199
|
-
}
|
|
200
|
-
if (allConfigs.length === 0) {
|
|
201
|
-
log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
|
|
202
|
-
const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
|
|
203
|
-
allConfigs = result.rows.map(buildConfig);
|
|
204
|
-
// Cache each individual config by its scope
|
|
205
|
-
for (const config of allConfigs) {
|
|
206
|
-
const key = `storage:${databaseId}:scope:${config.scope}`;
|
|
207
|
-
storageModuleCache.set(key, config);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
// Find entity-scoped modules and probe their entity tables for the ownerId
|
|
211
|
-
const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
|
|
212
|
-
for (const mod of entityModules) {
|
|
213
|
-
const probeResult = await pgClient.query({
|
|
214
|
-
text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
215
|
-
values: [ownerId],
|
|
216
|
-
});
|
|
217
|
-
if (probeResult.rows.length > 0) {
|
|
218
|
-
// Found the matching module — cache the ownerId→module mapping
|
|
219
|
-
storageModuleCache.set(ownerCacheKey, mod);
|
|
220
|
-
log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
|
|
221
|
-
`(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
|
|
222
|
-
return mod;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
100
|
/**
|
|
229
101
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
230
102
|
*
|
|
@@ -270,11 +142,6 @@ export async function loadAllStorageModules(pgClient, databaseId) {
|
|
|
270
142
|
log.debug(`Loading all storage modules for database ${databaseId}`);
|
|
271
143
|
const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
|
|
272
144
|
const configs = result.rows.map(buildConfig);
|
|
273
|
-
// Cache each individual config by its scope
|
|
274
|
-
for (const config of configs) {
|
|
275
|
-
const key = `storage:${databaseId}:scope:${config.scope}`;
|
|
276
|
-
storageModuleCache.set(key, config);
|
|
277
|
-
}
|
|
278
145
|
// Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
|
|
279
146
|
if (configs.length > 0) {
|
|
280
147
|
const sentinel = { ...configs[0], _allConfigs: configs };
|
|
@@ -351,9 +218,9 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
351
218
|
return cached;
|
|
352
219
|
}
|
|
353
220
|
log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
|
|
354
|
-
// Entity-
|
|
355
|
-
// app-
|
|
356
|
-
const isEntityScoped = storageConfig.
|
|
221
|
+
// Entity-keyed planes (the module records an entity table) use the
|
|
222
|
+
// (owner_id, key) composite lookup; app- and database-scope planes just use key.
|
|
223
|
+
const isEntityScoped = storageConfig.entityTableId !== null;
|
|
357
224
|
const hasOwner = ownerId && isEntityScoped;
|
|
358
225
|
const result = await pgClient.query({
|
|
359
226
|
text: hasOwner
|
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';
|
|
@@ -38,5 +39,5 @@ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
|
38
39
|
export { PresignedUrlPreset } from './preset';
|
|
39
40
|
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
40
41
|
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
41
|
-
export { clearBucketCache, clearStorageModuleCache, getBucketConfig,
|
|
42
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
42
43
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
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.
|
|
31
|
+
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = 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");
|
|
@@ -68,8 +70,6 @@ var storage_module_cache_1 = require("./storage-module-cache");
|
|
|
68
70
|
Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
|
|
69
71
|
Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
|
|
70
72
|
Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
|
|
71
|
-
Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
|
|
72
|
-
Object.defineProperty(exports, "getStorageModuleConfigForOwner", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfigForOwner; } });
|
|
73
73
|
Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
|
|
74
74
|
Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
|
|
75
75
|
Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
|
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");
|
|
@@ -95,8 +96,8 @@ async function resolveManagedUploadTarget(args) {
|
|
|
95
96
|
`unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
|
|
96
97
|
'default to; there is no environment bucket to fall back to');
|
|
97
98
|
}
|
|
98
|
-
if (storageConfig.
|
|
99
|
-
// An entity-
|
|
99
|
+
if (storageConfig.entityTableId !== null) {
|
|
100
|
+
// An entity-keyed module resolves its bucket per owning row, and a
|
|
100
101
|
// multipart column write does not carry one. Refuse rather than write a
|
|
101
102
|
// tenant's file into whichever bucket happened to resolve.
|
|
102
103
|
throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
|
|
@@ -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.15.0",
|
|
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,6 +44,7 @@
|
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
45
|
"@pgpmjs/logger": "^2.25.0",
|
|
46
46
|
"@pgsql/quotes": "^18.2.4",
|
|
47
|
+
"graphile-storage-registry": "^0.1.0",
|
|
47
48
|
"lru-cache": "^11.2.7",
|
|
48
49
|
"mime-bytes": "^0.32.0"
|
|
49
50
|
},
|
|
@@ -61,5 +62,5 @@
|
|
|
61
62
|
"@types/node": "^22.19.11",
|
|
62
63
|
"makage": "^0.3.0"
|
|
63
64
|
},
|
|
64
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
|
|
65
66
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
8
8
|
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
9
9
|
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
10
|
+
* Pairs are discovered from the registry's FK relations (see
|
|
11
|
+
* graphile-storage-registry) — table naming carries no meaning here.
|
|
10
12
|
*
|
|
11
13
|
* 2. Delete middleware — wraps `delete*` mutations on `@storageFiles`-tagged tables
|
|
12
14
|
* with S3 object cleanup (sync + async GC fallback via AFTER DELETE trigger).
|