diff --git a/packages/cli/src/context/scan/enabled-tables.ts b/packages/cli/src/context/scan/enabled-tables.ts index f522d44f..e1f53c09 100644 --- a/packages/cli/src/context/scan/enabled-tables.ts +++ b/packages/cli/src/context/scan/enabled-tables.ts @@ -1,17 +1,76 @@ -import type { KtxSchemaSnapshot } from './types.js'; +import { hasTableRef, tableRefSet, type KtxTableRefKey } from './table-ref.js'; +import type { KtxSchemaSnapshot, KtxTableRef } from './types.js'; -export function resolveEnabledTables(connection: Record | undefined): Set | null { +/** + * Parses the `enabled_tables` field on a connection into a scope of + * fully-qualified table refs. Returns `null` when the field is absent or + * empty (meaning "no scope — include every table in the resolved schemas"). + * + * Accepted entry forms: + * "catalog.db.name" — fully qualified + * "db.name" — schema-qualified (catalog = null; legacy / Postgres-shape) + * "name" — bare (catalog = db = null; SQLite-shape) + * { catalog?, db?, name } — escape hatch for identifiers containing dots + * + * The setup wizard writes the fully-qualified form going forward; the lenient + * parser keeps existing project configs working. + */ +export function resolveEnabledTables( + connection: Record | undefined, +): ReadonlySet | null { const raw = connection?.enabled_tables; if (!Array.isArray(raw) || raw.length === 0) return null; - return new Set(raw.filter((v): v is string => typeof v === 'string')); + const refs: KtxTableRef[] = []; + for (const value of raw) { + const parsed = parseEnabledTableEntry(value); + if (parsed) refs.push(parsed); + } + if (refs.length === 0) return null; + return tableRefSet(refs); } -export function filterSnapshotTables(snapshot: KtxSchemaSnapshot, enabledTables: Set): KtxSchemaSnapshot { +function parseEnabledTableEntry(value: unknown): KtxTableRef | null { + if (typeof value === 'string') { + return parseDottedEntry(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const entry = value as { catalog?: unknown; db?: unknown; name?: unknown }; + const name = typeof entry.name === 'string' ? entry.name : null; + if (!name) return null; + return { + catalog: typeof entry.catalog === 'string' ? entry.catalog : null, + db: typeof entry.db === 'string' ? entry.db : null, + name, + }; + } + return null; +} + +function parseDottedEntry(value: string): KtxTableRef | null { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + const parts = trimmed.split('.'); + if (parts.length === 3) { + return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; + } + if (parts.length === 2) { + return { catalog: null, db: parts[0]!, name: parts[1]! }; + } + if (parts.length === 1) { + return { catalog: null, db: null, name: parts[0]! }; + } + return null; +} + +/** @internal — kept as a defensive backstop for the live-database adapter and tests. */ +export function filterSnapshotTables( + snapshot: KtxSchemaSnapshot, + enabledTables: ReadonlySet, +): KtxSchemaSnapshot { return { ...snapshot, - tables: snapshot.tables.filter((table) => { - const key = table.db ? `${table.db}.${table.name}` : table.name; - return enabledTables.has(key); - }), + tables: snapshot.tables.filter((table) => + hasTableRef(enabledTables, { catalog: table.catalog, db: table.db, name: table.name }), + ), }; } diff --git a/packages/cli/src/context/scan/table-ref.test.ts b/packages/cli/src/context/scan/table-ref.test.ts new file mode 100644 index 00000000..8e3ddae1 --- /dev/null +++ b/packages/cli/src/context/scan/table-ref.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + hasTableRef, + scopedTableNames, + tableRefFromKey, + tableRefKey, + tableRefSet, + type KtxTableRefKey, +} from './table-ref.js'; + +describe('tableRefKey roundtrip', () => { + it('encodes and decodes a three-part ref', () => { + const ref = { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }; + expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref); + }); + + it('treats null catalog/db as the empty segment', () => { + const ref = { catalog: null, db: 'public', name: 'users' }; + expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref); + }); + + it('roundtrips a bare-name ref', () => { + const ref = { catalog: null, db: null, name: 'orders' }; + expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref); + }); +}); + +describe('tableRefSet', () => { + it('produces a set with member-equality on canonical keys', () => { + const scope = tableRefSet([ + { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }, + { catalog: 'ANALYTICS', db: 'MARTS', name: 'ITEMS' }, + ]); + expect(scope.size).toBe(2); + expect(scope.has(tableRefKey({ catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }))).toBe(true); + expect(scope.has(tableRefKey({ catalog: 'ANALYTICS', db: 'MARTS', name: 'OTHER' }))).toBe(false); + }); +}); + +describe('hasTableRef', () => { + const scope = tableRefSet([ + { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }, + { catalog: null, db: 'public', name: 'users' }, + ]); + + it('matches fully qualified entries exactly', () => { + expect(hasTableRef(scope, { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' })).toBe(true); + }); + + it('matches when the scope omits catalog (legacy 2-part entry)', () => { + expect(hasTableRef(scope, { catalog: 'PRODUCTION_DB', db: 'public', name: 'users' })).toBe(true); + }); + + it('rejects refs not in the scope', () => { + expect(hasTableRef(scope, { catalog: 'ANALYTICS', db: 'STAGING', name: 'LISTINGS' })).toBe(false); + expect(hasTableRef(scope, { catalog: null, db: 'public', name: 'orders' })).toBe(false); + }); +}); + +describe('scopedTableNames', () => { + it('projects to the requested (catalog, db) namespace', () => { + const scope = tableRefSet([ + { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }, + { catalog: 'ANALYTICS', db: 'MARTS', name: 'ITEMS' }, + { catalog: 'ANALYTICS', db: 'STAGING', name: 'LISTINGS' }, + ]); + expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'MARTS' }).sort()).toEqual(['ITEMS', 'LISTINGS']); + expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']); + }); + + it('treats null in the scope entry as a wildcard for that segment', () => { + const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]); + expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual(['users']); + }); + + it('returns empty when no scope entry matches the namespace', () => { + const scope = tableRefSet([{ catalog: 'A', db: 'B', name: 'C' }]); + expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]); + }); + + it('dedupes when the same name appears under different catalog projections', () => { + const scope: ReadonlySet = tableRefSet([ + { catalog: null, db: 'public', name: 'users' }, + { catalog: 'A', db: 'public', name: 'users' }, + ]); + expect(scopedTableNames(scope, { catalog: 'A', db: 'public' })).toEqual(['users']); + }); +}); diff --git a/packages/cli/src/context/scan/table-ref.ts b/packages/cli/src/context/scan/table-ref.ts new file mode 100644 index 00000000..6d0236c5 --- /dev/null +++ b/packages/cli/src/context/scan/table-ref.ts @@ -0,0 +1,62 @@ +import type { KtxTableRef } from './types.js'; + +/** + * Branded canonical string representation of a {@link KtxTableRef}. + * + * Connectors compare scopes for set membership via these keys instead of the + * raw object (JS `Set` uses identity equality, which would be useless + * here). Build a key with {@link tableRefKey} and decode with + * {@link tableRefFromKey}. + */ +export type KtxTableRefKey = string & { readonly __brand: 'KtxTableRefKey' }; + +const SEPARATOR = '\x1f'; + +export function tableRefKey(ref: KtxTableRef): KtxTableRefKey { + return `${ref.catalog ?? ''}${SEPARATOR}${ref.db ?? ''}${SEPARATOR}${ref.name}` as KtxTableRefKey; +} + +export function tableRefFromKey(key: KtxTableRefKey): KtxTableRef { + const [catalog = '', db = '', name = ''] = key.split(SEPARATOR); + return { + catalog: catalog.length > 0 ? catalog : null, + db: db.length > 0 ? db : null, + name, + }; +} + +export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet { + return new Set(refs.map(tableRefKey)); +} + +export function hasTableRef(scope: ReadonlySet, ref: KtxTableRef): boolean { + if (scope.has(tableRefKey(ref))) return true; + if (ref.catalog !== null) { + if (scope.has(tableRefKey({ ...ref, catalog: null }))) return true; + } + if (ref.db !== null) { + if (scope.has(tableRefKey({ ...ref, db: null }))) return true; + } + return false; +} + +/** + * Return the bare table names from a scope that fall within the given + * (catalog, db) namespace. `catalog: null` is treated as a wildcard so that + * legacy 2-part `"db.name"` entries continue to match. Same for `db: null`. + */ +export function scopedTableNames( + scope: ReadonlySet, + namespace: { catalog?: string | null; db?: string | null }, +): string[] { + const names = new Set(); + const wantCatalog = namespace.catalog ?? null; + const wantDb = namespace.db ?? null; + for (const key of scope) { + const ref = tableRefFromKey(key); + if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue; + if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue; + names.add(ref.name); + } + return [...names]; +} diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index f4299e86..f5f6c7f1 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -1,3 +1,5 @@ +import type { KtxTableRefKey } from './table-ref.js'; + export type KtxConnectionDriver = | 'sqlite' | 'postgres' @@ -137,6 +139,15 @@ export interface KtxScanInput { connectionId: string; driver: KtxConnectionDriver; scope?: KtxSchemaScope; + /** + * Restricts introspection to a specific set of fully-qualified tables. + * `undefined` means "all tables within {@link scope}". Connectors that honor + * this field should push the filter into their metadata queries; the + * live-database adapter also applies a final filter before writing, so a + * connector that ignores `tableScope` will over-fetch but produce correct + * output. + */ + tableScope?: ReadonlySet; mode?: KtxScanMode; dryRun?: boolean; detectRelationships?: boolean;