graphile-presigned-url-plugin 1.14.1 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/download-url-field.js +2 -1
- package/esm/download-url-field.js +2 -1
- package/esm/index.d.ts +1 -1
- package/esm/index.js +1 -1
- package/esm/managed-upload.d.ts +12 -4
- package/esm/managed-upload.js +26 -9
- package/esm/plugin.d.ts +2 -0
- package/esm/plugin.js +24 -33
- package/esm/storage-module-cache.d.ts +0 -41
- package/esm/storage-module-cache.js +6 -142
- package/index.d.ts +1 -1
- package/index.js +1 -3
- package/managed-upload.d.ts +12 -4
- package/managed-upload.js +26 -9
- package/package.json +3 -2
- package/plugin.d.ts +2 -0
- package/plugin.js +24 -33
- package/storage-module-cache.d.ts +0 -41
- package/storage-module-cache.js +6 -144
package/download-url-field.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
40
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
package/esm/managed-upload.d.ts
CHANGED
|
@@ -68,11 +68,19 @@ export interface ManagedUploadTarget {
|
|
|
68
68
|
* * a registered column names its storage module, and either a logical bucket
|
|
69
69
|
* key or the reserved default tag for its declared publicness;
|
|
70
70
|
* * an unregistered column (a bare `image`/`upload` on a database provisioned
|
|
71
|
-
* before the registry) falls back to the
|
|
72
|
-
* reserved default tag. That is a *tenant* default, not
|
|
71
|
+
* before the registry) falls back to the database's single global storage
|
|
72
|
+
* plane and the same reserved default tag. That is a *tenant* default, not
|
|
73
|
+
* an environment one.
|
|
73
74
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
75
|
+
* The fallback is by shape, not by scope name: a global plane is one with no
|
|
76
|
+
* entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
|
|
77
|
+
* whether it registered as 'database', 'platform', or 'app'. Matching the name
|
|
78
|
+
* instead sent every tenant whose plane registers as 'database' — i.e. every
|
|
79
|
+
* database-scope plane — to STORAGE_MODULE_NOT_FOUND.
|
|
80
|
+
*
|
|
81
|
+
* A database with no global plane raises, and so does one with several: there is
|
|
82
|
+
* nowhere unambiguously tenant-owned to put the bytes, and the deployment's
|
|
83
|
+
* configured bucket is not an answer.
|
|
76
84
|
*/
|
|
77
85
|
export declare function resolveManagedUploadTarget(args: {
|
|
78
86
|
options: PresignedUrlPluginOptions;
|
package/esm/managed-upload.js
CHANGED
|
@@ -55,11 +55,19 @@ export function buildFileProjection(file, bucket, s3) {
|
|
|
55
55
|
* * a registered column names its storage module, and either a logical bucket
|
|
56
56
|
* key or the reserved default tag for its declared publicness;
|
|
57
57
|
* * an unregistered column (a bare `image`/`upload` on a database provisioned
|
|
58
|
-
* before the registry) falls back to the
|
|
59
|
-
* reserved default tag. That is a *tenant* default, not
|
|
58
|
+
* before the registry) falls back to the database's single global storage
|
|
59
|
+
* plane and the same reserved default tag. That is a *tenant* default, not
|
|
60
|
+
* an environment one.
|
|
60
61
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
62
|
+
* The fallback is by shape, not by scope name: a global plane is one with no
|
|
63
|
+
* entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
|
|
64
|
+
* whether it registered as 'database', 'platform', or 'app'. Matching the name
|
|
65
|
+
* instead sent every tenant whose plane registers as 'database' — i.e. every
|
|
66
|
+
* database-scope plane — to STORAGE_MODULE_NOT_FOUND.
|
|
67
|
+
*
|
|
68
|
+
* A database with no global plane raises, and so does one with several: there is
|
|
69
|
+
* nowhere unambiguously tenant-owned to put the bytes, and the deployment's
|
|
70
|
+
* configured bucket is not an answer.
|
|
63
71
|
*/
|
|
64
72
|
export async function resolveManagedUploadTarget(args) {
|
|
65
73
|
const { options, withPgClient, pgSettings, databaseId, field, defaultPublicAccess } = args;
|
|
@@ -71,7 +79,7 @@ export async function resolveManagedUploadTarget(args) {
|
|
|
71
79
|
}
|
|
72
80
|
catch (err) {
|
|
73
81
|
// An unregistered column is a legitimate state (it predates the registry)
|
|
74
|
-
// and falls back to the tenant's
|
|
82
|
+
// and falls back to the tenant's global plane below. Any other
|
|
75
83
|
// failure — a broken connection, a missing registry table — is not.
|
|
76
84
|
if (err?.name === 'FileRefFieldNotRegisteredError')
|
|
77
85
|
return null;
|
|
@@ -79,19 +87,28 @@ export async function resolveManagedUploadTarget(args) {
|
|
|
79
87
|
}
|
|
80
88
|
});
|
|
81
89
|
const allConfigs = await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId));
|
|
90
|
+
const globalConfigs = allConfigs.filter((c) => c.entityTableId === null);
|
|
91
|
+
if (!binding && globalConfigs.length > 1) {
|
|
92
|
+
// Several global planes and no registry row to disambiguate: picking one is
|
|
93
|
+
// picking a tenant's bucket at random. Name them and refuse.
|
|
94
|
+
throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
|
|
95
|
+
`unregistered upload column and database ${databaseId} has ${globalConfigs.length} global ` +
|
|
96
|
+
`storage planes (scopes: ${globalConfigs.map((c) => c.scope).join(', ')}); register the column ` +
|
|
97
|
+
'so it names the plane it writes to');
|
|
98
|
+
}
|
|
82
99
|
const storageConfig = binding
|
|
83
100
|
? allConfigs.find((c) => c.id === binding.storageModuleId)
|
|
84
|
-
:
|
|
101
|
+
: globalConfigs[0];
|
|
85
102
|
if (!storageConfig) {
|
|
86
103
|
throw new Error(binding
|
|
87
104
|
? `STORAGE_MODULE_NOT_FOUND: file_ref_field ${binding.id} names storage module ` +
|
|
88
105
|
`${binding.storageModuleId}, which database ${databaseId} does not have`
|
|
89
106
|
: `STORAGE_MODULE_NOT_FOUND: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
|
|
90
|
-
`unregistered upload column and database ${databaseId} has no
|
|
107
|
+
`unregistered upload column and database ${databaseId} has no global storage plane to ` +
|
|
91
108
|
'default to; there is no environment bucket to fall back to');
|
|
92
109
|
}
|
|
93
|
-
if (storageConfig.
|
|
94
|
-
// An entity-
|
|
110
|
+
if (storageConfig.entityTableId !== null) {
|
|
111
|
+
// An entity-keyed module resolves its bucket per owning row, and a
|
|
95
112
|
// multipart column write does not carry one. Refuse rather than write a
|
|
96
113
|
// tenant's file into whichever bucket happened to resolve.
|
|
97
114
|
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
|
-
|
|
97
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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-
|
|
358
|
-
// app-
|
|
359
|
-
const isEntityScoped = storageConfig.
|
|
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,
|
|
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.
|
|
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.d.ts
CHANGED
|
@@ -68,11 +68,19 @@ export interface ManagedUploadTarget {
|
|
|
68
68
|
* * a registered column names its storage module, and either a logical bucket
|
|
69
69
|
* key or the reserved default tag for its declared publicness;
|
|
70
70
|
* * an unregistered column (a bare `image`/`upload` on a database provisioned
|
|
71
|
-
* before the registry) falls back to the
|
|
72
|
-
* reserved default tag. That is a *tenant* default, not
|
|
71
|
+
* before the registry) falls back to the database's single global storage
|
|
72
|
+
* plane and the same reserved default tag. That is a *tenant* default, not
|
|
73
|
+
* an environment one.
|
|
73
74
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
75
|
+
* The fallback is by shape, not by scope name: a global plane is one with no
|
|
76
|
+
* entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
|
|
77
|
+
* whether it registered as 'database', 'platform', or 'app'. Matching the name
|
|
78
|
+
* instead sent every tenant whose plane registers as 'database' — i.e. every
|
|
79
|
+
* database-scope plane — to STORAGE_MODULE_NOT_FOUND.
|
|
80
|
+
*
|
|
81
|
+
* A database with no global plane raises, and so does one with several: there is
|
|
82
|
+
* nowhere unambiguously tenant-owned to put the bytes, and the deployment's
|
|
83
|
+
* configured bucket is not an answer.
|
|
76
84
|
*/
|
|
77
85
|
export declare function resolveManagedUploadTarget(args: {
|
|
78
86
|
options: PresignedUrlPluginOptions;
|
package/managed-upload.js
CHANGED
|
@@ -61,11 +61,19 @@ function buildFileProjection(file, bucket, s3) {
|
|
|
61
61
|
* * a registered column names its storage module, and either a logical bucket
|
|
62
62
|
* key or the reserved default tag for its declared publicness;
|
|
63
63
|
* * an unregistered column (a bare `image`/`upload` on a database provisioned
|
|
64
|
-
* before the registry) falls back to the
|
|
65
|
-
* reserved default tag. That is a *tenant* default, not
|
|
64
|
+
* before the registry) falls back to the database's single global storage
|
|
65
|
+
* plane and the same reserved default tag. That is a *tenant* default, not
|
|
66
|
+
* an environment one.
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
68
|
+
* The fallback is by shape, not by scope name: a global plane is one with no
|
|
69
|
+
* entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
|
|
70
|
+
* whether it registered as 'database', 'platform', or 'app'. Matching the name
|
|
71
|
+
* instead sent every tenant whose plane registers as 'database' — i.e. every
|
|
72
|
+
* database-scope plane — to STORAGE_MODULE_NOT_FOUND.
|
|
73
|
+
*
|
|
74
|
+
* A database with no global plane raises, and so does one with several: there is
|
|
75
|
+
* nowhere unambiguously tenant-owned to put the bytes, and the deployment's
|
|
76
|
+
* configured bucket is not an answer.
|
|
69
77
|
*/
|
|
70
78
|
async function resolveManagedUploadTarget(args) {
|
|
71
79
|
const { options, withPgClient, pgSettings, databaseId, field, defaultPublicAccess } = args;
|
|
@@ -77,7 +85,7 @@ async function resolveManagedUploadTarget(args) {
|
|
|
77
85
|
}
|
|
78
86
|
catch (err) {
|
|
79
87
|
// An unregistered column is a legitimate state (it predates the registry)
|
|
80
|
-
// and falls back to the tenant's
|
|
88
|
+
// and falls back to the tenant's global plane below. Any other
|
|
81
89
|
// failure — a broken connection, a missing registry table — is not.
|
|
82
90
|
if (err?.name === 'FileRefFieldNotRegisteredError')
|
|
83
91
|
return null;
|
|
@@ -85,19 +93,28 @@ async function resolveManagedUploadTarget(args) {
|
|
|
85
93
|
}
|
|
86
94
|
});
|
|
87
95
|
const allConfigs = await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId));
|
|
96
|
+
const globalConfigs = allConfigs.filter((c) => c.entityTableId === null);
|
|
97
|
+
if (!binding && globalConfigs.length > 1) {
|
|
98
|
+
// Several global planes and no registry row to disambiguate: picking one is
|
|
99
|
+
// picking a tenant's bucket at random. Name them and refuse.
|
|
100
|
+
throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
|
|
101
|
+
`unregistered upload column and database ${databaseId} has ${globalConfigs.length} global ` +
|
|
102
|
+
`storage planes (scopes: ${globalConfigs.map((c) => c.scope).join(', ')}); register the column ` +
|
|
103
|
+
'so it names the plane it writes to');
|
|
104
|
+
}
|
|
88
105
|
const storageConfig = binding
|
|
89
106
|
? allConfigs.find((c) => c.id === binding.storageModuleId)
|
|
90
|
-
:
|
|
107
|
+
: globalConfigs[0];
|
|
91
108
|
if (!storageConfig) {
|
|
92
109
|
throw new Error(binding
|
|
93
110
|
? `STORAGE_MODULE_NOT_FOUND: file_ref_field ${binding.id} names storage module ` +
|
|
94
111
|
`${binding.storageModuleId}, which database ${databaseId} does not have`
|
|
95
112
|
: `STORAGE_MODULE_NOT_FOUND: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
|
|
96
|
-
`unregistered upload column and database ${databaseId} has no
|
|
113
|
+
`unregistered upload column and database ${databaseId} has no global storage plane to ` +
|
|
97
114
|
'default to; there is no environment bucket to fall back to');
|
|
98
115
|
}
|
|
99
|
-
if (storageConfig.
|
|
100
|
-
// An entity-
|
|
116
|
+
if (storageConfig.entityTableId !== null) {
|
|
117
|
+
// An entity-keyed module resolves its bucket per owning row, and a
|
|
101
118
|
// multipart column write does not carry one. Refuse rather than write a
|
|
102
119
|
// tenant's file into whichever bucket happened to resolve.
|
|
103
120
|
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.
|
|
3
|
+
"version": "1.15.1",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -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": "
|
|
65
|
+
"gitHead": "e35db12526094b515596ced2dd27a1ec8edd99d5"
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
*
|
package/storage-module-cache.js
CHANGED
|
@@ -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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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-
|
|
371
|
-
// app-
|
|
372
|
-
const isEntityScoped = storageConfig.
|
|
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
|