graphile-presigned-url-plugin 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugin.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * on root Mutation for each @storageFiles/@storageBuckets pair. These combine
9
9
  * bucket resolution + file INSERT + presigned URL generation in one step.
10
10
  * E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
11
+ * Pairs are discovered from the registry's FK relations (see
12
+ * graphile-storage-registry) — table naming carries no meaning here.
11
13
  *
12
14
  * 2. Delete middleware — wraps `delete*` mutations on `@storageFiles`-tagged tables
13
15
  * with S3 object cleanup (sync + async GC fallback via AFTER DELETE trigger).
@@ -23,8 +25,11 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
23
25
  require("graphile-build");
24
26
  const logger_1 = require("@pgpmjs/logger");
25
27
  const grafast_1 = require("grafast");
28
+ const graphile_storage_registry_1 = require("graphile-storage-registry");
26
29
  const mime_bytes_1 = require("mime-bytes");
30
+ const custom_key_1 = require("./custom-key");
27
31
  const default_bucket_1 = require("./default-bucket");
32
+ const file_lifecycle_1 = require("./file-lifecycle");
28
33
  const managed_upload_1 = require("./managed-upload");
29
34
  const physical_bucket_1 = require("./physical-bucket");
30
35
  const request_pg_client_1 = require("./request-pg-client");
@@ -34,9 +39,7 @@ const log = new logger_1.Logger('graphile-presigned-url:plugin');
34
39
  // --- Protocol-level constants (not configurable) ---
35
40
  const MAX_CONTENT_HASH_LENGTH = 128;
36
41
  const MAX_CONTENT_TYPE_LENGTH = 255;
37
- const MAX_CUSTOM_KEY_LENGTH = 1024;
38
42
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
39
- const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
40
43
  // --- Helpers ---
