diff --git a/.agents/skills/graphile-meta/SKILL.md b/.agents/skills/graphile-meta/SKILL.md index 269637553..ed080c300 100644 --- a/.agents/skills/graphile-meta/SKILL.md +++ b/.agents/skills/graphile-meta/SKILL.md @@ -121,29 +121,31 @@ 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 - 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, build, inflectAttr): ScopeMeta | null` - in `storage-search-meta-builders.ts` (or a new `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. +3. **A builder** — add `buildScopeMeta(codec, inflectAttr): ScopeMeta | null` + in a builder file (`scope` lives in `scope-meta-builders.ts`). Read + `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) @@ -171,7 +173,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. @@ -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 | @@ -196,4 +198,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..9864e0741 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,25 +2384,126 @@ 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 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, uniques: [], relations: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toBeNull(); + }); + + 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', scopeTier: 'database', scopeKey: 'database_id' } + }; + 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 @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', scopeTier: 'global' } + }; + 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 the exact key column and entity table', () => { + const codec = createMockCodec('project', { + id: createMockAttribute('uuid'), + tenant_ref: createMockAttribute('uuid') + }); + (codec as any).extensions = { + ...codec.extensions, + // 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: {} } + }); + const tables = callInitHook(build); + expect(tables[0].scope).toEqual({ + scope: 'org', + tier: 'entity', + keyColumn: 'tenantRef', + entityTable: 'organizations', + source: 'smartTag' + }); + }); + + 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: 'org' } + }; + const build = createMockBuild({ + broken: { codec, uniques: [], relations: {} } }); + 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 51355b363..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 @@ -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: "Provenance of the scope metadata (always 'smartTag')" } + }) }); 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)' } + }) }); 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..72990dddc --- /dev/null +++ b/graphile/graphile-settings/src/plugins/meta-schema/scope-meta-builders.ts @@ -0,0 +1,53 @@ +import type { PgCodec, ScopeMeta, ScopeTier } from './types'; + +const VALID_TIERS = new Set(['global', 'database', 'entity']); + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** + * Detect provisioning scope metadata for a table. + * + * 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.*`. + * + * 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 tags = (codec as any).extensions?.tags; + + 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)})` + ); + } + + const keyColumn = readString(tags.scopeKey); + const entityTable = readString(tags.scopeEntityTable); + + return { + scope, + tier, + keyColumn: keyColumn ? inflectAttr(keyColumn, codec) : null, + entityTable, + source: 'smartTag' + }; +} 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..ac01c0f88 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; + /** 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 5b0bc63af..280d1d491 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! + + """Provenance of the scope metadata (always 'smartTag')""" + 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)""" + 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..f90567122 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)", + "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": "Provenance of the scope metadata (always 'smartTag')", + "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,