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
package/plugin.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
9
9
|
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
10
10
|
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
11
|
+
* Pairs are discovered from the registry's FK relations (see
|
|
12
|
+
* graphile-storage-registry) — table naming carries no meaning here.
|
|
11
13
|
*
|
|
12
14
|
* 2. Delete middleware — wraps `delete*` mutations on `@storageFiles`-tagged tables
|
|
13
15
|
* with S3 object cleanup (sync + async GC fallback via AFTER DELETE trigger).
|
|
@@ -23,8 +25,11 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
25
|
require("graphile-build");
|
|
24
26
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
27
|
const grafast_1 = require("grafast");
|
|
28
|
+
const graphile_storage_registry_1 = require("graphile-storage-registry");
|
|
26
29
|
const mime_bytes_1 = require("mime-bytes");
|
|
30
|
+
const custom_key_1 = require("./custom-key");
|
|
27
31
|
const default_bucket_1 = require("./default-bucket");
|
|
32
|
+
const file_lifecycle_1 = require("./file-lifecycle");
|
|
28
33
|
const managed_upload_1 = require("./managed-upload");
|
|
29
34
|
const physical_bucket_1 = require("./physical-bucket");
|
|
30
35
|
const request_pg_client_1 = require("./request-pg-client");
|
|
@@ -34,9 +39,7 @@ const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
|
34
39
|
// --- Protocol-level constants (not configurable) ---
|
|
35
40
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
36
41
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
37
|
-
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
38
42
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
39
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
40
43
|
// --- Helpers ---
|
|
41
44
|
function isValidSha256(hash) {
|
|
42
45
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -44,24 +47,6 @@ function isValidSha256(hash) {
|
|
|
44
47
|
function buildS3Key(contentHash) {
|
|
45
48
|
return contentHash;
|
|
46
49
|
}
|
|
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
50
|
function derivePathFromKey(key) {
|
|
66
51
|
const lastSlash = key.lastIndexOf('/');
|
|
67
52
|
if (lastSlash <= 0)
|
|
@@ -115,38 +100,25 @@ function createPresignedUrlPlugin(options) {
|
|
|
115
100
|
if (!jsonType) {
|
|
116
101
|
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
117
102
|
}
|
|
118
|
-
|
|
119
|
-
|
|
103
|
+
// Each @storageFiles table is paired with its @storageBuckets table
|
|
104
|
+
// through the registry's actual FK relation; a tagged table that cannot
|
|
105
|
+
// be paired is a provisioning bug and throws at schema build.
|
|
106
|
+
const planes = (0, graphile_storage_registry_1.discoverStoragePlanes)(build.input.pgRegistry);
|
|
107
|
+
if (planes.length === 0)
|
|
120
108
|
return fields;
|
|
121
109
|
const newFields = {};
|
|
122
110
|
// --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// Instead, derive the prefix from the raw SQL table name:
|
|
130
|
-
// "data_room_files" → prefix "data_room" → matches "data_room_buckets"
|
|
131
|
-
// "app_files" → prefix "app" → matches "app_buckets"
|
|
132
|
-
const filesRawName = filesCodec.extensions?.pg?.name;
|
|
133
|
-
const filesPrefix = filesRawName?.replace(/_files$/, '');
|
|
134
|
-
const matchingBucketCodec = bucketCodecs.find((bc) => {
|
|
135
|
-
const bucketRawName = bc.extensions?.pg?.name;
|
|
136
|
-
const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
|
|
137
|
-
return bucketPrefix === filesPrefix;
|
|
138
|
-
});
|
|
139
|
-
if (!matchingBucketCodec) {
|
|
140
|
-
log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
144
|
-
const mutationName = `upload${filesTypeName}`;
|
|
111
|
+
for (const plane of planes) {
|
|
112
|
+
const filesCodec = plane.filesCodec;
|
|
113
|
+
const matchingBucketCodec = plane.bucketsCodec;
|
|
114
|
+
const names = (0, graphile_storage_registry_1.uploadSurfaceNames)(build.inflection, plane.filesCodec);
|
|
115
|
+
const { filesTypeName, uploadMutation: mutationName } = names;
|
|
116
|
+
const hasOwnerId = plane.hasOwnerId;
|
|
145
117
|
const ownerIdGqlType = hasOwnerId
|
|
146
118
|
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
147
119
|
: null;
|
|
148
120
|
const InputType = new GraphQLInputObjectType({
|
|
149
|
-
name:
|
|
121
|
+
name: names.uploadInputType,
|
|
150
122
|
fields: {
|
|
151
123
|
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
152
124
|
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted: the public one (true) or the private one (default false). Ignored when bucketKey is given.' },
|
|
@@ -161,7 +133,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
161
133
|
},
|
|
162
134
|
});
|
|
163
135
|
const PayloadType = new GraphQLObjectType({
|
|
164
|
-
name:
|
|
136
|
+
name: names.uploadPayloadType,
|
|
165
137
|
fields: {
|
|
166
138
|
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
167
139
|
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
@@ -249,7 +221,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
249
221
|
});
|
|
250
222
|
// --- Bulk file upload mutation ---
|
|
251
223
|
const BulkFileInputType = new GraphQLInputObjectType({
|
|
252
|
-
name:
|
|
224
|
+
name: names.bulkUploadFileInputType,
|
|
253
225
|
fields: {
|
|
254
226
|
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
255
227
|
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
@@ -259,7 +231,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
259
231
|
},
|
|
260
232
|
});
|
|
261
233
|
const BulkFilePayloadType = new GraphQLObjectType({
|
|
262
|
-
name:
|
|
234
|
+
name: names.bulkUploadFilePayloadType,
|
|
263
235
|
fields: {
|
|
264
236
|
uploadUrl: { type: GraphQLString },
|
|
265
237
|
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
@@ -271,7 +243,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
271
243
|
},
|
|
272
244
|
});
|
|
273
245
|
const BulkInputType = new GraphQLInputObjectType({
|
|
274
|
-
name:
|
|
246
|
+
name: names.bulkUploadInputType,
|
|
275
247
|
fields: {
|
|
276
248
|
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
277
249
|
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
@@ -282,12 +254,12 @@ function createPresignedUrlPlugin(options) {
|
|
|
282
254
|
},
|
|
283
255
|
});
|
|
284
256
|
const BulkPayloadType = new GraphQLObjectType({
|
|
285
|
-
name:
|
|
257
|
+
name: names.bulkUploadPayloadType,
|
|
286
258
|
fields: {
|
|
287
259
|
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
288
260
|
},
|
|
289
261
|
});
|
|
290
|
-
const bulkMutationName =
|
|
262
|
+
const bulkMutationName = names.bulkUploadMutation;
|
|
291
263
|
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
292
264
|
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
293
265
|
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
@@ -552,7 +524,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
552
524
|
if (!bucket.allow_custom_keys) {
|
|
553
525
|
throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys');
|
|
554
526
|
}
|
|
555
|
-
const keyError = validateCustomKey(customKey);
|
|
527
|
+
const keyError = (0, custom_key_1.validateCustomKey)(customKey);
|
|
556
528
|
if (keyError) {
|
|
557
529
|
throw new Error(keyError);
|
|
558
530
|
}
|
|
@@ -564,9 +536,20 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
564
536
|
}
|
|
565
537
|
// Dedup / versioning check
|
|
566
538
|
let previousVersionId = null;
|
|
539
|
+
// A row whose bytes never landed must not be reported as a dedup hit: the
|
|
540
|
+
// caller would store a reference to an object that is not in S3. Such a row is
|
|
541
|
+
// dropped instead, and the upload proceeds as a fresh one below — the insert is
|
|
542
|
+
// what enqueues the confirm-upload job, so restarting the lifecycle is the only
|
|
543
|
+
// way the retry can ever leave `requested`. Dropping it is also what keeps the
|
|
544
|
+
// retry insertable at all for a content-addressed key, where the key *is* the
|
|
545
|
+
// hash and a second row would collide on (bucket_id, key). The GC job the
|
|
546
|
+
// delete enqueues re-takes the reference count when it runs (≥5s later), by
|
|
547
|
+
// which point the replacement row exists, so it no-ops.
|
|
548
|
+
const statusColumn = (0, file_lifecycle_1.statusSelectFragment)(storageConfig);
|
|
549
|
+
let staleFileId = null;
|
|
567
550
|
if (isCustomKey) {
|
|
568
551
|
const existingResult = await txClient.query({
|
|
569
|
-
text: `SELECT id, content_hash
|
|
552
|
+
text: `SELECT id, content_hash${statusColumn}
|
|
570
553
|
FROM ${storageConfig.filesQualifiedName}
|
|
571
554
|
WHERE key = $1
|
|
572
555
|
AND bucket_id = $2
|
|
@@ -577,24 +560,30 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
577
560
|
if (existingResult.rows.length > 0) {
|
|
578
561
|
const existing = existingResult.rows[0];
|
|
579
562
|
if (existing.content_hash === contentHash) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
563
|
+
if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existing)) {
|
|
564
|
+
log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
|
|
565
|
+
return {
|
|
566
|
+
uploadUrl: null,
|
|
567
|
+
fileId: existing.id,
|
|
568
|
+
key: s3Key,
|
|
569
|
+
deduplicated: true,
|
|
570
|
+
expiresAt: null,
|
|
571
|
+
previousVersionId: null,
|
|
572
|
+
file: projectFile(existing.id, s3Key),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
staleFileId = existing.id;
|
|
576
|
+
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
previousVersionId = existing.id;
|
|
580
|
+
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
590
581
|
}
|
|
591
|
-
previousVersionId = existing.id;
|
|
592
|
-
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
593
582
|
}
|
|
594
583
|
}
|
|
595
584
|
else {
|
|
596
585
|
const dedupResult = await txClient.query({
|
|
597
|
-
text: `SELECT id
|
|
586
|
+
text: `SELECT id${statusColumn}
|
|
598
587
|
FROM ${storageConfig.filesQualifiedName}
|
|
599
588
|
WHERE content_hash = $1
|
|
600
589
|
AND bucket_id = $2
|
|
@@ -603,22 +592,33 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
603
592
|
});
|
|
604
593
|
if (dedupResult.rows.length > 0) {
|
|
605
594
|
const existingFile = dedupResult.rows[0];
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
595
|
+
if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existingFile)) {
|
|
596
|
+
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
597
|
+
return {
|
|
598
|
+
uploadUrl: null,
|
|
599
|
+
fileId: existingFile.id,
|
|
600
|
+
key: s3Key,
|
|
601
|
+
deduplicated: true,
|
|
602
|
+
expiresAt: null,
|
|
603
|
+
previousVersionId: null,
|
|
604
|
+
file: projectFile(existingFile.id, s3Key),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
staleFileId = existingFile.id;
|
|
608
|
+
log.info(`Restarting upload of hash ${contentHash}: file ${staleFileId} is ${existingFile.status}, so it carries no bytes`);
|
|
616
609
|
}
|
|
617
610
|
}
|
|
611
|
+
if (staleFileId !== null) {
|
|
612
|
+
await txClient.query({
|
|
613
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
614
|
+
values: [staleFileId],
|
|
615
|
+
});
|
|
616
|
+
}
|
|
618
617
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
619
618
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
620
|
-
// Create file record
|
|
621
|
-
|
|
619
|
+
// Create file record. An entity-keyed plane (the module records an entity
|
|
620
|
+
// table) carries owner_id on its rows; app- and database-scope planes do not.
|
|
621
|
+
const hasOwnerColumn = storageConfig.entityTableId !== null;
|
|
622
622
|
const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
|
|
623
623
|
const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
|
|
624
624
|
if (hasOwnerColumn) {
|
|
@@ -1,45 +1,4 @@
|
|
|
1
1
|
import type { BucketConfig, StorageModuleConfig } from './types';
|
|
2
|
-
/**
|
|
3
|
-
* Resolve the app-level storage module config for a database, using the LRU cache.
|
|
4
|
-
*
|
|
5
|
-
* This is the default path when no ownerId is provided. It returns the
|
|
6
|
-
* storage module with scope = 'app' (app-level / database-wide).
|
|
7
|
-
*
|
|
8
|
-
* @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
|
|
9
|
-
* @param databaseId - The metaschema database UUID
|
|
10
|
-
* @returns StorageModuleConfig or null if no storage module is provisioned
|
|
11
|
-
*/
|
|
12
|
-
export declare function getStorageModuleConfig(pgClient: {
|
|
13
|
-
query: (opts: {
|
|
14
|
-
text: string;
|
|
15
|
-
values?: unknown[];
|
|
16
|
-
}) => Promise<{
|
|
17
|
-
rows: unknown[];
|
|
18
|
-
}>;
|
|
19
|
-
}, databaseId: string): Promise<StorageModuleConfig | null>;
|
|
20
|
-
/**
|
|
21
|
-
* Resolve the storage module config for a specific owner entity.
|
|
22
|
-
*
|
|
23
|
-
* When ownerId is provided, this function:
|
|
24
|
-
* 1. Loads ALL storage modules for the database (cached)
|
|
25
|
-
* 2. Finds which entity-scoped module contains the ownerId in its entity table
|
|
26
|
-
* 3. Returns that module's config
|
|
27
|
-
*
|
|
28
|
-
* This is the core of Option C — the ownerId tells us which scope to use.
|
|
29
|
-
*
|
|
30
|
-
* @param pgClient - A pg client from the Graphile context
|
|
31
|
-
* @param databaseId - The metaschema database UUID
|
|
32
|
-
* @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
|
|
33
|
-
* @returns StorageModuleConfig or null if no matching module found
|
|
34
|
-
*/
|
|
35
|
-
export declare function getStorageModuleConfigForOwner(pgClient: {
|
|
36
|
-
query: (opts: {
|
|
37
|
-
text: string;
|
|
38
|
-
values?: unknown[];
|
|
39
|
-
}) => Promise<{
|
|
40
|
-
rows: unknown[];
|
|
41
|
-
}>;
|
|
42
|
-
}, databaseId: string, ownerId: string): Promise<StorageModuleConfig | null>;
|
|
43
2
|
/**
|
|
44
3
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
45
4
|
*
|
package/storage-module-cache.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getStorageModuleConfig = getStorageModuleConfig;
|
|
4
|
-
exports.getStorageModuleConfigForOwner = getStorageModuleConfigForOwner;
|
|
5
3
|
exports.resolveStorageModuleByFileId = resolveStorageModuleByFileId;
|
|
6
4
|
exports.loadAllStorageModules = loadAllStorageModules;
|
|
7
5
|
exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
|
|
@@ -41,50 +39,9 @@ const storageModuleCache = new lru_cache_1.LRUCache({
|
|
|
41
39
|
updateAgeOnGet: true,
|
|
42
40
|
});
|
|
43
41
|
/**
|
|
44
|
-
* SQL query to resolve
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* Filters to app-level (scope = 'app') by default.
|
|
48
|
-
*
|
|
49
|
-
* Requires the multi-scope schema (scope column on storage_module).
|
|
50
|
-
*/
|
|
51
|
-
const APP_STORAGE_MODULE_QUERY = `
|
|
52
|
-
SELECT
|
|
53
|
-
sm.id,
|
|
54
|
-
sm.scope,
|
|
55
|
-
sm.entity_table_id,
|
|
56
|
-
bs.schema_name AS buckets_schema,
|
|
57
|
-
bt.name AS buckets_table,
|
|
58
|
-
fs.schema_name AS files_schema,
|
|
59
|
-
ft.name AS files_table,
|
|
60
|
-
sm.endpoint,
|
|
61
|
-
sm.public_url_prefix,
|
|
62
|
-
sm.provider,
|
|
63
|
-
sm.allowed_origins,
|
|
64
|
-
sm.upload_url_expiry_seconds,
|
|
65
|
-
sm.download_url_expiry_seconds,
|
|
66
|
-
sm.default_max_file_size,
|
|
67
|
-
sm.max_filename_length,
|
|
68
|
-
sm.cache_ttl_seconds,
|
|
69
|
-
sm.max_bulk_files,
|
|
70
|
-
sm.max_bulk_total_size,
|
|
71
|
-
sm.has_path_shares,
|
|
72
|
-
NULL AS entity_schema,
|
|
73
|
-
NULL AS entity_table
|
|
74
|
-
FROM metaschema_modules_public.storage_module sm
|
|
75
|
-
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
|
|
76
|
-
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
77
|
-
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
78
|
-
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
79
|
-
WHERE sm.database_id = $1
|
|
80
|
-
AND sm.scope = 'app'
|
|
81
|
-
LIMIT 1
|
|
82
|
-
`;
|
|
83
|
-
/**
|
|
84
|
-
* SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
|
|
85
|
-
*
|
|
86
|
-
* Returns all storage modules with their entity table names for ownerId resolution.
|
|
87
|
-
* Requires the multi-scope schema.
|
|
42
|
+
* SQL query to resolve ALL storage modules for a database, whatever their
|
|
43
|
+
* scope. Returns each module with its entity table names so callers can
|
|
44
|
+
* classify entity-keyed planes and resolve owners.
|
|
88
45
|
*/
|
|
89
46
|
const ALL_STORAGE_MODULES_QUERY = `
|
|
90
47
|
SELECT
|
|
@@ -107,6 +64,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
107
64
|
sm.max_bulk_files,
|
|
108
65
|
sm.max_bulk_total_size,
|
|
109
66
|
sm.has_path_shares,
|
|
67
|
+
sm.has_confirm_upload,
|
|
110
68
|
es.schema_name AS entity_schema,
|
|
111
69
|
et.name AS entity_table
|
|
112
70
|
FROM metaschema_modules_public.storage_module sm
|
|
@@ -145,99 +103,11 @@ function buildConfig(row) {
|
|
|
145
103
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
146
104
|
cacheTtlSeconds,
|
|
147
105
|
hasPathShares: row.has_path_shares ?? false,
|
|
106
|
+
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
148
107
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
149
108
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
|
150
109
|
};
|
|
151
110
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Resolve the app-level storage module config for a database, using the LRU cache.
|
|
154
|
-
*
|
|
155
|
-
* This is the default path when no ownerId is provided. It returns the
|
|
156
|
-
* storage module with scope = 'app' (app-level / database-wide).
|
|
157
|
-
*
|
|
158
|
-
* @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
|
|
159
|
-
* @param databaseId - The metaschema database UUID
|
|
160
|
-
* @returns StorageModuleConfig or null if no storage module is provisioned
|
|
161
|
-
*/
|
|
162
|
-
async function getStorageModuleConfig(pgClient, databaseId) {
|
|
163
|
-
const cacheKey = `storage:${databaseId}:app`;
|
|
164
|
-
const cached = storageModuleCache.get(cacheKey);
|
|
165
|
-
if (cached) {
|
|
166
|
-
return cached;
|
|
167
|
-
}
|
|
168
|
-
log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
|
|
169
|
-
const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
|
|
170
|
-
if (result.rows.length === 0) {
|
|
171
|
-
log.warn(`No app-level storage module found for database ${databaseId}`);
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
const config = buildConfig(result.rows[0]);
|
|
175
|
-
storageModuleCache.set(cacheKey, config);
|
|
176
|
-
log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
|
|
177
|
-
return config;
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Resolve the storage module config for a specific owner entity.
|
|
181
|
-
*
|
|
182
|
-
* When ownerId is provided, this function:
|
|
183
|
-
* 1. Loads ALL storage modules for the database (cached)
|
|
184
|
-
* 2. Finds which entity-scoped module contains the ownerId in its entity table
|
|
185
|
-
* 3. Returns that module's config
|
|
186
|
-
*
|
|
187
|
-
* This is the core of Option C — the ownerId tells us which scope to use.
|
|
188
|
-
*
|
|
189
|
-
* @param pgClient - A pg client from the Graphile context
|
|
190
|
-
* @param databaseId - The metaschema database UUID
|
|
191
|
-
* @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
|
|
192
|
-
* @returns StorageModuleConfig or null if no matching module found
|
|
193
|
-
*/
|
|
194
|
-
async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
|
|
195
|
-
// Check if we already have a cached mapping for this ownerId
|
|
196
|
-
const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
|
|
197
|
-
const cachedOwner = storageModuleCache.get(ownerCacheKey);
|
|
198
|
-
if (cachedOwner) {
|
|
199
|
-
return cachedOwner;
|
|
200
|
-
}
|
|
201
|
-
// Load all storage modules for this database
|
|
202
|
-
const allModulesCacheKey = `storage:${databaseId}:all`;
|
|
203
|
-
let allConfigs;
|
|
204
|
-
const cachedAll = storageModuleCache.get(allModulesCacheKey);
|
|
205
|
-
if (cachedAll) {
|
|
206
|
-
// We stored a sentinel; re-derive from individual caches
|
|
207
|
-
// Actually, let's just query fresh — this is the cache-miss path
|
|
208
|
-
allConfigs = [];
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
allConfigs = [];
|
|
212
|
-
}
|
|
213
|
-
if (allConfigs.length === 0) {
|
|
214
|
-
log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
|
|
215
|
-
const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
|
|
216
|
-
allConfigs = result.rows.map(buildConfig);
|
|
217
|
-
// Cache each individual config by its scope
|
|
218
|
-
for (const config of allConfigs) {
|
|
219
|
-
const key = `storage:${databaseId}:scope:${config.scope}`;
|
|
220
|
-
storageModuleCache.set(key, config);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
// Find entity-scoped modules and probe their entity tables for the ownerId
|
|
224
|
-
const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
|
|
225
|
-
for (const mod of entityModules) {
|
|
226
|
-
const probeResult = await pgClient.query({
|
|
227
|
-
text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
228
|
-
values: [ownerId],
|
|
229
|
-
});
|
|
230
|
-
if (probeResult.rows.length > 0) {
|
|
231
|
-
// Found the matching module — cache the ownerId→module mapping
|
|
232
|
-
storageModuleCache.set(ownerCacheKey, mod);
|
|
233
|
-
log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
|
|
234
|
-
`(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
|
|
235
|
-
return mod;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
111
|
/**
|
|
242
112
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
243
113
|
*
|
|
@@ -283,11 +153,6 @@ async function loadAllStorageModules(pgClient, databaseId) {
|
|
|
283
153
|
log.debug(`Loading all storage modules for database ${databaseId}`);
|
|
284
154
|
const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
|
|
285
155
|
const configs = result.rows.map(buildConfig);
|
|
286
|
-
// Cache each individual config by its scope
|
|
287
|
-
for (const config of configs) {
|
|
288
|
-
const key = `storage:${databaseId}:scope:${config.scope}`;
|
|
289
|
-
storageModuleCache.set(key, config);
|
|
290
|
-
}
|
|
291
156
|
// Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
|
|
292
157
|
if (configs.length > 0) {
|
|
293
158
|
const sentinel = { ...configs[0], _allConfigs: configs };
|
|
@@ -364,9 +229,9 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
|
|
|
364
229
|
return cached;
|
|
365
230
|
}
|
|
366
231
|
log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
|
|
367
|
-
// Entity-
|
|
368
|
-
// app-
|
|
369
|
-
const isEntityScoped = storageConfig.
|
|
232
|
+
// Entity-keyed planes (the module records an entity table) use the
|
|
233
|
+
// (owner_id, key) composite lookup; app- and database-scope planes just use key.
|
|
234
|
+
const isEntityScoped = storageConfig.entityTableId !== null;
|
|
370
235
|
const hasOwner = ownerId && isEntityScoped;
|
|
371
236
|
const result = await pgClient.query({
|
|
372
237
|
text: hasOwner
|
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) */
|