graphile-presigned-url-plugin 1.13.1 → 1.14.1

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