Skip to content

Fdc native sql type inference - #11110

Open
yantong-google wants to merge 3 commits into
firebase:mainfrom
yantong-google:fdc-native-sql-type-inference
Open

yantong-google wants to merge 3 commits into
firebase:mainfrom
yantong-google:fdc-native-sql-type-inference

Conversation

@yantong-google

Copy link
Copy Markdown

Description

This PR introduces support for Firebase Data Connect Native SQL Type Inference, allowing developers to write native PostgreSQL queries and have their GraphQL types automatically inferred into _inferred_types.gql against a live database.

Context / Internship Handoff: This PR is in a polished handoff phase as my internship is concluding. The implementation is complete, linted, tested, and organized into 3 atomic commits so that teammates can easily review, test, or extend the work before merging.

1. Two-Phase Architecture

  • Preprocessing Phase (firebase dataconnect:sql:infer):
    • Connects to an active database and invokes the toolkit binary (fdc sql infer --config_dir=...).
    • Database connection resolution order:
      1. If --cloud-sql is specified: launches an in-process local Cloud SQL Auth Proxy (@google-cloud/cloud-sql-connector) listening on a temporary Unix domain socket (.s.PGSQL.5432) with IAM database authentication.
      2. Explicit connection string from FIREBASE_DATACONNECT_POSTGRESQL_STRING.
      3. Active database connection discovered automatically from a running Data Connect emulator via EmulatorHubClient.
    • Introspects native SQL queries and writes inferred GraphQL types to _inferred_types.gql in the connector directory.
  • Offline Reproducibility (fdc build & dataconnect:sdk:generate):
    • When dataconnect.nativeSqlInferMode: "db" is configured, build() (used during firebase deploy) and dataconnect:sdk:generate pass SQL_CONNECT_PREVIEW=native_sql_type_inference and SQL_CONNECT_INFER_MODE=db, but explicitly set FIREBASE_DATACONNECT_POSTGRESQL_STRING="".
    • This ensures that firebase deploy and SDK generation stay strictly offline and reproducible from committed .gql sources (including committed _inferred_types.gql), without unintended side-effects on the database.
    • dataconnect:sdk:generate emits a staleness warning advising developers to run firebase dataconnect:sql:infer if queries changed.

2. Feature Gating & Config

  • Registered public experiment flag: fdcnativesqlinfer (default: false).
  • Schema updated in schema/firebase-config.json and src/firebaseConfig.ts to support "nativeSqlInferMode": "db" under dataconnect.

3. Commit Structure

This branch has been rebased cleanly on upstream/main with 3 atomic commits:

  1. fix(dataconnect): propagate real Postgres errors from pglite proxy: Fixes onMessage generator in pgliteServer.ts to yield new Uint8Array(0) when PGlite yields 0 messages, preventing pg-gateway from overriding real Postgres errors or poisoning connections.
  2. feat(dataconnect): add native SQL type inference and dataconnect:sql:infer command: Core CLI command, config validation, experiment gating, and offline build/sdk:generate integration.
  3. feat(dataconnect): support --cloud-sql local proxy in dataconnect:sql:infer: Unix domain socket Cloud SQL proxy (src/dataconnect/cloudSqlProxy.ts) and --cloud-sql flag support.

4. Open Follow-ups / Handoff Notes for Teammates

  • CLOUD_IAM_SERVICE_ACCOUNT Auth: In src/dataconnect/cloudSqlProxy.ts, service account IAM auth currently relies on Application Default Credentials (new Connector()). Once cloud-sql-nodejs-connector#61 adds support for OAuth2 token suppliers for service accounts, update this to pass FBToolsAuthClient.

Scenarios Tested

