Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,21 @@ exports[`MetaSchemaPlugin _meta query contract contains required selection paths
"search.config.weights",
"search.hasUnifiedSearch",
"storage",
"storage.bucketsType",
"storage.downloadUrlField",
"storage.filesType",
"storage.isBucketsTable",
"storage.isFilesTable",
"storage.upload",
"storage.upload.bulkFileInputType",
"storage.upload.bulkFilePayloadType",
"storage.upload.bulkInputType",
"storage.upload.bulkMutation",
"storage.upload.bulkPayloadType",
"storage.upload.inputType",
"storage.upload.mutation",
"storage.upload.payloadType",
"storage.upload.requiresOwnerId",
"tableName",
"uniqueConstraints",
"uniqueConstraints.name",
Expand Down Expand Up @@ -278,6 +291,20 @@ exports[`MetaSchemaPlugin _meta query contract has stable printed GraphQL text 1
storage {
isFilesTable
isBucketsTable
filesType
bucketsType
downloadUrlField
upload {
mutation
inputType
payloadType
bulkMutation
bulkInputType
bulkPayloadType
bulkFileInputType
bulkFilePayloadType
requiresOwnerId
}
}
search {
algorithms
Expand Down
154 changes: 134 additions & 20 deletions graphile/graphile-meta/__tests__/meta-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,18 @@ function createMockBuild(
schemas: string[] = ['app_public'],
overrides: Record<string, any> = {}
) {
const resourceList = Object.values(resources).filter((resource: any) => resource.codec);
const baseBuild = {
input: {
pgRegistry: { pgResources: resources }
pgRegistry: {
pgResources: resources,
pgCodecs: Object.fromEntries(
resourceList.map((resource: any) => [resource.codec.name, resource.codec])
),
pgRelations: Object.fromEntries(
resourceList.map((resource: any) => [resource.codec.name, resource.relations || {}])
)
}
},
inflection: {
tableType: (codec: any) =>
Expand Down Expand Up @@ -390,7 +399,24 @@ query MetaContract {
rightTable { name }
}
}
storage { isFilesTable isBucketsTable }
storage {
isFilesTable
isBucketsTable
filesType
bucketsType
downloadUrlField
upload {
mutation
inputType
payloadType
bulkMutation
bulkInputType
bulkPayloadType
bulkFileInputType
bulkFilePayloadType
requiresOwnerId
}
}
search {
algorithms
columns { name algorithm }
Expand Down Expand Up @@ -479,6 +505,18 @@ const REQUIRED_META_QUERY_PATHS = [
'relations.manyToMany.rightTable.name',
'storage.isFilesTable',
'storage.isBucketsTable',
'storage.filesType',
'storage.bucketsType',
'storage.downloadUrlField',
'storage.upload.mutation',
'storage.upload.inputType',
'storage.upload.payloadType',
'storage.upload.bulkMutation',
'storage.upload.bulkInputType',
'storage.upload.bulkPayloadType',
'storage.upload.bulkFileInputType',
'storage.upload.bulkFilePayloadType',
'storage.upload.requiresOwnerId',
'search.algorithms',
'search.columns.name',
'search.columns.algorithm',
Expand Down Expand Up @@ -2182,43 +2220,119 @@ describe('MetaSchemaPlugin', () => {
expect(tables[0].storage).toBeNull();
});

it('detects @storageFiles tagged tables', () => {
const codec = createMockCodec('app_file', {
function createStoragePlaneResources(
filesName: string,
bucketsName: string,
opts: { ownerId?: boolean } = {}
): Record<string, any> {
const bucketsCodec = createMockCodec(bucketsName, {
id: createMockAttribute('uuid'),
key: createMockAttribute('text'),
...(opts.ownerId ? { owner_id: createMockAttribute('uuid') } : {})
});
(bucketsCodec as any).extensions = {
...bucketsCodec.extensions,
tags: { storageBuckets: true }
};
const filesCodec = createMockCodec(filesName, {
id: createMockAttribute('uuid'),
key: createMockAttribute('text'),
bucket_id: createMockAttribute('uuid')
});
(codec as any).extensions = {
...codec.extensions,
(filesCodec as any).extensions = {
...filesCodec.extensions,
tags: { storageFiles: true }
};
const build = createMockBuild({
app_file: { codec, uniques: [], relations: {} }
return {
[filesName]: {
codec: filesCodec,
uniques: [],
relations: {
[`${filesName}_bucket_fkey`]: {
isReferencee: false,
localAttributes: ['bucket_id'],
remoteAttributes: ['id'],
remoteResource: { codec: bucketsCodec }
}
}
},
[bucketsName]: { codec: bucketsCodec, uniques: [], relations: {} }
};
}

it('reports the full plane surface for @storageFiles tables', () => {
const build = createMockBuild(createStoragePlaneResources('app_file', 'app_bucket'));
const tables = callInitHook(build);
const files = tables.find((t: any) => t.tableName === 'app_file');
expect(files.storage).toEqual({
isFilesTable: true,
isBucketsTable: false,
filesType: 'AppFile',
bucketsType: 'AppBucket',
downloadUrlField: 'downloadUrl',
upload: {
mutation: 'uploadAppFile',
inputType: 'UploadAppFileInput',
payloadType: 'UploadAppFilePayload',
bulkMutation: 'uploadAppFiles',
bulkInputType: 'UploadAppFileBulkInput',
bulkPayloadType: 'UploadAppFileBulkPayload',
bulkFileInputType: 'UploadAppFileBulkFileInput',
bulkFilePayloadType: 'UploadAppFileBulkFilePayload',
requiresOwnerId: false
}
});
});

it('reports the same plane from the @storageBuckets side', () => {
const build = createMockBuild(createStoragePlaneResources('app_file', 'app_bucket'));
const tables = callInitHook(build);
const buckets = tables.find((t: any) => t.tableName === 'app_bucket');
expect(buckets.storage).toMatchObject({
isFilesTable: false,
isBucketsTable: true,
filesType: 'AppFile',
bucketsType: 'AppBucket',
downloadUrlField: null
});
expect(buckets.storage.upload.mutation).toBe('uploadAppFile');
});

it('pairs unprefixed files/buckets tables (database-scope planes)', () => {
const build = createMockBuild(createStoragePlaneResources('files', 'buckets'));
const tables = callInitHook(build);
expect(tables[0].storage).toEqual({
const files = tables.find((t: any) => t.tableName === 'files');
expect(files.storage).toMatchObject({
isFilesTable: true,
isBucketsTable: false
filesType: 'Files',
bucketsType: 'Buckets'
});
expect(files.storage.upload.mutation).toBe('uploadFiles');
});

it('reports requiresOwnerId for entity-keyed planes', () => {
const build = createMockBuild(
createStoragePlaneResources('data_room_file', 'data_room_bucket', { ownerId: true })
);
const tables = callInitHook(build);
const files = tables.find((t: any) => t.tableName === 'data_room_file');
expect(files.storage.upload.requiresOwnerId).toBe(true);
});

it('detects @storageBuckets tagged tables', () => {
const codec = createMockCodec('app_bucket', {
it('throws for a @storageFiles table with no FK to a @storageBuckets table', () => {
const codec = createMockCodec('app_file', {
id: createMockAttribute('uuid'),
key: createMockAttribute('text')
key: createMockAttribute('text'),
bucket_id: createMockAttribute('uuid')
});
(codec as any).extensions = {
...codec.extensions,
tags: { storageBuckets: true }
tags: { storageFiles: true }
};
const build = createMockBuild({
app_bucket: { codec, uniques: [], relations: {} }
});
const tables = callInitHook(build);
expect(tables[0].storage).toEqual({
isFilesTable: false,
isBucketsTable: true
app_file: { codec, uniques: [], relations: {} }
});
expect(() => callInitHook(build)).toThrow(/STORAGE_PLANE_UNPAIRED/);
});
});

Expand Down
3 changes: 3 additions & 0 deletions graphile/graphile-meta/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"graphile-storage-registry": "workspace:^"
},
"devDependencies": {
"@types/node": "^22.19.11",
"makage": "^0.3.0"
Expand Down
22 changes: 21 additions & 1 deletion graphile/graphile-meta/src/graphql-meta-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,32 @@ function createMetaSchemaType(): GraphQLObjectType {
})
});

const MetaStorageUploadType = new GraphQLObjectType({
name: 'MetaStorageUpload',
description: 'The GraphQL upload surface of a storage plane, derived from the registry facts the presigned-url plugin emits from',
fields: () => ({
mutation: { type: nn(GraphQLString), description: 'Root mutation field for single-file upload (e.g. uploadAppFile)' },
inputType: { type: nn(GraphQLString), description: 'Input type of the single upload mutation' },
payloadType: { type: nn(GraphQLString), description: 'Payload type of the single upload mutation' },
bulkMutation: { type: nn(GraphQLString), description: 'Root mutation field for bulk upload' },
bulkInputType: { type: nn(GraphQLString), description: 'Input type of the bulk upload mutation' },
bulkPayloadType: { type: nn(GraphQLString), description: 'Payload type of the bulk upload mutation' },
bulkFileInputType: { type: nn(GraphQLString), description: 'Per-file input type inside the bulk input' },
bulkFilePayloadType: { type: nn(GraphQLString), description: 'Per-file payload type inside the bulk payload' },
requiresOwnerId: { type: nn(GraphQLBoolean), description: 'Whether the upload input requires ownerId (entity-keyed plane)' }
})
});

const MetaStorageType = new GraphQLObjectType({
name: 'MetaStorage',
description: 'Storage metadata for a table',
fields: () => ({
isFilesTable: { type: nn(GraphQLBoolean), description: 'Whether this table is a storage files table' },
isBucketsTable: { type: nn(GraphQLBoolean), description: 'Whether this table is a storage buckets table' }
isBucketsTable: { type: nn(GraphQLBoolean), description: 'Whether this table is a storage buckets table' },
filesType: { type: nn(GraphQLString), description: 'GraphQL type name of the plane\'s files table' },
bucketsType: { type: nn(GraphQLString), description: 'GraphQL type name of the plane\'s buckets table' },
downloadUrlField: { type: GraphQLString, description: 'Computed download-URL field on the files type; null on the buckets side' },
upload: { type: nn(MetaStorageUploadType), description: 'The plane\'s GraphQL upload surface' }
})
});

Expand Down
78 changes: 75 additions & 3 deletions graphile/graphile-meta/src/storage-search-meta-builders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import {
discoverStoragePlanes,
DOWNLOAD_URL_FIELD,
type StoragePgRegistry,
type StoragePlanePair,
uploadSurfaceNames,
} from 'graphile-storage-registry';

import type {
I18nFieldMeta,
I18nMeta,
MetaBuild,
PgCodec,
RealtimeMeta,
SearchColumnMeta,
Expand All @@ -9,11 +18,46 @@ import type {
StorageMeta,
} from './types';

const planesCache = new WeakMap<object, StoragePlanePair[]>();

/**
* Discover the registry's storage planes once per registry. Pairing comes from
* the registry's actual files→buckets FK relations (graphile-storage-registry),
* never from table names; a tagged table that cannot be paired throws.
*/
function storagePlanesForBuild(build: MetaBuild): StoragePlanePair[] {
const registry = build.input.pgRegistry;
const cached = planesCache.get(registry);
if (cached) return cached;

const pgCodecs =
registry.pgCodecs ??
Object.fromEntries(
Object.values(registry.pgResources)
.map((resource: any) => resource.codec)
.filter(Boolean)
.map((codec: any) => [codec.name, codec]),
);

const storageRegistry: StoragePgRegistry = {
pgCodecs: pgCodecs as StoragePgRegistry['pgCodecs'],
pgRelations: (registry.pgRelations ?? {}) as StoragePgRegistry['pgRelations'],
};

const planes = discoverStoragePlanes(storageRegistry);
planesCache.set(registry, planes);
return planes;
}

/**
* Detect storage metadata from a codec's smart tags.
* Storage tables are identified by @storageFiles and @storageBuckets smart tags.
* Build storage metadata for a codec tagged @storageFiles or @storageBuckets.
*
* The plane pairing and upload-surface names derive from the same registry
* facts and inflection the presigned-url plugin emits from, so `_meta` and the
* emitted schema cannot disagree. A tagged table with no valid plane is a
* provisioning bug and throws rather than reporting a partial surface.
*/
export function buildStorageMeta(codec: PgCodec): StorageMeta | null {
export function buildStorageMeta(codec: PgCodec, build: MetaBuild): StorageMeta | null {
const tags = (codec as any).extensions?.tags;
if (!tags) return null;

Expand All @@ -22,9 +66,37 @@ export function buildStorageMeta(codec: PgCodec): StorageMeta | null {

if (!isFilesTable && !isBucketsTable) return null;

const planes = storagePlanesForBuild(build);
const plane = planes.find(
(candidate) => candidate.filesCodec === (codec as any) || candidate.bucketsCodec === (codec as any),
);
if (!plane) {
throw new Error(
`STORAGE_PLANE_UNPAIRED: storage-tagged table ${codec.name} belongs to no ` +
`discovered storage plane; check its @storageFiles/@storageBuckets smart tags ` +
`and the files table's FK to its buckets table.`,
);
}

const names = uploadSurfaceNames(build.inflection as any, plane.filesCodec);

return {
isFilesTable,
isBucketsTable,
filesType: names.filesTypeName,
bucketsType: build.inflection.tableType(plane.bucketsCodec as any),
downloadUrlField: isFilesTable ? DOWNLOAD_URL_FIELD : null,
upload: {
mutation: names.uploadMutation,
inputType: names.uploadInputType,
payloadType: names.uploadPayloadType,
bulkMutation: names.bulkUploadMutation,
bulkInputType: names.bulkUploadInputType,
bulkPayloadType: names.bulkUploadPayloadType,
bulkFileInputType: names.bulkUploadFileInputType,
bulkFilePayloadType: names.bulkUploadFilePayloadType,
requiresOwnerId: plane.hasOwnerId,
},
};
}

Expand Down
Loading
Loading