From 08c8f06de86722583b02261f2dbdef035a949cbc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 18 Jul 2026 10:04:20 +0000 Subject: [PATCH 1/3] feat(meta): add scope block to _meta introspection plugin Surfaces provisioning scope per table in _meta: scope, tier (global/database/entity), keyColumn, entityTable, and source (smartTag|inferred). Reads the @scope smart tag with a database_id column-inference fallback. Includes unit tests + updated snapshots. --- .agents/skills/graphile-meta/SKILL.md | 10 +- .../__snapshots__/meta-schema.test.ts.snap | 18 + .../__tests__/meta-schema.test.ts | 863 ++++++++++-------- .../plugins/meta-schema/graphql-meta-field.ts | 122 +-- .../meta-schema/scope-meta-builders.ts | 99 ++ .../plugins/meta-schema/table-meta-builder.ts | 31 +- .../src/plugins/meta-schema/types.ts | 17 + 7 files changed, 730 insertions(+), 430 deletions(-) create mode 100644 graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts diff --git a/.agents/skills/graphile-meta/SKILL.md b/.agents/skills/graphile-meta/SKILL.md index 269637553..f3e631233 100644 --- a/.agents/skills/graphile-meta/SKILL.md +++ b/.agents/skills/graphile-meta/SKILL.md @@ -121,7 +121,7 @@ map it onto a `Meta*` GraphQL type.** ## Adding a new metadata block to `_meta` -Worked pattern (this is exactly how a `scope` block is being added — mirror the +Worked pattern (this is exactly how the `scope` block was added — mirror the `storage`/`search` precedent): 1. **(DB, if the data isn't already a physical column)** Emit a smart tag from the @@ -131,8 +131,8 @@ Worked pattern (this is exactly how a `scope` block is being added — mirror th `pgpm package` + `pnpm run generate:constructive`. 2. **`types.ts`** — add the interface (e.g. `ScopeMeta`) and a nullable field on `TableMeta` (`scope: ScopeMeta | null`). -3. **A builder** — add `buildScopeMeta(codec, build, inflectAttr): ScopeMeta | null` - in `storage-search-meta-builders.ts` (or a new `scope-meta-builders.ts`). Read +3. **A builder** — add `buildScopeMeta(codec, inflectAttr): ScopeMeta | null` + in a builder file (`scope` lives in `scope-meta-builders.ts`). Read `codec.extensions.tags`, return `null` when the feature is absent (the convention across all builders). Optionally infer from physical columns as a fallback (e.g. `database_id` present → database scope) and record provenance. @@ -171,7 +171,7 @@ file, or direct introspection). Beyond raw structure it gives codegen the algorithm and know when `unifiedSearch` exists. - **i18n / realtime** — generate translation helpers and subscription hooks only where applicable. -- **Scope (planned)** — auto-inject `databaseId`/`orgId` scope keys from context, +- **Scope** — auto-inject `databaseId`/`orgId` scope keys from context, mark them non-required in create inputs, group/emit CLI & docs by tier, and choose the correct RLS/JWT context for generated test seeds. @@ -196,4 +196,4 @@ file, or direct introspection). Beyond raw structure it gives codegen the - `graphile-search` — the search plugin whose activation `_meta.search` reflects - `constructive-cli` / `constructive-graphql-codegen` — consumers of `_meta` -- `authoring-scoped-modules` (constructive-db) — the scope model `_meta.scope` will surface +- `authoring-scoped-modules` (constructive-db) — the scope model `_meta.scope` surfaces diff --git a/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap b/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap index 7fc0818f6..c2331538a 100644 --- a/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap +++ b/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap @@ -105,6 +105,12 @@ exports[`MetaSchemaPlugin _meta query contract contains required selection paths "relations.manyToMany.rightTable.name", "relations.manyToMany.type", "schemaName", + "scope", + "scope.entityTable", + "scope.keyColumn", + "scope.scope", + "scope.source", + "scope.tier", "search", "search.algorithms", "search.columns", @@ -293,6 +299,13 @@ exports[`MetaSchemaPlugin _meta query contract has stable printed GraphQL text 1 realtime { subscriptionFieldName } + scope { + scope + tier + keyColumn + entityTable + source + } } } }" @@ -732,6 +745,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "manyToMany": [], }, "schemaName": "app_public", + "scope": null, "search": null, "storage": null, "uniqueConstraints": [], @@ -1338,6 +1352,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], }, "schemaName": "app_public", + "scope": null, "search": null, "storage": null, "uniqueConstraints": [], @@ -1792,6 +1807,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "manyToMany": [], }, "schemaName": "app_public", + "scope": null, "search": null, "storage": null, "uniqueConstraints": [], @@ -2234,6 +2250,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], }, "schemaName": "app_public", + "scope": null, "search": null, "storage": null, "uniqueConstraints": [ @@ -2587,6 +2604,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "manyToMany": [], }, "schemaName": "app_public", + "scope": null, "search": null, "storage": null, "uniqueConstraints": [ diff --git a/graphile/graphile-settings/__tests__/meta-schema.test.ts b/graphile/graphile-settings/__tests__/meta-schema.test.ts index f8c46863a..75cb8f032 100644 --- a/graphile/graphile-settings/__tests__/meta-schema.test.ts +++ b/graphile/graphile-settings/__tests__/meta-schema.test.ts @@ -1,20 +1,21 @@ import { - MetaSchemaPlugin, - _pgTypeToGqlType, - _buildFieldMeta, - _cachedTablesMeta, -} from '../src/plugins/meta-schema'; -import { - parse, - print, - Kind, + graphql, GraphQLObjectType, GraphQLSchema, GraphQLString, - graphql, - type SelectionNode, + Kind, + parse, + print, + type SelectionNode } from 'graphql'; +import { + _buildFieldMeta, + _cachedTablesMeta, + _pgTypeToGqlType, + MetaSchemaPlugin +} from '../src/plugins/meta-schema'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -24,20 +25,20 @@ function createMockAttribute(pgType: string, opts: Record = {}) { codec: { name: pgType, arrayOfCodec: null, ...opts.codec }, notNull: false, hasDefault: false, - ...opts, + ...opts }; } function createMockCodec( name: string, attributes: Record, - schemaName = 'app_public', + schemaName = 'app_public' ) { return { name, attributes, isAnonymous: false, - extensions: { pg: { schemaName } }, + extensions: { pg: { schemaName } } }; } @@ -48,7 +49,7 @@ function createMockResource(opts: Record) { relations, uniques: opts.uniques || [], getRelations: () => relations, - getRelation: (name: string) => relations[name], + getRelation: (name: string) => relations[name] }; } @@ -59,11 +60,11 @@ function createMockResource(opts: Record) { function createMockBuild( resources: Record, schemas: string[] = ['app_public'], - overrides: Record = {}, + overrides: Record = {} ) { const baseBuild = { input: { - pgRegistry: { pgResources: resources }, + pgRegistry: { pgResources: resources } }, inflection: { tableType: (codec: any) => @@ -90,9 +91,9 @@ function createMockBuild( tableFieldName: (resource: any) => (resource.codec?.name || 'unknown').toLowerCase(), createField: (resource: any) => 'create' + (resource.codec?.name || ''), updateByKeys: (resource: any) => 'update' + (resource.codec?.name || ''), - deleteByKeys: (resource: any) => 'delete' + (resource.codec?.name || ''), + deleteByKeys: (resource: any) => 'delete' + (resource.codec?.name || '') }, - options: { pgSchemas: schemas }, + options: { pgSchemas: schemas } }; return { @@ -100,12 +101,12 @@ function createMockBuild( ...overrides, inflection: { ...baseBuild.inflection, - ...(overrides.inflection || {}), + ...(overrides.inflection || {}) }, options: { ...baseBuild.options, - ...(overrides.options || {}), - }, + ...(overrides.options || {}) + } }; } @@ -123,7 +124,7 @@ function callInitHook(build: any): any[] { function callGraphQLObjectTypeFieldsHook( fields: Record, build: any, - selfName: string, + selfName: string ): Record { const fieldsHook = MetaSchemaPlugin.schema!.hooks!.GraphQLObjectType_fields as ( fields: Record, @@ -153,7 +154,7 @@ function normalizeTablesForSnapshot(input: any[]): any[] { table.indexes = sortByName(table.indexes).map((idx: any) => ({ ...idx, columns: [...(idx.columns || [])].sort(), - fields: sortByName(idx.fields), + fields: sortByName(idx.fields) })); if (table.constraints?.primaryKey) { @@ -163,48 +164,48 @@ function normalizeTablesForSnapshot(input: any[]): any[] { ...table.constraints, unique: sortByName(table.constraints?.unique).map((c: any) => ({ ...c, - fields: sortByName(c.fields), + fields: sortByName(c.fields) })), foreignKey: sortByName(table.constraints?.foreignKey).map((c: any) => ({ ...c, fields: sortByName(c.fields), referencedFields: [...(c.referencedFields || [])].sort(), - refFields: sortByName(c.refFields), - })), + refFields: sortByName(c.refFields) + })) }; table.foreignKeyConstraints = sortByName(table.foreignKeyConstraints).map((c: any) => ({ ...c, fields: sortByName(c.fields), referencedFields: [...(c.referencedFields || [])].sort(), - refFields: sortByName(c.refFields), + refFields: sortByName(c.refFields) })); table.primaryKeyConstraints = sortByName(table.primaryKeyConstraints).map((c: any) => ({ ...c, - fields: sortByName(c.fields), + fields: sortByName(c.fields) })); table.uniqueConstraints = sortByName(table.uniqueConstraints).map((c: any) => ({ ...c, - fields: sortByName(c.fields), + fields: sortByName(c.fields) })); table.relations = { ...table.relations, belongsTo: sortByFieldName(table.relations?.belongsTo).map((rel: any) => ({ ...rel, - keys: sortByName(rel.keys), + keys: sortByName(rel.keys) })), has: sortByFieldName(table.relations?.has).map((rel: any) => ({ ...rel, - keys: sortByName(rel.keys), + keys: sortByName(rel.keys) })), hasOne: sortByFieldName(table.relations?.hasOne).map((rel: any) => ({ ...rel, - keys: sortByName(rel.keys), + keys: sortByName(rel.keys) })), hasMany: sortByFieldName(table.relations?.hasMany).map((rel: any) => ({ ...rel, - keys: sortByName(rel.keys), + keys: sortByName(rel.keys) })), manyToMany: sortByFieldName(table.relations?.manyToMany).map((rel: any) => ({ ...rel, @@ -212,19 +213,19 @@ function normalizeTablesForSnapshot(input: any[]): any[] { ...rel.junctionLeftConstraint, fields: sortByName(rel.junctionLeftConstraint?.fields), referencedFields: [...(rel.junctionLeftConstraint?.referencedFields || [])].sort(), - refFields: sortByName(rel.junctionLeftConstraint?.refFields), + refFields: sortByName(rel.junctionLeftConstraint?.refFields) }, junctionLeftKeyAttributes: sortByName(rel.junctionLeftKeyAttributes), junctionRightConstraint: { ...rel.junctionRightConstraint, fields: sortByName(rel.junctionRightConstraint?.fields), referencedFields: [...(rel.junctionRightConstraint?.referencedFields || [])].sort(), - refFields: sortByName(rel.junctionRightConstraint?.refFields), + refFields: sortByName(rel.junctionRightConstraint?.refFields) }, junctionRightKeyAttributes: sortByName(rel.junctionRightKeyAttributes), leftKeyAttributes: sortByName(rel.leftKeyAttributes), - rightKeyAttributes: sortByName(rel.rightKeyAttributes), - })), + rightKeyAttributes: sortByName(rel.rightKeyAttributes) + })) }; } @@ -400,6 +401,13 @@ query MetaContract { realtime { subscriptionFieldName } + scope { + scope + tier + keyColumn + entityTable + source + } } } } @@ -480,6 +488,11 @@ const REQUIRED_META_QUERY_PATHS = [ 'i18n.translatableFields.name', 'i18n.translatableFields.type', 'realtime.subscriptionFieldName', + 'scope.scope', + 'scope.tier', + 'scope.keyColumn', + 'scope.entityTable', + 'scope.source' ]; function collectSelectionPaths(selections: readonly SelectionNode[], prefix = ''): string[] { @@ -501,11 +514,11 @@ function getMetaQueryTablePaths(query: string): string[] { for (const definition of document.definitions) { if (definition.kind !== Kind.OPERATION_DEFINITION) continue; const metaField = definition.selectionSet.selections.find( - (sel) => sel.kind === Kind.FIELD && sel.name.value === '_meta', + (sel) => sel.kind === Kind.FIELD && sel.name.value === '_meta' ); if (!metaField || metaField.kind !== Kind.FIELD) continue; const tablesField = metaField.selectionSet?.selections.find( - (sel) => sel.kind === Kind.FIELD && sel.name.value === 'tables', + (sel) => sel.kind === Kind.FIELD && sel.name.value === 'tables' ); if (!tablesField || tablesField.kind !== Kind.FIELD || !tablesField.selectionSet) continue; return collectSelectionPaths(tablesField.selectionSet.selections); @@ -517,45 +530,45 @@ function buildComplexScenarioTables(): any[] { const userCodec = createMockCodec('user', { id: createMockAttribute('uuid', { notNull: true, hasDefault: true }), email_address: createMockAttribute('email', { notNull: true }), - display_name: createMockAttribute('text'), + display_name: createMockAttribute('text') }); const postCodec = createMockCodec('post', { id: createMockAttribute('uuid', { notNull: true, hasDefault: true }), author_id: createMockAttribute('uuid', { notNull: true }), title: createMockAttribute('text', { notNull: true }), - metadata: createMockAttribute('jsonb'), + metadata: createMockAttribute('jsonb') }); const commentCodec = createMockCodec('comment', { id: createMockAttribute('uuid', { notNull: true, hasDefault: true }), post_id: createMockAttribute('uuid', { notNull: true }), author_id: createMockAttribute('uuid', { notNull: true }), - body: createMockAttribute('text', { notNull: true }), + body: createMockAttribute('text', { notNull: true }) }); const tagCodec = createMockCodec('tag', { id: createMockAttribute('uuid', { notNull: true, hasDefault: true }), - slug: createMockAttribute('text', { notNull: true }), + slug: createMockAttribute('text', { notNull: true }) }); const postTagCodec = createMockCodec('post_tag', { post_id: createMockAttribute('uuid', { notNull: true }), - tag_id: createMockAttribute('uuid', { notNull: true }), + tag_id: createMockAttribute('uuid', { notNull: true }) }); const userUniques = [ { attributes: ['id'], isPrimary: true, tags: { name: 'users_pkey' } }, - { attributes: ['email_address'], isPrimary: false, tags: { name: 'users_email_key' } }, + { attributes: ['email_address'], isPrimary: false, tags: { name: 'users_email_key' } } ]; const postUniques = [ - { attributes: ['id'], isPrimary: true, tags: { name: 'posts_pkey' } }, + { attributes: ['id'], isPrimary: true, tags: { name: 'posts_pkey' } } ]; const commentUniques = [ - { attributes: ['id'], isPrimary: true, tags: { name: 'comments_pkey' } }, + { attributes: ['id'], isPrimary: true, tags: { name: 'comments_pkey' } } ]; const tagUniques = [ { attributes: ['id'], isPrimary: true, tags: { name: 'tags_pkey' } }, - { attributes: ['slug'], isPrimary: false, tags: { name: 'tags_slug_key' } }, + { attributes: ['slug'], isPrimary: false, tags: { name: 'tags_slug_key' } } ]; const postTagUniques = [ - { attributes: ['post_id', 'tag_id'], isPrimary: true, tags: { name: 'post_tags_pkey' } }, + { attributes: ['post_id', 'tag_id'], isPrimary: true, tags: { name: 'post_tags_pkey' } } ]; const postTagResource = createMockResource({ @@ -566,15 +579,15 @@ function buildComplexScenarioTables(): any[] { isReferencee: false, localAttributes: ['post_id'], remoteAttributes: ['id'], - remoteResource: { codec: postCodec, uniques: postUniques }, + remoteResource: { codec: postCodec, uniques: postUniques } }, post_tag_tag_fkey: { isReferencee: false, localAttributes: ['tag_id'], remoteAttributes: ['id'], - remoteResource: { codec: tagCodec, uniques: tagUniques }, - }, - }, + remoteResource: { codec: tagCodec, uniques: tagUniques } + } + } }); const postResource = createMockResource({ @@ -585,21 +598,21 @@ function buildComplexScenarioTables(): any[] { isReferencee: false, localAttributes: ['author_id'], remoteAttributes: ['id'], - remoteResource: { codec: userCodec, uniques: userUniques }, + remoteResource: { codec: userCodec, uniques: userUniques } }, post_comments: { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: commentCodec, uniques: commentUniques }, + remoteResource: { codec: commentCodec, uniques: commentUniques } }, post_post_tags: { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: postTagCodec, uniques: postTagUniques }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: postTagUniques } + } + } }); const tagResource = createMockResource({ @@ -610,9 +623,9 @@ function buildComplexScenarioTables(): any[] { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['tag_id'], - remoteResource: { codec: postTagCodec, uniques: postTagUniques }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: postTagUniques } + } + } }); const pgManyToManyRealtionshipsByResource = new Map([ @@ -625,9 +638,9 @@ function buildComplexScenarioTables(): any[] { junctionTable: postTagResource, rightRelationName: 'post_tag_tag_fkey', rightTable: tagResource, - allowsMultipleEdgesToNode: false, - }, - ], + allowsMultipleEdgesToNode: false + } + ] ], [ tagResource, @@ -638,10 +651,10 @@ function buildComplexScenarioTables(): any[] { junctionTable: postTagResource, rightRelationName: 'post_tag_post_fkey', rightTable: postResource, - allowsMultipleEdgesToNode: false, - }, - ], - ], + allowsMultipleEdgesToNode: false + } + ] + ] ]); const build = createMockBuild( @@ -654,34 +667,34 @@ function buildComplexScenarioTables(): any[] { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['author_id'], - remoteResource: { codec: postCodec, uniques: postUniques }, + remoteResource: { codec: postCodec, uniques: postUniques } }, user_comments: { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['author_id'], - remoteResource: { codec: commentCodec, uniques: commentUniques }, - }, - }, + remoteResource: { codec: commentCodec, uniques: commentUniques } + } + } }, user_duplicate_codec: { codec: userCodec, uniques: userUniques, relations: {} }, user_unique_lookup: { codec: userCodec, uniques: userUniques, relations: {}, - isUnique: true, + isUnique: true }, user_virtual: { codec: userCodec, uniques: userUniques, relations: {}, - isVirtual: true, + isVirtual: true }, helper_fn_resource: { codec: createMockCodec('helper_function', { value: createMockAttribute('text') }), uniques: [], relations: {}, - parameters: [{ name: 'input' }], + parameters: [{ name: 'input' }] }, post: postResource, comment: { @@ -692,18 +705,18 @@ function buildComplexScenarioTables(): any[] { isReferencee: false, localAttributes: ['post_id'], remoteAttributes: ['id'], - remoteResource: { codec: postCodec, uniques: postUniques }, + remoteResource: { codec: postCodec, uniques: postUniques } }, comment_author_fkey: { isReferencee: false, localAttributes: ['author_id'], remoteAttributes: ['id'], - remoteResource: { codec: userCodec, uniques: userUniques }, - }, - }, + remoteResource: { codec: userCodec, uniques: userUniques } + } + } }, tag: tagResource, - post_tag: postTagResource, + post_tag: postTagResource }, ['app_public'], { @@ -719,9 +732,9 @@ function buildComplexScenarioTables(): any[] { if (rightName === 'tag') return 'tags'; if (rightName === 'post') return 'posts'; return details?.rightRelationName || null; - }, - }, - }, + } + } + } ); return callInitHook(build); @@ -738,7 +751,7 @@ describe('MetaSchemaPlugin', () => { ['varchar', 'String'], ['char', 'String'], ['name', 'String'], - ['bpchar', 'String'], + ['bpchar', 'String'] ])('maps %s → %s', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); @@ -750,14 +763,14 @@ describe('MetaSchemaPlugin', () => { it.each([ ['int2', 'Int'], ['int4', 'Int'], - ['integer', 'Int'], + ['integer', 'Int'] ])('maps %s → %s (integer types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); it.each([ ['int8', 'BigInt'], - ['bigint', 'BigInt'], + ['bigint', 'BigInt'] ])('maps %s → %s (bigint types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); @@ -765,21 +778,21 @@ describe('MetaSchemaPlugin', () => { it.each([ ['float4', 'Float'], ['float8', 'Float'], - ['numeric', 'BigFloat'], + ['numeric', 'BigFloat'] ])('maps %s → %s (float types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); it.each([ ['bool', 'Boolean'], - ['boolean', 'Boolean'], + ['boolean', 'Boolean'] ])('maps %s → %s (boolean types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); it.each([ ['timestamptz', 'Datetime'], - ['timestamp', 'Datetime'], + ['timestamp', 'Datetime'] ])('maps %s → %s (timestamp types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); @@ -794,21 +807,21 @@ describe('MetaSchemaPlugin', () => { ['cidr', 'InternetAddress'], ['xml', 'String'], ['bytea', 'String'], - ['macaddr', 'String'], + ['macaddr', 'String'] ])('maps %s → %s (additional scalar types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); it.each([ ['json', 'JSON'], - ['jsonb', 'JSON'], + ['jsonb', 'JSON'] ])('maps %s → %s (json types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); it.each([ ['geometry', 'GeoJSON'], - ['geography', 'GeoJSON'], + ['geography', 'GeoJSON'] ])('maps %s → %s (geo types)', (pg, gql) => { expect(_pgTypeToGqlType(pg)).toBe(gql); }); @@ -836,14 +849,14 @@ describe('MetaSchemaPlugin', () => { isArray: false, isNotNull: true, hasDefault: true, - subtype: null, + subtype: null }, isNotNull: true, hasDefault: true, isPrimaryKey: false, isForeignKey: false, description: null, - enumValues: null, + enumValues: null }); }); @@ -857,7 +870,7 @@ describe('MetaSchemaPlugin', () => { it('detects array types via arrayOfCodec', () => { const attr = createMockAttribute('text', { - codec: { name: 'text', arrayOfCodec: { name: '_text' } }, + codec: { name: 'text', arrayOfCodec: { name: '_text' } } }); const result = _buildFieldMeta('tags', attr); expect(result.type.isArray).toBe(true); @@ -875,7 +888,7 @@ describe('MetaSchemaPlugin', () => { hasGraphQLTypeForPgCodec: () => { throw new Error('runtime lookup failed'); }, - getGraphQLTypeNameByPgCodec: (): string | null => null, + getGraphQLTypeNameByPgCodec: (): string | null => null }; const result = _buildFieldMeta('id', attr, build); @@ -884,7 +897,7 @@ describe('MetaSchemaPlugin', () => { it('maps array codecs by nested element type when root codec is unknown', () => { const attr = createMockAttribute('_int4', { - codec: { name: '_int4', arrayOfCodec: { name: 'int4' } }, + codec: { name: '_int4', arrayOfCodec: { name: 'int4' } } }); const result = _buildFieldMeta('ids', attr, {}); @@ -894,7 +907,7 @@ describe('MetaSchemaPlugin', () => { it('reads geometrySubtype from attribute extensions', () => { const attr = createMockAttribute('geometry', { - extensions: { geometrySubtype: 'Polygon' }, + extensions: { geometrySubtype: 'Polygon' } }); const result = _buildFieldMeta('zoneBoundary', attr); expect(result.type.subtype).toBe('Polygon'); @@ -924,10 +937,10 @@ describe('MetaSchemaPlugin', () => { const codec = createMockCodec('user', { display_name: createMockAttribute('text'), created_at: createMockAttribute('timestamptz'), - is_active: createMockAttribute('bool'), + is_active: createMockAttribute('bool') }); const build = createMockBuild({ - user: { codec, uniques: [], relations: {} }, + user: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -941,10 +954,10 @@ describe('MetaSchemaPlugin', () => { it('falls back to raw PG name if inflection fails', () => { const codec = createMockCodec('user', { - display_name: createMockAttribute('text'), + display_name: createMockAttribute('text') }); const build = createMockBuild({ - user: { codec, uniques: [], relations: {} }, + user: { codec, uniques: [], relations: {} } }); // Sabotage inflection so it throws build.inflection._attributeName = () => { @@ -958,11 +971,11 @@ describe('MetaSchemaPlugin', () => { it('inflects FK constraint field names', () => { const userCodec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const postCodec = createMockCodec('post', { id: createMockAttribute('uuid'), - author_id: createMockAttribute('uuid'), + author_id: createMockAttribute('uuid') }); const build = createMockBuild({ @@ -975,10 +988,10 @@ describe('MetaSchemaPlugin', () => { isReferencee: false, localAttributes: ['author_id'], remoteAttributes: ['id'], - remoteResource: { codec: userCodec, uniques: [] }, - }, - }, - }, + remoteResource: { codec: userCodec, uniques: [] } + } + } + } }); const tables = callInitHook(build); @@ -992,11 +1005,11 @@ describe('MetaSchemaPlugin', () => { it('inflects relation key field names', () => { const userCodec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const postCodec = createMockCodec('post', { id: createMockAttribute('uuid'), - author_id: createMockAttribute('uuid'), + author_id: createMockAttribute('uuid') }); const build = createMockBuild({ @@ -1009,10 +1022,10 @@ describe('MetaSchemaPlugin', () => { isReferencee: false, localAttributes: ['author_id'], remoteAttributes: ['id'], - remoteResource: { codec: userCodec, uniques: [] }, - }, - }, - }, + remoteResource: { codec: userCodec, uniques: [] } + } + } + } }); const tables = callInitHook(build); @@ -1026,7 +1039,7 @@ describe('MetaSchemaPlugin', () => { it('inflects index and PK constraint field names', () => { const codec = createMockCodec('user', { id: createMockAttribute('uuid'), - email_address: createMockAttribute('text'), + email_address: createMockAttribute('text') }); const build = createMockBuild({ @@ -1034,10 +1047,10 @@ describe('MetaSchemaPlugin', () => { codec, uniques: [ { attributes: ['id'], isPrimary: true, tags: { name: 'user_pkey' } }, - { attributes: ['email_address'], isPrimary: false, tags: { name: 'user_email_key' } }, + { attributes: ['email_address'], isPrimary: false, tags: { name: 'user_email_key' } } ], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); @@ -1062,12 +1075,12 @@ describe('MetaSchemaPlugin', () => { describe('init hook — table deduplication', () => { it('deduplicates resources sharing the same codec', () => { const codec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild({ user: { codec, uniques: [], relations: {} }, - user_2: { codec, uniques: [], relations: {} }, // same codec object + user_2: { codec, uniques: [], relations: {} } // same codec object }); const tables = callInitHook(build); @@ -1077,15 +1090,15 @@ describe('MetaSchemaPlugin', () => { it('includes distinct tables with different codecs', () => { const userCodec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const postCodec = createMockCodec('post', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild({ user: { codec: userCodec, uniques: [], relations: {} }, - post: { codec: postCodec, uniques: [], relations: {} }, + post: { codec: postCodec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1099,11 +1112,11 @@ describe('MetaSchemaPlugin', () => { name: 'anon', attributes: { id: createMockAttribute('uuid') }, isAnonymous: true, - extensions: { pg: { schemaName: 'app_public' } }, + extensions: { pg: { schemaName: 'app_public' } } }; const build = createMockBuild({ - anon: { codec: anonCodec, uniques: [], relations: {} }, + anon: { codec: anonCodec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1115,11 +1128,11 @@ describe('MetaSchemaPlugin', () => { name: 'no_schema', attributes: { id: createMockAttribute('uuid') }, isAnonymous: false, - extensions: { pg: {} }, // no schemaName + extensions: { pg: {} } // no schemaName }; const build = createMockBuild({ - no_schema: { codec, uniques: [], relations: {} }, + no_schema: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1128,14 +1141,14 @@ describe('MetaSchemaPlugin', () => { it('skips non-table resources (unique lookups, virtual resources, functions)', () => { const codec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild({ user: { codec, uniques: [], relations: {} }, user_lookup: { codec, uniques: [], relations: {}, isUnique: true }, user_virtual: { codec, uniques: [], relations: {}, isVirtual: true }, - user_function: { codec, uniques: [], relations: {}, parameters: [{ name: 'id' }] }, + user_function: { codec, uniques: [], relations: {}, parameters: [{ name: 'id' }] } }); const tables = callInitHook(build); @@ -1147,12 +1160,12 @@ describe('MetaSchemaPlugin', () => { const codecWithoutAttributes = { name: 'broken', isAnonymous: false, - extensions: { pg: { schemaName: 'app_public' } }, + extensions: { pg: { schemaName: 'app_public' } } }; const build = createMockBuild({ no_codec: {}, - no_attributes: { codec: codecWithoutAttributes, uniques: [], relations: {} }, + no_attributes: { codec: codecWithoutAttributes, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1173,11 +1186,11 @@ describe('MetaSchemaPlugin', () => { balance: createMockAttribute('numeric'), is_active: createMockAttribute('bool'), created_at: createMockAttribute('timestamptz'), - metadata: createMockAttribute('jsonb'), + metadata: createMockAttribute('jsonb') }); const build = createMockBuild({ - user: { codec, uniques: [], relations: {} }, + user: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1196,11 +1209,11 @@ describe('MetaSchemaPlugin', () => { it('preserves pgType alongside gqlType', () => { const codec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild({ - user: { codec, uniques: [], relations: {} }, + user: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); @@ -1213,19 +1226,19 @@ describe('MetaSchemaPlugin', () => { it('uses runtime build codec mappings for custom scalar types', () => { const codec = createMockCodec('user', { id: createMockAttribute('uuid'), - email_address: createMockAttribute('email'), + email_address: createMockAttribute('email') }); const build = createMockBuild( { - user: { codec, uniques: [], relations: {} }, + user: { codec, uniques: [], relations: {} } }, ['app_public'], { hasGraphQLTypeForPgCodec: (attrCodec: any) => attrCodec?.name === 'email', getGraphQLTypeNameByPgCodec: (attrCodec: any) => - attrCodec?.name === 'email' ? 'ConstructiveInternalTypeEmail' : null, - }, + attrCodec?.name === 'email' ? 'ConstructiveInternalTypeEmail' : null + } ); const tables = callInitHook(build); @@ -1241,15 +1254,15 @@ describe('MetaSchemaPlugin', () => { describe('init hook — schema filtering', () => { it('only includes tables from configured schemas', () => { const publicCodec = createMockCodec('user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }, 'app_public'); const privateCodec = createMockCodec('secret', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }, 'app_private'); const build = createMockBuild({ user: { codec: publicCodec, uniques: [], relations: {} }, - secret: { codec: privateCodec, uniques: [], relations: {} }, + secret: { codec: privateCodec, uniques: [], relations: {} } }, ['app_public']); const tables = callInitHook(build); @@ -1261,24 +1274,24 @@ describe('MetaSchemaPlugin', () => { const publicCodec = createMockCodec( 'user', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }, - 'app_public', + 'app_public' ); const privateCodec = createMockCodec( 'audit_log', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }, - 'app_private', + 'app_private' ); const build = createMockBuild( { user: { codec: publicCodec, uniques: [], relations: {} }, - audit_log: { codec: privateCodec, uniques: [], relations: {} }, + audit_log: { codec: privateCodec, uniques: [], relations: {} } }, - [], + [] ); const tables = callInitHook(build); @@ -1290,7 +1303,7 @@ describe('MetaSchemaPlugin', () => { describe('init hook — inflection and query fallbacks', () => { it('falls back when tableType inflection throws', () => { const codec = createMockCodec('audit_log', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild( @@ -1298,8 +1311,8 @@ describe('MetaSchemaPlugin', () => { audit_log: { codec, uniques: [{ attributes: ['id'], isPrimary: true }], - relations: {}, - }, + relations: {} + } }, ['app_public'], { @@ -1315,9 +1328,9 @@ describe('MetaSchemaPlugin', () => { }, deleteByKeys: () => { throw new Error('deleteByKeys failed'); - }, - }, - }, + } + } + } ); const table = callInitHook(build)[0]; @@ -1330,7 +1343,7 @@ describe('MetaSchemaPlugin', () => { it('uses deterministic fallback names when inflectors throw or return nullish', () => { const codec = createMockCodec('audit_log', { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }); const build = createMockBuild( @@ -1338,8 +1351,8 @@ describe('MetaSchemaPlugin', () => { audit_log: { codec, uniques: [{ attributes: ['id'], isPrimary: true }], - relations: {}, - }, + relations: {} + } }, ['app_public'], { @@ -1371,9 +1384,9 @@ describe('MetaSchemaPlugin', () => { updateByKeys: (): string | null => null, deleteByKeys: () => { throw new Error('deleteByKeys failed'); - }, - }, - }, + } + } + } ); const table = callInitHook(build)[0]; @@ -1390,7 +1403,7 @@ describe('MetaSchemaPlugin', () => { createInputType: 'CreateAuditLogInput', createPayloadType: 'CreateAuditLogPayload', updatePayloadType: 'UpdateAuditLogPayload', - deletePayloadType: 'DeleteAuditLogPayload', + deletePayloadType: 'DeleteAuditLogPayload' }); expect(table.query).toEqual({ @@ -1398,16 +1411,16 @@ describe('MetaSchemaPlugin', () => { one: 'auditlog', create: 'createAuditLog', update: 'updateAuditLog', - delete: 'deleteAuditLog', + delete: 'deleteAuditLog' }); }); it('omits one/update/delete when a table has no primary key', () => { const codec = createMockCodec('event_log', { - occurred_at: createMockAttribute('timestamptz'), + occurred_at: createMockAttribute('timestamptz') }); const build = createMockBuild({ - event_log: { codec, uniques: [], relations: {} }, + event_log: { codec, uniques: [], relations: {} } }); const table = callInitHook(build)[0]; @@ -1421,20 +1434,20 @@ describe('MetaSchemaPlugin', () => { describe('init hook — relation and naming behavior', () => { it('classifies reverse relations into hasOne vs hasMany using remote unique constraints', () => { const userCodec = createMockCodec('user', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const profileCodec = createMockCodec('profile', { id: createMockAttribute('uuid', { notNull: true }), - user_id: createMockAttribute('uuid', { notNull: true }), + user_id: createMockAttribute('uuid', { notNull: true }) }); const postCodec = createMockCodec('post', { id: createMockAttribute('uuid', { notNull: true }), - author_id: createMockAttribute('uuid', { notNull: true }), + author_id: createMockAttribute('uuid', { notNull: true }) }); const profileUniques = [ { attributes: ['id'], isPrimary: true, tags: { name: 'profiles_pkey' } }, - { attributes: ['user_id'], isPrimary: false, tags: { name: 'profiles_user_id_key' } }, + { attributes: ['user_id'], isPrimary: false, tags: { name: 'profiles_user_id_key' } } ]; const postUniques = [{ attributes: ['id'], isPrimary: true, tags: { name: 'posts_pkey' } }]; @@ -1447,16 +1460,16 @@ describe('MetaSchemaPlugin', () => { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['user_id'], - remoteResource: { codec: profileCodec, uniques: profileUniques }, + remoteResource: { codec: profileCodec, uniques: profileUniques } }, user_posts: { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['author_id'], - remoteResource: { codec: postCodec, uniques: postUniques }, - }, - }, - }, + remoteResource: { codec: postCodec, uniques: postUniques } + } + } + } }); const user = callInitHook(build)[0]; @@ -1469,11 +1482,11 @@ describe('MetaSchemaPlugin', () => { it('marks belongsTo relation unique when local fk columns are uniquely constrained', () => { const userCodec = createMockCodec('user', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const profileCodec = createMockCodec('profile', { id: createMockAttribute('uuid', { notNull: true }), - user_id: createMockAttribute('uuid', { notNull: true }), + user_id: createMockAttribute('uuid', { notNull: true }) }); const build = createMockBuild({ @@ -1482,24 +1495,24 @@ describe('MetaSchemaPlugin', () => { codec: profileCodec, uniques: [ { attributes: ['id'], isPrimary: true }, - { attributes: ['user_id'], isPrimary: false }, + { attributes: ['user_id'], isPrimary: false } ], relations: { profile_user_fkey: { isReferencee: false, localAttributes: ['user_id'], remoteAttributes: ['id'], - remoteResource: { codec: userCodec, uniques: [{ attributes: ['id'], isPrimary: true }] }, - }, - }, - }, + remoteResource: { codec: userCodec, uniques: [{ attributes: ['id'], isPrimary: true }] } + } + } + } }); const profile = callInitHook(build).find((table: any) => table.name === 'Profile'); expect(profile.relations.belongsTo).toHaveLength(1); expect(profile.relations.belongsTo[0]).toMatchObject({ fieldName: 'profile_user_fkey', - isUnique: true, + isUnique: true }); }); @@ -1507,7 +1520,7 @@ describe('MetaSchemaPlugin', () => { const codec = createMockCodec('invoice_line', { id: createMockAttribute('uuid', { notNull: true }), account_id: createMockAttribute('uuid', { notNull: true }), - order_id: createMockAttribute('uuid', { notNull: true }), + order_id: createMockAttribute('uuid', { notNull: true }) }); const build = createMockBuild({ @@ -1515,33 +1528,33 @@ describe('MetaSchemaPlugin', () => { codec, uniques: [ { attributes: ['id'], isPrimary: true }, - { attributes: ['account_id', 'order_id'], isPrimary: false }, + { attributes: ['account_id', 'order_id'], isPrimary: false } ], - relations: {}, - }, + relations: {} + } }); const table = callInitHook(build)[0]; expect(table.constraints.primaryKey?.name).toBe('invoice_line_pkey'); expect(table.constraints.unique.map((u: any) => u.name)).toEqual([ - 'invoice_line_account_id_order_id_key', + 'invoice_line_account_id_order_id_key' ]); expect(table.indexes.map((idx: any) => idx.name)).toEqual([ 'invoice_line_id_idx', - 'invoice_line_account_id_order_id_idx', + 'invoice_line_account_id_order_id_idx' ]); }); it('builds many-to-many metadata from pg-many-to-many relationship details', () => { const postCodec = createMockCodec('post', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const tagCodec = createMockCodec('tag', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const postTagCodec = createMockCodec('post_tag', { post_id: createMockAttribute('uuid', { notNull: true }), - tag_id: createMockAttribute('uuid', { notNull: true }), + tag_id: createMockAttribute('uuid', { notNull: true }) }); const postUniques = [{ attributes: ['id'], isPrimary: true }]; @@ -1556,15 +1569,15 @@ describe('MetaSchemaPlugin', () => { isReferencee: false, localAttributes: ['post_id'], remoteAttributes: ['id'], - remoteResource: { codec: postCodec, uniques: postUniques }, + remoteResource: { codec: postCodec, uniques: postUniques } }, post_tag_tag_fkey: { isReferencee: false, localAttributes: ['tag_id'], remoteAttributes: ['id'], - remoteResource: { codec: tagCodec, uniques: tagUniques }, - }, - }, + remoteResource: { codec: tagCodec, uniques: tagUniques } + } + } }); const postResource = createMockResource({ codec: postCodec, @@ -1574,14 +1587,14 @@ describe('MetaSchemaPlugin', () => { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: postTagCodec, uniques: postTagUniques }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: postTagUniques } + } + } }); const tagResource = createMockResource({ codec: tagCodec, uniques: tagUniques, - relations: {}, + relations: {} }); const pgManyToManyRealtionshipsByResource = new Map([ @@ -1594,25 +1607,25 @@ describe('MetaSchemaPlugin', () => { junctionTable: postTagResource, rightRelationName: 'post_tag_tag_fkey', rightTable: tagResource, - allowsMultipleEdgesToNode: false, - }, - ], - ], + allowsMultipleEdgesToNode: false + } + ] + ] ]); const build = createMockBuild( { post: postResource, tag: tagResource, - post_tag: postTagResource, + post_tag: postTagResource }, ['app_public'], { pgManyToManyRealtionshipsByResource, inflection: { - _manyToManyRelation: () => 'tags', - }, - }, + _manyToManyRelation: () => 'tags' + } + } ); const post = callInitHook(build).find((table: any) => table.name === 'Post'); @@ -1624,20 +1637,20 @@ describe('MetaSchemaPlugin', () => { junctionLeftConstraint: { name: 'post_post_tags', referencedTable: 'post', - referencedFields: ['id'], + referencedFields: ['id'] }, junctionRightConstraint: { name: 'post_tag_tag_fkey', referencedTable: 'tag', - referencedFields: ['id'], + referencedFields: ['id'] }, - rightTable: { name: 'tag' }, + rightTable: { name: 'tag' } }); expect(post.relations.manyToMany[0].junctionLeftKeyAttributes.map((f: any) => f.name)).toEqual([ - 'postId', + 'postId' ]); expect(post.relations.manyToMany[0].junctionRightKeyAttributes.map((f: any) => f.name)).toEqual([ - 'tagId', + 'tagId' ]); expect(post.relations.manyToMany[0].leftKeyAttributes.map((f: any) => f.name)).toEqual(['id']); expect(post.relations.manyToMany[0].rightKeyAttributes.map((f: any) => f.name)).toEqual(['id']); @@ -1645,14 +1658,14 @@ describe('MetaSchemaPlugin', () => { it('falls back to codec matching when many-to-many map key is a different resource object', () => { const postCodec = createMockCodec('post', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const tagCodec = createMockCodec('tag', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const postTagCodec = createMockCodec('post_tag', { post_id: createMockAttribute('uuid', { notNull: true }), - tag_id: createMockAttribute('uuid', { notNull: true }), + tag_id: createMockAttribute('uuid', { notNull: true }) }); const postUniques = [{ attributes: ['id'], isPrimary: true }]; @@ -1667,15 +1680,15 @@ describe('MetaSchemaPlugin', () => { isReferencee: false, localAttributes: ['post_id'], remoteAttributes: ['id'], - remoteResource: { codec: postCodec, uniques: postUniques }, + remoteResource: { codec: postCodec, uniques: postUniques } }, post_tag_tag_fkey: { isReferencee: false, localAttributes: ['tag_id'], remoteAttributes: ['id'], - remoteResource: { codec: tagCodec, uniques: tagUniques }, - }, - }, + remoteResource: { codec: tagCodec, uniques: tagUniques } + } + } }); const postResourceInBuild = createMockResource({ codec: postCodec, @@ -1685,9 +1698,9 @@ describe('MetaSchemaPlugin', () => { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: postTagCodec, uniques: postTagUniques }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: postTagUniques } + } + } }); const postResourceInMap = createMockResource({ codec: postCodec, @@ -1697,14 +1710,14 @@ describe('MetaSchemaPlugin', () => { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: postTagCodec, uniques: postTagUniques }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: postTagUniques } + } + } }); const tagResource = createMockResource({ codec: tagCodec, uniques: tagUniques, - relations: {}, + relations: {} }); const pgManyToManyRealtionshipsByResource = new Map([ @@ -1717,25 +1730,25 @@ describe('MetaSchemaPlugin', () => { junctionTable: postTagResource, rightRelationName: 'post_tag_tag_fkey', rightTable: tagResource, - allowsMultipleEdgesToNode: false, - }, - ], - ], + allowsMultipleEdgesToNode: false + } + ] + ] ]); const build = createMockBuild( { post: postResourceInBuild, tag: tagResource, - post_tag: postTagResource, + post_tag: postTagResource }, ['app_public'], { pgManyToManyRealtionshipsByResource, inflection: { - _manyToManyRelation: () => 'tags', - }, - }, + _manyToManyRelation: () => 'tags' + } + } ); const post = callInitHook(build).find((table: any) => table.name === 'Post'); @@ -1745,14 +1758,14 @@ describe('MetaSchemaPlugin', () => { it('skips malformed many-to-many relation details', () => { const postCodec = createMockCodec('post', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const tagCodec = createMockCodec('tag', { - id: createMockAttribute('uuid', { notNull: true }), + id: createMockAttribute('uuid', { notNull: true }) }); const postTagCodec = createMockCodec('post_tag', { post_id: createMockAttribute('uuid', { notNull: true }), - tag_id: createMockAttribute('uuid', { notNull: true }), + tag_id: createMockAttribute('uuid', { notNull: true }) }); const postResource = createMockResource({ @@ -1763,26 +1776,26 @@ describe('MetaSchemaPlugin', () => { isReferencee: true, localAttributes: ['id'], remoteAttributes: ['post_id'], - remoteResource: { codec: postTagCodec, uniques: [{ attributes: ['post_id', 'tag_id'], isPrimary: true }] }, - }, - }, + remoteResource: { codec: postTagCodec, uniques: [{ attributes: ['post_id', 'tag_id'], isPrimary: true }] } + } + } }); const tagResource = createMockResource({ codec: tagCodec, uniques: [{ attributes: ['id'], isPrimary: true }], - relations: {}, + relations: {} }); const postTagResource = createMockResource({ codec: postTagCodec, uniques: [{ attributes: ['post_id', 'tag_id'], isPrimary: true }], - relations: {}, + relations: {} }); const build = createMockBuild( { post: postResource, tag: tagResource, - post_tag: postTagResource, + post_tag: postTagResource }, ['app_public'], { @@ -1796,12 +1809,12 @@ describe('MetaSchemaPlugin', () => { junctionTable: postTagResource, rightRelationName: 'missing_right_relation', rightTable: tagResource, - allowsMultipleEdgesToNode: false, - }, - ], - ], - ]), - }, + allowsMultipleEdgesToNode: false + } + ] + ] + ]) + } ); let tables: any[] = []; @@ -1824,14 +1837,14 @@ describe('MetaSchemaPlugin', () => { it('adds _meta to Query and resolves cached tables through an executable schema', async () => { const codec = createMockCodec('user', { id: createMockAttribute('uuid', { notNull: true }), - email_address: createMockAttribute('text'), + email_address: createMockAttribute('text') }); const build = createMockBuild({ user: { codec, uniques: [{ attributes: ['id'], isPrimary: true, tags: { name: 'users_pkey' } }], - relations: {}, - }, + relations: {} + } }); const seededTables = callInitHook(build); @@ -1839,18 +1852,18 @@ describe('MetaSchemaPlugin', () => { { ping: { type: GraphQLString, - resolve: () => 'pong', - }, + resolve: () => 'pong' + } }, build, - 'Query', + 'Query' ); const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', - fields: () => queryFields, - }), + fields: () => queryFields + }) }); const result = await graphql({ @@ -1867,7 +1880,7 @@ describe('MetaSchemaPlugin', () => { } } } - `, + ` }); expect(result.errors).toBeUndefined(); @@ -1876,17 +1889,17 @@ describe('MetaSchemaPlugin', () => { expect((result.data as any)?._meta?.tables?.[0]).toMatchObject({ name: 'User', schemaName: 'app_public', - query: seededTables[0].query, + query: seededTables[0].query }); expect((result.data as any)?._meta?.tables?.[0]?.fields).toEqual([ { name: 'id', - type: { pgType: 'uuid', gqlType: 'UUID', isArray: false }, + type: { pgType: 'uuid', gqlType: 'UUID', isArray: false } }, { name: 'emailAddress', - type: { pgType: 'text', gqlType: 'String', isArray: false }, - }, + type: { pgType: 'text', gqlType: 'String', isArray: false } + } ]); }); }); @@ -1897,15 +1910,15 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const secondBuild = createMockBuild({ project: { codec: createMockCodec('project', { id: createMockAttribute('uuid') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const first = callInitHook(firstBuild); @@ -1935,8 +1948,8 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].storage).toBeNull(); @@ -1946,38 +1959,38 @@ describe('MetaSchemaPlugin', () => { const codec = createMockCodec('app_file', { id: createMockAttribute('uuid'), key: createMockAttribute('text'), - bucket_id: createMockAttribute('uuid'), + bucket_id: createMockAttribute('uuid') }); (codec as any).extensions = { ...codec.extensions, - tags: { storageFiles: true }, + tags: { storageFiles: true } }; const build = createMockBuild({ - app_file: { codec, uniques: [], relations: {} }, + app_file: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].storage).toEqual({ isFilesTable: true, - isBucketsTable: false, + isBucketsTable: false }); }); it('detects @storageBuckets tagged tables', () => { const codec = createMockCodec('app_bucket', { id: createMockAttribute('uuid'), - key: createMockAttribute('text'), + key: createMockAttribute('text') }); (codec as any).extensions = { ...codec.extensions, - tags: { storageBuckets: true }, + tags: { storageBuckets: true } }; const build = createMockBuild({ - app_bucket: { codec, uniques: [], relations: {} }, + app_bucket: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].storage).toEqual({ isFilesTable: false, - isBucketsTable: true, + isBucketsTable: true }); }); }); @@ -1988,8 +2001,8 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].search).toBeNull(); @@ -1999,34 +2012,34 @@ describe('MetaSchemaPlugin', () => { const codec = createMockCodec('article', { id: createMockAttribute('uuid'), title: createMockAttribute('text'), - tsv: createMockAttribute('tsvector'), + tsv: createMockAttribute('tsvector') }); const build = createMockBuild({ - article: { codec, uniques: [], relations: {} }, + article: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].search).toEqual({ algorithms: ['tsvector'], columns: [{ name: 'tsv', algorithm: 'tsvector' }], hasUnifiedSearch: true, - config: null, + config: null }); }); it('detects vector columns (pgvector)', () => { const codec = createMockCodec('document', { id: createMockAttribute('uuid'), - embedding: createMockAttribute('vector'), + embedding: createMockAttribute('vector') }); const build = createMockBuild({ - document: { codec, uniques: [], relations: {} }, + document: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].search).toEqual({ algorithms: ['vector'], columns: [{ name: 'embedding', algorithm: 'vector' }], hasUnifiedSearch: false, - config: null, + config: null }); }); @@ -2035,24 +2048,24 @@ describe('MetaSchemaPlugin', () => { (bm25Attr as any).extensions = { tags: { bm25Index: true } }; const codec = createMockCodec('article', { id: createMockAttribute('uuid'), - body: bm25Attr, + body: bm25Attr }); const build = createMockBuild({ - article: { codec, uniques: [], relations: {} }, + article: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].search).toEqual({ algorithms: ['bm25'], columns: [{ name: 'body', algorithm: 'bm25' }], hasUnifiedSearch: true, - config: null, + config: null }); }); it('parses @searchConfig smart tag', () => { const codec = createMockCodec('article', { id: createMockAttribute('uuid'), - tsv: createMockAttribute('tsvector'), + tsv: createMockAttribute('tsvector') }); (codec as any).extensions = { ...codec.extensions, @@ -2061,12 +2074,12 @@ describe('MetaSchemaPlugin', () => { weights: { bm25: 0.6, tsv: 0.4 }, boost_recent: true, boost_recency_field: 'created_at', - boost_recency_decay: 0.95, - }, - }, + boost_recency_decay: 0.95 + } + } }; const build = createMockBuild({ - article: { codec, uniques: [], relations: {} }, + article: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].search).toEqual({ @@ -2077,8 +2090,8 @@ describe('MetaSchemaPlugin', () => { weights: { bm25: 0.6, tsv: 0.4 }, boostRecent: true, boostRecencyField: 'created_at', - boostRecencyDecay: 0.95, - }, + boostRecencyDecay: 0.95 + } }); }); @@ -2089,10 +2102,10 @@ describe('MetaSchemaPlugin', () => { id: createMockAttribute('uuid'), tsv: createMockAttribute('tsvector'), embedding: createMockAttribute('vector'), - body: bm25Attr, + body: bm25Attr }); const build = createMockBuild({ - article: { codec, uniques: [], relations: {} }, + article: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].search!.algorithms).toEqual(['bm25', 'tsvector', 'vector']); @@ -2107,11 +2120,11 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid'), - name: createMockAttribute('text'), + name: createMockAttribute('text') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); for (const field of tables[0].fields) { @@ -2122,73 +2135,73 @@ describe('MetaSchemaPlugin', () => { it('detects enum type on a field', () => { const enumCodec = { name: 'status_enum', - values: [{ value: 'active' }, { value: 'inactive' }, { value: 'pending' }], + values: [{ value: 'active' }, { value: 'inactive' }, { value: 'pending' }] }; const build = createMockBuild({ task: { codec: createMockCodec('task', { id: createMockAttribute('uuid'), - status: createMockAttribute('status_enum', { codec: enumCodec }), + status: createMockAttribute('status_enum', { codec: enumCodec }) }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const statusField = tables[0].fields.find((f: any) => f.name === 'status'); expect(statusField.enumValues).toEqual({ name: 'status_enum', - values: ['active', 'inactive', 'pending'], + values: ['active', 'inactive', 'pending'] }); }); it('detects enum values as plain strings', () => { const enumCodec = { name: 'priority_enum', - values: ['low', 'medium', 'high'], + values: ['low', 'medium', 'high'] }; const build = createMockBuild({ task: { codec: createMockCodec('task', { id: createMockAttribute('uuid'), - priority: createMockAttribute('priority_enum', { codec: enumCodec }), + priority: createMockAttribute('priority_enum', { codec: enumCodec }) }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const priorityField = tables[0].fields.find((f: any) => f.name === 'priority'); expect(priorityField.enumValues).toEqual({ name: 'priority_enum', - values: ['low', 'medium', 'high'], + values: ['low', 'medium', 'high'] }); }); it('detects enum through domain wrapper', () => { const innerEnumCodec = { name: 'color_enum', - values: [{ value: 'red' }, { value: 'green' }, { value: 'blue' }], + values: [{ value: 'red' }, { value: 'green' }, { value: 'blue' }] }; const domainCodec = { name: 'color_domain', - domainOfCodec: innerEnumCodec, + domainOfCodec: innerEnumCodec }; const build = createMockBuild({ item: { codec: createMockCodec('item', { id: createMockAttribute('uuid'), - color: createMockAttribute('color_domain', { codec: domainCodec }), + color: createMockAttribute('color_domain', { codec: domainCodec }) }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const colorField = tables[0].fields.find((f: any) => f.name === 'color'); expect(colorField.enumValues).toEqual({ name: 'color_enum', - values: ['red', 'green', 'blue'], + values: ['red', 'green', 'blue'] }); }); @@ -2199,11 +2212,11 @@ describe('MetaSchemaPlugin', () => { item: { codec: createMockCodec('item', { id: createMockAttribute('uuid'), - tags: createMockAttribute('_text', { codec: arrayCodec }), + tags: createMockAttribute('_text', { codec: arrayCodec }) }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const tagsField = tables[0].fields.find((f: any) => f.name === 'tags'); @@ -2217,11 +2230,11 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid'), - name: createMockAttribute('text'), + name: createMockAttribute('text') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].i18n).toBeNull(); @@ -2234,13 +2247,13 @@ describe('MetaSchemaPlugin', () => { id: createMockAttribute('uuid'), title: createMockAttribute('text'), body: createMockAttribute('text'), - views: createMockAttribute('int4'), + views: createMockAttribute('int4') }, isAnonymous: false, extensions: { pg: { schemaName: 'app_public' }, - tags: { i18n: 'post_translations' }, - }, + tags: { i18n: 'post_translations' } + } }; const translationCodec = { name: 'postTranslations', @@ -2248,24 +2261,24 @@ describe('MetaSchemaPlugin', () => { post_id: createMockAttribute('uuid'), lang_code: createMockAttribute('text'), title: createMockAttribute('text'), - body: createMockAttribute('text'), + body: createMockAttribute('text') }, isAnonymous: false, extensions: { - pg: { schemaName: 'app_public', name: 'post_translations' }, - }, + pg: { schemaName: 'app_public', name: 'post_translations' } + } }; const build = createMockBuild({ post: { codec: baseCodec, uniques: [], - relations: {}, + relations: {} }, post_translations: { codec: translationCodec, uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const postTable = tables.find((t: any) => t.name === 'Post'); @@ -2273,8 +2286,8 @@ describe('MetaSchemaPlugin', () => { translationTable: 'post_translations', translatableFields: [ { name: 'title', type: 'text' }, - { name: 'body', type: 'text' }, - ], + { name: 'body', type: 'text' } + ] }); }); @@ -2284,43 +2297,43 @@ describe('MetaSchemaPlugin', () => { attributes: { id: createMockAttribute('uuid'), label: createMockAttribute('text'), - count: createMockAttribute('int4'), + count: createMockAttribute('int4') }, isAnonymous: false, extensions: { pg: { schemaName: 'app_public' }, - tags: { i18n: 'item_translations' }, - }, + tags: { i18n: 'item_translations' } + } }; const translationCodec = { name: 'itemTranslations', attributes: { item_id: createMockAttribute('uuid'), lang_code: createMockAttribute('text'), - label: createMockAttribute('text'), + label: createMockAttribute('text') }, isAnonymous: false, extensions: { - pg: { schemaName: 'app_public', name: 'item_translations' }, - }, + pg: { schemaName: 'app_public', name: 'item_translations' } + } }; const build = createMockBuild({ item: { codec: baseCodec, uniques: [], - relations: {}, + relations: {} }, item_translations: { codec: translationCodec, uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); const itemTable = tables.find((t: any) => t.name === 'Item'); expect(itemTable.i18n).toEqual({ translationTable: 'item_translations', - translatableFields: [{ name: 'label', type: 'text' }], + translatableFields: [{ name: 'label', type: 'text' }] }); }); }); @@ -2331,11 +2344,11 @@ describe('MetaSchemaPlugin', () => { user: { codec: createMockCodec('user', { id: createMockAttribute('uuid'), - name: createMockAttribute('text'), + name: createMockAttribute('text') }), uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].realtime).toBeNull(); @@ -2346,24 +2359,24 @@ describe('MetaSchemaPlugin', () => { name: 'message', attributes: { id: createMockAttribute('uuid'), - content: createMockAttribute('text'), + content: createMockAttribute('text') }, isAnonymous: false, extensions: { pg: { schemaName: 'app_public' }, - tags: { realtime: true }, - }, + tags: { realtime: true } + } }; const build = createMockBuild({ message: { codec: realtimeCodec, uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].realtime).toEqual({ - subscriptionFieldName: 'onMessageChanged', + subscriptionFieldName: 'onMessageChanged' }); }); @@ -2371,26 +2384,162 @@ describe('MetaSchemaPlugin', () => { const realtimeCodec = { name: 'chat_room', attributes: { - id: createMockAttribute('uuid'), + id: createMockAttribute('uuid') }, isAnonymous: false, extensions: { pg: { schemaName: 'app_public' }, - tags: { realtime: true }, - }, + tags: { realtime: true } + } }; const build = createMockBuild({ chat_room: { codec: realtimeCodec, uniques: [], - relations: {}, - }, + relations: {} + } }); const tables = callInitHook(build); expect(tables[0].realtime).toEqual({ - subscriptionFieldName: 'onChatRoomChanged', + subscriptionFieldName: 'onChatRoomChanged' + }); + }); + }); + + describe('scope metadata', () => { + it('returns null scope for tables without @scope tag or a scope key column', () => { + const build = createMockBuild({ + widget: { + codec: createMockCodec('widget', { + id: createMockAttribute('uuid'), + name: createMockAttribute('text') + }), + uniques: [], + relations: {} + } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toBeNull(); + }); + + it('reads a database @scope smart tag', () => { + const codec = createMockCodec('app_setting', { + id: createMockAttribute('uuid'), + database_id: createMockAttribute('uuid') + }); + (codec as any).extensions = { + ...codec.extensions, + tags: { scope: 'database' } + }; + const build = createMockBuild({ + app_setting: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'database', + tier: 'database', + keyColumn: 'databaseId', + entityTable: null, + source: 'smartTag' + }); + }); + + it('reads a global (platform/app) @scope smart tag with no key column', () => { + const codec = createMockCodec('feature_flag', { + id: createMockAttribute('uuid'), + key: createMockAttribute('text') + }); + (codec as any).extensions = { + ...codec.extensions, + tags: { scope: 'platform' } + }; + const build = createMockBuild({ + feature_flag: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'platform', + tier: 'global', + keyColumn: null, + entityTable: null, + source: 'smartTag' }); }); + + it('reads an entity @scope smart tag with entity table and scope-named key', () => { + const codec = createMockCodec('project', { + id: createMockAttribute('uuid'), + org_id: createMockAttribute('uuid') + }); + (codec as any).extensions = { + ...codec.extensions, + tags: { scope: 'org', scopeEntityTable: 'organizations' } + }; + const build = createMockBuild({ + project: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'org', + tier: 'entity', + keyColumn: 'orgId', + entityTable: 'organizations', + source: 'smartTag' + }); + }); + + it('honors an explicit scopeKey smart tag over inference', () => { + const codec = createMockCodec('membership', { + id: createMockAttribute('uuid'), + owner_id: createMockAttribute('uuid') + }); + (codec as any).extensions = { + ...codec.extensions, + tags: { scope: 'team', scopeKey: 'owner_id' } + }; + const build = createMockBuild({ + membership: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'team', + tier: 'entity', + keyColumn: 'ownerId', + entityTable: null, + source: 'smartTag' + }); + }); + + it('infers a database scope from a database_id column when untagged', () => { + const codec = createMockCodec('audit_log', { + id: createMockAttribute('uuid'), + database_id: createMockAttribute('uuid'), + message: createMockAttribute('text') + }); + const build = createMockBuild({ + audit_log: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'database', + tier: 'database', + keyColumn: 'databaseId', + entityTable: null, + source: 'inferred' + }); + }); + + it('does not infer entity/global scope from arbitrary columns', () => { + const codec = createMockCodec('note', { + id: createMockAttribute('uuid'), + author_id: createMockAttribute('uuid') + }); + const build = createMockBuild({ + note: { codec, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toBeNull(); + }); }); describe('_meta query contract', () => { diff --git a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts index 51355b363..430dac29a 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts @@ -1,13 +1,14 @@ import { GraphQLBoolean, + type GraphQLFieldConfig, GraphQLFloat, GraphQLList, GraphQLNonNull, GraphQLObjectType, - GraphQLString, - type GraphQLFieldConfig, type GraphQLOutputType, + GraphQLString } from 'graphql'; + import type { TableMeta } from './types'; type FieldMap = Record; @@ -17,7 +18,7 @@ function nn(type: T): GraphQLNonNull { } function nnList( - type: T, + type: T ): GraphQLNonNull>> { return nn(new GraphQLList(nn(type))); } @@ -32,8 +33,8 @@ function createMetaSchemaType(): GraphQLObjectType { isArray: { type: nn(GraphQLBoolean) }, isNotNull: { type: GraphQLBoolean }, hasDefault: { type: GraphQLBoolean }, - subtype: { type: GraphQLString }, - }), + subtype: { type: GraphQLString } + }) }); const MetaEnumType = new GraphQLObjectType({ @@ -41,8 +42,8 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'Information about a PostgreSQL enum type', fields: () => ({ name: { type: nn(GraphQLString), description: 'The PostgreSQL enum type name' }, - values: { type: nnList(GraphQLString), description: 'Allowed values for this enum' }, - }), + values: { type: nnList(GraphQLString), description: 'Allowed values for this enum' } + }) }); const MetaFieldType = new GraphQLObjectType({ @@ -56,8 +57,8 @@ function createMetaSchemaType(): GraphQLObjectType { isPrimaryKey: { type: nn(GraphQLBoolean) }, isForeignKey: { type: nn(GraphQLBoolean) }, description: { type: GraphQLString }, - enumValues: { type: MetaEnumType, description: 'Enum metadata if this field has an enum type' }, - }), + enumValues: { type: MetaEnumType, description: 'Enum metadata if this field has an enum type' } + }) }); const MetaIndexType = new GraphQLObjectType({ @@ -68,8 +69,8 @@ function createMetaSchemaType(): GraphQLObjectType { isUnique: { type: nn(GraphQLBoolean) }, isPrimary: { type: nn(GraphQLBoolean) }, columns: { type: nnList(GraphQLString) }, - fields: { type: new GraphQLList(nn(MetaFieldType)) }, - }), + fields: { type: new GraphQLList(nn(MetaFieldType)) } + }) }); const MetaPrimaryKeyConstraintType = new GraphQLObjectType({ @@ -77,8 +78,8 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'Information about a primary key constraint', fields: () => ({ name: { type: nn(GraphQLString) }, - fields: { type: nnList(MetaFieldType) }, - }), + fields: { type: nnList(MetaFieldType) } + }) }); const MetaUniqueConstraintType = new GraphQLObjectType({ @@ -86,16 +87,16 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'Information about a unique constraint', fields: () => ({ name: { type: nn(GraphQLString) }, - fields: { type: nnList(MetaFieldType) }, - }), + fields: { type: nnList(MetaFieldType) } + }) }); const MetaRefTableType = new GraphQLObjectType({ name: 'MetaRefTable', description: 'Reference to a related table', fields: () => ({ - name: { type: nn(GraphQLString) }, - }), + name: { type: nn(GraphQLString) } + }) }); const MetaForeignKeyConstraintType = new GraphQLObjectType({ @@ -107,8 +108,8 @@ function createMetaSchemaType(): GraphQLObjectType { referencedTable: { type: nn(GraphQLString) }, referencedFields: { type: nnList(GraphQLString) }, refFields: { type: new GraphQLList(nn(MetaFieldType)) }, - refTable: { type: MetaRefTableType }, - }), + refTable: { type: MetaRefTableType } + }) }); const MetaConstraintsType = new GraphQLObjectType({ @@ -117,8 +118,8 @@ function createMetaSchemaType(): GraphQLObjectType { fields: () => ({ primaryKey: { type: MetaPrimaryKeyConstraintType }, unique: { type: nnList(MetaUniqueConstraintType) }, - foreignKey: { type: nnList(MetaForeignKeyConstraintType) }, - }), + foreignKey: { type: nnList(MetaForeignKeyConstraintType) } + }) }); const MetaInflectionType = new GraphQLObjectType({ @@ -136,8 +137,8 @@ function createMetaSchemaType(): GraphQLObjectType { createInputType: { type: nn(GraphQLString) }, createPayloadType: { type: nn(GraphQLString) }, updatePayloadType: { type: GraphQLString }, - deletePayloadType: { type: nn(GraphQLString) }, - }), + deletePayloadType: { type: nn(GraphQLString) } + }) }); const MetaQueryType = new GraphQLObjectType({ @@ -148,8 +149,8 @@ function createMetaSchemaType(): GraphQLObjectType { one: { type: GraphQLString }, create: { type: GraphQLString }, update: { type: GraphQLString }, - delete: { type: GraphQLString }, - }), + delete: { type: GraphQLString } + }) }); const MetaBelongsToRelationType = new GraphQLObjectType({ @@ -160,8 +161,8 @@ function createMetaSchemaType(): GraphQLObjectType { isUnique: { type: nn(GraphQLBoolean) }, type: { type: GraphQLString }, keys: { type: nnList(MetaFieldType) }, - references: { type: nn(MetaRefTableType) }, - }), + references: { type: nn(MetaRefTableType) } + }) }); const MetaHasRelationType = new GraphQLObjectType({ @@ -172,8 +173,8 @@ function createMetaSchemaType(): GraphQLObjectType { isUnique: { type: nn(GraphQLBoolean) }, type: { type: GraphQLString }, keys: { type: nnList(MetaFieldType) }, - referencedBy: { type: nn(MetaRefTableType) }, - }), + referencedBy: { type: nn(MetaRefTableType) } + }) }); const MetaManyToManyRelationType = new GraphQLObjectType({ @@ -189,8 +190,8 @@ function createMetaSchemaType(): GraphQLObjectType { junctionRightKeyAttributes: { type: nnList(MetaFieldType) }, leftKeyAttributes: { type: nnList(MetaFieldType) }, rightKeyAttributes: { type: nnList(MetaFieldType) }, - rightTable: { type: nn(MetaRefTableType) }, - }), + rightTable: { type: nn(MetaRefTableType) } + }) }); const MetaRelationsType = new GraphQLObjectType({ @@ -201,8 +202,8 @@ function createMetaSchemaType(): GraphQLObjectType { has: { type: nnList(MetaHasRelationType) }, hasOne: { type: nnList(MetaHasRelationType) }, hasMany: { type: nnList(MetaHasRelationType) }, - manyToMany: { type: nnList(MetaManyToManyRelationType) }, - }), + manyToMany: { type: nnList(MetaManyToManyRelationType) } + }) }); const MetaStorageType = new GraphQLObjectType({ @@ -210,8 +211,8 @@ function createMetaSchemaType(): GraphQLObjectType { 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' } + }) }); const MetaSearchConfigType = new GraphQLObjectType({ @@ -223,12 +224,12 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'JSON-encoded per-adapter score weights', resolve(source: any) { return source.weights ? JSON.stringify(source.weights) : null; - }, + } }, boostRecent: { type: nn(GraphQLBoolean), description: 'Whether recency boosting is enabled' }, boostRecencyField: { type: GraphQLString, description: 'Field used for recency decay' }, - boostRecencyDecay: { type: GraphQLFloat, description: 'Exponential decay factor per day' }, - }), + boostRecencyDecay: { type: GraphQLFloat, description: 'Exponential decay factor per day' } + }) }); const MetaSearchColumnType = new GraphQLObjectType({ @@ -236,8 +237,8 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'A searchable column with its algorithm', fields: () => ({ name: { type: nn(GraphQLString), description: 'Column name (camelCase)' }, - algorithm: { type: nn(GraphQLString), description: 'Search algorithm: tsvector, bm25, trgm, or vector' }, - }), + algorithm: { type: nn(GraphQLString), description: 'Search algorithm: tsvector, bm25, trgm, or vector' } + }) }); const MetaSearchType = new GraphQLObjectType({ @@ -247,8 +248,8 @@ function createMetaSchemaType(): GraphQLObjectType { algorithms: { type: nnList(GraphQLString), description: 'Active search algorithms on this table' }, columns: { type: nnList(MetaSearchColumnType), description: 'Searchable columns with their algorithm' }, hasUnifiedSearch: { type: nn(GraphQLBoolean), description: 'Whether unifiedSearch composite filter is available' }, - config: { type: MetaSearchConfigType, description: 'Per-table search configuration' }, - }), + config: { type: MetaSearchConfigType, description: 'Per-table search configuration' } + }) }); const MetaI18nFieldType = new GraphQLObjectType({ @@ -256,8 +257,8 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'A translatable field', fields: () => ({ name: { type: nn(GraphQLString), description: 'GraphQL field name' }, - type: { type: nn(GraphQLString), description: 'PostgreSQL column type (text, citext)' }, - }), + type: { type: nn(GraphQLString), description: 'PostgreSQL column type (text, citext)' } + }) }); const MetaI18nType = new GraphQLObjectType({ @@ -265,16 +266,28 @@ function createMetaSchemaType(): GraphQLObjectType { description: 'i18n metadata for a table with @i18n tag', fields: () => ({ translationTable: { type: nn(GraphQLString), description: 'Name of the translation table' }, - translatableFields: { type: nnList(MetaI18nFieldType), description: 'Fields that are translatable' }, - }), + translatableFields: { type: nnList(MetaI18nFieldType), description: 'Fields that are translatable' } + }) }); const MetaRealtimeType = new GraphQLObjectType({ name: 'MetaRealtime', description: 'Realtime metadata for a table with @realtime tag', fields: () => ({ - subscriptionFieldName: { type: nn(GraphQLString), description: 'The generated subscription field name (e.g. onPostChanged)' }, - }), + subscriptionFieldName: { type: nn(GraphQLString), description: 'The generated subscription field name (e.g. onPostChanged)' } + }) + }); + + const MetaScopeType = new GraphQLObjectType({ + name: 'MetaScope', + description: 'Provisioning scope metadata for a table', + fields: () => ({ + scope: { type: nn(GraphQLString), description: "Provisioning scope: 'platform', 'app', 'database', or an entity scope (e.g. 'org')" }, + tier: { type: nn(GraphQLString), description: "Coarse bucket: 'global', 'database', or 'entity'" }, + keyColumn: { type: GraphQLString, description: 'Inflected scope key column (e.g. databaseId, orgId), null for global tiers' }, + entityTable: { type: GraphQLString, description: 'SQL name of the entity table for entity scopes, else null' }, + source: { type: nn(GraphQLString), description: "How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)" } + }) }); const MetaTableType = new GraphQLObjectType({ @@ -296,21 +309,22 @@ function createMetaSchemaType(): GraphQLObjectType { search: { type: MetaSearchType, description: 'Search metadata (null if no search configured)' }, i18n: { type: MetaI18nType, description: 'i18n metadata (null if no @i18n tag)' }, realtime: { type: MetaRealtimeType, description: 'Realtime metadata (null if no @realtime tag)' }, - }), + scope: { type: MetaScopeType, description: 'Provisioning scope metadata (null if no @scope tag and not inferrable)' } + }) }); return new GraphQLObjectType({ name: 'MetaSchema', description: 'Root meta schema type', fields: () => ({ - tables: { type: nnList(MetaTableType) }, - }), + tables: { type: nnList(MetaTableType) } + }) }); } export function extendQueryWithMetaField( fields: FieldMap, - tablesMeta: TableMeta[], + tablesMeta: TableMeta[] ): FieldMap { const metaSchemaType = createMetaSchemaType(); const metaField: GraphQLFieldConfig = { @@ -319,11 +333,11 @@ export function extendQueryWithMetaField( 'Metadata about the database schema, including tables, fields, indexes, and constraints. Useful for code generation tools.', resolve() { return { tables: tablesMeta }; - }, + } }; return { ...fields, - _meta: metaField, + _meta: metaField }; } diff --git a/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts b/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts new file mode 100644 index 000000000..427be20fe --- /dev/null +++ b/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts @@ -0,0 +1,99 @@ +import type { PgCodec, ScopeMeta, ScopeTier } from './types'; + +/** Scopes with no per-row key column (global-tier registries). */ +const GLOBAL_SCOPES = new Set(['platform', 'app']); + +/** + * Candidate scope key columns to look for, in priority order, when a scope + * does not name its own key column via the smart tag. + */ +const DATABASE_KEY_CANDIDATES = ['database_id']; +const ENTITY_KEY_CANDIDATES = ['entity_id', 'owner_id']; + +function tierForScope(scope: string): ScopeTier { + if (scope === 'database') return 'database'; + if (GLOBAL_SCOPES.has(scope)) return 'global'; + return 'entity'; +} + +/** Return the first candidate attribute that exists on the codec, else null. */ +function firstExistingAttr( + attributes: Record | undefined, + candidates: string[] +): string | null { + if (!attributes) return null; + for (const name of candidates) { + if (name in attributes) return name; + } + return null; +} + +function resolveKeyColumn( + scope: string, + tier: ScopeTier, + attributes: Record | undefined, + explicitKey: string | null +): string | null { + if (tier === 'global') return null; + if (explicitKey && attributes && explicitKey in attributes) return explicitKey; + if (tier === 'database') { + return firstExistingAttr(attributes, DATABASE_KEY_CANDIDATES); + } + // entity tier: try a scope-named key (org → org_id) first, then generics + return firstExistingAttr(attributes, [`${scope}_id`, ...ENTITY_KEY_CANDIDATES]); +} + +/** + * Detect provisioning scope metadata for a table. + * + * Primary source: the `@scope` smart tag emitted by the constructive-db + * generators (`metaschema.append_table_smart_tags(id, {scope, scopeEntityTable})`), + * surfaced by PostGraphile as `codec.extensions.tags.scope`. + * + * Fallback: infer a `database` scope from a `database_id` column when no tag is + * present. Platform/app/entity scopes cannot be distinguished from columns + * alone, so those return null unless the tag is set. + */ +export function buildScopeMeta( + codec: PgCodec, + inflectAttr: (attrName: string, codec: PgCodec) => string +): ScopeMeta | null { + const attributes = codec.attributes; + const tags = (codec as any).extensions?.tags; + + const rawScope = tags?.scope; + if (typeof rawScope === 'string' && rawScope.length > 0) { + const scope = rawScope; + const tier = tierForScope(scope); + const explicitKey = + typeof tags.scopeKey === 'string' && tags.scopeKey.length > 0 + ? tags.scopeKey + : null; + const keyAttr = resolveKeyColumn(scope, tier, attributes, explicitKey); + const entityTable = + typeof tags.scopeEntityTable === 'string' && tags.scopeEntityTable.length > 0 + ? tags.scopeEntityTable + : null; + return { + scope, + tier, + keyColumn: keyAttr ? inflectAttr(keyAttr, codec) : null, + entityTable, + source: 'smartTag' + }; + } + + // Inference fallback: only the database tier is unambiguous from columns. + const dbKey = firstExistingAttr(attributes, DATABASE_KEY_CANDIDATES); + if (dbKey) { + return { + scope: 'database', + tier: 'database', + keyColumn: inflectAttr(dbKey, codec), + entityTable: null, + source: 'inferred' + }; + } + + return null; +} diff --git a/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts b/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts index 0b98fccb2..f6095c20e 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts @@ -2,40 +2,41 @@ import { buildForeignKeyConstraints, buildIndexes, buildPrimaryKey, - buildUniqueConstraints, + buildUniqueConstraints } from './constraint-meta-builders'; import { buildInflectionMeta, buildQueryMeta, resolveTableType } from './name-meta-builders'; import { buildBelongsToRelations, buildManyToManyRelations, - buildReverseRelations, + buildReverseRelations } from './relation-meta-builders'; -import { buildStorageMeta, buildSearchMeta, buildI18nMeta, buildRealtimeMeta } from './storage-search-meta-builders'; -import { buildFieldMeta } from './type-mappings'; +import { buildScopeMeta } from './scope-meta-builders'; +import { buildI18nMeta, buildRealtimeMeta,buildSearchMeta, buildStorageMeta } from './storage-search-meta-builders'; import { - createBuildContext, type BuildContext, - type TableResourceWithCodec, + createBuildContext, + type TableResourceWithCodec } from './table-meta-context'; import { getConfiguredSchemas, getRelations, getSchemaName, getUniques, - isTableResource, + isTableResource } from './table-resource-utils'; +import { buildFieldMeta } from './type-mappings'; import type { ConstraintsMeta, MetaBuild, PgCodec, - TableMeta, RelationsMeta, + TableMeta } from './types'; function buildTableMeta( resource: TableResourceWithCodec, schemaName: string, - context: BuildContext, + context: BuildContext ): TableMeta { const codec = resource.codec; const attributes = codec.attributes; @@ -58,8 +59,8 @@ function buildTableMeta( const fields = Object.entries(attributes).map(([attrName, attr]) => buildFieldMeta(context.inflectAttr(attrName, codec), attr, context.build, { isPrimaryKey: pkAttrNames.has(attrName), - isForeignKey: fkAttrNames.has(attrName), - }), + isForeignKey: fkAttrNames.has(attrName) + }) ); const indexes = buildIndexes(codec, attributes, uniques, context); const primaryKey = buildPrimaryKey(codec, attributes, uniques, context); @@ -68,13 +69,13 @@ function buildTableMeta( codec, attributes, relations, - context, + context ); const constraints: ConstraintsMeta = { primaryKey, unique: uniqueConstraints, - foreignKey: foreignKeyConstraints, + foreignKey: foreignKeyConstraints }; const belongsTo = buildBelongsToRelations(codec, attributes, uniques, relations, context); @@ -86,7 +87,7 @@ function buildTableMeta( has: [...hasOne, ...hasMany], hasOne, hasMany, - manyToMany, + manyToMany }; const tableType = resolveTableType(context.build, codec); @@ -95,6 +96,7 @@ function buildTableMeta( const search = buildSearchMeta(codec, context.build, context.inflectAttr); const i18n = buildI18nMeta(codec, context.build, context.inflectAttr); const realtime = buildRealtimeMeta(codec, context.build); + const scope = buildScopeMeta(codec, context.inflectAttr); return { name: tableType, @@ -112,6 +114,7 @@ function buildTableMeta( search, i18n, realtime, + scope }; } diff --git a/graphile/graphile-settings/src/plugins/meta-schema/types.ts b/graphile/graphile-settings/src/plugins/meta-schema/types.ts index 22181a917..6f64e5c39 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/types.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/types.ts @@ -14,6 +14,23 @@ export interface TableMeta { search: SearchMeta | null; i18n: I18nMeta | null; realtime: RealtimeMeta | null; + scope: ScopeMeta | null; +} + +/** Coarse provisioning tier a table's scope belongs to. */ +export type ScopeTier = 'global' | 'database' | 'entity'; + +export interface ScopeMeta { + /** The provisioning scope, e.g. 'platform', 'app', 'database', 'org', 'user' */ + scope: string; + /** Coarse bucket: global (platform/app), database, or entity */ + tier: ScopeTier; + /** Inflected scope key column (e.g. 'databaseId', 'orgId'), or null for global tiers */ + keyColumn: string | null; + /** SQL name of the entity table for entity scopes, else null */ + entityTable: string | null; + /** Where the scope was determined: 'smartTag' (@scope) or 'inferred' (columns) */ + source: 'smartTag' | 'inferred'; } export interface StorageMeta { From aab05a24bc1f6c6f76c9bc62409f206771ae6290 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 18 Jul 2026 10:54:26 +0000 Subject: [PATCH 2/3] test: update introspection/SDL snapshots for _meta scope field --- .../schema-snapshot.test.ts.snap | 25 +++++ .../__snapshots__/graphile-test.test.ts.snap | 95 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index 5b0bc63af..3f5fd2e2d 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -835,6 +835,28 @@ type MetaSchema { tables: [MetaTable!]! } +"""Provisioning scope metadata for a table""" +type MetaScope { + """SQL name of the entity table for entity scopes, else null""" + entityTable: String + + """ + Inflected scope key column (e.g. databaseId, orgId), null for global tiers + """ + keyColumn: String + + """ + Provisioning scope: 'platform', 'app', 'database', or an entity scope (e.g. 'org') + """ + scope: String! + + """How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)""" + source: String! + + """Coarse bucket: 'global', 'database', or 'entity'""" + tier: String! +} + """Search metadata for a table""" type MetaSearch { """Active search algorithms on this table""" @@ -902,6 +924,9 @@ type MetaTable { relations: MetaRelations! schemaName: String! + """Provisioning scope metadata (null if no @scope tag and not inferrable)""" + scope: MetaScope + """Search metadata (null if no search configured)""" search: MetaSearch diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index 60996f70b..27a27edd8 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -1483,6 +1483,18 @@ based pagination. May not be used with \`last\`.", "ofType": null, }, }, + { + "args": [], + "deprecationReason": null, + "description": "Provisioning scope metadata (null if no @scope tag and not inferrable)", + "isDeprecated": false, + "name": "scope", + "type": { + "kind": "OBJECT", + "name": "MetaScope", + "ofType": null, + }, + }, ], "inputFields": null, "interfaces": [], @@ -3334,6 +3346,89 @@ based pagination. May not be used with \`last\`.", "name": "MetaRealtime", "possibleTypes": null, }, + { + "description": "Provisioning scope metadata for a table", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "Provisioning scope: 'platform', 'app', 'database', or an entity scope (e.g. 'org')", + "isDeprecated": false, + "name": "scope", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Coarse bucket: 'global', 'database', or 'entity'", + "isDeprecated": false, + "name": "tier", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Inflected scope key column (e.g. databaseId, orgId), null for global tiers", + "isDeprecated": false, + "name": "keyColumn", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "SQL name of the entity table for entity scopes, else null", + "isDeprecated": false, + "name": "entityTable", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)", + "isDeprecated": false, + "name": "source", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "MetaScope", + "possibleTypes": null, + }, { "description": "The root mutation type which contains root level fields which mutate data.", "enumValues": null, From 8a1010fead1b2d38aae8b3806c005a9639e28296 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 18 Jul 2026 11:11:18 +0000 Subject: [PATCH 3/3] refactor(meta): make @scope tag the sole source of truth for scope Remove all column-name inference from buildScopeMeta. scope, tier, keyColumn (verbatim), and entityTable now come only from the @scope smart tag (scope/scopeTier/scopeKey/scopeEntityTable); untagged tables return null. Throws if the tag is present without a valid scopeTier. --- .agents/skills/graphile-meta/SKILL.md | 24 ++-- .../__tests__/meta-schema.test.ts | 89 +++++--------- .../plugins/meta-schema/graphql-meta-field.ts | 4 +- .../meta-schema/scope-meta-builders.ts | 112 ++++++------------ .../src/plugins/meta-schema/types.ts | 4 +- .../schema-snapshot.test.ts.snap | 4 +- .../__snapshots__/graphile-test.test.ts.snap | 4 +- 7 files changed, 81 insertions(+), 160 deletions(-) diff --git a/.agents/skills/graphile-meta/SKILL.md b/.agents/skills/graphile-meta/SKILL.md index f3e631233..ed080c300 100644 --- a/.agents/skills/graphile-meta/SKILL.md +++ b/.agents/skills/graphile-meta/SKILL.md @@ -124,26 +124,28 @@ map it onto a `Meta*` GraphQL type.** Worked pattern (this is exactly how the `scope` block was added — mirror the `storage`/`search` precedent): -1. **(DB, if the data isn't already a physical column)** Emit a smart tag from the - generator so it lands on the codec, e.g. - `metaschema.append_table_smart_tags(v_table_id, jsonb_build_object('scope', v_scope))` - at the single scope decision point (`apply_scope_fields.sql`), then - `pgpm package` + `pnpm run generate:constructive`. +1. **(DB)** Emit a smart tag carrying **all** the metadata from the generator so it + lands on the codec — never make the plugin guess. For scope, emit every field + (`scope`, `scopeTier`, `scopeKey`, `scopeEntityTable`) at the single decision + point (`apply_scope_fields.sql`): + `metaschema.append_table_smart_tags(v_table_id, jsonb_build_object('scope', v_scope, 'scopeTier', v_tier, 'scopeKey', v_key_col, 'scopeEntityTable', v_entity_table))`, + then `pgpm package` + `pnpm run generate:constructive`. 2. **`types.ts`** — add the interface (e.g. `ScopeMeta`) and a nullable field on `TableMeta` (`scope: ScopeMeta | null`). 3. **A builder** — add `buildScopeMeta(codec, inflectAttr): ScopeMeta | null` in a builder file (`scope` lives in `scope-meta-builders.ts`). Read - `codec.extensions.tags`, return `null` when the feature is absent (the - convention across all builders). Optionally infer from physical columns as a - fallback (e.g. `database_id` present → database scope) and record provenance. + `codec.extensions.tags` and return the values **verbatim**; return `null` when + the tag is absent (the convention across all builders). Do NOT infer key + columns from physical column names — generators name key columns freely, so + column-name heuristics are brittle. The DB is the source of truth. 4. **`table-meta-builder.ts`** — call the builder inside `buildTableMeta` next to `buildStorageMeta`/`buildSearchMeta` and add it to the returned object. 5. **`graphql-meta-field.ts`** — declare a `MetaScope` `GraphQLObjectType` and add the `scope` field to `MetaTableType` (nullable, with a description). 6. **Tests** — extend `__tests__/meta-schema.test.ts` and its snapshot (`__snapshots__/meta-schema.test.ts.snap`). Metadata is tested in-process with - mocked PostGraphile build resources (no live DB). Add both a tagged fixture and, - if you added inference, an untagged fixture. + mocked PostGraphile build resources (no live DB). Add a tagged fixture and an + untagged fixture (asserting `null`). Run: `pnpm --filter graphile-settings exec jest --runInBand` ### Builder conventions (follow these) @@ -188,7 +190,7 @@ file, or direct introspection). Beyond raw structure it gives codegen the |---|---|---| | `_meta` field missing | `MetaSchemaPreset`/`Plugin` not loaded | Add the preset (or use `ConstructivePreset`) | | Table missing from `_meta.tables` | Its schema isn't in the configured `pgSchemas`, or resource isn't a real table | Ensure the schema is in `makePgService({ schemas })`; virtual/function resources are skipped | -| `storage`/`search`/`i18n`/`realtime`/`scope` is `null` | No corresponding smart tag / column detected | Emit the smart tag in SQL (`append_table_smart_tags`) and re-introspect; verify `codec.extensions.tags` | +| `storage`/`search`/`i18n`/`realtime`/`scope` is `null` | No corresponding smart tag emitted | Emit the smart tag in SQL (`append_table_smart_tags`) and re-introspect; verify `codec.extensions.tags` | | Stale metadata after schema change | `_meta` is cached at build time | Rebuild the schema (server uses LISTEN/NOTIFY cache invalidation) | | New field not appearing | Added to `types.ts`/builder but not to `graphql-meta-field.ts` | The GraphQL types are hand-declared — add the field to `MetaTableType` and its object type | diff --git a/graphile/graphile-settings/__tests__/meta-schema.test.ts b/graphile/graphile-settings/__tests__/meta-schema.test.ts index 75cb8f032..9864e0741 100644 --- a/graphile/graphile-settings/__tests__/meta-schema.test.ts +++ b/graphile/graphile-settings/__tests__/meta-schema.test.ts @@ -2407,29 +2407,27 @@ describe('MetaSchemaPlugin', () => { }); describe('scope metadata', () => { - it('returns null scope for tables without @scope tag or a scope key column', () => { + it('returns null scope for tables without an @scope tag (no column inference)', () => { + const codec = createMockCodec('widget', { + id: createMockAttribute('uuid'), + // a database_id column must NOT be enough to infer a scope + database_id: createMockAttribute('uuid') + }); const build = createMockBuild({ - widget: { - codec: createMockCodec('widget', { - id: createMockAttribute('uuid'), - name: createMockAttribute('text') - }), - uniques: [], - relations: {} - } + widget: { codec, uniques: [], relations: {} } }); const tables = callInitHook(build); expect(tables[0].scope).toBeNull(); }); - it('reads a database @scope smart tag', () => { + it('reads a database @scope smart tag verbatim', () => { const codec = createMockCodec('app_setting', { id: createMockAttribute('uuid'), database_id: createMockAttribute('uuid') }); (codec as any).extensions = { ...codec.extensions, - tags: { scope: 'database' } + tags: { scope: 'database', scopeTier: 'database', scopeKey: 'database_id' } }; const build = createMockBuild({ app_setting: { codec, uniques: [], relations: {} } @@ -2444,14 +2442,14 @@ describe('MetaSchemaPlugin', () => { }); }); - it('reads a global (platform/app) @scope smart tag with no key column', () => { + it('reads a global @scope smart tag with no key column', () => { const codec = createMockCodec('feature_flag', { id: createMockAttribute('uuid'), key: createMockAttribute('text') }); (codec as any).extensions = { ...codec.extensions, - tags: { scope: 'platform' } + tags: { scope: 'platform', scopeTier: 'global' } }; const build = createMockBuild({ feature_flag: { codec, uniques: [], relations: {} } @@ -2466,14 +2464,20 @@ describe('MetaSchemaPlugin', () => { }); }); - it('reads an entity @scope smart tag with entity table and scope-named key', () => { + it('reads an entity @scope smart tag with the exact key column and entity table', () => { const codec = createMockCodec('project', { id: createMockAttribute('uuid'), - org_id: createMockAttribute('uuid') + tenant_ref: createMockAttribute('uuid') }); (codec as any).extensions = { ...codec.extensions, - tags: { scope: 'org', scopeEntityTable: 'organizations' } + // key column named freely by the generator — must be taken verbatim + tags: { + scope: 'org', + scopeTier: 'entity', + scopeKey: 'tenant_ref', + scopeEntityTable: 'organizations' + } }; const build = createMockBuild({ project: { codec, uniques: [], relations: {} } @@ -2482,63 +2486,24 @@ describe('MetaSchemaPlugin', () => { expect(tables[0].scope).toEqual({ scope: 'org', tier: 'entity', - keyColumn: 'orgId', + keyColumn: 'tenantRef', entityTable: 'organizations', source: 'smartTag' }); }); - it('honors an explicit scopeKey smart tag over inference', () => { - const codec = createMockCodec('membership', { - id: createMockAttribute('uuid'), - owner_id: createMockAttribute('uuid') + it('throws when @scope is present but scopeTier is missing or invalid', () => { + const codec = createMockCodec('broken', { + id: createMockAttribute('uuid') }); (codec as any).extensions = { ...codec.extensions, - tags: { scope: 'team', scopeKey: 'owner_id' } + tags: { scope: 'org' } }; const build = createMockBuild({ - membership: { codec, uniques: [], relations: {} } - }); - const tables = callInitHook(build); - expect(tables[0].scope).toEqual({ - scope: 'team', - tier: 'entity', - keyColumn: 'ownerId', - entityTable: null, - source: 'smartTag' - }); - }); - - it('infers a database scope from a database_id column when untagged', () => { - const codec = createMockCodec('audit_log', { - id: createMockAttribute('uuid'), - database_id: createMockAttribute('uuid'), - message: createMockAttribute('text') - }); - const build = createMockBuild({ - audit_log: { codec, uniques: [], relations: {} } + broken: { codec, uniques: [], relations: {} } }); - const tables = callInitHook(build); - expect(tables[0].scope).toEqual({ - scope: 'database', - tier: 'database', - keyColumn: 'databaseId', - entityTable: null, - source: 'inferred' - }); - }); - - it('does not infer entity/global scope from arbitrary columns', () => { - const codec = createMockCodec('note', { - id: createMockAttribute('uuid'), - author_id: createMockAttribute('uuid') - }); - const build = createMockBuild({ - note: { codec, uniques: [], relations: {} } - }); - const tables = callInitHook(build); - expect(tables[0].scope).toBeNull(); + expect(() => callInitHook(build)).toThrow(/scopeTier/); }); }); diff --git a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts index 430dac29a..0eec12d36 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts @@ -286,7 +286,7 @@ function createMetaSchemaType(): GraphQLObjectType { tier: { type: nn(GraphQLString), description: "Coarse bucket: 'global', 'database', or 'entity'" }, keyColumn: { type: GraphQLString, description: 'Inflected scope key column (e.g. databaseId, orgId), null for global tiers' }, entityTable: { type: GraphQLString, description: 'SQL name of the entity table for entity scopes, else null' }, - source: { type: nn(GraphQLString), description: "How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)" } + source: { type: nn(GraphQLString), description: "Provenance of the scope metadata (always 'smartTag')" } }) }); @@ -309,7 +309,7 @@ function createMetaSchemaType(): GraphQLObjectType { search: { type: MetaSearchType, description: 'Search metadata (null if no search configured)' }, i18n: { type: MetaI18nType, description: 'i18n metadata (null if no @i18n tag)' }, realtime: { type: MetaRealtimeType, description: 'Realtime metadata (null if no @realtime tag)' }, - scope: { type: MetaScopeType, description: 'Provisioning scope metadata (null if no @scope tag and not inferrable)' } + scope: { type: MetaScopeType, description: 'Provisioning scope metadata (null if no @scope tag)' } }) }); diff --git a/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts b/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts index 427be20fe..72990dddc 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts @@ -1,99 +1,53 @@ import type { PgCodec, ScopeMeta, ScopeTier } from './types'; -/** Scopes with no per-row key column (global-tier registries). */ -const GLOBAL_SCOPES = new Set(['platform', 'app']); +const VALID_TIERS = new Set(['global', 'database', 'entity']); -/** - * Candidate scope key columns to look for, in priority order, when a scope - * does not name its own key column via the smart tag. - */ -const DATABASE_KEY_CANDIDATES = ['database_id']; -const ENTITY_KEY_CANDIDATES = ['entity_id', 'owner_id']; - -function tierForScope(scope: string): ScopeTier { - if (scope === 'database') return 'database'; - if (GLOBAL_SCOPES.has(scope)) return 'global'; - return 'entity'; -} - -/** Return the first candidate attribute that exists on the codec, else null. */ -function firstExistingAttr( - attributes: Record | undefined, - candidates: string[] -): string | null { - if (!attributes) return null; - for (const name of candidates) { - if (name in attributes) return name; - } - return null; -} - -function resolveKeyColumn( - scope: string, - tier: ScopeTier, - attributes: Record | undefined, - explicitKey: string | null -): string | null { - if (tier === 'global') return null; - if (explicitKey && attributes && explicitKey in attributes) return explicitKey; - if (tier === 'database') { - return firstExistingAttr(attributes, DATABASE_KEY_CANDIDATES); - } - // entity tier: try a scope-named key (org → org_id) first, then generics - return firstExistingAttr(attributes, [`${scope}_id`, ...ENTITY_KEY_CANDIDATES]); +function readString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; } /** * Detect provisioning scope metadata for a table. * - * Primary source: the `@scope` smart tag emitted by the constructive-db - * generators (`metaschema.append_table_smart_tags(id, {scope, scopeEntityTable})`), - * surfaced by PostGraphile as `codec.extensions.tags.scope`. + * The `@scope` smart tag emitted by the constructive-db generators is the sole + * source of truth. `apply_scope_fields` knows the exact scope, tier, key column, + * and entity table, and emits them via + * `metaschema.append_table_smart_tags(id, { scope, scopeTier, scopeKey, scopeEntityTable })`. + * PostGraphile surfaces these as `codec.extensions.tags.*`. * - * Fallback: infer a `database` scope from a `database_id` column when no tag is - * present. Platform/app/entity scopes cannot be distinguished from columns - * alone, so those return null unless the tag is set. + * There is NO inference from column names — a table without the tag returns + * `null` (the same convention as every other nullable metadata block). This + * avoids guessing key columns, whose names are chosen freely by each generator. */ export function buildScopeMeta( codec: PgCodec, inflectAttr: (attrName: string, codec: PgCodec) => string ): ScopeMeta | null { - const attributes = codec.attributes; const tags = (codec as any).extensions?.tags; - const rawScope = tags?.scope; - if (typeof rawScope === 'string' && rawScope.length > 0) { - const scope = rawScope; - const tier = tierForScope(scope); - const explicitKey = - typeof tags.scopeKey === 'string' && tags.scopeKey.length > 0 - ? tags.scopeKey - : null; - const keyAttr = resolveKeyColumn(scope, tier, attributes, explicitKey); - const entityTable = - typeof tags.scopeEntityTable === 'string' && tags.scopeEntityTable.length > 0 - ? tags.scopeEntityTable - : null; - return { - scope, - tier, - keyColumn: keyAttr ? inflectAttr(keyAttr, codec) : null, - entityTable, - source: 'smartTag' - }; + const scope = readString(tags?.scope); + if (!scope) return null; + + const rawTier = readString(tags.scopeTier); + const tier = + rawTier && VALID_TIERS.has(rawTier as ScopeTier) + ? (rawTier as ScopeTier) + : null; + if (!tier) { + throw new Error( + `@scope tag on codec "${codec.name}" is missing a valid scopeTier ` + + `(expected one of global|database|entity, got ${JSON.stringify(rawTier)})` + ); } - // Inference fallback: only the database tier is unambiguous from columns. - const dbKey = firstExistingAttr(attributes, DATABASE_KEY_CANDIDATES); - if (dbKey) { - return { - scope: 'database', - tier: 'database', - keyColumn: inflectAttr(dbKey, codec), - entityTable: null, - source: 'inferred' - }; - } + const keyColumn = readString(tags.scopeKey); + const entityTable = readString(tags.scopeEntityTable); - return null; + return { + scope, + tier, + keyColumn: keyColumn ? inflectAttr(keyColumn, codec) : null, + entityTable, + source: 'smartTag' + }; } diff --git a/graphile/graphile-settings/src/plugins/meta-schema/types.ts b/graphile/graphile-settings/src/plugins/meta-schema/types.ts index 6f64e5c39..ac01c0f88 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/types.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/types.ts @@ -29,8 +29,8 @@ export interface ScopeMeta { keyColumn: string | null; /** SQL name of the entity table for entity scopes, else null */ entityTable: string | null; - /** Where the scope was determined: 'smartTag' (@scope) or 'inferred' (columns) */ - source: 'smartTag' | 'inferred'; + /** Provenance of the scope metadata. Always the @scope smart tag. */ + source: 'smartTag'; } export interface StorageMeta { diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index 3f5fd2e2d..280d1d491 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -850,7 +850,7 @@ type MetaScope { """ scope: String! - """How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)""" + """Provenance of the scope metadata (always 'smartTag')""" source: String! """Coarse bucket: 'global', 'database', or 'entity'""" @@ -924,7 +924,7 @@ type MetaTable { relations: MetaRelations! schemaName: String! - """Provisioning scope metadata (null if no @scope tag and not inferrable)""" + """Provisioning scope metadata (null if no @scope tag)""" scope: MetaScope """Search metadata (null if no search configured)""" diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index 27a27edd8..f90567122 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -1486,7 +1486,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Provisioning scope metadata (null if no @scope tag and not inferrable)", + "description": "Provisioning scope metadata (null if no @scope tag)", "isDeprecated": false, "name": "scope", "type": { @@ -3409,7 +3409,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "How scope was determined: 'smartTag' (@scope) or 'inferred' (columns)", + "description": "Provenance of the scope metadata (always 'smartTag')", "isDeprecated": false, "name": "source", "type": {