Automated Unit Tests

  • src/emulator/dataconnectEmulator.spec.ts:
    • sqlInfer: Verified command arguments (--config_dir, --connector_id) and error handling when child process exits non-zero.
    • dataconnect:sql:infer command:
      • Rejects when fdcnativesqlinfer experiment is disabled.
      • Rejects when dataconnect.nativeSqlInferMode is missing or set to an unsupported mode (e.g. "pglite").
      • Reuses active database connection from running Data Connect emulator via EmulatorHubClient.
      • Uses FIREBASE_DATACONNECT_POSTGRESQL_STRING when emulator is not running.
      • Fails with actionable error when no database connection is available.
      • Verified --cloud-sql flag behavior (proxy not invoked when omitted).
      • Falls back to MISSING_PROJECT_PLACEHOLDER when projectId is not provided.
    • dataconnect:sdk:generate:
      • Verified SQL_CONNECT_PREVIEW and SQL_CONNECT_INFER_MODE=db are passed.
      • Verified FIREBASE_DATACONNECT_POSTGRESQL_STRING="" is passed to guarantee offline codegen.
      • Verified staleness warning is emitted when nativeSqlInferMode is set.
    • dataconnect build:
      • Verified FIREBASE_DATACONNECT_POSTGRESQL_STRING="" is passed to guarantee offline reproducible build during deploy.
    • nativeSqlInferEnv:
      • Verified env var mapping and preview flag merging logic.
  • src/dataconnect/cloudSqlProxy.spec.ts:
    • Verified instance connection name resolution.
    • Verified CLOUD_IAM_USER uses FBToolsAuthClient.
    • Verified CLOUD_IAM_SERVICE_ACCOUNT initializes ADC connector.
    • Verified non-IAM / built-in database users throw an actionable error.
    • Verified Unix socket server cleanup on process exit / signals.
  • src/emulator/dataconnect/pgliteServer.ts:
    • Verified real Postgres errors are propagated without connection poisoning when PGlite yields empty messages.

Manual End-to-End Testing

  • Started Data Connect emulator via firebase emulators:start.
  • Ran firebase dataconnect:sql:infer in another terminal; confirmed _inferred_types.gql was generated from native SQL queries.
  • Tested firebase dataconnect:sql:infer --cloud-sql against a live Cloud SQL instance with IAM user authentication.
  • Ran firebase dataconnect:sdk:generate; confirmed SDK generation consumed _inferred_types.gql without requiring database connectivity.

Sample Commands

1. Enable the experiment

firebase experiments:enable fdcnativesqlinfer

Returning an empty Uint8Array from onMessage ensures pg-gateway sets skipProcessing = true even when PGlite yields 0 backend messages (such as on Flush 'H', or when PGlite discards Describe messages following a Parse error).

This avoids:
1. Error overriding: Prevents pg-gateway from injecting a fake "Message code not yet implemented" error that overwrites real PostgreSQL errors.
2. Connection poisoning: Prevents emitting a premature ReadyForQuery that leaves unconsumed messages in the client socket buffer and desynchronizes subsequent queries.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces native SQL type inference for Firebase Data Connect, adding the dataconnect:sql:infer command, supporting local Cloud SQL Auth Proxy connections, and integrating the feature with SDK generation and builds under a new experiment flag. Feedback on the changes highlights three critical issues: first, nativeSqlInferMode needs to support configurations with multiple Data Connect services where dataconnect is an array; second, the generator in pgliteServer.ts should yield the empty Uint8Array instead of returning it, as return values are ignored during standard async iteration; and third, the custom signal handlers in cloudSqlProxy.ts must properly propagate signals to prevent the CLI process from hanging on Ctrl+C.

