graphile-presigned-url-plugin 1.9.3 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/download-url-field.js +25 -25
- package/esm/download-url-field.js +26 -26
- package/esm/plugin.js +142 -73
- package/esm/request-pg-client.d.ts +29 -0
- package/esm/request-pg-client.js +37 -0
- package/esm/storage-module-cache.d.ts +11 -0
- package/esm/storage-module-cache.js +14 -2
- package/esm/types.d.ts +7 -0
- package/package.json +4 -4
- package/plugin.js +141 -72
- package/request-pg-client.d.ts +29 -0
- package/request-pg-client.js +40 -0
- package/storage-module-cache.d.ts +11 -0
- package/storage-module-cache.js +15 -2
- package/types.d.ts +7 -0
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 request_pg_client_1 = require("./request-pg-client");
|
|
27
28
|
const s3_signer_1 = require("./s3-signer");
|
|
28
29
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
29
30
|
const log = new logger_1.Logger('graphile-presigned-url:download-url');
|
|
@@ -49,21 +50,21 @@ function resolveS3(options) {
|
|
|
49
50
|
return options.s3;
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
|
-
* Build a per-database S3Config
|
|
53
|
-
*
|
|
53
|
+
* Build a per-database S3Config for a *known* physical bucket. `physicalName`
|
|
54
|
+
* is required — the stored coordinate on the bucket row is the only source;
|
|
55
|
+
* no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
|
|
54
56
|
*/
|
|
55
|
-
function resolveS3ForDatabase(options, storageConfig,
|
|
57
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
56
58
|
const globalS3 = resolveS3(options);
|
|
57
|
-
const
|
|
58
|
-
?
|
|
59
|
-
: globalS3.
|
|
60
|
-
|
|
61
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
59
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
60
|
+
? storageConfig.publicUrlPrefix
|
|
61
|
+
: globalS3.publicUrlPrefix;
|
|
62
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
62
63
|
return globalS3;
|
|
63
64
|
}
|
|
64
65
|
return {
|
|
65
66
|
...globalS3,
|
|
66
|
-
bucket,
|
|
67
|
+
bucket: physicalName,
|
|
67
68
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
68
69
|
};
|
|
69
70
|
}
|
|
@@ -115,7 +116,7 @@ function createDownloadUrlPlugin(options) {
|
|
|
115
116
|
let downloadUrlExpirySeconds = 3600;
|
|
116
117
|
try {
|
|
117
118
|
if (withPgClient && pgSettings) {
|
|
118
|
-
const databaseId = await withPgClient
|
|
119
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
119
120
|
const dbResult = await pgClient.query({
|
|
120
121
|
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
121
122
|
});
|
|
@@ -126,25 +127,24 @@ function createDownloadUrlPlugin(options) {
|
|
|
126
127
|
const config = databaseId
|
|
127
128
|
? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
|
|
128
129
|
: null;
|
|
129
|
-
const resolved = config
|
|
130
|
-
? await withPgClient
|
|
131
|
-
// Look up the
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (bucketResult.rows[0]?.key) {
|
|
139
|
-
bucketKey = bucketResult.rows[0].key;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return { config, databaseId, bucketKey };
|
|
130
|
+
const resolved = config && bucketId
|
|
131
|
+
? await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
132
|
+
// Look up the stored physical coordinate for scoped S3 resolution
|
|
133
|
+
const bucketResult = await pgClient.query({
|
|
134
|
+
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
135
|
+
values: [bucketId],
|
|
136
|
+
});
|
|
137
|
+
const row = bucketResult.rows[0];
|
|
138
|
+
return row ? { config, physicalName: (0, storage_module_cache_1.storedPhysicalName)(row) } : null;
|
|
143
139
|
})
|
|
144
140
|
: null;
|
|
145
141
|
if (resolved) {
|
|
142
|
+
if (resolved.physicalName === null) {
|
|
143
|
+
// No physical bucket was ever provisioned — no object can exist.
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
146
|
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
147
|
-
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.
|
|
147
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
}
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
import 'graphile-build';
|
|
22
22
|
import { Logger } from '@pgpmjs/logger';
|
|
23
23
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
24
|
+
import { withRequestPgClient } from './request-pg-client';
|
|
24
25
|
import { generatePresignedGetUrl } from './s3-signer';
|
|
25
|
-
import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache';
|
|
26
|
+
import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
26
27
|
const log = new Logger('graphile-presigned-url:download-url');
|
|
27
28
|
/**
|
|
28
29
|
* Creates the downloadUrl computed field plugin.
|
|
@@ -46,21 +47,21 @@ function resolveS3(options) {
|
|
|
46
47
|
return options.s3;
|
|
47
48
|
}
|
|
48
49
|
/**
|
|
49
|
-
* Build a per-database S3Config
|
|
50
|
-
*
|
|
50
|
+
* Build a per-database S3Config for a *known* physical bucket. `physicalName`
|
|
51
|
+
* is required — the stored coordinate on the bucket row is the only source;
|
|
52
|
+
* no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
|
|
51
53
|
*/
|
|
52
|
-
function resolveS3ForDatabase(options, storageConfig,
|
|
54
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
53
55
|
const globalS3 = resolveS3(options);
|
|
54
|
-
const
|
|
55
|
-
?
|
|
56
|
-
: globalS3.
|
|
57
|
-
|
|
58
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
56
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
57
|
+
? storageConfig.publicUrlPrefix
|
|
58
|
+
: globalS3.publicUrlPrefix;
|
|
59
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
59
60
|
return globalS3;
|
|
60
61
|
}
|
|
61
62
|
return {
|
|
62
63
|
...globalS3,
|
|
63
|
-
bucket,
|
|
64
|
+
bucket: physicalName,
|
|
64
65
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
65
66
|
};
|
|
66
67
|
}
|
|
@@ -112,7 +113,7 @@ export function createDownloadUrlPlugin(options) {
|
|
|
112
113
|
let downloadUrlExpirySeconds = 3600;
|
|
113
114
|
try {
|
|
114
115
|
if (withPgClient && pgSettings) {
|
|
115
|
-
const databaseId = await withPgClient
|
|
116
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
116
117
|
const dbResult = await pgClient.query({
|
|
117
118
|
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
118
119
|
});
|
|
@@ -123,25 +124,24 @@ export function createDownloadUrlPlugin(options) {
|
|
|
123
124
|
const config = databaseId
|
|
124
125
|
? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
|
|
125
126
|
: null;
|
|
126
|
-
const resolved = config
|
|
127
|
-
? await withPgClient
|
|
128
|
-
// Look up the
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (bucketResult.rows[0]?.key) {
|
|
136
|
-
bucketKey = bucketResult.rows[0].key;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { config, databaseId, bucketKey };
|
|
127
|
+
const resolved = config && bucketId
|
|
128
|
+
? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
129
|
+
// Look up the stored physical coordinate for scoped S3 resolution
|
|
130
|
+
const bucketResult = await pgClient.query({
|
|
131
|
+
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
132
|
+
values: [bucketId],
|
|
133
|
+
});
|
|
134
|
+
const row = bucketResult.rows[0];
|
|
135
|
+
return row ? { config, physicalName: storedPhysicalName(row) } : null;
|
|
140
136
|
})
|
|
141
137
|
: null;
|
|
142
138
|
if (resolved) {
|
|
139
|
+
if (resolved.physicalName === null) {
|
|
140
|
+
// No physical bucket was ever provisioned — no object can exist.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
143
|
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
144
|
-
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.
|
|
144
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
package/esm/plugin.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
import 'graphile-build';
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
|
+
import { withRequestPgClient } from './request-pg-client';
|
|
22
23
|
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
23
|
-
import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec } from './storage-module-cache';
|
|
24
|
+
import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
24
25
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
25
26
|
// --- Protocol-level constants (not configurable) ---
|
|
26
27
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
@@ -74,30 +75,79 @@ function resolveS3(options) {
|
|
|
74
75
|
}
|
|
75
76
|
return options.s3;
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
80
|
+
*
|
|
81
|
+
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
82
|
+
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
83
|
+
* the row is authoritative and this function must not be consulted again.
|
|
84
|
+
*/
|
|
85
|
+
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
86
|
+
if (options.resolveBucketName) {
|
|
87
|
+
return options.resolveBucketName(databaseId, bucketKey);
|
|
88
|
+
}
|
|
89
|
+
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
90
|
+
return resolveS3(options).bucket;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
94
|
+
* required — callers must resolve the coordinate (stored row value, or a
|
|
95
|
+
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
96
|
+
*/
|
|
97
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
78
98
|
const globalS3 = resolveS3(options);
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
: globalS3.
|
|
82
|
-
|
|
83
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
99
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
100
|
+
? storageConfig.publicUrlPrefix
|
|
101
|
+
: globalS3.publicUrlPrefix;
|
|
102
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
84
103
|
return globalS3;
|
|
85
104
|
}
|
|
86
105
|
return {
|
|
87
106
|
...globalS3,
|
|
88
|
-
bucket,
|
|
107
|
+
bucket: physicalName,
|
|
89
108
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
90
109
|
};
|
|
91
110
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
/**
|
|
112
|
+
* First provision of a logical bucket: mint a name, create the physical S3
|
|
113
|
+
* bucket, and record the exact name on the source row. Returns the recorded
|
|
114
|
+
* physical name.
|
|
115
|
+
*
|
|
116
|
+
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
117
|
+
* value is the durable coordinate: route resolution and every later read use
|
|
118
|
+
* it verbatim; nothing is recomputed.
|
|
119
|
+
*
|
|
120
|
+
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
121
|
+
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
122
|
+
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
123
|
+
* claim, because the buckets table's catalog-sync trigger calls
|
|
124
|
+
* `jwt_private.current_database_id()` and would otherwise raise
|
|
125
|
+
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
126
|
+
* write's transaction without switching off the privileged role.
|
|
127
|
+
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
128
|
+
* the recorded name without a DB round-trip.
|
|
129
|
+
*/
|
|
130
|
+
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
131
|
+
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
132
|
+
if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
|
|
133
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
134
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
135
|
+
markS3BucketProvisioned(s3BucketName);
|
|
136
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
137
|
+
}
|
|
138
|
+
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
139
|
+
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
140
|
+
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
141
|
+
// write runs under the resolved database claim (privileged role preserved).
|
|
142
|
+
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
143
|
+
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
144
|
+
SET physical_name = $1
|
|
145
|
+
WHERE id = $2 AND physical_name IS NULL`,
|
|
146
|
+
values: [s3BucketName, bucket.id],
|
|
147
|
+
}));
|
|
148
|
+
bucket.physical_name = s3BucketName;
|
|
149
|
+
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
150
|
+
return s3BucketName;
|
|
101
151
|
}
|
|
102
152
|
// --- Plugin factory ---
|
|
103
153
|
export function createPresignedUrlPlugin(options) {
|
|
@@ -202,7 +252,10 @@ export function createPresignedUrlPlugin(options) {
|
|
|
202
252
|
pgSettings: $pgSettings,
|
|
203
253
|
});
|
|
204
254
|
return lambda($combined, async (vals) => {
|
|
205
|
-
|
|
255
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
256
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
257
|
+
// across every statement (see withRequestPgClient).
|
|
258
|
+
const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
206
259
|
if (!databaseId)
|
|
207
260
|
throw new Error('DATABASE_NOT_FOUND');
|
|
208
261
|
// Module registration is server config, not user data:
|
|
@@ -211,22 +264,24 @@ export function createPresignedUrlPlugin(options) {
|
|
|
211
264
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
212
265
|
if (!storageConfig)
|
|
213
266
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
267
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
268
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
269
|
+
if (!bucket)
|
|
270
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
271
|
+
// First provision mints + records the coordinate; afterwards the
|
|
272
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
273
|
+
const physicalName = bucket.physical_name === null
|
|
274
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
275
|
+
: bucket.physical_name;
|
|
276
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
277
|
+
// File row INSERT under the request role (RLS enforced).
|
|
278
|
+
return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
279
|
+
contentHash: vals.contentHash,
|
|
280
|
+
contentType: vals.contentType,
|
|
281
|
+
size: vals.size,
|
|
282
|
+
filename: vals.filename,
|
|
283
|
+
key: vals.customKey,
|
|
284
|
+
}));
|
|
230
285
|
});
|
|
231
286
|
},
|
|
232
287
|
});
|
|
@@ -291,7 +346,10 @@ export function createPresignedUrlPlugin(options) {
|
|
|
291
346
|
pgSettings: $pgSettings,
|
|
292
347
|
});
|
|
293
348
|
return lambda($combined, async (vals) => {
|
|
294
|
-
|
|
349
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
350
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
351
|
+
// across every statement (see withRequestPgClient).
|
|
352
|
+
const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
295
353
|
if (!databaseId)
|
|
296
354
|
throw new Error('DATABASE_NOT_FOUND');
|
|
297
355
|
// Module registration is server config, not user data:
|
|
@@ -300,34 +358,38 @@ export function createPresignedUrlPlugin(options) {
|
|
|
300
358
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
301
359
|
if (!storageConfig)
|
|
302
360
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
361
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
362
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
363
|
+
if (!bucket)
|
|
364
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
365
|
+
// Enforce bulk upload limits
|
|
366
|
+
const filesArray = vals.files;
|
|
367
|
+
if (filesArray.length > storageConfig.maxBulkFiles) {
|
|
368
|
+
throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
|
|
369
|
+
}
|
|
370
|
+
const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
371
|
+
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
372
|
+
throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
|
|
373
|
+
}
|
|
374
|
+
// First provision mints + records the coordinate; afterwards the
|
|
375
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
376
|
+
const physicalName = bucket.physical_name === null
|
|
377
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
378
|
+
: bucket.physical_name;
|
|
379
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
380
|
+
// File row INSERTs under the request role (RLS enforced).
|
|
381
|
+
return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
382
|
+
const results = [];
|
|
383
|
+
for (const file of filesArray) {
|
|
384
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
385
|
+
contentHash: file.contentHash,
|
|
386
|
+
contentType: file.contentType,
|
|
387
|
+
size: file.size,
|
|
388
|
+
filename: file.filename,
|
|
389
|
+
key: file.key,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
return { files: results };
|
|
331
393
|
});
|
|
332
394
|
});
|
|
333
395
|
},
|
|
@@ -373,7 +435,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
373
435
|
const pgSettings = graphqlContext.pgSettings;
|
|
374
436
|
if (withPgClient) {
|
|
375
437
|
try {
|
|
376
|
-
const databaseId = await withPgClient
|
|
438
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
377
439
|
// Module registration is server config, not user data:
|
|
378
440
|
// resolve it without the request role's pgSettings.
|
|
379
441
|
const allConfigs = databaseId
|
|
@@ -381,7 +443,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
381
443
|
: [];
|
|
382
444
|
const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
|
|
383
445
|
if (storageConfig) {
|
|
384
|
-
await withPgClient
|
|
446
|
+
await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
385
447
|
// Read the file row (RLS enforced)
|
|
386
448
|
const result = await pgClient.query({
|
|
387
449
|
text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
@@ -406,7 +468,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
406
468
|
const pgSettings = graphqlContext.pgSettings;
|
|
407
469
|
if (withPgClient) {
|
|
408
470
|
try {
|
|
409
|
-
const databaseId = await withPgClient
|
|
471
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
410
472
|
// Module registration is server config, not user data:
|
|
411
473
|
// resolve it without the request role's pgSettings.
|
|
412
474
|
const allConfigs = databaseId
|
|
@@ -414,7 +476,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
414
476
|
: [];
|
|
415
477
|
const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
|
|
416
478
|
if (storageConfig)
|
|
417
|
-
await withPgClient
|
|
479
|
+
await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
418
480
|
// Check refcount: any other file with the same key in this bucket?
|
|
419
481
|
const refResult = await pgClient.query({
|
|
420
482
|
text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
|
|
@@ -425,18 +487,25 @@ export function createPresignedUrlPlugin(options) {
|
|
|
425
487
|
log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
|
|
426
488
|
return;
|
|
427
489
|
}
|
|
428
|
-
// No other references — attempt sync S3 delete
|
|
429
|
-
//
|
|
490
|
+
// No other references — attempt sync S3 delete.
|
|
491
|
+
// Read the stored physical coordinate; the object lives in the
|
|
492
|
+
// recorded bucket, never a recomputed prefix name.
|
|
430
493
|
const bucketResult = await pgClient.query({
|
|
431
|
-
text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
494
|
+
text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
432
495
|
values: [fileRow.bucket_id],
|
|
433
496
|
});
|
|
434
|
-
const
|
|
435
|
-
if (!
|
|
497
|
+
const bucketRow = bucketResult.rows[0];
|
|
498
|
+
if (!bucketRow) {
|
|
436
499
|
log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
|
|
437
500
|
return;
|
|
438
501
|
}
|
|
439
|
-
const
|
|
502
|
+
const physicalName = storedPhysicalName(bucketRow);
|
|
503
|
+
if (physicalName === null) {
|
|
504
|
+
// No physical bucket was ever provisioned — there is no object to delete.
|
|
505
|
+
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
440
509
|
await deleteS3Object(s3ForDb, fileRow.key);
|
|
441
510
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
442
511
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
export interface RequestPgClient {
|
|
20
|
+
query(opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values?: unknown[];
|
|
23
|
+
}): Promise<{
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
}>;
|
|
26
|
+
withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
|
|
29
|
+
export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
async function applyRequestSettings(tx, pgSettings) {
|
|
20
|
+
if (!pgSettings)
|
|
21
|
+
return;
|
|
22
|
+
const entries = Object.entries(pgSettings)
|
|
23
|
+
.filter(([, value]) => value != null)
|
|
24
|
+
.map(([key, value]) => [key, String(value)]);
|
|
25
|
+
if (entries.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
await tx.query({
|
|
28
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
29
|
+
values: [JSON.stringify(entries)],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function withRequestPgClient(withPgClient, pgSettings, cb) {
|
|
33
|
+
return withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
34
|
+
await applyRequestSettings(tx, pgSettings);
|
|
35
|
+
return cb(tx);
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
|
|
|
100
100
|
};
|
|
101
101
|
sqlType?: string;
|
|
102
102
|
}, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
|
|
103
|
+
/**
|
|
104
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
105
|
+
*
|
|
106
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
107
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
108
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
109
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
110
|
+
*/
|
|
111
|
+
export declare function storedPhysicalName(row: {
|
|
112
|
+
physical_name?: string | null;
|
|
113
|
+
}): string | null;
|
|
103
114
|
/**
|
|
104
115
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
105
116
|
*
|
|
@@ -320,6 +320,17 @@ const bucketCache = new LRUCache({
|
|
|
320
320
|
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
321
321
|
updateAgeOnGet: true,
|
|
322
322
|
});
|
|
323
|
+
/**
|
|
324
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
325
|
+
*
|
|
326
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
327
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
328
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
329
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
330
|
+
*/
|
|
331
|
+
export function storedPhysicalName(row) {
|
|
332
|
+
return row.physical_name == null ? null : row.physical_name;
|
|
333
|
+
}
|
|
323
334
|
/**
|
|
324
335
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
325
336
|
*
|
|
@@ -346,11 +357,11 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
346
357
|
const hasOwner = ownerId && isEntityScoped;
|
|
347
358
|
const result = await pgClient.query({
|
|
348
359
|
text: hasOwner
|
|
349
|
-
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
|
|
360
|
+
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
350
361
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
351
362
|
WHERE key = $1 AND owner_id = $2
|
|
352
363
|
LIMIT 1`
|
|
353
|
-
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
|
|
364
|
+
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
354
365
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
355
366
|
WHERE key = $1
|
|
356
367
|
LIMIT 1`,
|
|
@@ -369,6 +380,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
369
380
|
allowed_mime_types: row.allowed_mime_types,
|
|
370
381
|
max_file_size: row.max_file_size,
|
|
371
382
|
allow_custom_keys: row.allow_custom_keys ?? false,
|
|
383
|
+
physical_name: storedPhysicalName(row),
|
|
372
384
|
};
|
|
373
385
|
bucketCache.set(cacheKey, config);
|
|
374
386
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
|
package/esm/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface BucketConfig {
|
|
|
11
11
|
allowed_mime_types: string[] | null;
|
|
12
12
|
max_file_size: number | null;
|
|
13
13
|
allow_custom_keys: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The physical S3/MinIO bucket name recorded when the physical bucket was
|
|
16
|
+
* first provisioned. NULL until the first upload provisions it. Once set,
|
|
17
|
+
* it is the source of truth for the physical bucket — reads never
|
|
18
|
+
* reconstruct the name from a prefix convention.
|
|
19
|
+
*/
|
|
20
|
+
physical_name: string | null;
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* Storage module configuration resolved from metaschema for a given database.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@aws-sdk/client-s3": "^3.1052.0",
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
|
-
"@pgpmjs/logger": "^2.
|
|
45
|
+
"@pgpmjs/logger": "^2.24.0",
|
|
46
46
|
"@pgsql/quotes": "^18.2.0",
|
|
47
47
|
"lru-cache": "^11.2.7"
|
|
48
48
|
},
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"postgraphile": "5.0.3"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@constructive-io/s3-utils": "^2.
|
|
59
|
+
"@constructive-io/s3-utils": "^2.30.0",
|
|
60
60
|
"@types/node": "^22.19.11",
|
|
61
61
|
"makage": "^0.3.0"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "d508453a32a2f1d70ff6916871046615226e6f33"
|
|
64
64
|
}
|
package/plugin.js
CHANGED
|
@@ -23,6 +23,7 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
23
|
require("graphile-build");
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
|
+
const request_pg_client_1 = require("./request-pg-client");
|
|
26
27
|
const s3_signer_1 = require("./s3-signer");
|
|
27
28
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
28
29
|
const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
@@ -78,30 +79,79 @@ function resolveS3(options) {
|
|
|
78
79
|
}
|
|
79
80
|
return options.s3;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
84
|
+
*
|
|
85
|
+
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
86
|
+
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
87
|
+
* the row is authoritative and this function must not be consulted again.
|
|
88
|
+
*/
|
|
89
|
+
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
90
|
+
if (options.resolveBucketName) {
|
|
91
|
+
return options.resolveBucketName(databaseId, bucketKey);
|
|
92
|
+
}
|
|
93
|
+
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
94
|
+
return resolveS3(options).bucket;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
98
|
+
* required — callers must resolve the coordinate (stored row value, or a
|
|
99
|
+
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
100
|
+
*/
|
|
101
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
82
102
|
const globalS3 = resolveS3(options);
|
|
83
|
-
const
|
|
84
|
-
?
|
|
85
|
-
: globalS3.
|
|
86
|
-
|
|
87
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
103
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
104
|
+
? storageConfig.publicUrlPrefix
|
|
105
|
+
: globalS3.publicUrlPrefix;
|
|
106
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
88
107
|
return globalS3;
|
|
89
108
|
}
|
|
90
109
|
return {
|
|
91
110
|
...globalS3,
|
|
92
|
-
bucket,
|
|
111
|
+
bucket: physicalName,
|
|
93
112
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
94
113
|
};
|
|
95
114
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
/**
|
|
116
|
+
* First provision of a logical bucket: mint a name, create the physical S3
|
|
117
|
+
* bucket, and record the exact name on the source row. Returns the recorded
|
|
118
|
+
* physical name.
|
|
119
|
+
*
|
|
120
|
+
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
121
|
+
* value is the durable coordinate: route resolution and every later read use
|
|
122
|
+
* it verbatim; nothing is recomputed.
|
|
123
|
+
*
|
|
124
|
+
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
125
|
+
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
126
|
+
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
127
|
+
* claim, because the buckets table's catalog-sync trigger calls
|
|
128
|
+
* `jwt_private.current_database_id()` and would otherwise raise
|
|
129
|
+
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
130
|
+
* write's transaction without switching off the privileged role.
|
|
131
|
+
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
132
|
+
* the recorded name without a DB round-trip.
|
|
133
|
+
*/
|
|
134
|
+
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
135
|
+
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
136
|
+
if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
|
|
137
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
138
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
139
|
+
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
140
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
141
|
+
}
|
|
142
|
+
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
143
|
+
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
144
|
+
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
145
|
+
// write runs under the resolved database claim (privileged role preserved).
|
|
146
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
147
|
+
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
148
|
+
SET physical_name = $1
|
|
149
|
+
WHERE id = $2 AND physical_name IS NULL`,
|
|
150
|
+
values: [s3BucketName, bucket.id],
|
|
151
|
+
}));
|
|
152
|
+
bucket.physical_name = s3BucketName;
|
|
153
|
+
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
154
|
+
return s3BucketName;
|
|
105
155
|
}
|
|
106
156
|
// --- Plugin factory ---
|
|
107
157
|
function createPresignedUrlPlugin(options) {
|
|
@@ -206,7 +256,10 @@ function createPresignedUrlPlugin(options) {
|
|
|
206
256
|
pgSettings: $pgSettings,
|
|
207
257
|
});
|
|
208
258
|
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
209
|
-
|
|
259
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
260
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
261
|
+
// across every statement (see withRequestPgClient).
|
|
262
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
210
263
|
if (!databaseId)
|
|
211
264
|
throw new Error('DATABASE_NOT_FOUND');
|
|
212
265
|
// Module registration is server config, not user data:
|
|
@@ -215,22 +268,24 @@ function createPresignedUrlPlugin(options) {
|
|
|
215
268
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
216
269
|
if (!storageConfig)
|
|
217
270
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
271
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
272
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
273
|
+
if (!bucket)
|
|
274
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
275
|
+
// First provision mints + records the coordinate; afterwards the
|
|
276
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
277
|
+
const physicalName = bucket.physical_name === null
|
|
278
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
279
|
+
: bucket.physical_name;
|
|
280
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
281
|
+
// File row INSERT under the request role (RLS enforced).
|
|
282
|
+
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
283
|
+
contentHash: vals.contentHash,
|
|
284
|
+
contentType: vals.contentType,
|
|
285
|
+
size: vals.size,
|
|
286
|
+
filename: vals.filename,
|
|
287
|
+
key: vals.customKey,
|
|
288
|
+
}));
|
|
234
289
|
});
|
|
235
290
|
},
|
|
236
291
|
});
|
|
@@ -295,7 +350,10 @@ function createPresignedUrlPlugin(options) {
|
|
|
295
350
|
pgSettings: $pgSettings,
|
|
296
351
|
});
|
|
297
352
|
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
298
|
-
|
|
353
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
354
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
355
|
+
// across every statement (see withRequestPgClient).
|
|
356
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
299
357
|
if (!databaseId)
|
|
300
358
|
throw new Error('DATABASE_NOT_FOUND');
|
|
301
359
|
// Module registration is server config, not user data:
|
|
@@ -304,34 +362,38 @@ function createPresignedUrlPlugin(options) {
|
|
|
304
362
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
305
363
|
if (!storageConfig)
|
|
306
364
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
365
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
366
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
367
|
+
if (!bucket)
|
|
368
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
369
|
+
// Enforce bulk upload limits
|
|
370
|
+
const filesArray = vals.files;
|
|
371
|
+
if (filesArray.length > storageConfig.maxBulkFiles) {
|
|
372
|
+
throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
|
|
373
|
+
}
|
|
374
|
+
const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
375
|
+
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
376
|
+
throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
|
|
377
|
+
}
|
|
378
|
+
// First provision mints + records the coordinate; afterwards the
|
|
379
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
380
|
+
const physicalName = bucket.physical_name === null
|
|
381
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
382
|
+
: bucket.physical_name;
|
|
383
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
384
|
+
// File row INSERTs under the request role (RLS enforced).
|
|
385
|
+
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
386
|
+
const results = [];
|
|
387
|
+
for (const file of filesArray) {
|
|
388
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
389
|
+
contentHash: file.contentHash,
|
|
390
|
+
contentType: file.contentType,
|
|
391
|
+
size: file.size,
|
|
392
|
+
filename: file.filename,
|
|
393
|
+
key: file.key,
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
return { files: results };
|
|
335
397
|
});
|
|
336
398
|
});
|
|
337
399
|
},
|
|
@@ -377,7 +439,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
377
439
|
const pgSettings = graphqlContext.pgSettings;
|
|
378
440
|
if (withPgClient) {
|
|
379
441
|
try {
|
|
380
|
-
const databaseId = await withPgClient
|
|
442
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
381
443
|
// Module registration is server config, not user data:
|
|
382
444
|
// resolve it without the request role's pgSettings.
|
|
383
445
|
const allConfigs = databaseId
|
|
@@ -385,7 +447,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
385
447
|
: [];
|
|
386
448
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
|
|
387
449
|
if (storageConfig) {
|
|
388
|
-
await withPgClient
|
|
450
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
389
451
|
// Read the file row (RLS enforced)
|
|
390
452
|
const result = await pgClient.query({
|
|
391
453
|
text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
@@ -410,7 +472,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
410
472
|
const pgSettings = graphqlContext.pgSettings;
|
|
411
473
|
if (withPgClient) {
|
|
412
474
|
try {
|
|
413
|
-
const databaseId = await withPgClient
|
|
475
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
414
476
|
// Module registration is server config, not user data:
|
|
415
477
|
// resolve it without the request role's pgSettings.
|
|
416
478
|
const allConfigs = databaseId
|
|
@@ -418,7 +480,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
418
480
|
: [];
|
|
419
481
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
|
|
420
482
|
if (storageConfig)
|
|
421
|
-
await withPgClient
|
|
483
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
422
484
|
// Check refcount: any other file with the same key in this bucket?
|
|
423
485
|
const refResult = await pgClient.query({
|
|
424
486
|
text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
|
|
@@ -429,18 +491,25 @@ function createPresignedUrlPlugin(options) {
|
|
|
429
491
|
log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
|
|
430
492
|
return;
|
|
431
493
|
}
|
|
432
|
-
// No other references — attempt sync S3 delete
|
|
433
|
-
//
|
|
494
|
+
// No other references — attempt sync S3 delete.
|
|
495
|
+
// Read the stored physical coordinate; the object lives in the
|
|
496
|
+
// recorded bucket, never a recomputed prefix name.
|
|
434
497
|
const bucketResult = await pgClient.query({
|
|
435
|
-
text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
498
|
+
text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
436
499
|
values: [fileRow.bucket_id],
|
|
437
500
|
});
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
501
|
+
const bucketRow = bucketResult.rows[0];
|
|
502
|
+
if (!bucketRow) {
|
|
440
503
|
log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
|
|
441
504
|
return;
|
|
442
505
|
}
|
|
443
|
-
const
|
|
506
|
+
const physicalName = (0, storage_module_cache_1.storedPhysicalName)(bucketRow);
|
|
507
|
+
if (physicalName === null) {
|
|
508
|
+
// No physical bucket was ever provisioned — there is no object to delete.
|
|
509
|
+
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
444
513
|
await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
|
|
445
514
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
446
515
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
export interface RequestPgClient {
|
|
20
|
+
query(opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values?: unknown[];
|
|
23
|
+
}): Promise<{
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
}>;
|
|
26
|
+
withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
|
|
29
|
+
export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
4
|
+
*
|
|
5
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
6
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
7
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
8
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
9
|
+
* setting applied for one statement is already gone by the next. The request
|
|
10
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
11
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
12
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
13
|
+
*
|
|
14
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
15
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
16
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
17
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
18
|
+
* manual, multi-statement RLS work.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.withRequestPgClient = withRequestPgClient;
|
|
22
|
+
async function applyRequestSettings(tx, pgSettings) {
|
|
23
|
+
if (!pgSettings)
|
|
24
|
+
return;
|
|
25
|
+
const entries = Object.entries(pgSettings)
|
|
26
|
+
.filter(([, value]) => value != null)
|
|
27
|
+
.map(([key, value]) => [key, String(value)]);
|
|
28
|
+
if (entries.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
await tx.query({
|
|
31
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
32
|
+
values: [JSON.stringify(entries)],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function withRequestPgClient(withPgClient, pgSettings, cb) {
|
|
36
|
+
return withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
37
|
+
await applyRequestSettings(tx, pgSettings);
|
|
38
|
+
return cb(tx);
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
|
|
|
100
100
|
};
|
|
101
101
|
sqlType?: string;
|
|
102
102
|
}, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
|
|
103
|
+
/**
|
|
104
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
105
|
+
*
|
|
106
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
107
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
108
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
109
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
110
|
+
*/
|
|
111
|
+
export declare function storedPhysicalName(row: {
|
|
112
|
+
physical_name?: string | null;
|
|
113
|
+
}): string | null;
|
|
103
114
|
/**
|
|
104
115
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
105
116
|
*
|
package/storage-module-cache.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.getStorageModuleConfigForOwner = getStorageModuleConfigForOwner;
|
|
|
5
5
|
exports.resolveStorageModuleByFileId = resolveStorageModuleByFileId;
|
|
6
6
|
exports.loadAllStorageModules = loadAllStorageModules;
|
|
7
7
|
exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
|
|
8
|
+
exports.storedPhysicalName = storedPhysicalName;
|
|
8
9
|
exports.getBucketConfig = getBucketConfig;
|
|
9
10
|
exports.isS3BucketProvisioned = isS3BucketProvisioned;
|
|
10
11
|
exports.markS3BucketProvisioned = markS3BucketProvisioned;
|
|
@@ -332,6 +333,17 @@ const bucketCache = new lru_cache_1.LRUCache({
|
|
|
332
333
|
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
333
334
|
updateAgeOnGet: true,
|
|
334
335
|
});
|
|
336
|
+
/**
|
|
337
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
338
|
+
*
|
|
339
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
340
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
341
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
342
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
343
|
+
*/
|
|
344
|
+
function storedPhysicalName(row) {
|
|
345
|
+
return row.physical_name == null ? null : row.physical_name;
|
|
346
|
+
}
|
|
335
347
|
/**
|
|
336
348
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
337
349
|
*
|
|
@@ -358,11 +370,11 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
|
|
|
358
370
|
const hasOwner = ownerId && isEntityScoped;
|
|
359
371
|
const result = await pgClient.query({
|
|
360
372
|
text: hasOwner
|
|
361
|
-
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
|
|
373
|
+
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
362
374
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
363
375
|
WHERE key = $1 AND owner_id = $2
|
|
364
376
|
LIMIT 1`
|
|
365
|
-
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
|
|
377
|
+
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
366
378
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
367
379
|
WHERE key = $1
|
|
368
380
|
LIMIT 1`,
|
|
@@ -381,6 +393,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
|
|
|
381
393
|
allowed_mime_types: row.allowed_mime_types,
|
|
382
394
|
max_file_size: row.max_file_size,
|
|
383
395
|
allow_custom_keys: row.allow_custom_keys ?? false,
|
|
396
|
+
physical_name: storedPhysicalName(row),
|
|
384
397
|
};
|
|
385
398
|
bucketCache.set(cacheKey, config);
|
|
386
399
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
|
package/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface BucketConfig {
|
|
|
11
11
|
allowed_mime_types: string[] | null;
|
|
12
12
|
max_file_size: number | null;
|
|
13
13
|
allow_custom_keys: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The physical S3/MinIO bucket name recorded when the physical bucket was
|
|
16
|
+
* first provisioned. NULL until the first upload provisions it. Once set,
|
|
17
|
+
* it is the source of truth for the physical bucket — reads never
|
|
18
|
+
* reconstruct the name from a prefix convention.
|
|
19
|
+
*/
|
|
20
|
+
physical_name: string | null;
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* Storage module configuration resolved from metaschema for a given database.
|