graphile-presigned-url-plugin 1.14.1 → 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.
@@ -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,
@@ -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,
package/esm/index.d.ts CHANGED
@@ -39,5 +39,5 @@ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
39
39
  export { PresignedUrlPreset } from './preset';
40
40
  export { type WithPgClient, withRequestPgClient } from './request-pg-client';
41
41
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
42
- 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';
43
43
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/esm/index.js CHANGED
@@ -37,4 +37,4 @@ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
37
37
  export { PresignedUrlPreset } from './preset';
38
38
  export { withRequestPgClient } from './request-pg-client';
39
39
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
40
- 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';
@@ -90,8 +90,8 @@ export async function resolveManagedUploadTarget(args) {
90
90
  `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
91
91
  'default to; there is no environment bucket to fall back to');
92
92
  }
93
- if (storageConfig.scope !== 'app') {
94
- // 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
95
95
  // multipart column write does not carry one. Refuse rather than write a
96
96
  // tenant's file into whichever bucket happened to resolve.
97
97
  throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
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,6 +21,7 @@
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';
23
26
  import { validateCustomKey } from './custom-key';
24
27
  import { resolveDefaultBucket } from './default-bucket';
@@ -93,38 +96,25 @@ export function createPresignedUrlPlugin(options) {
93
96
  if (!jsonType) {
94
97
  log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
95
98
  }
96
- const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
97
- 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)
98
104
  return fields;
99
105
  const newFields = {};
100
106
  // --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
101
- const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
102
- for (const filesCodec of fileCodecs) {
103
- const filesTypeName = build.inflection.tableType(filesCodec);
104
- // Find the matching bucket codec by table name prefix.
105
- // Schema-name matching is ambiguous when multiple storage modules share
106
- // the same PG schema (e.g. app_files + data_room_files both in storage_public).
107
- // Instead, derive the prefix from the raw SQL table name:
108
- // "data_room_files" → prefix "data_room" → matches "data_room_buckets"
109
- // "app_files" → prefix "app" → matches "app_buckets"
110
- const filesRawName = filesCodec.extensions?.pg?.name;
111
- const filesPrefix = filesRawName?.replace(/_files$/, '');
112
- const matchingBucketCodec = bucketCodecs.find((bc) => {
113
- const bucketRawName = bc.extensions?.pg?.name;
114
- const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
115
- return bucketPrefix === filesPrefix;
116
- });
117
- if (!matchingBucketCodec) {
118
- log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
119
- continue;
120
- }
121
- const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
122
- 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;
123
113
  const ownerIdGqlType = hasOwnerId
124
114
  ? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
125
115
  : null;
126
116
  const InputType = new GraphQLInputObjectType({
127
- name: `Upload${filesTypeName}Input`,
117
+ name: names.uploadInputType,
128
118
  fields: {
129
119
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
130
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.' },
@@ -139,7 +129,7 @@ export function createPresignedUrlPlugin(options) {
139
129
  },
140
130
  });
141
131
  const PayloadType = new GraphQLObjectType({
142
- name: `Upload${filesTypeName}Payload`,
132
+ name: names.uploadPayloadType,
143
133
  fields: {
144
134
  uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
145
135
  fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
@@ -227,7 +217,7 @@ export function createPresignedUrlPlugin(options) {
227
217
  });
228
218
  // --- Bulk file upload mutation ---
229
219
  const BulkFileInputType = new GraphQLInputObjectType({
230
- name: `Upload${filesTypeName}BulkFileInput`,
220
+ name: names.bulkUploadFileInputType,
231
221
  fields: {
232
222
  contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
233
223
  contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
@@ -237,7 +227,7 @@ export function createPresignedUrlPlugin(options) {
237
227
  },
238
228
  });
239
229
  const BulkFilePayloadType = new GraphQLObjectType({
240
- name: `Upload${filesTypeName}BulkFilePayload`,
230
+ name: names.bulkUploadFilePayloadType,
241
231
  fields: {
242
232
  uploadUrl: { type: GraphQLString },
243
233
  fileId: { type: new GraphQLNonNull(GraphQLString) },
@@ -249,7 +239,7 @@ export function createPresignedUrlPlugin(options) {
249
239
  },
250
240
  });
251
241
  const BulkInputType = new GraphQLInputObjectType({
252
- name: `Upload${filesTypeName}BulkInput`,
242
+ name: names.bulkUploadInputType,
253
243
  fields: {
254
244
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
255
245
  isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
@@ -260,12 +250,12 @@ export function createPresignedUrlPlugin(options) {
260
250
  },
261
251
  });
262
252
  const BulkPayloadType = new GraphQLObjectType({
263
- name: `Upload${filesTypeName}BulkPayload`,
253
+ name: names.bulkUploadPayloadType,
264
254
  fields: {
265
255
  files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
266
256
  },
267
257
  });
268
- const bulkMutationName = `upload${filesTypeName}s`;
258
+ const bulkMutationName = names.bulkUploadMutation;
269
259
  log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
270
260
  newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
271
261
  description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
@@ -622,8 +612,9 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
622
612
  }
623
613
  // Auto-derive ltree path from custom key directory (only when has_path_shares)
624
614
  const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
625
- // Create file record
626
- 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;
627
618
  const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
628
619
  const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
629
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
  *
@@ -28,51 +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
- sm.has_confirm_upload,
60
- NULL AS entity_schema,
61
- NULL AS entity_table
62
- FROM metaschema_modules_public.storage_module sm
63
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
64
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
65
- JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
66
- JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
67
- WHERE sm.database_id = $1
68
- AND sm.scope = 'app'
69
- LIMIT 1
70
- `;
71
- /**
72
- * SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
73
- *
74
- * Returns all storage modules with their entity table names for ownerId resolution.
75
- * 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.
76
34
  */
77
35
  const ALL_STORAGE_MODULES_QUERY = `
78
36
  SELECT
@@ -139,95 +97,6 @@ function buildConfig(row) {
139
97
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
140
98
  };
141
99
  }
142
- /**
143
- * Resolve the app-level storage module config for a database, using the LRU cache.
144
- *
145
- * This is the default path when no ownerId is provided. It returns the
146
- * storage module with scope = 'app' (app-level / database-wide).
147
- *
148
- * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
149
- * @param databaseId - The metaschema database UUID
150
- * @returns StorageModuleConfig or null if no storage module is provisioned
151
- */
152
- export async function getStorageModuleConfig(pgClient, databaseId) {
153
- const cacheKey = `storage:${databaseId}:app`;
154
- const cached = storageModuleCache.get(cacheKey);
155
- if (cached) {
156
- return cached;
157
- }
158
- log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
159
- const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
160
- if (result.rows.length === 0) {
161
- log.warn(`No app-level storage module found for database ${databaseId}`);
162
- return null;
163
- }
164
- const config = buildConfig(result.rows[0]);
165
- storageModuleCache.set(cacheKey, config);
166
- log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
167
- return config;
168
- }
169
- /**
170
- * Resolve the storage module config for a specific owner entity.
171
- *
172
- * When ownerId is provided, this function:
173
- * 1. Loads ALL storage modules for the database (cached)
174
- * 2. Finds which entity-scoped module contains the ownerId in its entity table
175
- * 3. Returns that module's config
176
- *
177
- * This is the core of Option C — the ownerId tells us which scope to use.
178
- *
179
- * @param pgClient - A pg client from the Graphile context
180
- * @param databaseId - The metaschema database UUID
181
- * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
182
- * @returns StorageModuleConfig or null if no matching module found
183
- */
184
- export async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
185
- // Check if we already have a cached mapping for this ownerId
186
- const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
187
- const cachedOwner = storageModuleCache.get(ownerCacheKey);
188
- if (cachedOwner) {
189
- return cachedOwner;
190
- }
191
- // Load all storage modules for this database
192
- const allModulesCacheKey = `storage:${databaseId}:all`;
193
- let allConfigs;
194
- const cachedAll = storageModuleCache.get(allModulesCacheKey);
195
- if (cachedAll) {
196
- // We stored a sentinel; re-derive from individual caches
197
- // Actually, let's just query fresh — this is the cache-miss path
198
- allConfigs = [];
199
- }
200
- else {
201
- allConfigs = [];
202
- }
203
- if (allConfigs.length === 0) {
204
- log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
205
- const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
206
- allConfigs = result.rows.map(buildConfig);
207
- // Cache each individual config by its scope
208
- for (const config of allConfigs) {
209
- const key = `storage:${databaseId}:scope:${config.scope}`;
210
- storageModuleCache.set(key, config);
211
- }
212
- }
213
- // Find entity-scoped modules and probe their entity tables for the ownerId
214
- const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
215
- for (const mod of entityModules) {
216
- const probeResult = await pgClient.query({
217
- text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
218
- values: [ownerId],
219
- });
220
- if (probeResult.rows.length > 0) {
221
- // Found the matching module — cache the ownerId→module mapping
222
- storageModuleCache.set(ownerCacheKey, mod);
223
- log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
224
- `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
225
- return mod;
226
- }
227
- }
228
- log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
229
- return null;
230
- }
231
100
  /**
232
101
  * Resolve the storage module that owns a specific file by probing all file tables.
233
102
  *
@@ -273,11 +142,6 @@ export async function loadAllStorageModules(pgClient, databaseId) {
273
142
  log.debug(`Loading all storage modules for database ${databaseId}`);
274
143
  const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
275
144
  const configs = result.rows.map(buildConfig);
276
- // Cache each individual config by its scope
277
- for (const config of configs) {
278
- const key = `storage:${databaseId}:scope:${config.scope}`;
279
- storageModuleCache.set(key, config);
280
- }
281
145
  // Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
282
146
  if (configs.length > 0) {
283
147
  const sentinel = { ...configs[0], _allConfigs: configs };
@@ -354,9 +218,9 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
354
218
  return cached;
355
219
  }
356
220
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
357
- // Entity-scoped buckets use (owner_id, key) composite lookup;
358
- // app-level buckets just use key.
359
- 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;
360
224
  const hasOwner = ownerId && isEntityScoped;
361
225
  const result = await pgClient.query({
362
226
  text: hasOwner
package/index.d.ts CHANGED
@@ -39,5 +39,5 @@ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
39
39
  export { PresignedUrlPreset } from './preset';
40
40
  export { type WithPgClient, withRequestPgClient } from './request-pg-client';
41
41
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
42
- 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';
43
43
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/index.js CHANGED
@@ -28,7 +28,7 @@
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.validateCustomKey = 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; } });
@@ -70,8 +70,6 @@ var storage_module_cache_1 = require("./storage-module-cache");
70
70
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
71
71
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
72
72
  Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
73
- Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
74
- Object.defineProperty(exports, "getStorageModuleConfigForOwner", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfigForOwner; } });
75
73
  Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
76
74
  Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
77
75
  Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
package/managed-upload.js CHANGED
@@ -96,8 +96,8 @@ async function resolveManagedUploadTarget(args) {
96
96
  `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
97
97
  'default to; there is no environment bucket to fall back to');
98
98
  }
99
- if (storageConfig.scope !== 'app') {
100
- // 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
101
101
  // multipart column write does not carry one. Refuse rather than write a
102
102
  // tenant's file into whichever bucket happened to resolve.
103
103
  throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.14.1",
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": "8d77dfe57bb328cbdfe10732b10ca24865adf9c0"
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).
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,6 +25,7 @@ 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");
27
30
  const custom_key_1 = require("./custom-key");
28
31
  const default_bucket_1 = require("./default-bucket");
@@ -97,38 +100,25 @@ function createPresignedUrlPlugin(options) {
97
100
  if (!jsonType) {
98
101
  log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
99
102
  }
100
- const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
101
- 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)
102
108
  return fields;
103
109
  const newFields = {};
104
110
  // --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
105
- const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
106
- for (const filesCodec of fileCodecs) {
107
- const filesTypeName = build.inflection.tableType(filesCodec);
108
- // Find the matching bucket codec by table name prefix.
109
- // Schema-name matching is ambiguous when multiple storage modules share
110
- // the same PG schema (e.g. app_files + data_room_files both in storage_public).
111
- // Instead, derive the prefix from the raw SQL table name:
112
- // "data_room_files" → prefix "data_room" → matches "data_room_buckets"
113
- // "app_files" → prefix "app" → matches "app_buckets"
114
- const filesRawName = filesCodec.extensions?.pg?.name;
115
- const filesPrefix = filesRawName?.replace(/_files$/, '');
116
- const matchingBucketCodec = bucketCodecs.find((bc) => {
117
- const bucketRawName = bc.extensions?.pg?.name;
118
- const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
119
- return bucketPrefix === filesPrefix;
120
- });
121
- if (!matchingBucketCodec) {
122
- log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
123
- continue;
124
- }
125
- const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
126
- 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;
127
117
  const ownerIdGqlType = hasOwnerId
128
118
  ? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
129
119
  : null;
130
120
  const InputType = new GraphQLInputObjectType({
131
- name: `Upload${filesTypeName}Input`,
121
+ name: names.uploadInputType,
132
122
  fields: {
133
123
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
134
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.' },
@@ -143,7 +133,7 @@ function createPresignedUrlPlugin(options) {
143
133
  },
144
134
  });
145
135
  const PayloadType = new GraphQLObjectType({
146
- name: `Upload${filesTypeName}Payload`,
136
+ name: names.uploadPayloadType,
147
137
  fields: {
148
138
  uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
149
139
  fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
@@ -231,7 +221,7 @@ function createPresignedUrlPlugin(options) {
231
221
  });
232
222
  // --- Bulk file upload mutation ---
233
223
  const BulkFileInputType = new GraphQLInputObjectType({
234
- name: `Upload${filesTypeName}BulkFileInput`,
224
+ name: names.bulkUploadFileInputType,
235
225
  fields: {
236
226
  contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
237
227
  contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
@@ -241,7 +231,7 @@ function createPresignedUrlPlugin(options) {
241
231
  },
242
232
  });
243
233
  const BulkFilePayloadType = new GraphQLObjectType({
244
- name: `Upload${filesTypeName}BulkFilePayload`,
234
+ name: names.bulkUploadFilePayloadType,
245
235
  fields: {
246
236
  uploadUrl: { type: GraphQLString },
247
237
  fileId: { type: new GraphQLNonNull(GraphQLString) },
@@ -253,7 +243,7 @@ function createPresignedUrlPlugin(options) {
253
243
  },
254
244
  });
255
245
  const BulkInputType = new GraphQLInputObjectType({
256
- name: `Upload${filesTypeName}BulkInput`,
246
+ name: names.bulkUploadInputType,
257
247
  fields: {
258
248
  bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
259
249
  isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
@@ -264,12 +254,12 @@ function createPresignedUrlPlugin(options) {
264
254
  },
265
255
  });
266
256
  const BulkPayloadType = new GraphQLObjectType({
267
- name: `Upload${filesTypeName}BulkPayload`,
257
+ name: names.bulkUploadPayloadType,
268
258
  fields: {
269
259
  files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
270
260
  },
271
261
  });
272
- const bulkMutationName = `upload${filesTypeName}s`;
262
+ const bulkMutationName = names.bulkUploadMutation;
273
263
  log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
274
264
  newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
275
265
  description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
@@ -626,8 +616,9 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
626
616
  }
627
617
  // Auto-derive ltree path from custom key directory (only when has_path_shares)
628
618
  const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
629
- // Create file record
630
- 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;
631
622
  const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
632
623
  const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
633
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,51 +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
- sm.has_confirm_upload,
73
- NULL AS entity_schema,
74
- NULL AS entity_table
75
- FROM metaschema_modules_public.storage_module sm
76
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
77
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
78
- JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
79
- JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
80
- WHERE sm.database_id = $1
81
- AND sm.scope = 'app'
82
- LIMIT 1
83
- `;
84
- /**
85
- * SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
86
- *
87
- * Returns all storage modules with their entity table names for ownerId resolution.
88
- * 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.
89
45
  */
