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/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
|
+
}
|
package/download-url-field.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
|
|
|
24
24
|
require("graphile-build");
|
|
25
25
|
const logger_1 = require("@pgpmjs/logger");
|
|
26
26
|
const grafast_1 = require("grafast");
|
|
27
|
+
const graphile_storage_registry_1 = require("graphile-storage-registry");
|
|
27
28
|
const request_pg_client_1 = require("./request-pg-client");
|
|
28
29
|
const s3_signer_1 = require("./s3-signer");
|
|
29
30
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
@@ -90,7 +91,7 @@ function createDownloadUrlPlugin(options) {
|
|
|
90
91
|
const { graphql: { GraphQLString }, } = build;
|
|
91
92
|
const capturedCodec = pgCodec;
|
|
92
93
|
return build.extend(fields, {
|
|
93
|
-
|
|
94
|
+
[graphile_storage_registry_1.DOWNLOAD_URL_FIELD]: context.fieldWithHooks({ fieldName: graphile_storage_registry_1.DOWNLOAD_URL_FIELD }, {
|
|
94
95
|
description: 'URL to download this file. For public files, returns the public URL. ' +
|
|
95
96
|
'For private files, returns a time-limited presigned URL.',
|
|
96
97
|
type: GraphQLString,
|
|
@@ -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
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import 'graphile-build';
|
|
22
22
|
import { Logger } from '@pgpmjs/logger';
|
|
23
23
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
24
|
+
import { DOWNLOAD_URL_FIELD } from 'graphile-storage-registry';
|
|
24
25
|
import { withRequestPgClient } from './request-pg-client';
|
|
25
26
|
import { generatePresignedGetUrl } from './s3-signer';
|
|
26
27
|
import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
@@ -87,7 +88,7 @@ export function createDownloadUrlPlugin(options) {
|
|
|
87
88
|
const { graphql: { GraphQLString }, } = build;
|
|
88
89
|
const capturedCodec = pgCodec;
|
|
89
90
|
return build.extend(fields, {
|
|
90
|
-
|
|
91
|
+
[DOWNLOAD_URL_FIELD]: context.fieldWithHooks({ fieldName: DOWNLOAD_URL_FIELD }, {
|
|
91
92
|
description: 'URL to download this file. For public files, returns the public URL. ' +
|
|
92
93
|
'For private files, returns a time-limited presigned URL.',
|
|
93
94
|
type: GraphQLString,
|
|
@@ -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';
|
|
@@ -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/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';
|
|
@@ -36,4 +37,4 @@ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
|
36
37
|
export { PresignedUrlPreset } from './preset';
|
|
37
38
|
export { withRequestPgClient } from './request-pg-client';
|
|
38
39
|
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
39
|
-
export { clearBucketCache, clearStorageModuleCache, getBucketConfig,
|
|
40
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
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';
|
|
@@ -89,8 +90,8 @@ export async function resolveManagedUploadTarget(args) {
|
|
|
89
90
|
`unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
|
|
90
91
|
'default to; there is no environment bucket to fall back to');
|
|
91
92
|
}
|
|
92
|
-
if (storageConfig.
|
|
93
|
-
// An entity-
|
|
93
|
+
if (storageConfig.entityTableId !== null) {
|
|
94
|
+
// An entity-keyed module resolves its bucket per owning row, and a
|
|
94
95
|
// multipart column write does not carry one. Refuse rather than write a
|
|
95
96
|
// tenant's file into whichever bucket happened to resolve.
|
|
96
97
|
throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
|
|
@@ -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.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).
|
package/esm/plugin.js
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).
|
|
@@ -19,8 +21,11 @@
|
|
|
19
21
|
import 'graphile-build';
|
|
20
22
|
import { Logger } from '@pgpmjs/logger';
|
|
21
23
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
24
|
+
import { discoverStoragePlanes, uploadSurfaceNames } from 'graphile-storage-registry';
|
|
22
25
|
import { checkTypeAgreement } from 'mime-bytes';
|
|
26
|
+
import { validateCustomKey } from './custom-key';
|
|
23
27
|
import { resolveDefaultBucket } from './default-bucket';
|
|
28
|
+
import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
|
|
24
29
|
import { buildFileProjection } from './managed-upload';
|
|
25
30
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
26
31
|
import { withRequestPgClient } from './request-pg-client';
|
|
@@ -30,9 +35,7 @@ const log = new Logger('graphile-presigned-url:plugin');
|
|
|
30
35
|
// --- Protocol-level constants (not configurable) ---
|
|
31
36
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
32
37
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
33
|
-
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
34
38
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
35
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
36
39
|
// --- Helpers ---
|
|
37
40
|
function isValidSha256(hash) {
|
|
38
41
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -40,24 +43,6 @@ function isValidSha256(hash) {
|
|
|
40
43
|
function buildS3Key(contentHash) {
|
|
41
44
|
return contentHash;
|
|
42
45
|
}
|
|
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
46
|
function derivePathFromKey(key) {
|
|
62
47
|
const lastSlash = key.lastIndexOf('/');
|
|
63
48
|
if (lastSlash <= 0)
|
|
@@ -111,38 +96,25 @@ export function createPresignedUrlPlugin(options) {
|
|
|
111
96
|
if (!jsonType) {
|
|
112
97
|
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
113
98
|
}
|
|
114
|
-
|
|
115
|
-
|
|
99
|
+
// Each @storageFiles table is paired with its @storageBuckets table
|
|
100
|
+
// through the registry's actual FK relation; a tagged table that cannot
|
|
101
|
+
// be paired is a provisioning bug and throws at schema build.
|
|
102
|
+
const planes = discoverStoragePlanes(build.input.pgRegistry);
|
|
103
|
+
if (planes.length === 0)
|
|
116
104
|
return fields;
|
|
117
105
|
const newFields = {};
|
|
118
106
|
// --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Instead, derive the prefix from the raw SQL table name:
|
|
126
|
-
// "data_room_files" → prefix "data_room" → matches "data_room_buckets"
|
|
127
|
-
// "app_files" → prefix "app" → matches "app_buckets"
|
|
128
|
-
const filesRawName = filesCodec.extensions?.pg?.name;
|
|
129
|
-
const filesPrefix = filesRawName?.replace(/_files$/, '');
|
|
130
|
-
const matchingBucketCodec = bucketCodecs.find((bc) => {
|
|
131
|
-
const bucketRawName = bc.extensions?.pg?.name;
|
|
132
|
-
const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
|
|
133
|
-
return bucketPrefix === filesPrefix;
|
|
134
|
-
});
|
|
135
|
-
if (!matchingBucketCodec) {
|
|
136
|
-
log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
140
|
-
const mutationName = `upload${filesTypeName}`;
|
|
107
|
+
for (const plane of planes) {
|
|
108
|
+
const filesCodec = plane.filesCodec;
|
|
109
|
+
const matchingBucketCodec = plane.bucketsCodec;
|
|
110
|
+
const names = uploadSurfaceNames(build.inflection, plane.filesCodec);
|
|
111
|
+
const { filesTypeName, uploadMutation: mutationName } = names;
|
|
112
|
+
const hasOwnerId = plane.hasOwnerId;
|
|
141
113
|
const ownerIdGqlType = hasOwnerId
|
|
142
114
|
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
143
115
|
: null;
|
|
144
116
|
const InputType = new GraphQLInputObjectType({
|
|
145
|
-
name:
|
|
117
|
+
name: names.uploadInputType,
|
|
146
118
|
fields: {
|
|
147
119
|
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
148
120
|
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.' },
|
|
@@ -157,7 +129,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
157
129
|
},
|
|
158
130
|
});
|
|
159
131
|
const PayloadType = new GraphQLObjectType({
|
|
160
|
-
name:
|
|
132
|
+
name: names.uploadPayloadType,
|
|
161
133
|
fields: {
|
|
162
134
|
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
163
135
|
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
@@ -245,7 +217,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
245
217
|
});
|
|
246
218
|
// --- Bulk file upload mutation ---
|
|
247
219
|
const BulkFileInputType = new GraphQLInputObjectType({
|
|
248
|
-
name:
|
|
220
|
+
name: names.bulkUploadFileInputType,
|
|
249
221
|
fields: {
|
|
250
222
|
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
251
223
|
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
@@ -255,7 +227,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
255
227
|
},
|
|
256
228
|
});
|
|
257
229
|
const BulkFilePayloadType = new GraphQLObjectType({
|
|
258
|
-
name:
|
|
230
|
+
name: names.bulkUploadFilePayloadType,
|
|
259
231
|
fields: {
|
|
260
232
|
uploadUrl: { type: GraphQLString },
|
|
261
233
|
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
@@ -267,7 +239,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
267
239
|
},
|
|
268
240
|
});
|
|
269
241
|
const BulkInputType = new GraphQLInputObjectType({
|
|
270
|
-
name:
|
|
242
|
+
name: names.bulkUploadInputType,
|
|
271
243
|
fields: {
|
|
272
244
|
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
273
245
|
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
@@ -278,12 +250,12 @@ export function createPresignedUrlPlugin(options) {
|
|
|
278
250
|
},
|
|
279
251
|
});
|
|
280
252
|
const BulkPayloadType = new GraphQLObjectType({
|
|
281
|
-
name:
|
|
253
|
+
name: names.bulkUploadPayloadType,
|
|
282
254
|
fields: {
|
|
283
255
|
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
284
256
|
},
|
|
285
257
|
});
|
|
286
|
-
const bulkMutationName =
|
|
258
|
+
const bulkMutationName = names.bulkUploadMutation;
|
|
287
259
|
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
288
260
|
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
289
261
|
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
@@ -560,9 +532,20 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
560
532
|
}
|
|
561
533
|
// Dedup / versioning check
|
|
562
534
|
let previousVersionId = null;
|
|
535
|
+
// A row whose bytes never landed must not be reported as a dedup hit: the
|
|
536
|
+
// caller would store a reference to an object that is not in S3. Such a row is
|
|
537
|
+
// dropped instead, and the upload proceeds as a fresh one below — the insert is
|
|
538
|
+
// what enqueues the confirm-upload job, so restarting the lifecycle is the only
|
|
539
|
+
// way the retry can ever leave `requested`. Dropping it is also what keeps the
|
|
540
|
+
// retry insertable at all for a content-addressed key, where the key *is* the
|
|
541
|
+
// hash and a second row would collide on (bucket_id, key). The GC job the
|
|
542
|
+
// delete enqueues re-takes the reference count when it runs (≥5s later), by
|
|
543
|
+
// which point the replacement row exists, so it no-ops.
|
|
544
|
+
const statusColumn = statusSelectFragment(storageConfig);
|
|
545
|
+
let staleFileId = null;
|
|
563
546
|
if (isCustomKey) {
|
|
564
547
|
const existingResult = await txClient.query({
|
|
565
|
-
text: `SELECT id, content_hash
|
|
548
|
+
text: `SELECT id, content_hash${statusColumn}
|
|
566
549
|
FROM ${storageConfig.filesQualifiedName}
|
|
567
550
|
WHERE key = $1
|
|
568
551
|
AND bucket_id = $2
|
|
@@ -573,24 +556,30 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
573
556
|
if (existingResult.rows.length > 0) {
|
|
574
557
|
const existing = existingResult.rows[0];
|
|
575
558
|
if (existing.content_hash === contentHash) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
559
|
+
if (isLiveFileRow(storageConfig, existing)) {
|
|
560
|
+
log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
|
|
561
|
+
return {
|
|
562
|
+
uploadUrl: null,
|
|
563
|
+
fileId: existing.id,
|
|
564
|
+
key: s3Key,
|
|
565
|
+
deduplicated: true,
|
|
566
|
+
expiresAt: null,
|
|
567
|
+
previousVersionId: null,
|
|
568
|
+
file: projectFile(existing.id, s3Key),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
staleFileId = existing.id;
|
|
572
|
+
log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
previousVersionId = existing.id;
|
|
576
|
+
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
586
577
|
}
|
|
587
|
-
previousVersionId = existing.id;
|
|
588
|
-
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
589
578
|
}
|
|
590
579
|
}
|
|
591
580
|
else {
|
|
592
581
|
const dedupResult = await txClient.query({
|
|
593
|
-
text: `SELECT id
|
|
582
|
+
text: `SELECT id${statusColumn}
|
|
594
583
|
FROM ${storageConfig.filesQualifiedName}
|
|
595
584
|
WHERE content_hash = $1
|
|
596
585
|
AND bucket_id = $2
|
|
@@ -599,22 +588,33 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
599
588
|
});
|
|
600
589
|
if (dedupResult.rows.length > 0) {
|
|
601
590
|
const existingFile = dedupResult.rows[0];
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
591
|
+
if (isLiveFileRow(storageConfig, existingFile)) {
|
|
592
|
+
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
593
|
+
return {
|
|
594
|
+
uploadUrl: null,
|
|
595
|
+
fileId: existingFile.id,
|
|
596
|
+
key: s3Key,
|
|
597
|
+
deduplicated: true,
|
|
598
|
+
expiresAt: null,
|
|
599
|
+
previousVersionId: null,
|
|
600
|
+
file: projectFile(existingFile.id, s3Key),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
staleFileId = existingFile.id;
|
|
604
|
+
log.info(`Restarting upload of hash ${contentHash}: file ${staleFileId} is ${existingFile.status}, so it carries no bytes`);
|
|
612
605
|
}
|
|
613
606
|
}
|
|
607
|
+
if (staleFileId !== null) {
|
|
608
|
+
await txClient.query({
|
|
609
|
+
text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
|
|
610
|
+
values: [staleFileId],
|
|
611
|
+
});
|
|
612
|
+
}
|
|
614
613
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
615
614
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
616
|
-
// Create file record
|
|
617
|
-
|
|
615
|
+
// Create file record. An entity-keyed plane (the module records an entity
|
|
616
|
+
// table) carries owner_id on its rows; app- and database-scope planes do not.
|
|
617
|
+
const hasOwnerColumn = storageConfig.entityTableId !== null;
|
|
618
618
|
const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
|
|
619
619
|
const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
|
|
620
620
|
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
|
*
|