graphile-presigned-url-plugin 1.11.4 → 1.12.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/default-bucket.d.ts +42 -0
- package/default-bucket.js +51 -0
- package/esm/default-bucket.d.ts +42 -0
- package/esm/default-bucket.js +48 -0
- package/esm/file-ref-registry.d.ts +61 -0
- package/esm/file-ref-registry.js +115 -0
- package/esm/index.d.ts +8 -1
- package/esm/index.js +6 -1
- package/esm/managed-upload.d.ts +132 -0
- package/esm/managed-upload.js +262 -0
- package/esm/physical-bucket.d.ts +60 -0
- package/esm/physical-bucket.js +111 -0
- package/esm/plugin.js +53 -84
- package/esm/s3-signer.d.ts +13 -0
- package/esm/s3-signer.js +23 -1
- package/esm/types.d.ts +34 -2
- package/file-ref-registry.d.ts +61 -0
- package/file-ref-registry.js +121 -0
- package/index.d.ts +8 -1
- package/index.js +20 -1
- package/managed-upload.d.ts +132 -0
- package/managed-upload.js +268 -0
- package/package.json +2 -2
- package/physical-bucket.d.ts +60 -0
- package/physical-bucket.js +117 -0
- package/plugin.js +57 -88
- package/s3-signer.d.ts +13 -0
- package/s3-signer.js +23 -0
- package/types.d.ts +34 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The resolved bucket coordinate.
|
|
16
|
+
*
|
|
17
|
+
* `physicalName` is the recorded S3 bucket name, or null when the logical
|
|
18
|
+
* bucket has never been provisioned — the caller mints and records it then.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResolvedBucketCoordinate {
|
|
21
|
+
bucketId: string;
|
|
22
|
+
resolvedKey: string;
|
|
23
|
+
bucketType: 'public' | 'private' | 'temp';
|
|
24
|
+
physicalName: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the bucket a write should land in.
|
|
28
|
+
*
|
|
29
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
30
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
31
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
32
|
+
* and an assertion on the named bucket's type when one is
|
|
33
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDefaultBucket(pgClient: {
|
|
36
|
+
query: (opts: {
|
|
37
|
+
text: string;
|
|
38
|
+
values?: unknown[];
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
rows: unknown[];
|
|
41
|
+
}>;
|
|
42
|
+
}, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side bucket resolution.
|
|
4
|
+
*
|
|
5
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
6
|
+
* never to the server's environment: a client-chosen key means a different
|
|
7
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
8
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
9
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
10
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
11
|
+
*
|
|
12
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
13
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.resolveDefaultBucket = resolveDefaultBucket;
|
|
17
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
18
|
+
const log = new logger_1.Logger('graphile-presigned-url:default-bucket');
|
|
19
|
+
const RESOLVE_DEFAULT_BUCKET_QUERY = `
|
|
20
|
+
SELECT bucket_id, resolved_key, bucket_type, physical_name
|
|
21
|
+
FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
|
|
22
|
+
`;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the bucket a write should land in.
|
|
25
|
+
*
|
|
26
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
27
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
28
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
29
|
+
* and an assertion on the named bucket's type when one is
|
|
30
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
31
|
+
*/
|
|
32
|
+
async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
|
|
33
|
+
const result = await pgClient.query({
|
|
34
|
+
text: RESOLVE_DEFAULT_BUCKET_QUERY,
|
|
35
|
+
values: [databaseId, scope, entityId, publicAccess, bucketKey],
|
|
36
|
+
});
|
|
37
|
+
const row = result.rows[0];
|
|
38
|
+
if (!row) {
|
|
39
|
+
// resolve_default_bucket raises on zero and on several matches, so an empty
|
|
40
|
+
// result means the function did not run as declared rather than "no bucket".
|
|
41
|
+
throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
|
|
42
|
+
`database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
|
|
43
|
+
}
|
|
44
|
+
log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
|
|
45
|
+
return {
|
|
46
|
+
bucketId: row.bucket_id,
|
|
47
|
+
resolvedKey: row.resolved_key,
|
|
48
|
+
bucketType: row.bucket_type,
|
|
49
|
+
physicalName: row.physical_name,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The resolved bucket coordinate.
|
|
16
|
+
*
|
|
17
|
+
* `physicalName` is the recorded S3 bucket name, or null when the logical
|
|
18
|
+
* bucket has never been provisioned — the caller mints and records it then.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResolvedBucketCoordinate {
|
|
21
|
+
bucketId: string;
|
|
22
|
+
resolvedKey: string;
|
|
23
|
+
bucketType: 'public' | 'private' | 'temp';
|
|
24
|
+
physicalName: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the bucket a write should land in.
|
|
28
|
+
*
|
|
29
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
30
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
31
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
32
|
+
* and an assertion on the named bucket's type when one is
|
|
33
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDefaultBucket(pgClient: {
|
|
36
|
+
query: (opts: {
|
|
37
|
+
text: string;
|
|
38
|
+
values?: unknown[];
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
rows: unknown[];
|
|
41
|
+
}>;
|
|
42
|
+
}, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
import { Logger } from '@pgpmjs/logger';
|
|
15
|
+
const log = new Logger('graphile-presigned-url:default-bucket');
|
|
16
|
+
const RESOLVE_DEFAULT_BUCKET_QUERY = `
|
|
17
|
+
SELECT bucket_id, resolved_key, bucket_type, physical_name
|
|
18
|
+
FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
|
|
19
|
+
`;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the bucket a write should land in.
|
|
22
|
+
*
|
|
23
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
24
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
25
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
26
|
+
* and an assertion on the named bucket's type when one is
|
|
27
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
|
|
30
|
+
const result = await pgClient.query({
|
|
31
|
+
text: RESOLVE_DEFAULT_BUCKET_QUERY,
|
|
32
|
+
values: [databaseId, scope, entityId, publicAccess, bucketKey],
|
|
33
|
+
});
|
|
34
|
+
const row = result.rows[0];
|
|
35
|
+
if (!row) {
|
|
36
|
+
// resolve_default_bucket raises on zero and on several matches, so an empty
|
|
37
|
+
// result means the function did not run as declared rather than "no bucket".
|
|
38
|
+
throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
|
|
39
|
+
`database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
|
|
40
|
+
}
|
|
41
|
+
log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
|
|
42
|
+
return {
|
|
43
|
+
bucketId: row.bucket_id,
|
|
44
|
+
resolvedKey: row.resolved_key,
|
|
45
|
+
bucketType: row.bucket_type,
|
|
46
|
+
physicalName: row.physical_name,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
3
|
+
* document column writes into.
|
|
4
|
+
*
|
|
5
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
6
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
7
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
8
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
9
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question — "what does a write to this column bind
|
|
12
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
13
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
14
|
+
* unmanaged lane produced objects no tenant owned.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A field's recorded storage intent.
|
|
18
|
+
*
|
|
19
|
+
* `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
|
|
20
|
+
* both may be absent — resolution then uses the reserved default tag for
|
|
21
|
+
* `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
|
|
22
|
+
* is resolved per written row, inside the tenant.
|
|
23
|
+
*/
|
|
24
|
+
export interface FileRefFieldBinding {
|
|
25
|
+
id: string;
|
|
26
|
+
storageModuleId: string;
|
|
27
|
+
bucketKey: string | null;
|
|
28
|
+
bucketTags: string[] | null;
|
|
29
|
+
isPublic: boolean | null;
|
|
30
|
+
enforceFk: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare class FileRefFieldNotRegisteredError extends Error {
|
|
33
|
+
readonly databaseId: string;
|
|
34
|
+
readonly schemaName: string;
|
|
35
|
+
readonly tableName: string;
|
|
36
|
+
readonly columnName: string;
|
|
37
|
+
constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Look up the storage binding for a document column, or throw.
|
|
41
|
+
*
|
|
42
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
43
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
44
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getFileRefFieldBinding(pgClient: {
|
|
47
|
+
query: (opts: {
|
|
48
|
+
text: string;
|
|
49
|
+
values?: unknown[];
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
rows: unknown[];
|
|
52
|
+
}>;
|
|
53
|
+
}, databaseId: string, field: {
|
|
54
|
+
schemaName: string;
|
|
55
|
+
tableName: string;
|
|
56
|
+
columnName: string;
|
|
57
|
+
}): Promise<FileRefFieldBinding>;
|
|
58
|
+
/**
|
|
59
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clearFileRefFieldCache(): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
3
|
+
* document column writes into.
|
|
4
|
+
*
|
|
5
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
6
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
7
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
8
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
9
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question — "what does a write to this column bind
|
|
12
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
13
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
14
|
+
* unmanaged lane produced objects no tenant owned.
|
|
15
|
+
*/
|
|
16
|
+
import { Logger } from '@pgpmjs/logger';
|
|
17
|
+
import { LRUCache } from 'lru-cache';
|
|
18
|
+
const log = new Logger('graphile-presigned-url:file-ref-registry');
|
|
19
|
+
const FIVE_MINUTES_MS = 1000 * 60 * 5;
|
|
20
|
+
const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the registry row for a document column.
|
|
23
|
+
*
|
|
24
|
+
* Joined through metaschema rather than keyed by name, because the registry
|
|
25
|
+
* records field *ids*: the physical (schema, table, column) triple is what the
|
|
26
|
+
* GraphQL layer knows, and metaschema is the only thing that maps one to the
|
|
27
|
+
* other.
|
|
28
|
+
*/
|
|
29
|
+
const FILE_REF_FIELD_QUERY = `
|
|
30
|
+
SELECT
|
|
31
|
+
frf.id,
|
|
32
|
+
frf.storage_module_id,
|
|
33
|
+
frf.bucket_key,
|
|
34
|
+
frf.bucket_tags::text[] AS bucket_tags,
|
|
35
|
+
frf.is_public,
|
|
36
|
+
frf.enforce_fk
|
|
37
|
+
FROM metaschema_modules_public.file_ref_field frf
|
|
38
|
+
JOIN metaschema_public.field f ON f.id = frf.field_id
|
|
39
|
+
JOIN metaschema_public.table t ON t.id = frf.table_id
|
|
40
|
+
JOIN metaschema_public.schema s ON s.id = t.schema_id
|
|
41
|
+
WHERE frf.database_id = $1
|
|
42
|
+
AND s.schema_name = $2
|
|
43
|
+
AND t.name = $3
|
|
44
|
+
AND f.name = $4
|
|
45
|
+
LIMIT 1
|
|
46
|
+
`;
|
|
47
|
+
/**
|
|
48
|
+
* LRU cache of field bindings.
|
|
49
|
+
*
|
|
50
|
+
* A binding is schema, not data: it changes only when a database is
|
|
51
|
+
* re-provisioned, so it caches on the same terms as the storage module config
|
|
52
|
+
* next to it. Misses are never cached — an unregistered column is a hard error
|
|
53
|
+
* every time it is written, not a remembered "no".
|
|
54
|
+
*/
|
|
55
|
+
const bindingCache = new LRUCache({
|
|
56
|
+
max: 500,
|
|
57
|
+
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
58
|
+
updateAgeOnGet: true,
|
|
59
|
+
});
|
|
60
|
+
export class FileRefFieldNotRegisteredError extends Error {
|
|
61
|
+
databaseId;
|
|
62
|
+
schemaName;
|
|
63
|
+
tableName;
|
|
64
|
+
columnName;
|
|
65
|
+
constructor(databaseId, schemaName, tableName, columnName) {
|
|
66
|
+
super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
|
|
67
|
+
`is not a registered file-reference field in database ${databaseId}. ` +
|
|
68
|
+
'A managed upload needs the declared storage module and bucket intent; ' +
|
|
69
|
+
'there is no server-global bucket to fall back to.');
|
|
70
|
+
this.databaseId = databaseId;
|
|
71
|
+
this.schemaName = schemaName;
|
|
72
|
+
this.tableName = tableName;
|
|
73
|
+
this.columnName = columnName;
|
|
74
|
+
this.name = 'FileRefFieldNotRegisteredError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Look up the storage binding for a document column, or throw.
|
|
79
|
+
*
|
|
80
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
81
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
82
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
83
|
+
*/
|
|
84
|
+
export async function getFileRefFieldBinding(pgClient, databaseId, field) {
|
|
85
|
+
const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
|
|
86
|
+
const cached = bindingCache.get(cacheKey);
|
|
87
|
+
if (cached)
|
|
88
|
+
return cached;
|
|
89
|
+
const result = await pgClient.query({
|
|
90
|
+
text: FILE_REF_FIELD_QUERY,
|
|
91
|
+
values: [databaseId, field.schemaName, field.tableName, field.columnName],
|
|
92
|
+
});
|
|
93
|
+
if (result.rows.length === 0) {
|
|
94
|
+
throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
|
|
95
|
+
}
|
|
96
|
+
const row = result.rows[0];
|
|
97
|
+
const binding = {
|
|
98
|
+
id: row.id,
|
|
99
|
+
storageModuleId: row.storage_module_id,
|
|
100
|
+
bucketKey: row.bucket_key,
|
|
101
|
+
bucketTags: row.bucket_tags,
|
|
102
|
+
isPublic: row.is_public,
|
|
103
|
+
enforceFk: row.enforce_fk,
|
|
104
|
+
};
|
|
105
|
+
bindingCache.set(cacheKey, binding);
|
|
106
|
+
log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
|
|
107
|
+
`${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
|
|
108
|
+
return binding;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
112
|
+
*/
|
|
113
|
+
export function clearFileRefFieldCache() {
|
|
114
|
+
bindingCache.clear();
|
|
115
|
+
}
|
package/esm/index.d.ts
CHANGED
|
@@ -26,9 +26,16 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
30
|
+
export { resolveDefaultBucket } from './default-bucket';
|
|
29
31
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
32
|
+
export type { FileRefFieldBinding } from './file-ref-registry';
|
|
33
|
+
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
34
|
+
export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
|
|
35
|
+
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
|
|
30
36
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
37
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
38
|
+
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
39
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
40
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
34
41
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -26,8 +26,13 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { resolveDefaultBucket } from './default-bucket';
|
|
29
30
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
31
|
+
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
32
|
+
export { assertUploadAllowedByBucket, buildFileProjection, finalizeStagedUpload, resolveManagedUploadTarget, } from './managed-upload';
|
|
33
|
+
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
|
|
30
34
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
35
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
36
|
+
export { withRequestPgClient } from './request-pg-client';
|
|
37
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
38
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed upload lifecycle, shared by both transports.
|
|
3
|
+
*
|
|
4
|
+
* Multipart-through-GraphQL and presigned two-step are transports for one
|
|
5
|
+
* lifecycle, not two data models: either way an object gets a files row, a
|
|
6
|
+
* server-chosen key, a tenant-resolved bucket, and a projection document that
|
|
7
|
+
* carries the files row's id. The only difference is who moves the bytes.
|
|
8
|
+
*
|
|
9
|
+
* This module owns the parts that are the same:
|
|
10
|
+
* * `resolveManagedUploadTarget` — from a document column to a concrete
|
|
11
|
+
* (storage module, bucket, physical bucket, S3 config).
|
|
12
|
+
* * `finalizeStagedUpload` — from bytes already in S3 under a staging key to a
|
|
13
|
+
* files row and a projection document, deduplicating on content hash.
|
|
14
|
+
* * `buildFileProjection` — the document shape the column stores.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here bakes a presigned URL into a row: `url` is populated only for a
|
|
17
|
+
* public bucket, where it is a stable CDN address rather than a credential with
|
|
18
|
+
* an expiry.
|
|
19
|
+
*/
|
|
20
|
+
import { type FileRefFieldBinding } from './file-ref-registry';
|
|
21
|
+
import { type WithPgClient } from './request-pg-client';
|
|
22
|
+
import type { BucketConfig, FileProjection, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
|
|
23
|
+
/**
|
|
24
|
+
* The document a managed `image`/`upload` column stores.
|
|
25
|
+
*
|
|
26
|
+
* `id` is the files row — the load-bearing field: it is what makes the column a
|
|
27
|
+
* projection rather than a second, unmanaged copy of the truth, and it is what
|
|
28
|
+
* storage GC counts before collecting an object.
|
|
29
|
+
*
|
|
30
|
+
* `url` is retained for existing readers of the pre-managed shape and is set
|
|
31
|
+
* only for public buckets. Prefer `id` plus the files row's `downloadUrl`, which
|
|
32
|
+
* is late-bound and works for private buckets too.
|
|
33
|
+
*/
|
|
34
|
+
export type { FileProjection } from './types';
|
|
35
|
+
/**
|
|
36
|
+
* Build the projection document for a files row.
|
|
37
|
+
*
|
|
38
|
+
* A public bucket has a stable address, so `url` is a real, durable value there.
|
|
39
|
+
* A private bucket has no such address — only presigned, expiring ones — so the
|
|
40
|
+
* field is omitted rather than filled with a URL that dies in an hour.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildFileProjection(file: {
|
|
43
|
+
id: string;
|
|
44
|
+
key: string;
|
|
45
|
+
bucketId: string;
|
|
46
|
+
mime: string;
|
|
47
|
+
size: number;
|
|
48
|
+
filename?: string | null;
|
|
49
|
+
}, bucket: {
|
|
50
|
+
is_public: boolean;
|
|
51
|
+
}, s3: S3Config): FileProjection;
|
|
52
|
+
/**
|
|
53
|
+
* Everything a managed upload needs before bytes move.
|
|
54
|
+
*/
|
|
55
|
+
export interface ManagedUploadTarget {
|
|
56
|
+
databaseId: string;
|
|
57
|
+
storageConfig: StorageModuleConfig;
|
|
58
|
+
bucket: BucketConfig;
|
|
59
|
+
physicalName: string;
|
|
60
|
+
s3: S3Config;
|
|
61
|
+
/** The registry row, or null when the column predates registration. */
|
|
62
|
+
binding: FileRefFieldBinding | null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve where a write to a document column lands.
|
|
66
|
+
*
|
|
67
|
+
* Two routes, one rule — the bucket is always resolved inside the tenant:
|
|
68
|
+
* * a registered column names its storage module, and either a logical bucket
|
|
69
|
+
* key or the reserved default tag for its declared publicness;
|
|
70
|
+
* * an unregistered column (a bare `image`/`upload` on a database provisioned
|
|
71
|
+
* before the registry) falls back to the app-scope module and the same
|
|
72
|
+
* reserved default tag. That is a *tenant* default, not an environment one.
|
|
73
|
+
*
|
|
74
|
+
* A database with no storage module raises: there is nowhere tenant-owned to put
|
|
75
|
+
* the bytes, and the deployment's configured bucket is not an answer.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveManagedUploadTarget(args: {
|
|
78
|
+
options: PresignedUrlPluginOptions;
|
|
79
|
+
withPgClient: WithPgClient;
|
|
80
|
+
pgSettings: Record<string, string> | null;
|
|
81
|
+
databaseId: string;
|
|
82
|
+
field: {
|
|
83
|
+
schemaName: string;
|
|
84
|
+
tableName: string;
|
|
85
|
+
columnName: string;
|
|
86
|
+
};
|
|
87
|
+
/** Publicness to use when the column is unregistered. */
|
|
88
|
+
defaultPublicAccess: boolean;
|
|
89
|
+
}): Promise<ManagedUploadTarget>;
|
|
90
|
+
/**
|
|
91
|
+
* Validate an upload against the resolved bucket's rules.
|
|
92
|
+
*
|
|
93
|
+
* The same rules the presigned lane enforces — a transport must not be a way
|
|
94
|
+
* around a bucket's mime allowlist or size cap.
|
|
95
|
+
*/
|
|
96
|
+
export declare function assertUploadAllowedByBucket(target: ManagedUploadTarget, contentType: string, size: number): void;
|
|
97
|
+
/**
|
|
98
|
+
* Turn bytes already staged in S3 into a files row and a projection document.
|
|
99
|
+
*
|
|
100
|
+
* The content hash is only known once the stream has been read, so a streaming
|
|
101
|
+
* transport writes to a staging key first and promotes here:
|
|
102
|
+
*
|
|
103
|
+
* * hash already present in this bucket → drop the staged object, reuse the
|
|
104
|
+
* existing files row. Dedup is a property of the object, so it holds no
|
|
105
|
+
* matter which transport wrote it first.
|
|
106
|
+
* * otherwise → server-side copy to the content-addressed key, drop the staged
|
|
107
|
+
* object, insert the files row.
|
|
108
|
+
*
|
|
109
|
+
* The row is inserted *after* the bytes land, so the confirm-upload job the
|
|
110
|
+
* insert trigger enqueues finds the object and completes the
|
|
111
|
+
* `requested → uploaded` transition without any extra wiring here.
|
|
112
|
+
*
|
|
113
|
+
* Every failure path leaves S3 as it found it. Bytes written by this call and
|
|
114
|
+
* not reachable through a files row would be invisible to storage GC, which
|
|
115
|
+
* collects objects by walking rows — so an object is only left behind once the
|
|
116
|
+
* row naming it exists.
|
|
117
|
+
*/
|
|
118
|
+
export declare function finalizeStagedUpload(args: {
|
|
119
|
+
target: ManagedUploadTarget;
|
|
120
|
+
withPgClient: WithPgClient;
|
|
121
|
+
pgSettings: Record<string, string> | null;
|
|
122
|
+
staged: {
|
|
123
|
+
stagingKey: string;
|
|
124
|
+
contentHash: string;
|
|
125
|
+
contentType: string;
|
|
126
|
+
size: number;
|
|
127
|
+
filename?: string | null;
|
|
128
|
+
};
|
|
129
|
+
}): Promise<{
|
|
130
|
+
projection: FileProjection;
|
|
131
|
+
deduplicated: boolean;
|
|
132
|
+
}>;
|