90
46
  const ALL_STORAGE_MODULES_QUERY = `
91
47
  SELECT
@@ -152,95 +108,6 @@ function buildConfig(row) {
152
108
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
153
109
  };
154
110
  }
155
- /**
156
- * Resolve the app-level storage module config for a database, using the LRU cache.
157
- *
158
- * This is the default path when no ownerId is provided. It returns the
159
- * storage module with scope = 'app' (app-level / database-wide).
160
- *
161
- * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
162
- * @param databaseId - The metaschema database UUID
163
- * @returns StorageModuleConfig or null if no storage module is provisioned
164
- */
165
- async function getStorageModuleConfig(pgClient, databaseId) {
166
- const cacheKey = `storage:${databaseId}:app`;
167
- const cached = storageModuleCache.get(cacheKey);
168
- if (cached) {
169
- return cached;
170
- }
171
- log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
172
- const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
173
- if (result.rows.length === 0) {
174
- log.warn(`No app-level storage module found for database ${databaseId}`);
175
- return null;
176
- }
177
- const config = buildConfig(result.rows[0]);
178
- storageModuleCache.set(cacheKey, config);
179
- log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
180
- return config;
181
- }
182
- /**
183
- * Resolve the storage module config for a specific owner entity.
184
- *
185
- * When ownerId is provided, this function:
186
- * 1. Loads ALL storage modules for the database (cached)
187
- * 2. Finds which entity-scoped module contains the ownerId in its entity table
188
- * 3. Returns that module's config
189
- *
190
- * This is the core of Option C — the ownerId tells us which scope to use.
191
- *
192
- * @param pgClient - A pg client from the Graphile context
193
- * @param databaseId - The metaschema database UUID
194
- * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
195
- * @returns StorageModuleConfig or null if no matching module found
196
- */
197
- async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
198
- // Check if we already have a cached mapping for this ownerId
199
- const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
200
- const cachedOwner = storageModuleCache.get(ownerCacheKey);
201
- if (cachedOwner) {
202
- return cachedOwner;
203
- }
204
- // Load all storage modules for this database
205
- const allModulesCacheKey = `storage:${databaseId}:all`;
206
- let allConfigs;
207
- const cachedAll = storageModuleCache.get(allModulesCacheKey);
208
- if (cachedAll) {
209
- // We stored a sentinel; re-derive from individual caches
210
- // Actually, let's just query fresh — this is the cache-miss path
211
- allConfigs = [];
212
- }
213
- else {
214
- allConfigs = [];
215
- }
216
- if (allConfigs.length === 0) {
217
- log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
218
- const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
219
- allConfigs = result.rows.map(buildConfig);
220
- // Cache each individual config by its scope
221
- for (const config of allConfigs) {
222
- const key = `storage:${databaseId}:scope:${config.scope}`;
223
- storageModuleCache.set(key, config);
224
- }
225
- }
226
- // Find entity-scoped modules and probe their entity tables for the ownerId
227
- const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
228
- for (const mod of entityModules) {
229
- const probeResult = await pgClient.query({
230
- text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
231
- values: [ownerId],
232
- });
233
- if (probeResult.rows.length > 0) {
234
- // Found the matching module — cache the ownerId→module mapping
235
- storageModuleCache.set(ownerCacheKey, mod);
236
- log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
237
- `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`);
238
- return mod;
239
- }
240
- }
241
- log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
242
- return null;
243
- }
244
111
  /**
245
112
  * Resolve the storage module that owns a specific file by probing all file tables.
246
113
  *
@@ -286,11 +153,6 @@ async function loadAllStorageModules(pgClient, databaseId) {
286
153
  log.debug(`Loading all storage modules for database ${databaseId}`);
287
154
  const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
288
155
  const configs = result.rows.map(buildConfig);
289
- // Cache each individual config by its scope
290
- for (const config of configs) {
291
- const key = `storage:${databaseId}:scope:${config.scope}`;
292
- storageModuleCache.set(key, config);
293
- }
294
156
  // Store the full list under a sentinel key (only if non-empty to avoid caching failed lookups)
295
157
  if (configs.length > 0) {
296
158
  const sentinel = { ...configs[0], _allConfigs: configs };
@@ -367,9 +229,9 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
367
229
  return cached;
368
230
  }
369
231
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
370
- // Entity-scoped buckets use (owner_id, key) composite lookup;
371
- // app-level buckets just use key.
372
- 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;
373
235
  const hasOwner = ownerId && isEntityScoped;
374
236
  const result = await pgClient.query({
375
237
  text: hasOwner