Comment on lines +35 to +39
export function nativeSqlInferMode(config?: Config): string | undefined {
const mode = config?.get("dataconnect.nativeSqlInferMode") as string | undefined;
if (!mode) {
return undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of nativeSqlInferMode assumes that dataconnect in firebase.json is always a single object. However, firebase-tools supports multiple Data Connect services configured as an array (i.e., DataConnectMultiple). If a user has multiple services, config.get("dataconnect.nativeSqlInferMode") will return undefined, causing the feature to silently fail or throw a missing configuration error. We should handle both single and multiple service configurations.

Suggested change
export function nativeSqlInferMode(config?: Config): string | undefined {
const mode = config?.get("dataconnect.nativeSqlInferMode") as string | undefined;
if (!mode) {
return undefined;
}
interface DataConnectServiceConfig {
nativeSqlInferMode?: string;
}
export function nativeSqlInferMode(config?: Config): string | undefined {
const dataconnect = config?.get("dataconnect");
if (!dataconnect) {
return undefined;
}
let mode: string | undefined;
if (Array.isArray(dataconnect)) {
const services = dataconnect as DataConnectServiceConfig[];
mode = services.find((s) => s.nativeSqlInferMode)?.nativeSqlInferMode;
} else {
const service = dataconnect as DataConnectServiceConfig;
mode = service.nativeSqlInferMode;
}
if (!mode) {
return undefined;
}

Comment on lines +73 to 83
// Returning an empty Uint8Array ensures pg-gateway sets skipProcessing = true
// even when PGlite produces 0 backend messages (such as on Flush 'H', or when
// PGlite discards Describe messages following a Parse error).
//
// This avoids:
// 1. Error overriding: Prevents pg-gateway from injecting a fake "Message code not yet implemented"
// error that overwrites the real PostgreSQL error (e.g. syntax or table not found errors).
// 2. Connection poisoning: Prevents emitting a premature ReadyForQuery that leaves unconsumed
// messages in the client socket buffer and desynchronizes subsequent queries.
return new Uint8Array(0);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In JavaScript/TypeScript, standard consumers of async iterables (such as for await...of loops or Readable.from()) completely ignore the return value of a generator function (i.e., when done: true). Since onMessage is an async * generator, returning new Uint8Array(0) on line 82 will be ignored by pg-gateway if it consumes the generator using standard iteration. To ensure pg-gateway receives this empty buffer and sets skipProcessing = true, you should yield the empty array instead of returning it.

Suggested change
// Returning an empty Uint8Array ensures pg-gateway sets skipProcessing = true
// even when PGlite produces 0 backend messages (such as on Flush 'H', or when
// PGlite discards Describe messages following a Parse error).
//
// This avoids:
// 1. Error overriding: Prevents pg-gateway from injecting a fake "Message code not yet implemented"
// error that overwrites the real PostgreSQL error (e.g. syntax or table not found errors).
// 2. Connection poisoning: Prevents emitting a premature ReadyForQuery that leaves unconsumed
// messages in the client socket buffer and desynchronizes subsequent queries.
return new Uint8Array(0);
},
// Returning an empty Uint8Array ensures pg-gateway sets skipProcessing = true
// even when PGlite produces 0 backend messages (such as on Flush 'H', or when
// PGlite discards Describe messages following a Parse error).
//
// This avoids:
// 1. Error overriding: Prevents pg-gateway from injecting a fake "Message code not yet implemented"
// error that overwrites the real PostgreSQL error (e.g. syntax or table not found errors).
// 2. Connection poisoning: Prevents emitting a premature ReadyForQuery that leaves unconsumed
// messages in the client socket buffer and desynchronizes subsequent queries.
yield new Uint8Array(0);
},

Comment on lines +111 to +120
const onSignal = () => {
void close();
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
const closeAndUnregister = async (): Promise<void> => {
process.removeListener("SIGINT", onSignal);
process.removeListener("SIGTERM", onSignal);
await close();
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When registering custom SIGINT or SIGTERM handlers in Node.js, the default process termination behavior is overridden. Since onSignal only schedules the asynchronous close() cleanup but does not exit the process or propagate the signal, the CLI process will hang indefinitely on Ctrl+C if there are active handles (such as the Cloud SQL connector or spawned child processes). To prevent hangs, the signal handler should clean up, remove itself, and then re-send the signal to the process so it can terminate naturally with the correct exit status.

  const onSignal = async (signal: string) => {
    process.removeListener("SIGINT", onSignal);
    process.removeListener("SIGTERM", onSignal);
    await close();
    process.kill(process.pid, signal);
  };
  process.on("SIGINT", () => void onSignal("SIGINT"));
  process.on("SIGTERM", () => void onSignal("SIGTERM"));
  const closeAndUnregister = async (): Promise<void> => {
    process.removeListener("SIGINT", onSignal);
    process.removeListener("SIGTERM", onSignal);
    await close();
  };

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants