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.
@@ -28,50 +28,9 @@ const storageModuleCache = new LRUCache({
28
28
  updateAgeOnGet: true,
29
29
  });
30
30
  /**
31
- * SQL query to resolve the app-level storage module config for a database.
32
- *
33
- * Joins storage_module → table → schema to get fully-qualified table names.
34
- * Filters to app-level (scope = 'app') by default.
35
- *
36
- * Requires the multi-scope schema (scope column on storage_module).
37
- */
38
- const APP_STORAGE_MODULE_QUERY = `
39
- SELECT
40
- sm.id,
41
- sm.scope,
42
- sm.entity_table_id,
43
- bs.schema_name AS buckets_schema,
44
- bt.name AS buckets_table,
45
- fs.schema_name AS files_schema,
46
- ft.name AS files_table,
47
- sm.endpoint,
48
- sm.public_url_prefix,
49
- sm.provider,
50
- sm.allowed_origins,
51
- sm.upload_url_expiry_seconds,
52
- sm.download_url_expiry_seconds,
53
- sm.default_max_file_size,
54
- sm.max_filename_length,
55
- sm.cache_ttl_seconds,
56
- sm.max_bulk_files,
57
- sm.max_bulk_total_size,
58
- sm.has_path_shares,
59
- NULL AS entity_schema,
60
- NULL AS entity_table
61
- FROM metaschema_modules_public.storage_module sm
62
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
63
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
64
- JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
65
- JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
66
- WHERE sm.database_id = $1
67
- AND sm.scope = 'app'
68
- LIMIT 1
69
- `;
70
- /**
71
- * SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
72
- *
73
- * Returns all storage modules with their entity table names for ownerId resolution.
74
- * Requires the multi-scope schema.
31
+ * SQL query to resolve ALL storage modules for a database, whatever their
32
+ * scope. Returns each module with its entity table names so callers can
33
+ * classify entity-keyed planes and resolve owners.
75
34
  */
76
35
  const ALL_STORAGE_MODULES_QUERY = `
77
36
  SELECT
@@ -94,6 +53,7 @@ const ALL_STORAGE_MODULES_QUERY = `
94
53
  sm.max_bulk_files,
95
54
  sm.max_bulk_total_size,
96
55
  sm.has_path_shares,
56
+ sm.has_confirm_upload,
97
57
  es.schema_name AS entity_schema,
98
58
  et.name AS entity_table
99
59
  FROM metaschema_modules_public.storage_module sm
@@ -132,99 +92,11 @@ function buildConfig(row) {
132
92
  maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
133
93
  cacheTtlSeconds,
134
94
  hasPathShares: row.has_path_shares ?? false,
95
+ hasConfirmUpload: row.has_confirm_upload ?? false,
135
96
  maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
136
97
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
137
98
  };
138
99
  }
139
- /**
140
- * Resolve the app-level storage module config for a database, using the LRU cache.
141
- *
142
- * This is the default path when no ownerId is provided. It returns the
143
- * storage module with scope = 'app' (app-level / database-wide).
144
- *
145
- * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
146
- * @param databaseId - The metaschema database UUID
147
- * @returns StorageModuleConfig or null if no storage module is provisioned
148
- */
149
- export async function getStorageModuleConfig(pgClient, databaseId) {
150
- const cacheKey = `storage:${databaseId}:app`;
151
- const cached = storageModuleCache.get(cacheKey);
152
- if (cached) {
153
- return cached;
154
- }
155
- log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
156
- const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
157
- if (result.rows.length === 0) {
158
- log.warn(`No app-level storage module found for database ${databaseId}`);
159
- return null;
160
- }
161
- const config = buildConfig(result.rows[0]);
162
- storageModuleCache.set(cacheKey, config);
163
- log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
164
- return config;
165
- }
166
- /**
167
- * Resolve the storage module config for a specific owner entity.
168
- *
169
- * When ownerId is provided, this function:
170
- * 1. Loads ALL storage modules for the database (cached)
171
- * 2. Finds which entity-scoped module contains the ownerId in its entity table
172
- * 3. Returns that module's config
173
- *
174
- * This is the core of Option C — the ownerId tells us which scope to use.
175
- *
176
- * @param pgClient - A pg client from the Graphile context
177
- * @param databaseId - The metaschema database UUID
178
- * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
179
- * @returns StorageModuleConfig or null if no matching module found
180
- */
181
- export async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
182
- // Check if we already have a cached mapping for this ownerId
183
- const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
184
- const cachedOwner = storageModuleCache.get(ownerCacheKey);
185
- if (cachedOwner) {
186
- return cachedOwner;
187
- }
188
- // Load all storage modules for this database
189
- const allModulesCacheKey = `storage:${databaseId}:all`;
190
- let allConfigs;
191
- const cachedAll = storageModuleCache.get(allModulesCacheKey);
192
- if (cachedAll) {
193
- // We stored a sentinel; re-derive from individual caches
194
- // Actually, let's just query fresh — this is the cache-miss path
195
- allConfigs = [];
196
- }
197
- else {
198
- allConfigs = [];
199
- }
200
- if (allConfigs.length === 0) {
201
- log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
202
- const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
203
- allConfigs = result.rows.map(buildConfig);
204
- // Cache each individual config by its scope
205
- for (const config of allConfigs) {
206
- const key = `storage:${databaseId}:scope:${config.scope}`;
207
- storageModuleCache.set(key, config);
208
- }
209
- }
210
- // Find entity-scoped modules and probe their entity tables for the ownerId
211
- const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
212
- for (const mod of entityModules) {
213
- const probeResult = await pgClient.query({
214
- text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
215
- values: [ownerId],
216
- });
217
- if (probeResult.rows.length > 0) {
218
- // Found the matching module — cache the ownerId→module mapping
219
- storageModuleCache.set(ownerCacheKey, mod);
220
- log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
221
- `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
222
- return mod;
223
- }
224
- }
225
- log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
226
- return null;
227
- }
228
100
  /**
229
101
  * Resolve the storage module that owns a specific file by probing all file tables.
230
102
  *
@@ -270,11 +142,6 @@ export async function loadAllStorageModules(pgClient, databaseId) {
270
142
  log.debug(`Loading all storage modules for database ${databaseId}`);
271
143
  const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
272
144
  const configs = result.rows.map(buildConfig);
273
- // Cache each individual config by its scope
274
- for (const config of configs) {
275
- const key = `storage:${databaseId}:scope:${config.scope}`;
276
- storageModuleCache.set(key, config);
277
- }
278
145
  // Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
279
146
  if (configs.length > 0) {
280
147
  const sentinel = { ...configs[0], _allConfigs: configs };
@@ -351,9 +218,9 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
351
218
  return cached;
352
219
  }
353
220
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
354
- // Entity-scoped buckets use (owner_id, key) composite lookup;
355
- // app-level buckets just use key.
356
- const isEntityScoped = storageConfig.scope !== 'app';
221
+ // Entity-keyed planes (the module records an entity table) use the
222
+ // (owner_id, key) composite lookup; app- and database-scope planes just use key.
223
+ const isEntityScoped = storageConfig.entityTableId !== null;
357
224
  const hasOwner = ownerId && isEntityScoped;
358
225
  const result = await pgClient.query({
359
226
  text: hasOwner
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';
@@ -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/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.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");
@@ -68,8 +70,6 @@ var storage_module_cache_1 = require("./storage-module-cache");
68
70
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
69
71
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
70
72
  Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
71
- Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
72
- Object.defineProperty(exports, "getStorageModuleConfigForOwner", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfigForOwner; } });
73
73
  Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
74
74
  Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
75
75
  Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
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");
@@ -95,8 +96,8 @@ async function resolveManagedUploadTarget(args) {
95
96
  `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
96
97
  'default to; there is no environment bucket to fall back to');
97
98
  }
98
- if (storageConfig.scope !== 'app') {
99
- // An entity-scoped module resolves its bucket per owning row, and a
99
+ if (storageConfig.entityTableId !== null) {
100
+ // An entity-keyed module resolves its bucket per owning row, and a
100
101
  // multipart column write does not carry one. Refuse rather than write a
101
102
  // tenant's file into whichever bucket happened to resolve.
102
103
  throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
@@ -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.14.0",
3
+ "version": "1.15.0",
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",
@@ -44,6 +44,7 @@
44
44
  "@aws-sdk/s3-request-presigner": "^3.1052.0",
45
45
  "@pgpmjs/logger": "^2.25.0",
46
46
  "@pgsql/quotes": "^18.2.4",
47
+ "graphile-storage-registry": "^0.1.0",
47
48
  "lru-cache": "^11.2.7",
48
49
  "mime-bytes": "^0.32.0"
49
50
  },
@@ -61,5 +62,5 @@
61
62
  "@types/node": "^22.19.11",
62
63
  "makage": "^0.3.0"
63
64
  },
64
- "gitHead": "b5b6b9f05276b7e9cbd3d1f4acd31dd315c353ea"
65
+ "gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
65
66
  }
package/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).