41
44
  function isValidSha256(hash) {
42
45
  return SHA256_HEX_REGEX.test(hash);
@@ -44,24 +47,6 @@ function isValidSha256(hash) {
44
47
  function buildS3Key(contentHash) {
45
48
  return contentHash;
46
49
  }
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
50
  function derivePathFromKey(key) {
66
51
  const lastSlash = key.lastIndexOf('/');
67
52
  if (lastSlash <= 0)
@@ -115,38 +100,25 @@ function createPresignedUrlPlugin(options) {
115
100
  if (!jsonType) {
116
101
  log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
117
102
  }
118
- const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
119
- if (bucketCodecs.length === 0)
103
+ // Each @storageFiles table is paired with its @storageBuckets table
104
+ // through the registry's actual FK relation; a tagged table that cannot
105
+ // be paired is a provisioning bug and throws at schema build.
106
+ const planes = (0, graphile_storage_registry_1.discoverStoragePlanes)(build.input.pgRegistry);
107
+ if (planes.length === 0)
120
108
  return fields;
121
109
  const newFields = {};
122
110
  // --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
123
- const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
124
- for (const filesCodec of fileCodecs) {
125
- const filesTypeName = build.inflection.tableType(filesCodec);
126
- // Find the matching bucket codec by table name prefix.
127
- // Schema-name matching is ambiguous when multiple storage modules share
128
- // the same PG schema (e.g. app_files + data_room_files both in storage_public).
129
- // Instead, derive the prefix from the raw SQL table name:
130
- // "data_room_files" → prefix "data_room" → matches "data_room_buckets"
131
- // "app_files" → prefix "app" → matches "app_buckets"
132
- const filesRawName = filesCodec.extensions?.pg?.name;
133
- const filesPrefix = filesRawName?.replace(/_files$/, '');
134
- const matchingBucketCodec = bucketCodecs.find((bc) => {
135
- const bucketRawName = bc.extensions?.pg?.name;
136
- const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
137
- return bucketPrefix === filesPrefix;
138
- });
139
- if (!matchingBucketCodec) {
140
- log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
141
- continue;
142
- }
143
- const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
144
- const mutationName = `upload${filesTypeName}`;
111
+ for (const plane of planes) {
112
+ const filesCodec = plane.filesCodec;
113
+ const matchingBucketCodec = plane.bucketsCodec;
114
+ const names = (0, graphile_storage_registry_1.uploadSurfaceNames)(build.inflection, plane.filesCodec);
115
+ const { filesTypeName, uploadMutation: mutationName } = names;
116
+ const hasOwnerId = plane.hasOwnerId;
145
117
  const ownerIdGqlType = hasOwnerId
146
118
  ? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
147
119
  : null;
148
120
  const InputType = new GraphQLInputObjectType({
149
- name: `Upload${filesTypeName}Input`,
121
+ name: names.uploadInputType,
150
122
  fields: {
151
123
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
152
124
  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.' },
@@ -161,7 +133,7 @@ function createPresignedUrlPlugin(options) {
161
133
  },
162
134
  });
163
135
  const PayloadType = new GraphQLObjectType({
164
- name: `Upload${filesTypeName}Payload`,
136
+ name: names.uploadPayloadType,
165
137
  fields: {
166
138
  uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
167
139
  fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
@@ -249,7 +221,7 @@ function createPresignedUrlPlugin(options) {
249
221
  });
250
222
  // --- Bulk file upload mutation ---
251
223
  const BulkFileInputType = new GraphQLInputObjectType({
252
- name: `Upload${filesTypeName}BulkFileInput`,
224
+ name: names.bulkUploadFileInputType,
253
225
  fields: {
254
226
  contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
255
227
  contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
@@ -259,7 +231,7 @@ function createPresignedUrlPlugin(options) {
259
231
  },
260
232
  });
261
233
  const BulkFilePayloadType = new GraphQLObjectType({
262
- name: `Upload${filesTypeName}BulkFilePayload`,
234
+ name: names.bulkUploadFilePayloadType,
263
235
  fields: {
264
236
  uploadUrl: { type: GraphQLString },
265
237
  fileId: { type: new GraphQLNonNull(GraphQLString) },
@@ -271,7 +243,7 @@ function createPresignedUrlPlugin(options) {
271
243
  },
272
244
  });
273
245
  const BulkInputType = new GraphQLInputObjectType({
274
- name: `Upload${filesTypeName}BulkInput`,
246
+ name: names.bulkUploadInputType,
275
247
  fields: {
276
248
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
277
249
  isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
@@ -282,12 +254,12 @@ function createPresignedUrlPlugin(options) {
282
254
  },
283
255
  });
284
256
  const BulkPayloadType = new GraphQLObjectType({
285
- name: `Upload${filesTypeName}BulkPayload`,
257
+ name: names.bulkUploadPayloadType,
286
258
  fields: {
287
259
  files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
288
260
  },
289
261
  });
290
- const bulkMutationName = `upload${filesTypeName}s`;
262
+ const bulkMutationName = names.bulkUploadMutation;
291
263
  log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
292
264
  newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
293
265
  description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
@@ -552,7 +524,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
552
524
  if (!bucket.allow_custom_keys) {
553
525
  throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys');
554
526
  }
555
- const keyError = validateCustomKey(customKey);
527
+ const keyError = (0, custom_key_1.validateCustomKey)(customKey);
556
528
  if (keyError) {
557
529
  throw new Error(keyError);
558
530
  }
@@ -564,9 +536,20 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
564
536
  }
565
537
  // Dedup / versioning check
566
538
  let previousVersionId = null;
539
+ // A row whose bytes never landed must not be reported as a dedup hit: the
540
+ // caller would store a reference to an object that is not in S3. Such a row is
541
+ // dropped instead, and the upload proceeds as a fresh one below — the insert is
542
+ // what enqueues the confirm-upload job, so restarting the lifecycle is the only
543
+ // way the retry can ever leave `requested`. Dropping it is also what keeps the
544
+ // retry insertable at all for a content-addressed key, where the key *is* the
545
+ // hash and a second row would collide on (bucket_id, key). The GC job the
546
+ // delete enqueues re-takes the reference count when it runs (≥5s later), by
547
+ // which point the replacement row exists, so it no-ops.
548
+ const statusColumn = (0, file_lifecycle_1.statusSelectFragment)(storageConfig);
549
+ let staleFileId = null;
567
550
  if (isCustomKey) {
568
551
  const existingResult = await txClient.query({
569
- text: `SELECT id, content_hash
552
+ text: `SELECT id, content_hash${statusColumn}
570
553
  FROM ${storageConfig.filesQualifiedName}
571
554
  WHERE key = $1
572
555
  AND bucket_id = $2
@@ -577,24 +560,30 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
577
560
  if (existingResult.rows.length > 0) {
578
561
  const existing = existingResult.rows[0];
579
562
  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
- };
563
+ if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existing)) {
564
+ log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
565
+ return {
566
+ uploadUrl: null,
567
+ fileId: existing.id,
568
+ key: s3Key,
569
+ deduplicated: true,
570
+ expiresAt: null,
571
+ previousVersionId: null,
572
+ file: projectFile(existing.id, s3Key),
573
+ };
574
+ }
575
+ staleFileId = existing.id;
576
+ log.info(`Restarting upload of key ${s3Key}: file ${staleFileId} is ${existing.status}, so it carries no bytes`);
577
+ }
578
+ else {
579
+ previousVersionId = existing.id;
580
+ log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
590
581
  }
591
- previousVersionId = existing.id;
592
- log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
593
582
  }
594
583
  }
595
584
  else {
596
585
  const dedupResult = await txClient.query({
597
- text: `SELECT id
586
+ text: `SELECT id${statusColumn}
598
587
  FROM ${storageConfig.filesQualifiedName}
599
588
  WHERE content_hash = $1
600
589
  AND bucket_id = $2
@@ -603,22 +592,33 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
603
592
  });
604
593
  if (dedupResult.rows.length > 0) {
605
594
  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
- };
595
+ if ((0, file_lifecycle_1.isLiveFileRow)(storageConfig, existingFile)) {
596
+ log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
597
+ return {
598
+ uploadUrl: null,
599
+ fileId: existingFile.id,
600
+ key: s3Key,
601
+ deduplicated: true,
602
+ expiresAt: null,
603
+ previousVersionId: null,
604
+ file: projectFile(existingFile.id, s3Key),
605
+ };
606
+ }
607
+ staleFileId = existingFile.id;
608
+ log.info(`Restarting upload of hash ${contentHash}: file ${staleFileId} is ${existingFile.status}, so it carries no bytes`);
616
609
  }
617
610
  }
611
+ if (staleFileId !== null) {
612
+ await txClient.query({
613
+ text: `DELETE FROM ${storageConfig.filesQualifiedName} WHERE id = $1`,
614
+ values: [staleFileId],
615
+ });
616
+ }
618
617
  // Auto-derive ltree path from custom key directory (only when has_path_shares)
619
618
  const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
620
- // Create file record
621
- const hasOwnerColumn = storageConfig.scope !== 'app';
619
+ // Create file record. An entity-keyed plane (the module records an entity
620
+ // table) carries owner_id on its rows; app- and database-scope planes do not.
621
+ const hasOwnerColumn = storageConfig.entityTableId !== null;
622
622
  const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
623
623
  const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
624
624
  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
  *
@@ -1,7 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getStorageModuleConfig = getStorageModuleConfig;
4
- exports.getStorageModuleConfigForOwner = getStorageModuleConfigForOwner;
5
3
  exports.resolveStorageModuleByFileId = resolveStorageModuleByFileId;
6
4
  exports.loadAllStorageModules = loadAllStorageModules;
7
5
  exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
@@ -41,50 +39,9 @@ const storageModuleCache = new lru_cache_1.LRUCache({
41
39
  updateAgeOnGet: true,
42
40
  });
43
41
  /**
44
- * SQL query to resolve the app-level storage module config for a database.
45
- *
46
- * Joins storage_module → table → schema to get fully-qualified table names.
47
- * Filters to app-level (scope = 'app') by default.
48
- *
49
- * Requires the multi-scope schema (scope column on storage_module).
50
- */
51
- const APP_STORAGE_MODULE_QUERY = `
52
- SELECT
53
- sm.id,
54
- sm.scope,
55
- sm.entity_table_id,
56
- bs.schema_name AS buckets_schema,
57
- bt.name AS buckets_table,
58
- fs.schema_name AS files_schema,
59
- ft.name AS files_table,
60
- sm.endpoint,
61
- sm.public_url_prefix,
62
- sm.provider,
63
- sm.allowed_origins,
64
- sm.upload_url_expiry_seconds,
65
- sm.download_url_expiry_seconds,
66
- sm.default_max_file_size,
67
- sm.max_filename_length,
68
- sm.cache_ttl_seconds,
69
- sm.max_bulk_files,
70
- sm.max_bulk_total_size,
71
- sm.has_path_shares,
72
- NULL AS entity_schema,
73
- NULL AS entity_table
74
- FROM metaschema_modules_public.storage_module sm
75
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
76
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
77
- JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
78
- JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
79
- WHERE sm.database_id = $1
80
- AND sm.scope = 'app'
81
- LIMIT 1
82
- `;
83
- /**
84
- * SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
85
- *
86
- * Returns all storage modules with their entity table names for ownerId resolution.
87
- * Requires the multi-scope schema.
42
+ * SQL query to resolve ALL storage modules for a database, whatever their
43
+ * scope. Returns each module with its entity table names so callers can
44
+ * classify entity-keyed planes and resolve owners.
88
45
  */
89
46
  const ALL_STORAGE_MODULES_QUERY = `
90
47
  SELECT
@@ -107,6 +64,7 @@ const ALL_STORAGE_MODULES_QUERY = `
107
64
  sm.max_bulk_files,
108
65
  sm.max_bulk_total_size,
109
66
  sm.has_path_shares,
67
+ sm.has_confirm_upload,
110
68
  es.schema_name AS entity_schema,
111
69
  et.name AS entity_table
112
70
  FROM metaschema_modules_public.storage_module sm
@@ -145,99 +103,11 @@ function buildConfig(row) {
145
103
  maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
146
104
  cacheTtlSeconds,
147
105
  hasPathShares: row.has_path_shares ?? false,
106
+ hasConfirmUpload: row.has_confirm_upload ?? false,
148
107
  maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
149
108
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
150
109
  };
151
110
  }
152
- /**
153
- * Resolve the app-level storage module config for a database, using the LRU cache.
154
- *
155
- * This is the default path when no ownerId is provided. It returns the
156
- * storage module with scope = 'app' (app-level / database-wide).
157
- *
158
- * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
159
- * @param databaseId - The metaschema database UUID
160
- * @returns StorageModuleConfig or null if no storage module is provisioned
161
- */
162
- async function getStorageModuleConfig(pgClient, databaseId) {
163
- const cacheKey = `storage:${databaseId}:app`;
164
- const cached = storageModuleCache.get(cacheKey);
165
- if (cached) {
166
- return cached;
167
- }
168
- log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
169
- const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
170
- if (result.rows.length === 0) {
171
- log.warn(`No app-level storage module found for database ${databaseId}`);
172
- return null;
173
- }
174
- const config = buildConfig(result.rows[0]);
175
- storageModuleCache.set(cacheKey, config);
176
- log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
177
- return config;
178
- }
179
- /**
180
- * Resolve the storage module config for a specific owner entity.
181
- *
182
- * When ownerId is provided, this function:
183
- * 1. Loads ALL storage modules for the database (cached)
184
- * 2. Finds which entity-scoped module contains the ownerId in its entity table
185
- * 3. Returns that module's config
186
- *
187
- * This is the core of Option C — the ownerId tells us which scope to use.
188
- *
189
- * @param pgClient - A pg client from the Graphile context
190
- * @param databaseId - The metaschema database UUID
191
- * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
192
- * @returns StorageModuleConfig or null if no matching module found
193
- */
194
- async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
195
- // Check if we already have a cached mapping for this ownerId
196
- const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
197
- const cachedOwner = storageModuleCache.get(ownerCacheKey);
198
- if (cachedOwner) {
199
- return cachedOwner;
200
- }
201
- // Load all storage modules for this database
202
- const allModulesCacheKey = `storage:${databaseId}:all`;
203
- let allConfigs;
204
- const cachedAll = storageModuleCache.get(allModulesCacheKey);
205
- if (cachedAll) {
206
- // We stored a sentinel; re-derive from individual caches
207
- // Actually, let's just query fresh — this is the cache-miss path
208
- allConfigs = [];
209
- }
210
- else {
211
- allConfigs = [];
212
- }
213
- if (allConfigs.length === 0) {
214
- log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
215
- const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
216
- allConfigs = result.rows.map(buildConfig);
217
- // Cache each individual config by its scope
218
- for (const config of allConfigs) {
219
- const key = `storage:${databaseId}:scope:${config.scope}`;
220
- storageModuleCache.set(key, config);
221
- }
222
- }
223
- // Find entity-scoped modules and probe their entity tables for the ownerId
224
- const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
225
- for (const mod of entityModules) {
226
- const probeResult = await pgClient.query({
227
- text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
228
- values: [ownerId],
229
- });
230
- if (probeResult.rows.length > 0) {
231
- // Found the matching module — cache the ownerId→module mapping
232
- storageModuleCache.set(ownerCacheKey, mod);
233
- log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
234
- `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
235
- return mod;
236
- }
237
- }
238
- log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
239
- return null;
240
- }
241
111
  /**
242
112
  * Resolve the storage module that owns a specific file by probing all file tables.
243
113
  *
@@ -283,11 +153,6 @@ async function loadAllStorageModules(pgClient, databaseId) {
283
153
  log.debug(`Loading all storage modules for database ${databaseId}`);
284
154
  const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
285
155
  const configs = result.rows.map(buildConfig);
286
- // Cache each individual config by its scope
287
- for (const config of configs) {
288
- const key = `storage:${databaseId}:scope:${config.scope}`;
289
- storageModuleCache.set(key, config);
290
- }
291
156
  // Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
292
157
  if (configs.length > 0) {
293
158
  const sentinel = { ...configs[0], _allConfigs: configs };
@@ -364,9 +229,9 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
364
229
  return cached;
365
230
  }
366
231
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
367
- // Entity-scoped buckets use (owner_id, key) composite lookup;
368
- // app-level buckets just use key.
369
- const isEntityScoped = storageConfig.scope !== 'app';
232
+ // Entity-keyed planes (the module records an entity table) use the
233
+ // (owner_id, key) composite lookup; app- and database-scope planes just use key.
234
+ const isEntityScoped = storageConfig.entityTableId !== null;
370
235
  const hasOwner = ownerId && isEntityScoped;
371
236
  const result = await pgClient.query({
372
237
  text: hasOwner
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) */