diff --git a/common/api-review/data-connect.api.md b/common/api-review/data-connect.api.md index 27a9d4af20..c464494884 100644 --- a/common/api-review/data-connect.api.md +++ b/common/api-review/data-connect.api.md @@ -11,6 +11,14 @@ import { FirebaseError } from '@firebase/util'; import { LogLevelString } from '@firebase/logger'; import { Provider } from '@firebase/component'; +// @public (undocumented) +export interface CacheSettings { + // (undocumented) + cacheProvider: MemoryStub; + // (undocumented) + maxAge?: number; +} + // @public export type CallerSdkType = 'Base' | 'Generated' | 'TanstackReactCore' | 'GeneratedReact' | 'TanstackAngularCore' | 'GeneratedAngular'; @@ -66,6 +74,12 @@ export class DataConnect { setInitialized(): void; } +// @public (undocumented) +export interface DataConnectEntityArray { + // (undocumented) + entityIds: string[]; +} + // @public export class DataConnectError extends FirebaseError { /* Excluded from this release type: name */ @@ -75,6 +89,17 @@ export class DataConnectError extends FirebaseError { // @public (undocumented) export type DataConnectErrorCode = 'other' | 'already-initialized' | 'not-initialized' | 'not-supported' | 'invalid-argument' | 'partial-error' | 'unauthorized'; +// @public (undocumented) +export type DataConnectExtension = { + path: Array; +} & (DataConnectEntityArray | DataConnectSingleEntity); + +// @public (undocumented) +export interface DataConnectExtensions { + // (undocumented) + dataConnect?: DataConnectExtension[]; +} + // @public export class DataConnectOperationError extends DataConnectError { /* Excluded from this release type: name */ @@ -98,17 +123,49 @@ export interface DataConnectOperationFailureResponseErrorInfo { } // @public -export interface DataConnectOptions extends ConnectorConfig { +export interface DataConnectOptions extends ConnectorConfig, DataConnectSettings { // (undocumented) projectId: string; } +// @public (undocumented) +export interface DataConnectResponse { + // (undocumented) + data: T; + // (undocumented) + errors: Error[]; + // (undocumented) + extensions: DataConnectExtensions; +} + // @public (undocumented) export interface DataConnectResult extends OpResult { // (undocumented) ref: OperationRef; } +// @public (undocumented) +export interface DataConnectSettings { + // (undocumented) + cacheSettings?: CacheSettings; +} + +// @public (undocumented) +export interface DataConnectSingleEntity { + // (undocumented) + entityId: string; +} + +// @public +export interface DataConnectSubscription { + // (undocumented) + errCallback?: (e?: DataConnectError) => void; + // (undocumented) + unsubscribe: () => void; + // (undocumented) + userCallback: OnResultSubscription; +} + // @public (undocumented) export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; @@ -116,13 +173,34 @@ export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; export function executeMutation(mutationRef: MutationRef): MutationPromise; // @public -export function executeQuery(queryRef: QueryRef): QueryPromise; +export function executeQuery(queryRef: QueryRef, options?: ExecuteQueryOptions): QueryPromise; + +// @public (undocumented) +export interface ExecuteQueryOptions { + // (undocumented) + fetchPolicy: QueryFetchPolicy; +} // @public +export function getDataConnect(options: ConnectorConfig, settings?: DataConnectSettings): DataConnect; + +// @public (undocumented) export function getDataConnect(options: ConnectorConfig): DataConnect; // @public -export function getDataConnect(app: FirebaseApp, options: ConnectorConfig): DataConnect; +export function getDataConnect(app: FirebaseApp, connectorConfig: ConnectorConfig): DataConnect; + +// @public +export function getDataConnect(app: FirebaseApp, connectorConfig: ConnectorConfig, settings: DataConnectSettings): DataConnect; + +// @public (undocumented) +export function makeMemoryCacheProvider(): MemoryStub<'MEMORY'>; + +// @public (undocumented) +export class MemoryStub { + // (undocumented) + type: 'MEMORY'; +} // @public (undocumented) export const MUTATION_STR = "mutation"; @@ -175,6 +253,8 @@ export interface OpResult { // (undocumented) data: Data; // (undocumented) + extensions?: DataConnectExtensions; + // (undocumented) fetchTime: string; // (undocumented) source: DataSource; @@ -183,6 +263,16 @@ export interface OpResult { // @public (undocumented) export const QUERY_STR = "query"; +// @public +export const QueryFetchPolicy: { + readonly PREFER_CACHE: "PREFER_CACHE"; + readonly CACHE_ONLY: "CACHE_ONLY"; + readonly SERVER_ONLY: "SERVER_ONLY"; +}; + +// @public (undocumented) +export type QueryFetchPolicy = (typeof QueryFetchPolicy)[keyof typeof QueryFetchPolicy]; + // @public export interface QueryPromise extends Promise> { } @@ -238,6 +328,14 @@ export const SOURCE_CACHE = "CACHE"; // @public (undocumented) export const SOURCE_SERVER = "SERVER"; +// @public (undocumented) +export const StorageType: { + MEMORY: string; +}; + +// @public (undocumented) +export type StorageType = (typeof StorageType)[keyof typeof StorageType]; + // @public export function subscribe(queryRefOrSerializedResult: QueryRef | SerializedRef, observer: SubscriptionOptions): QueryUnsubscribe; diff --git a/common/api-review/util.api.md b/common/api-review/util.api.md index 4ac51fda55..44127a8c9f 100644 --- a/common/api-review/util.api.md +++ b/common/api-review/util.api.md @@ -221,6 +221,11 @@ export class FirebaseError extends Error { // @public export type FirebaseSignInProvider = 'custom' | 'email' | 'password' | 'phone' | 'anonymous' | 'google.com' | 'facebook.com' | 'github.com' | 'twitter.com' | 'microsoft.com' | 'apple.com'; +// Warning: (ae-missing-release-tag) "generateSHA256HashBrowser" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function generateSHA256HashBrowser(input: string): Promise; + // @public export const getDefaultAppConfig: () => Record | undefined; diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/setup.md b/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/setup.md new file mode 100644 index 0000000000..77a3b2e2ac --- /dev/null +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/setup.md @@ -0,0 +1,20 @@ +# Setup + +If the user hasn't already installed the SDK, always run the user's node package manager of choice, and install the package in the directory ../package.json. +For more information on where the library is located, look at the connector.yaml file. + +```ts +import { initializeApp } from 'firebase/app'; + +initializeApp({ + // fill in your project config here using the values from your Firebase project or from the `firebase_get_sdk_config` tool from the Firebase MCP server. +}); +``` + +Then, you can run the SDK as needed. +```ts +import { ... } from '@firebasegen/default-connector'; +``` + + + diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/usage.md b/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/usage.md new file mode 100644 index 0000000000..8359ffbfed --- /dev/null +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/.guides/usage.md @@ -0,0 +1,27 @@ +# Basic Usage + +Always prioritize using a supported framework over using the generated SDK +directly. Supported frameworks simplify the developer experience and help ensure +best practices are followed. + + + + + +## Advanced Usage +If a user is not using a supported framework, they can use the generated SDK directly. + +Here's an example of how to use it with the first 5 operations: + +```js +import { createMovie, listMovies } from '@firebasegen/default-connector'; + + +// Operation CreateMovie: For variables, look at type CreateMovieVars in ../index.d.ts +const { data } = await CreateMovie(dataConnect, createMovieVars); + +// Operation ListMovies: +const { data } = await ListMovies(dataConnect); + + +``` \ No newline at end of file diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/README.md b/e2e/data-connect/dataconnect-generated/js/default-connector/README.md index b3f5971d70..e04b813baf 100644 --- a/e2e/data-connect/dataconnect-generated/js/default-connector/README.md +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/README.md @@ -1,5 +1,10 @@ +# Generated TypeScript README +This README will guide you through the process of using the generated JavaScript SDK package for the connector `default`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations. + +***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.* + # Table of Contents -- [**Overview**](#generated-typescript-readme) +- [**Overview**](#generated-javascript-readme) - [**Accessing the connector**](#accessing-the-connector) - [*Connecting to the local Emulator*](#connecting-to-the-local-emulator) - [**Queries**](#queries) @@ -7,21 +12,14 @@ - [**Mutations**](#mutations) - [*CreateMovie*](#createmovie) -# Generated TypeScript README -This README will guide you through the process of using the generated TypeScript SDK package for the connector `default`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations. - -***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.* +# Accessing the connector +A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `default`. You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does). You can use this generated SDK by importing from the package `@firebasegen/default-connector` as shown below. Both CommonJS and ESM imports are supported. You can also follow the instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#set-client). -# Accessing the connector -A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `default`. - -You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does). - -```javascript +```typescript import { getDataConnect } from 'firebase/data-connect'; import { connectorConfig } from '@firebasegen/default-connector'; @@ -34,7 +32,7 @@ By default, the connector will connect to the production service. To connect to the emulator, you can use the following code. You can also follow the emulator instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#instrument-clients). -```javascript +```typescript import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect'; import { connectorConfig } from '@firebasegen/default-connector'; @@ -61,16 +59,31 @@ Below are examples of how to use the `default` connector's generated functions t ## ListMovies You can execute the `ListMovies` query using the following action shortcut function, or by calling `executeQuery()` after calling the following `QueryRef` function, both of which are defined in [default-connector/index.d.ts](./index.d.ts): -```javascript +```typescript listMovies(): QueryPromise; -listMoviesRef(): QueryRef; +interface ListMoviesRef { + ... + /* Allow users to create refs without passing in DataConnect */ + (): QueryRef; +} +export const listMoviesRef: ListMoviesRef; ``` You can also pass in a `DataConnect` instance to the action shortcut function or `QueryRef` function. -```javascript +```typescript listMovies(dc: DataConnect): QueryPromise; -listMoviesRef(dc: DataConnect): QueryRef; +interface ListMoviesRef { + ... + (dc: DataConnect): QueryRef; +} +export const listMoviesRef: ListMoviesRef; +``` + +If you need the name of the operation without creating a ref, you can retrieve the operation name by calling the `operationName` property on the listMoviesRef: +```typescript +const name = listMoviesRef.operationName; +console.log(name); ``` ### Variables @@ -79,7 +92,7 @@ The `ListMovies` query has no variables. Recall that executing the `ListMovies` query returns a `QueryPromise` that resolves to an object with a `data` property. The `data` property is an object of type `ListMoviesData`, which is defined in [default-connector/index.d.ts](./index.d.ts). It has the following fields: -```javascript +```typescript export interface ListMoviesData { movies: ({ id: UUIDString; @@ -91,7 +104,7 @@ export interface ListMoviesData { ``` ### Using `ListMovies`'s action shortcut function -```javascript +```typescript import { getDataConnect } from 'firebase/data-connect'; import { connectorConfig, listMovies } from '@firebasegen/default-connector'; @@ -115,7 +128,7 @@ listMovies().then((response) => { ### Using `ListMovies`'s `QueryRef` function -```javascript +```typescript import { getDataConnect, executeQuery } from 'firebase/data-connect'; import { connectorConfig, listMoviesRef } from '@firebasegen/default-connector'; @@ -157,22 +170,37 @@ Below are examples of how to use the `default` connector's generated functions t ## CreateMovie You can execute the `CreateMovie` mutation using the following action shortcut function, or by calling `executeMutation()` after calling the following `MutationRef` function, both of which are defined in [default-connector/index.d.ts](./index.d.ts): -```javascript +```typescript createMovie(vars: CreateMovieVariables): MutationPromise; -createMovieRef(vars: CreateMovieVariables): MutationRef; +interface CreateMovieRef { + ... + /* Allow users to create refs without passing in DataConnect */ + (vars: CreateMovieVariables): MutationRef; +} +export const createMovieRef: CreateMovieRef; ``` You can also pass in a `DataConnect` instance to the action shortcut function or `MutationRef` function. -```javascript +```typescript createMovie(dc: DataConnect, vars: CreateMovieVariables): MutationPromise; -createMovieRef(dc: DataConnect, vars: CreateMovieVariables): MutationRef; +interface CreateMovieRef { + ... + (dc: DataConnect, vars: CreateMovieVariables): MutationRef; +} +export const createMovieRef: CreateMovieRef; +``` + +If you need the name of the operation without creating a ref, you can retrieve the operation name by calling the `operationName` property on the createMovieRef: +```typescript +const name = createMovieRef.operationName; +console.log(name); ``` ### Variables The `CreateMovie` mutation requires an argument of type `CreateMovieVariables`, which is defined in [default-connector/index.d.ts](./index.d.ts). It has the following fields: -```javascript +```typescript export interface CreateMovieVariables { title: string; genre: string; @@ -183,14 +211,14 @@ export interface CreateMovieVariables { Recall that executing the `CreateMovie` mutation returns a `MutationPromise` that resolves to an object with a `data` property. The `data` property is an object of type `CreateMovieData`, which is defined in [default-connector/index.d.ts](./index.d.ts). It has the following fields: -```javascript +```typescript export interface CreateMovieData { movie_insert: Movie_Key; } ``` ### Using `CreateMovie`'s action shortcut function -```javascript +```typescript import { getDataConnect } from 'firebase/data-connect'; import { connectorConfig, createMovie, CreateMovieVariables } from '@firebasegen/default-connector'; @@ -222,7 +250,7 @@ createMovie(createMovieVars).then((response) => { ### Using `CreateMovie`'s `MutationRef` function -```javascript +```typescript import { getDataConnect, executeMutation } from 'firebase/data-connect'; import { connectorConfig, createMovieRef, CreateMovieVariables } from '@firebasegen/default-connector'; diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/esm/index.esm.js b/e2e/data-connect/dataconnect-generated/js/default-connector/esm/index.esm.js index e88d2d56d3..5f79fe6f59 100644 --- a/e2e/data-connect/dataconnect-generated/js/default-connector/esm/index.esm.js +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/esm/index.esm.js @@ -29,7 +29,7 @@ export const connectorConfig = { location: 'us-central1' }; -export function createMovieRef(dcOrVars, vars) { +export const createMovieRef = (dcOrVars, vars) => { const { dc: dcInstance, vars: inputVars } = validateArgs( connectorConfig, dcOrVars, @@ -38,17 +38,19 @@ export function createMovieRef(dcOrVars, vars) { ); dcInstance._useGeneratedSdk(); return mutationRef(dcInstance, 'CreateMovie', inputVars); -} +}; +createMovieRef.operationName = 'CreateMovie'; export function createMovie(dcOrVars, vars) { return executeMutation(createMovieRef(dcOrVars, vars)); } -export function listMoviesRef(dc) { +export const listMoviesRef = dc => { const { dc: dcInstance } = validateArgs(connectorConfig, dc, undefined); dcInstance._useGeneratedSdk(); return queryRef(dcInstance, 'ListMovies'); -} +}; +listMoviesRef.operationName = 'ListMovies'; export function listMovies(dc) { return executeQuery(listMoviesRef(dc)); diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/index.cjs.js b/e2e/data-connect/dataconnect-generated/js/default-connector/index.cjs.js index 814406ff09..414ffe1753 100644 --- a/e2e/data-connect/dataconnect-generated/js/default-connector/index.cjs.js +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/index.cjs.js @@ -30,7 +30,7 @@ const connectorConfig = { }; exports.connectorConfig = connectorConfig; -exports.createMovieRef = function createMovieRef(dcOrVars, vars) { +const createMovieRef = (dcOrVars, vars) => { const { dc: dcInstance, vars: inputVars } = validateArgs( connectorConfig, dcOrVars, @@ -40,16 +40,20 @@ exports.createMovieRef = function createMovieRef(dcOrVars, vars) { dcInstance._useGeneratedSdk(); return mutationRef(dcInstance, 'CreateMovie', inputVars); }; +createMovieRef.operationName = 'CreateMovie'; +exports.createMovieRef = createMovieRef; exports.createMovie = function createMovie(dcOrVars, vars) { return executeMutation(createMovieRef(dcOrVars, vars)); }; -exports.listMoviesRef = function listMoviesRef(dc) { +const listMoviesRef = dc => { const { dc: dcInstance } = validateArgs(connectorConfig, dc, undefined); dcInstance._useGeneratedSdk(); return queryRef(dcInstance, 'ListMovies'); }; +listMoviesRef.operationName = 'ListMovies'; +exports.listMoviesRef = listMoviesRef; exports.listMovies = function listMovies(dc) { return executeQuery(listMoviesRef(dc)); diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/index.d.ts b/e2e/data-connect/dataconnect-generated/js/default-connector/index.d.ts index 474fefd0f4..6e74346c71 100644 --- a/e2e/data-connect/dataconnect-generated/js/default-connector/index.d.ts +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/index.d.ts @@ -55,15 +55,20 @@ export interface Movie_Key { __typename?: 'Movie_Key'; } -/* Allow users to create refs without passing in DataConnect */ -export function createMovieRef( - vars: CreateMovieVariables -): MutationRef; -/* Allow users to pass in custom DataConnect instances */ -export function createMovieRef( - dc: DataConnect, - vars: CreateMovieVariables -): MutationRef; +interface CreateMovieRef { + /* Allow users to create refs without passing in DataConnect */ + (vars: CreateMovieVariables): MutationRef< + CreateMovieData, + CreateMovieVariables + >; + /* Allow users to pass in custom DataConnect instances */ + (dc: DataConnect, vars: CreateMovieVariables): MutationRef< + CreateMovieData, + CreateMovieVariables + >; + operationName: string; +} +export const createMovieRef: CreateMovieRef; export function createMovie( vars: CreateMovieVariables @@ -73,12 +78,14 @@ export function createMovie( vars: CreateMovieVariables ): MutationPromise; -/* Allow users to create refs without passing in DataConnect */ -export function listMoviesRef(): QueryRef; -/* Allow users to pass in custom DataConnect instances */ -export function listMoviesRef( - dc: DataConnect -): QueryRef; +interface ListMoviesRef { + /* Allow users to create refs without passing in DataConnect */ + (): QueryRef; + /* Allow users to pass in custom DataConnect instances */ + (dc: DataConnect): QueryRef; + operationName: string; +} +export const listMoviesRef: ListMoviesRef; export function listMovies(): QueryPromise; export function listMovies( diff --git a/e2e/data-connect/dataconnect-generated/js/default-connector/package.json b/e2e/data-connect/dataconnect-generated/js/default-connector/package.json index e9b57fa0a3..820389543e 100644 --- a/e2e/data-connect/dataconnect-generated/js/default-connector/package.json +++ b/e2e/data-connect/dataconnect-generated/js/default-connector/package.json @@ -5,7 +5,7 @@ "description": "Generated SDK For default", "license": "Apache-2.0", "engines": { - "node": " >=22.0.0" + "node": " >=18.0" }, "typings": "index.d.ts", "module": "esm/index.esm.js", @@ -20,6 +20,6 @@ "./package.json": "./package.json" }, "peerDependencies": { - "firebase": "^10.14.0 || ^11.3.0" + "firebase": "^10.14.0 || ^11.3.0 || ^12.0.0" } } \ No newline at end of file diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/main/implicit.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/main/implicit.gql new file mode 100644 index 0000000000..6654507573 --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/main/implicit.gql @@ -0,0 +1,6 @@ +extend type Movie { + """ + ✨ Implicit primary key field. It's a UUID column default to a generated new value. See `@table` for how to customize it. + """ + id: UUID! @default(expr: "uuidV4()") @fdc_generated(from: "Movie", purpose: IMPLICIT_KEY_FIELD) +} diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/main/input.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/main/input.gql new file mode 100644 index 0000000000..0ea1891be2 --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/main/input.gql @@ -0,0 +1,197 @@ +""" +✨ `Movie_KeyOutput` returns the primary key fields of table type `Movie`. + +It has the same format as `Movie_Key`, but is only used as mutation return value. +""" +scalar Movie_KeyOutput +""" +✨ Generated data input type for table 'Movie'. It includes all necessary fields for creating or upserting rows into table. +""" +input Movie_Data { + """ + ✨ Generated from Field `Movie`.`id` of type `UUID!` + """ + id: UUID + """ + ✨ `_expr` server value variant of `id` (✨ Generated from Field `Movie`.`id` of type `UUID!`) + """ + id_expr: UUID_Expr + """ + ✨ Generated from Field `Movie`.`genre` of type `String` + """ + genre: String + """ + ✨ `_expr` server value variant of `genre` (✨ Generated from Field `Movie`.`genre` of type `String`) + """ + genre_expr: String_Expr + """ + ✨ Generated from Field `Movie`.`imageUrl` of type `String!` + """ + imageUrl: String + """ + ✨ `_expr` server value variant of `imageUrl` (✨ Generated from Field `Movie`.`imageUrl` of type `String!`) + """ + imageUrl_expr: String_Expr + """ + ✨ Generated from Field `Movie`.`title` of type `String!` + """ + title: String + """ + ✨ `_expr` server value variant of `title` (✨ Generated from Field `Movie`.`title` of type `String!`) + """ + title_expr: String_Expr +} +""" +✨ Generated filter input type for table 'Movie'. This input allows filtering objects using various conditions. Use `_or`, `_and`, and `_not` to compose complex filters. +""" +input Movie_Filter { + """ + Apply multiple filter conditions using `AND` logic. + """ + _and: [Movie_Filter!] + """ + Negate the result of the provided filter condition. + """ + _not: Movie_Filter + """ + Apply multiple filter conditions using `OR` logic. + """ + _or: [Movie_Filter!] + """ + ✨ Generated from Field `Movie`.`id` of type `UUID!` + """ + id: UUID_Filter + """ + ✨ Generated from Field `Movie`.`genre` of type `String` + """ + genre: String_Filter + """ + ✨ Generated from Field `Movie`.`imageUrl` of type `String!` + """ + imageUrl: String_Filter + """ + ✨ Generated from Field `Movie`.`title` of type `String!` + """ + title: String_Filter +} +""" +✨ Generated first-row input type for table 'Movie'. This input selects the first row matching the filter criteria, ordered according to the specified conditions. +""" +input Movie_FirstRow { + """ + Order the result by the specified fields. + """ + orderBy: [Movie_Order!] + """ + Filters rows based on the specified conditions. + """ + where: Movie_Filter +} +""" +✨ Generated having input type for table 'Movie'. This input allows you to filter groups during aggregate queries using various conditions. Use `_or`, `_and`, and `_not` to compose complex filters. +""" +input Movie_Having { + """ + Apply multiple Having conditions using `AND` logic. + """ + _and: [Movie_Having!] + """ + Whether to apply DISTINCT to the aggregate function. + """ + _distinct: Boolean + """ + Negate the result of the provided Having condition. + """ + _not: Movie_Having + """ + Apply multiple Having conditions using `OR` logic. + """ + _or: [Movie_Having!] + """ + ✨ Generated from Field `Movie`.`_count` of type `Int!` + """ + _count: Int_Filter + """ + ✨ Generated from Field `Movie`.`genre_count` of type `Int!` + """ + genre_count: Int_Filter + """ + ✨ Generated from Field `Movie`.`id_count` of type `Int!` + """ + id_count: Int_Filter + """ + ✨ Generated from Field `Movie`.`imageUrl_count` of type `Int!` + """ + imageUrl_count: Int_Filter + """ + ✨ Generated from Field `Movie`.`title_count` of type `Int!` + """ + title_count: Int_Filter +} +""" +✨ Generated key input type for table 'Movie'. It represents the primary key fields used to uniquely identify a row in the table. +""" +input Movie_Key { + """ + ✨ Generated from Field `Movie`.`id` of type `UUID!` + """ + id: UUID + """ + ✨ `_expr` server value variant of `id` (✨ Generated from Field `Movie`.`id` of type `UUID!`) + """ + id_expr: UUID_Expr +} +""" +✨ Generated list filter input type for table 'Movie'. This input applies filtering logic based on the count or existence of related objects that matches certain criteria. +""" +input Movie_ListFilter { + """ + The desired number of objects that match the condition (defaults to at least one). + """ + count: Int_Filter = {gt:0} + """ + Condition of the related objects to filter for. + """ + exist: Movie_Filter +} +""" +✨ Generated order input type for table 'Movie'. This input defines the sorting order of rows in query results based on one or more fields. +""" +input Movie_Order { + """ + ✨ Generated from Field `Movie`.`id` of type `UUID!` + """ + id: OrderDirection + """ + ✨ Generated from Field `Movie`.`genre` of type `String` + """ + genre: OrderDirection + """ + ✨ Generated from Field `Movie`.`imageUrl` of type `String!` + """ + imageUrl: OrderDirection + """ + ✨ Generated from Field `Movie`.`title` of type `String!` + """ + title: OrderDirection + """ + ✨ Generated from Field `Movie`.`_count` of type `Int!` + """ + _count: OrderDirection + """ + ✨ Generated from Field `Movie`.`genre_count` of type `Int!` + """ + genre_count: OrderDirection + """ + ✨ Generated from Field `Movie`.`id_count` of type `Int!` + """ + id_count: OrderDirection + """ + ✨ Generated from Field `Movie`.`imageUrl_count` of type `Int!` + """ + imageUrl_count: OrderDirection + """ + ✨ Generated from Field `Movie`.`title_count` of type `Int!` + """ + title_count: OrderDirection +} diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/main/mutation.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/main/mutation.gql new file mode 100644 index 0000000000..e4b642728b --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/main/mutation.gql @@ -0,0 +1,114 @@ +extend type Mutation { + """ + ✨ Insert a single `Movie` into the table and return its key. Columns not specified in `data` will receive defaults (e.g. `null`). + """ + movie_insert( + """ + Data object to insert into the table. + """ + data: Movie_Data! + ): Movie_KeyOutput! @fdc_generated(from: "Movie", purpose: INSERT_SINGLE) + """ + ✨ Insert `Movie` objects into the table and return their keys. Columns not specified in `data` will receive defaults (e.g. `null`). + """ + movie_insertMany( + """ + List of data objects to insert into the table. + """ + data: [Movie_Data!]! + ): [Movie_KeyOutput!]! @fdc_generated(from: "Movie", purpose: INSERT_MULTIPLE) + """ + ✨ Insert or update a single `Movie` into the table, based on the primary key. Returns the key of the newly inserted or existing updated `Movie`. + """ + movie_upsert( + """ + Data object to insert or update if it already exists. + """ + data: Movie_Data! + ): Movie_KeyOutput! @fdc_generated(from: "Movie", purpose: UPSERT_SINGLE) + """ + ✨ Insert or update `Movie` objects into the table, based on the primary key. Returns the key of the newly inserted or existing updated `Movie`. + """ + movie_upsertMany( + """ + List of data objects to insert or update if it already exists. + """ + data: [Movie_Data!]! + ): [Movie_KeyOutput!]! @fdc_generated(from: "Movie", purpose: UPSERT_MULTIPLE) + """ + ✨ Update a single `Movie` based on `id`, `key` or `first`, setting columns specified in `data`. Returns the key of the updated `Movie` or `null` if not found. + """ + movie_update( + """ + The unique ID of the object. + """ + id: UUID + + """ + The key used to identify the object. + """ + key: Movie_Key + + """ + Fetch the first row based on the filters and ordering. + """ + first: Movie_FirstRow + + """ + Data object containing fields to be updated. + """ + data: Movie_Data! + ): Movie_KeyOutput @fdc_generated(from: "Movie", purpose: UPDATE_SINGLE) + """ + ✨ Update `Movie` objects matching `where` conditions (or `all`, if true) according to `data`. Returns the number of rows updated. + """ + movie_updateMany( + """ + Filter condition to specify which rows to update. + """ + where: Movie_Filter + + """ + Set to true to update all rows. + """ + all: Boolean = false + + """ + Data object containing fields to update. + """ + data: Movie_Data! + ): Int! @fdc_generated(from: "Movie", purpose: UPDATE_MULTIPLE) + """ + ✨ Delete a single `Movie` based on `id`, `key` or `first` and return its key (or `null` if not found). + """ + movie_delete( + """ + The unique ID of the object. + """ + id: UUID + + """ + The key used to identify the object. + """ + key: Movie_Key + + """ + Fetch the first row based on the filters and ordering. + """ + first: Movie_FirstRow + ): Movie_KeyOutput @fdc_generated(from: "Movie", purpose: DELETE_SINGLE) + """ + ✨ Delete `Movie` objects matching `where` conditions (or `all`, if true). Returns the number of rows deleted. + """ + movie_deleteMany( + """ + Filter condition to specify which rows to delete. + """ + where: Movie_Filter + + """ + Set to true to delete all rows. + """ + all: Boolean = false + ): Int! @fdc_generated(from: "Movie", purpose: DELETE_MULTIPLE) +} diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/main/query.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/main/query.gql new file mode 100644 index 0000000000..a22ffa2e4b --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/main/query.gql @@ -0,0 +1,55 @@ +extend type Query { + """ + ✨ Look up a single `Movie` based on `id`, `key` or `first` and return selected fields (or `null` if not found). + """ + movie( + """ + The unique ID of the object. + """ + id: UUID + + """ + The key used to identify the object. + """ + key: Movie_Key + + """ + Fetch the first row based on the filters and ordering. + """ + first: Movie_FirstRow + ): Movie @fdc_generated(from: "Movie", purpose: QUERY_SINGLE) + """ + ✨ List `Movie` objects in the table and return selected fields, optionally filtered by `where` conditions + """ + movies( + """ + Filter condition to narrow down the query results. + """ + where: Movie_Filter + + """ + Order the query results by specific fields. + """ + orderBy: [Movie_Order!] + + """ + Number of rows to skip before starting to return the results. + """ + offset: Int + + """ + Maximum number of rows to return (defaults to 100 rows). + """ + limit: Int = 100 + + """ + Set to true to return distinct results. + """ + distinct: Boolean = false + + """ + Filter condition to apply to the groups of aggregate queries. + """ + having: Movie_Having + ): [Movie!]! @fdc_generated(from: "Movie", purpose: QUERY_MULTIPLE) +} diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/main/relation.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/main/relation.gql new file mode 100644 index 0000000000..4b4b6601e1 --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/main/relation.gql @@ -0,0 +1,50 @@ +extend type Movie { + """ + Implicit metadata field that cannot be written. It provides extra information about query results. + """ + _metadata: _Metadata @fdc_generated(from: "Movie", purpose: METADATA_FIELD) + """ + ✨ Count the number of rows in the `Movie` table. + """ + _count: Int! @fdc_generated(from: "Movie.", purpose: QUERY_COUNT) + """ + ✨ Count the number of rows in the `Movie` table where the `genre` field is non-null. Pass the `distinct` argument to instead count the number of distinct values. + """ + genre_count( + """ + Set to true to count the number of distinct values. + """ + distinct: Boolean = false + ): Int! @fdc_generated(from: "Movie.genre", purpose: QUERY_COUNT) + """ + ✨ Count the number of rows in the `Movie` table where the `id` field is non-null. Pass the `distinct` argument to instead count the number of distinct values. + """ + id_count( + """ + Set to true to count the number of distinct values. + """ + distinct: Boolean = false + ): Int! @fdc_generated(from: "Movie.id", purpose: QUERY_COUNT) + """ + ✨ Count the number of rows in the `Movie` table where the `imageUrl` field is non-null. Pass the `distinct` argument to instead count the number of distinct values. + """ + imageUrl_count( + """ + Set to true to count the number of distinct values. + """ + distinct: Boolean = false + ): Int! @fdc_generated(from: "Movie.imageUrl", purpose: QUERY_COUNT) + """ + ✨ Count the number of rows in the `Movie` table where the `title` field is non-null. Pass the `distinct` argument to instead count the number of distinct values. + """ + title_count( + """ + Set to true to count the number of distinct values. + """ + distinct: Boolean = false + ): Int! @fdc_generated(from: "Movie.title", purpose: QUERY_COUNT) + """ + A generated field that is used for caching results in SDKs. + """ + _id: ID! @fdc_generated(from: "Movie.id", purpose: GLOBAL_ID) +} diff --git a/e2e/data-connect/dataconnect/.dataconnect/schema/prelude.gql b/e2e/data-connect/dataconnect/.dataconnect/schema/prelude.gql new file mode 100644 index 0000000000..28894fd7e9 --- /dev/null +++ b/e2e/data-connect/dataconnect/.dataconnect/schema/prelude.gql @@ -0,0 +1,2194 @@ +"AccessLevel specifies coarse access policies for common situations." +enum AccessLevel @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + """ + This operation is accessible to anyone, with or without authentication. + Equivalent to: `@auth(expr: "true")` + """ + PUBLIC + + """ + This operation can be executed only with a valid Firebase Auth ID token. + **Note:** This access level allows anonymous and unverified accounts, + which may present security and abuse risks. + Equivalent to: `@auth(expr: "auth.uid != nil")` + """ + USER_ANON + + """ + This operation is restricted to non-anonymous Firebase Auth accounts. + Equivalent to: `@auth(expr: "auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'")` + """ + USER + + """ + This operation is restricted to Firebase Auth accounts with verified email addresses. + Equivalent to: `@auth(expr: "auth.uid != nil && auth.token.email_verified")` + """ + USER_EMAIL_VERIFIED + + """ + This operation cannot be executed by anyone. The operation can only be performed + by using the Admin SDK from a privileged environment. + Equivalent to: `@auth(expr: "false")` + """ + NO_ACCESS +} + +""" +The `@auth` directive defines the authentication policy for a query or mutation. + +It must be added to any operation that you wish to be accessible from a client +application. If not specified, the operation defaults to `@auth(level: NO_ACCESS)`. + +Refer to [Data Connect Auth Guide](https://firebase.google.com/docs/data-connect/authorization-and-security) for the best practices. +""" +directive @auth( + """ + The minimal level of access required to perform this operation. + Exactly one of `level` and `expr` should be specified. + """ + level: AccessLevel @fdc_oneOf(required: true) + """ + A CEL expression that grants access to this operation if the expression + evaluates to `true`. + Exactly one of `level` and `expr` should be specified. + """ + expr: Boolean_Expr @fdc_oneOf(required: true) + """ + If the `@auth` on this operation is considered insecure, then developer + acknowledgement is required to deploy this operation, for new operations. + `@auth` is considered insecure if `level: PUBLIC`, or if + `level: USER/USER_ANON/USER_EMAIL_VERIFIED` and `auth.uid` is not referenced + in the operation. + If `insecureReason` is set, no further developer acknowledgement is needed. + """ + insecureReason: String +) on QUERY | MUTATION + +""" +Require that this mutation always run in a DB transaction. + +Mutations with `@transaction` are guaranteed to either fully succeed or fully +fail. Upon the first error in a transaction (either an execution error or failed +`@check`), the transaction will be rolled back. In the GraphQL response, all +fields within the transaction will be `null`, each with an error raised. + +- Fields that have been already evaluated will be nullified due to the rollback + and a "(rolled back)" error will be reported on each of them. +- The execution error or failed `@check` will be reported on the current field. +- Subsequent fields will not be executed. An `(aborted)` error will be reported + on each subsequent field. + +Mutations without `@transaction` would execute each root field one after +another in sequence. They surface any errors as partial +[field errors](https://spec.graphql.org/October2021/#sec-Errors.Field-errors), +but does not impact the execution of subsequent fields. However, failed +`@check`s still terminate the entire operation. + +The `@transaction` directive cannot be added to queries for now. +Currently, queries cannot fail partially, the response data is not guaranteed +to be a consistent snapshot. +""" +directive @transaction on MUTATION + +""" +Redact a part of the response from the client. + +Redacted fields are still evaluated for side effects (including data changes and +`@check`) and the results are still available to later steps in CEL expressions +(via `response.fieldName`). +""" +directive @redact on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Ensure this field is present and is not null or `[]`, or abort the request / transaction. + +A CEL expression, `expr` is used to test the field value. It defaults to +rejecting null and `[]` but a custom expression can be provided instead. + +If the field occurs multiple times (i.e. directly or indirectly nested under a +list), `expr` will be executed once for each occurrence and `@check` succeeds if +all values succeed. `@check` fails when the field is not present at all (i.e. +all ancestor paths contain `null` or `[]`), unless `optional` is true. + +If a `@check` fails in a mutation, the top-level field containing it will be +replaced with a partial error, whose message can be customzied via the `message` +argument. Each subsequent top-level fields will return an aborted error (i.e. +not executed). To rollback previous steps, see `@transaction`. +""" +directive @check( + """ + The CEL expression to test the field value (or values if nested under a list). + + Within the CEL expression, a special value `this` evaluates to the field that + this directive is attached to. If this field occurs multiple times because + any ancestor is a list, each occurrence is tested with `this` bound to each + value. When the field itself is a list or object, `this` follows the same + structure (including all descendants selected in case of objects). + + For any given path, if an ancestor is `null` or `[]`, the field will not be + reached and the CEL evaluation will be skipped for that path. In other words, + evaluation only takes place when `this` is `null` or non-null, but never + undefined. (See also the `optional` argument.) + """ + expr: Boolean_Expr! = "!(this in [null, []])" + """ + The error message to return to the client if the check fails. + + Defaults to "permission denied" if not specified. + """ + message: String! = "permission denied" + """ + Whether the check should pass or fail (default) when the field is not present. + + A field will not be reached at a given path if its parent or any ancestor is + `[]` or `null`. When this happens to all paths, the field will not be present + anywhere in the response tree. In other words, `expr` is evaluated 0 times. + By default, @check will automatically fail in this case. Set this argument to + `true` to make it pass even if no tests are run (a.k.a. "vacuously true"). + """ + optional: Boolean = false +) repeatable on QUERY | MUTATION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Marks an element of a GraphQL operation as no longer supported for client use. +The Firebase Data Connect backend will continue supporting this element, +but it will no longer be visible in the generated SDKs. +""" +directive @retired( + "Provides the reason for retirement." + reason: String +) on QUERY | MUTATION | FIELD | VARIABLE_DEFINITION + +"Query filter criteria for `String` scalar fields." +input String_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: String @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. Currently only `auth.uid` is supported as an expression. + """ + eq_expr: String_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: String @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. Currently only `auth.uid` is supported as an expression. + """ + ne_expr: String_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [String!] + "Match if field value is not among the provided list of values." + nin: [String!] + "Match if field value is greater than the provided value." + gt: String + "Match if field value is greater than or equal to the provided value." + ge: String + "Match if field value is less than the provided value." + lt: String + "Match if field value is less than or equal to the provided value." + le: String + """ + Match if field value contains the provided value as a substring. Equivalent + to `LIKE '%value%'` + """ + contains: String + """ + Match if field value starts with the provided value. Equivalent to + `LIKE 'value%'` + """ + startsWith: String + """ + Match if field value ends with the provided value. Equivalent to + `LIKE '%value'` + """ + endsWith: String + """ + Match based on the provided pattern. + """ + pattern: String_Pattern +} + +input String_Pattern { + """ + Match using LIKE semantics (https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE) + """ + like: String @fdc_oneOf + """ + Match against a POSIX regular expression. + """ + regex: String @fdc_oneOf + """ + If true, match patterns case-insensitively. + """ + ignoreCase: Boolean +} + +"Query filter criteris for `[String!]` scalar fields." +input String_ListFilter { + "Match if list field contains the provided value as a member." + includes: String + "Match if list field does not contain the provided value as a member." + excludes: String + "Match if list field contains all of the provided values as members." + includesAll: [String!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [String!] +} + +"Query filter criteria for `UUID` scalar fields." +input UUID_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: UUID @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. + """ + eq_expr: UUID_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: UUID @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. + """ + ne_expr: UUID_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [UUID!] + "Match if field value is not among the provided list of values." + nin: [UUID!] +} + +"Query filter criteris for `[UUID!]` scalar fields." +input UUID_ListFilter { + "Match if list field contains the provided value as a member." + includes: UUID + "Match if list field does not contain the provided value as a member." + excludes: UUID + "Match if list field contains all of the provided values as members." + includesAll: [UUID!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [UUID!] +} + +"Query filter criteria for `Int` scalar fields." +input Int_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: Int @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. + """ + eq_expr: Int_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: Int @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. + """ + ne_expr: Int_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [Int!] + "Match if field value is not among the provided list of values." + nin: [Int!] + "Match if field value is greater than the provided value." + gt: Int @fdc_oneOf(group: "gt") + """ + Match if field value is greater than the result of the provided server value + expression. + """ + gt_expr: Int_Expr @fdc_oneOf(group: "gt") + "Match if field value is greater than or equal to the provided value." + ge: Int @fdc_oneOf(group: "ge") + """ + Match if field value is greater than or equal to the result of the provided + server value expression. + """ + ge_expr: Int_Expr @fdc_oneOf(group: "ge") + "Match if field value is less than the provided value." + lt: Int @fdc_oneOf(group: "lt") + """ + Match if field value is less than the result of the provided server value + expression. + """ + lt_expr: Int_Expr @fdc_oneOf(group: "lt") + "Match if field value is less than or equal to the provided value." + le: Int @fdc_oneOf(group: "le") + """ + Match if field value is less than or equal to the result of the provided + server value expression. + """ + le_expr: Int_Expr @fdc_oneOf(group: "le") +} + +"Query filter criteris for `[Int!]` scalar fields." +input Int_ListFilter { + "Match if list field contains the provided value as a member." + includes: Int + "Match if list field does not contain the provided value as a member." + excludes: Int + "Match if list field contains all of the provided values as members." + includesAll: [Int!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [Int!] +} + +"Query filter criteria for `Int64` scalar fields." +input Int64_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: Int64 @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. + """ + eq_expr: Int64_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: Int64 @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. + """ + ne_expr: Int64_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [Int64!] + "Match if field value is not among the provided list of values." + nin: [Int64!] + "Match if field value is greater than the provided value." + gt: Int64 @fdc_oneOf(group: "gt") + """ + Match if field value is greater than the result of the provided server value + expression. + """ + gt_expr: Int64_Expr @fdc_oneOf(group: "gt") + "Match if field value is greater than or equal to the provided value." + ge: Int64 @fdc_oneOf(group: "ge") + """ + Match if field value is greater than or equal to the result of the provided + server value expression. + """ + ge_expr: Int64_Expr @fdc_oneOf(group: "ge") + "Match if field value is less than the provided value." + lt: Int64 @fdc_oneOf(group: "lt") + """ + Match if field value is less than the result of the provided server value + expression. + """ + lt_expr: Int64_Expr @fdc_oneOf(group: "lt") + "Match if field value is less than or equal to the provided value." + le: Int64 @fdc_oneOf(group: "le") + """ + Match if field value is less than or equal to the result of the provided + server value expression. + """ + le_expr: Int64_Expr @fdc_oneOf(group: "le") +} + +"Query filter criteria for `[Int64!]` scalar fields." +input Int64_ListFilter { + "Match if list field contains the provided value as a member." + includes: Int64 + "Match if list field does not contain the provided value as a member." + excludes: Int64 + "Match if list field contains all of the provided values as members." + includesAll: [Int64!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [Int64!] +} + +"Query filter criteria for `Float` scalar fields." +input Float_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: Float @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. + """ + eq_expr: Float_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: Float @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. + """ + ne_expr: Float_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [Float!] + "Match if field value is not among the provided list of values." + nin: [Float!] + "Match if field value is greater than the provided value." + gt: Float @fdc_oneOf(group: "gt") + """ + Match if field value is greater than the result of the provided server value + expression. + """ + gt_expr: Float_Expr @fdc_oneOf(group: "gt") + "Match if field value is greater than or equal to the provided value." + ge: Float @fdc_oneOf(group: "ge") + """ + Match if field value is greater than or equal to the result of the provided + server value expression. + """ + ge_expr: Float_Expr @fdc_oneOf(group: "ge") + "Match if field value is less than the provided value." + lt: Float @fdc_oneOf(group: "lt") + """ + Match if field value is less than the result of the provided server value + expression. + """ + lt_expr: Float_Expr @fdc_oneOf(group: "lt") + "Match if field value is less than or equal to the provided value." + le: Float @fdc_oneOf(group: "le") + """ + Match if field value is less than or equal to the result of the provided + server value expression. + """ + le_expr: Float_Expr @fdc_oneOf(group: "le") +} + +"Query filter criteria for `[Float!]` scalar fields." +input Float_ListFilter { + "Match if list field contains the provided value as a member." + includes: Float + "Match if list field does not contain the provided value as a member." + excludes: Float + "Match if list field contains all of the provided values as members." + includesAll: [Float!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [Float!] +} + +"Query filter criteria for `Boolean` scalar fields." +input Boolean_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: Boolean @fdc_oneOf(group: "eq") + "Match if field is equal to the result of the provided expression." + eq_expr: Boolean_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: Boolean @fdc_oneOf(group: "ne") + """ + Match if field does not match the result of the provided expression. + """ + ne_expr: Boolean_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [Boolean!] + "Match if field value is not among the provided list of values." + nin: [Boolean!] +} + +"Query filter criteria for `[Boolean!]` scalar fields." +input Boolean_ListFilter { + "Match if list field contains the provided value as a member." + includes: Boolean + "Match if list field does not contain the provided value as a member." + excludes: Boolean + "Match if list field contains all of the provided values as members." + includesAll: [Boolean!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [Boolean!] +} + +"Query filter criteria for `Any` scalar fields." +input Any_Filter { + "When true, match if field `IS NULL`. When false, match if field is `NOT NULL`." + isNull: Boolean + "Match if field is exactly equal to provided value." + eq: Any @fdc_oneOf(group: "eq") + """ + Match if field is exactly equal to the result of the provided server value + expression. + """ + eq_expr: Any_Expr @fdc_oneOf(group: "eq") + "Match if field is not equal to provided value." + ne: Any @fdc_oneOf(group: "ne") + """ + Match if field is not equal to the result of the provided server value + expression. + """ + ne_expr: Any_Expr @fdc_oneOf(group: "ne") + "Match if field value is among the provided list of values." + in: [Any!] + "Match if field value is not among the provided list of values." + nin: [Any!] +} + +"Query filter criteria for `[Any!]` scalar fields." +input Any_ListFilter { + "Match if list field contains the provided value as a member." + includes: Any + "Match if list field does not contain the provided value as a member." + excludes: Any + "Match if list field contains all of the provided values as members." + includesAll: [Any!] + "Match if list field does not contain any of the provided values as members." + excludesAll: [Any!] +} + +""" +Mark a string field as searchable. +When this directive is added, the field will be indexed for full-text search, +and a _search field will be generated at the query root. +This directive accepts a `language` argument that defaults to `"english"` in +case no value is specified. +See: +- go/fdc-full-text-search +""" +directive @searchable( + """ + Language of the fields that you are searching over can be specified here + (e.g. "french", "spanish", etc.). + Defaults to "english" if not specified. + """ + language: String = "english") on FIELD_DEFINITION + +extend type _Metadata { + # During full text search, the relevance of the query term to this row. + # In other cases, this field is not set. + relevance: Float +} + + +enum Search_QueryFormat @fdc_forbiddenAsFieldType { + """ + Allows search engine style semantics (e.g. quoted strings, AND and OR). + """ + QUERY, + """ + Splits the query into words and does ANDs between them. + """ + PLAIN, + """ + Matches an exact phrase. Requires the words to be in the same order (i.e. "brown + dog" will not match "brown and red dog"). + """ + PHRASE, + """ + Create complex queries using the full set of tsquery operators. + """ + ADVANCED, +} + +""" +(Internal) A string that uniquely identifies a type, field, and so on. + +The most common usage in FDC is `SomeType` or `SomeType.someField`. See the +linked page in the @specifiedBy directive for the GraphQL RFC with more details. +""" +scalar SchemaCoordinate + @specifiedBy(url: "https://github.com/graphql/graphql-wg/blob/6d02705dea034fb65ebc6799632adb7bd550d0aa/rfcs/SchemaCoordinates.md") + @fdc_forbiddenAsFieldType + @fdc_forbiddenAsVariableType + +"(Internal) The purpose of a generated type or field." +enum GeneratedPurpose @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + # Implicit fields added to the table types as columns. + IMPLICIT_KEY_FIELD + IMPLICIT_REF_FIELD + + # Generated static fields extended to table types. + METADATA_FIELD + + # Relational non-column fields extended to table types. + QUERY_MULTIPLE_ONE_TO_MANY + QUERY_MULTIPLE_MANY_TO_MANY + + # Generated fields for aggregates + QUERY_COUNT + QUERY_SUM + QUERY_AVG + QUERY_MIN + QUERY_MAX + + # Generated field for full text search + QUERY_MULTIPLE_BY_FULL_TEXT_SEARCH + + # Top-level Query fields. + QUERY_SINGLE + QUERY_MULTIPLE + QUERY_MULTIPLE_BY_SIMILARITY + + # Top-level Mutation fields. + INSERT_SINGLE + INSERT_MULTIPLE + UPSERT_SINGLE + UPSERT_MULTIPLE + UPDATE_SINGLE + UPDATE_MULTIPLE + DELETE_SINGLE + DELETE_MULTIPLE +} + +"(Internal) Added to definitions generated by FDC." +directive @fdc_generated( + "The source type or field that causes this definition to be generated." + from: SchemaCoordinate! + "The reason why this definition is generated, such as the intended use case." + purpose: GeneratedPurpose! +) on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +type _Service { + "Full Service Definition Language of the Frebase Data Connect Schema, including normalized schema, predefined and generated types." + sdl( + """ + Whether or not to omit Data Connect builtin GraphQL preludes. + They are static GraphQL publically available in the docsite. + """ + omitBuiltin: Boolean = false + """ + Whether or not to omit GQL description in the SDL. + We generate description to document generated schema. + It may bloat the size of SDL. + """ + omitDescription: Boolean = false + ): String! + "Orignal Schema Sources in the service." + schema: String! + "Generated documentation from the schema of the Firebase Data Connect Service." + docs: [_Doc!]! +} + +type _Doc { + "Name of the Doc Page." + page: String! + "The markdown content of the doc page." + markdown: String! +} + +"(Internal) Added to scalars representing quoted CEL expressions." +directive @fdc_celExpression( + "The expected CEL type that the expression should evaluate to." + returnType: String +) on SCALAR + +"(Internal) Added to scalars representing quoted SQL expressions." +directive @fdc_sqlExpression( + "The expected SQL type that the expression should evaluate to." + dataType: String +) on SCALAR + +"(Internal) Added to types that may not be used as variables." +directive @fdc_forbiddenAsVariableType on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"(Internal) Added to types that may not be used as fields in schema." +directive @fdc_forbiddenAsFieldType on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"Provides a frequently used example for this type / field / argument." +directive @fdc_example( + "A GraphQL literal value (verbatim) whose type matches the target." + value: Any + "A human-readable text description of what `value` means in this context." + description: String +) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +"(Internal) Marks this field / argument as conflicting with others in the same group." +directive @fdc_oneOf( + "The group name where fields / arguments conflict with each other." + group: String! = "" + "If true, exactly one field / argument in the group must be specified." + required: Boolean! = false +) repeatable on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + +""" +The `_Metadata` type is used to return metadata about a field in a response. +""" +type _Metadata { + # During vector similarity search, the distance between the query vector and + # this row's vector. In other cases, this field is not set. + distance: Float +} + +type Mutation { + """ + Run a query during the mutation and add fields into the response. + + Example: foo: query { users { id } } will add a field foo: {users: [{id: "..."}, …]} into the response JSON. + + Note: Data fetched this way can be handy for permission checks. See @check. + """ + query: Query +} + +""" +`UUID` is a string of hexadecimal digits representing an RFC4122-compliant UUID. + +UUIDs are always output as 32 lowercase hexadecimal digits without delimiters or +curly braces. +Inputs in the following formats are also accepted (case insensitive): + +- `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` +- `urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` +- `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` + +In the PostgreSQL table, it's stored as [`uuid`](https://www.postgresql.org/docs/current/datatype-uuid.html). +""" +scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") + +""" +`Int64` is a scalar that represents a 64-bit signed integer. + +In the PostgreSQL table, it's stored as [`bigint`](https://www.postgresql.org/docs/current/datatype-numeric.html). + +On the wire, it's encoded as string because 64-bit integer exceeds the range of JSON number. +""" +scalar Int64 + +""" +The `Any` scalar type accommodates any valid [JSON value](https://www.json.org/json-en.html) +(e.g., numbers, strings, booleans, arrays, objects). PostgreSQL efficiently +stores this data as jsonb, providing flexibility for schemas with evolving structures. + +Caution: JSON doesn't distinguish Int and Float. + +##### Example: + +#### Schema + +```graphql +type Movie @table { + name: String! + metadata: Any! +} +``` + +#### Mutation + +Insert a movie with name and metadata from JSON literal. + +```graphql +mutation InsertMovie { + movie_insert( + data: { + name: "The Dark Knight" + metadata: { + release_year: 2008 + genre: ["Action", "Adventure", "Superhero"] + cast: [ + { name: "Christopher Bale", age: 31 } + { name: "Heath Ledger", age: 28 } + ] + director: "Christopher Nolan" + } + } + ) +} +``` + +Insert a movie with name and metadata that's constructed from a few GQL variables. + +```graphql +mutation InsertMovie($name: String!, $releaseDate: Date!, $genre: [String], $cast: [Any], $director: String!, $boxOfficeInUSD: Int) { + movie_insert(data: { + name: $name, + release_date: $releaseDate, + genre: $genre, + cast: $cast, + director: $director, + box_office: $boxOfficeInUSD + }) +} +``` +**Note**: + + - A mix of non-null and nullable variables can be provided. + + - `Date!` can be passed into scalar `Any` as well! It's stored as string. + + - `$cast` is a nested array. `[Any]` can represent an array of arbitrary types, but it won't enforce the input shape. + +#### Query + +Since `metadata` field has scalar `Any` type, it would return the full JSON in the response. + +**Note**: You can't define selection set to scalar based on [GraphQL spec](https://spec.graphql.org/October2021/#sec-Field-Selections). + +```graphql +query GetAllMovies { + movies { + name + metadata + } +} +``` + +""" +scalar Any @specifiedBy(url: "https://www.json.org/json-en.html") + +""" +The `Void` scalar type represents the absence of any value. It is typically used +in operations where no value is expected in return. +""" +scalar Void @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType + +""" +The `True` scalar type only accepts the boolean value `true`. + +An optional field/argument typed as `True` may either be set +to `true` or omitted (not provided at all). The values `false` or `null` are not +accepted. +""" +scalar True + @fdc_forbiddenAsFieldType + @fdc_forbiddenAsVariableType + @fdc_example(value: true, description: "The only allowed value.") + +""" +A Common Expression Language (CEL) expression that returns a boolean at runtime. + +This expression can reference the `auth` variable, which is null when Firebase +Auth is not used. When Firebase Auth is used, the following fields are available: + + - `auth.uid`: The current user ID. + - `auth.token`: A map containing all token fields (e.g., claims). + +""" +scalar Boolean_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "bool") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "auth != null", description: "Allow only if a Firebase Auth user is present.") + +""" +A Common Expression Language (CEL) expression that returns a string at runtime. + +**Limitation**: Currently, only a limited set of expressions are supported. +""" +scalar String_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "string") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "auth.uid", description: "The ID of the currently logged in user in Firebase Auth. (Errors if not logged in.)") + @fdc_example(value: "uuidV4()", description: "Generates a new random UUID (version 4) string, formatted as 32 lower-case hex digits without delimiters.") + +""" +A Common Expression Language (CEL) expression that returns a UUID string at runtime. + +**Limitation**: Currently, only a limited set of expressions are supported. +""" +scalar UUID_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "string") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "uuidV4()", description: "Generates a new random UUID (version 4) every time.") + +""" +A Common Expression Language (CEL) expression that returns a Int at runtime. +""" +scalar Int_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "int") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "2 * 4", description: "Evaluates to 8.") + @fdc_example(value: "vars.foo.size()", description: "Assuming `vars.foo` is a string, it will evaluate to the length of the string.") + + +""" +A Common Expression Language (CEL) expression that returns a Int64 at runtime. +""" +scalar Int64_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "int64") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "5000*1000*1000", description: "Evaluates to 5e9.") + +""" +A Common Expression Language (CEL) expression that returns a Float at runtime. +""" +scalar Float_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "float") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "2.0 * 4.0", description: "Evaluates to 8.0.") + +""" +A Common Expression Language (CEL) expression whose return type is valid JSON. + +Examples: + - `{'A' : 'B'}` (Evaluates to a JSON object.) + - `['A', 'B']` (Evaluates to a JSON array.) + - `{'A' 1, 'B': [1, 2, {'foo': 'bar'}]}` (Nested JSON objects and arrays.) +""" +scalar Any_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + +""" +A PostgreSQL value expression whose return type is unspecified. +""" +scalar Any_SQL + @specifiedBy(url: "https://www.postgresql.org/docs/current/sql-expressions.html") + @fdc_sqlExpression + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + +""" +Defines a relational database table. + +In this example, we defined one table with a field named `myField`. + +```graphql +type TableName @table { + myField: String +} +``` +Data Connect adds an implicit `id` primary key column. So the above schema is equivalent to: + +```graphql +type TableName @table(key: "id") { + id: String @default(expr: "uuidV4()") + myField: String +} +``` + +Data Connect generates the following SQL table and CRUD operations to use it. + +```sql +CREATE TABLE "public"."table_name" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "my_field" text NULL, + PRIMARY KEY ("id") +) +``` + + * You can lookup a row: `query ($id: UUID!) { tableName(id: $id) { myField } } ` + * You can find rows using: `query tableNames(limit: 20) { myField }` + * You can insert a row: `mutation { tableName_insert(data: {myField: "foo"}) }` + * You can update a row: `mutation ($id: UUID!) { tableName_update(id: $id, data: {myField: "bar"}) }` + * You can delete a row: `mutation ($id: UUID!) { tableName_delete(id: $id) }` + +##### Customizations + +- `@table(singular)` and `@table(plural)` can customize the singular and plural name. +- `@table(name)` can customize the Postgres table name. +- `@table(key)` can customize the primary key field name and type. + +For example, the `User` table often has a `uid` as its primary key. + +```graphql +type User @table(key: "uid") { + uid: String! + name: String +} +``` + + * You can securely lookup a row: `query { user(key: {uid_expr: "auth.uid"}) { name } } ` + * You can securely insert a row: `mutation { user_insert(data: {uid_expr: "auth.uid" name: "Fred"}) }` + * You can securely update a row: `mutation { user_update(key: {uid_expr: "auth.uid"}, data: {name: "New Name"}) }` + * You can securely delete a row: `mutation { user_delete(key: {uid_expr: "auth.uid"}) }` + +`@table` type can be configured further with: + + - Custom SQL data types for columns. See `@col`. + - Add SQL indexes. See `@index`. + - Add SQL unique constraints. See `@unique`. + - Add foreign key constraints to define relations. See `@ref`. + +""" +directive @table( + """ + Configures the SQL database table name. Defaults to snake_case like `table_name`. + """ + name: String + """ + Configures the singular name. Defaults to the camelCase like `tableName`. + """ + singular: String + """ + Configures the plural name. Defaults to infer based on English plural pattern like `tableNames`. + """ + plural: String + """ + Defines the primary key of the table. Defaults to a single field named `id`. + If not present already, Data Connect adds an implicit field `id: UUID! @default(expr: "uuidV4()")`. + """ + key: [String!] +) on OBJECT + +""" +Defines a relational database Raw SQLview. + +Data Connect generates GraphQL queries with WHERE and ORDER BY clauses. +However, not all SQL features has native GraphQL equivalent. + +You can write **an arbitrary SQL SELECT statement**. Data Connect +would map Graphql fields on `@view` type to columns in your SELECT statement. + +* Scalar GQL fields (camelCase) should match a SQL column (snake_case) + in the SQL SELECT statement. +* Reference GQL field can point to another `@table` type. Similar to foreign key + defined with `@ref` on a `@table` type, a `@view` type establishes a relation + when `@ref(fields)` match `@ref(references)` on the target table. + +In this example, you can use `@view(sql)` to define an aggregation view on existing +table. + +```graphql +type User @table { + name: String + score: Int +} +type UserAggregation @view(sql: ''' + SELECT + COUNT(*) as count, + SUM(score) as sum, + AVG(score) as average, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY score) AS median, + (SELECT id FROM "user" LIMIT 1) as example_id + FROM "user" +''') { + count: Int + sum: Int + average: Float + median: Float + example: User + exampleId: UUID +} +``` + +###### Example: Query Raw SQL View + +```graphql +query { + userAggregations { + count sum average median + exampleId example { id } + } +} +``` + +##### One-to-One View + +An one-to-one companion `@view` can be handy if you want to argument a `@table` +with additional implied content. + +```graphql +type Restaurant @table { + name: String! +} +type Review @table { + restaurant: Restaurant! + rating: Int! +} +type RestaurantStats @view(sql: ''' + SELECT + restaurant_id, + COUNT(*) AS review_count, + AVG(rating) AS average_rating + FROM review + GROUP BY restaurant_id +''') { + restaurant: Restaurant @unique + reviewCount: Int + averageRating: Float +} +``` + +In this example, `@unique` convey the assumption that each `Restaurant` should +have only one `RestaurantStats` object. + +###### Example: Query One-to-One View + +```graphql +query ListRestaurants { + restaurants { + name + stats: restaurantStats_on_restaurant { + reviewCount + averageRating + } + } +} +``` + +###### Example: Filter based on One-to-One View + +```graphql +query BestRestaurants($minAvgRating: Float, $minReviewCount: Int) { + restaurants(where: { + restaurantStats_on_restaurant: { + averageRating: {ge: $minAvgRating} + reviewCount: {ge: $minReviewCount} + } + }) { name } +} +``` + +##### Customizations + +- One of `@view(sql)` or `@view(name)` should be defined. + `@view(name)` can refer to a persisted SQL view in the Postgres schema. +- `@view(singular)` and `@view(plural)` can customize the singular and plural name. + +`@view` type can be configured further: + + - `@unique` lets you define one-to-one relation. + - `@col` lets you customize SQL column mapping. For example, `@col(name: "column_in_select")`. + +##### Limitations + +Raw SQL view doesn't have a primary key, so it doesn't support lookup. Other +`@table` or `@view` cannot have `@ref` to a view either. + +View cannot be mutated. You can perform CRUD operations on the underlying +table to alter its content. + +**Important: Data Connect doesn't parse and validate SQL** + +- If the SQL view is invalid or undefined, related requests may fail. +- If the SQL view return incompatible types. Firebase Data Connect may surface + errors. +- If a field doesn't have a corresponding column in the SQL SELECT statement, + it will always be `null`. +- There is no way to ensure VIEW to TABLE `@ref` constraint. +- All fields must be nullable in case they aren't found in the SELECT statement + or in the referenced table. + +**Important: You should always test `@view`!** + +""" +directive @view( + """ + The SQL view name. If neither `name` nor `sql` are provided, defaults to the + snake_case of the singular type name. + `name` and `sql` cannot be specified at the same time. + """ + name: String @fdc_oneOf + """ + SQL `SELECT` statement used as the basis for this type. + SQL SELECT columns should use snake_case. GraphQL fields should use camelCase. + `name` and `sql` cannot be specified at the same time. + """ + sql: String @fdc_oneOf + """ + Configures the singular name. Defaults to the camelCase like `viewName`. + """ + singular: String + """ + Configures the plural name. Defaults to infer based on English plural pattern like `viewNames`. + """ + plural: String +) on OBJECT + +""" +Customizes a field that represents a SQL database table column. + +Data Connect maps scalar Fields on `@table` type to a SQL column of +corresponding data type. + +- scalar `UUID` maps to [`uuid`](https://www.postgresql.org/docs/current/datatype-uuid.html). +- scalar `String` maps to [`text`](https://www.postgresql.org/docs/current/datatype-character.html). +- scalar `Int` maps to [`int`](https://www.postgresql.org/docs/current/datatype-numeric.html). +- scalar `Int64` maps to [`bigint`](https://www.postgresql.org/docs/current/datatype-numeric.html). +- scalar `Float` maps to [`double precision`](https://www.postgresql.org/docs/current/datatype-numeric.html). +- scalar `Boolean` maps to [`boolean`](https://www.postgresql.org/docs/current/datatype-boolean.html). +- scalar `Date` maps to [`date`](https://www.postgresql.org/docs/current/datatype-datetime.html). +- scalar `Timestamp` maps to [`timestamptz`](https://www.postgresql.org/docs/current/datatype-datetime.html). +- scalar `Any` maps to [`jsonb`](https://www.postgresql.org/docs/current/datatype-json.html). +- scalar `Vector` maps to [`pgvector`](https://github.com/pgvector/pgvector). + +Array scalar fields are mapped to [Postgres arrays](https://www.postgresql.org/docs/current/arrays.html). + +###### Example: Serial Primary Key + +For example, you can define auto-increment primary key. + +```graphql +type Post @table { + id: Int! @col(name: "post_id", dataType: "serial") +} +``` + +Data Connect converts it to the following SQL table schema. + +```sql +CREATE TABLE "public"."post" ( + "post_id" serial NOT NULL, + PRIMARY KEY ("id") +) +``` + +###### Example: Vector + +```graphql +type Post @table { + content: String! @col(name: "post_content") + contentEmbedding: Vector! @col(size:768) +} +``` + +""" +directive @col( + """ + The SQL database column name. Defaults to snake_case of the field name. + """ + name: String + """ + Configures the custom SQL data type. + + Each GraphQL type can map to multiple SQL data types. + Refer to [Postgres supported data types](https://www.postgresql.org/docs/current/datatype.html). + + Incompatible SQL data type will lead to undefined behavior. + """ + dataType: String + """ + Required on `Vector` columns. It specifies the length of the Vector. + `textembedding-gecko@003` model generates `Vector` of `@col(size:768)`. + """ + size: Int +) on FIELD_DEFINITION + + +""" +Defines a foreign key reference to another table. + +For example, we can define a many-to-one relation. + +```graphql +type ManyTable @table { + refField: OneTable! +} +type OneTable @table { + someField: String! +} +``` +Data Connect adds implicit foreign key column and relation query field. So the +above schema is equivalent to the following schema. + +```graphql +type ManyTable @table { + id: UUID! @default(expr: "uuidV4()") + refField: OneTable! @ref(fields: "refFieldId", references: "id") + refFieldId: UUID! +} +type OneTable @table { + id: UUID! @default(expr: "uuidV4()") + someField: UUID! + # Generated Fields: + # manyTables_on_refField: [ManyTable!]! +} +``` +Data Connect generates the necessary foreign key constraint. + +```sql +CREATE TABLE "public"."many_table" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ref_field_id" uuid NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "many_table_ref_field_id_fkey" FOREIGN KEY ("ref_field_id") REFERENCES "public"."one_table" ("id") ON DELETE CASCADE +) +``` + +###### Example: Traverse the Reference Field + +```graphql +query ($id: UUID!) { + manyTable(id: $id) { + refField { id } + } +} +``` + +###### Example: Reverse Traverse the Reference field + +```graphql +query ($id: UUID!) { + oneTable(id: $id) { + manyTables_on_refField { id } + } +} +``` + +##### Optional Many-to-One Relation + +An optional foreign key reference will be set to null if the referenced row is deleted. + +In this example, if a `User` is deleted, the `assignee` and `reporter` +references will be set to null. + +```graphql +type Bug @table { + title: String! + assignee: User + reproter: User +} + +type User @table { name: String! } +``` + +##### Required Many-to-One Relation + +A required foreign key reference will cascade delete if the referenced row is +deleted. + +In this example, if a `Post` is deleted, associated comments will also be +deleted. + +```graphql +type Comment @table { + post: Post! + content: String! +} + +type Post @table { title: String! } +``` + +##### Many To Many Relation + +You can define a many-to-many relation with a join table. + +```graphql +type Membership @table(key: ["group", "user"]) { + group: Group! + user: User! + role: String! @default(value: "member") +} + +type Group @table { name: String! } +type User @table { name: String! } +``` + +When Data Connect sees a table with two reference field as its primary key, it +knows this is a join table, so expands the many-to-many query field. + +```graphql +type Group @table { + name: String! + # Generated Fields: + # users_via_Membership: [User!]! + # memberships_on_group: [Membership!]! +} +type User @table { + name: String! + # Generated Fields: + # groups_via_Membership: [Group!]! + # memberships_on_user: [Membership!]! +} +``` + +###### Example: Traverse the Many-To-Many Relation + +```graphql +query ($id: UUID!) { + group(id: $id) { + users: users_via_Membership { + name + } + } +} +``` + +###### Example: Traverse to the Join Table + +```graphql +query ($id: UUID!) { + group(id: $id) { + memberships: memberships_on_group { + user { name } + role + } + } +} +``` + +##### One To One Relation + +You can even define a one-to-one relation with the help of `@unique` or `@table(key)`. + +```graphql +type User @table { + name: String +} +type Account @table { + user: User! @unique +} +# Alternatively, use primary key constraint. +# type Account @table(key: "user") { +# user: User! +# } +``` + +###### Example: Transerse the Reference Field + +```graphql +query ($id: UUID!) { + account(id: $id) { + user { id } + } +} +``` + +###### Example: Reverse Traverse the Reference field + +```graphql +query ($id: UUID!) { + user(id: $id) { + account_on_user { id } + } +} +``` + +##### Customizations + +- `@ref(constraintName)` can customize the SQL foreign key constraint name (`table_name_ref_field_fkey` above). +- `@ref(fields)` can customize the foreign key field names. +- `@ref(references)` can customize the constraint to reference other columns. + By default, `@ref(references)` is the primary key of the `@ref` table. + Other fields with `@unique` may also be referred in the foreign key constraint. + +""" +directive @ref( + "The SQL database foreign key constraint name. Defaults to snake_case `{table_name}_{field_name}_fkey`." + constraintName: String + """ + Foreign key fields. Defaults to `{tableName}{PrimaryIdName}`. + """ + fields: [String!] + "The fields that the foreign key references in the other table. Defaults to its primary key." + references: [String!] +) on FIELD_DEFINITION + +"Defines the orderBy direction in a query." +enum OrderDirection @fdc_forbiddenAsFieldType { +"Results are ordered in ascending order." + ASC +"Results are ordered in descending order." + DESC +} + +""" +Specifies the default value for a column field. + +For example: + +```graphql +type User @table(key: "uid") { + uid: String! @default(expr: "auth.uid") + number: Int! @col(dataType: "serial") + createdAt: Date! @default(expr: "request.time") + role: String! @default(value: "Member") + credit: Int! @default(value: 100) +} +``` + +The supported arguments vary based on the field type. +""" +directive @default( + "A constant value validated against the field's GraphQL type during compilation." + value: Any @fdc_oneOf(required: true) + "A CEL expression whose return value must match the field's data type." + expr: Any_Expr @fdc_oneOf(required: true) + """ + A raw SQL expression, whose SQL data type must match the underlying column. + + The value is any variable-free expression (in particular, cross-references to + other columns in the current table are not allowed). Subqueries are not allowed either. + See [PostgreSQL defaults](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-DEFAULT) + for more details. + """ + sql: Any_SQL @fdc_oneOf(required: true) +) on FIELD_DEFINITION + +""" +Defines a database index to optimize query performance. + +```graphql +type User @table @index(fields: ["name", "phoneNumber"], order: [ASC, DESC]) { + name: String @index + phoneNumber: Int64 @index + tags: [String] @index # GIN Index +} +``` + +##### Single Field Index + +You can put `@index` on a `@col` field to create a SQL index. + +`@index(order)` matters little for single field indexes, as they can be scanned +in both directions. + +##### Composite Index + +You can put `@index(fields: [...])` on `@table` type to define composite indexes. + +`@index(order: [...])` can customize the index order to satisfy particular +filter and order requirement. + +""" +directive @index( + """ + Configure the SQL database index id. + + If not overridden, Data Connect generates the index name: + - `{table_name}_{first_field}_{second_field}_aa_idx` + - `{table_name}_{field_name}_idx` + """ + name: String + """ + Only allowed and required when used on a `@table` type. + Specifies the fields to create the index on. + """ + fields: [String!] + """ + Only allowed for `BTREE` `@index` on `@table` type. + Specifies the order for each indexed column. Defaults to all `ASC`. + """ + order: [IndexFieldOrder!] + """ + Customize the index type. + + For most index, it defaults to `BTREE`. + For array fields, only allowed `IndexType` is `GIN`. + For `Vector` fields, defaults to `HNSW`, may configure to `IVFFLAT`. + """ + type: IndexType + """ + Only allowed when used on vector field. + Defines the vector similarity method. Defaults to `INNER_PRODUCT`. + """ + vector_method: VectorSimilarityMethod +) repeatable on FIELD_DEFINITION | OBJECT + +"Specifies the sorting order for database indexes." +enum IndexFieldOrder @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "Sorts the field in ascending order (from lowest to highest)." + ASC + "Sorts the field in descending order (from highest to lowest)." + DESC +} + +"Defines the type of index to be used in the database." +enum IndexType @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "A general-purpose index type commonly used for sorting and searching." + BTREE + "Generalized Inverted Index, optimized for indexing composite values such as arrays." + GIN + "Hierarchical Navigable Small World graph, used for nearest-neighbor searches on vector fields." + HNSW + "Inverted File Index, optimized for approximate nearest-neighbor searches in vector databases." + IVFFLAT +} + +""" +Defines unique constraints on `@table`. + +For example, + +```graphql +type User @table { + phoneNumber: Int64 @unique +} +type UserProfile @table { + user: User! @unique + address: String @unique +} +``` + +- `@unique` on a `@col` field adds a single-column unique constraint. +- `@unique` on a `@table` type adds a composite unique constraint. +- `@unique` on a `@ref` defines a one-to-one relation. It adds unique constraint + on `@ref(fields)`. + +`@unique` ensures those fields can uniquely identify a row, so other `@table` +type may define `@ref(references)` to refer to fields that has a unique constraint. + +""" +directive @unique( + """ + Configures the SQL database unique constraint name. + + If not overridden, Data Connect generates the unique constraint name: + - `table_name_first_field_second_field_uidx` + - `table_name_only_field_name_uidx` + """ + indexName: String + """ + Only allowed and required when used on OBJECT, + this specifies the fields to create a unique constraint on. + """ + fields: [String!] +) repeatable on FIELD_DEFINITION | OBJECT + +""" +Date is a string in the YYYY-MM-DD format representing a local-only date. + +See the description for Timestamp for range and limitations. + +As a FDC-specific extension, inputs that includes time portions (as specified by +the Timestamp scalar) are accepted but only the date portion is used. In other +words, only the part before "T" is used and the rest discarded. This effectively +truncates it to the local date in the specified time-zone. + +Outputs will always be in the canonical YYYY-MM-DD format. + +In the PostgreSQL table, it's stored as [`date`](https://www.postgresql.org/docs/current/datatype-datetime.html). +""" +scalar Date @specifiedBy(url: "https://scalars.graphql.org/andimarek/local-date.html") + +""" +Timestamp is a RFC 3339 string that represents an exact point in time. + +The serialization format follows https://scalars.graphql.org/andimarek/date-time +except the "Non-optional exact milliseconds" Section. As a FDC-specific +extension, inputs and outputs may contain 0, 3, 6, or 9 fractional digits. + +Specifically, output precision varies by server-side factors such as data source +support and clients must not rely on an exact number of digits. Clients may +truncate extra digits as fit, with the caveat that there may be information loss +if the truncated value is subsequently sent back to the server. + +FDC only supports year 1583 to 9999 (inclusive) and uses the ISO-8601 calendar +system for all date-time calculations. Notably, the expanded year representation +(+/-YYYYY) is rejected and Year 1582 and before may either be rejected or cause +undefined behavior. + +In the PostgreSQL table, it's stored as [`timestamptz`](https://www.postgresql.org/docs/current/datatype-datetime.html). +""" +scalar Timestamp @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") + +""" +A Common Expression Language (CEL) expression that returns a Timestamp at runtime. + +Limitation: Right now, only a few expressions are supported. +""" +scalar Timestamp_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "google.protobuf.Timestamp") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "request.time", description: "The timestamp when the request is received (with microseconds precision).") + +""" +A Common Expression Language (CEL) expression that returns a Timestamp at runtime, +which is then truncated to UTC date only. The time-of-day parts are discarded. + +Limitation: Right now, only a few expressions are supported. +""" +scalar Date_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "google.protobuf.Timestamp") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "request.time", description: "The UTC date on which the request is received.") + +"Conditions on a `Date` value." +input Date_Filter { + "Match if the field `IS NULL`." + isNull: Boolean + "Match if the field is exactly equal to the provided value." + eq: Date @fdc_oneOf(group: "eq") + "Match if the field equals the provided CEL expression." + eq_expr: Date_Expr @fdc_oneOf(group: "eq") + "Match if the field equals the provided relative date." + eq_date: Date_Relative @fdc_oneOf(group: "eq") + "Match if the field is not equal to the provided value." + ne: Date @fdc_oneOf(group: "ne") + "Match if the field is not equal to the provided CEL expression." + ne_expr: Date_Expr @fdc_oneOf(group: "ne") + "Match if the field is not equal to the provided relative date." + ne_date: Date_Relative @fdc_oneOf(group: "ne") + "Match if the field value is among the provided list of values." + in: [Date!] + "Match if the field value is not among the provided list of values." + nin: [Date!] + "Match if the field value is greater than the provided value." + gt: Date @fdc_oneOf(group: "gt") + "Match if the field value is greater than the provided CEL expression." + gt_expr: Date_Expr @fdc_oneOf(group: "gt") + "Match if the field value is greater than the provided relative date." + gt_date: Date_Relative @fdc_oneOf(group: "gt") + "Match if the field value is greater than or equal to the provided value." + ge: Date @fdc_oneOf(group: "ge") + "Match if the field value is greater than or equal to the provided CEL expression." + ge_expr: Date_Expr @fdc_oneOf(group: "ge") + "Match if the field value is greater than or equal to the provided relative date." + ge_date: Date_Relative @fdc_oneOf(group: "ge") + "Match if the field value is less than the provided value." + lt: Date @fdc_oneOf(group: "lt") + "Match if the field value is less than the provided CEL expression." + lt_expr: Date_Expr @fdc_oneOf(group: "lt") + "Match if the field value is less than the provided relative date." + lt_date: Date_Relative @fdc_oneOf(group: "lt") + "Match if the field value is less than or equal to the provided value." + le: Date @fdc_oneOf(group: "le") + "Match if the field value is less than or equal to the provided CEL expression." + le_expr: Date_Expr @fdc_oneOf(group: "le") + "Match if the field value is less than or equal to the provided relative date." + le_date: Date_Relative @fdc_oneOf(group: "le") +} + +"Conditions on a`Date` list." +input Date_ListFilter { + "Match if the list contains the provided date." + includes: Date @fdc_oneOf(group: "includes") + "Match if the list contains the provided date CEL expression." + includes_expr: Date_Expr @fdc_oneOf(group: "includes") + "Match if the list contains the provided relative date." + includes_date: Date_Relative @fdc_oneOf(group: "includes") + "Match if the list does not contain the provided date." + excludes: Date @fdc_oneOf(group: "excludes") + "Match if the list does not contain the provided date CEL expression." + excludes_expr: Date_Expr @fdc_oneOf(group: "excludes") + "Match if the list does not contain the provided relative date." + excludes_date: Date_Relative @fdc_oneOf(group: "excludes") + "Match if the list contains all the provided dates." + includesAll: [Date!] + "Match if the list contains none of the provided dates." + excludesAll: [Date!] +} + +"Conditions on a `Timestamp` value." +input Timestamp_Filter { + "Match if the field `IS NULL`." + isNull: Boolean + "Match if the field is exactly equal to the provided value." + eq: Timestamp @fdc_oneOf(group: "eq") + "Match if the field equals the provided CEL expression." + eq_expr: Timestamp_Expr @fdc_oneOf(group: "eq") + "Match if the field equals the provided relative time." + eq_time: Timestamp_Relative @fdc_oneOf(group: "eq") + "Match if the field is not equal to the provided value." + ne: Timestamp @fdc_oneOf(group: "ne") + "Match if the field is not equal to the provided CEL expression." + ne_expr: Timestamp_Expr @fdc_oneOf(group: "ne") + "Match if the field is not equal to the provided relative time." + ne_time: Timestamp_Relative @fdc_oneOf(group: "ne") + "Match if the field value is among the provided list of values." + in: [Timestamp!] + "Match if the field value is not among the provided list of values." + nin: [Timestamp!] + "Match if the field value is greater than the provided value." + gt: Timestamp @fdc_oneOf(group: "gt") + "Match if the field value is greater than the provided CEL expression." + gt_expr: Timestamp_Expr @fdc_oneOf(group: "gt") + "Match if the field value is greater than the provided relative time." + gt_time: Timestamp_Relative @fdc_oneOf(group: "gt") + "Match if the field value is greater than or equal to the provided value." + ge: Timestamp @fdc_oneOf(group: "ge") + "Match if the field value is greater than or equal to the provided CEL expression." + ge_expr: Timestamp_Expr @fdc_oneOf(group: "ge") + "Match if the field value is greater than or equal to the provided relative time." + ge_time: Timestamp_Relative @fdc_oneOf(group: "ge") + "Match if the field value is less than the provided value." + lt: Timestamp @fdc_oneOf(group: "lt") + "Match if the field value is less than the provided CEL expression." + lt_expr: Timestamp_Expr @fdc_oneOf(group: "lt") + "Match if the field value is less than the provided relative time." + lt_time: Timestamp_Relative @fdc_oneOf(group: "lt") + "Match if the field value is less than or equal to the provided value." + le: Timestamp @fdc_oneOf(group: "le") + "Match if the field value is less than or equal to the provided CEL expression." + le_expr: Timestamp_Expr @fdc_oneOf(group: "le") + "Match if the field value is less than or equal to the provided relative time." + le_time: Timestamp_Relative @fdc_oneOf(group: "le") +} + +"Conditions on a `Timestamp` list." +input Timestamp_ListFilter { + "Match if the list contains the provided timestamp." + includes: Timestamp @fdc_oneOf(group: "includes") + "Match if the list contains the provided timestamp CEL expression." + includes_expr: Timestamp_Expr @fdc_oneOf(group: "includes") + "Match if the list contains the provided relative timestamp." + includes_time: Timestamp_Relative @fdc_oneOf(group: "includes") + "Match if the list does not contain the provided timestamp." + excludes: Timestamp @fdc_oneOf(group: "excludes") + "Match if the list does not contain the provided timestamp CEL expression." + excludes_expr: Timestamp_Expr @fdc_oneOf(group: "excludes") + "Match if the list does not contain the provided relative timestamp." + excludes_time: Timestamp_Relative @fdc_oneOf(group: "excludes") + "Match if the list contains all the provided timestamps." + includesAll: [Timestamp!] + "Match if the list contains none of the provided timestamps." + excludesAll: [Timestamp!] +} + +"Update input of a `Date` value. Only one of `inc` or `dec` may be specified." +input Date_Update { + "Increment the field by a provided duration." + inc: Date_Duration @fdc_oneOf + "Decrement the field by a provided duration." + dec: Date_Duration @fdc_oneOf +} + +"Update input of a `Date` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Date_ListUpdate { + "Append the provided `Date` values to the existing list." + append: [Date!] @fdc_oneOf + "Prepend the provided `Date` values to the existing list." + prepend: [Date!] @fdc_oneOf + "Append any `Date` values that do not already exist to the list." + add: [Date!] @fdc_oneOf + "Remove all occurrences of each `Date` from the list." + remove: [Date!] @fdc_oneOf +} + +"Update input of a `Timestamp` value. Only one of `inc` or `dec` may be specified." +input Timestamp_Update { + "Increment the field by a provided duration." + inc: Timestamp_Duration @fdc_oneOf + "Decrement the field by a provided duration." + dec: Timestamp_Duration @fdc_oneOf +} + +"Update input of an `Timestamp` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Timestamp_ListUpdate { + "Append the provided `Timestamp` values to the existing list." + append: [Timestamp!] @fdc_oneOf + "Prepend the provided `Timestamp` values to the existing list." + prepend: [Timestamp!] @fdc_oneOf + "Append any `Timestamp` values that do not already exist to the list." + add: [Timestamp!] @fdc_oneOf + "Remove all occurrences of each `Timestamp` from the list." + remove: [Timestamp!] @fdc_oneOf +} + +"A runtime-calculated `Timestamp` value relative to `now` or `at`." +input Timestamp_Relative @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "Match for the current time." + now: True @fdc_oneOf(group: "from", required: true) + "A specific timestamp for matching." + at: Timestamp @fdc_oneOf(group: "from", required: true) + "Add the provided duration to the base timestamp." + add: Timestamp_Duration + "Subtract the provided duration from the base timestamp." + sub: Timestamp_Duration + "Truncate the timestamp to the provided interval." + truncateTo: Timestamp_Interval +} + +input Timestamp_Duration @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "The number of milliseconds for the duration." + milliseconds: Int! = 0 + "The number of seconds for the duration." + seconds: Int! = 0 + "The number of minutes for the duration." + minutes: Int! = 0 + "The number of hours for the duration." + hours: Int! = 0 + "The number of days for the duration." + days: Int! = 0 + "The number of weeks for the duration." + weeks: Int! = 0 + "The number of months for the duration." + months: Int! = 0 + "The number of years for the duration." + years: Int! = 0 +} + +enum Timestamp_Interval @fdc_forbiddenAsFieldType { + "Represents a time interval of one second." + SECOND + "Represents a time interval of one minute." + MINUTE + "Represents a time interval of one hour." + HOUR + "Represents a time interval of one day." + DAY + "Represents a time interval of one week." + WEEK + "Represents a time interval of one month." + MONTH + "Represents a time interval of one year." + YEAR +} + +"A runtime-calculated Date value relative to `today` or `on`." +input Date_Relative @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "Match for today’s date." + today: True @fdc_oneOf(group: "from", required: true) + "A specific date for matching." + on: Date @fdc_oneOf(group: "from", required: true) + "Add the provided duration to the base date." + add: Date_Duration + "Subtract the provided duration from the base date." + sub: Date_Duration + "Truncate the date to the provided interval." + truncateTo: Date_Interval +} + +input Date_Duration @fdc_forbiddenAsVariableType @fdc_forbiddenAsFieldType { + "The number of days for the duration." + days: Int! = 0 + "The number of weeks for the duration." + weeks: Int! = 0 + "The number of months for the duration." + months: Int! = 0 + "The number of years for the duration." + years: Int! = 0 +} + +enum Date_Interval @fdc_forbiddenAsFieldType { + "Represents a time interval of one week." + WEEK + "Represents a time interval of one month." + MONTH + "Represents a time interval of one year." + YEAR +} + +"Update input of a `String` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input String_ListUpdate { + "Append the provided values to the existing list." + append: [String!] @fdc_oneOf + "Prepend the provided values to the existing list." + prepend: [String!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [String!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [String!] @fdc_oneOf +} + +"Update input of an `ID` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input UUID_ListUpdate { + "Append the provided UUIDs to the existing list." + append: [UUID!] @fdc_oneOf + "Prepend the provided UUIDs to the existing list." + prepend: [UUID!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [UUID!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [UUID!] @fdc_oneOf +} + +"Update input of an `Int` value. Only one of `inc` or `dec` may be specified." +input Int_Update { + "Increment the field by a provided value." + inc: Int @fdc_oneOf + "Decrement the field by a provided value." + dec: Int @fdc_oneOf +} + +"Update input of an `Int` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Int_ListUpdate { + "Append the provided list of values to the existing list." + append: [Int!] @fdc_oneOf + "Prepend the provided list of values to the existing list." + prepend: [Int!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [Int!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [Int!] @fdc_oneOf +} + +"Update input of an `Int64` value. Only one of `inc` or `dec` may be specified." +input Int64_Update { + "Increment the field by a provided value." + inc: Int64 @fdc_oneOf + "Decrement the field by a provided value." + dec: Int64 @fdc_oneOf +} + +"Update input of an `Int64` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Int64_ListUpdate { + "Append the provided list of values to the existing list." + append: [Int64!] @fdc_oneOf + "Prepend the provided list of values to the existing list." + prepend: [Int64!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [Int64!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [Int64!] @fdc_oneOf +} + +"Update input of a `Float` value. Only one of `inc` or `dec` may be specified." +input Float_Update { + "Increment the field by a provided value." + inc: Float @fdc_oneOf + "Decrement the field by a provided value." + dec: Float @fdc_oneOf +} + +"Update input of a `Float` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Float_ListUpdate { + "Append the provided list of values to the existing list." + append: [Float!] @fdc_oneOf + "Prepend the provided list of values to the existing list." + prepend: [Float!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [Float!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [Float!] @fdc_oneOf +} + +"Update input of a `Boolean` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Boolean_ListUpdate { + "Append the provided list of values to the existing list." + append: [Boolean!] @fdc_oneOf + "Prepend the provided list of values to the existing list." + prepend: [Boolean!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [Boolean!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [Boolean!] @fdc_oneOf +} + +"Update input of an `Any` list value. Only one of `append`, `prepend`, `add`, or `remove` may be specified." +input Any_ListUpdate { + "Append the provided list of values to the existing list." + append: [Any!] @fdc_oneOf + "Prepend the provided list of values to the existing list." + prepend: [Any!] @fdc_oneOf + "Append values that do not already exist to the list." + add: [Any!] @fdc_oneOf + "Remove all occurrences of each value from the list." + remove: [Any!] @fdc_oneOf +} + +type Query { + """ + _service provides customized introspection on Firebase Data Connect Sevice. + """ + _service: _Service! +} + +""" +Vector is an array of single-precision floating-point numbers, serialized +as a JSON array. All elements must be finite (no NaN, Infinity or -Infinity). + +Example: [1.1, 2, 3.3] + +In the PostgreSQL table, it's stored as [`pgvector`](https://github.com/pgvector/pgvector). + +See `Vector_Embed` for how to generate text embeddings in query and mutations. +""" +scalar Vector + +""" +Defines the similarity function to use when comparing vectors in queries. + +Defaults to `INNER_PRODUCT`. + +View [all vector functions](https://github.com/pgvector/pgvector?tab=readme-ov-file#vector-functions). +""" +enum VectorSimilarityMethod @fdc_forbiddenAsFieldType { + "Measures the Euclidean (L2) distance between two vectors." + L2 + "Measures the cosine similarity between two vectors." + COSINE + "Measures the inner product(dot product) between two vectors." + INNER_PRODUCT +} + +"Conditions on a Vector value." +input Vector_Filter { + "Match if the field is exactly equal to the provided vector." + eq: Vector + "Match if the field is not equal to the provided vector." + ne: Vector + "Match if the field value is among the provided list of vectors." + in: [Vector!] + "Match if the field value is not among the provided list of vectors." + nin: [Vector!] + "Match if the field is `NULL`." + isNull: Boolean +} + +input Vector_ListFilter { + "Match if the list includes the supplied vector." + includes: Vector + "Match if the list does not include the supplied vector." + excludes: Vector + "Match if the list contains all the provided vectors." + includesAll: [Vector!] + "Match if the list contains none of the provided vectors." + excludesAll: [Vector!] +} + +""" +Create a vector embedding of text using the given model on Vertex AI. + +Cloud SQL for Postgresql natively integrates with [Vertex AI Text embeddings API](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api) +to effectively generate text embeddings. + +If you uses [`Vector`](scalar.md#Vector) in your schema, Firebase Data Connect automatically installs +[`pgvector`](https://github.com/pgvector/pgvector) and [`google_ml_integration`](https://cloud.google.com/sql/docs/postgres/integrate-cloud-sql-with-vertex-ai) +Postgres extensions in your Cloud SQL database. + +Given a Post table with a `Vector` embedding field. + +```graphql +type Post @table { + content: String! + contentEmbedding: Vector @col(size:768) +} +``` + +NOTE: All natively supported `Vector_Embed_Model` generates vector of length `768`. + +###### Example: Insert embedding + +```graphql +mutation CreatePost($content: String!) { + post_insert(data: { + content: $content, + contentEmbedding_embed: {model: "textembedding-gecko@003", text: $content}, + }) +} +``` + +###### Example: Vector similarity Search + +```graphql +query SearchPost($query: String!) { + posts_contentEmbedding_similarity(compare_embed: {model: "textembedding-gecko@003", text: $query}) { + id + content + } +} +``` +""" +input Vector_Embed @fdc_forbiddenAsVariableType { + """ + The model to use for vector embedding. + Recommend the latest stable model: `textembedding-gecko@003`. + """ + model: Vector_Embed_Model! + "The text to generate the vector embedding from." + text: String! +} + +""" +The Vertex AI model version that is required in input `Vector_Embed`. + +It is recommended to use the latest stable model version: `textembedding-gecko@003`. + +View all supported [Vertex AI Text embeddings APIs](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api). +""" +scalar Vector_Embed_Model + @specifiedBy(url: "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versioning") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "textembedding-gecko@003", description: "A stable version of the textembedding-gecko model") + @fdc_example(value: "textembedding-gecko@001", description: "An older version of the textembedding-gecko model") + @fdc_example(value: "text-embedding-004", description: "Another text embedding model") + +# Intentionally left blank. + diff --git a/packages/data-connect/.eslintrc.js b/packages/data-connect/.eslintrc.js index faef63a039..b8a23da0be 100644 --- a/packages/data-connect/.eslintrc.js +++ b/packages/data-connect/.eslintrc.js @@ -49,17 +49,7 @@ module.exports = { 'alphabetize': { 'order': 'asc', 'caseInsensitive': true } } ], - 'no-restricted-globals': [ - 'error', - { - 'name': 'window', - 'message': 'Use `PlatformSupport.getPlatform().window` instead.' - }, - { - 'name': 'document', - 'message': 'Use `PlatformSupport.getPlatform().document` instead.' - } - ] + 'no-restricted-globals': ['error'] }, overrides: [ { diff --git a/packages/data-connect/src/api.browser.ts b/packages/data-connect/src/api.browser.ts index d31c325353..f3d7556780 100644 --- a/packages/data-connect/src/api.browser.ts +++ b/packages/data-connect/src/api.browser.ts @@ -15,95 +15,6 @@ * limitations under the License. */ -import { - OnCompleteSubscription, - OnErrorSubscription, - OnResultSubscription, - QueryRef, - QueryUnsubscribe, - SubscriptionOptions, - toQueryRef -} from './api/query'; -import { OpResult, SerializedRef } from './api/Reference'; -import { DataConnectError, Code } from './core/error'; - -/** - * Subscribe to a `QueryRef` - * @param queryRefOrSerializedResult query ref or serialized result. - * @param observer observer object to use for subscribing. - * @returns `SubscriptionOptions` - */ -export function subscribe( - queryRefOrSerializedResult: - | QueryRef - | SerializedRef, - observer: SubscriptionOptions -): QueryUnsubscribe; -/** - * Subscribe to a `QueryRef` - * @param queryRefOrSerializedResult query ref or serialized result. - * @param onNext Callback to call when result comes back. - * @param onError Callback to call when error gets thrown. - * @param onComplete Called when subscription completes. - * @returns `SubscriptionOptions` - */ -export function subscribe( - queryRefOrSerializedResult: - | QueryRef - | SerializedRef, - onNext: OnResultSubscription, - onError?: OnErrorSubscription, - onComplete?: OnCompleteSubscription -): QueryUnsubscribe; -/** - * Subscribe to a `QueryRef` - * @param queryRefOrSerializedResult query ref or serialized result. - * @param observerOrOnNext observer object or next function. - * @param onError Callback to call when error gets thrown. - * @param onComplete Called when subscription completes. - * @returns `SubscriptionOptions` - */ -export function subscribe( - queryRefOrSerializedResult: - | QueryRef - | SerializedRef, - observerOrOnNext: - | SubscriptionOptions - | OnResultSubscription, - onError?: OnErrorSubscription, - onComplete?: OnCompleteSubscription -): QueryUnsubscribe { - let ref: QueryRef; - let initialCache: OpResult | undefined; - if ('refInfo' in queryRefOrSerializedResult) { - const serializedRef: SerializedRef = - queryRefOrSerializedResult; - const { data, source, fetchTime } = serializedRef; - initialCache = { - data, - source, - fetchTime - }; - ref = toQueryRef(serializedRef); - } else { - ref = queryRefOrSerializedResult; - } - let onResult: OnResultSubscription | undefined = undefined; - if (typeof observerOrOnNext === 'function') { - onResult = observerOrOnNext; - } else { - onResult = observerOrOnNext.onNext; - onError = observerOrOnNext.onErr; - onComplete = observerOrOnNext.onComplete; - } - if (!onResult) { - throw new DataConnectError(Code.INVALID_ARGUMENT, 'Must provide onNext'); - } - return ref.dataConnect._queryManager.addSubscription( - ref, - onResult, - onComplete, - onError, - initialCache - ); -} +export * from './core/query/subscribe'; +export { MemoryStub } from './cache/Cache'; +export { makeMemoryCacheProvider } from './api/DataConnect'; diff --git a/packages/data-connect/src/api.node.ts b/packages/data-connect/src/api.node.ts index f8236ebe2d..486bd6f90c 100644 --- a/packages/data-connect/src/api.node.ts +++ b/packages/data-connect/src/api.node.ts @@ -15,4 +15,5 @@ * limitations under the License. */ -export { subscribe } from './api.browser'; +export * from './core/query/subscribe'; +export { makeMemoryCacheProvider, CacheProvider } from './api/DataConnect'; diff --git a/packages/data-connect/src/api/DataConnect.ts b/packages/data-connect/src/api/DataConnect.ts index 15e713ba23..92c4f2540a 100644 --- a/packages/data-connect/src/api/DataConnect.ts +++ b/packages/data-connect/src/api/DataConnect.ts @@ -30,13 +30,15 @@ import { updateEmulatorBanner } from '@firebase/util'; +import { DataConnectCache, MemoryStub } from '../cache/Cache'; +import { InternalCacheProvider } from '../cache/CacheProvider'; import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; import { Code, DataConnectError } from '../core/error'; import { AuthTokenProvider, FirebaseAuthProvider } from '../core/FirebaseAuthProvider'; -import { QueryManager } from '../core/QueryManager'; +import { QueryManager } from '../core/query/QueryManager'; import { logDebug, logError } from '../logger'; import { CallerSdkType, @@ -45,6 +47,7 @@ import { TransportClass } from '../network'; import { RESTTransport } from '../network/transport/rest'; +import { PROD_HOST } from '../util/url'; import { MutationManager } from './Mutation'; @@ -85,7 +88,9 @@ export function parseOptions(fullHost: string): TransportOptions { /** * DataConnectOptions including project id */ -export interface DataConnectOptions extends ConnectorConfig { +export interface DataConnectOptions + extends ConnectorConfig, + DataConnectSettings { projectId: string; } @@ -104,6 +109,10 @@ export class DataConnect { _isUsingGeneratedSdk: boolean = false; _callerSdkType: CallerSdkType = CallerSdkTypeEnum.Base; private _appCheckTokenProvider?: AppCheckTokenProvider; + /** + * @internal + */ + private cache?: DataConnectCache; // @internal constructor( public readonly app: FirebaseApp, @@ -149,6 +158,13 @@ export class DataConnect { return copy; } + /** + * @internal + */ + setCacheSettings(cacheSettings: CacheSettings): void { + this.dataConnectOptions.cacheSettings = cacheSettings; + } + // @internal setInitialized(): void { if (this._initialized) { @@ -159,13 +175,26 @@ export class DataConnect { this._transportClass = RESTTransport; } - if (this._authProvider) { - this._authTokenProvider = new FirebaseAuthProvider( - this.app.name, - this.app.options, - this._authProvider + this._authTokenProvider = new FirebaseAuthProvider( + this.app.name, + this.app.options, + this._authProvider + ); + const connectorConfig: ConnectorConfig = { + connector: this.dataConnectOptions.connector, + service: this.dataConnectOptions.service, + location: this.dataConnectOptions.location + }; + if (this.dataConnectOptions.cacheSettings) { + this.cache = new DataConnectCache( + this._authTokenProvider, + this.app.options.projectId!, + connectorConfig, + this._transportOptions?.host || PROD_HOST, + this.dataConnectOptions.cacheSettings ); } + if (this._appCheckProvider) { this._appCheckTokenProvider = new AppCheckTokenProvider( this.app, @@ -173,7 +202,6 @@ export class DataConnect { ); } - this._initialized = true; this._transport = new this._transportClass( this.dataConnectOptions, this.app.options.apiKey, @@ -191,8 +219,10 @@ export class DataConnect { this._transportOptions.sslEnabled ); } - this._queryManager = new QueryManager(this._transport); + + this._queryManager = new QueryManager(this._transport, this, this.cache); this._mutationManager = new MutationManager(this._transport); + this._initialized = true; } // @internal @@ -251,55 +281,97 @@ export function connectDataConnectEmulator( dc.enableEmulator({ host, port, sslEnabled }); } +export interface DataConnectSettings { + cacheSettings?: CacheSettings; +} + /** * Initialize DataConnect instance * @param options ConnectorConfig */ +export function getDataConnect( + options: ConnectorConfig, + settings?: DataConnectSettings +): DataConnect; export function getDataConnect(options: ConnectorConfig): DataConnect; /** * Initialize DataConnect instance * @param app FirebaseApp to initialize to. - * @param options ConnectorConfig + * @param connectorConfig ConnectorConfig */ export function getDataConnect( app: FirebaseApp, - options: ConnectorConfig + connectorConfig: ConnectorConfig ): DataConnect; + +/** + * Initialize DataConnect instance + * @param app FirebaseApp to initialize to. + * @param connectorConfig ConnectorConfig + */ +export function getDataConnect( + app: FirebaseApp, + connectorConfig: ConnectorConfig, + settings: DataConnectSettings +): DataConnect; + export function getDataConnect( - appOrOptions: FirebaseApp | ConnectorConfig, - optionalOptions?: ConnectorConfig + appOrConnectorConfig: FirebaseApp | ConnectorConfig, + settingsOrConnectorConfig?: ConnectorConfig | DataConnectSettings, + settings?: DataConnectSettings ): DataConnect { let app: FirebaseApp; - let dcOptions: ConnectorConfig; - if ('location' in appOrOptions) { - dcOptions = appOrOptions; + let connectorConfig: ConnectorConfig; + let realSettings: DataConnectSettings; + if ('location' in appOrConnectorConfig) { + connectorConfig = appOrConnectorConfig; app = getApp(); + realSettings = settingsOrConnectorConfig as DataConnectSettings; } else { - dcOptions = optionalOptions!; - app = appOrOptions; + app = appOrConnectorConfig; + connectorConfig = settingsOrConnectorConfig as ConnectorConfig; + realSettings = settings as DataConnectSettings; } if (!app || Object.keys(app).length === 0) { app = getApp(); } + + // Options to store in Firebase Component Provider. + const serializedOptions = { + ...connectorConfig, + projectId: app.options.projectId + }; + + // We should sort the keys before initialization. + const sortedSerialized = Object.fromEntries( + Object.entries(serializedOptions).sort() + ); + const provider = _getProvider(app, 'data-connect'); - const identifier = JSON.stringify(dcOptions); + const identifier = JSON.stringify(sortedSerialized); if (provider.isInitialized(identifier)) { const dcInstance = provider.getImmediate({ identifier }); const options = provider.getOptions(identifier); const optionsValid = Object.keys(options).length > 0; + // TODO: check whether the current DataConnect provider is the same as what's being passed in. if (optionsValid) { logDebug('Re-using cached instance'); return dcInstance; } } - validateDCOptions(dcOptions); + validateDCOptions(connectorConfig); logDebug('Creating new DataConnect instance'); // Initialize with options. return provider.initialize({ instanceIdentifier: identifier, - options: dcOptions + options: Object.fromEntries( + Object.entries({ + ...realSettings, + ...sortedSerialized + }).sort() + ) }); } @@ -334,3 +406,24 @@ export function terminate(dataConnect: DataConnect): Promise { return dataConnect._delete(); // TODO(mtewani): Stop pending tasks } +export const StorageType = { + MEMORY: 'MEMORY' +}; + +export type StorageType = (typeof StorageType)[keyof typeof StorageType]; + +export interface CacheSettings { + cacheProvider: CacheProvider; // TODO: Modify the API proposal to make this required. + maxAge?: number; +} +export interface CacheProvider { + type: T; + /** + * @internal + */ + initialize(cacheId: string): InternalCacheProvider; +} + +export function makeMemoryCacheProvider(): CacheProvider<'MEMORY'> { + return new MemoryStub(); +} diff --git a/packages/data-connect/src/api/Reference.ts b/packages/data-connect/src/api/Reference.ts index f9d7687dd1..fec1199639 100644 --- a/packages/data-connect/src/api/Reference.ts +++ b/packages/data-connect/src/api/Reference.ts @@ -15,7 +15,10 @@ * limitations under the License. */ +import { DataConnectExtensions } from '../network'; + import { DataConnect, DataConnectOptions } from './DataConnect'; +import { QueryResult } from './query'; export const QUERY_STR = 'query'; export const MUTATION_STR = 'mutation'; export type ReferenceType = typeof QUERY_STR | typeof MUTATION_STR; @@ -28,6 +31,15 @@ export interface OpResult { data: Data; source: DataSource; fetchTime: string; + extensions?: DataConnectExtensions; +} + +/** + * @internal + */ +export interface CachedQueryResult + extends QueryResult { + entityIds: Record; } export interface OperationRef<_Data, Variables> { diff --git a/packages/data-connect/src/api/index.ts b/packages/data-connect/src/api/index.ts index 72ee8b313e..1140e7ed9f 100644 --- a/packages/data-connect/src/api/index.ts +++ b/packages/data-connect/src/api/index.ts @@ -16,7 +16,25 @@ */ export * from '../network'; -export * from './DataConnect'; +export { + ExecuteQueryOptions, + QueryFetchPolicy +} from '../core/query/queryOptions'; +export { + CacheSettings, + validateDCOptions, + ConnectorConfig, + DataConnect, + DataConnectOptions, + DataConnectSettings, + StorageType, + TransportOptions, + areTransportOptionsEqual, + connectDataConnectEmulator, + getDataConnect, + parseOptions, + terminate +} from './DataConnect'; export * from './Reference'; export * from './Mutation'; export * from './query'; diff --git a/packages/data-connect/src/api/query.ts b/packages/data-connect/src/api/query.ts index 43683cafd6..a99a2cba1d 100644 --- a/packages/data-connect/src/api/query.ts +++ b/packages/data-connect/src/api/query.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DataConnectError } from '../core/error'; +import { ExecuteQueryOptions } from '../core/query/queryOptions'; import { DataConnect, getDataConnect } from './DataConnect'; import { @@ -25,21 +25,6 @@ import { SerializedRef } from './Reference'; -/** - * Signature for `OnResultSubscription` for `subscribe` - */ -export type OnResultSubscription = ( - res: QueryResult -) => void; -/** - * Signature for `OnErrorSubscription` for `subscribe` - */ -export type OnErrorSubscription = (err?: DataConnectError) => void; -/** - * Signature for unsubscribe from `subscribe` - */ -export type QueryUnsubscribe = () => void; - /** * QueryRef object */ @@ -69,9 +54,13 @@ export interface QueryPromise * @returns `QueryPromise` */ export function executeQuery( - queryRef: QueryRef + queryRef: QueryRef, + options?: ExecuteQueryOptions ): QueryPromise { - return queryRef.dataConnect._queryManager.executeQuery(queryRef); + return queryRef.dataConnect._queryManager.maybeExecuteQuery( + queryRef, + options + ); } /** @@ -111,7 +100,9 @@ export function queryRef( initialCache?: QueryResult ): QueryRef { dcInstance.setInitialized(); - dcInstance._queryManager.track(queryName, variables, initialCache); + if (initialCache !== undefined) { + dcInstance._queryManager.updateSSR(initialCache); + } return { dataConnect: dcInstance, refType: QUERY_STR, @@ -132,15 +123,3 @@ export function toQueryRef( } = serializedRef; return queryRef(getDataConnect(connectorConfig), name, variables); } -/** - * `OnCompleteSubscription` - */ -export type OnCompleteSubscription = () => void; -/** - * Representation of full observer options in `subscribe` - */ -export interface SubscriptionOptions { - onNext?: OnResultSubscription; - onErr?: OnErrorSubscription; - onComplete?: OnCompleteSubscription; -} diff --git a/packages/data-connect/src/cache/Cache.ts b/packages/data-connect/src/cache/Cache.ts new file mode 100644 index 0000000000..b7f898db07 --- /dev/null +++ b/packages/data-connect/src/cache/Cache.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { generateSHA256HashBrowser } from '@firebase/util'; + +import { + CacheProvider, + CacheSettings, + type ConnectorConfig +} from '../api/DataConnect'; +import { DataConnectError } from '../core/error'; +import { type AuthTokenProvider } from '../core/FirebaseAuthProvider'; + +import { InternalCacheProvider } from './CacheProvider'; +import { ImpactedQueryRefsAccumulator } from './ImpactedQueryRefsAccumulator'; +import { InMemoryCacheProvider } from './InMemoryCacheProvider'; +import { ResultTree } from './ResultTree'; +import { ResultTreeProcessor } from './ResultTreeProcessor'; + +export const Memory = 'memory'; + +export type DataConnectStorage = typeof Memory; + +/** + * ServerValues + */ +export interface ServerValues extends Record { + maxAge?: number; +} + +export class DataConnectCache { + private cacheProvider: InternalCacheProvider | null = null; + private uid: string | null = null; + constructor( + private authProvider: AuthTokenProvider, + private projectId: string, + private connectorConfig: ConnectorConfig, + private host: string, + public readonly cacheSettings: CacheSettings + ) { + this.authProvider.addTokenChangeListener(async _ => { + const newUid = this.authProvider.getAuth().getUid(); + // We should only close if the token changes and so does the new UID + if (this.uid !== newUid) { + await this.cacheProvider?.close(); + this.uid = newUid; + const identifier = await this.getIdentifier(this.uid); + this.cacheProvider = this.initializeNewProviders(identifier); + } + }); + } + + async initialize(): Promise { + if (!this.cacheProvider) { + const identifier = await this.getIdentifier(this.uid); + this.cacheProvider = this.initializeNewProviders(identifier); + } + } + + async getIdentifier(uid: string | null): Promise { + const identifier = `${ + 'memory' // TODO: replace this with indexeddb when persistence is available. + }-${this.projectId}-${this.connectorConfig.service}-${ + this.connectorConfig.connector + }-${this.connectorConfig.location}-${uid}-${this.host}`; // TODO: Check if null is the right identifier here. + const sha256 = await generateSHA256HashBrowser(identifier); + return sha256; + } + + initializeNewProviders(identifier: string): InternalCacheProvider { + return this.cacheSettings.cacheProvider.initialize(identifier); + } + + async containsResultTree(queryId: string): Promise { + await this.initialize(); + const resultTree = await this.cacheProvider!.getResultTree(queryId); + return resultTree !== undefined; + } + async getResultTree(queryId: string): Promise { + await this.initialize(); + return this.cacheProvider!.getResultTree(queryId); + } + async getResultJSON(queryId: string): Promise { + await this.initialize(); + const processor = new ResultTreeProcessor(); + const cacheProvider = this.cacheProvider; + const resultTree = await cacheProvider!.getResultTree(queryId); + if (!resultTree) { + throw new DataConnectError( + 'invalid-argument', + `${queryId} not found in cache. Call "update() first."` + ); + } + return processor.hydrateResults(resultTree.getRootStub()); + } + async update( + queryId: string, + serverValues: ServerValues, + entityIds: Record + ): Promise { + await this.initialize(); + const processor = new ResultTreeProcessor(); + const acc = new ImpactedQueryRefsAccumulator(); + const cacheProvider = this.cacheProvider; + const { data, entityNode: stubDataObject } = + await processor.dehydrateResults( + serverValues, + entityIds, + cacheProvider!, + acc, + queryId + ); + const now = new Date(); + await cacheProvider!.setResultTree( + queryId, + new ResultTree( + data, + stubDataObject, + serverValues.maxAge || this.cacheSettings.maxAge, + now, + now + ) + ); + return acc.consumeEvents(); + } +} + +export class MemoryStub implements CacheProvider<'MEMORY'> { + type: 'MEMORY' = 'MEMORY'; + /** + * @internal + */ + initialize(cacheId: string): InMemoryCacheProvider { + return new InMemoryCacheProvider(cacheId); + } +} diff --git a/packages/data-connect/src/cache/CacheProvider.ts b/packages/data-connect/src/cache/CacheProvider.ts new file mode 100644 index 0000000000..00edaa8f42 --- /dev/null +++ b/packages/data-connect/src/cache/CacheProvider.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityDataObject } from './EntityDataObject'; +import { ResultTree } from './ResultTree'; + +export interface InternalCacheProvider { + getBdo(globalId: string): Promise; + updateBackingData(backingData: EntityDataObject): Promise; + createGlobalId(): string; + getResultTree(queryId: string): Promise; + setResultTree(queryId: string, resultTree: ResultTree): Promise; + close(): Promise; +} diff --git a/packages/data-connect/src/cache/EntityDataObject.ts b/packages/data-connect/src/cache/EntityDataObject.ts new file mode 100644 index 0000000000..bf7f9adaa9 --- /dev/null +++ b/packages/data-connect/src/cache/EntityDataObject.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type FDCScalarValue = + | string + | number + | boolean + | undefined + | null + | Record + | FDCScalarValue[]; + +export interface BackingDataObjectJson { + map: { + [key: string]: FDCScalarValue; + }; + queriesReferenced: Set; + globalID: string; +} + +export class EntityDataObject { + getMap(): { [key: string]: FDCScalarValue } { + return this.map; + } + getStorableMap(map: { [key: string]: FDCScalarValue }): { + [key: string]: FDCScalarValue; + } { + const newMap: { [key: string]: FDCScalarValue } = {}; + for (const key in map) { + if (map.hasOwnProperty(key)) { + newMap[key] = map[key]; + } + } + return newMap; + } + toStorableJson(): BackingDataObjectJson { + return { + globalID: this.globalID, + map: this.getStorableMap(this.map), + queriesReferenced: this.queriesReferenced + }; + } + static fromStorableJson(json: BackingDataObjectJson): EntityDataObject { + const bdo = new EntityDataObject(json.globalID); + bdo.map = json.map; + bdo.queriesReferenced = json.queriesReferenced; + return bdo; + } + private map: { [key: string]: FDCScalarValue } = {}; + private queriesReferenced = new Set(); + constructor(public readonly globalID: string) {} + updateServerValue( + key: string, + value: FDCScalarValue, + requestedFrom: string + ): string[] { + this.map[key] = value; + this.queriesReferenced.add(requestedFrom); + return Array.from(this.queriesReferenced); + } + // TODO(mtewani): Add a way to track what fields are associated with each query during runtime. +} diff --git a/packages/data-connect/src/cache/EntityNode.ts b/packages/data-connect/src/cache/EntityNode.ts new file mode 100644 index 0000000000..c93a6e6c92 --- /dev/null +++ b/packages/data-connect/src/cache/EntityNode.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DataConnectError } from '../core/error'; + +import { InternalCacheProvider } from './CacheProvider'; +import { + EntityDataObject, + BackingDataObjectJson, + FDCScalarValue +} from './EntityDataObject'; +import { ImpactedQueryRefsAccumulator } from './ImpactedQueryRefsAccumulator'; +export const InMemoryProvider = 'inmemory' as const; + +export const GLOBAL_ID_KEY = '_id'; +export class EntityNode { + entityData?: EntityDataObject; + scalars: Record = {}; + references: { [key: string]: EntityNode } = {}; + objectLists: { + [key: string]: EntityNode[]; + } = {}; + globalId?: string; + impactedQueryRefs = new Set(); + constructor(private acc = new ImpactedQueryRefsAccumulator()) {} + + async loadData( + queryId: string, + values: FDCScalarValue, + entityIds: Record | undefined, + cacheProvider?: InternalCacheProvider // TODO: Look into why null is being passed in here. + ): Promise { + if (values === undefined || !cacheProvider) { + return; + } + if (typeof values !== 'object' || Array.isArray(values)) { + throw new DataConnectError( + 'invalid-argument', + 'EntityNode initialized with non-object value' + ); + } + if (values === null) { + return; + } + + if ( + typeof values === 'object' && + entityIds && + entityIds[GLOBAL_ID_KEY] && + typeof entityIds[GLOBAL_ID_KEY] === 'string' + ) { + this.globalId = entityIds[GLOBAL_ID_KEY]; + // TODO: Add current query id to BDO + this.entityData = await cacheProvider.getBdo(this.globalId); + } else { + } + for (const key in values) { + if (values.hasOwnProperty(key)) { + if (typeof values[key] === 'object') { + const ids: Record | undefined = + entityIds && (entityIds[key] as Record); + if (Array.isArray(values[key])) { + const objArray: EntityNode[] = []; + const scalarArray: Array> = []; + for (const [index, value] of values[key].entries()) { + if (typeof value === 'object') { + if (Array.isArray(value)) { + // Note: we don't support sparse arrays. + } else { + const entityNode = new EntityNode(this.acc); + await entityNode.loadData( + queryId, + value, + ids && (ids[index] as Record), + cacheProvider + ); + objArray.push(entityNode); + } + } else { + scalarArray.push(value); + } + } + if (scalarArray.length > 0 && objArray.length > 0) { + throw new DataConnectError( + 'invalid-argument', + 'Sparse array detected.' + ); + } + if (scalarArray.length > 0) { + if (this.entityData) { + const impactedRefs = this.entityData.updateServerValue( + key, + scalarArray, + queryId + ); + this.acc.add(impactedRefs); + } else { + this.scalars[key] = scalarArray; + } + } else if (objArray.length > 0) { + this.objectLists[key] = objArray; + } else { + this.scalars[key] = []; + } + } else { + if (values[key] === null) { + this.scalars[key] = null; + continue; + } + const stubDataObject = new EntityNode(this.acc); + await stubDataObject.loadData( + queryId, + (values as Record)[key], + ids && (ids[key] as Record), + cacheProvider + ); + this.references[key] = stubDataObject; + } + } else { + if (this.entityData) { + // TODO: Track only the fields we need for the BDO + const impactedRefs = this.entityData.updateServerValue( + key, + values[key] as FDCScalarValue, + queryId + ); + this.acc.add(impactedRefs); + } else { + this.scalars[key] = values[key] as FDCScalarValue; + } + } + } + } + if (this.entityData) { + await cacheProvider.updateBackingData(this.entityData); + } + } + + toJson(): object { + const resultObject: Record = {}; + const entityDataMap = this.entityData?.getMap(); + for (const key in entityDataMap) { + if (entityDataMap?.hasOwnProperty(key)) { + resultObject[key] = entityDataMap[key]; + } + } + // Scalars should never have stubdataobjects + for (const key in this.scalars) { + if (this.scalars.hasOwnProperty(key)) { + resultObject[key] = this.scalars[key]; + } + } + for (const key in this.references) { + if (this.references.hasOwnProperty(key)) { + resultObject[key] = this.references[key].toJson(); + } + } + for (const key in this.objectLists) { + if (this.objectLists.hasOwnProperty(key)) { + resultObject[key] = this.objectLists[key].map(obj => { + return obj.toJson(); + }); + } + } + return resultObject; + } + static parseMap( + map: Record< + string, + | EntityNode + | EntityNode[] + | FDCScalarValue + | StubDataObjectJson + | StubDataObjectJson[] + >, + isSdo = false + ): Record< + string, + | EntityNode + | EntityNode[] + | FDCScalarValue + | StubDataObjectJson + | StubDataObjectJson[] + > { + const newMap: typeof map = {}; + for (const key in map) { + if (map.hasOwnProperty(key)) { + if (Array.isArray(map[key])) { + newMap[key] = map[key].map(value => + isSdo + ? EntityNode.fromStorableJson(value as StubDataObjectJson) + : value + ) as EntityNode[] | FDCScalarValue[]; + } else { + newMap[key] = isSdo + ? EntityNode.fromStorableJson(map[key] as StubDataObjectJson) + : map[key]; + } + } + } + return newMap; + } + static fromStorableJson(obj: StubDataObjectJson): EntityNode { + const sdo = new EntityNode(); + if (obj.backingData) { + sdo.entityData = EntityDataObject.fromStorableJson(obj.backingData); + } + sdo.acc = new ImpactedQueryRefsAccumulator(); + sdo.globalId = obj.globalID; + sdo.impactedQueryRefs = new Set(); + sdo.scalars = EntityNode.parseMap(obj.scalars) as Record< + string, + FDCScalarValue + >; + sdo.references = EntityNode.parseMap( + obj.references + ) as typeof sdo.references; + sdo.objectLists = EntityNode.parseMap( + obj.objectLists, + true + ) as typeof sdo.objectLists; + return sdo; + } + getStorableMap(map: { [key: string]: EntityNode | EntityNode[] }): { + [key: string]: StubDataObjectJson | StubDataObjectJson[]; + } { + const newMap: { [key: string]: StubDataObjectJson | StubDataObjectJson[] } = + {}; + for (const key in map) { + if (map.hasOwnProperty(key)) { + if (Array.isArray(map[key])) { + newMap[key] = map[key].map(value => value.toStorableJson()); + } else { + newMap[key] = map[key].toStorableJson(); + } + } + } + return newMap; + } + toStorableJson(): StubDataObjectJson { + const obj: StubDataObjectJson = { + globalID: this.globalId, + scalars: this.scalars, + references: this.getStorableMap( + this.references + ) as StubDataObjectJson['references'], + objectLists: this.getStorableMap( + this.objectLists + ) as StubDataObjectJson['objectLists'] + }; + if (this.entityData) { + obj.backingData = this.entityData.toStorableJson(); + } + return obj; + } +} + +export interface StubDataObjectJson { + backingData?: BackingDataObjectJson; + globalID?: string; + scalars: { [key: string]: FDCScalarValue }; + references: { [key: string]: StubDataObjectJson }; + objectLists: { + [key: string]: StubDataObjectJson[]; + }; +} diff --git a/packages/data-connect/src/cache/ImpactedQueryRefsAccumulator.ts b/packages/data-connect/src/cache/ImpactedQueryRefsAccumulator.ts new file mode 100644 index 0000000000..9cdfb71ff9 --- /dev/null +++ b/packages/data-connect/src/cache/ImpactedQueryRefsAccumulator.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ImpactedQueryRefsAccumulator { + impacted = new Set(); + add(impacted: string[]): void { + impacted.forEach(ref => this.impacted.add(ref)); + } + consumeEvents(): string[] { + const events = Array.from(this.impacted); + this.impacted.clear(); + return events; + } +} diff --git a/packages/data-connect/src/cache/InMemoryCacheProvider.ts b/packages/data-connect/src/cache/InMemoryCacheProvider.ts new file mode 100644 index 0000000000..de71a2ce28 --- /dev/null +++ b/packages/data-connect/src/cache/InMemoryCacheProvider.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InternalCacheProvider } from './CacheProvider'; +import { EntityDataObject } from './EntityDataObject'; +import { ResultTree } from './ResultTree'; + +export class InMemoryCacheProvider implements InternalCacheProvider { + private bdos = new Map(); + private resultTrees = new Map(); + constructor(private _keyId: string) {} + + setResultTree(queryId: string, rt: ResultTree): Promise { + this.resultTrees.set(queryId, rt); + return Promise.resolve(); + } + // TODO: Should this be in the cache provider? This seems common along all CacheProviders. + async getResultTree(queryId: string): Promise { + return this.resultTrees.get(queryId); + } + createGlobalId(): string { + return crypto.randomUUID(); + } + updateBackingData(backingData: EntityDataObject): Promise { + this.bdos.set(backingData.globalID, backingData); + return Promise.resolve(); + } + async getBdo(globalId: string): Promise { + if (!this.bdos.has(globalId)) { + this.bdos.set(globalId, new EntityDataObject(globalId)); + } + // Because of the above, we can guarantee that there will be a BDO at the globalId. + return this.bdos.get(globalId)!; + } + close(): Promise { + // TODO: Noop + return Promise.resolve(); + } +} diff --git a/packages/data-connect/src/cache/ResultTree.ts b/packages/data-connect/src/cache/ResultTree.ts new file mode 100644 index 0000000000..413025068d --- /dev/null +++ b/packages/data-connect/src/cache/ResultTree.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityNode, StubDataObjectJson } from './EntityNode'; + +export class ResultTree { + static parse(value: ResultTreeJson): ResultTree { + const rt = new ResultTree( + value.data, + EntityNode.fromStorableJson(value.rootStub), + value.maxAge, + value.cachedAt, + value.lastAccessed + ); + return rt; + } + constructor( + public readonly data: string, + private rootStub: EntityNode, + private maxAge: number = 30000, + public readonly cachedAt: Date, + private _lastAccessed: Date + ) {} + isStale(): boolean { + return ( + Date.now() - new Date(this.cachedAt.getTime()).getTime() > this.maxAge + ); + } + updateMaxAge(maxAgeInMs: number): void { + this.maxAge = maxAgeInMs; + } + updateAccessed(): void { + this._lastAccessed = new Date(); + } + get lastAccessed(): Date { + return this._lastAccessed; + } + getRootStub(): EntityNode { + return this.rootStub; + } +} + +interface ResultTreeJson { + rootStub: StubDataObjectJson; + maxAge: number; + cachedAt: Date; + lastAccessed: Date; + data: string; +} diff --git a/packages/data-connect/src/cache/ResultTreeProcessor.ts b/packages/data-connect/src/cache/ResultTreeProcessor.ts new file mode 100644 index 0000000000..f48aaf247c --- /dev/null +++ b/packages/data-connect/src/cache/ResultTreeProcessor.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InternalCacheProvider } from './CacheProvider'; +import { EntityNode } from './EntityNode'; +import { ImpactedQueryRefsAccumulator } from './ImpactedQueryRefsAccumulator'; + +interface DehydratedResults { + entityNode: EntityNode; + data: string; +} + +export class ResultTreeProcessor { + hydrateResults(rootStubObject: EntityNode): string { + return JSON.stringify(rootStubObject.toJson()); + } + async dehydrateResults( + json: Record, + entityIds: Record, // TODO: handle entity ids. + cacheProvider: InternalCacheProvider, + acc: ImpactedQueryRefsAccumulator, + queryId: string + ): Promise { + const entityNode = new EntityNode(acc); + await entityNode.loadData(queryId, json, entityIds, cacheProvider); + return { + entityNode, + data: JSON.stringify(entityNode.toStorableJson()) + }; + } +} diff --git a/packages/data-connect/src/core/FirebaseAuthProvider.ts b/packages/data-connect/src/core/FirebaseAuthProvider.ts index a19b8a46d6..bb512e9ecf 100644 --- a/packages/data-connect/src/core/FirebaseAuthProvider.ts +++ b/packages/data-connect/src/core/FirebaseAuthProvider.ts @@ -29,6 +29,7 @@ import { logDebug, logError } from '../logger'; export interface AuthTokenProvider { getToken(forceRefresh: boolean): Promise; addTokenChangeListener(listener: AuthTokenListener): void; + getAuth(): FirebaseAuthInternal; } export type AuthTokenListener = (token: string | null) => void; @@ -45,6 +46,9 @@ export class FirebaseAuthProvider implements AuthTokenProvider { _authProvider.onInit(auth => (this._auth = auth)); } } + getAuth(): FirebaseAuthInternal { + return this._auth; + } getToken(forceRefresh: boolean): Promise { if (!this._auth) { return new Promise((resolve, reject) => { diff --git a/packages/data-connect/src/core/QueryManager.ts b/packages/data-connect/src/core/QueryManager.ts deleted file mode 100644 index 109f1d105b..0000000000 --- a/packages/data-connect/src/core/QueryManager.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @license - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - OnCompleteSubscription, - OnErrorSubscription, - OnResultSubscription, - QueryPromise, - QueryRef, - QueryResult -} from '../api/query'; -import { - OperationRef, - QUERY_STR, - OpResult, - SerializedRef, - SOURCE_SERVER, - DataSource, - SOURCE_CACHE -} from '../api/Reference'; -import { logDebug } from '../logger'; -import { DataConnectTransport } from '../network'; -import { encoderImpl } from '../util/encoder'; -import { setIfNotExists } from '../util/map'; - -import { Code, DataConnectError } from './error'; - -/** - * Representation of user provided subscription options. - */ -interface DataConnectSubscription { - userCallback: OnResultSubscription; - errCallback?: (e?: DataConnectError) => void; - onCompleteCallback?: () => void; - unsubscribe: () => void; -} - -interface TrackedQuery { - ref: Omit, 'dataConnect'>; - subscriptions: Array>; - currentCache: OpResult | null; - lastError: DataConnectError | null; -} - -function getRefSerializer( - queryRef: QueryRef, - data: Data, - source: DataSource -) { - return function toJSON(): SerializedRef { - return { - data, - refInfo: { - name: queryRef.name, - variables: queryRef.variables, - connectorConfig: { - projectId: queryRef.dataConnect.app.options.projectId!, - ...queryRef.dataConnect.getSettings() - } - }, - fetchTime: Date.now().toLocaleString(), - source - }; - }; -} - -export class QueryManager { - _queries: Map>; - constructor(private transport: DataConnectTransport) { - this._queries = new Map(); - } - track( - queryName: string, - variables: Variables, - initialCache?: OpResult - ): TrackedQuery { - const ref: TrackedQuery['ref'] = { - name: queryName, - variables, - refType: QUERY_STR - }; - const key = encoderImpl(ref); - const newTrackedQuery: TrackedQuery = { - ref, - subscriptions: [], - currentCache: initialCache || null, - lastError: null - }; - // @ts-ignore - setIfNotExists(this._queries, key, newTrackedQuery); - return this._queries.get(key) as TrackedQuery; - } - addSubscription( - queryRef: OperationRef, - onResultCallback: OnResultSubscription, - onCompleteCallback?: OnCompleteSubscription, - onErrorCallback?: OnErrorSubscription, - initialCache?: OpResult - ): () => void { - const key = encoderImpl({ - name: queryRef.name, - variables: queryRef.variables, - refType: QUERY_STR - }); - const trackedQuery = this._queries.get(key) as TrackedQuery< - Data, - Variables - >; - const subscription = { - userCallback: onResultCallback, - onCompleteCallback, - errCallback: onErrorCallback - }; - const unsubscribe = (): void => { - const trackedQuery = this._queries.get(key)!; - trackedQuery.subscriptions = trackedQuery.subscriptions.filter( - sub => sub !== subscription - ); - onCompleteCallback?.(); - }; - if (initialCache && trackedQuery.currentCache !== initialCache) { - logDebug('Initial cache found. Comparing dates.'); - if ( - !trackedQuery.currentCache || - (trackedQuery.currentCache && - compareDates( - trackedQuery.currentCache.fetchTime, - initialCache.fetchTime - )) - ) { - trackedQuery.currentCache = initialCache; - } - } - if (trackedQuery.currentCache !== null) { - const cachedData = trackedQuery.currentCache.data; - onResultCallback({ - data: cachedData, - source: SOURCE_CACHE, - ref: queryRef as QueryRef, - toJSON: getRefSerializer( - queryRef as QueryRef, - trackedQuery.currentCache.data, - SOURCE_CACHE - ), - fetchTime: trackedQuery.currentCache.fetchTime - }); - if (trackedQuery.lastError !== null && onErrorCallback) { - onErrorCallback(undefined); - } - } - - trackedQuery.subscriptions.push({ - userCallback: onResultCallback, - errCallback: onErrorCallback, - unsubscribe - }); - if (!trackedQuery.currentCache) { - logDebug( - `No cache available for query ${ - queryRef.name - } with variables ${JSON.stringify( - queryRef.variables - )}. Calling executeQuery.` - ); - const promise = this.executeQuery(queryRef as QueryRef); - // We want to ignore the error and let subscriptions handle it - promise.then(undefined, err => {}); - } - return unsubscribe; - } - executeQuery( - queryRef: QueryRef - ): QueryPromise { - if (queryRef.refType !== QUERY_STR) { - throw new DataConnectError( - Code.INVALID_ARGUMENT, - `ExecuteQuery can only execute query operation` - ); - } - const key = encoderImpl({ - name: queryRef.name, - variables: queryRef.variables, - refType: QUERY_STR - }); - const trackedQuery = this._queries.get(key)!; - const result = this.transport.invokeQuery( - queryRef.name, - queryRef.variables - ); - const newR = result.then( - res => { - const fetchTime = new Date().toString(); - const result: QueryResult = { - ...res, - source: SOURCE_SERVER, - ref: queryRef, - toJSON: getRefSerializer(queryRef, res.data, SOURCE_SERVER), - fetchTime - }; - trackedQuery.subscriptions.forEach(subscription => { - subscription.userCallback(result); - }); - trackedQuery.currentCache = { - data: res.data, - source: SOURCE_CACHE, - fetchTime - }; - return result; - }, - err => { - trackedQuery.lastError = err; - trackedQuery.subscriptions.forEach(subscription => { - if (subscription.errCallback) { - subscription.errCallback(err); - } - }); - throw err; - } - ); - - return newR; - } - enableEmulator(host: string, port: number): void { - this.transport.useEmulator(host, port); - } -} -function compareDates(str1: string, str2: string): boolean { - const date1 = new Date(str1); - const date2 = new Date(str2); - return date1.getTime() < date2.getTime(); -} diff --git a/packages/data-connect/src/core/query/QueryManager.ts b/packages/data-connect/src/core/query/QueryManager.ts new file mode 100644 index 0000000000..e3f3cf3db7 --- /dev/null +++ b/packages/data-connect/src/core/query/QueryManager.ts @@ -0,0 +1,441 @@ +/** + * @license + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type DataConnect } from '../../api/DataConnect'; +import { QueryRef, QueryResult } from '../../api/query'; +import { + OperationRef, + QUERY_STR, + SerializedRef, + SOURCE_SERVER, + DataSource, + SOURCE_CACHE, + OpResult +} from '../../api/Reference'; +import { DataConnectSubscription } from '../../api.browser'; +import { DataConnectCache, ServerValues } from '../../cache/Cache'; +import { logDebug } from '../../logger'; +import { DataConnectExtension, DataConnectTransport } from '../../network'; +import { decoderImpl, encoderImpl } from '../../util/encoder'; +import { Code, DataConnectError } from '../error'; + +import { ExecuteQueryOptions, QueryFetchPolicy } from './queryOptions'; +import { + OnCompleteSubscription, + OnErrorSubscription, + OnResultSubscription +} from './subscribe'; + +export function getRefSerializer( + queryRef: QueryRef, + data: Data, + source: DataSource +) { + return function toJSON(): SerializedRef { + return { + data, + refInfo: { + name: queryRef.name, + variables: queryRef.variables, + connectorConfig: { + projectId: queryRef.dataConnect.app.options.projectId!, + ...queryRef.dataConnect.getSettings() + } + }, + fetchTime: Date.now().toLocaleString(), // TODO: Fix the fetch time here. + source + }; + }; +} + +export class QueryManager { + private callbacks = new Map< + string, + Array> + >(); + private subscriptionCache = new Map>(); + constructor( + private transport: DataConnectTransport, + private dc: DataConnect, + private cache?: DataConnectCache + ) {} + private queue: Array> = []; + async waitForQueuedWrites(): Promise { + for (const promise of this.queue) { + await promise; + } + this.queue = []; + } + + updateSSR(updatedData: QueryResult): void { + this.queue.push( + this.updateCache(updatedData).then(async result => + this.publishCacheResultsToSubscribers(result) + ) + ); + } + + async updateCache( + result: QueryResult + ): Promise { + await this.waitForQueuedWrites(); + if (this.cache) { + const entityIds = parseEntityIds(result); + return this.cache.update( + encoderImpl({ + name: result.ref.name, + variables: result.ref.variables, + refType: QUERY_STR + }), + result.data as ServerValues, + entityIds + ); + } else { + const key = encoderImpl({ + name: result.ref.name, + variables: result.ref.variables, + refType: QUERY_STR + }); + this.subscriptionCache.set(key, result); + return [key]; + } + } + + addSubscription( + queryRef: OperationRef, + onResultCallback: OnResultSubscription, + onCompleteCallback?: OnCompleteSubscription, + onErrorCallback?: OnErrorSubscription, + initialCache?: QueryResult + ): () => void { + const key = encoderImpl({ + name: queryRef.name, + variables: queryRef.variables, + refType: QUERY_STR + }); + + const unsubscribe = (): void => { + if (this.callbacks.has(key)) { + const callbackList = this.callbacks.get(key)!; + this.callbacks.set( + key, + callbackList.filter(callback => callback !== subscription) + ); + onCompleteCallback?.(); + } + }; + const subscription: DataConnectSubscription = { + userCallback: onResultCallback, + errCallback: onErrorCallback, + unsubscribe + }; + + if (initialCache) { + this.updateSSR(initialCache); + } + + logDebug( + `Cache not available for query ${ + queryRef.name + } with variables ${JSON.stringify( + queryRef.variables + )}. Calling executeQuery.` + ); + const promise = this.maybeExecuteQuery( + queryRef as QueryRef + ); + // We want to ignore the error and let subscriptions handle it + promise.then(undefined, err => {}); + + if (!this.callbacks.has(key)) { + this.callbacks.set(key, []); + } + this.callbacks + .get(key)! + .push(subscription as DataConnectSubscription); + + return unsubscribe; + } + async loadCache( + queryRef: QueryRef, + cachingEnabled: boolean, + options?: ExecuteQueryOptions + ): Promise | null> { + const key = encoderImpl({ + name: queryRef.name, + variables: queryRef.variables, + refType: QUERY_STR + }); + if (!cachingEnabled) { + const cachedData = this.subscriptionCache.get(key) as QueryResult< + Data, + Variables + >; + if (cachedData) { + this.publishDataToSubscribers(key, { + data: cachedData.data, + fetchTime: cachedData.fetchTime, + ref: cachedData.ref, + source: SOURCE_CACHE, + toJSON: getRefSerializer( + cachedData.ref, + cachedData.data, + SOURCE_CACHE + ) + }); + return cachedData; + } + } + if ( + options?.fetchPolicy !== QueryFetchPolicy.SERVER_ONLY && + cachingEnabled && + (await this.cache!.containsResultTree(key)) && + !(await this.cache!.getResultTree(key))!.isStale() + ) { + const cacheResult: Data = JSON.parse( + await this.cache!.getResultJSON(key) + ); + const resultTree = await this.cache!.getResultTree(key); + const result: QueryResult = { + source: SOURCE_CACHE, + ref: queryRef, + data: cacheResult, + toJSON: getRefSerializer(queryRef, cacheResult, SOURCE_CACHE), + fetchTime: resultTree!.cachedAt.toString() + }; + (await this.cache!.getResultTree(key))!.updateAccessed(); + logDebug( + `Cache found for query ${queryRef.name} with variables ${JSON.stringify( + queryRef.variables + )}. Calling executeQuery` + ); + return result; + } else { + if (options?.fetchPolicy === QueryFetchPolicy.SERVER_ONLY) { + logDebug(`Skipping cache for fetch policy "serverOnly"`); + } else { + logDebug( + `No Cache found for query ${ + queryRef.name + } with variables ${JSON.stringify( + queryRef.variables + )}. Calling executeQuery` + ); + } + } + return null; + } + async maybeExecuteQuery( + queryRef: QueryRef, + options?: ExecuteQueryOptions + ): Promise> { + await this.waitForQueuedWrites(); + if (queryRef.refType !== QUERY_STR) { + throw new DataConnectError( + Code.INVALID_ARGUMENT, + `ExecuteQuery can only execute query operations` + ); + } + let queryResult: QueryResult | undefined; + const cachingEnabled = this.cache && !!this.cache.cacheSettings; + let shouldExecute = false; + const key = encoderImpl({ + name: queryRef.name, + variables: queryRef.variables, + refType: QUERY_STR + }); + if (options?.fetchPolicy === QueryFetchPolicy.SERVER_ONLY) { + // definitely execute query + // queryResult = await this.executeQuery(queryRef, options); + shouldExecute = true; + } else { + if (!cachingEnabled) { + // read from subscriber cache. + const fromSubscriberCache = await this.getFromSubscriberCache(key); + if (!fromSubscriberCache) { + shouldExecute = true; + } + queryResult = fromSubscriberCache as QueryResult; + } else { + if (!this.cache?.containsResultTree(key)) { + shouldExecute = true; + } else { + queryResult = await this.getFromResultTreeCache(key, queryRef); + } + } + } + let impactedQueries: string[] = [key]; + if (shouldExecute) { + try { + const response = await this.transport.invokeQuery( + queryRef.name, + queryRef.variables + ); + queryResult = { + data: response.data, + fetchTime: new Date().toISOString(), + ref: queryRef, + source: SOURCE_SERVER, + extensions: response.extensions, + toJSON: getRefSerializer(queryRef, response.data, SOURCE_SERVER) + }; + impactedQueries = await this.updateCache(queryResult); + } catch (e: unknown) { + this.publishErrorToSubscribers(key, e); + throw e; + } + } + if (!cachingEnabled) { + this.subscriptionCache.set(key, queryResult!); + this.publishDataToSubscribers(key, queryResult!); + } else { + await this.publishCacheResultsToSubscribers(impactedQueries); + } + return queryResult!; + } + publishErrorToSubscribers(key: string, err: unknown): void { + this.callbacks.get(key)?.forEach(subscription => { + if (subscription.errCallback) { + subscription.errCallback(err as DataConnectError); + } + }); + } + async getFromResultTreeCache( + key: string, + queryRef: QueryRef + ): Promise> { + const cacheResult: Data = JSON.parse(await this.cache!.getResultJSON(key)); + const resultTree = await this.cache!.getResultTree(key); + const result: QueryResult = { + source: SOURCE_CACHE, + ref: queryRef, + data: cacheResult, + toJSON: getRefSerializer(queryRef, cacheResult, SOURCE_CACHE), + fetchTime: resultTree!.cachedAt.toString() + }; + (await this.cache!.getResultTree(key))!.updateAccessed(); + return result; + } + async getFromSubscriberCache( + key: string + ): Promise | undefined> { + if (!this.subscriptionCache.has(key)) { + return; + } + const result = this.subscriptionCache.get(key); + result!.source = SOURCE_CACHE; + result!.toJSON = getRefSerializer(result!.ref, result!.data, SOURCE_CACHE); + return result; + } + + publishDataToSubscribers( + key: string, + queryResult: QueryResult + ): void { + if (!this.callbacks.has(key)) { + return; + } + const subscribers = this.callbacks.get(key); + subscribers!.forEach(callback => { + callback.userCallback(queryResult); + }); + } + async publishCacheResultsToSubscribers( + impactedQueries: string[] + ): Promise { + if (!this.cache) { + return; + } + for (const query of impactedQueries) { + const callbacks = this.callbacks.get(query); + if (!callbacks) { + continue; + } + const newJson = (await this.cache.getResultTree(query))! + .getRootStub() + .toJson(); + const { name, variables } = decoderImpl(query) as QueryRef< + unknown, + unknown + >; + const queryRef: QueryRef = { + dataConnect: this.dc, + refType: QUERY_STR, + name, + variables + }; + this.publishDataToSubscribers(query, { + data: newJson, + fetchTime: new Date().toISOString(), + ref: queryRef, + source: SOURCE_CACHE, + toJSON: getRefSerializer(queryRef, newJson, SOURCE_CACHE) + }); + } + } + enableEmulator(host: string, port: number): void { + this.transport.useEmulator(host, port); + } +} + +// TODO: Move this to its own file +// TODO: Make this return a Record, and use this as a lookup table for entity ids +export function parseEntityIds( + result: OpResult +): Record { + // Iterate through extensions.dataConnect + const dataConnectExtensions = result.extensions?.dataConnect; + const dataCopy = Object.assign(result); + if (!dataConnectExtensions) { + return dataCopy; + } + const ret: Record = {}; + for (const extension of dataConnectExtensions) { + const { path } = extension; + populatePath(path, ret, extension); + } + return ret; +} + +// TODO: Only enable this if clientCache.includeEntityId is true +// mutates the object to update the path +export function populatePath( + path: Array, + toUpdate: Record, + extension: DataConnectExtension +): void { + let curObj: Record = toUpdate; + for (const slice of path) { + if (typeof curObj[slice] !== 'object') { + curObj[slice] = {}; + } + curObj = curObj[slice] as Record; + } + + if ('entityId' in extension) { + curObj['_id'] = extension.entityId; + } else { + const entityArr = extension.entityIds; + for (let i = 0; i < entityArr.length; i++) { + const entityId = entityArr[i]; + if (typeof curObj[i] === 'undefined') { + curObj[i] = {}; + } + (curObj[i] as Record)._id = entityId; + } + } +} diff --git a/packages/data-connect/src/core/query/queryOptions.ts b/packages/data-connect/src/core/query/queryOptions.ts new file mode 100644 index 0000000000..6afb1fe94d --- /dev/null +++ b/packages/data-connect/src/core/query/queryOptions.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const QueryFetchPolicy = { + PREFER_CACHE: 'PREFER_CACHE', + CACHE_ONLY: 'CACHE_ONLY', + SERVER_ONLY: 'SERVER_ONLY' +} as const; + +/* + * Represents policy for how executeQuery fetches data + * + */ +export type QueryFetchPolicy = + (typeof QueryFetchPolicy)[keyof typeof QueryFetchPolicy]; + +export interface ExecuteQueryOptions { + fetchPolicy: QueryFetchPolicy; +} diff --git a/packages/data-connect/src/core/query/subscribe.ts b/packages/data-connect/src/core/query/subscribe.ts new file mode 100644 index 0000000000..aed0a37f1b --- /dev/null +++ b/packages/data-connect/src/core/query/subscribe.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryRef, QueryResult, toQueryRef } from '../../api/query'; +import { SerializedRef } from '../../api/Reference'; +import { DataConnectError, Code } from '../error'; + +import { getRefSerializer } from './QueryManager'; + +/** + * `OnCompleteSubscription` + */ +export type OnCompleteSubscription = () => void; +/** + * Representation of full observer options in `subscribe` + */ +export interface SubscriptionOptions { + onNext?: OnResultSubscription; + onErr?: OnErrorSubscription; + onComplete?: OnCompleteSubscription; +} + +/** + * Signature for `OnResultSubscription` for `subscribe` + */ +export type OnResultSubscription = ( + res: QueryResult +) => void; +/** + * Signature for `OnErrorSubscription` for `subscribe` + */ +export type OnErrorSubscription = (err?: DataConnectError) => void; +/** + * Signature for unsubscribe from `subscribe` + */ +export type QueryUnsubscribe = () => void; +/** + * Representation of user provided subscription options. + */ +export interface DataConnectSubscription { + userCallback: OnResultSubscription; + errCallback?: (e?: DataConnectError) => void; + unsubscribe: () => void; +} + +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param observer observer object to use for subscribing. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + observer: SubscriptionOptions +): QueryUnsubscribe; +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param onNext Callback to call when result comes back. + * @param onError Callback to call when error gets thrown. + * @param onComplete Called when subscription completes. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + onNext: OnResultSubscription, + onError?: OnErrorSubscription, + onComplete?: OnCompleteSubscription +): QueryUnsubscribe; +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param observerOrOnNext observer object or next function. + * @param onError Callback to call when error gets thrown. + * @param onComplete Called when subscription completes. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + observerOrOnNext: + | SubscriptionOptions + | OnResultSubscription, + onError?: OnErrorSubscription, + onComplete?: OnCompleteSubscription +): QueryUnsubscribe { + let ref: QueryRef; + let initialCache: QueryResult | undefined; + if ('refInfo' in queryRefOrSerializedResult) { + const serializedRef: SerializedRef = + queryRefOrSerializedResult; + const { data, source, fetchTime } = serializedRef; + + ref = toQueryRef(serializedRef); + initialCache = { + data, + source, + fetchTime, + ref, + toJSON: getRefSerializer(ref, data, source) + }; + } else { + ref = queryRefOrSerializedResult; + } + let onResult: OnResultSubscription | undefined = undefined; + if (typeof observerOrOnNext === 'function') { + onResult = observerOrOnNext; + } else { + onResult = observerOrOnNext.onNext; + onError = observerOrOnNext.onErr; + onComplete = observerOrOnNext.onComplete; + } + if (!onResult) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'Must provide onNext'); + } + return ref.dataConnect._queryManager.addSubscription( + ref, + onResult, + onComplete, + onError, + initialCache + ); +} diff --git a/packages/data-connect/src/network/fetch.ts b/packages/data-connect/src/network/fetch.ts index 62cef6fb22..39206bb9c0 100644 --- a/packages/data-connect/src/network/fetch.ts +++ b/packages/data-connect/src/network/fetch.ts @@ -26,7 +26,12 @@ import { import { SDK_VERSION } from '../core/version'; import { logError } from '../logger'; -import { CallerSdkType, CallerSdkTypeEnum } from './transport'; +import { + CallerSdkType, + CallerSdkTypeEnum, + DataConnectExtensions, + DataConnectResponse +} from './transport'; let connectFetch: typeof fetch | null = globalThis.fetch; export function initializeFetch(fetchImpl: typeof fetch): void { @@ -52,7 +57,7 @@ export interface DataConnectFetchBody { operationName: string; variables: T; } -export function dcFetch( +export async function dcFetch( url: string, body: DataConnectFetchBody, { signal }: AbortController, @@ -62,7 +67,7 @@ export function dcFetch( _isUsingGen: boolean, _callerSdkType: CallerSdkType, _isUsingEmulator: boolean -): Promise<{ data: T; errors: Error[] }> { +): Promise> { if (!connectFetch) { throw new DataConnectError(Code.OTHER, 'No Fetch Implementation detected!'); } @@ -90,51 +95,49 @@ export function dcFetch( fetchOptions.credentials = 'include'; } - return connectFetch(url, fetchOptions) - .catch(err => { - throw new DataConnectError( - Code.OTHER, - 'Failed to fetch: ' + JSON.stringify(err) - ); - }) - .then(async response => { - let jsonResponse = null; - try { - jsonResponse = await response.json(); - } catch (e) { - throw new DataConnectError(Code.OTHER, JSON.stringify(e)); - } - const message = getMessage(jsonResponse); - if (response.status >= 400) { - logError( - 'Error while performing request: ' + JSON.stringify(jsonResponse) - ); - if (response.status === 401) { - throw new DataConnectError(Code.UNAUTHORIZED, message); - } - throw new DataConnectError(Code.OTHER, message); - } - return jsonResponse; - }) - .then(res => { - if (res.errors && res.errors.length) { - const stringified = JSON.stringify(res.errors); - const response: DataConnectOperationFailureResponse = { - errors: res.errors, - data: res.data - }; - throw new DataConnectOperationError( - 'DataConnect error while performing request: ' + stringified, - response - ); - } - return res; - }); + let response: Response; + try { + response = await connectFetch(url, fetchOptions); + } catch (err) { + throw new DataConnectError( + Code.OTHER, + 'Failed to fetch: ' + JSON.stringify(err) + ); + } + let jsonResponse: JsonResponse; + try { + jsonResponse = await response.json(); + } catch (e) { + throw new DataConnectError(Code.OTHER, JSON.stringify(e)); + } + const message = getErrorMessage(jsonResponse); + if (response.status >= 400) { + logError('Error while performing request: ' + JSON.stringify(jsonResponse)); + if (response.status === 401) { + throw new DataConnectError(Code.UNAUTHORIZED, message); + } + throw new DataConnectError(Code.OTHER, message); + } + if (jsonResponse.errors && jsonResponse.errors.length) { + const stringified = JSON.stringify(jsonResponse.errors); + const failureResponse: DataConnectOperationFailureResponse = { + errors: jsonResponse.errors, + data: jsonResponse.data as Record + }; + throw new DataConnectOperationError( + 'DataConnect error while performing request: ' + stringified, + failureResponse + ); + } + return jsonResponse as DataConnectResponse; } -interface MessageObject { +interface JsonResponse { message?: string; + errors: []; + data: Record | T | null; + extensions: DataConnectExtensions; } -function getMessage(obj: MessageObject): string { +function getErrorMessage(obj: JsonResponse): string { if ('message' in obj && obj.message) { return obj.message; } diff --git a/packages/data-connect/src/network/transport/index.ts b/packages/data-connect/src/network/transport/index.ts index 8b106b4d63..3f4a9fe5e8 100644 --- a/packages/data-connect/src/network/transport/index.ts +++ b/packages/data-connect/src/network/transport/index.ts @@ -39,6 +39,28 @@ export const CallerSdkTypeEnum = { GeneratedAngular: 'GeneratedAngular' // Generated Angular SDK } as const; +export interface DataConnectEntityArray { + entityIds: string[]; +} + +export interface DataConnectSingleEntity { + entityId: string; +} + +export type DataConnectExtension = { + path: Array; +} & (DataConnectEntityArray | DataConnectSingleEntity); + +export interface DataConnectExtensions { + dataConnect?: DataConnectExtension[]; +} + +export interface DataConnectResponse { + data: T; + errors: Error[]; + extensions: DataConnectExtensions; +} + /** * @internal */ @@ -46,11 +68,11 @@ export interface DataConnectTransport { invokeQuery( queryName: string, body?: U - ): Promise<{ data: T; errors: Error[] }>; + ): Promise>; invokeMutation( queryName: string, body?: U - ): Promise<{ data: T; errors: Error[] }>; + ): Promise>; useEmulator(host: string, port?: number, sslEnabled?: boolean): void; onTokenChanged: (token: string | null) => void; _setCallerSdkType(callerSdkType: CallerSdkType): void; diff --git a/packages/data-connect/src/network/transport/rest.ts b/packages/data-connect/src/network/transport/rest.ts index 4a3af8ac41..5f5fa320ef 100644 --- a/packages/data-connect/src/network/transport/rest.ts +++ b/packages/data-connect/src/network/transport/rest.ts @@ -23,7 +23,12 @@ import { logDebug } from '../../logger'; import { addToken, urlBuilder } from '../../util/url'; import { dcFetch } from '../fetch'; -import { CallerSdkType, CallerSdkTypeEnum, DataConnectTransport } from '.'; +import { + CallerSdkType, + CallerSdkTypeEnum, + DataConnectResponse, + DataConnectTransport +} from '.'; export class RESTTransport implements DataConnectTransport { private _host = ''; @@ -40,7 +45,7 @@ export class RESTTransport implements DataConnectTransport { constructor( options: DataConnectOptions, private apiKey?: string | undefined, - private appId?: string, + private appId?: string | null, private authProvider?: AuthTokenProvider | undefined, private appCheckProvider?: AppCheckTokenProvider | undefined, transportOptions?: TransportOptions | undefined, @@ -111,7 +116,10 @@ export class RESTTransport implements DataConnectTransport { resolve(this._accessToken) ); if (this.appCheckProvider) { - this._appCheckToken = (await this.appCheckProvider.getToken())?.token; + const appCheckToken = await this.appCheckProvider.getToken(); + if (appCheckToken) { + this._appCheckToken = appCheckToken.token; + } } if (this.authProvider) { starterPromise = this.authProvider @@ -134,9 +142,9 @@ export class RESTTransport implements DataConnectTransport { } withRetry( - promiseFactory: () => Promise<{ data: T; errors: Error[] }>, + promiseFactory: () => Promise>, retry = false - ): Promise<{ data: T; errors: Error[] }> { + ): Promise> { let isNewToken = false; return this.getWithAuth(retry) .then(res => { @@ -164,7 +172,7 @@ export class RESTTransport implements DataConnectTransport { invokeQuery: ( queryName: string, body?: U - ) => Promise<{ data: T; errors: Error[] }> = ( + ) => Promise> = ( queryName: string, body: U ) => { @@ -193,7 +201,7 @@ export class RESTTransport implements DataConnectTransport { invokeMutation: ( queryName: string, body?: U - ) => Promise<{ data: T; errors: Error[] }> = ( + ) => Promise> = ( mutationName: string, body: U ) => { diff --git a/packages/data-connect/src/register.ts b/packages/data-connect/src/register.ts index badebf2a29..39bb989e7b 100644 --- a/packages/data-connect/src/register.ts +++ b/packages/data-connect/src/register.ts @@ -33,13 +33,16 @@ export function registerDataConnect(variant?: string): void { _registerComponent( new Component( 'data-connect', - (container, { instanceIdentifier: settings, options }) => { + (container, { instanceIdentifier: connectorConfigStr, options }) => { const app = container.getProvider('app').getImmediate()!; const authProvider = container.getProvider('auth-internal'); const appCheckProvider = container.getProvider('app-check-internal'); let newOpts = options as ConnectorConfig; - if (settings) { - newOpts = JSON.parse(settings); + if (connectorConfigStr) { + newOpts = { + ...newOpts, + ...JSON.parse(connectorConfigStr) + }; } if (!app.options.projectId) { throw new DataConnectError( diff --git a/packages/data-connect/src/util/encoder.ts b/packages/data-connect/src/util/encoder.ts index 55aff801d2..23bc1c3cd0 100644 --- a/packages/data-connect/src/util/encoder.ts +++ b/packages/data-connect/src/util/encoder.ts @@ -17,7 +17,14 @@ export type HmacImpl = (obj: unknown) => string; export let encoderImpl: HmacImpl; +export type DecodeHmacImpl = (s: string) => object; +export let decoderImpl: DecodeHmacImpl; export function setEncoder(encoder: HmacImpl): void { encoderImpl = encoder; } +export function setDecoder(decoder: DecodeHmacImpl): void { + decoderImpl = decoder; +} +// TODO(mtewani): Fix issue where if fields are out of order, caching breaks. setEncoder(o => JSON.stringify(o)); +setDecoder(s => JSON.parse(s)); diff --git a/packages/data-connect/src/util/url.ts b/packages/data-connect/src/util/url.ts index 5063058276..16fb28750d 100644 --- a/packages/data-connect/src/util/url.ts +++ b/packages/data-connect/src/util/url.ts @@ -19,6 +19,8 @@ import { DataConnectOptions, TransportOptions } from '../api/DataConnect'; import { Code, DataConnectError } from '../core/error'; import { logError } from '../logger'; +export const PROD_HOST = 'firebasedataconnect.googleapis.com'; + export function urlBuilder( projectConfig: DataConnectOptions, transportOptions: TransportOptions @@ -26,7 +28,7 @@ export function urlBuilder( const { connector, location, projectId: project, service } = projectConfig; const { host, sslEnabled, port } = transportOptions; const protocol = sslEnabled ? 'https' : 'http'; - const realHost = host || `firebasedataconnect.googleapis.com`; + const realHost = host || PROD_HOST; let baseUrl = `${protocol}://${realHost}`; if (typeof port === 'number') { baseUrl += `:${port}`; diff --git a/packages/data-connect/test/queries.test.ts b/packages/data-connect/test/queries.test.ts index 24db1e4508..7c8a48a8c5 100644 --- a/packages/data-connect/test/queries.test.ts +++ b/packages/data-connect/test/queries.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -85,13 +86,18 @@ interface PostVariables { } describe('DataConnect Tests', async () => { let dc: DataConnect; + let app: FirebaseApp; const TEST_ID = crypto.randomUUID(); beforeEach(async () => { + app = initializeApp({ + projectId: PROJECT_ID + }); dc = initDatabase(); await seedDatabase(dc, TEST_ID); }); afterEach(async () => { await deleteDatabase(dc); + await deleteApp(app); await terminate(dc); }); function getPostsRef(): QueryRef { @@ -132,7 +138,7 @@ describe('DataConnect Tests', async () => { const taskListQuery = getPostsRef(); const queryResult = await executeQuery(taskListQuery); const result = await waitForFirstEvent(taskListQuery); - expect(result.data).to.eq(queryResult.data); + expect(result.data).to.deep.eq(queryResult.data); expect(result.source).to.eq(SOURCE_CACHE); }); it(`returns the proper JSON when calling .toJSON()`, async () => { diff --git a/packages/data-connect/test/unit/QueryManager.test.ts b/packages/data-connect/test/unit/QueryManager.test.ts index 9acc948d57..74166c47f5 100644 --- a/packages/data-connect/test/unit/QueryManager.test.ts +++ b/packages/data-connect/test/unit/QueryManager.test.ts @@ -59,7 +59,9 @@ describe('Query Manager Tests', () => { ); // @ts-ignore - expect(() => executeQuery(mutation)).to.throw(error.message); + await expect(executeQuery(mutation)).to.eventually.be.rejectedWith( + error.message + ); expect(() => executeQuery(query)).to.not.throw(error.message); }); }); diff --git a/packages/data-connect/test/unit/caching.test.ts b/packages/data-connect/test/unit/caching.test.ts new file mode 100644 index 0000000000..88fbe2f752 --- /dev/null +++ b/packages/data-connect/test/unit/caching.test.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { initializeApp, FirebaseApp, deleteApp } from '@firebase/app'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + DataConnect, + executeQuery, + getDataConnect, + makeMemoryCacheProvider, + queryRef, + QueryResult, + subscribe, + DataConnectExtension +} from '../../src'; +import { initializeFetch } from '../../src/network/fetch'; + +describe('caching', () => { + let dc: DataConnect; + let app: FirebaseApp; + beforeEach(() => { + const { firebaseApp, dc: newDC } = setup(); + dc = newDC; + app = firebaseApp; + }); + afterEach(async () => { + await deleteApp(app); + }); + it('should resolve from cache with an interdependent query', async () => { + interface Q1Data { + movies: Array<{ + title: string; + }>; + } + interface Q2Data { + movies: Array<{ + title: string; + genre: string; + }>; + } + + const q1MovieData = { + movies: [ + { + title: 'matrix' + } + ] + }; + const q2MovieData = { + movies: [ + { + title: 'matrix', + genre: 'sci-fi' + } + ] + }; + const date = new Date().toISOString(); + const q1 = queryRef(dc, 'q1'); + await updateCacheData( + dc, + { + data: q1MovieData, + fetchTime: date, + ref: q1, + source: 'CACHE' + }, + [ + { + path: ['movies', 0], + entityId: 'matrix' + } + ] + ); + const q2 = queryRef(dc, 'q2'); + await updateCacheData( + dc, + { + data: q2MovieData, + fetchTime: date, + ref: q2, + source: 'CACHE' + }, + [ + { + path: ['movies', 0], + entityId: 'matrix' + } + ] + ); + + const events: Array< + Pick, 'data' | 'source'> + > = []; + subscribe(q2, event => { + events.push({ + data: event.data, + source: event.source + }); + }); + + const expected = { + data: { + movies: [ + { + title: 'the matrix' + } + ] + }, + extensions: { + dataConnect: [ + { + path: ['movies', 0], + entityId: 'matrix' + } + ] + } + }; + stubFetch(expected); + const result = await executeQuery(q1, { + fetchPolicy: 'SERVER_ONLY' + }); + expect(result.data.movies).to.deep.eq(expected.data.movies); + // wait for 2 seconds to make sure we don't have too many events coming in. + await waitFor(2000); + + expect(events.length).to.eq(2); + expect(events).to.deep.eq([ + { + data: { + movies: [ + { + genre: 'sci-fi', + title: 'matrix' + } + ] + }, + source: 'CACHE' + } as QueryResult, + { + data: { + movies: [ + { + genre: 'sci-fi', + title: 'the matrix' + } + ] + }, + source: 'CACHE' + } as QueryResult + ]); + }); + it('should not resolve from cache when caching is disabled', async () => { + const dcWithoutCache = getDataConnect({ + connector: 'a', + location: 'b', + service: 'c' + }); + interface Q1Data { + movies: Array<{ + title: string; + }>; + } + interface Q2Data { + movies: Array<{ + title: string; + genre: string; + }>; + } + + const q1MovieData = { + movies: [ + { + title: 'matrix' + } + ] + }; + const q2MovieData = { + movies: [ + { + title: 'matrix', + genre: 'sci-fi' + } + ] + }; + const date = new Date().toISOString(); + const q1 = queryRef(dcWithoutCache, 'q1'); + await updateCacheData(dcWithoutCache, { + data: q1MovieData, + fetchTime: date, + ref: q1, + source: 'CACHE' + }); + const q2 = queryRef(dcWithoutCache, 'q2'); + await updateCacheData(dcWithoutCache, { + data: q2MovieData, + fetchTime: date, + ref: q2, + source: 'CACHE' + }); + + const events: Array< + Pick, 'data' | 'source'> + > = []; + subscribe(q2, event => { + events.push({ + data: event.data, + source: event.source + }); + }); + + const expected = { + data: { + movies: [ + { + title: 'matrix' + } + ] + }, + extensions: { + dataConnect: [ + { + path: ['movies', 0], + entityId: 'matrix' + } + ] + } + }; + stubFetch(expected); + const result = await executeQuery(q1, { + fetchPolicy: 'SERVER_ONLY' + }); + expect(result.data.movies).to.deep.eq(expected.data.movies); + // wait for 2 seconds to make sure we don't have too many events coming in. + await waitFor(2000); + + expect(events.length).to.eq(1); + expect(events).to.deep.eq([ + { + data: { + movies: [ + { + genre: 'sci-fi', + title: 'matrix' + } + ] + }, + source: 'CACHE' + } as QueryResult + ]); + }); +}); + +function stubFetch(response: unknown): void { + const fakeFetchImpl = sinon.stub().returns({ + json: () => { + return Promise.resolve(response); + }, + status: 200 + }); + initializeFetch(fakeFetchImpl); +} + +function setup(): { firebaseApp: FirebaseApp; dc: DataConnect } { + const app = initializeApp({ + projectId: 'p2' + }); + const connectorConfig = { + connector: 'c', + location: 'l', + service: 's' + }; + const dc = getDataConnect(connectorConfig, { + cacheSettings: { + cacheProvider: makeMemoryCacheProvider() + } + }); + return { firebaseApp: app, dc }; +} + +async function waitFor(milliseconds: number): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), milliseconds); + }); +} +async function updateCacheData( + dc: DataConnect, + { + data, + fetchTime, + ref, + source + }: Omit, 'toJSON'>, + extension?: DataConnectExtension[] +): Promise { + const connectorConfig = dc.getSettings(); + const projectId = dc.app.options.projectId; + return dc._queryManager.updateCache({ + data, + fetchTime, + ref, + source, + extensions: { + dataConnect: extension + }, + toJSON() { + return { + data, + fetchTime, + source, + refInfo: { + connectorConfig: { + ...connectorConfig, + projectId: projectId! + }, + name: ref.name, + variables: ref.variables + } + }; + } + }); +} diff --git a/packages/data-connect/test/unit/parseEntityIds.test.ts b/packages/data-connect/test/unit/parseEntityIds.test.ts new file mode 100644 index 0000000000..1b807fb757 --- /dev/null +++ b/packages/data-connect/test/unit/parseEntityIds.test.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable camelcase */ +import { expect } from 'chai'; + +import { OpResult, SOURCE_SERVER } from '../../src'; +import { parseEntityIds } from '../../src/core/query/QueryManager'; +describe('parseEntityIds', () => { + it('should parse single entity id', () => { + const fetchTime = new Date().toISOString(); + const response: OpResult = { + // Same as before except no more _id + data: { + posts: [ + { + title: 'Post 1', + author: { + name: 'Alice' + }, + comments_on_post: [ + { + content: 'Great post!' + }, + { + content: 'Insightful' + } + ] + }, + { + title: 'Post 2', + author: { + name: 'Bob' + }, + comments_on_post: [ + { + content: 'Me too' + }, + { + content: 'Congrats!' + } + ] + } + ] + }, + fetchTime, + source: SOURCE_SERVER, + // New! SDKs should parse this. Only present when Connector.clientCache.includeEntityId is true (can be set via the control plane soon; CLI will set it according to YAML later) + extensions: { + // Unique top-level key to avoid (future) conflicts with other GQL extensions + // Not called "cache" since it can be used for realtime and pagination too later + dataConnect: [ + // This is a list, each item contains a path and properties for that path. + { + // If path points to a list, then there will be multiple IDs, one for + // each element, in list order. + path: ['posts'], + entityIds: ['idForPost1GoesHere', 'idForPost2GoesHere'] + // Later we can attach properties like pagination to the same list here. + }, + { + // If path points to a single object, there is only one entityId. + path: ['posts', 0, 'author'], // Each path segment may be string (field name) or number (index). + entityId: 'idForAuthorOfPost1' // singular entityId, not entityIds + // Later we can attach object-level properties here. + }, + { + // There's some path repetition for nested objects but it will not be so + // bad after response gzipping (for proto and JSON alike). + path: ['posts', 1, 'author'], // Each path segment may be string (field name) or number (index). + entityId: 'idForAuthorOfPost2' + }, + // The backend will prefer to return paths pointing to lists when possible. + { + path: ['posts', 0, 'comments_on_post'], + entityIds: ['idForPost1Comment1', 'idForPost1Comment2'] + }, + { + path: ['posts', 1, 'comments_on_post'], + entityIds: ['idForPost2Comment1', 'idForPost2Comment2'] + } + // Although these are valid under our spec, the backend in practice + // will choose the wire-efficient equivalent above. + /* + { + path: ["posts", 1, "comments_on_post", 0], + entityId: "idForPost2Comment1" + }, + { + path: ["posts", 1, "comments_on_post", 1], + entityId: "idForPost2Comment2" + }, /* and so on */ + // Later if we need to do a response-level property, we still can: + // {path: [/*empty*/], propertyForTheWholeResponse: ...} + ] + } + }; + const actual = parseEntityIds(response); + // @ts-ignore + expect(actual.posts[0].author._id).to.eq('idForAuthorOfPost1'); + // @ts-ignore + expect(actual.posts[1].author._id).to.eq('idForAuthorOfPost2'); + // @ts-ignore + expect(actual.posts[0]._id).to.eq('idForPost1GoesHere'); + // @ts-ignore + expect(actual.posts[1]._id).to.eq('idForPost2GoesHere'); + // @ts-ignore + expect(actual.posts[0].comments_on_post[0]._id).to.eq('idForPost1Comment1'); + // @ts-ignore + expect(actual.posts[0].comments_on_post[1]._id).to.eq('idForPost1Comment2'); + // @ts-ignore + expect(actual.posts[1].comments_on_post[0]._id).to.eq('idForPost2Comment1'); + // @ts-ignore + expect(actual.posts[1].comments_on_post[1]._id).to.eq('idForPost2Comment2'); + }); +}); diff --git a/packages/data-connect/test/unit/queries.test.ts b/packages/data-connect/test/unit/queries.test.ts index 02d19bf856..51f12c5b21 100644 --- a/packages/data-connect/test/unit/queries.test.ts +++ b/packages/data-connect/test/unit/queries.test.ts @@ -15,7 +15,11 @@ * limitations under the License. */ -import { FirebaseAuthTokenData } from '@firebase/auth-interop-types'; +import { initializeApp } from '@firebase/app'; +import { + FirebaseAuthInternal, + FirebaseAuthTokenData +} from '@firebase/auth-interop-types'; import { expect } from 'chai'; import * as chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -40,6 +44,9 @@ const options: DataConnectOptions = { }; const INITIAL_TOKEN = 'initial token'; class FakeAuthProvider implements AuthTokenProvider { + getAuth(): FirebaseAuthInternal { + throw new Error('Method not implemented.'); + } private token: string | null = INITIAL_TOKEN; addTokenChangeListener(listener: AuthTokenListener): void {} getToken(forceRefresh: boolean): Promise { @@ -82,6 +89,11 @@ function getPostsRef(): QueryRef { }); } describe('Queries', () => { + beforeEach(() => { + initializeApp({ + projectId: 'p' + }); + }); afterEach(() => { fakeFetchImpl.resetHistory(); }); diff --git a/packages/data-connect/test/unit/transportoptions.test.ts b/packages/data-connect/test/unit/transportoptions.test.ts index a7136b5c40..7a39281910 100644 --- a/packages/data-connect/test/unit/transportoptions.test.ts +++ b/packages/data-connect/test/unit/transportoptions.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; import { expect } from 'chai'; import { queryRef } from '../../src'; @@ -24,8 +25,16 @@ import { connectDataConnectEmulator, getDataConnect } from '../../src/api/DataConnect'; -import { app } from '../util'; describe('Transport Options', () => { + let app: FirebaseApp; + beforeEach(() => { + app = initializeApp({ + projectId: 'p' + }); + }); + afterEach(async () => { + await deleteApp(app); + }); it('should return false if transport options are not equal', () => { const transportOptions1: TransportOptions = { host: 'h', diff --git a/packages/data-connect/test/unit/utils.test.ts b/packages/data-connect/test/unit/utils.test.ts index 666ca04ac3..8cb299ef66 100644 --- a/packages/data-connect/test/unit/utils.test.ts +++ b/packages/data-connect/test/unit/utils.test.ts @@ -15,12 +15,19 @@ * limitations under the License. */ +import { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; import { expect } from 'chai'; import { getDataConnect } from '../../src'; import { validateArgs } from '../../src/util/validateArgs'; -import { app } from '../util'; describe('Utils', () => { + let app: FirebaseApp; + beforeEach(() => { + app = initializeApp({ projectId: 'p' }); + }); + afterEach(async () => { + await deleteApp(app); + }); it('[Vars required: true] should throw if no arguments are provided', () => { const connectorConfig = { connector: 'c', location: 'l', service: 's' }; expect(() => diff --git a/packages/data-connect/test/util.ts b/packages/data-connect/test/util.ts index 625c263c31..6c89c3c962 100644 --- a/packages/data-connect/test/util.ts +++ b/packages/data-connect/test/util.ts @@ -15,8 +15,6 @@ * limitations under the License. */ -import { initializeApp } from '@firebase/app'; - import { connectDataConnectEmulator, ConnectorConfig, @@ -38,10 +36,6 @@ export function getConnectionConfig(): ConnectorConfig { }; } -export const app = initializeApp({ - projectId: PROJECT_ID -}); - // Seed the database to have the proper fields to query, such as a list of tasks. export function initDatabase(): DataConnect { const instance = getDataConnect(getConnectionConfig()); diff --git a/packages/firebase/package.json b/packages/firebase/package.json index c71928a0ef..f614fae232 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -1,6 +1,6 @@ { "name": "firebase", - "version": "12.7.0", + "version": "12.3.0-cache", "description": "Firebase JavaScript library for web and Node.js", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index 12fcf8a6de..f77604a4bc 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -43,3 +43,4 @@ export * from './src/formatters'; export * from './src/compat'; export * from './src/global'; export * from './src/url'; +export * from './src/sha256'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 1829c32a42..ecd3557cf8 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -38,3 +38,4 @@ export * from './src/formatters'; export * from './src/compat'; export * from './src/global'; export * from './src/url'; +export * from './src/sha256'; diff --git a/packages/util/src/sha256.ts b/packages/util/src/sha256.ts new file mode 100644 index 0000000000..2645e8459a --- /dev/null +++ b/packages/util/src/sha256.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export async function generateSHA256HashBrowser( + input: string +): Promise { + const textEncoder = new TextEncoder(); + const data = textEncoder.encode(input); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + // Convert ArrayBuffer to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hexHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hexHash; +}