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.
@@ -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
+ }
@@ -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
- downloadUrl: context.fieldWithHooks({ fieldName: 'downloadUrl' }, {
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
- downloadUrl: context.fieldWithHooks({ fieldName: 'downloadUrl' }, {
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, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
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, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
40
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
@@ -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.scope !== 'app') {
93
- // An entity-scoped module resolves its bucket per owning row, and a
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
- 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.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
- const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
115
- if (bucketCodecs.length === 0)
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 fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
120
- for (const filesCodec of fileCodecs) {
121
- const filesTypeName = build.inflection.tableType(filesCodec);
122
- // Find the matching bucket codec by table name prefix.
123
- // Schema-name matching is ambiguous when multiple storage modules share
124
- // the same PG schema (e.g. app_files + data_room_files both in storage_public).
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: `Upload${filesTypeName}Input`,
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: `Upload${filesTypeName}Payload`,
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: `Upload${filesTypeName}BulkFileInput`,
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: `Upload${filesTypeName}BulkFilePayload`,
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: `Upload${filesTypeName}BulkInput`,
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: `Upload${filesTypeName}BulkPayload`,
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 = `upload${filesTypeName}s`;
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
- 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
- };
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
- 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
- };
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
- const hasOwnerColumn = storageConfig.scope !== 'app';
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
  *