graphile-presigned-url-plugin 1.16.0 → 1.17.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/esm/managed-upload.js +8 -15
- package/esm/physical-bucket.js +2 -6
- package/esm/plugin.js +10 -25
- package/esm/storage-file-recorder.d.ts +27 -0
- package/esm/storage-file-recorder.js +52 -0
- package/esm/storage-module-cache.js +7 -0
- package/esm/types.d.ts +4 -0
- package/managed-upload.js +8 -15
- package/package.json +3 -3
- package/physical-bucket.js +2 -6
- package/plugin.js +10 -25
- package/storage-file-recorder.d.ts +27 -0
- package/storage-file-recorder.js +55 -0
- package/storage-module-cache.js +7 -0
- package/types.d.ts +4 -0
package/esm/managed-upload.js
CHANGED
|
@@ -24,6 +24,7 @@ import { getFileRefFieldBinding } from './file-ref-registry';
|
|
|
24
24
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
25
25
|
import { withRequestPgClient } from './request-pg-client';
|
|
26
26
|
import { copyS3Object, deleteS3Object } from './s3-signer';
|
|
27
|
+
import { recordManagedFile } from './storage-file-recorder';
|
|
27
28
|
import { getBucketConfig, loadAllStorageModules } from './storage-module-cache';
|
|
28
29
|
const log = new Logger('graphile-presigned-url:managed-upload');
|
|
29
30
|
/**
|
|
@@ -252,22 +253,14 @@ export async function finalizeStagedUpload(args) {
|
|
|
252
253
|
let fileId;
|
|
253
254
|
try {
|
|
254
255
|
fileId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
finalKey,
|
|
263
|
-
staged.contentHash,
|
|
264
|
-
staged.contentType,
|
|
265
|
-
staged.size,
|
|
266
|
-
staged.filename ?? null,
|
|
267
|
-
bucket.is_public,
|
|
268
|
-
],
|
|
256
|
+
return recordManagedFile(pgClient, storageConfig, {
|
|
257
|
+
bucketId: bucket.id,
|
|
258
|
+
key: finalKey,
|
|
259
|
+
contentHash: staged.contentHash,
|
|
260
|
+
mimeType: staged.contentType,
|
|
261
|
+
size: staged.size,
|
|
262
|
+
filename: staged.filename,
|
|
269
263
|
});
|
|
270
|
-
return result.rows[0].id;
|
|
271
264
|
});
|
|
272
265
|
}
|
|
273
266
|
catch (err) {
|
package/esm/physical-bucket.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* environment-level bucket standing in for a tenant's.
|
|
10
10
|
*/
|
|
11
11
|
import { Logger } from '@pgpmjs/logger';
|
|
12
|
+
import { recordPhysicalName } from 'graphile-storage-registry';
|
|
12
13
|
import { withRequestPgClient } from './request-pg-client';
|
|
13
14
|
import { s3FailureError } from './s3-failure';
|
|
14
15
|
import { isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
@@ -108,12 +109,7 @@ export async function provisionAndRecordPhysicalBucket(options, withPgClient, st
|
|
|
108
109
|
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
109
110
|
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
110
111
|
// write runs under the resolved database claim (privileged role preserved).
|
|
111
|
-
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query(
|
|
112
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
113
|
-
SET physical_name = $1
|
|
114
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
115
|
-
values: [s3BucketName, bucket.id],
|
|
116
|
-
}));
|
|
112
|
+
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => recordPhysicalName((query) => client.query(query), storageConfig.bucketsQualifiedName, bucket.id, s3BucketName));
|
|
117
113
|
bucket.physical_name = s3BucketName;
|
|
118
114
|
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
119
115
|
return s3BucketName;
|
package/esm/plugin.js
CHANGED
|
@@ -30,6 +30,7 @@ import { buildFileProjection } from './managed-upload';
|
|
|
30
30
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
31
31
|
import { withRequestPgClient } from './request-pg-client';
|
|
32
32
|
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
33
|
+
import { recordManagedFile } from './storage-file-recorder';
|
|
33
34
|
import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
34
35
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
35
36
|
// --- Protocol-level constants (not configurable) ---
|
|
@@ -612,32 +613,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
612
613
|
}
|
|
613
614
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
614
615
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (previousVersionId) {
|
|
625
|
-
columns.push('previous_version_id');
|
|
626
|
-
values.push(previousVersionId);
|
|
627
|
-
}
|
|
628
|
-
if (derivedPath) {
|
|
629
|
-
columns.push('path');
|
|
630
|
-
values.push(derivedPath);
|
|
631
|
-
}
|
|
632
|
-
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
633
|
-
const fileResult = await txClient.query({
|
|
634
|
-
text: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
635
|
-
(${columns.join(', ')})
|
|
636
|
-
VALUES (${placeholders})
|
|
637
|
-
RETURNING id`,
|
|
638
|
-
values,
|
|
616
|
+
const fileId = await recordManagedFile(txClient, storageConfig, {
|
|
617
|
+
bucketId: bucket.id,
|
|
618
|
+
key: s3Key,
|
|
619
|
+
contentHash,
|
|
620
|
+
mimeType: contentType,
|
|
621
|
+
size,
|
|
622
|
+
filename: filename || null,
|
|
623
|
+
previousVersionId,
|
|
624
|
+
path: derivedPath,
|
|
639
625
|
});
|
|
640
|
-
const fileId = fileResult.rows[0].id;
|
|
641
626
|
// Generate presigned PUT URL
|
|
642
627
|
const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
643
628
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StorageModuleConfig } from './types';
|
|
2
|
+
interface RecordManagedFileInput {
|
|
3
|
+
bucketId: string;
|
|
4
|
+
key: string;
|
|
5
|
+
contentHash: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
size: number;
|
|
8
|
+
filename: string | null | undefined;
|
|
9
|
+
previousVersionId?: string | null;
|
|
10
|
+
path?: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Record a managed file through the storage plane's generated recorder.
|
|
14
|
+
*
|
|
15
|
+
* The recorder owns the files-table insert so bucket inheritance and claims
|
|
16
|
+
* attribution run at the database boundary. Optional arguments are only named
|
|
17
|
+
* when the generated function supports them and the caller has a value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function recordManagedFile(pgClient: {
|
|
20
|
+
query: (opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values: unknown[];
|
|
23
|
+
}) => Promise<{
|
|
24
|
+
rows: unknown[];
|
|
25
|
+
}>;
|
|
26
|
+
}, storageConfig: StorageModuleConfig, input: RecordManagedFileInput): Promise<string>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record a managed file through the storage plane's generated recorder.
|
|
3
|
+
*
|
|
4
|
+
* The recorder owns the files-table insert so bucket inheritance and claims
|
|
5
|
+
* attribution run at the database boundary. Optional arguments are only named
|
|
6
|
+
* when the generated function supports them and the caller has a value.
|
|
7
|
+
*/
|
|
8
|
+
export async function recordManagedFile(pgClient, storageConfig, input) {
|
|
9
|
+
if (!storageConfig.recorderQualifiedName) {
|
|
10
|
+
throw new Error(`STORAGE_RECORDER_MISSING: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
11
|
+
`must expose ${storageConfig.filesTableName}_record_file in its private schema`);
|
|
12
|
+
}
|
|
13
|
+
if (input.previousVersionId != null && !storageConfig.hasVersioning) {
|
|
14
|
+
throw new Error(`STORAGE_VERSIONING_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
15
|
+
'does not support previous_version_id');
|
|
16
|
+
}
|
|
17
|
+
if (input.path != null && !storageConfig.hasPathShares) {
|
|
18
|
+
throw new Error(`STORAGE_PATH_SHARES_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
19
|
+
'does not support path');
|
|
20
|
+
}
|
|
21
|
+
const args = [
|
|
22
|
+
'bucket_id := $1::uuid',
|
|
23
|
+
'key := $2::text',
|
|
24
|
+
'content_hash := $3::text',
|
|
25
|
+
'mime_type := $4::text',
|
|
26
|
+
'size := $5::bigint',
|
|
27
|
+
'filename := $6::text',
|
|
28
|
+
'upload := $7::jsonb',
|
|
29
|
+
];
|
|
30
|
+
const values = [
|
|
31
|
+
input.bucketId,
|
|
32
|
+
input.key,
|
|
33
|
+
input.contentHash,
|
|
34
|
+
input.mimeType,
|
|
35
|
+
input.size,
|
|
36
|
+
input.filename ?? null,
|
|
37
|
+
null,
|
|
38
|
+
];
|
|
39
|
+
if (storageConfig.hasVersioning && input.previousVersionId != null) {
|
|
40
|
+
args.push(`previous_version_id := $${values.length + 1}::uuid`);
|
|
41
|
+
values.push(input.previousVersionId);
|
|
42
|
+
}
|
|
43
|
+
if (storageConfig.hasPathShares && input.path != null) {
|
|
44
|
+
args.push(`path := $${values.length + 1}::text`);
|
|
45
|
+
values.push(input.path);
|
|
46
|
+
}
|
|
47
|
+
const result = await pgClient.query({
|
|
48
|
+
text: `SELECT id FROM ${storageConfig.recorderQualifiedName}(${args.join(', ')})`,
|
|
49
|
+
values,
|
|
50
|
+
});
|
|
51
|
+
return result.rows[0].id;
|
|
52
|
+
}
|
|
@@ -41,6 +41,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
41
41
|
bt.name AS buckets_table,
|
|
42
42
|
fs.schema_name AS files_schema,
|
|
43
43
|
ft.name AS files_table,
|
|
44
|
+
ps.schema_name AS private_schema,
|
|
44
45
|
sm.endpoint,
|
|
45
46
|
sm.public_url_prefix,
|
|
46
47
|
sm.provider,
|
|
@@ -53,6 +54,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
53
54
|
sm.max_bulk_files,
|
|
54
55
|
sm.max_bulk_total_size,
|
|
55
56
|
sm.has_path_shares,
|
|
57
|
+
sm.has_versioning,
|
|
56
58
|
sm.has_confirm_upload,
|
|
57
59
|
es.schema_name AS entity_schema,
|
|
58
60
|
et.name AS entity_table
|
|
@@ -61,6 +63,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
61
63
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
62
64
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
63
65
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
66
|
+
LEFT JOIN metaschema_public.schema ps ON ps.id = sm.private_schema_id
|
|
64
67
|
LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
|
|
65
68
|
LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
|
|
66
69
|
WHERE sm.database_id = $1
|
|
@@ -74,6 +77,9 @@ function buildConfig(row) {
|
|
|
74
77
|
id: row.id,
|
|
75
78
|
bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
|
|
76
79
|
filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
|
|
80
|
+
recorderQualifiedName: row.private_schema
|
|
81
|
+
? QuoteUtils.quoteQualifiedIdentifier(row.private_schema, `${row.files_table}_record_file`)
|
|
82
|
+
: null,
|
|
77
83
|
schemaName: row.buckets_schema,
|
|
78
84
|
bucketsTableName: row.buckets_table,
|
|
79
85
|
filesTableName: row.files_table,
|
|
@@ -92,6 +98,7 @@ function buildConfig(row) {
|
|
|
92
98
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
93
99
|
cacheTtlSeconds,
|
|
94
100
|
hasPathShares: row.has_path_shares ?? false,
|
|
101
|
+
hasVersioning: row.has_versioning ?? false,
|
|
95
102
|
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
96
103
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
97
104
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
package/esm/types.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface StorageModuleConfig {
|
|
|
35
35
|
bucketsTableName: string;
|
|
36
36
|
/** Files table name */
|
|
37
37
|
filesTableName: string;
|
|
38
|
+
/** Qualified generated managed-file recorder, or null when unavailable */
|
|
39
|
+
recorderQualifiedName: string | null;
|
|
38
40
|
/** Scope name (e.g., 'app', 'org', 'team') */
|
|
39
41
|
scope: string;
|
|
40
42
|
/** Entity table ID for entity-scoped storage (NULL for app-level) */
|
|
@@ -67,6 +69,8 @@ export interface StorageModuleConfig {
|
|
|
67
69
|
* without it every row is treated as live, because there is nothing to read.
|
|
68
70
|
*/
|
|
69
71
|
hasConfirmUpload: boolean;
|
|
72
|
+
/** Whether the files table carries the versioning chain. */
|
|
73
|
+
hasVersioning: boolean;
|
|
70
74
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
71
75
|
maxBulkFiles: number;
|
|
72
76
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|
package/managed-upload.js
CHANGED
|
@@ -30,6 +30,7 @@ const file_ref_registry_1 = require("./file-ref-registry");
|
|
|
30
30
|
const physical_bucket_1 = require("./physical-bucket");
|
|
31
31
|
const request_pg_client_1 = require("./request-pg-client");
|
|
32
32
|
const s3_signer_1 = require("./s3-signer");
|
|
33
|
+
const storage_file_recorder_1 = require("./storage-file-recorder");
|
|
33
34
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
34
35
|
const log = new logger_1.Logger('graphile-presigned-url:managed-upload');
|
|
35
36
|
/**
|
|
@@ -258,22 +259,14 @@ async function finalizeStagedUpload(args) {
|
|
|
258
259
|
let fileId;
|
|
259
260
|
try {
|
|
260
261
|
fileId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
finalKey,
|
|
269
|
-
staged.contentHash,
|
|
270
|
-
staged.contentType,
|
|
271
|
-
staged.size,
|
|
272
|
-
staged.filename ?? null,
|
|
273
|
-
bucket.is_public,
|
|
274
|
-
],
|
|
262
|
+
return (0, storage_file_recorder_1.recordManagedFile)(pgClient, storageConfig, {
|
|
263
|
+
bucketId: bucket.id,
|
|
264
|
+
key: finalKey,
|
|
265
|
+
contentHash: staged.contentHash,
|
|
266
|
+
mimeType: staged.contentType,
|
|
267
|
+
size: staged.size,
|
|
268
|
+
filename: staged.filename,
|
|
275
269
|
});
|
|
276
|
-
return result.rows[0].id;
|
|
277
270
|
});
|
|
278
271
|
}
|
|
279
272
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -44,7 +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.
|
|
47
|
+
"graphile-storage-registry": "^0.1.1",
|
|
48
48
|
"lru-cache": "^11.2.7",
|
|
49
49
|
"mime-bytes": "^0.32.0"
|
|
50
50
|
},
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"@types/node": "^22.19.11",
|
|
63
63
|
"makage": "^0.3.0"
|
|
64
64
|
},
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "b91ecc6db944b29a29b5474268de54ddca5dc34a"
|
|
66
66
|
}
|
package/physical-bucket.js
CHANGED
|
@@ -15,6 +15,7 @@ exports.mintPhysicalBucketName = mintPhysicalBucketName;
|
|
|
15
15
|
exports.resolveS3ForDatabase = resolveS3ForDatabase;
|
|
16
16
|
exports.provisionAndRecordPhysicalBucket = provisionAndRecordPhysicalBucket;
|
|
17
17
|
const logger_1 = require("@pgpmjs/logger");
|
|
18
|
+
const graphile_storage_registry_1 = require("graphile-storage-registry");
|
|
18
19
|
const request_pg_client_1 = require("./request-pg-client");
|
|
19
20
|
const s3_failure_1 = require("./s3-failure");
|
|
20
21
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
@@ -114,12 +115,7 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
|
|
|
114
115
|
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
115
116
|
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
116
117
|
// write runs under the resolved database claim (privileged role preserved).
|
|
117
|
-
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query(
|
|
118
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
119
|
-
SET physical_name = $1
|
|
120
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
121
|
-
values: [s3BucketName, bucket.id],
|
|
122
|
-
}));
|
|
118
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => (0, graphile_storage_registry_1.recordPhysicalName)((query) => client.query(query), storageConfig.bucketsQualifiedName, bucket.id, s3BucketName));
|
|
123
119
|
bucket.physical_name = s3BucketName;
|
|
124
120
|
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
125
121
|
return s3BucketName;
|
package/plugin.js
CHANGED
|
@@ -34,6 +34,7 @@ const managed_upload_1 = require("./managed-upload");
|
|
|
34
34
|
const physical_bucket_1 = require("./physical-bucket");
|
|
35
35
|
const request_pg_client_1 = require("./request-pg-client");
|
|
36
36
|
const s3_signer_1 = require("./s3-signer");
|
|
37
|
+
const storage_file_recorder_1 = require("./storage-file-recorder");
|
|
37
38
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
38
39
|
const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
39
40
|
// --- Protocol-level constants (not configurable) ---
|
|
@@ -616,32 +617,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
616
617
|
}
|
|
617
618
|
// Auto-derive ltree path from custom key directory (only when has_path_shares)
|
|
618
619
|
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (previousVersionId) {
|
|
629
|
-
columns.push('previous_version_id');
|
|
630
|
-
values.push(previousVersionId);
|
|
631
|
-
}
|
|
632
|
-
if (derivedPath) {
|
|
633
|
-
columns.push('path');
|
|
634
|
-
values.push(derivedPath);
|
|
635
|
-
}
|
|
636
|
-
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
637
|
-
const fileResult = await txClient.query({
|
|
638
|
-
text: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
639
|
-
(${columns.join(', ')})
|
|
640
|
-
VALUES (${placeholders})
|
|
641
|
-
RETURNING id`,
|
|
642
|
-
values,
|
|
620
|
+
const fileId = await (0, storage_file_recorder_1.recordManagedFile)(txClient, storageConfig, {
|
|
621
|
+
bucketId: bucket.id,
|
|
622
|
+
key: s3Key,
|
|
623
|
+
contentHash,
|
|
624
|
+
mimeType: contentType,
|
|
625
|
+
size,
|
|
626
|
+
filename: filename || null,
|
|
627
|
+
previousVersionId,
|
|
628
|
+
path: derivedPath,
|
|
643
629
|
});
|
|
644
|
-
const fileId = fileResult.rows[0].id;
|
|
645
630
|
// Generate presigned PUT URL
|
|
646
631
|
const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
647
632
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StorageModuleConfig } from './types';
|
|
2
|
+
interface RecordManagedFileInput {
|
|
3
|
+
bucketId: string;
|
|
4
|
+
key: string;
|
|
5
|
+
contentHash: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
size: number;
|
|
8
|
+
filename: string | null | undefined;
|
|
9
|
+
previousVersionId?: string | null;
|
|
10
|
+
path?: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Record a managed file through the storage plane's generated recorder.
|
|
14
|
+
*
|
|
15
|
+
* The recorder owns the files-table insert so bucket inheritance and claims
|
|
16
|
+
* attribution run at the database boundary. Optional arguments are only named
|
|
17
|
+
* when the generated function supports them and the caller has a value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function recordManagedFile(pgClient: {
|
|
20
|
+
query: (opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values: unknown[];
|
|
23
|
+
}) => Promise<{
|
|
24
|
+
rows: unknown[];
|
|
25
|
+
}>;
|
|
26
|
+
}, storageConfig: StorageModuleConfig, input: RecordManagedFileInput): Promise<string>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recordManagedFile = recordManagedFile;
|
|
4
|
+
/**
|
|
5
|
+
* Record a managed file through the storage plane's generated recorder.
|
|
6
|
+
*
|
|
7
|
+
* The recorder owns the files-table insert so bucket inheritance and claims
|
|
8
|
+
* attribution run at the database boundary. Optional arguments are only named
|
|
9
|
+
* when the generated function supports them and the caller has a value.
|
|
10
|
+
*/
|
|
11
|
+
async function recordManagedFile(pgClient, storageConfig, input) {
|
|
12
|
+
if (!storageConfig.recorderQualifiedName) {
|
|
13
|
+
throw new Error(`STORAGE_RECORDER_MISSING: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
14
|
+
`must expose ${storageConfig.filesTableName}_record_file in its private schema`);
|
|
15
|
+
}
|
|
16
|
+
if (input.previousVersionId != null && !storageConfig.hasVersioning) {
|
|
17
|
+
throw new Error(`STORAGE_VERSIONING_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
18
|
+
'does not support previous_version_id');
|
|
19
|
+
}
|
|
20
|
+
if (input.path != null && !storageConfig.hasPathShares) {
|
|
21
|
+
throw new Error(`STORAGE_PATH_SHARES_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
|
|
22
|
+
'does not support path');
|
|
23
|
+
}
|
|
24
|
+
const args = [
|
|
25
|
+
'bucket_id := $1::uuid',
|
|
26
|
+
'key := $2::text',
|
|
27
|
+
'content_hash := $3::text',
|
|
28
|
+
'mime_type := $4::text',
|
|
29
|
+
'size := $5::bigint',
|
|
30
|
+
'filename := $6::text',
|
|
31
|
+
'upload := $7::jsonb',
|
|
32
|
+
];
|
|
33
|
+
const values = [
|
|
34
|
+
input.bucketId,
|
|
35
|
+
input.key,
|
|
36
|
+
input.contentHash,
|
|
37
|
+
input.mimeType,
|
|
38
|
+
input.size,
|
|
39
|
+
input.filename ?? null,
|
|
40
|
+
null,
|
|
41
|
+
];
|
|
42
|
+
if (storageConfig.hasVersioning && input.previousVersionId != null) {
|
|
43
|
+
args.push(`previous_version_id := $${values.length + 1}::uuid`);
|
|
44
|
+
values.push(input.previousVersionId);
|
|
45
|
+
}
|
|
46
|
+
if (storageConfig.hasPathShares && input.path != null) {
|
|
47
|
+
args.push(`path := $${values.length + 1}::text`);
|
|
48
|
+
values.push(input.path);
|
|
49
|
+
}
|
|
50
|
+
const result = await pgClient.query({
|
|
51
|
+
text: `SELECT id FROM ${storageConfig.recorderQualifiedName}(${args.join(', ')})`,
|
|
52
|
+
values,
|
|
53
|
+
});
|
|
54
|
+
return result.rows[0].id;
|
|
55
|
+
}
|
package/storage-module-cache.js
CHANGED
|
@@ -52,6 +52,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
52
52
|
bt.name AS buckets_table,
|
|
53
53
|
fs.schema_name AS files_schema,
|
|
54
54
|
ft.name AS files_table,
|
|
55
|
+
ps.schema_name AS private_schema,
|
|
55
56
|
sm.endpoint,
|
|
56
57
|
sm.public_url_prefix,
|
|
57
58
|
sm.provider,
|
|
@@ -64,6 +65,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
64
65
|
sm.max_bulk_files,
|
|
65
66
|
sm.max_bulk_total_size,
|
|
66
67
|
sm.has_path_shares,
|
|
68
|
+
sm.has_versioning,
|
|
67
69
|
sm.has_confirm_upload,
|
|
68
70
|
es.schema_name AS entity_schema,
|
|
69
71
|
et.name AS entity_table
|
|
@@ -72,6 +74,7 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
72
74
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
73
75
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
74
76
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
77
|
+
LEFT JOIN metaschema_public.schema ps ON ps.id = sm.private_schema_id
|
|
75
78
|
LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
|
|
76
79
|
LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
|
|
77
80
|
WHERE sm.database_id = $1
|
|
@@ -85,6 +88,9 @@ function buildConfig(row) {
|
|
|
85
88
|
id: row.id,
|
|
86
89
|
bucketsQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
|
|
87
90
|
filesQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
|
|
91
|
+
recorderQualifiedName: row.private_schema
|
|
92
|
+
? quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.private_schema, `${row.files_table}_record_file`)
|
|
93
|
+
: null,
|
|
88
94
|
schemaName: row.buckets_schema,
|
|
89
95
|
bucketsTableName: row.buckets_table,
|
|
90
96
|
filesTableName: row.files_table,
|
|
@@ -103,6 +109,7 @@ function buildConfig(row) {
|
|
|
103
109
|
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
|
|
104
110
|
cacheTtlSeconds,
|
|
105
111
|
hasPathShares: row.has_path_shares ?? false,
|
|
112
|
+
hasVersioning: row.has_versioning ?? false,
|
|
106
113
|
hasConfirmUpload: row.has_confirm_upload ?? false,
|
|
107
114
|
maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
|
|
108
115
|
maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
|
package/types.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface StorageModuleConfig {
|
|
|
35
35
|
bucketsTableName: string;
|
|
36
36
|
/** Files table name */
|
|
37
37
|
filesTableName: string;
|
|
38
|
+
/** Qualified generated managed-file recorder, or null when unavailable */
|
|
39
|
+
recorderQualifiedName: string | null;
|
|
38
40
|
/** Scope name (e.g., 'app', 'org', 'team') */
|
|
39
41
|
scope: string;
|
|
40
42
|
/** Entity table ID for entity-scoped storage (NULL for app-level) */
|
|
@@ -67,6 +69,8 @@ export interface StorageModuleConfig {
|
|
|
67
69
|
* without it every row is treated as live, because there is nothing to read.
|
|
68
70
|
*/
|
|
69
71
|
hasConfirmUpload: boolean;
|
|
72
|
+
/** Whether the files table carries the versioning chain. */
|
|
73
|
+
hasVersioning: boolean;
|
|
70
74
|
/** Max files per requestBulkUploadUrls batch (default: 100) */
|
|
71
75
|
maxBulkFiles: number;
|
|
72
76
|
/** Max total size per bulk upload batch in bytes (default: 1GB) */
|