graphile-presigned-url-plugin 1.21.0 → 1.21.1

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