diff --git a/pgpm/export/__tests__/cross-flow-parity.test.ts b/pgpm/export/__tests__/cross-flow-parity.test.ts index 19056c019..7a0a4f6c6 100644 --- a/pgpm/export/__tests__/cross-flow-parity.test.ts +++ b/pgpm/export/__tests__/cross-flow-parity.test.ts @@ -20,8 +20,9 @@ import { toCamelCase } from 'inflekt'; import { exportMeta } from '../src/export-meta'; import { exportGraphQLMeta } from '../src/export-graphql-meta'; import { GraphQLClient } from '../src/graphql-client'; -import { META_TABLE_CONFIG } from '../src/export-utils'; -import { getGraphQLQueryName } from '../src/graphql-naming'; +import { META_TABLE_CONFIG, FieldType } from '../src/export-utils'; +import { getGraphQLQueryName, getGraphQLTypeName, GraphQLTypeInfo } from '../src/graphql-naming'; +import { lookupByPgUdt } from '../src/type-map'; jest.setTimeout(60000); @@ -42,6 +43,10 @@ const API_ID = 'c0000001-0000-0000-0000-000000000001'; const SITE_ID = 'c1000001-0000-0000-0000-000000000001'; const DOMAIN_ID = 'c2000001-0000-0000-0000-000000000001'; const API_SCHEMA_ID = 'c3000001-0000-0000-0000-000000000001'; +const INDEX_ID = 'd0000001-0000-0000-0000-000000000001'; +const RLS_FUNCTION_ID = 'd1000001-0000-0000-0000-000000000001'; +const CORS_SETTINGS_ID = 'd2000001-0000-0000-0000-000000000001'; +const USER_AUTH_MODULE_ID = 'd3000001-0000-0000-0000-000000000001'; // ============================================================================= // Helper: build a mock GraphQLClient that reads from the real database @@ -51,6 +56,77 @@ const API_SCHEMA_ID = 'c3000001-0000-0000-0000-000000000001'; function createMockGraphQLClient(pgClient: PgTestClient): GraphQLClient { const client = new GraphQLClient({ endpoint: 'http://mock' }); + // Override introspectType to query information_schema and return GraphQL-style type info + client.introspectType = async (typeName: string): Promise> => { + // Reverse-lookup: find the META_TABLE_CONFIG entry whose GraphQL type name matches + let matchedKey: string | undefined; + for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { + if (getGraphQLTypeName(config.table) === typeName) { + matchedKey = key; + break; + } + } + + if (!matchedKey) { + return new Map(); + } + + const config = META_TABLE_CONFIG[matchedKey]; + + try { + // Query information_schema to get column names, types, and enum info + const result = await pgClient.query(` + SELECT column_name, udt_name, is_updatable + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position + `, [config.schema, config.table]); + + // Also query enum types for this schema to detect enum columns + let enumTypes: Map = new Map(); + try { + const enumResult = await pgClient.query(` + SELECT t.typname AS enum_name, e.enumlabel AS enum_value + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE n.nspname = $1 + `, [config.schema]); + for (const row of enumResult.rows) { + const vals = enumTypes.get(row.enum_name) || []; + vals.push(row.enum_value); + enumTypes.set(row.enum_name, vals); + } + } catch { + // Enum query failed — skip enum detection + } + + const fields = new Map(); + for (const row of result.rows) { + // Convert snake_case column name to camelCase (PostGraphile style) + const camelName = toCamelCase(row.column_name); + const udtName = row.udt_name; + const isList = udtName.startsWith('_'); + const baseUdt = isList ? udtName.slice(1) : udtName; + + // Check if this is an enum type + if (enumTypes.has(baseUdt)) { + fields.set(camelName, { typeName: baseUdt, kind: 'ENUM', list: isList, nonNull: false }); + continue; + } + + // Map PostgreSQL udt_name to GraphQL type info via canonical type map + const entry = lookupByPgUdt(baseUdt); + const gqlTypeName = entry?.gqlTypeName ?? 'String'; + const gqlKind = entry?.gqlKind ?? 'SCALAR'; + fields.set(camelName, { typeName: gqlTypeName, kind: gqlKind, list: isList, nonNull: false }); + } + return fields; + } catch { + return new Map(); + } + }; + // Override fetchAllNodes to query the real database and return camelCase rows client.fetchAllNodes = async >( queryFieldName: string, @@ -92,11 +168,74 @@ function createMockGraphQLClient(pgClient: PgTestClient): GraphQLClient { try { const result = await pgClient.query(`SELECT * FROM ${schemaTable} ${whereClause}`, params); + // Get column type info for this table to simulate PostGraphile transformations + const colResult = await pgClient.query(` + SELECT column_name, udt_name + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + `, [config.schema, config.table]); + const colTypes = new Map(); + for (const r of colResult.rows) { + colTypes.set(r.column_name, r.udt_name); + } + + // Get enum types to simulate custom inflector uppercase behavior + let enumColumns: Set = new Set(); + try { + const enumResult = await pgClient.query(` + SELECT DISTINCT attname, t.typname + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + JOIN pg_type t ON a.atttypid = t.oid + WHERE n.nspname = $1 AND c.relname = $2 AND t.typtype = 'e' + `, [config.schema, config.table]); + for (const r of enumResult.rows) { + enumColumns.add(r.attname); + } + } catch { + // Enum detection failed — skip + } + // Convert snake_case PG rows to camelCase (simulating PostGraphile) return result.rows.map(row => { const camelRow: Record = {}; for (const [key, value] of Object.entries(row)) { - camelRow[toCamelCase(key)] = value; + const camelKey = toCamelCase(key); + const udtName = colTypes.get(key); + + if (value === null || value === undefined) { + camelRow[camelKey] = value; + continue; + } + + // Simulate PostGraphile Interval OBJECT type: convert PG interval strings + // to { seconds, minutes, hours, days, months, years } objects + if (udtName === 'interval' && typeof value === 'string') { + camelRow[camelKey] = parsePgInterval(value); + continue; + } + + // Simulate custom inflector ENUM uppercase (e.g., 'app' → 'APP') + if (enumColumns.has(key) && typeof value === 'string') { + camelRow[camelKey] = value.toUpperCase(); + continue; + } + + // Simulate PostGraphile BigInt scalar: bigint values arrive as strings + if (udtName === 'int8' && typeof value === 'number') { + camelRow[camelKey] = String(value); + continue; + } + + // Simulate PostGraphile Datetime scalar: truncate timestamptz to second precision + // and return as ISO string (matching real PostGraphile JSON response) + if ((udtName === 'timestamptz' || udtName === 'timestamp') && value instanceof Date) { + camelRow[camelKey] = new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString(); + continue; + } + + camelRow[camelKey] = value; } return camelRow as T; }); @@ -113,6 +252,47 @@ function createMockGraphQLClient(pgClient: PgTestClient): GraphQLClient { return client; } +/** + * Parse a PostgreSQL interval string into the object shape that PostGraphile's + * Interval type returns: { years, months, days, hours, minutes, seconds }. + * This simulates what the real PostGraphile server does. + * + * Handles formats like: + * '30 days' → { years: 0, months: 0, days: 30, hours: 0, minutes: 0, seconds: 0 } + * '01:30:00' → { years: 0, months: 0, days: 0, hours: 1, minutes: 30, seconds: 0 } + * + * NOTE: Also available from @pgpmjs/export as parsePgInterval (interval-utils.ts). + * Kept inline here to avoid importing src code that depends on build output + * in this integration test. + */ +function parsePgInterval(value: string): Record { + const result = { years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0 }; + + // Try HH:MM:SS format + const timeMatch = value.match(/^(\d+):(\d+):(\d+)/); + if (timeMatch) { + result.hours = parseInt(timeMatch[1], 10); + result.minutes = parseInt(timeMatch[2], 10); + result.seconds = parseInt(timeMatch[3], 10); + return result; + } + + // Try descriptive format: 'N unit N unit ...' + const parts = value.trim().split(/\s+/); + for (let i = 0; i < parts.length - 1; i += 2) { + const num = parseInt(parts[i], 10); + const unit = parts[i + 1].toLowerCase(); + if (unit.startsWith('year')) result.years = num; + else if (unit.startsWith('mon')) result.months = num; + else if (unit.startsWith('day')) result.days = num; + else if (unit.startsWith('hour')) result.hours = num; + else if (unit.startsWith('minute')) result.minutes = num; + else if (unit.startsWith('second')) result.seconds = num; + } + + return result; +} + // ============================================================================= // Test suite // ============================================================================= @@ -197,6 +377,50 @@ describe('Cross-flow parity: exportMeta vs exportGraphQLMeta', () => { schema_id uuid, api_id uuid ); + + -- Extended coverage tables (dynamic fields, array types, jsonb, renamed fields) + CREATE TABLE metaschema_public.index ( + id uuid PRIMARY KEY, + database_id uuid, + schema_id uuid, + table_id uuid, + name text, + type text, + columns uuid[], + predicates jsonb, + is_unique boolean + ); + CREATE TABLE metaschema_public.rls_function ( + id uuid PRIMARY KEY, + database_id uuid, + schema_id uuid, + table_id uuid, + role_name text, + command text, + function_name text, + is_using boolean, + with_check text, + force_enabled boolean, + priority int4 + ); + CREATE TABLE services_public.cors_settings ( + id uuid PRIMARY KEY, + database_id uuid, + api_id uuid, + allowed_origins text[], + allow_credentials boolean, + max_age int4 + ); + CREATE TABLE metaschema_modules_public.user_auth_module ( + id uuid PRIMARY KEY, + database_id uuid, + schema_id uuid, + sign_in_cross_origin_function text, + one_time_token_function text, + sign_in_function text, + sign_up_function text, + sign_out_function text + ); `); // Seed data @@ -246,6 +470,31 @@ describe('Cross-flow parity: exportMeta vs exportGraphQLMeta', () => { INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) VALUES ($1, $2, $3, $4) `, [API_SCHEMA_ID, DATABASE_ID, SCHEMA_ID_PUB, API_ID]); + + // Seed extended coverage tables + // index table — tests uuid[] and jsonb columns + await pg.query(` + INSERT INTO metaschema_public.index (id, database_id, schema_id, table_id, name, type, columns, predicates, is_unique) + VALUES ($1, $2, $3, $4, 'users_pkey', 'btree', ARRAY[$5]::uuid[], '{"columns": ["id"]}'::jsonb, true) + `, [INDEX_ID, DATABASE_ID, SCHEMA_ID_PUB, TABLE_ID_USERS, FIELD_ID_1]); + + // rls_function table — tests boolean and int columns + await pg.query(` + INSERT INTO metaschema_public.rls_function (id, database_id, schema_id, table_id, role_name, command, function_name, is_using, force_enabled, priority) + VALUES ($1, $2, $3, $4, 'authenticated', 'SELECT', 'check_owner', true, true, 10) + `, [RLS_FUNCTION_ID, DATABASE_ID, SCHEMA_ID_PUB, TABLE_ID_USERS]); + + // cors_settings table — tests text[] columns + await pg.query(` + INSERT INTO services_public.cors_settings (id, database_id, api_id, allowed_origins, allow_credentials, max_age) + VALUES ($1, $2, $3, ARRAY['http://localhost:3000', 'https://example.com']::text[], true, 3600) + `, [CORS_SETTINGS_ID, DATABASE_ID, API_ID]); + + // user_auth_module — tests the renamed field from PR #1172 + await pg.query(` + INSERT INTO metaschema_modules_public.user_auth_module (id, database_id, schema_id, sign_in_cross_origin_function, one_time_token_function, sign_in_function, sign_up_function, sign_out_function) + VALUES ($1, $2, $3, 'sign_in_cross_origin', 'generate_token', 'sign_in', 'sign_up', 'sign_out') + `, [USER_AUTH_MODULE_ID, DATABASE_ID, SCHEMA_ID_PUB]); }) ])); }); @@ -381,15 +630,92 @@ describe('Cross-flow parity: exportMeta vs exportGraphQLMeta', () => { database_id: DATABASE_ID }); - // We didn't seed any modules, so module tables should be absent from both - const moduleTables = [ - 'rls_module', 'user_auth_module', 'memberships_module', - 'permissions_module', 'limits_module', 'levels_module' + // We didn't seed some modules, so those tables should be absent from both + const unseededTables = [ + 'rls_module', 'memberships_module', 'permissions_module', 'limits_module', 'levels_module' ]; - for (const table of moduleTables) { + for (const table of unseededTables) { expect(sqlResult[table]).toBeUndefined(); expect(gqlResult[table]).toBeUndefined(); } }); + + it('index table with uuid[] and jsonb columns should be identical across both flows', async () => { + const sqlResult = await exportMeta({ + opts: { pg: dbConfig }, + dbname: dbConfig.database, + database_id: DATABASE_ID + }); + + const mockClient = createMockGraphQLClient(pg); + const gqlResult = await exportGraphQLMeta({ + client: mockClient, + database_id: DATABASE_ID + }); + + // Both flows should export the index table + expect(sqlResult['index']).toBeDefined(); + expect(gqlResult['index']).toBeDefined(); + expect(gqlResult['index']?.trim()).toBe(sqlResult['index']?.trim()); + }); + + it('rls_function table with boolean and int columns should be identical across both flows', async () => { + const sqlResult = await exportMeta({ + opts: { pg: dbConfig }, + dbname: dbConfig.database, + database_id: DATABASE_ID + }); + + const mockClient = createMockGraphQLClient(pg); + const gqlResult = await exportGraphQLMeta({ + client: mockClient, + database_id: DATABASE_ID + }); + + expect(sqlResult['rls_function']).toBeDefined(); + expect(gqlResult['rls_function']).toBeDefined(); + expect(gqlResult['rls_function']?.trim()).toBe(sqlResult['rls_function']?.trim()); + }); + + it('cors_settings table with text[] columns should be identical across both flows', async () => { + const sqlResult = await exportMeta({ + opts: { pg: dbConfig }, + dbname: dbConfig.database, + database_id: DATABASE_ID + }); + + const mockClient = createMockGraphQLClient(pg); + const gqlResult = await exportGraphQLMeta({ + client: mockClient, + database_id: DATABASE_ID + }); + + expect(sqlResult['cors_settings']).toBeDefined(); + expect(gqlResult['cors_settings']).toBeDefined(); + expect(gqlResult['cors_settings']?.trim()).toBe(sqlResult['cors_settings']?.trim()); + }); + + it('user_auth_module (PR #1172 renamed field) should be identical across both flows', async () => { + const sqlResult = await exportMeta({ + opts: { pg: dbConfig }, + dbname: dbConfig.database, + database_id: DATABASE_ID + }); + + const mockClient = createMockGraphQLClient(pg); + const gqlResult = await exportGraphQLMeta({ + client: mockClient, + database_id: DATABASE_ID + }); + + // The key bug that PR #1172 fixed: sign_in_one_time_token_function → sign_in_cross_origin_function + // With dynamic fields, both flows discover this field from the DB schema automatically + expect(sqlResult['user_auth_module']).toBeDefined(); + expect(gqlResult['user_auth_module']).toBeDefined(); + expect(gqlResult['user_auth_module']?.trim()).toBe(sqlResult['user_auth_module']?.trim()); + + // Verify the renamed field is present in the output + expect(sqlResult['user_auth_module']).toContain('sign_in_cross_origin'); + }); }); diff --git a/pgpm/export/__tests__/dynamic-fields.test.ts b/pgpm/export/__tests__/dynamic-fields.test.ts new file mode 100644 index 000000000..98fc8cfae --- /dev/null +++ b/pgpm/export/__tests__/dynamic-fields.test.ts @@ -0,0 +1,327 @@ +/** + * Unit tests for dynamic field discovery logic. + * + * Validates: + * 1. Type mapping parity: mapPgTypeToFieldType and mapGraphQLTypeToFieldType + * agree on equivalent types (Type Mapping Alignment Table). + * 2. Round-trip field name verification: toSnakeCase(toCamelCase(snake_name)) + * === snake_name for all META_TABLE_CONFIG table/field names. + * 3. mapPgTypeToFieldType and mapGraphQLTypeToFieldType correctness. + * 4. typeOverrides take precedence over introspected types. + */ + +import { toCamelCase, toSnakeCase } from 'inflekt'; + +import { + META_TABLE_CONFIG, + mapPgTypeToFieldType, + FieldType +} from '../src/export-utils'; +import { + mapGraphQLTypeToFieldType, + unwrapGraphQLType, + getGraphQLTypeName, + getGraphQLQueryName +} from '../src/graphql-naming'; +import { PG_TYPE_MAP } from '../src/type-map'; + +// ============================================================================= +// Task 10: Type Mapping Parity Validation +// ============================================================================= + +describe('Type mapping parity: mapPgTypeToFieldType vs mapGraphQLTypeToFieldType', () => { + // Derived from the canonical PG_TYPE_MAP — adding a type there + // automatically updates both mappers and this test. + const parityTable: Array<[string, string, FieldType]> = PG_TYPE_MAP.flatMap(entry => + entry.pgUdtNames.map(pgUdt => [pgUdt, entry.gqlTypeName, entry.fieldType] as [string, string, FieldType]) + ); + + for (const [pgUdtName, gqlTypeName, expectedFieldType] of parityTable) { + const isPgArray = pgUdtName.startsWith('_'); + + it(`PG "${pgUdtName}" ↔ GQL "${gqlTypeName}"${isPgArray ? ' (array)' : ''} → FieldType "${expectedFieldType}"`, () => { + expect(mapPgTypeToFieldType(pgUdtName)).toBe(expectedFieldType); + expect(mapGraphQLTypeToFieldType(gqlTypeName, isPgArray)).toBe(expectedFieldType); + }); + } + + it('unknown PG type and unknown GQL type both fall back to "text"', () => { + expect(mapPgTypeToFieldType('unknown_type')).toBe('text'); + expect(mapGraphQLTypeToFieldType('UnknownScalar', false)).toBe('text'); + }); + + it('ID GraphQL type maps to uuid FieldType (parity with uuid PG type)', () => { + expect(mapGraphQLTypeToFieldType('ID', false)).toBe('uuid'); + }); +}); + +// ============================================================================= +// Task 11: Round-trip field name verification +// ============================================================================= + +describe('Round-trip field name verification: snake_case → camelCase → snake_case', () => { + it('every META_TABLE_CONFIG table name round-trips through camelCase conversion', () => { + const failures: string[] = []; + + for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { + const original = config.table; + const camel = toCamelCase(original); + const roundTrip = toSnakeCase(camel); + if (roundTrip !== original) { + failures.push(`${key}: "${original}" → camelCase("${camel}") → snake_case("${roundTrip}")`); + } + } + + expect(failures).toEqual([]); + }); + + it('every META_TABLE_CONFIG key round-trips through camelCase conversion', () => { + const failures: string[] = []; + + for (const key of Object.keys(META_TABLE_CONFIG)) { + const camel = toCamelCase(key); + const roundTrip = toSnakeCase(camel); + if (roundTrip !== key) { + failures.push(`"${key}" → camelCase("${camel}") → snake_case("${roundTrip}")`); + } + } + + expect(failures).toEqual([]); + }); + + it('common snake_case column names round-trip correctly', () => { + const commonNames = [ + 'id', 'database_id', 'schema_id', 'table_id', 'field_id', + 'name', 'type', 'description', 'is_public', 'role_name', + 'og_image', 'apple_touch_icon', 'sign_in_function', + 'sign_in_cross_origin_function', 'one_time_token_function', + 'created_at', 'updated_at', 'foreign_key_constraint', + 'ref_table_id', 'ref_field_ids', 'delete_action', + 'smart_tags', 'api_id', 'site_id', 'app_image', + 'app_store_link', 'play_store_link' + ]; + + const failures: string[] = []; + for (const name of commonNames) { + const camel = toCamelCase(name); + const roundTrip = toSnakeCase(camel); + if (roundTrip !== name) { + failures.push(`"${name}" → "${camel}" → "${roundTrip}"`); + } + } + + expect(failures).toEqual([]); + }); +}); + +// ============================================================================= +// mapPgTypeToFieldType unit tests +// ============================================================================= + +describe('mapPgTypeToFieldType', () => { + it('maps uuid types correctly', () => { + expect(mapPgTypeToFieldType('uuid')).toBe('uuid'); + expect(mapPgTypeToFieldType('_uuid')).toBe('uuid[]'); + }); + + it('maps text types correctly', () => { + expect(mapPgTypeToFieldType('text')).toBe('text'); + expect(mapPgTypeToFieldType('varchar')).toBe('text'); + expect(mapPgTypeToFieldType('bpchar')).toBe('text'); + expect(mapPgTypeToFieldType('name')).toBe('text'); + expect(mapPgTypeToFieldType('_text')).toBe('text[]'); + expect(mapPgTypeToFieldType('_varchar')).toBe('text[]'); + }); + + it('maps boolean type correctly', () => { + expect(mapPgTypeToFieldType('bool')).toBe('boolean'); + }); + + it('maps json types correctly', () => { + expect(mapPgTypeToFieldType('jsonb')).toBe('jsonb'); + expect(mapPgTypeToFieldType('json')).toBe('jsonb'); + expect(mapPgTypeToFieldType('_jsonb')).toBe('jsonb[]'); + }); + + it('maps integer types correctly', () => { + expect(mapPgTypeToFieldType('int2')).toBe('int'); + expect(mapPgTypeToFieldType('int4')).toBe('int'); + expect(mapPgTypeToFieldType('int8')).toBe('int'); + expect(mapPgTypeToFieldType('numeric')).toBe('int'); + }); + + it('maps temporal types correctly', () => { + expect(mapPgTypeToFieldType('interval')).toBe('interval'); + expect(mapPgTypeToFieldType('timestamptz')).toBe('timestamptz'); + expect(mapPgTypeToFieldType('timestamp')).toBe('timestamptz'); + }); + + it('falls back to text for unknown types', () => { + expect(mapPgTypeToFieldType('geometry')).toBe('text'); + expect(mapPgTypeToFieldType('unknown_array')).toBe('text'); + expect(mapPgTypeToFieldType('citext')).toBe('text'); + }); +}); + +// ============================================================================= +// mapGraphQLTypeToFieldType unit tests +// ============================================================================= + +describe('mapGraphQLTypeToFieldType', () => { + it('maps scalar types correctly', () => { + expect(mapGraphQLTypeToFieldType('UUID', false)).toBe('uuid'); + expect(mapGraphQLTypeToFieldType('ID', false)).toBe('uuid'); + expect(mapGraphQLTypeToFieldType('String', false)).toBe('text'); + expect(mapGraphQLTypeToFieldType('Boolean', false)).toBe('boolean'); + expect(mapGraphQLTypeToFieldType('Int', false)).toBe('int'); + expect(mapGraphQLTypeToFieldType('BigInt', false)).toBe('int'); + expect(mapGraphQLTypeToFieldType('BigFloat', false)).toBe('int'); + expect(mapGraphQLTypeToFieldType('Float', false)).toBe('int'); + expect(mapGraphQLTypeToFieldType('JSON', false)).toBe('jsonb'); + expect(mapGraphQLTypeToFieldType('Interval', false)).toBe('interval'); + expect(mapGraphQLTypeToFieldType('Datetime', false)).toBe('timestamptz'); + }); + + it('maps list types to array FieldTypes', () => { + expect(mapGraphQLTypeToFieldType('UUID', true)).toBe('uuid[]'); + expect(mapGraphQLTypeToFieldType('String', true)).toBe('text[]'); + expect(mapGraphQLTypeToFieldType('JSON', true)).toBe('jsonb[]'); + }); + + it('falls back to text for unsupported list types', () => { + expect(mapGraphQLTypeToFieldType('Boolean', true)).toBe('text'); + expect(mapGraphQLTypeToFieldType('Int', true)).toBe('text'); + }); + + it('falls back to text for unknown types', () => { + expect(mapGraphQLTypeToFieldType('SomeUnknownType', false)).toBe('text'); + }); +}); + +// ============================================================================= +// unwrapGraphQLType unit tests +// ============================================================================= + +describe('unwrapGraphQLType', () => { + it('unwraps a named type', () => { + const result = unwrapGraphQLType({ name: 'UUID', kind: 'SCALAR' }); + expect(result).toEqual({ typeName: 'UUID', kind: 'SCALAR', nonNull: false, list: false }); + }); + + it('unwraps NON_NULL wrapper', () => { + const result = unwrapGraphQLType({ + name: null, + kind: 'NON_NULL', + ofType: { name: 'UUID', kind: 'SCALAR' } + }); + expect(result).toEqual({ typeName: 'UUID', kind: 'SCALAR', nonNull: true, list: false }); + }); + + it('unwraps LIST wrapper', () => { + const result = unwrapGraphQLType({ + name: null, + kind: 'LIST', + ofType: { name: 'UUID', kind: 'SCALAR' } + }); + expect(result).toEqual({ typeName: 'UUID', kind: 'SCALAR', nonNull: false, list: true }); + }); + + it('unwraps NON_NULL(LIST(UUID)) — typical PostGraphile [UUID!]! pattern', () => { + const result = unwrapGraphQLType({ + name: null, + kind: 'NON_NULL', + ofType: { + name: null, + kind: 'LIST', + ofType: { name: 'UUID', kind: 'SCALAR' } + } + }); + // The leaf type is UUID, its immediate parent is LIST, so list=true. + // nonNull tracks the immediate parent kind of the leaf type, not the outermost wrapper. + expect(result).toEqual({ typeName: 'UUID', kind: 'SCALAR', nonNull: false, list: true }); + }); + + it('returns Unknown for null type ref', () => { + const result = unwrapGraphQLType(null); + expect(result).toEqual({ typeName: 'Unknown', kind: 'UNKNOWN', nonNull: false, list: false }); + }); + + it('returns Unknown for empty ofType chain', () => { + const result = unwrapGraphQLType({ name: null, kind: null, ofType: null }); + expect(result).toEqual({ typeName: 'Unknown', kind: 'UNKNOWN', nonNull: false, list: false }); + }); +}); + +// ============================================================================= +// getGraphQLTypeName unit tests +// ============================================================================= + +describe('getGraphQLTypeName', () => { + it('derives PascalCase singular type names from snake_case table names', () => { + expect(getGraphQLTypeName('database')).toBe('Database'); + expect(getGraphQLTypeName('schema')).toBe('Schema'); + expect(getGraphQLTypeName('foreign_key_constraint')).toBe('ForeignKeyConstraint'); + expect(getGraphQLTypeName('user_auth_module')).toBe('UserAuthModule'); + expect(getGraphQLTypeName('rls_function')).toBe('RlsFunction'); + }); +}); + +// ============================================================================= +// typeOverrides precedence test (logic-level, no DB needed) +// ============================================================================= + +describe('typeOverrides should take precedence over introspected types', () => { + it('META_TABLE_CONFIG entries with typeOverrides have correct override field types', () => { + // sites has typeOverrides for og_image, favicon, apple_touch_icon, logo + const sites = META_TABLE_CONFIG.sites; + expect(sites.typeOverrides).toBeDefined(); + expect(sites.typeOverrides!.og_image).toBe('image'); + expect(sites.typeOverrides!.favicon).toBe('upload'); + expect(sites.typeOverrides!.apple_touch_icon).toBe('image'); + expect(sites.typeOverrides!.logo).toBe('image'); + + // apps has typeOverrides for app_image, app_store_link, play_store_link + const apps = META_TABLE_CONFIG.apps; + expect(apps.typeOverrides).toBeDefined(); + expect(apps.typeOverrides!.app_image).toBe('image'); + expect(apps.typeOverrides!.app_store_link).toBe('url'); + expect(apps.typeOverrides!.play_store_link).toBe('url'); + + // site_metadata has typeOverrides for og_image + const siteMetadata = META_TABLE_CONFIG.site_metadata; + expect(siteMetadata.typeOverrides).toBeDefined(); + expect(siteMetadata.typeOverrides!.og_image).toBe('image'); + }); + + it('tables without typeOverrides should have no typeOverrides key', () => { + const database = META_TABLE_CONFIG.database; + expect(database.typeOverrides).toBeUndefined(); + + const field = META_TABLE_CONFIG.field; + expect(field.typeOverrides).toBeUndefined(); + }); +}); + +// ============================================================================= +// GraphQL type name derivation for all META_TABLE_CONFIG entries +// ============================================================================= + +describe('GraphQL type name derivation for all config entries', () => { + it('every META_TABLE_CONFIG entry should produce a non-empty GraphQL type name', () => { + for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { + const typeName = getGraphQLTypeName(config.table); + expect(typeName.length).toBeGreaterThan(0); + // Type names should be PascalCase (start with uppercase) + expect(typeName[0]).toBe(typeName[0].toUpperCase()); + } + }); + + it('every META_TABLE_CONFIG entry should produce a non-empty GraphQL query name', () => { + for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { + const queryName = getGraphQLQueryName(config.table); + expect(queryName.length).toBeGreaterThan(0); + // Query names should be camelCase (start with lowercase) + expect(queryName[0]).toBe(queryName[0].toLowerCase()); + } + }); +}); diff --git a/pgpm/export/__tests__/export-flow.test.ts b/pgpm/export/__tests__/export-flow.test.ts index b56eac78e..42bb9f175 100644 --- a/pgpm/export/__tests__/export-flow.test.ts +++ b/pgpm/export/__tests__/export-flow.test.ts @@ -206,7 +206,7 @@ const SCHEMA_SHIMS_SQL = ` sign_in_function text, sign_up_function text, sign_out_function text, - sign_in_one_time_token_function text, + sign_in_cross_origin_function text, one_time_token_function text, extend_token_expires text, send_account_deletion_email_function text, diff --git a/pgpm/export/__tests__/export-utils.test.ts b/pgpm/export/__tests__/export-utils.test.ts index 367c84915..7cda2784f 100644 --- a/pgpm/export/__tests__/export-utils.test.ts +++ b/pgpm/export/__tests__/export-utils.test.ts @@ -73,22 +73,23 @@ describe('META_TABLE_CONFIG and META_TABLE_ORDER consistency', () => { for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { expect(validSchemas).toContain(config.schema); expect(config.table).toBeTruthy(); - expect(Object.keys(config.fields).length).toBeGreaterThan(0); } }); - it('every config entry should have an id field of type uuid', () => { + it('config entries should not carry hardcoded `fields` (fully dynamic discovery)', () => { for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { - expect(config.fields).toHaveProperty('id'); - expect(config.fields.id).toBe('uuid'); + expect((config as { fields?: unknown }).fields).toBeUndefined(); } }); - it('every config entry (except database) should have a database_id field', () => { + it('typeOverrides should only contain valid FieldType values', () => { + const validFieldTypes: string[] = ['uuid', 'uuid[]', 'text', 'text[]', 'boolean', 'image', 'upload', 'url', 'jsonb', 'jsonb[]', 'int', 'interval', 'timestamptz']; for (const [key, config] of Object.entries(META_TABLE_CONFIG)) { - if (key === 'database') continue; - expect(config.fields).toHaveProperty('database_id'); - expect(config.fields.database_id).toBe('uuid'); + if (config.typeOverrides) { + for (const [fieldName, fieldType] of Object.entries(config.typeOverrides)) { + expect(validFieldTypes).toContain(fieldType); + } + } } }); }); diff --git a/pgpm/export/src/export-graphql-meta.ts b/pgpm/export/src/export-graphql-meta.ts index cfaae6146..84919d578 100644 --- a/pgpm/export/src/export-graphql-meta.ts +++ b/pgpm/export/src/export-graphql-meta.ts @@ -6,15 +6,19 @@ * generate SQL INSERT statements. */ import { Parser } from 'csv-to-pg'; +import { toSnakeCase } from 'inflekt'; -import { FieldType, META_TABLE_CONFIG } from './export-utils'; +import { FieldType, META_TABLE_CONFIG, TableConfig } from './export-utils'; import { GraphQLClient } from './graphql-client'; import { buildFieldsFragment, getGraphQLQueryName, + getGraphQLTypeName, graphqlRowToPostgresRow, - intervalToPostgres + intervalToPostgres, + mapGraphQLTypeToFieldType } from './graphql-naming'; +import { lookupByGqlType } from './type-map'; export interface ExportGraphQLMetaParams { /** GraphQL client configured for the meta/services API endpoint */ @@ -25,6 +29,86 @@ export interface ExportGraphQLMetaParams { export type ExportGraphQLMetaResult = Record; +/** + * Result of dynamic field discovery from GraphQL introspection. + * Includes the field type map and metadata needed for value normalization. + */ +interface DynamicFieldsResult { + /** Map of snake_case field name -> FieldType */ + fields: Record; + /** Set of snake_case field names that are ENUM-typed (need lowercase normalization) */ + enumFields: Set; +} + +/** + * Discover fields dynamically from a GraphQL type via introspection. + * Queries `__type` to enumerate fields and infer their `FieldType` via + * `mapGraphQLTypeToFieldType`. Tracks ENUM fields separately so callers + * can normalize their CONSTANT_CASE values back to lowercase. + * + * `typeOverrides` from the config are applied on top for special types + * (image, upload, url) that cannot be inferred from the GraphQL type alone. + */ +const buildDynamicFieldsFromGraphQL = async ( + client: GraphQLClient, + tableConfig: TableConfig +): Promise => { + const emptyResult: DynamicFieldsResult = { fields: {}, enumFields: new Set() }; + + const typeName = getGraphQLTypeName(tableConfig.table); + + try { + const introspectedFields = await client.introspectType(typeName); + + const dynamicFields: Record = {}; + const enumFields = new Set(); + for (const [camelName, typeInfo] of introspectedFields) { + const snakeName = toSnakeCase(camelName); + // Skip internal GraphQL fields + if (camelName.startsWith('__')) continue; + // Track enum fields for lowercase normalization (custom inflector uppercases them) + if (typeInfo.kind === 'ENUM') { + enumFields.add(snakeName); + } + // Skip non-scalar fields (relations/computed columns like "database" of type Database) + // Only SCALAR and ENUM kinds can be selected without sub-field selections + // EXCEPTION: types registered as non-SCALAR in PG_TYPE_MAP (e.g. Interval=OBJECT) + // are handled via buildFieldsFragment sub-selections and intervalToPostgres conversion + if (typeInfo.kind !== 'SCALAR' && typeInfo.kind !== 'ENUM') { + const mapEntry = lookupByGqlType(typeInfo.typeName); + if (mapEntry && mapEntry.gqlKind !== 'SCALAR') { + dynamicFields[snakeName] = mapEntry.fieldType; + continue; + } + continue; + } + dynamicFields[snakeName] = mapGraphQLTypeToFieldType(typeInfo.typeName, typeInfo.list); + } + + // Apply type overrides (e.g., image, upload, url) + if (tableConfig.typeOverrides) { + for (const [fieldName, fieldType] of Object.entries(tableConfig.typeOverrides)) { + if (dynamicFields[fieldName]) { + dynamicFields[fieldName] = fieldType; + } + } + } + + return { fields: dynamicFields, enumFields }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes('Cannot query field') || + message.includes('is not defined by type') || + message.includes('Unknown field') + ) { + // Type not available in the GraphQL schema — return empty + return emptyResult; + } + throw err; + } +}; + /** * Fetch metadata via GraphQL and generate SQL INSERT statements. * This is the GraphQL equivalent of exportMeta() in export-meta.ts. @@ -39,8 +123,12 @@ export const exportGraphQLMeta = async ({ const tableConfig = META_TABLE_CONFIG[key]; if (!tableConfig) return; - const pgFieldNames = Object.keys(tableConfig.fields); - const graphqlFieldsFragment = buildFieldsFragment(pgFieldNames, tableConfig.fields); + // Build fields dynamically: either from hardcoded config or via introspection + const { fields: configFields, enumFields } = await buildDynamicFieldsFromGraphQL(client, tableConfig); + if (Object.keys(configFields).length === 0) return; + + const pgFieldNames = Object.keys(configFields); + const graphqlFieldsFragment = buildFieldsFragment(pgFieldNames, configFields); const graphqlQueryName = getGraphQLQueryName(tableConfig.table); // The 'database' table is fetched by id, not by database_id @@ -58,13 +146,30 @@ export const exportGraphQLMeta = async ({ if (rows.length > 0) { // Convert camelCase GraphQL keys back to snake_case for the Parser // Also convert interval objects back to Postgres interval strings + // and normalize enum values from CONSTANT_CASE back to lowercase const pgRows = rows.map(row => { const pgRow = graphqlRowToPostgresRow(row); - // Convert any interval fields from {seconds, minutes, ...} objects to strings - for (const [fieldName, fieldType] of Object.entries(tableConfig.fields)) { + for (const [fieldName, fieldType] of Object.entries(configFields)) { + // Convert interval fields from {seconds, minutes, ...} objects to strings if (fieldType === 'interval' && pgRow[fieldName] && typeof pgRow[fieldName] === 'object') { pgRow[fieldName] = intervalToPostgres(pgRow[fieldName] as Record); } + // Truncate timestamptz to second precision for parity with SQL flow + // PostGraphile's Datetime scalar preserves full millisecond precision, + // but the pg driver + our SQL flow truncates to .000Z via Date rounding + if (fieldType === 'timestamptz' && typeof pgRow[fieldName] === 'string') { + const d = new Date(pgRow[fieldName] as string); + if (!isNaN(d.getTime())) { + pgRow[fieldName] = new Date(Math.floor(d.getTime() / 1000) * 1000).toISOString(); + } + } + } + // Normalize enum values: custom inflector uppercases them to CONSTANT_CASE, + // but PostgreSQL stores them in lowercase — convert back for parity with SQL flow + for (const fieldName of enumFields) { + if (typeof pgRow[fieldName] === 'string') { + pgRow[fieldName] = (pgRow[fieldName] as string).toLowerCase(); + } } return pgRow; }); @@ -79,7 +184,7 @@ export const exportGraphQLMeta = async ({ } const dynamicFields: Record = {}; - for (const [fieldName, fieldType] of Object.entries(tableConfig.fields)) { + for (const [fieldName, fieldType] of Object.entries(configFields)) { if (returnedKeys.has(fieldName)) { dynamicFields[fieldName] = fieldType; } diff --git a/pgpm/export/src/export-meta.ts b/pgpm/export/src/export-meta.ts index 02c0b53ad..ac8ef7461 100644 --- a/pgpm/export/src/export-meta.ts +++ b/pgpm/export/src/export-meta.ts @@ -3,7 +3,7 @@ import { Parser } from 'csv-to-pg'; import { getPgPool } from 'pg-cache'; import type { Pool } from 'pg'; -import { FieldType, TableConfig, META_TABLE_CONFIG } from './export-utils'; +import { FieldType, TableConfig, META_TABLE_CONFIG, mapPgTypeToFieldType } from './export-utils'; /** * Query actual columns from information_schema for a given table. @@ -25,10 +25,10 @@ const getTableColumns = async (pool: Pool, schemaName: string, tableName: string }; /** - * Build dynamic fields config by intersecting the hardcoded config with actual database columns. - * - Only includes columns that exist in the database - * - Preserves special type hints from config (image, upload, url) for columns that exist - * - Infers types from PostgreSQL for columns not in config + * Build dynamic fields config from the database via information_schema. + * - All fields are derived from `information_schema.columns` + `mapPgTypeToFieldType`. + * - `typeOverrides` from the config are applied on top for special types + * (image, upload, url) that cannot be inferred from PG types alone. */ const buildDynamicFields = async ( pool: Pool, @@ -43,13 +43,18 @@ const buildDynamicFields = async ( const dynamicFields: Record = {}; - // For each column in the hardcoded config, check if it exists in the database - for (const [fieldName, fieldType] of Object.entries(tableConfig.fields)) { - if (actualColumns.has(fieldName)) { - // Column exists - use the config's type hint (preserves special types like 'image', 'upload', 'url') - dynamicFields[fieldName] = fieldType; + // Derive all fields from information_schema + for (const [columnName, udtName] of actualColumns) { + dynamicFields[columnName] = mapPgTypeToFieldType(udtName); + } + + // Apply type overrides (image, upload, url) + if (tableConfig.typeOverrides) { + for (const [fieldName, fieldType] of Object.entries(tableConfig.typeOverrides)) { + if (dynamicFields[fieldName]) { + dynamicFields[fieldName] = fieldType; + } } - // If column doesn't exist in database, skip it (this fixes the bug) } return dynamicFields; @@ -70,8 +75,9 @@ export const exportMeta = async ({ opts, dbname, database_id }: ExportMetaParams }); const sql: Record = {}; - // Cache for dynamically built parsers + // Cache for dynamically built parsers and their field configs const parsers: Record = {}; + const parserFields: Record> = {}; // Build parser dynamically by querying actual columns from the database const getParser = async (key: string): Promise => { @@ -92,6 +98,8 @@ export const exportMeta = async ({ opts, dbname, database_id }: ExportMetaParams return null; } + parserFields[key] = dynamicFields; + const parser = new Parser({ schema: tableConfig.schema, table: tableConfig.table, @@ -112,6 +120,24 @@ export const exportMeta = async ({ opts, dbname, database_id }: ExportMetaParams const result = await pool.query(query, [database_id]); if (result.rows.length) { + // Truncate timestamptz to second precision to match PostGraphile's Datetime scalar + // which truncates milliseconds in the GraphQL flow + const fields = parserFields[key]; + if (fields) { + for (const row of result.rows) { + for (const [fieldName, fieldType] of Object.entries(fields)) { + if (fieldType === 'timestamptz') { + const val = row[fieldName]; + if (val instanceof Date) { + // Truncate to second precision and convert to ISO string + // so both SQL and GraphQL flows pass the same value type to the Parser + row[fieldName] = new Date(Math.floor(val.getTime() / 1000) * 1000).toISOString(); + } + } + } + } + } + const parsed = await parser.parse(result.rows); if (parsed) { sql[key] = parsed; diff --git a/pgpm/export/src/export-utils.ts b/pgpm/export/src/export-utils.ts index 9f8c3b095..7703e5b84 100644 --- a/pgpm/export/src/export-utils.ts +++ b/pgpm/export/src/export-utils.ts @@ -5,6 +5,7 @@ import { toSnakeCase } from 'inflekt'; import path from 'path'; import { PgpmPackage, getMissingInstallableModules, parseAuthor } from '@pgpmjs/core'; +import { lookupByPgUdt } from './type-map'; // ============================================================================= // Shared constants @@ -43,41 +44,11 @@ export const DB_REQUIRED_EXTENSIONS = [ /** * Map PostgreSQL data types to FieldType values. * Uses udt_name from information_schema which gives the base type name. + * Delegates to the canonical PG_TYPE_MAP in type-map.ts. */ -const mapPgTypeToFieldType = (udtName: string): FieldType => { - switch (udtName) { - case 'uuid': - return 'uuid'; - case '_uuid': - return 'uuid[]'; - case 'text': - case 'varchar': - case 'bpchar': - case 'name': - return 'text'; - case '_text': - case '_varchar': - return 'text[]'; - case 'bool': - return 'boolean'; - case 'jsonb': - case 'json': - return 'jsonb'; - case '_jsonb': - return 'jsonb[]'; - case 'int4': - case 'int8': - case 'int2': - case 'numeric': - return 'int'; - case 'interval': - return 'interval'; - case 'timestamptz': - case 'timestamp': - return 'timestamptz'; - default: - return 'text'; - } +export const mapPgTypeToFieldType = (udtName: string): FieldType => { + const entry = lookupByPgUdt(udtName); + return entry?.fieldType ?? 'text'; }; /** @@ -210,19 +181,19 @@ export interface TableConfig { schema: string; table: string; conflictDoNothing?: boolean; - fields: Record; + typeOverrides?: Record; // only for special types (image, upload, url) that can't be inferred } /** * Shared metadata table configuration. * - * This is the **superset** of fields needed by both the SQL export flow - * (export-meta.ts) and the GraphQL export flow (export-graphql-meta.ts). - * Each flow dynamically filters to only the fields that actually exist: - * - SQL flow: uses buildDynamicFields() to intersect with information_schema - * - GraphQL flow: filters to fields present in the returned data + * Fields are discovered dynamically at runtime via introspection: + * - SQL flow: uses information_schema.columns + mapPgTypeToFieldType() + * - GraphQL flow: uses __type introspection + mapGraphQLTypeToFieldType() + * + * Only `typeOverrides` are hardcoded for special types (image, upload, url) + * that cannot be inferred from database/GraphQL types alone. * - * Adding a field here that doesn't exist in a particular environment is safe. */ export const META_TABLE_CONFIG: Record = { // ============================================================================= @@ -230,261 +201,88 @@ export const META_TABLE_CONFIG: Record = { // ============================================================================= database: { schema: 'metaschema_public', - table: 'database', - fields: { - id: 'uuid', - owner_id: 'uuid', - name: 'text', - hash: 'uuid' - } + table: 'database' }, schema: { schema: 'metaschema_public', - table: 'schema', - fields: { - id: 'uuid', - database_id: 'uuid', - name: 'text', - schema_name: 'text', - description: 'text', - is_public: 'boolean' - } + table: 'schema' }, function: { schema: 'metaschema_public', - table: 'function', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - name: 'text' - } + table: 'function' }, table: { schema: 'metaschema_public', - table: 'table', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - name: 'text', - description: 'text' - } + table: 'table' }, field: { schema: 'metaschema_public', table: 'field', - // Use ON CONFLICT DO NOTHING to handle the unique constraint (databases_field_uniq_names_idx) - // which normalizes UUID field names by stripping suffixes like _id, _uuid, etc. - // This causes collisions when tables have both 'foo' (text) and 'foo_id' (uuid) columns. - conflictDoNothing: true, - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - type: 'text', - description: 'text' - } + conflictDoNothing: true }, policy: { schema: 'metaschema_public', - table: 'policy', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - grantee_name: 'text', - privilege: 'text', - permissive: 'boolean', - disabled: 'boolean', - policy_type: 'text', - data: 'jsonb' - } + table: 'policy' }, index: { schema: 'metaschema_public', - table: 'index', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - field_ids: 'uuid[]', - include_field_ids: 'uuid[]', - access_method: 'text', - index_params: 'jsonb', - where_clause: 'jsonb', - is_unique: 'boolean' - } + table: 'index' }, trigger: { schema: 'metaschema_public', - table: 'trigger', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - event: 'text', - function_name: 'text' - } + table: 'trigger' }, trigger_function: { schema: 'metaschema_public', - table: 'trigger_function', - fields: { - id: 'uuid', - database_id: 'uuid', - name: 'text', - code: 'text' - } + table: 'trigger_function' }, rls_function: { schema: 'metaschema_public', - table: 'rls_function', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - label: 'text', - description: 'text', - data: 'jsonb', - inline: 'boolean', - security: 'int' - } + table: 'rls_function' }, foreign_key_constraint: { schema: 'metaschema_public', - table: 'foreign_key_constraint', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - description: 'text', - smart_tags: 'jsonb', - type: 'text', - field_ids: 'uuid[]', - ref_table_id: 'uuid', - ref_field_ids: 'uuid[]', - delete_action: 'text', - update_action: 'text' - } + table: 'foreign_key_constraint' }, primary_key_constraint: { schema: 'metaschema_public', - table: 'primary_key_constraint', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - type: 'text', - field_ids: 'uuid[]' - } + table: 'primary_key_constraint' }, unique_constraint: { schema: 'metaschema_public', - table: 'unique_constraint', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - description: 'text', - smart_tags: 'jsonb', - type: 'text', - field_ids: 'uuid[]' - } + table: 'unique_constraint' }, check_constraint: { schema: 'metaschema_public', - table: 'check_constraint', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - name: 'text', - type: 'text', - field_ids: 'uuid[]', - expr: 'jsonb' - } + table: 'check_constraint' }, full_text_search: { schema: 'metaschema_public', - table: 'full_text_search', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - field_id: 'uuid', - field_ids: 'uuid[]', - weights: 'text[]', - langs: 'text[]' - } + table: 'full_text_search' }, schema_grant: { schema: 'metaschema_public', - table: 'schema_grant', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - grantee_name: 'text' - } + table: 'schema_grant' }, table_grant: { schema: 'metaschema_public', - table: 'table_grant', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - privilege: 'text', - grantee_name: 'text', - field_ids: 'uuid[]', - is_grant: 'boolean' - } + table: 'table_grant' }, default_privilege: { schema: 'metaschema_public', - table: 'default_privilege', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - object_type: 'text', - privilege: 'text', - grantee_name: 'text', - is_grant: 'boolean' - } + table: 'default_privilege' }, // ============================================================================= // services_public tables // ============================================================================= domains: { schema: 'services_public', - table: 'domains', - fields: { - id: 'uuid', - database_id: 'uuid', - site_id: 'uuid', - api_id: 'uuid', - domain: 'text', - subdomain: 'text' - } + table: 'domains' }, sites: { schema: 'services_public', table: 'sites', - fields: { - id: 'uuid', - database_id: 'uuid', - title: 'text', - description: 'text', + typeOverrides: { og_image: 'image', favicon: 'upload', apple_touch_icon: 'image', @@ -493,987 +291,238 @@ export const META_TABLE_CONFIG: Record = { }, apis: { schema: 'services_public', - table: 'apis', - fields: { - id: 'uuid', - database_id: 'uuid', - name: 'text', - is_public: 'boolean', - role_name: 'text', - anon_role: 'text' - } + table: 'apis' }, apps: { schema: 'services_public', table: 'apps', - fields: { - id: 'uuid', - database_id: 'uuid', - site_id: 'uuid', - name: 'text', + typeOverrides: { app_image: 'image', app_store_link: 'url', - app_store_id: 'text', - app_id_prefix: 'text', play_store_link: 'url' } }, site_modules: { schema: 'services_public', - table: 'site_modules', - fields: { - id: 'uuid', - database_id: 'uuid', - site_id: 'uuid', - name: 'text', - data: 'jsonb' - } + table: 'site_modules' }, site_themes: { schema: 'services_public', - table: 'site_themes', - fields: { - id: 'uuid', - database_id: 'uuid', - site_id: 'uuid', - theme: 'jsonb' - } + table: 'site_themes' }, site_metadata: { schema: 'services_public', table: 'site_metadata', - fields: { - id: 'uuid', - database_id: 'uuid', - site_id: 'uuid', - title: 'text', - description: 'text', + typeOverrides: { og_image: 'image' } }, api_modules: { schema: 'services_public', - table: 'api_modules', - fields: { - id: 'uuid', - database_id: 'uuid', - api_id: 'uuid', - name: 'text', - data: 'jsonb' - } + table: 'api_modules' }, api_extensions: { schema: 'services_public', - table: 'api_extensions', - fields: { - id: 'uuid', - database_id: 'uuid', - api_id: 'uuid', - name: 'text' - } + table: 'api_extensions' }, api_schemas: { schema: 'services_public', - table: 'api_schemas', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - api_id: 'uuid' - } + table: 'api_schemas' }, database_settings: { schema: 'services_public', - table: 'database_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - enable_aggregates: 'boolean', - enable_postgis: 'boolean', - enable_search: 'boolean', - enable_direct_uploads: 'boolean', - enable_presigned_uploads: 'boolean', - enable_many_to_many: 'boolean', - enable_connection_filter: 'boolean', - enable_ltree: 'boolean', - enable_llm: 'boolean', - enable_bulk: 'boolean', - options: 'jsonb' - } + table: 'database_settings' }, api_settings: { schema: 'services_public', - table: 'api_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - api_id: 'uuid', - enable_aggregates: 'boolean', - enable_postgis: 'boolean', - enable_search: 'boolean', - enable_direct_uploads: 'boolean', - enable_presigned_uploads: 'boolean', - enable_many_to_many: 'boolean', - enable_connection_filter: 'boolean', - enable_ltree: 'boolean', - enable_llm: 'boolean', - enable_bulk: 'boolean', - options: 'jsonb' - } + table: 'api_settings' }, rls_settings: { schema: 'services_public', - table: 'rls_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - authenticate_schema_id: 'uuid', - role_schema_id: 'uuid', - authenticate_function_id: 'uuid', - authenticate_strict_function_id: 'uuid', - current_role_function_id: 'uuid', - current_role_id_function_id: 'uuid', - current_user_agent_function_id: 'uuid', - current_ip_address_function_id: 'uuid' - } + table: 'rls_settings' }, cors_settings: { schema: 'services_public', - table: 'cors_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - api_id: 'uuid', - allowed_origins: 'text[]' - } + table: 'cors_settings' }, pubkey_settings: { schema: 'services_public', - table: 'pubkey_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - crypto_network: 'text', - user_field: 'text', - sign_up_with_key_function_id: 'uuid', - sign_in_request_challenge_function_id: 'uuid', - sign_in_record_failure_function_id: 'uuid', - sign_in_with_challenge_function_id: 'uuid' - } + table: 'pubkey_settings' }, webauthn_settings: { schema: 'services_public', - table: 'webauthn_settings', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - credentials_schema_id: 'uuid', - sessions_schema_id: 'uuid', - session_secrets_schema_id: 'uuid', - credentials_table_id: 'uuid', - sessions_table_id: 'uuid', - session_credentials_table_id: 'uuid', - session_secrets_table_id: 'uuid', - user_field_id: 'uuid', - rp_id: 'text', - rp_name: 'text', - origin_allowlist: 'text[]', - attestation_type: 'text', - require_user_verification: 'boolean', - resident_key: 'text', - challenge_expiry_seconds: 'int' - } + table: 'webauthn_settings' }, // ============================================================================= // metaschema_modules_public tables // ============================================================================= rls_module: { schema: 'metaschema_modules_public', - table: 'rls_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - session_credentials_table_id: 'uuid', - sessions_table_id: 'uuid', - users_table_id: 'uuid', - authenticate: 'text', - authenticate_strict: 'text', - current_role: 'text', - current_role_id: 'text' - } + table: 'rls_module' }, user_auth_module: { schema: 'metaschema_modules_public', - table: 'user_auth_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - emails_table_id: 'uuid', - users_table_id: 'uuid', - secrets_table_id: 'uuid', - encrypted_table_id: 'uuid', - sessions_table_id: 'uuid', - session_credentials_table_id: 'uuid', - audits_table_id: 'uuid', - audits_table_name: 'text', - sign_in_function: 'text', - sign_up_function: 'text', - sign_out_function: 'text', - sign_in_cross_origin_function: 'text', - request_cross_origin_token_function: 'text', - extend_token_expires: 'text', - send_account_deletion_email_function: 'text', - delete_account_function: 'text', - set_password_function: 'text', - reset_password_function: 'text', - forgot_password_function: 'text', - send_verification_email_function: 'text', - verify_email_function: 'text', - verify_password_function: 'text', - check_password_function: 'text' - } + table: 'user_auth_module' }, memberships_module: { schema: 'metaschema_modules_public', - table: 'memberships_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - memberships_table_id: 'uuid', - memberships_table_name: 'text', - members_table_id: 'uuid', - members_table_name: 'text', - membership_defaults_table_id: 'uuid', - membership_defaults_table_name: 'text', - grants_table_id: 'uuid', - grants_table_name: 'text', - actor_table_id: 'uuid', - limits_table_id: 'uuid', - default_limits_table_id: 'uuid', - permissions_table_id: 'uuid', - default_permissions_table_id: 'uuid', - sprt_table_id: 'uuid', - admin_grants_table_id: 'uuid', - admin_grants_table_name: 'text', - owner_grants_table_id: 'uuid', - owner_grants_table_name: 'text', - membership_type: 'int', - entity_table_id: 'uuid', - entity_table_owner_id: 'uuid', - prefix: 'text', - actor_mask_check: 'text', - actor_perm_check: 'text', - entity_ids_by_mask: 'text', - entity_ids_by_perm: 'text', - entity_ids_function: 'text' - } + table: 'memberships_module' }, permissions_module: { schema: 'metaschema_modules_public', - table: 'permissions_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - default_table_id: 'uuid', - default_table_name: 'text', - bitlen: 'int', - membership_type: 'int', - entity_table_id: 'uuid', - actor_table_id: 'uuid', - prefix: 'text', - get_padded_mask: 'text', - get_mask: 'text', - get_by_mask: 'text', - get_mask_by_name: 'text' - } + table: 'permissions_module' }, limits_module: { schema: 'metaschema_modules_public', - table: 'limits_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - default_table_id: 'uuid', - default_table_name: 'text', - limit_increment_function: 'text', - limit_decrement_function: 'text', - limit_increment_trigger: 'text', - limit_decrement_trigger: 'text', - limit_update_trigger: 'text', - limit_check_function: 'text', - prefix: 'text', - membership_type: 'int', - entity_table_id: 'uuid', - actor_table_id: 'uuid' - } + table: 'limits_module' }, levels_module: { schema: 'metaschema_modules_public', - table: 'levels_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - steps_table_id: 'uuid', - steps_table_name: 'text', - achievements_table_id: 'uuid', - achievements_table_name: 'text', - levels_table_id: 'uuid', - levels_table_name: 'text', - level_requirements_table_id: 'uuid', - level_requirements_table_name: 'text', - completed_step: 'text', - incompleted_step: 'text', - tg_achievement: 'text', - tg_achievement_toggle: 'text', - tg_achievement_toggle_boolean: 'text', - tg_achievement_boolean: 'text', - upsert_achievement: 'text', - tg_update_achievements: 'text', - steps_required: 'text', - level_achieved: 'text', - prefix: 'text', - membership_type: 'int', - entity_table_id: 'uuid', - actor_table_id: 'uuid' - } + table: 'levels_module' }, users_module: { schema: 'metaschema_modules_public', - table: 'users_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - type_table_id: 'uuid', - type_table_name: 'text' - } + table: 'users_module' }, hierarchy_module: { schema: 'metaschema_modules_public', - table: 'hierarchy_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - chart_edges_table_id: 'uuid', - chart_edges_table_name: 'text', - hierarchy_sprt_table_id: 'uuid', - hierarchy_sprt_table_name: 'text', - chart_edge_grants_table_id: 'uuid', - chart_edge_grants_table_name: 'text', - entity_table_id: 'uuid', - users_table_id: 'uuid', - prefix: 'text', - private_schema_name: 'text', - sprt_table_name: 'text', - rebuild_hierarchy_function: 'text', - get_subordinates_function: 'text', - get_managers_function: 'text', - is_manager_of_function: 'text' - } + table: 'hierarchy_module' }, membership_types_module: { schema: 'metaschema_modules_public', - table: 'membership_types_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text' - } + table: 'membership_types_module' }, invites_module: { schema: 'metaschema_modules_public', - table: 'invites_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - emails_table_id: 'uuid', - users_table_id: 'uuid', - invites_table_id: 'uuid', - claimed_invites_table_id: 'uuid', - invites_table_name: 'text', - claimed_invites_table_name: 'text', - submit_invite_code_function: 'text', - prefix: 'text', - membership_type: 'int', - entity_table_id: 'uuid' - } + table: 'invites_module' }, emails_module: { schema: 'metaschema_modules_public', - table: 'emails_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text' - } + table: 'emails_module' }, sessions_module: { schema: 'metaschema_modules_public', - table: 'sessions_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - sessions_table_id: 'uuid', - session_credentials_table_id: 'uuid', - auth_settings_table_id: 'uuid', - users_table_id: 'uuid', - sessions_default_expiration: 'interval', - sessions_table: 'text', - session_credentials_table: 'text', - auth_settings_table: 'text' - } + table: 'sessions_module' }, user_state_module: { schema: 'metaschema_modules_public', - table: 'user_state_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text' - } + table: 'user_state_module' }, profiles_module: { schema: 'metaschema_modules_public', - table: 'profiles_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - profile_permissions_table_id: 'uuid', - profile_permissions_table_name: 'text', - profile_grants_table_id: 'uuid', - profile_grants_table_name: 'text', - profile_definition_grants_table_id: 'uuid', - profile_definition_grants_table_name: 'text', - membership_type: 'int', - entity_table_id: 'uuid', - actor_table_id: 'uuid', - permissions_table_id: 'uuid', - memberships_table_id: 'uuid', - prefix: 'text' - } + table: 'profiles_module' }, config_secrets_user_module: { schema: 'metaschema_modules_public', - table: 'config_secrets_user_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text' - } + table: 'config_secrets_user_module' }, connected_accounts_module: { schema: 'metaschema_modules_public', - table: 'connected_accounts_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text' - } + table: 'connected_accounts_module' }, phone_numbers_module: { schema: 'metaschema_modules_public', - table: 'phone_numbers_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text' - } + table: 'phone_numbers_module' }, crypto_addresses_module: { schema: 'metaschema_modules_public', - table: 'crypto_addresses_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text', - crypto_network: 'text' - } + table: 'crypto_addresses_module' }, crypto_auth_module: { schema: 'metaschema_modules_public', - table: 'crypto_auth_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - users_table_id: 'uuid', - sessions_table_id: 'uuid', - session_credentials_table_id: 'uuid', - secrets_table_id: 'uuid', - addresses_table_id: 'uuid', - user_field: 'text', - crypto_network: 'text', - sign_in_request_challenge: 'text', - sign_in_record_failure: 'text', - sign_up_with_key: 'text', - sign_in_with_challenge: 'text' - } + table: 'crypto_auth_module' }, field_module: { schema: 'metaschema_modules_public', - table: 'field_module', - fields: { - id: 'uuid', - database_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - field_id: 'uuid', - node_type: 'text', - data: 'jsonb', - triggers: 'text[]', - functions: 'text[]' - } + table: 'field_module' }, table_module: { schema: 'metaschema_modules_public', - table: 'table_module', - fields: { - id: 'uuid', - database_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - node_type: 'text', - data: 'jsonb', - fields: 'uuid[]' - } + table: 'table_module' }, table_template_module: { schema: 'metaschema_modules_public', - table: 'table_template_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text', - node_type: 'text', - data: 'jsonb' - } + table: 'table_template_module' }, secure_table_provision: { schema: 'metaschema_modules_public', - table: 'secure_table_provision', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - nodes: 'jsonb', - use_rls: 'boolean', - fields: 'jsonb[]', - grants: 'jsonb', - policies: 'jsonb', - out_fields: 'uuid[]' - } + table: 'secure_table_provision' }, uuid_module: { schema: 'metaschema_modules_public', - table: 'uuid_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - uuid_function: 'text', - uuid_seed: 'text' - } + table: 'uuid_module' }, default_ids_module: { schema: 'metaschema_modules_public', - table: 'default_ids_module', - fields: { - id: 'uuid', - database_id: 'uuid' - } + table: 'default_ids_module' }, denormalized_table_field: { schema: 'metaschema_modules_public', - table: 'denormalized_table_field', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - field_id: 'uuid', - set_ids: 'uuid[]', - ref_table_id: 'uuid', - ref_field_id: 'uuid', - ref_ids: 'uuid[]', - use_updates: 'boolean', - update_defaults: 'boolean', - func_name: 'text', - func_order: 'int' - } + table: 'denormalized_table_field' }, relation_provision: { schema: 'metaschema_modules_public', - table: 'relation_provision', - fields: { - id: 'uuid', - database_id: 'uuid', - relation_type: 'text', - source_table_id: 'uuid', - target_table_id: 'uuid', - field_name: 'text', - delete_action: 'text', - is_required: 'boolean', - api_required: 'boolean', - junction_table_id: 'uuid', - junction_table_name: 'text', - junction_schema_id: 'uuid', - source_field_name: 'text', - target_field_name: 'text', - use_composite_key: 'boolean', - create_index: 'boolean', - expose_in_api: 'boolean', - nodes: 'jsonb', - grants: 'jsonb', - policies: 'jsonb', - out_field_id: 'uuid', - out_junction_table_id: 'uuid', - out_source_field_id: 'uuid', - out_target_field_id: 'uuid' - } + table: 'relation_provision' }, - rate_limits_module: { + entity_type_provision: { schema: 'metaschema_modules_public', - table: 'rate_limits_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - rate_limit_settings_table_id: 'uuid', - ip_rate_limits_table_id: 'uuid', - rate_limits_table_id: 'uuid', - rate_limit_settings_table: 'text', - ip_rate_limits_table: 'text', - rate_limits_table: 'text' - } + table: 'entity_type_provision' }, - storage_module: { + rate_limits_module: { schema: 'metaschema_modules_public', - table: 'storage_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - buckets_table_id: 'uuid', - files_table_id: 'uuid', - buckets_table_name: 'text', - files_table_name: 'text', - membership_type: 'int', - policies: 'jsonb', - skip_default_policy_tables: 'text[]', - entity_table_id: 'uuid', - endpoint: 'text', - public_url_prefix: 'text', - provider: 'text', - allowed_origins: 'text[]', - restrict_reads: 'boolean', - has_path_shares: 'boolean', - path_shares_table_id: 'uuid', - upload_url_expiry_seconds: 'int', - download_url_expiry_seconds: 'int', - max_filename_length: 'int', - cache_ttl_seconds: 'int', - max_bulk_files: 'int', - has_versioning: 'boolean', - has_content_hash: 'boolean', - has_custom_keys: 'boolean', - has_audit_log: 'boolean', - file_events_table_id: 'uuid' - } + table: 'rate_limits_module' }, - entity_type_provision: { + storage_module: { schema: 'metaschema_modules_public', - table: 'entity_type_provision', - fields: { - id: 'uuid', - database_id: 'uuid', - name: 'text', - prefix: 'text', - description: 'text', - parent_entity: 'text', - table_name: 'text', - is_visible: 'boolean', - has_limits: 'boolean', - has_profiles: 'boolean', - has_levels: 'boolean', - has_storage: 'boolean', - has_invites: 'boolean', - storage_config: 'jsonb', - skip_entity_policies: 'boolean', - table_provision: 'jsonb', - out_membership_type: 'int', - out_entity_table_id: 'uuid', - out_entity_table_name: 'text', - out_installed_modules: 'text[]', - out_storage_module_id: 'uuid', - out_buckets_table_id: 'uuid', - out_files_table_id: 'uuid', - out_path_shares_table_id: 'uuid', - out_invites_module_id: 'uuid' - } + table: 'storage_module' }, billing_module: { schema: 'metaschema_modules_public', - table: 'billing_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - meters_table_id: 'uuid', - meters_table_name: 'text', - plan_subscriptions_table_id: 'uuid', - plan_subscriptions_table_name: 'text', - ledger_table_id: 'uuid', - ledger_table_name: 'text', - balances_table_id: 'uuid', - balances_table_name: 'text', - record_usage_function: 'text', - prefix: 'text' - } + table: 'billing_module' }, billing_provider_module: { schema: 'metaschema_modules_public', - table: 'billing_provider_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - provider: 'text', - products_table_id: 'uuid', - prices_table_id: 'uuid', - subscriptions_table_id: 'uuid', - billing_customers_table_id: 'uuid', - billing_customers_table_name: 'text', - billing_products_table_id: 'uuid', - billing_products_table_name: 'text', - billing_prices_table_id: 'uuid', - billing_prices_table_name: 'text', - billing_subscriptions_table_id: 'uuid', - billing_subscriptions_table_name: 'text', - billing_webhook_events_table_id: 'uuid', - billing_webhook_events_table_name: 'text', - process_billing_event_function: 'text', - prefix: 'text' - } + table: 'billing_provider_module' }, devices_module: { schema: 'metaschema_modules_public', - table: 'devices_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - user_devices_table_id: 'uuid', - device_settings_table_id: 'uuid', - user_devices_table: 'text', - device_settings_table: 'text' - } + table: 'devices_module' }, identity_providers_module: { schema: 'metaschema_modules_public', - table: 'identity_providers_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text' - } + table: 'identity_providers_module' }, notifications_module: { schema: 'metaschema_modules_public', - table: 'notifications_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - notifications_table_id: 'uuid', - read_state_table_id: 'uuid', - preferences_table_id: 'uuid', - channels_table_id: 'uuid', - delivery_log_table_id: 'uuid', - owner_table_id: 'uuid', - user_settings_table_id: 'uuid', - organization_settings_table_id: 'uuid', - has_channels: 'boolean', - has_preferences: 'boolean', - has_settings_extension: 'boolean', - has_digest_metadata: 'boolean', - has_subscriptions: 'boolean' - } + table: 'notifications_module' }, plans_module: { schema: 'metaschema_modules_public', - table: 'plans_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - plans_table_id: 'uuid', - plans_table_name: 'text', - plan_limits_table_id: 'uuid', - plan_limits_table_name: 'text', - plan_pricing_table_id: 'uuid', - plan_overrides_table_id: 'uuid', - apply_plan_function: 'text', - apply_plan_aggregate_function: 'text', - prefix: 'text' - } + table: 'plans_module' }, realtime_module: { schema: 'metaschema_modules_public', - table: 'realtime_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - subscriptions_schema_id: 'uuid', - change_log_table_id: 'uuid', - listener_node_table_id: 'uuid', - source_registry_table_id: 'uuid', - retention_hours: 'int', - lookahead_hours: 'int', - partition_interval: 'text', - notify_channel: 'text' - } + table: 'realtime_module' }, session_secrets_module: { schema: 'metaschema_modules_public', - table: 'session_secrets_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text', - sessions_table_id: 'uuid' - } + table: 'session_secrets_module' }, config_secrets_org_module: { schema: 'metaschema_modules_public', - table: 'config_secrets_org_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - table_id: 'uuid', - table_name: 'text' - } + table: 'config_secrets_org_module' }, webauthn_auth_module: { schema: 'metaschema_modules_public', - table: 'webauthn_auth_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - users_table_id: 'uuid', - credentials_table_id: 'uuid', - sessions_table_id: 'uuid', - session_credentials_table_id: 'uuid', - session_secrets_table_id: 'uuid', - auth_settings_table_id: 'uuid', - rp_id: 'text', - rp_name: 'text', - origin_allowlist: 'text[]', - attestation_type: 'text', - require_user_verification: 'boolean', - resident_key: 'text', - challenge_expiry: 'interval' - } + table: 'webauthn_auth_module' }, webauthn_credentials_module: { schema: 'metaschema_modules_public', - table: 'webauthn_credentials_module', - fields: { - id: 'uuid', - database_id: 'uuid', - schema_id: 'uuid', - private_schema_id: 'uuid', - table_id: 'uuid', - owner_table_id: 'uuid', - table_name: 'text' - } + table: 'webauthn_credentials_module' }, spatial_relation: { schema: 'metaschema_public', - table: 'spatial_relation', - fields: { - id: 'uuid', - database_id: 'uuid', - table_id: 'uuid', - field_id: 'uuid', - ref_table_id: 'uuid', - ref_field_id: 'uuid', - name: 'text', - operator: 'text', - param_name: 'text', - category: 'text', - module: 'text', - scope: 'int', - tags: 'text[]' - } + table: 'spatial_relation' } }; diff --git a/pgpm/export/src/graphql-client.ts b/pgpm/export/src/graphql-client.ts index 886554acf..cf2685c12 100644 --- a/pgpm/export/src/graphql-client.ts +++ b/pgpm/export/src/graphql-client.ts @@ -3,6 +3,8 @@ * Used by the GraphQL export flow to fetch data from the Constructive GraphQL API. */ +import { GraphQLTypeInfo, unwrapGraphQLType } from './graphql-naming'; + interface GraphQLClientOptions { endpoint: string; token?: string; @@ -188,4 +190,70 @@ export class GraphQLClient { return parts.length > 0 ? `(${parts.join(', ')})` : ''; } + + /** + * Introspect a GraphQL type to discover its fields and their types. + * Used by the dynamic export flow to discover what fields are available + * instead of hardcoding them in META_TABLE_CONFIG. + * + * Returns a Map of camelCase field name → { typeName, list, nonNull }. + */ + async introspectType(typeName: string): Promise> { + const result = await this.query<{ + __type: { + fields: Array<{ + name: string; + type: { + name: string | null; + kind: string | null; + ofType: { + name: string | null; + kind: string | null; + ofType: { + name: string | null; + kind: string | null; + ofType: { + name: string | null; + kind: string | null; + } | null; + } | null; + } | null; + }; + }>; + }; + }>(` + query IntrospectType($typeName: String!) { + __type(name: $typeName) { + fields { + name + type { + name + kind + ofType { + name + kind + ofType { + name + kind + ofType { + name + kind + } + } + } + } + } + } + } + `, { typeName }); + + const fields = new Map(); + if (result.__type?.fields) { + for (const field of result.__type.fields) { + const typeInfo = unwrapGraphQLType(field.type); + fields.set(field.name, typeInfo); + } + } + return fields; + } } diff --git a/pgpm/export/src/graphql-naming.ts b/pgpm/export/src/graphql-naming.ts index dd6dedcac..ac277d9df 100644 --- a/pgpm/export/src/graphql-naming.ts +++ b/pgpm/export/src/graphql-naming.ts @@ -15,6 +15,9 @@ */ import { toCamelCase, toPascalCase, toSnakeCase, distinctPluralize, singularizeLast } from 'inflekt'; +import { FieldType } from './export-utils'; +import { lookupByGqlType } from './type-map'; + /** * Get the GraphQL query field name for a given Postgres table name. * Mirrors the PostGraphile InflektPlugin's allRowsConnection inflector: @@ -41,21 +44,7 @@ export const graphqlRowToPostgresRow = ( return result; }; -/** - * Convert a PostgreSQL interval object (from GraphQL Interval type) back to a Postgres interval string. - * e.g. { years: 0, months: 0, days: 0, hours: 1, minutes: 30, seconds: 0 } -> '1 hour 30 minutes' - */ -export const intervalToPostgres = (interval: Record | null): string | null => { - if (!interval) return null; - const parts: string[] = []; - if (interval.years) parts.push(`${interval.years} year${interval.years !== 1 ? 's' : ''}`); - if (interval.months) parts.push(`${interval.months} mon${interval.months !== 1 ? 's' : ''}`); - if (interval.days) parts.push(`${interval.days} day${interval.days !== 1 ? 's' : ''}`); - if (interval.hours) parts.push(`${interval.hours}:${String(interval.minutes ?? 0).padStart(2, '0')}:${String(interval.seconds ?? 0).padStart(2, '0')}`); - else if (interval.minutes) parts.push(`00:${String(interval.minutes).padStart(2, '0')}:${String(interval.seconds ?? 0).padStart(2, '0')}`); - else if (interval.seconds) parts.push(`00:00:${String(interval.seconds).padStart(2, '0')}`); - return parts.length > 0 ? parts.join(' ') : '00:00:00'; -}; +export { intervalToPostgres } from './interval-utils'; /** * Convert an array of Postgres field names (with optional type hints) to a GraphQL fields fragment. @@ -76,3 +65,82 @@ export const buildFieldsFragment = ( return camel; }).join('\n '); }; + +// ============================================================================= +// GraphQL introspection helpers +// ============================================================================= + +/** + * Represents the unwrapped type info from a GraphQL introspection field. + * PostGraphile wraps types in NON_NULL and LIST layers via nested `ofType`. + */ +export interface GraphQLTypeInfo { + /** The leaf/nullable type name (e.g. "UUID", "String", "Interval") */ + typeName: string; + /** The leaf type kind (e.g. "SCALAR", "OBJECT", "ENUM") */ + kind: string; + /** Whether the outermost wrapper is NON_NULL */ + nonNull: boolean; + /** Whether the type is a list */ + list: boolean; +} + +/** + * Unwrap a GraphQL introspection type reference into its leaf type name and list status. + * PostGraphile wraps types like: { kind: NON_NULL, name: null, ofType: { kind: LIST, name: null, ofType: { kind: SCALAR, name: "UUID" } } } + * This function recursively unwraps ofType layers, detecting LIST wrappers via the `kind` field. + */ +export const unwrapGraphQLType = ( + typeRef: { name: string | null; kind?: string; ofType?: any } | null, + parentKind?: string +): GraphQLTypeInfo => { + if (!typeRef) return { typeName: 'Unknown', kind: 'UNKNOWN', nonNull: false, list: false }; + + // If the type has a name, it's the leaf type + if (typeRef.name) { + const isList = parentKind === 'LIST'; + return { typeName: typeRef.name, kind: typeRef.kind ?? 'UNKNOWN', nonNull: parentKind === 'NON_NULL', list: isList }; + } + + // If it has ofType, it's a wrapper (NON_NULL or LIST) + if (typeRef.ofType) { + return unwrapGraphQLType(typeRef.ofType, typeRef.kind ?? undefined); + } + + return { typeName: 'Unknown', kind: 'UNKNOWN', nonNull: false, list: false }; +}; + +/** + * Map GraphQL scalar/type names to FieldType values. + * Delegates to the canonical PG_TYPE_MAP in type-map.ts. + */ +export const mapGraphQLTypeToFieldType = (gqlTypeName: string, isList = false): FieldType => { + // Handle list types — map to the array variants that exist in FieldType + if (isList) { + const inner = mapGraphQLTypeToFieldType(gqlTypeName, false); + // Only these array types exist in FieldType: uuid[], text[], jsonb[] + switch (inner) { + case 'uuid': return 'uuid[]'; + case 'text': return 'text[]'; + case 'jsonb': return 'jsonb[]'; + default: return 'text'; // safe fallback for unsupported array types + } + } + + // ID is a GraphQL-only type (relay-style) that maps to uuid; + // it has no direct PG udt_name counterpart in PG_TYPE_MAP. + if (gqlTypeName === 'ID') return 'uuid'; + + const entry = lookupByGqlType(gqlTypeName); + return entry?.fieldType ?? 'text'; +}; + +/** + * Derive the GraphQL type name (PascalCase singular) from a PostgreSQL table name. + * Mirrors PostGraphile's InflektPlugin type inflector: + * singularizeLast(toPascalCase(pgTableName)) + * e.g. "user_auth_module" → "UserAuthModule" + */ +export const getGraphQLTypeName = (pgTableName: string): string => { + return singularizeLast(toPascalCase(pgTableName)); +}; diff --git a/pgpm/export/src/index.ts b/pgpm/export/src/index.ts index 04b70a8f8..b357a3d2a 100644 --- a/pgpm/export/src/index.ts +++ b/pgpm/export/src/index.ts @@ -3,7 +3,7 @@ export * from './export-migrations'; export * from './export-graphql'; export * from './export-graphql-meta'; export { GraphQLClient } from './graphql-client'; -export { getGraphQLQueryName, graphqlRowToPostgresRow, buildFieldsFragment, intervalToPostgres } from './graphql-naming'; +export { getGraphQLQueryName, getGraphQLTypeName, graphqlRowToPostgresRow, buildFieldsFragment, mapGraphQLTypeToFieldType, unwrapGraphQLType, GraphQLTypeInfo } from './graphql-naming'; export { DB_REQUIRED_EXTENSIONS, SERVICE_REQUIRED_EXTENSIONS, @@ -11,6 +11,7 @@ export { META_COMMON_FOOTER, META_TABLE_ORDER, META_TABLE_CONFIG, + mapPgTypeToFieldType, makeReplacer, preparePackage, normalizeOutdir, @@ -26,3 +27,5 @@ export type { PreparePackageOptions, MissingModulesResult } from './export-utils'; +export { PG_TYPE_MAP, TypeMapEntry, lookupByPgUdt, lookupByGqlType } from './type-map'; +export { intervalToPostgres, parsePgInterval, PgInterval } from './interval-utils'; diff --git a/pgpm/export/src/interval-utils.ts b/pgpm/export/src/interval-utils.ts new file mode 100644 index 000000000..3e591eb98 --- /dev/null +++ b/pgpm/export/src/interval-utils.ts @@ -0,0 +1,72 @@ +/** + * Interval conversion utilities shared between graphql-naming.ts and tests. + * + * NOTE: @pgpmjs/csv-to-pg has its own formatInterval() in parse.ts which + * performs the same object→string conversion. That is a separate package with + * its own release cycle, so we do not cross-reference it here. If interval + * handling needs to be unified across packages, that would require an + * architectural change (shared dependency or monorepo util package). + */ + +/** Shape of a PostGraphile Interval object (OBJECT type in introspection). */ +export interface PgInterval { + years: number | null; + months: number | null; + days: number | null; + hours: number | null; + minutes: number | null; + seconds: number | null; +} + +/** + * Convert a PostgreSQL interval object (from GraphQL Interval type) back to a + * Postgres interval string. + * e.g. { years: 0, months: 0, days: 0, hours: 1, minutes: 30, seconds: 0 } → '1:30:00' + */ +export const intervalToPostgres = (interval: Record | null): string | null => { + if (!interval) return null; + const parts: string[] = []; + if (interval.years) parts.push(`${interval.years} year${interval.years !== 1 ? 's' : ''}`); + if (interval.months) parts.push(`${interval.months} mon${interval.months !== 1 ? 's' : ''}`); + if (interval.days) parts.push(`${interval.days} day${interval.days !== 1 ? 's' : ''}`); + if (interval.hours) parts.push(`${interval.hours}:${String(interval.minutes ?? 0).padStart(2, '0')}:${String(interval.seconds ?? 0).padStart(2, '0')}`); + else if (interval.minutes) parts.push(`00:${String(interval.minutes).padStart(2, '0')}:${String(interval.seconds ?? 0).padStart(2, '0')}`); + else if (interval.seconds) parts.push(`00:00:${String(interval.seconds).padStart(2, '0')}`); + return parts.length > 0 ? parts.join(' ') : '00:00:00'; +}; + +/** + * Parse a PostgreSQL interval string into the object shape that PostGraphile's + * Interval type returns: { years, months, days, hours, minutes, seconds }. + * + * Handles formats like: + * '30 days' → { years: 0, months: 0, days: 30, hours: 0, minutes: 0, seconds: 0 } + * '1:30:00' → { years: 0, months: 0, days: 0, hours: 1, minutes: 30, seconds: 0 } + */ +export const parsePgInterval = (value: string): Record => { + const result = { years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0 }; + + // Try HH:MM:SS format + const timeMatch = value.match(/^(\d+):(\d+):(\d+)/); + if (timeMatch) { + result.hours = parseInt(timeMatch[1], 10); + result.minutes = parseInt(timeMatch[2], 10); + result.seconds = parseInt(timeMatch[3], 10); + return result; + } + + // Try descriptive format: 'N unit N unit ...' + const parts = value.trim().split(/\s+/); + for (let i = 0; i < parts.length - 1; i += 2) { + const num = parseInt(parts[i], 10); + const unit = parts[i + 1].toLowerCase(); + if (unit.startsWith('year')) result.years = num; + else if (unit.startsWith('mon')) result.months = num; + else if (unit.startsWith('day')) result.days = num; + else if (unit.startsWith('hour')) result.hours = num; + else if (unit.startsWith('minute')) result.minutes = num; + else if (unit.startsWith('second')) result.seconds = num; + } + + return result; +}; \ No newline at end of file diff --git a/pgpm/export/src/type-map.ts b/pgpm/export/src/type-map.ts new file mode 100644 index 000000000..484c0113d --- /dev/null +++ b/pgpm/export/src/type-map.ts @@ -0,0 +1,83 @@ +/** + * Single-source type mapping between PostgreSQL, PostGraphile GraphQL, and FieldType. + * + * This is the canonical mapping table. All other mappers and tests derive from it: + * - `mapPgTypeToFieldType` in export-utils.ts + * - `mapGraphQLTypeToFieldType` in graphql-naming.ts + * - `pgUdtToGraphQLType` / `pgUdtToGraphQLKind` in cross-flow-parity test + * - parity table in dynamic-fields test + * + * When a new type needs to be supported, add it here and all consumers update automatically. + */ +import { FieldType } from './export-utils'; + +export interface TypeMapEntry { + /** PostgreSQL udt_name values from information_schema (e.g. ['int4', 'int2']) */ + pgUdtNames: string[]; + /** PostGraphile v5 GraphQL type name (e.g. 'Int', 'BigInt', 'Datetime') */ + gqlTypeName: string; + /** FieldType used by csv-to-pg Parser (e.g. 'int', 'timestamptz') */ + fieldType: FieldType; + /** GraphQL kind that PostGraphile reports via introspection */ + gqlKind: 'SCALAR' | 'OBJECT' | 'ENUM'; + /** Whether this is a PostgreSQL array type (e.g. _uuid, _text, _jsonb) */ + isArray?: boolean; +} + +/** + * Canonical PG → GraphQL → FieldType mapping table. + * Aligned with PostGraphile v5's PgCodecsPlugin type assignments: + * - int2, int4 → Int + * - int8 (bigint) → BigInt + * - numeric → BigFloat + * - float4, float8 → Float + * - interval → Interval (OBJECT kind, not SCALAR) + * - timestamptz, timestamp → Datetime + */ +export const PG_TYPE_MAP: TypeMapEntry[] = [ + { pgUdtNames: ['uuid'], gqlTypeName: 'UUID', fieldType: 'uuid', gqlKind: 'SCALAR' }, + { pgUdtNames: ['_uuid'], gqlTypeName: 'UUID', fieldType: 'uuid[]', gqlKind: 'SCALAR', isArray: true }, + { pgUdtNames: ['text', 'varchar', 'bpchar', 'name'], gqlTypeName: 'String', fieldType: 'text', gqlKind: 'SCALAR' }, + { pgUdtNames: ['_text', '_varchar'], gqlTypeName: 'String', fieldType: 'text[]', gqlKind: 'SCALAR', isArray: true }, + { pgUdtNames: ['bool'], gqlTypeName: 'Boolean', fieldType: 'boolean', gqlKind: 'SCALAR' }, + { pgUdtNames: ['jsonb', 'json'], gqlTypeName: 'JSON', fieldType: 'jsonb', gqlKind: 'SCALAR' }, + { pgUdtNames: ['_jsonb'], gqlTypeName: 'JSON', fieldType: 'jsonb[]', gqlKind: 'SCALAR', isArray: true }, + { pgUdtNames: ['int2', 'int4'], gqlTypeName: 'Int', fieldType: 'int', gqlKind: 'SCALAR' }, + { pgUdtNames: ['int8'], gqlTypeName: 'BigInt', fieldType: 'int', gqlKind: 'SCALAR' }, + { pgUdtNames: ['numeric'], gqlTypeName: 'BigFloat', fieldType: 'int', gqlKind: 'SCALAR' }, + { pgUdtNames: ['float4', 'float8'], gqlTypeName: 'Float', fieldType: 'int', gqlKind: 'SCALAR' }, + { pgUdtNames: ['interval'], gqlTypeName: 'Interval', fieldType: 'interval', gqlKind: 'OBJECT' }, + { pgUdtNames: ['timestamptz', 'timestamp'], gqlTypeName: 'Datetime', fieldType: 'timestamptz', gqlKind: 'SCALAR' }, +]; + +// ============================================================================= +// Lookup indices (built once at module load) +// ============================================================================= + +/** Reverse index: pgUdtName → TypeMapEntry */ +const pgUdtIndex = new Map(); +for (const entry of PG_TYPE_MAP) { + for (const udt of entry.pgUdtNames) { + pgUdtIndex.set(udt, entry); + } +} + +/** Reverse index: gqlTypeName → TypeMapEntry (first match wins) */ +const gqlTypeIndex = new Map(); +for (const entry of PG_TYPE_MAP) { + if (!gqlTypeIndex.has(entry.gqlTypeName)) { + gqlTypeIndex.set(entry.gqlTypeName, entry); + } +} + +/** + * Look up a TypeMapEntry by PostgreSQL udt_name. + * Returns undefined for unknown types (callers should fall back to 'text'). + */ +export const lookupByPgUdt = (udtName: string): TypeMapEntry | undefined => pgUdtIndex.get(udtName); + +/** + * Look up a TypeMapEntry by GraphQL type name. + * Returns undefined for unknown types (callers should fall back to 'text'). + */ +export const lookupByGqlType = (gqlTypeName: string): TypeMapEntry | undefined => gqlTypeIndex.get(gqlTypeName);