diff --git a/.changeset/four-pillows-fetch.md b/.changeset/four-pillows-fetch.md new file mode 100644 index 00000000000..11e65a57574 --- /dev/null +++ b/.changeset/four-pillows-fetch.md @@ -0,0 +1,5 @@ +--- +'@firebase/messaging': patch +--- + +The logging endpoint has been updated to ensure proper logging of WebPush entries. This resolves an issue where BigQuery logs were missing WebPush data. The payload structure has also been updated in alignment with the latest logging requirements as specified in go/firelog. diff --git a/.changeset/sharp-dingos-admire.md b/.changeset/sharp-dingos-admire.md deleted file mode 100644 index aeb1b082ad5..00000000000 --- a/.changeset/sharp-dingos-admire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@firebase/firestore": patch ---- - -Re-enable useFetchStreams with the latest WebChannel implementation. This reduces the memory usage of WebChannel. diff --git a/.changeset/small-geckos-mix.md b/.changeset/small-geckos-mix.md deleted file mode 100644 index 33ddb4a7116..00000000000 --- a/.changeset/small-geckos-mix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@firebase/firestore': patch ---- - -Refactor Firestore client instantiation. This prepares for future features that require client to restart. diff --git a/.github/workflows/canary-deploy.yml b/.github/workflows/canary-deploy.yml index c74bbc78762..94dba2ecf0b 100644 --- a/.github/workflows/canary-deploy.yml +++ b/.github/workflows/canary-deploy.yml @@ -52,6 +52,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/prerelease-manual-deploy.yml b/.github/workflows/prerelease-manual-deploy.yml index a5508db4709..993e391cee6 100644 --- a/.github/workflows/prerelease-manual-deploy.yml +++ b/.github/workflows/prerelease-manual-deploy.yml @@ -55,6 +55,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 0ff1a0b2185..344b73fe1e4 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -65,6 +65,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 3aa2716b5e2..84974ea45f6 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -91,6 +91,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/test-changed-auth.yml b/.github/workflows/test-changed-auth.yml index 67d16551b3e..52b08c12e21 100644 --- a/.github/workflows/test-changed-auth.yml +++ b/.github/workflows/test-changed-auth.yml @@ -102,3 +102,28 @@ jobs: run: xvfb-run yarn test:changed auth env: BROWSERS: 'Firefox' + + test-webkit: + name: Test Auth on Webkit if Changed + runs-on: macos-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Node (20) + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Test setup and yarn install + run: | + cp config/ci.config.json config/project.json + yarn + npx playwright install webkit + - name: build + run: yarn build:changed auth + - name: Run tests on changed packages + run: yarn test:changed auth + env: + BROWSERS: 'WebkitHeadless' \ No newline at end of file diff --git a/.github/workflows/test-changed-firestore.yml b/.github/workflows/test-changed-firestore.yml index e148d164909..4a924d14e25 100644 --- a/.github/workflows/test-changed-firestore.yml +++ b/.github/workflows/test-changed-firestore.yml @@ -231,6 +231,62 @@ jobs: BROWSERS: 'Firefox' EXPERIMENTAL_MODE: true + compat-test-webkit: + name: Test Firestore Compatible on Webkit + runs-on: macos-latest + needs: build + if: ${{ needs.build.outputs.changed == 'true'}} + steps: + - name: Set up Node (20) + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Download build archive + uses: actions/download-artifact@v3 + with: + name: build.tar.gz + - name: Unzip build artifact + run: tar xf build.tar.gz + - name: Test setup + run: | + cp config/ci.config.json config/project.json + npx playwright install webkit + - name: Run compat tests + run: cd packages/firestore-compat && yarn run test:ci + env: + BROWSERS: 'WebkitHeadless' + + test-webkit: + name: Test Firestore on Webkit + strategy: + matrix: + # TODO (dlarocque): Add test:travis once the browser tests are isolated + # Exclude test:travis for now, since it includes node tests, which are failing for + # some reason. + test-name: ["test:browser", "test:lite:browser", "test:browser:prod:nameddb", "test:lite:browser:nameddb"] + runs-on: macos-latest + needs: build + if: ${{ needs.build.outputs.changed == 'true'}} + steps: + - name: Download build archive + uses: actions/download-artifact@v3 + with: + name: build.tar.gz + - name: Unzip build artifact + run: tar xf build.tar.gz + - name: Set up Node (20) + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Test setup + run: | + cp config/ci.config.json config/project.json + npx playwright install webkit + - name: Run tests + run: cd packages/firestore && yarn run ${{ matrix.test-name }} + env: + BROWSERS: 'WebkitHeadless' + EXPERIMENTAL_MODE: true # A job that fails if any required job in the test matrix fails, # to be used as a required check for merging. check-required-tests: @@ -241,4 +297,4 @@ jobs: steps: - name: Check test matrix if: needs.build.result == 'failure' || needs.test-chrome.result == 'failure' || needs.compat-test-chrome.result == 'failure' - run: exit 1 + run: exit 1 \ No newline at end of file diff --git a/.github/workflows/test-changed.yml b/.github/workflows/test-changed.yml index 7da82df4a26..5119c83cb37 100644 --- a/.github/workflows/test-changed.yml +++ b/.github/workflows/test-changed.yml @@ -78,3 +78,29 @@ jobs: run: xvfb-run yarn test:changed core env: BROWSERS: 'Firefox' + + + test-webkit: + name: Test Packages With Changed Files in Webkit + runs-on: macos-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Node (20) + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Test setup and yarn install + run: | + cp config/ci.config.json config/project.json + yarn + npx playwright install webkit + - name: build + run: yarn build:changed core + - name: Run tests on changed packages + run: yarn test:changed core + env: + BROWSERS: 'WebkitHeadless' \ No newline at end of file diff --git a/common/api-review/data-connect.api.md b/common/api-review/data-connect.api.md new file mode 100644 index 00000000000..b2c6fb01931 --- /dev/null +++ b/common/api-review/data-connect.api.md @@ -0,0 +1,234 @@ +## API Report File for "@firebase/data-connect" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; +import { FirebaseApp } from '@firebase/app'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { FirebaseError } from '@firebase/util'; +import { LogLevelString } from '@firebase/logger'; +import { Provider } from '@firebase/component'; + +// @public (undocumented) +export interface CancellableOperation extends PromiseLike<{ + data: T; +}> { + // (undocumented) + cancel: () => void; +} + +// @public +export function connectDataConnectEmulator(dc: DataConnect, host: string, port?: number, sslEnabled?: boolean): void; + +// @public +export interface ConnectorConfig { + // (undocumented) + connector: string; + // (undocumented) + location: string; + // (undocumented) + service: string; +} + +// @public +export class DataConnect { + constructor(app: FirebaseApp, dataConnectOptions: DataConnectOptions, _authProvider: Provider, _appCheckProvider: Provider); + // (undocumented) + readonly app: FirebaseApp; + // (undocumented) + enableEmulator(transportOptions: TransportOptions): void; + // (undocumented) + getSettings(): ConnectorConfig; + // (undocumented) + isEmulator: boolean; + // (undocumented) + setInitialized(): void; +} + +// @public +export interface DataConnectOptions extends ConnectorConfig { + // (undocumented) + projectId: string; +} + +// @public (undocumented) +export interface DataConnectResult extends OpResult { + // (undocumented) + ref: OperationRef; +} + +// @public +export interface DataConnectSubscription { + // (undocumented) + errCallback?: (e?: FirebaseError) => void; + // (undocumented) + unsubscribe: () => void; + // (undocumented) + userCallback: OnResultSubscription; +} + +// @public (undocumented) +export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; + +// @public +export function executeMutation(mutationRef: MutationRef): MutationPromise; + +// @public +export function executeQuery(queryRef: QueryRef): QueryPromise; + +// @public +export function getDataConnect(options: ConnectorConfig): DataConnect; + +// @public +export function getDataConnect(app: FirebaseApp, options: ConnectorConfig): DataConnect; + +// @public (undocumented) +export const MUTATION_STR = "mutation"; + +// @public +export interface MutationPromise extends PromiseLike> { +} + +// @public (undocumented) +export interface MutationRef extends OperationRef { + // (undocumented) + refType: typeof MUTATION_STR; +} + +// @public +export function mutationRef(dcInstance: DataConnect, mutationName: string): MutationRef; + +// @public (undocumented) +export function mutationRef(dcInstance: DataConnect, mutationName: string, variables: Variables): MutationRef; + +// @public +export interface MutationResult extends DataConnectResult { + // (undocumented) + ref: MutationRef; +} + +// @public +export type OnCompleteSubscription = () => void; + +// @public +export type OnErrorSubscription = (err?: FirebaseError) => void; + +// @public +export type OnResultSubscription = (res: QueryResult) => void; + +// @public (undocumented) +export interface OperationRef<_Data, Variables> { + // (undocumented) + dataConnect: DataConnect; + // (undocumented) + name: string; + // (undocumented) + refType: ReferenceType; + // (undocumented) + variables: Variables; +} + +// @public (undocumented) +export interface OpResult { + // (undocumented) + data: Data; + // (undocumented) + fetchTime: string; + // (undocumented) + source: DataSource; +} + +// @public (undocumented) +export const QUERY_STR = "query"; + +// @public +export interface QueryPromise extends PromiseLike> { +} + +// @public +export interface QueryRef extends OperationRef { + // (undocumented) + refType: typeof QUERY_STR; +} + +// @public +export function queryRef(dcInstance: DataConnect, queryName: string): QueryRef; + +// @public +export function queryRef(dcInstance: DataConnect, queryName: string, variables: Variables): QueryRef; + +// @public +export interface QueryResult extends DataConnectResult { + // (undocumented) + ref: QueryRef; + // (undocumented) + toJSON: () => SerializedRef; +} + +// @public +export type QueryUnsubscribe = () => void; + +// @public (undocumented) +export type ReferenceType = typeof QUERY_STR | typeof MUTATION_STR; + +// @public +export interface RefInfo { + // (undocumented) + connectorConfig: DataConnectOptions; + // (undocumented) + name: string; + // (undocumented) + variables: Variables; +} + +// @public +export interface SerializedRef extends OpResult { + // (undocumented) + refInfo: RefInfo; +} + +// @public (undocumented) +export function setLogLevel(logLevel: LogLevelString): void; + +// @public (undocumented) +export const SOURCE_CACHE = "CACHE"; + +// @public (undocumented) +export const SOURCE_SERVER = "SERVER"; + +// @public +export function subscribe(queryRefOrSerializedResult: QueryRef | SerializedRef, observer: SubscriptionOptions): QueryUnsubscribe; + +// @public +export function subscribe(queryRefOrSerializedResult: QueryRef | SerializedRef, onNext: OnResultSubscription, onError?: OnErrorSubscription, onComplete?: OnCompleteSubscription): QueryUnsubscribe; + +// @public +export interface SubscriptionOptions { + // (undocumented) + onComplete?: OnCompleteSubscription; + // (undocumented) + onErr?: OnErrorSubscription; + // (undocumented) + onNext?: OnResultSubscription; +} + +// @public +export function terminate(dataConnect: DataConnect): Promise; + +// @public +export function toQueryRef(serializedRef: SerializedRef): QueryRef; + +// @public +export interface TransportOptions { + // (undocumented) + host: string; + // (undocumented) + port?: number; + // (undocumented) + sslEnabled?: boolean; +} + + +``` diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 4a9ef4c0171..603e2349505 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -494,5 +494,4 @@ export class WriteBatch { // @public export function writeBatch(firestore: Firestore): WriteBatch; - ``` diff --git a/config/karma.base.js b/config/karma.base.js index e074987f29b..49824296740 100644 --- a/config/karma.base.js +++ b/config/karma.base.js @@ -20,6 +20,33 @@ const path = require('path'); const webpackTestConfig = require('./webpack.test'); const { argv } = require('yargs'); +function determineBrowsers() { + const supportedBrowsers = ['ChromeHeadless', 'WebkitHeadless', 'Firefox']; + + if (process.env.BROWSERS) { + const browsers = process.env.BROWSERS.split(','); + + const validBrowsers = browsers.filter(browser => + supportedBrowsers.includes(browser) + ); + if (validBrowsers.length === 0) { + console.error( + `The \'BROWSER\' environment variable was set, but no supported browsers were listed. The supported browsers are ${JSON.stringify( + supportedBrowsers + )}.` + ); + return []; + } else { + return validBrowsers; + } + } else { + console.log( + "The 'BROWSER' environment variable is undefined. Defaulting to 'ChromeHeadless'." + ); + return ['ChromeHeadless']; + } +} + const config = { // disable watcher autoWatch: false, @@ -57,10 +84,11 @@ const config = { // changes autoWatch: false, - // start these browsers - // available browser launchers: - // https://npmjs.org/browse/keyword/karma-launcher - browsers: process.env?.BROWSERS?.split(',') ?? ['ChromeHeadless'], + // Browsers to launch for testing + // To use a custom set of browsers, define the BROWSERS environment variable as a comma-seperated list. + // Supported browsers are 'ChromeHeadless', 'WebkitHeadless', and 'Firefox'. + // See: https://karma-runner.github.io/6.4/config/browsers.html + browsers: determineBrowsers(), webpack: webpackTestConfig, diff --git a/integration/compat-interop/package.json b/integration/compat-interop/package.json index 2090a7e91b8..d77b3f72449 100644 --- a/integration/compat-interop/package.json +++ b/integration/compat-interop/package.json @@ -8,8 +8,8 @@ "test:debug": "karma start --browsers Chrome --auto-watch" }, "dependencies": { - "@firebase/app": "0.10.11", - "@firebase/app-compat": "0.2.41", + "@firebase/app": "0.10.12", + "@firebase/app-compat": "0.2.42", "@firebase/analytics": "0.10.8", "@firebase/analytics-compat": "0.2.14", "@firebase/auth": "1.7.9", diff --git a/integration/firebase/package.json b/integration/firebase/package.json index a12e233adc7..fee40f50f0a 100644 --- a/integration/firebase/package.json +++ b/integration/firebase/package.json @@ -7,7 +7,7 @@ "test:ci": "node ../../scripts/run_tests_in_ci.js -s test" }, "devDependencies": { - "firebase": "10.13.2", + "firebase": "10.14.0", "@types/chai": "4.3.14", "@types/mocha": "9.1.1", "chai": "4.4.1", diff --git a/integration/firestore/package.json b/integration/firestore/package.json index 256569ac012..a6b7002c1a0 100644 --- a/integration/firestore/package.json +++ b/integration/firestore/package.json @@ -14,8 +14,8 @@ "test:memory:debug": "yarn build:memory; karma start --auto-watch --browsers Chrome" }, "dependencies": { - "@firebase/app": "0.10.11", - "@firebase/firestore": "4.7.2" + "@firebase/app": "0.10.12", + "@firebase/firestore": "4.7.3" }, "devDependencies": { "@types/mocha": "9.1.1", diff --git a/integration/messaging/package.json b/integration/messaging/package.json index d36f0844b9a..98398379670 100644 --- a/integration/messaging/package.json +++ b/integration/messaging/package.json @@ -9,7 +9,7 @@ "test:manual": "mocha --exit" }, "devDependencies": { - "firebase": "10.13.2", + "firebase": "10.14.0", "chai": "4.4.1", "chromedriver": "119.0.1", "express": "4.19.2", diff --git a/package.json b/package.json index 1d4115799a7..a2e85a8f4fb 100644 --- a/package.json +++ b/package.json @@ -120,10 +120,10 @@ "karma-firefox-launcher": "2.1.3", "karma-mocha": "2.0.1", "karma-mocha-reporter": "2.2.5", - "karma-safari-launcher": "1.0.0", "karma-sourcemap-loader": "0.4.0", "karma-spec-reporter": "0.0.36", "karma-summary-reporter": "3.1.1", + "karma-webkit-launcher": "2.6.0", "karma-webpack": "5.0.0", "lcov-result-merger": "3.3.0", "lerna": "4.0.0", @@ -139,6 +139,7 @@ "nyc": "15.1.0", "ora": "5.4.1", "patch-package": "7.0.2", + "playwright": "1.46.1", "postinstall-postinstall": "2.1.0", "prettier": "2.8.7", "protractor": "5.4.2", diff --git a/packages/analytics-compat/package.json b/packages/analytics-compat/package.json index 62fa41c2029..69e8486c953 100644 --- a/packages/analytics-compat/package.json +++ b/packages/analytics-compat/package.json @@ -22,7 +22,7 @@ "@firebase/app-compat": "0.x" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "rollup": "2.79.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.31.2", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 57b08f0e84a..da518d3c1ee 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -47,7 +47,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "@rollup/plugin-commonjs": "21.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/analytics/testing/get-fake-firebase-services.ts b/packages/analytics/testing/get-fake-firebase-services.ts index 596e431e0b2..4d666a76d25 100644 --- a/packages/analytics/testing/get-fake-firebase-services.ts +++ b/packages/analytics/testing/get-fake-firebase-services.ts @@ -15,7 +15,12 @@ * limitations under the License. */ -import { FirebaseApp, initializeApp, _registerComponent } from '@firebase/app'; +import { + FirebaseApp, + initializeApp, + _registerComponent, + _addOrOverwriteComponent +} from '@firebase/app'; import { Component, ComponentType } from '@firebase/component'; import { _FirebaseInstallationsInternal } from '@firebase/installations'; import { AnalyticsService } from '../src/factory'; @@ -78,5 +83,18 @@ export function getFullApp(fakeAppParams?: { ) ); const app = initializeApp({ ...fakeConfig, ...fakeAppParams }); + _addOrOverwriteComponent( + app, + //@ts-ignore + new Component( + 'heartbeat', + () => { + return { + triggerHeartbeat: () => {} + } as any; + }, + ComponentType.PUBLIC + ) + ); return app; } diff --git a/packages/app-check-compat/package.json b/packages/app-check-compat/package.json index 464f9e36c25..3e8bdbe526b 100644 --- a/packages/app-check-compat/package.json +++ b/packages/app-check-compat/package.json @@ -43,7 +43,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "rollup": "2.79.1", "@rollup/plugin-commonjs": "21.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/app-check/package.json b/packages/app-check/package.json index d74ec9843df..de951812af2 100644 --- a/packages/app-check/package.json +++ b/packages/app-check/package.json @@ -44,7 +44,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "@rollup/plugin-commonjs": "21.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/app-compat/CHANGELOG.md b/packages/app-compat/CHANGELOG.md index 405318ebf80..f486e95beba 100644 --- a/packages/app-compat/CHANGELOG.md +++ b/packages/app-compat/CHANGELOG.md @@ -1,5 +1,12 @@ # @firebase/app-compat +## 0.2.42 + +### Patch Changes + +- Updated dependencies [[`beaa4dffb`](https://github.com/firebase/firebase-js-sdk/commit/beaa4dffb7f48cb12ccc6c1d1b7cdc9c3605fc04)]: + - @firebase/app@0.10.12 + ## 0.2.41 ### Patch Changes diff --git a/packages/app-compat/package.json b/packages/app-compat/package.json index 8a8b39eca7a..c39de375265 100644 --- a/packages/app-compat/package.json +++ b/packages/app-compat/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/app-compat", - "version": "0.2.41", + "version": "0.2.42", "description": "The primary entrypoint to the Firebase JS SDK", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -37,7 +37,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "@firebase/util": "1.10.0", "@firebase/logger": "0.4.2", "@firebase/component": "0.6.9", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 09e7cfe0592..2600804c24a 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,11 @@ # @firebase/app +## 0.10.12 + +### Patch Changes + +- [`beaa4dffb`](https://github.com/firebase/firebase-js-sdk/commit/beaa4dffb7f48cb12ccc6c1d1b7cdc9c3605fc04) [#8480](https://github.com/firebase/firebase-js-sdk/pull/8480) - Included Data Connect product. + ## 0.10.11 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2cb10dbcbeb..5f6fd222987 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/app", - "version": "0.10.11", + "version": "0.10.12", "description": "The primary entrypoint to the Firebase JS SDK", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", diff --git a/packages/app/src/constants.ts b/packages/app/src/constants.ts index 92102192e93..8ef4eada39c 100644 --- a/packages/app/src/constants.ts +++ b/packages/app/src/constants.ts @@ -24,6 +24,7 @@ import { name as appCheckName } from '../../../packages/app-check/package.json'; import { name as authName } from '../../../packages/auth/package.json'; import { name as authCompatName } from '../../../packages/auth-compat/package.json'; import { name as databaseName } from '../../../packages/database/package.json'; +import { name as dataconnectName } from '../../../packages/data-connect/package.json'; import { name as databaseCompatName } from '../../../packages/database-compat/package.json'; import { name as functionsName } from '../../../packages/functions/package.json'; import { name as functionsCompatName } from '../../../packages/functions-compat/package.json'; @@ -59,6 +60,7 @@ export const PLATFORM_LOG_STRING = { [authName]: 'fire-auth', [authCompatName]: 'fire-auth-compat', [databaseName]: 'fire-rtdb', + [dataconnectName]: 'fire-data-connect', [databaseCompatName]: 'fire-rtdb-compat', [functionsName]: 'fire-fn', [functionsCompatName]: 'fire-fn-compat', diff --git a/packages/auth-compat/package.json b/packages/auth-compat/package.json index b6366b2f725..a9b96264e21 100644 --- a/packages/auth-compat/package.json +++ b/packages/auth-compat/package.json @@ -57,7 +57,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "@rollup/plugin-json": "4.1.0", "rollup": "2.79.1", "rollup-plugin-replace": "2.2.0", diff --git a/packages/auth/package.json b/packages/auth/package.json index e30312d4b68..2281455b890 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -131,7 +131,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "@rollup/plugin-json": "4.1.0", "@rollup/plugin-strip": "2.1.0", "@types/express": "4.17.21", diff --git a/packages/data-connect/.eslintrc.js b/packages/data-connect/.eslintrc.js new file mode 100644 index 00000000000..faef63a0395 --- /dev/null +++ b/packages/data-connect/.eslintrc.js @@ -0,0 +1,88 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.eslint.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + }, + plugins: ['import'], + ignorePatterns: ['compat/*'], + rules: { + 'no-console': ['error', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + args: 'none' + } + ], + 'import/order': [ + 'error', + { + 'groups': [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index' + ], + 'newlines-between': 'always', + '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.' + } + ] + }, + overrides: [ + { + files: ['**/*.d.ts'], + rules: { + 'camelcase': 'off', + 'import/no-duplicates': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off' + } + }, + { + files: ['**/*.test.ts', '**/test/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error' + } + }, + { + files: ['scripts/*.ts'], + rules: { + 'import/no-extraneous-dependencies': 'off', + '@typescript-eslint/no-require-imports': 'off' + } + } + ] +}; diff --git a/packages/data-connect/.gitignore b/packages/data-connect/.gitignore new file mode 100644 index 00000000000..48a928da5dd --- /dev/null +++ b/packages/data-connect/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz \ No newline at end of file diff --git a/packages/data-connect/.npmignore b/packages/data-connect/.npmignore new file mode 100644 index 00000000000..c2969b2bf95 --- /dev/null +++ b/packages/data-connect/.npmignore @@ -0,0 +1,5 @@ +node_modules/ +rollup.config.mjs +package-lock.json +tsconfig.json +src/ \ No newline at end of file diff --git a/packages/data-connect/CHANGELOG.md b/packages/data-connect/CHANGELOG.md new file mode 100644 index 00000000000..e41376ae9ab --- /dev/null +++ b/packages/data-connect/CHANGELOG.md @@ -0,0 +1,17 @@ +## Unreleased + +## 0.1.0 + +### Minor Changes + +- [`beaa4dffb`](https://github.com/firebase/firebase-js-sdk/commit/beaa4dffb7f48cb12ccc6c1d1b7cdc9c3605fc04) [#8480](https://github.com/firebase/firebase-js-sdk/pull/8480) - Included Data Connect product. + +* Added app check support # @firebase/data-connect + +## 0.0.3 + +- Updated reporting to use @firebase/data-connect instead of @firebase/connect. +- Added functionality to retry queries and mutations if the server responds with UNAUTHENTICATED. +- Moved `validateArgs` to core SDK. +- Updated errors to only show relevant details to the user. +- Added ability to track whether user is calling core sdk or generated sdk. diff --git a/packages/data-connect/api-extractor.json b/packages/data-connect/api-extractor.json new file mode 100644 index 00000000000..deee6510e4b --- /dev/null +++ b/packages/data-connect/api-extractor.json @@ -0,0 +1,11 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/public.d.ts", + "apiReport": { + /** + * apiReport is handled by repo-scripts/prune-dts/extract-public-api.ts + */ + "enabled": false + } +} \ No newline at end of file diff --git a/packages/data-connect/karma.conf.js b/packages/data-connect/karma.conf.js new file mode 100644 index 00000000000..acb47c2ab3b --- /dev/null +++ b/packages/data-connect/karma.conf.js @@ -0,0 +1,34 @@ +/** + * @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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = [`test/**/*.test.ts`]; + +module.exports = function (config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages/data-connect/package.json b/packages/data-connect/package.json new file mode 100644 index 00000000000..1c07f25bd0d --- /dev/null +++ b/packages/data-connect/package.json @@ -0,0 +1,80 @@ +{ + "name": "@firebase/data-connect", + "version": "0.1.0", + "description": "", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.node.cjs.js", + "browser": "dist/index.esm2017.js", + "module": "dist/index.esm2017.js", + "esm5": "dist/index.esm5.js", + "exports": { + ".": { + "types": "./dist/public.d.ts", + "node": { + "import": "./dist/node-esm/index.node.esm.js", + "require": "./dist/index.node.cjs.js" + }, + "esm5": "./dist/index.esm5.js", + "browser": { + "require": "./dist/index.cjs.js", + "import": "./dist/index.esm2017.js" + }, + "default": "./dist/index.esm2017.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore' --fix", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c rollup.config.js && yarn api-report", + "prettier": "prettier --write '*.js' '*.ts' '@(src|test)/**/*.ts'", + "build:deps": "lerna run --scope @firebase/'{app,data-connect}' --include-dependencies build", + "dev": "rollup -c -w", + "test": "run-p --npm-path npm test:emulator", + "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:emulator", + "test:all": "run-p --npm-path npm lint test:unit", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js", + "test:unit": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/unit/**/*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js", + "test:emulator": "ts-node --compiler-options='{\"module\":\"commonjs\"}' ../../scripts/emulator-testing/dataconnect-test-runner.ts", + "api-report": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' ts-node ../../repo-scripts/prune-dts/extract-public-api.ts --package data-connect --packageRoot . --typescriptDts ./dist/src/index.d.ts --rollupDts ./dist/private.d.ts --untrimmedRollupDts ./dist/internal.d.ts --publicDts ./dist/public.d.ts && yarn api-report:api-json", + "api-report:api-json": "rm -rf temp && api-extractor run --local --verbose", + "doc": "api-documenter markdown --input temp --output docs", + "typings:public": "node ../../scripts/build/use_typings.js ./dist/public.d.ts" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app": "0.x" + }, + "dependencies": { + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "devDependencies": { + "@firebase/app": "0.10.12", + "rollup": "2.79.1", + "rollup-plugin-typescript2": "0.31.2", + "typescript": "4.7.4" + }, + "repository": { + "directory": "packages/data-connect", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} \ No newline at end of file diff --git a/packages/data-connect/rollup.config.js b/packages/data-connect/rollup.config.js new file mode 100644 index 00000000000..cb220911d69 --- /dev/null +++ b/packages/data-connect/rollup.config.js @@ -0,0 +1,153 @@ +/** + * @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 json from '@rollup/plugin-json'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import replace from 'rollup-plugin-replace'; +import typescript from 'typescript'; +import { generateBuildTargetReplaceConfig } from '../../scripts/build/rollup_replace_build_target'; +import { emitModulePackageFile } from '../../scripts/build/rollup_emit_module_package_file'; +import pkg from './package.json'; + +const deps = [ + ...Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }), + '@firebase/app' +]; + +function onWarn(warning, defaultWarn) { + if (warning.code === 'CIRCULAR_DEPENDENCY') { + throw new Error(warning); + } + defaultWarn(warning); +} + +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + abortOnError: false + }), + json() +]; + +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + }, + abortOnError: false + }), + json({ preferConst: true }) +]; + +const browserBuilds = [ + { + input: 'src/index.ts', + output: [ + { + file: pkg.esm5, + format: 'es', + sourcemap: true + } + ], + plugins: [ + ...es5BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 5)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.ts', + output: [ + { + file: pkg.module, + format: 'es', + sourcemap: true + } + ], + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 2017)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.cjs.js', + format: 'cjs', + sourcemap: true + } + ], + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('cjs', 2017)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + } +]; + +const nodeBuilds = [ + { + input: 'src/index.node.ts', + output: { file: pkg.main, format: 'cjs', sourcemap: true }, + plugins: [ + ...es5BuildPlugins, + replace(generateBuildTargetReplaceConfig('cjs', 5)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.node.ts', + output: { + file: pkg.exports['.'].node.import, + format: 'es', + sourcemap: true + }, + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 2017)), + emitModulePackageFile() + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + } +]; + +export default [...browserBuilds, ...nodeBuilds]; diff --git a/packages/data-connect/src/api.browser.ts b/packages/data-connect/src/api.browser.ts new file mode 100644 index 00000000000..1ffcb8d1647 --- /dev/null +++ b/packages/data-connect/src/api.browser.ts @@ -0,0 +1,108 @@ +/** + * @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, + 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, + onError, + initialCache + ); +} diff --git a/packages/data-connect/src/api.node.ts b/packages/data-connect/src/api.node.ts new file mode 100644 index 00000000000..f8236ebe2d7 --- /dev/null +++ b/packages/data-connect/src/api.node.ts @@ -0,0 +1,18 @@ +/** + * @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. + */ + +export { subscribe } from './api.browser'; diff --git a/packages/data-connect/src/api/DataConnect.ts b/packages/data-connect/src/api/DataConnect.ts new file mode 100644 index 00000000000..27ab83660fd --- /dev/null +++ b/packages/data-connect/src/api/DataConnect.ts @@ -0,0 +1,289 @@ +/** + * @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 { + FirebaseApp, + _getProvider, + _removeServiceInstance, + getApp +} from '@firebase/app'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; + +import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; +import { Code, DataConnectError } from '../core/error'; +import { + AuthTokenProvider, + FirebaseAuthProvider +} from '../core/FirebaseAuthProvider'; +import { QueryManager } from '../core/QueryManager'; +import { logDebug, logError } from '../logger'; +import { DataConnectTransport, TransportClass } from '../network'; +import { RESTTransport } from '../network/transport/rest'; + +import { MutationManager } from './Mutation'; + +/** + * Connector Config for calling Data Connect backend. + */ +export interface ConnectorConfig { + location: string; + connector: string; + service: string; +} + +/** + * Options to connect to emulator + */ +export interface TransportOptions { + host: string; + sslEnabled?: boolean; + port?: number; +} + +const FIREBASE_DATA_CONNECT_EMULATOR_HOST_VAR = + 'FIREBASE_DATA_CONNECT_EMULATOR_HOST'; + +/** + * + * @param fullHost + * @returns TransportOptions + * @internal + */ +export function parseOptions(fullHost: string): TransportOptions { + const [protocol, hostName] = fullHost.split('://'); + const isSecure = protocol === 'https'; + const [host, portAsString] = hostName.split(':'); + const port = Number(portAsString); + return { host, port, sslEnabled: isSecure }; +} +/** + * DataConnectOptions including project id + */ +export interface DataConnectOptions extends ConnectorConfig { + projectId: string; +} + +/** + * Class representing Firebase Data Connect + */ +export class DataConnect { + _queryManager!: QueryManager; + _mutationManager!: MutationManager; + isEmulator = false; + _initialized = false; + private _transport!: DataConnectTransport; + private _transportClass: TransportClass | undefined; + private _transportOptions?: TransportOptions; + private _authTokenProvider?: AuthTokenProvider; + _isUsingGeneratedSdk: boolean = false; + private _appCheckTokenProvider?: AppCheckTokenProvider; + // @internal + constructor( + public readonly app: FirebaseApp, + // TODO(mtewani): Replace with _dataConnectOptions in the future + private readonly dataConnectOptions: DataConnectOptions, + private readonly _authProvider: Provider, + private readonly _appCheckProvider: Provider + ) { + if (typeof process !== 'undefined' && process.env) { + const host = process.env[FIREBASE_DATA_CONNECT_EMULATOR_HOST_VAR]; + if (host) { + logDebug('Found custom host. Using emulator'); + this.isEmulator = true; + this._transportOptions = parseOptions(host); + } + } + } + // @internal + _useGeneratedSdk(): void { + if (!this._isUsingGeneratedSdk) { + this._isUsingGeneratedSdk = true; + } + } + _delete(): Promise { + _removeServiceInstance( + this.app, + 'data-connect', + JSON.stringify(this.getSettings()) + ); + return Promise.resolve(); + } + + // @internal + getSettings(): ConnectorConfig { + const copy = JSON.parse(JSON.stringify(this.dataConnectOptions)); + delete copy.projectId; + return copy; + } + + // @internal + setInitialized(): void { + if (this._initialized) { + return; + } + if (this._transportClass === undefined) { + logDebug('transportClass not provided. Defaulting to RESTTransport.'); + this._transportClass = RESTTransport; + } + + if (this._authProvider) { + this._authTokenProvider = new FirebaseAuthProvider( + this.app.name, + this.app.options, + this._authProvider + ); + } + if (this._appCheckProvider) { + this._appCheckTokenProvider = new AppCheckTokenProvider( + this.app.name, + this._appCheckProvider + ); + } + + this._initialized = true; + this._transport = new this._transportClass( + this.dataConnectOptions, + this.app.options.apiKey, + this.app.options.appId, + this._authTokenProvider, + this._appCheckTokenProvider, + undefined, + this._isUsingGeneratedSdk + ); + if (this._transportOptions) { + this._transport.useEmulator( + this._transportOptions.host, + this._transportOptions.port, + this._transportOptions.sslEnabled + ); + } + this._queryManager = new QueryManager(this._transport); + this._mutationManager = new MutationManager(this._transport); + } + + // @internal + enableEmulator(transportOptions: TransportOptions): void { + if (this._initialized) { + logError('enableEmulator called after initialization'); + throw new DataConnectError( + Code.ALREADY_INITIALIZED, + 'DataConnect instance already initialized!' + ); + } + this._transportOptions = transportOptions; + this.isEmulator = true; + } +} + +/** + * Connect to the DataConnect Emulator + * @param dc Data Connect instance + * @param host host of emulator server + * @param port port of emulator server + * @param sslEnabled use https + */ +export function connectDataConnectEmulator( + dc: DataConnect, + host: string, + port?: number, + sslEnabled = false +): void { + dc.enableEmulator({ host, port, sslEnabled }); +} + +/** + * Initialize DataConnect instance + * @param options ConnectorConfig + */ +export function getDataConnect(options: ConnectorConfig): DataConnect; +/** + * Initialize DataConnect instance + * @param app FirebaseApp to initialize to. + * @param options ConnectorConfig + */ +export function getDataConnect( + app: FirebaseApp, + options: ConnectorConfig +): DataConnect; +export function getDataConnect( + appOrOptions: FirebaseApp | ConnectorConfig, + optionalOptions?: ConnectorConfig +): DataConnect { + let app: FirebaseApp; + let dcOptions: ConnectorConfig; + if ('location' in appOrOptions) { + dcOptions = appOrOptions; + app = getApp(); + } else { + dcOptions = optionalOptions!; + app = appOrOptions; + } + + if (!app || Object.keys(app).length === 0) { + app = getApp(); + } + const provider = _getProvider(app, 'data-connect'); + const identifier = JSON.stringify(dcOptions); + if (provider.isInitialized(identifier)) { + const dcInstance = provider.getImmediate({ identifier }); + const options = provider.getOptions(identifier); + const optionsValid = Object.keys(options).length > 0; + if (optionsValid) { + logDebug('Re-using cached instance'); + return dcInstance; + } + } + validateDCOptions(dcOptions); + + logDebug('Creating new DataConnect instance'); + // Initialize with options. + return provider.initialize({ + instanceIdentifier: identifier, + options: dcOptions + }); +} + +/** + * + * @param dcOptions + * @returns {void} + * @internal + */ +export function validateDCOptions(dcOptions: ConnectorConfig): boolean { + const fields = ['connector', 'location', 'service']; + if (!dcOptions) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'DC Option Required'); + } + fields.forEach(field => { + if (dcOptions[field] === null || dcOptions[field] === undefined) { + throw new DataConnectError(Code.INVALID_ARGUMENT, `${field} Required`); + } + }); + return true; +} + +/** + * Delete DataConnect instance + * @param dataConnect DataConnect instance + * @returns + */ +export function terminate(dataConnect: DataConnect): Promise { + return dataConnect._delete(); + // TODO(mtewani): Stop pending tasks +} diff --git a/packages/data-connect/src/api/Mutation.ts b/packages/data-connect/src/api/Mutation.ts new file mode 100644 index 00000000000..ca2efdb7a30 --- /dev/null +++ b/packages/data-connect/src/api/Mutation.ts @@ -0,0 +1,129 @@ +/** + * @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 { DataConnectTransport } from '../network/transport'; + +import { DataConnect } from './DataConnect'; +import { + DataConnectResult, + MUTATION_STR, + OperationRef, + SOURCE_SERVER +} from './Reference'; + +export interface MutationRef + extends OperationRef { + refType: typeof MUTATION_STR; +} + +/** + * Creates a `MutationRef` + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string +): MutationRef; +/** + * + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + * @param variables variables to send with mutation + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string, + variables: Variables +): MutationRef; +/** + * + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + * @param variables variables to send with mutation + * @returns `MutationRef` + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string, + variables?: Variables +): MutationRef { + dcInstance.setInitialized(); + const ref: MutationRef = { + dataConnect: dcInstance, + name: mutationName, + refType: MUTATION_STR, + variables: variables as Variables + }; + return ref; +} + +/** + * @internal + */ +export class MutationManager { + private _inflight: Array> = []; + constructor(private _transport: DataConnectTransport) {} + executeMutation( + mutationRef: MutationRef + ): MutationPromise { + const result = this._transport.invokeMutation( + mutationRef.name, + mutationRef.variables + ); + const withRefPromise = result.then(res => { + const obj: MutationResult = { + ...res, // Double check that the result is result.data, not just result + source: SOURCE_SERVER, + ref: mutationRef, + fetchTime: Date.now().toLocaleString() + }; + return obj; + }); + this._inflight.push(result); + const removePromise = (): Array> => + (this._inflight = this._inflight.filter(promise => promise !== result)); + result.then(removePromise, removePromise); + return withRefPromise; + } +} + +/** + * Mutation Result from `executeMutation` + */ +export interface MutationResult + extends DataConnectResult { + ref: MutationRef; +} +/** + * Mutation return value from `executeMutation` + */ +export interface MutationPromise + extends PromiseLike> { + // reserved for special actions like cancellation +} + +/** + * Execute Mutation + * @param mutationRef mutation to execute + * @returns `MutationRef` + */ +export function executeMutation( + mutationRef: MutationRef +): MutationPromise { + return mutationRef.dataConnect._mutationManager.executeMutation(mutationRef); +} diff --git a/packages/data-connect/src/api/Reference.ts b/packages/data-connect/src/api/Reference.ts new file mode 100644 index 00000000000..f9d7687dd18 --- /dev/null +++ b/packages/data-connect/src/api/Reference.ts @@ -0,0 +1,58 @@ +/** + * @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 { DataConnect, DataConnectOptions } from './DataConnect'; +export const QUERY_STR = 'query'; +export const MUTATION_STR = 'mutation'; +export type ReferenceType = typeof QUERY_STR | typeof MUTATION_STR; + +export const SOURCE_SERVER = 'SERVER'; +export const SOURCE_CACHE = 'CACHE'; +export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; + +export interface OpResult { + data: Data; + source: DataSource; + fetchTime: string; +} + +export interface OperationRef<_Data, Variables> { + name: string; + variables: Variables; + refType: ReferenceType; + dataConnect: DataConnect; +} + +export interface DataConnectResult extends OpResult { + ref: OperationRef; + // future metadata +} + +/** + * Serialized RefInfo as a result of `QueryResult.toJSON().refInfo` + */ +export interface RefInfo { + name: string; + variables: Variables; + connectorConfig: DataConnectOptions; +} +/** + * Serialized Ref as a result of `QueryResult.toJSON()` + */ +export interface SerializedRef extends OpResult { + refInfo: RefInfo; +} diff --git a/packages/data-connect/src/api/index.ts b/packages/data-connect/src/api/index.ts new file mode 100644 index 00000000000..885dac5a923 --- /dev/null +++ b/packages/data-connect/src/api/index.ts @@ -0,0 +1,24 @@ +/** + * @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. + */ + +export * from '../network'; +export * from './DataConnect'; +export * from './Reference'; +export * from './Mutation'; +export * from './query'; +export { setLogLevel } from '../logger'; +export { validateArgs } from '../util/validateArgs'; diff --git a/packages/data-connect/src/api/query.ts b/packages/data-connect/src/api/query.ts new file mode 100644 index 00000000000..a4ab17b7ceb --- /dev/null +++ b/packages/data-connect/src/api/query.ts @@ -0,0 +1,154 @@ +/** + * @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 { DataConnectError } from '../core/error'; + +import { DataConnect, getDataConnect } from './DataConnect'; +import { + OperationRef, + QUERY_STR, + DataConnectResult, + 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; +/** + * Representation of user provided subscription options. + */ +export interface DataConnectSubscription { + userCallback: OnResultSubscription; + errCallback?: (e?: DataConnectError) => void; + unsubscribe: () => void; +} + +/** + * QueryRef object + */ +export interface QueryRef + extends OperationRef { + refType: typeof QUERY_STR; +} +/** + * Result of `executeQuery` + */ +export interface QueryResult + extends DataConnectResult { + ref: QueryRef; + toJSON: () => SerializedRef; +} +/** + * Promise returned from `executeQuery` + */ +export interface QueryPromise + extends PromiseLike> { + // reserved for special actions like cancellation +} + +/** + * Execute Query + * @param queryRef query to execute. + * @returns `QueryPromise` + */ +export function executeQuery( + queryRef: QueryRef +): QueryPromise { + return queryRef.dataConnect._queryManager.executeQuery(queryRef); +} + +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string +): QueryRef; +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @param variables Variables to execute with + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string, + variables: Variables +): QueryRef; +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @param variables Variables to execute with + * @param initialCache initial cache to use for client hydration + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string, + variables?: Variables, + initialCache?: QueryResult +): QueryRef { + dcInstance.setInitialized(); + dcInstance._queryManager.track(queryName, variables, initialCache); + return { + dataConnect: dcInstance, + refType: QUERY_STR, + name: queryName, + variables: variables as Variables + }; +} +/** + * Converts serialized ref to query ref + * @param serializedRef ref to convert to `QueryRef` + * @returns `QueryRef` + */ +export function toQueryRef( + serializedRef: SerializedRef +): QueryRef { + const { + refInfo: { name, variables, connectorConfig } + } = 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/core/AppCheckTokenProvider.ts b/packages/data-connect/src/core/AppCheckTokenProvider.ts new file mode 100644 index 00000000000..d9cdaeb6f39 --- /dev/null +++ b/packages/data-connect/src/core/AppCheckTokenProvider.ts @@ -0,0 +1,69 @@ +/** + * @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 { + AppCheckInternalComponentName, + AppCheckTokenListener, + AppCheckTokenResult, + FirebaseAppCheckInternal +} from '@firebase/app-check-interop-types'; +import { Provider } from '@firebase/component'; + +/** + * @internal + * Abstraction around AppCheck's token fetching capabilities. + */ +export class AppCheckTokenProvider { + private appCheck?: FirebaseAppCheckInternal; + constructor( + private appName_: string, + private appCheckProvider?: Provider + ) { + this.appCheck = appCheckProvider?.getImmediate({ optional: true }); + if (!this.appCheck) { + void appCheckProvider + ?.get() + .then(appCheck => (this.appCheck = appCheck)) + .catch(); + } + } + + getToken(forceRefresh?: boolean): Promise { + if (!this.appCheck) { + return new Promise((resolve, reject) => { + // Support delayed initialization of FirebaseAppCheck. This allows our + // customers to initialize the RTDB SDK before initializing Firebase + // AppCheck and ensures that all requests are authenticated if a token + // becomes available before the timoeout below expires. + setTimeout(() => { + if (this.appCheck) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); + } + return this.appCheck.getToken(forceRefresh); + } + + addTokenChangeListener(listener: AppCheckTokenListener): void { + void this.appCheckProvider + ?.get() + .then(appCheck => appCheck.addTokenListener(listener)); + } +} diff --git a/packages/data-connect/src/core/FirebaseAuthProvider.ts b/packages/data-connect/src/core/FirebaseAuthProvider.ts new file mode 100644 index 00000000000..a19b8a46d6c --- /dev/null +++ b/packages/data-connect/src/core/FirebaseAuthProvider.ts @@ -0,0 +1,84 @@ +/** + * @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 { FirebaseOptions } from '@firebase/app-types'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName, + FirebaseAuthTokenData +} from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; + +import { logDebug, logError } from '../logger'; + +// @internal +export interface AuthTokenProvider { + getToken(forceRefresh: boolean): Promise; + addTokenChangeListener(listener: AuthTokenListener): void; +} +export type AuthTokenListener = (token: string | null) => void; + +// @internal +export class FirebaseAuthProvider implements AuthTokenProvider { + private _auth: FirebaseAuthInternal; + constructor( + private _appName: string, + private _options: FirebaseOptions, + private _authProvider: Provider + ) { + this._auth = _authProvider.getImmediate({ optional: true })!; + if (!this._auth) { + _authProvider.onInit(auth => (this._auth = auth)); + } + } + getToken(forceRefresh: boolean): Promise { + if (!this._auth) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (this._auth) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); + } + return this._auth.getToken(forceRefresh).catch(error => { + if (error && error.code === 'auth/token-not-initialized') { + logDebug( + 'Got auth/token-not-initialized error. Treating as null token.' + ); + return null; + } else { + logError( + 'Error received when attempting to retrieve token: ' + + JSON.stringify(error) + ); + return Promise.reject(error); + } + }); + } + addTokenChangeListener(listener: AuthTokenListener): void { + this._auth?.addAuthTokenListener(listener); + } + removeTokenChangeListener(listener: (token: string | null) => void): void { + this._authProvider + .get() + .then(auth => auth.removeAuthTokenListener(listener)) + .catch(err => logError(err)); + } +} diff --git a/packages/data-connect/src/core/QueryManager.ts b/packages/data-connect/src/core/QueryManager.ts new file mode 100644 index 00000000000..c82e0fee903 --- /dev/null +++ b/packages/data-connect/src/core/QueryManager.ts @@ -0,0 +1,226 @@ +/** + * @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 { + DataConnectSubscription, + 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 { DataConnectError } from './error'; + +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, + 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, + errCallback: onErrorCallback + }; + const unsubscribe = (): void => { + const trackedQuery = this._queries.get(key)!; + trackedQuery.subscriptions = trackedQuery.subscriptions.filter( + sub => sub !== subscription + ); + }; + 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 { + 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/error.ts b/packages/data-connect/src/core/error.ts new file mode 100644 index 00000000000..f0beb128afa --- /dev/null +++ b/packages/data-connect/src/core/error.ts @@ -0,0 +1,64 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +export type DataConnectErrorCode = + | 'other' + | 'already-initialized' + | 'not-initialized' + | 'not-supported' + | 'invalid-argument' + | 'partial-error' + | 'unauthorized'; + +export type Code = DataConnectErrorCode; + +export const Code = { + OTHER: 'other' as DataConnectErrorCode, + ALREADY_INITIALIZED: 'already-initialized' as DataConnectErrorCode, + NOT_INITIALIZED: 'not-initialized' as DataConnectErrorCode, + NOT_SUPPORTED: 'not-supported' as DataConnectErrorCode, + INVALID_ARGUMENT: 'invalid-argument' as DataConnectErrorCode, + PARTIAL_ERROR: 'partial-error' as DataConnectErrorCode, + UNAUTHORIZED: 'unauthorized' as DataConnectErrorCode +}; + +/** An error returned by a DataConnect operation. */ +export class DataConnectError extends FirebaseError { + /** The stack of the error. */ + readonly stack?: string; + + /** @hideconstructor */ + constructor( + /** + * The backend error code associated with this error. + */ + readonly code: DataConnectErrorCode, + /** + * A custom error description. + */ + readonly message: string + ) { + super(code, message); + + // HACK: We write a toString property directly because Error is not a real + // class and so inheritance does not work correctly. We could alternatively + // do the same "back-door inheritance" trick that FirebaseError does. + this.toString = () => `${this.name}: [code=${this.code}]: ${this.message}`; + } +} diff --git a/packages/data-connect/src/core/version.ts b/packages/data-connect/src/core/version.ts new file mode 100644 index 00000000000..dd9e7850454 --- /dev/null +++ b/packages/data-connect/src/core/version.ts @@ -0,0 +1,27 @@ +/** + * @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. + */ + +/** The semver (www.semver.org) version of the SDK. */ +export let SDK_VERSION = ''; + +/** + * SDK_VERSION should be set before any database instance is created + * @internal + */ +export function setSDKVersion(version: string): void { + SDK_VERSION = version; +} diff --git a/packages/data-connect/src/index.node.ts b/packages/data-connect/src/index.node.ts new file mode 100644 index 00000000000..0a4970e4856 --- /dev/null +++ b/packages/data-connect/src/index.node.ts @@ -0,0 +1,25 @@ +/** + * @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 { initializeFetch } from './network/fetch'; +import { registerDataConnect } from './register'; + +export * from './api'; +export * from './api.node'; +initializeFetch(fetch); + +registerDataConnect('node'); diff --git a/packages/data-connect/src/index.ts b/packages/data-connect/src/index.ts new file mode 100644 index 00000000000..6963618400c --- /dev/null +++ b/packages/data-connect/src/index.ts @@ -0,0 +1,35 @@ +/** + * Firebase Data Connect + * + * @packageDocumentation + */ + +/** + * @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 { DataConnect } from './api/DataConnect'; +import { registerDataConnect } from './register'; + +export * from './api'; +export * from './api.browser'; + +registerDataConnect(); + +declare module '@firebase/component' { + interface NameServiceMapping { + 'data-connect': DataConnect; + } +} diff --git a/packages/data-connect/src/logger.ts b/packages/data-connect/src/logger.ts new file mode 100644 index 00000000000..ee66e8796c3 --- /dev/null +++ b/packages/data-connect/src/logger.ts @@ -0,0 +1,31 @@ +/** + * @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 { Logger, LogLevelString } from '@firebase/logger'; + +import { SDK_VERSION } from './core/version'; + +const logger = new Logger('@firebase/data-connect'); +export function setLogLevel(logLevel: LogLevelString): void { + logger.setLogLevel(logLevel); +} +export function logDebug(msg: string): void { + logger.debug(`DataConnect (${SDK_VERSION}): ${msg}`); +} + +export function logError(msg: string): void { + logger.error(`DataConnect (${SDK_VERSION}): ${msg}`); +} diff --git a/packages/data-connect/src/network/fetch.ts b/packages/data-connect/src/network/fetch.ts new file mode 100644 index 00000000000..928b9f873cf --- /dev/null +++ b/packages/data-connect/src/network/fetch.ts @@ -0,0 +1,109 @@ +/** + * @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 { Code, DataConnectError } from '../core/error'; +import { SDK_VERSION } from '../core/version'; +import { logDebug, logError } from '../logger'; + +let connectFetch: typeof fetch | null = globalThis.fetch; +export function initializeFetch(fetchImpl: typeof fetch): void { + connectFetch = fetchImpl; +} +function getGoogApiClientValue(_isUsingGen: boolean): string { + let str = 'gl-js/ fire/' + SDK_VERSION; + if (_isUsingGen) { + str += ' web/gen'; + } + return str; +} +export function dcFetch( + url: string, + body: U, + { signal }: AbortController, + appId: string | null, + accessToken: string | null, + appCheckToken: string | null, + _isUsingGen: boolean +): Promise<{ data: T; errors: Error[] }> { + if (!connectFetch) { + throw new DataConnectError(Code.OTHER, 'No Fetch Implementation detected!'); + } + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Client': getGoogApiClientValue(_isUsingGen) + }; + if (accessToken) { + headers['X-Firebase-Auth-Token'] = accessToken; + } + if (appId) { + headers['x-firebase-gmpid'] = appId; + } + if (appCheckToken) { + headers['X-Firebase-AppCheck'] = appCheckToken; + } + const bodyStr = JSON.stringify(body); + logDebug(`Making request out to ${url} with body: ${bodyStr}`); + + return connectFetch(url, { + body: bodyStr, + method: 'POST', + headers, + signal + }) + .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); + logError('DataConnect error while performing request: ' + stringified); + throw new DataConnectError(Code.OTHER, stringified); + } + return res as { data: T; errors: Error[] }; + }); +} +interface MessageObject { + message?: string; +} +function getMessage(obj: MessageObject): string { + if ('message' in obj) { + return obj.message; + } + return JSON.stringify(obj); +} diff --git a/packages/data-connect/src/network/index.ts b/packages/data-connect/src/network/index.ts new file mode 100644 index 00000000000..33a2202d57f --- /dev/null +++ b/packages/data-connect/src/network/index.ts @@ -0,0 +1,18 @@ +/** + * @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. + */ + +export * from './transport'; diff --git a/packages/data-connect/src/network/transport/index.ts b/packages/data-connect/src/network/transport/index.ts new file mode 100644 index 00000000000..5518faa0f95 --- /dev/null +++ b/packages/data-connect/src/network/transport/index.ts @@ -0,0 +1,53 @@ +/** + * @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 { DataConnectOptions, TransportOptions } from '../../api/DataConnect'; +import { AppCheckTokenProvider } from '../../core/AppCheckTokenProvider'; +import { AuthTokenProvider } from '../../core/FirebaseAuthProvider'; + +/** + * @internal + */ +export interface DataConnectTransport { + invokeQuery( + queryName: string, + body?: U + ): PromiseLike<{ data: T; errors: Error[] }>; + invokeMutation( + queryName: string, + body?: U + ): PromiseLike<{ data: T; errors: Error[] }>; + useEmulator(host: string, port?: number, sslEnabled?: boolean): void; + onTokenChanged: (token: string | null) => void; +} + +export interface CancellableOperation extends PromiseLike<{ data: T }> { + cancel: () => void; +} + +/** + * @internal + */ +export type TransportClass = new ( + options: DataConnectOptions, + apiKey?: string, + appId?: string, + authProvider?: AuthTokenProvider, + appCheckProvider?: AppCheckTokenProvider, + transportOptions?: TransportOptions, + _isUsingGen?: boolean +) => DataConnectTransport; diff --git a/packages/data-connect/src/network/transport/rest.ts b/packages/data-connect/src/network/transport/rest.ts new file mode 100644 index 00000000000..85847868c5d --- /dev/null +++ b/packages/data-connect/src/network/transport/rest.ts @@ -0,0 +1,222 @@ +/** + * @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 { DataConnectOptions, TransportOptions } from '../../api/DataConnect'; +import { AppCheckTokenProvider } from '../../core/AppCheckTokenProvider'; +import { DataConnectError, Code } from '../../core/error'; +import { AuthTokenProvider } from '../../core/FirebaseAuthProvider'; +import { logDebug } from '../../logger'; +import { addToken, urlBuilder } from '../../util/url'; +import { dcFetch } from '../fetch'; + +import { DataConnectTransport } from '.'; + +export class RESTTransport implements DataConnectTransport { + private _host = ''; + private _port: number | undefined; + private _location = 'l'; + private _connectorName = ''; + private _secure = true; + private _project = 'p'; + private _serviceName: string; + private _accessToken: string | null = null; + private _appCheckToken: string | null = null; + private _lastToken: string | null = null; + constructor( + options: DataConnectOptions, + private apiKey?: string | undefined, + private appId?: string, + private authProvider?: AuthTokenProvider | undefined, + private appCheckProvider?: AppCheckTokenProvider | undefined, + transportOptions?: TransportOptions | undefined, + private _isUsingGen = false + ) { + if (transportOptions) { + if (typeof transportOptions.port === 'number') { + this._port = transportOptions.port; + } + if (typeof transportOptions.sslEnabled !== 'undefined') { + this._secure = transportOptions.sslEnabled; + } + this._host = transportOptions.host; + } + const { location, projectId: project, connector, service } = options; + if (location) { + this._location = location; + } + if (project) { + this._project = project; + } + this._serviceName = service; + if (!connector) { + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Connector Name required!' + ); + } + this._connectorName = connector; + this.authProvider?.addTokenChangeListener(token => { + logDebug(`New Token Available: ${token}`); + this._accessToken = token; + }); + this.appCheckProvider?.addTokenChangeListener(result => { + const { token } = result; + logDebug(`New App Check Token Available: ${token}`); + this._appCheckToken = token; + }); + } + get endpointUrl(): string { + return urlBuilder( + { + connector: this._connectorName, + location: this._location, + projectId: this._project, + service: this._serviceName + }, + { host: this._host, sslEnabled: this._secure, port: this._port } + ); + } + useEmulator(host: string, port?: number, isSecure?: boolean): void { + this._host = host; + if (typeof port === 'number') { + this._port = port; + } + if (typeof isSecure !== 'undefined') { + this._secure = isSecure; + } + } + onTokenChanged(newToken: string | null): void { + this._accessToken = newToken; + } + + async getWithAuth(forceToken = false): Promise { + let starterPromise: Promise = new Promise(resolve => + resolve(this._accessToken) + ); + if (this.appCheckProvider) { + this._appCheckToken = (await this.appCheckProvider.getToken())?.token; + } + if (this.authProvider) { + starterPromise = this.authProvider + .getToken(/*forceToken=*/ forceToken) + .then(data => { + if (!data) { + return null; + } + this._accessToken = data.accessToken; + return this._accessToken; + }); + } else { + starterPromise = new Promise(resolve => resolve('')); + } + return starterPromise; + } + + _setLastToken(lastToken: string | null): void { + this._lastToken = lastToken; + } + + withRetry( + promiseFactory: () => Promise<{ data: T; errors: Error[] }>, + retry = false + ): Promise<{ data: T; errors: Error[] }> { + let isNewToken = false; + return this.getWithAuth(retry) + .then(res => { + isNewToken = this._lastToken !== res; + this._lastToken = res; + return res; + }) + .then(promiseFactory) + .catch(err => { + // Only retry if the result is unauthorized and the last token isn't the same as the new one. + if ( + 'code' in err && + err.code === Code.UNAUTHORIZED && + !retry && + isNewToken + ) { + logDebug('Retrying due to unauthorized'); + return this.withRetry(promiseFactory, true); + } + throw err; + }); + } + + // TODO(mtewani): Update U to include shape of body defined in line 13. + invokeQuery: ( + queryName: string, + body?: U + ) => PromiseLike<{ data: T; errors: Error[] }> = ( + queryName: string, + body: U + ) => { + const abortController = new AbortController(); + // TODO(mtewani): Update to proper value + const withAuth = this.withRetry(() => + dcFetch( + addToken(`${this.endpointUrl}:executeQuery`, this.apiKey), + { + name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`, + operationName: queryName, + variables: body + } as unknown as U, // TODO(mtewani): This is a patch, fix this. + abortController, + this.appId, + this._accessToken, + this._appCheckToken, + this._isUsingGen + ) + ); + + return { + then: withAuth.then.bind(withAuth), + catch: withAuth.catch.bind(withAuth) + }; + }; + invokeMutation: ( + queryName: string, + body?: U + ) => PromiseLike<{ data: T; errors: Error[] }> = ( + mutationName: string, + body: U + ) => { + const abortController = new AbortController(); + const taskResult = this.withRetry(() => { + return dcFetch( + addToken(`${this.endpointUrl}:executeMutation`, this.apiKey), + { + name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`, + operationName: mutationName, + variables: body + } as unknown as U, + abortController, + this.appId, + this._accessToken, + this._appCheckToken, + this._isUsingGen + ); + }); + + return { + then: taskResult.then.bind(taskResult), + // catch: taskResult.catch.bind(taskResult), + // finally: taskResult.finally.bind(taskResult), + cancel: () => abortController.abort() + }; + }; +} diff --git a/packages/data-connect/src/register.ts b/packages/data-connect/src/register.ts new file mode 100644 index 00000000000..53b44f4e43d --- /dev/null +++ b/packages/data-connect/src/register.ts @@ -0,0 +1,63 @@ +/** + * @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. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { + _registerComponent, + registerVersion, + SDK_VERSION +} from '@firebase/app'; +import { Component, ComponentType } from '@firebase/component'; + +import { name, version } from '../package.json'; +import { setSDKVersion } from '../src/core/version'; + +import { DataConnect, ConnectorConfig } from './api/DataConnect'; +import { Code, DataConnectError } from './core/error'; + +export function registerDataConnect(variant?: string): void { + setSDKVersion(SDK_VERSION); + _registerComponent( + new Component( + 'data-connect', + (container, { instanceIdentifier: settings, 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 (!app.options.projectId) { + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + } + return new DataConnect( + app, + { ...newOpts, projectId: app.options.projectId! }, + authProvider, + appCheckProvider + ); + }, + ComponentType.PUBLIC + ).setMultipleInstances(true) + ); + registerVersion(name, version, variant); + // BUILD_TARGET will be replaced by values like esm5, esm2017, cjs5, etc during the compilation + registerVersion(name, version, '__BUILD_TARGET__'); +} diff --git a/packages/data-connect/src/util/encoder.ts b/packages/data-connect/src/util/encoder.ts new file mode 100644 index 00000000000..55aff801d22 --- /dev/null +++ b/packages/data-connect/src/util/encoder.ts @@ -0,0 +1,23 @@ +/** + * @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. + */ + +export type HmacImpl = (obj: unknown) => string; +export let encoderImpl: HmacImpl; +export function setEncoder(encoder: HmacImpl): void { + encoderImpl = encoder; +} +setEncoder(o => JSON.stringify(o)); diff --git a/packages/data-connect/src/util/map.ts b/packages/data-connect/src/util/map.ts new file mode 100644 index 00000000000..5b96eb2f3dc --- /dev/null +++ b/packages/data-connect/src/util/map.ts @@ -0,0 +1,26 @@ +/** + * @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. + */ + +export function setIfNotExists( + map: Map, + key: string, + val: T +): void { + if (!map.has(key)) { + map.set(key, val); + } +} diff --git a/packages/data-connect/src/util/url.ts b/packages/data-connect/src/util/url.ts new file mode 100644 index 00000000000..b979ec19eb5 --- /dev/null +++ b/packages/data-connect/src/util/url.ts @@ -0,0 +1,49 @@ +/** + * @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 { DataConnectOptions, TransportOptions } from '../api/DataConnect'; +import { Code, DataConnectError } from '../core/error'; +import { logError } from '../logger'; + +export function urlBuilder( + projectConfig: DataConnectOptions, + transportOptions: TransportOptions +): string { + const { connector, location, projectId: project, service } = projectConfig; + const { host, sslEnabled, port } = transportOptions; + const protocol = sslEnabled ? 'https' : 'http'; + const realHost = host || `firebasedataconnect.googleapis.com`; + let baseUrl = `${protocol}://${realHost}`; + if (typeof port === 'number') { + baseUrl += `:${port}`; + } else if (typeof port !== 'undefined') { + logError('Port type is of an invalid type'); + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Incorrect type for port passed in!' + ); + } + return `${baseUrl}/v1beta/projects/${project}/locations/${location}/services/${service}/connectors/${connector}`; +} +export function addToken(url: string, apiKey?: string): string { + if (!apiKey) { + return url; + } + const newUrl = new URL(url); + newUrl.searchParams.append('key', apiKey); + return newUrl.toString(); +} diff --git a/packages/data-connect/src/util/validateArgs.ts b/packages/data-connect/src/util/validateArgs.ts new file mode 100644 index 00000000000..15d1effa3da --- /dev/null +++ b/packages/data-connect/src/util/validateArgs.ts @@ -0,0 +1,58 @@ +/** + * @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 { + ConnectorConfig, + DataConnect, + getDataConnect +} from '../api/DataConnect'; +import { Code, DataConnectError } from '../core/error'; +interface ParsedArgs { + dc: DataConnect; + vars: Variables; +} + +/** + * The generated SDK will allow the user to pass in either the variable or the data connect instance with the variable, + * and this function validates the variables and returns back the DataConnect instance and variables based on the arguments passed in. + * @param connectorConfig + * @param dcOrVars + * @param vars + * @param validateVars + * @returns {DataConnect} and {Variables} instance + * @internal + */ +export function validateArgs( + connectorConfig: ConnectorConfig, + dcOrVars?: DataConnect | Variables, + vars?: Variables, + validateVars?: boolean +): ParsedArgs { + let dcInstance: DataConnect; + let realVars: Variables; + if (dcOrVars && 'enableEmulator' in dcOrVars) { + dcInstance = dcOrVars as DataConnect; + realVars = vars; + } else { + dcInstance = getDataConnect(connectorConfig); + realVars = dcOrVars as Variables; + } + if (!dcInstance || (!realVars && validateVars)) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'Variables required.'); + } + return { dc: dcInstance, vars: realVars }; +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql new file mode 100755 index 00000000000..8c472f99a6e --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql @@ -0,0 +1,49 @@ +scalar Movie_Key +input Movie_Data { + id: String + id_expr: String_Expr + description: String + description_expr: String_Expr + genre: String + genre_expr: String_Expr + name: String + name_expr: String_Expr + test: String + test_expr: String_Expr +} +input Movie_Filter { + _and: [Movie_Filter!] + _not: Movie_Filter + _or: [Movie_Filter!] + id: String_Filter + description: String_Filter + genre: String_Filter + name: String_Filter + test: String_Filter +} +input Movie_ListFilter { + count: Int_Filter + exist: Movie_Filter +} +input Movie_ListUpdate { + append: [Movie_Data!] + delete: Int + i: Int + prepend: [Movie_Data!] + set: [Movie_Data!] + update: [Movie_Update!] +} +input Movie_Order { + id: OrderDirection + description: OrderDirection + genre: OrderDirection + name: OrderDirection + test: OrderDirection +} +input Movie_Update { + id: [String_Update!] + description: [String_Update!] + genre: [String_Update!] + name: [String_Update!] + test: [String_Update!] +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql new file mode 100755 index 00000000000..b6896486e4a --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql @@ -0,0 +1,8 @@ +extend type Mutation { + movie_insert(data: Movie_Data!): Movie_Key! + movie_upsert(data: Movie_Data!, update: Movie_Update): Movie_Key! + movie_update(id: String, id_expr: String_Expr, key: Movie_Key, data: Movie_Data, update: Movie_Update): Movie_Key + movie_updateMany(where: Movie_Filter, all: Boolean = false, data: Movie_Data, update: Movie_Update): Int! + movie_delete(id: String, id_expr: String_Expr, key: Movie_Key): Movie_Key + movie_deleteMany(where: Movie_Filter, all: Boolean = false): Int! +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql new file mode 100755 index 00000000000..53ee30ce8ad --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql @@ -0,0 +1,4 @@ +extend type Query { + movie(id: String, id_expr: String_Expr, key: Movie_Key): Movie + movies(where: Movie_Filter, orderBy: [Movie_Order!], limit: Int = 100): [Movie!] +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql new file mode 100755 index 00000000000..4007a693025 --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql @@ -0,0 +1,953 @@ +"Conditions on a string value" +input String_Filter { + isNull: Boolean + eq: String + eq_expr: String_Expr + ne: String + ne_expr: String_Expr + in: [String!] + nin: [String!] + gt: String + ge: String + lt: String + le: String + contains: String + startsWith: String + endsWith: String + pattern: String_Pattern +} + +""" +The pattern match condition on a string. Specify either like or regex. +https://www.postgresql.org/docs/current/functions-matching.html +""" +input String_Pattern { + "the LIKE expression to use" + like: String + "the POSIX regular expression" + regex: String + "when true, it's case-insensitive. In Postgres: ILIKE, ~*" + ignoreCase: Boolean + "when true, invert the condition. In Postgres: NOT LIKE, !~" + invert: Boolean +} + +"Conditions on a string list" +input String_ListFilter { + includes: String + excludes: String + includesAll: [String!] + excludesAll: [String!] +} + +"Conditions on a UUID value" +input UUID_Filter { + isNull: Boolean + eq: UUID + ne: UUID + in: [UUID!] + nin: [UUID!] +} + +"Conditions on a UUID list" +input UUID_ListFilter { + includes: UUID + excludes: UUID + includesAll: [UUID!] + excludesAll: [UUID!] +} + +"Conditions on an Int value" +input Int_Filter { + isNull: Boolean + eq: Int + ne: Int + in: [Int!] + nin: [Int!] + gt: Int + ge: Int + lt: Int + le: Int +} + +"Conditions on an Int list" +input Int_ListFilter { + includes: Int + excludes: Int + includesAll: [Int!] + excludesAll: [Int!] +} + +"Conditions on an Int64 value" +input Int64_Filter { + isNull: Boolean + eq: Int64 + ne: Int64 + in: [Int64!] + nin: [Int64!] + gt: Int64 + ge: Int64 + lt: Int64 + le: Int64 +} + +"Conditions on an Int64 list" +input Int64_ListFilter { + includes: Int64 + excludes: Int64 + includesAll: [Int64!] + excludesAll: [Int64!] +} + +"Conditions on a Float value" +input Float_Filter { + isNull: Boolean + eq: Float + ne: Float + in: [Float!] + nin: [Float!] + gt: Float + ge: Float + lt: Float + le: Float +} + +"Conditions on a Float list" +input Float_ListFilter { + includes: Float + excludes: Float + includesAll: [Float!] + excludesAll: [Float!] +} + +"Conditions on a Boolean value" +input Boolean_Filter { + isNull: Boolean + eq: Boolean + ne: Boolean + in: [Boolean!] + nin: [Boolean!] +} + +"Conditions on a Boolean list" +input Boolean_ListFilter { + includes: Boolean + excludes: Boolean + includesAll: [Boolean!] + excludesAll: [Boolean!] +} + +"Conditions on a Date value" +input Date_Filter { + isNull: Boolean + eq: Date + ne: Date + in: [Date!] + nin: [Date!] + gt: Date + ge: Date + lt: Date + le: Date + """ + Offset the date filters by a fixed duration. + last 3 months is {ge: {today: true}, offset: {months: -3}} + """ + offset: Date_Offset +} + +"Duration to offset a date value" +input Date_Offset { + days: Int + months: Int + years: Int +} + +"Conditions on a Date list" +input Date_ListFilter { + includes: Date + excludes: Date + includesAll: [Date!] + excludesAll: [Date!] +} + +"Conditions on an Timestamp value" +input Timestamp_Filter { + isNull: Boolean + eq: Timestamp + eq_expr: Timestamp_Expr + ne: Timestamp + ne_expr: Timestamp_Expr + in: [Timestamp!] + nin: [Timestamp!] + gt: Timestamp + gt_expr: Timestamp_Expr + ge: Timestamp + ge_expr: Timestamp_Expr + lt: Timestamp + lt_expr: Timestamp_Expr + le: Timestamp + le_expr: Timestamp_Expr + + """ + Offset timestamp input by a fixed duration. + in 12h is {le: {now: true}, offset: {hours: 12}} + """ + offset: Timestamp_Offset @deprecated +} + +"Duration to offset a timestamp value" +input Timestamp_Offset @fdc_deprecated { + milliseconds: Int + seconds: Int + minutes: Int + hours: Int + days: Int + months: Int + years: Int +} + +"Conditions on a Timestamp list" +input Timestamp_ListFilter { + includes: Timestamp + includes_expr: Timestamp_Expr + excludes: Timestamp + excludes_expr: Timestamp_Expr + includesAll: [Timestamp!] + excludesAll: [Timestamp!] +} + +"Conditions on an Any value" +input Any_Filter { + isNull: Boolean + eq: Any + ne: Any + in: [Any!] + nin: [Any!] +} + +"Conditions on a Any list" +input Any_ListFilter { + includes: Any + excludes: Any + includesAll: [Any!] + excludesAll: [Any!] +} + +"Conditions on an AuthUID value" +input AuthUID_Filter @fdc_deprecated { + eq: AuthUID + ne: AuthUID + in: [AuthUID!] + nin: [AuthUID!] + isNull: Boolean +} + +input AuthUID_ListFilter @fdc_deprecated { + "When true, will match if the list includes the id of the current user." + includes: AuthUID + excludes: AuthUID + includesAll: [AuthUID!] + excludesAll: [AuthUID!] +} + +"Conditions on an Vector value" +input Vector_Filter { + eq: Vector + ne: Vector + in: [Vector!] + nin: [Vector!] + isNull: Boolean +} + +input Vector_ListFilter { + "When true, will match if the list includes the supplied vector." + includes: Vector + excludes: Vector + includesAll: [Vector!] + excludesAll: [Vector!] +} +type Query { + _service: _Service! +} + +type Mutation { + # This is just a dummy field so that Mutation is always non-empty. + _firebase: Void @fdc_deprecated(reason: "dummy field -- does nothing useful") +} + +type _Service { + sdl: String! +} + +"(Internal) Added to things that may be removed from FDC and will soon be no longer usable in schema or operations." +directive @fdc_deprecated(reason: String = "No longer supported") on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +"(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 +"AccessLevel specifies coarse access policies for common situations." +enum AccessLevel { + """ + This operation can be executed by anyone with or without authentication. + Equivalent to @auth(expr: "true") + """ + PUBLIC + + """ + This operation can only be executed with a valid Firebase Auth ID token. + Note: it allows anonymous auth and unverified accounts, so may be subjected to abuses. + It’s equivalent to @auth(expr: "auth.uid != nil") + """ + USER_ANON + + """ + This operation can only be executed by a non-anonymous Firebase Auth account. + It’s equivalent to @auth(expr: "auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'")" + """ + USER + + """ + This operation can only be executed by a verified Firebase Auth account. + It’s equivalent to @auth(expr: "auth.uid != nil && auth.token.email_verified")" + """ + USER_EMAIL_VERIFIED + + """ + This operation can not be executed with no IAM credentials. + It’s equivalent to @auth(expr: "false") + """ + NO_ACCESS +} + +""" +Defines the auth policy for a query or mutation. This directive must be added to +any operation you wish to be accessible from a client application. If left +unspecified, defaults to `@auth(level: NO_ACCESS)`. +""" +directive @auth( + "The minimal level of access required to perform this operation." + level: AccessLevel @fdc_oneOf(required: true) + """ + A CEL expression that allows access to this operation if the expression + evaluates to `true`. + """ + expr: Boolean_Expr @fdc_oneOf(required: true) +) on QUERY | MUTATION +""" +Mark this field as a customized resolver. +It may takes customized input arguments and return customized types. + +TODO(b/315857408): Funnel this through API review. + +See: +- go/firemat:custom-resolvers +- go/custom-resolvers-hackweek +""" +directive @resolver on FIELD_DEFINITION +scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") +scalar Int64 +scalar Date +scalar Timestamp @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") +scalar Any +scalar Void +""" +AuthUID is a string representing a Firebase Auth uid. When passing a literal +value for an AuthUID in a query, you may instead pass `{current: true}` and the +currently signed in user's uid will be injected instead. For example: + +```gql +type Order { + customerId: AuthUID! + # ... +} + +query myOrders { + orders: (where: { + customerId: {eq: {current: true}} + }) { customerId } +} +``` +""" +scalar AuthUID @fdc_deprecated +scalar Vector +"Define the intervals used in timestamps and dates (subset)" +enum TimestampInterval @fdc_deprecated { + second + minute + hour + day + week + month + year +} + +input Timestamp_Sentinel @fdc_deprecated { + "Return the current time." + now: Boolean, + "Defines a timestamp relative to the current time. Offset values can be positive or negative." + fromNow: Timestamp_Offset + "Truncate the current/offset time to the specified interval." + truncateTo: TimestampInterval +} + +""" +A Common Expression Language (CEL) expression that returns a boolean at runtime. + +The expression can reference the `auth` variable, which is null if Firebase Auth +is not used. Otherwise, it contains the following fields: + + - `auth.uid`: The current user ID. + - `auth.token`: A map of all token fields (i.e. "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: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +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 Timestamp at runtime. + +Limitation: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +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 UUID string at runtime. + +Limitation: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +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 whose return type is unspecified. + +Limitation: Only a limited set of expressions are supported for now for each +type. For type XXX, see the @fdc_example directives on XXX_Expr for a full list. +""" +scalar Any_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression + @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 (formatted as 32 lower-case hex digits without delimiters if result type is String).") + @fdc_example(value: "request.time", description: "The timestamp when the request is received (with microseconds precision).") + +""" +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. + +Given `type TableName @table`, + + - `TableName` is the GQL type name. + - `tableName` is the singular name. Override with `@table(singular)`. + - `tableNames` is the plural name. Override with `@table(plural)`. + - `table_name` is the SQL table id. Override with `@table(name)`. + +Only a table type can be configured further with: + + - Customized data types. See `@col`. + - Index. See `@index` + - Unique constraint. See `@unqiue` + - Relation. See `@ref` + - Embedded Json. See `@embed` + +A scalar field map to a SQL database column. +An object field (like `type TableName @table { field: AnotherType }`) are either + + - a relation reference field if `AnotherType` is annotated with `@table`. + - an embedded json field if `field` is annotated with `@embed`. + +""" +directive @table( + "Override the SQL database table name. Defaults to ." + name: String + "Override the singular name. Default is the camel case of the type name." + singular: String + "Override the plural name. Default is generated based on English patterns." + plural: String + "The primary key of the table. Defaults to a single field `id: UUID!`. Generate if missing." + key: [String!] +) on OBJECT + +""" +Defines a relational database view. + +Given `type ViewName @view`, + - `ViewName` is the GQL type name. + - `viewName` is the singular name. Override with `@view(singular)`. + - `viewNames` is the plural name. Override with `@view(plural)`. + - `view_name` is the SQL view id. Override with `@view(name)`. + When `@view(sql)` is defined, it uses the given raw SQL as the view instead. + +A view type can be used just as a table type with queries. +A view type may have a nullable `@ref` field to another table, but cannot be +referenced in a `@ref`. + +WARNING: Firebase Data Connect does not validate the SQL of the view or +evaluate whether it matches the defined fields. + +If the SQL view is invalid or undefined, related requests may fail. +If the SQL view return incompatible types. Firebase Data Connect will surface +an error in the response. +""" +directive @view( + """ + The SQL view name. If no `name` or `sql` are provided, defaults to + snake_case of the singular type name. + """ + name: String @fdc_oneOf + """ + SQL SELECT statement to use as the basis for this type. Note that all SQL + identifiers should be snake_case and all GraphQL identifiers should be + camelCase. + """ + sql: String @fdc_oneOf + "Override the singular name. Default is the camel case of the type name." + singular: String + "Override the plural name. Default is generated based on English patterns." + plural: String +) on OBJECT + +""" +Specify additional column options. + +Given `type TableName @table { fieldName: Int } ` + + - `field_name` is the SQL column name. Override with `@col(name)`. + +""" +directive @col( + "The SQL database column name. Defaults to ." + name: String + """ + Override SQL columns data type. + Each GraphQL type could map to many SQL data types. + Refer to Postgres supported data types and mappings to GQL. + """ + dataType: String + """ + Defines a fixed column size for certain scalar types. + + - For Vector, size is required. It establishes the length of the vector. + - For String, size converts `text` type to `varchar(size)`. + """ + size: Int +) on FIELD_DEFINITION + + +""" +Define an embedded JSON field represented as Postgres `jsonb` (or `json`). + +Given `type TableName @table { fieldName: EmbeddedType @embed }` +`EmbeddedType` must NOT have `@table`. + + - Store JSON object if `EmbeddedType`. Required column if `EmbeddedType!`. + - Store JSON array if `[EmbeddedType]`. Required column if `[EmbeddedType]!`. + +""" +directive @embed on FIELD_DEFINITION + +""" +Define a reference field to another table. + +Given `type TableName @table { refField: AnotherTableName }`, it defines a foreign-key constraint + + - with id `table_name_ref_field_fkey` (override with `@ref(constraintName)`) + - from `table_name.ref_field` (override with `@ref(fields)`) + - to `another_table_name.id` (override with `@ref(references)`) + +Does not support `[AnotherTableName]` because array fields cannot have foreign-key constraints. +Nullability determines whether the reference is required. + + - `refField: AnotherTableName`: optional reference, SET_NULL on delete. + - `refField: AnotherTableName!`: required reference, CASCADE on delete. + +Consider all types of SQL relations: + + - many-to-one relations involve a reference field on the many-side. + - many-to-maybe-one if `refField: AnotherTableName`. + - many-to-exactly-one if `refField: AnotherTableName!`. + - one-to-one relations involve a unique reference field on one side. + - maybe-one-to-maybe-one if `refField: AnotherTableName @unique`. + - maybe-one-to-exact-one if `refField: AnotherTableName! @unique`. + - exact-one-to-exact-one shall be represented as a single table instead. + - many-to-many relations involve a join table. + - Its primary keys must be two non-null reference fields to tables bridged together to guarantee at most one relation per pair. + +type TableNameToAnotherTableName @table(key: ["refField", "anotherRefField"]) { + refField: TableName! + anotherRefField: AnotherTableName! +} + +""" +directive @ref( + "The SQL database foreign key constraint name. Default to __fkey." + constraintName: String + """ + Foreign key fields. Default to . + """ + fields: [String!] + "The fields that the foreign key references in the other table. Default to the primary key." + references: [String!] +) on FIELD_DEFINITION + +enum IndexFieldOrder { ASC DESC } + +""" +Defines a database index to optimize query performance. + +Given `type TableName @table @index(fields: [“fieldName”, “secondFieldName”])`, +`table_name_field_name_second_field_name_aa_idx` is the SQL index id. +`table_name_field_name_second_field_name_ad_idx` if `order: [ASC DESC]`. +`table_name_field_name_second_field_name_dd_idx` if `order: [DESC DESC]`. + +Given `type TableName @table { fieldName: Int @index } ` +`table_name_field_name_idx` is the SQL index id. +`order` matters less for single field indexes because they can be scanned in both ways. + +Override with `@index(name)` in case of index name conflicts. +""" +directive @index( + "The SQL database index id. Defaults to __idx." + name: String + """ + Only allowed and required when used on OBJECT. + The fields to create an index on. + """ + fields: [String!] + """ + Only allowed when used on OBJECT. + Index order of each column. Default to all ASC. + """ + order: [IndexFieldOrder!] +) repeatable on FIELD_DEFINITION | OBJECT + +""" +Defines a unique constraint. + +Given `type TableName @table @unique(fields: [“fieldName”, “secondFieldName”])`, +`table_name_field_name_second_field_name_uidx` is the SQL unique index id. +Given `type TableName @table { fieldName: Int @unique } ` +`table_name_field_name_idx` is the SQL unique index id. + +Override with `@unique(indexName)` in case of index name conflicts. +""" +directive @unique( + "The SQL database unique index name. Defaults to __uidx." + indexName: String + """ + Only allowed and required when used on OBJECT. + The fields to create a unique constraint on. + """ + fields: [String!] +) repeatable on FIELD_DEFINITION | OBJECT + +"Define the direction of an orderby query" +enum OrderDirection { + ASC + DESC +} + +""" +Defines what siliarlity function to use for fetching vectors. +Details here: https://github.com/pgvector/pgvector?tab=readme-ov-file#vector-functions +""" +enum VectorSimilarityMethod { + L2 + COSINE + INNER_PRODUCT +} + +enum ColDefault @fdc_deprecated { + """ + Generates a random UUID (v4) as the default column value. + Compatible with String or UUID typed fields. + """ + UUID + """ + Generates an auto-incrementing sequence as the default column value. + Compatible with Int and Int64 typed fields. + """ + SEQUENCE + """ + Populates the default column value with the current date or timestamp. + Compatible with Date and Timestamp typed fields. + """ + NOW +} + +""" +Specify the default column value. + +The supported arguments vary based on the field type. +""" +directive @default( + "A constant value. Validated against the field GraphQL type at compile-time." + value: Any @fdc_oneOf(required: true) + "(Deprecated) Built-in common ways to generate initial value." + generate: ColDefault @fdc_oneOf(required: true) @deprecated + "A CEL expression, whose return value must match the field 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. + https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-DEFAULT + """ + sql: Any_SQL @fdc_oneOf(required: true) +) on FIELD_DEFINITION +"Update input of a String value" +input String_Update { + set: String @fdc_oneOf(group: "set") + set_expr: String_Expr @fdc_oneOf(group: "set") +} + +"Update input of a String list value" +input String_ListUpdate { + set: [String!] + append: [String!] + prepend: [String!] + delete: Int + i: Int + update: String +} + +"Update input of a UUID value" +input UUID_Update { + set: UUID @fdc_oneOf(group: "set") + set_expr: UUID_Expr @fdc_oneOf(group: "set") +} + +"Update input of an ID list value" +input UUID_ListUpdate { + set: [UUID!] + append: [UUID!] + prepend: [UUID!] + delete: Int + i: Int + update: UUID +} + +"Update input of an Int value" +input Int_Update { + set: Int + inc: Int + dec: Int +} + +"Update input of an Int list value" +input Int_ListUpdate { + set: [Int!] + append: [Int!] + prepend: [Int!] + delete: Int + i: Int + update: Int +} + +"Update input of an Int64 value" +input Int64_Update { + set: Int64 + inc: Int64 + dec: Int64 +} + +"Update input of an Int64 list value" +input Int64_ListUpdate { + set: [Int64!] + append: [Int64!] + prepend: [Int64!] + delete: Int + i: Int + update: Int64 +} + +"Update input of a Float value" +input Float_Update { + set: Float + inc: Float + dec: Float +} + +"Update input of a Float list value" +input Float_ListUpdate { + set: [Float!] + append: [Float!] + prepend: [Float!] + delete: Int + i: Int + update: Float +} + +"Update input of a Boolean value" +input Boolean_Update { + set: Boolean +} + +"Update input of a Boolean list value" +input Boolean_ListUpdate { + set: [Boolean!] + append: [Boolean!] + prepend: [Boolean!] + delete: Int + i: Int + update: Boolean +} + +"Update input of a Date value" +input Date_Update { + set: Date + inc: Date_Offset + dec: Date_Offset +} + +"Update input of a Date list value" +input Date_ListUpdate { + set: [Date!] + append: [Date!] + prepend: [Date!] + delete: Int + i: Int + update: Date +} + +"Update input of a Timestamp value" +input Timestamp_Update { + set: Timestamp @fdc_oneOf(group: "set") + set_expr: Timestamp_Expr @fdc_oneOf(group: "set") + inc: Timestamp_Offset + dec: Timestamp_Offset +} + +"Update input of a Timestamp list value" +input Timestamp_ListUpdate { + set: [Timestamp!] + append: [Timestamp!] + prepend: [Timestamp!] + delete: Int + i: Int + update: Timestamp +} + +"Update input of an Any value" +input Any_Update { + set: Any +} + +"Update input of an Any list value" +input Any_ListUpdate { + set: [Any!] + append: [Any!] + prepend: [Any!] + delete: Int + i: Int + update: Any +} + +"Update input of an AuthUID value" +input AuthUID_Update @fdc_deprecated { + set: AuthUID +} + +"Update input of an AuthUID list value" +input AuthUID_ListUpdate @fdc_deprecated { + set: [AuthUID] + append: [AuthUID] + prepend: [AuthUID] + delete: Int + i: Int + update: AuthUID +} + +"Update input of an Vector value" +input Vector_Update { + set: Vector +} + +"Update input of a Vector list value" +input Vector_ListUpdate { + set: [Vector] + append: [Vector] + prepend: [Vector] + delete: Int + i: Int + update: Vector +} diff --git a/packages/data-connect/test/dataconnect/connector/connector.yaml b/packages/data-connect/test/dataconnect/connector/connector.yaml new file mode 100644 index 00000000000..064d9c2c184 --- /dev/null +++ b/packages/data-connect/test/dataconnect/connector/connector.yaml @@ -0,0 +1,6 @@ +connectorId: "movies" +authMode: "PUBLIC" +generate: + javascriptSdk: + outputDir: "./gen/web" + package: "@movie-app-ssr/movies" diff --git a/packages/data-connect/test/dataconnect/connector/mutations.gql b/packages/data-connect/test/dataconnect/connector/mutations.gql new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/data-connect/test/dataconnect/connector/queries.gql b/packages/data-connect/test/dataconnect/connector/queries.gql new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/data-connect/test/dataconnect/dataconnect.yaml b/packages/data-connect/test/dataconnect/dataconnect.yaml new file mode 100644 index 00000000000..442e98e5592 --- /dev/null +++ b/packages/data-connect/test/dataconnect/dataconnect.yaml @@ -0,0 +1,11 @@ +specVersion: "v1beta" +serviceId: "dataconnect" +location: "us-west2" +schema: + source: "./schema" + datasource: + postgresql: + database: "dataconnect-test" + cloudSql: + instanceId: "local" +connectorDirs: ["./connector"] diff --git a/packages/data-connect/test/dataconnect/index.esm.js b/packages/data-connect/test/dataconnect/index.esm.js new file mode 100644 index 00000000000..6c7c8f8a49a --- /dev/null +++ b/packages/data-connect/test/dataconnect/index.esm.js @@ -0,0 +1,47 @@ +/** + * @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 { + getDataConnect, + queryRef, + mutationRef, + executeQuery, + executeMutation +} from 'firebase/data-connect'; + +export const connectorConfig = { + connector: 'test', + service: 'dataconnect', + location: 'us-central1' +}; + +function validateArgs(dcOrVars, vars, validateVars) { + let dcInstance; + let realVars; + // TODO(mtewani); Check what happens if this is undefined. + if (dcOrVars && 'dataConnectOptions' in dcOrVars) { + dcInstance = dcOrVars; + realVars = vars; + } else { + dcInstance = getDataConnect(connectorConfig); + realVars = dcOrVars; + } + if (!dcInstance || (!realVars && validateVars)) { + throw new Error('You didn\t pass in the vars!'); + } + return { dc: dcInstance, vars: realVars }; +} diff --git a/packages/data-connect/test/dataconnect/logAddMovieVariables.json b/packages/data-connect/test/dataconnect/logAddMovieVariables.json new file mode 100644 index 00000000000..92e237b649f --- /dev/null +++ b/packages/data-connect/test/dataconnect/logAddMovieVariables.json @@ -0,0 +1 @@ +{"Name":"addMovie","Kind":"mutation","Variables":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"name","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"genre","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"description","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"Response":[{"Name":"movie_insert","TypeName":"Movie_Key","TypeInfo":{"Name":"Movie_Key","Kind":"TypeKey","Fields":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":{"Name":"Movie_Key","Kind":"TypeKey","Fields":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"Attribute":"NonNull","DefaultValue":null,"Description":""}],"Description":"# Example mutations\n# TODO: Replace with a really good illustrative example from devrel!\nmutation createOrder($name: String!) {\n order_insert(data : {name: $name})\n}"} \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json b/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json new file mode 100644 index 00000000000..ec747fa47dd --- /dev/null +++ b/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json b/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json new file mode 100644 index 00000000000..ec747fa47dd --- /dev/null +++ b/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/movies.tools.json b/packages/data-connect/test/dataconnect/movies.tools.json new file mode 100644 index 00000000000..f6938f8c163 --- /dev/null +++ b/packages/data-connect/test/dataconnect/movies.tools.json @@ -0,0 +1,6 @@ +{ + "connector": "movies", + "location": "us-central1", + "service": "dataconnect", + "tools": [] +} \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/schema/schema.gql b/packages/data-connect/test/dataconnect/schema/schema.gql new file mode 100644 index 00000000000..1b9ca01d832 --- /dev/null +++ b/packages/data-connect/test/dataconnect/schema/schema.gql @@ -0,0 +1,23 @@ +# # Example schema +# # TODO: Replace with a really good illustrative example from devrel! +# type Product @table { +# name: String! +# price: Int! +# } + +# type Order @table { +# name: String! +# } + +# type OrderItem @table(key: ["order", "product"]) { +# order: Order! +# product: Product! +# quantity: Int! +# } +type Movie @table { + id: String! + name: String! + genre: String! + description: String! + test: String +} diff --git a/packages/data-connect/test/dataconnect/test.tools.json b/packages/data-connect/test/dataconnect/test.tools.json new file mode 100644 index 00000000000..a048e8da310 --- /dev/null +++ b/packages/data-connect/test/dataconnect/test.tools.json @@ -0,0 +1,6 @@ +{ + "connector": "test", + "location": "us-central1", + "service": "dataconnect", + "tools": [] +} \ No newline at end of file diff --git a/packages/data-connect/test/emulatorSeeder.ts b/packages/data-connect/test/emulatorSeeder.ts new file mode 100644 index 00000000000..1517deb90f8 --- /dev/null +++ b/packages/data-connect/test/emulatorSeeder.ts @@ -0,0 +1,68 @@ +/** + * @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 fs from 'fs'; +import * as path from 'path'; + +import { ReferenceType } from '../src'; + +import { EMULATOR_PORT } from './util'; + +export interface SeedInfo { + type: ReferenceType; + name: string; +} +export async function setupQueries( + schema: string, + seedInfoArray: SeedInfo[] +): Promise { + const schemaPath = path.resolve(__dirname, schema); + const schemaFileContents = fs.readFileSync(schemaPath).toString(); + const toWrite = { + 'service_id': 'l', + 'schema': { + 'files': [ + { + 'path': `schema/${schema}`, + 'content': schemaFileContents + } + ] + }, + 'connectors': { + 'c': { + 'files': seedInfoArray.map(seedInfo => { + const fileName = seedInfo.name + '.gql'; + const operationFilePath = path.resolve(__dirname, fileName); + const operationFileContents = fs + .readFileSync(operationFilePath) + .toString(); + return { + path: `operations/${seedInfo.name}.gql`, + content: operationFileContents + }; + }) + } + }, + // eslint-disable-next-line camelcase + connection_string: + 'postgresql://postgres:secretpassword@localhost:5432/postgres?sslmode=disable' + }; + return fetch(`http://localhost:${EMULATOR_PORT}/setupSchema`, { + method: 'POST', + body: JSON.stringify(toWrite) + }); +} diff --git a/packages/data-connect/test/integration/.graphqlrc.yaml b/packages/data-connect/test/integration/.graphqlrc.yaml new file mode 100644 index 00000000000..4953f9bd343 --- /dev/null +++ b/packages/data-connect/test/integration/.graphqlrc.yaml @@ -0,0 +1,9 @@ +schema: + - ./dataconnect/schema/**/*.gql + - ./dataconnect/.dataconnect/**/*.gql +documents: + - ./dataconnect/connector/**/*.gql +extensions: + endpoints: + default: + url: http://127.0.0.1:8080/__/graphql diff --git a/packages/data-connect/test/mutations.gql b/packages/data-connect/test/mutations.gql new file mode 100644 index 00000000000..a826a39529a --- /dev/null +++ b/packages/data-connect/test/mutations.gql @@ -0,0 +1,6 @@ +mutation seedDatabase($id: UUID!, $content: String!) @auth(level: PUBLIC) { + post: post_insert(data: {id: $id, content: $content}) +} +mutation removePost($id: UUID!) @auth(level: PUBLIC) { + post: post_delete(id: $id) +} \ No newline at end of file diff --git a/packages/data-connect/test/mutations.mutation.graphql b/packages/data-connect/test/mutations.mutation.graphql new file mode 100644 index 00000000000..9e86f1c1eae --- /dev/null +++ b/packages/data-connect/test/mutations.mutation.graphql @@ -0,0 +1,4 @@ +mutation seeddatabase @auth(level: PUBLIC) { + post1: post_insert(data: {content: "do dishes"}) + post2: post_insert(data: {content: "schedule appointment"}) +} \ No newline at end of file diff --git a/packages/data-connect/test/post.gql b/packages/data-connect/test/post.gql new file mode 100644 index 00000000000..d483ec10130 --- /dev/null +++ b/packages/data-connect/test/post.gql @@ -0,0 +1,18 @@ +query getPost($id: UUID!) @auth(level: PUBLIC) { + post(id: $id) { + content + } +} +query listPosts @auth(level: PUBLIC) { + posts { + id, + content + } +} +query listPosts2 { + posts { + id, + content + } +} + diff --git a/packages/data-connect/test/queries.schema.gql b/packages/data-connect/test/queries.schema.gql new file mode 100644 index 00000000000..eb2c8ba86e0 --- /dev/null +++ b/packages/data-connect/test/queries.schema.gql @@ -0,0 +1 @@ +type Post @table {content: String!} \ No newline at end of file diff --git a/packages/data-connect/test/queries.test.ts b/packages/data-connect/test/queries.test.ts new file mode 100644 index 00000000000..dd7e4e6c9e3 --- /dev/null +++ b/packages/data-connect/test/queries.test.ts @@ -0,0 +1,216 @@ +/** + * @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 { uuidv4 } from '@firebase/util'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { + connectDataConnectEmulator, + DataConnect, + executeMutation, + executeQuery, + getDataConnect, + mutationRef, + QueryRef, + queryRef, + QueryResult, + SerializedRef, + subscribe, + terminate, + SOURCE_CACHE, + SOURCE_SERVER +} from '../src'; + +import { setupQueries } from './emulatorSeeder'; +import { getConnectionConfig, initDatabase, PROJECT_ID } from './util'; + +use(chaiAsPromised); + +interface Task { + id: string; + content: string; +} +interface TaskListResponse { + posts: Task[]; +} + +const SEEDED_DATA = [ + { + id: uuidv4(), + content: 'task 1' + }, + { + id: uuidv4(), + content: 'task 2' + } +]; +const REAL_DATA = SEEDED_DATA.map(obj => ({ + ...obj, + id: obj.id.replace(/-/g, '') +})); +function seedDatabase(instance: DataConnect): Promise { + // call mutation query that adds SEEDED_DATA to database + return new Promise((resolve, reject) => { + async function run(): Promise { + let idx = 0; + while (idx < SEEDED_DATA.length) { + const data = SEEDED_DATA[idx]; + const ref = mutationRef(instance, 'seedDatabase', data); + await executeMutation(ref); + idx++; + } + } + run().then(resolve, reject); + }); +} +async function deleteDatabase(instance: DataConnect): Promise { + for (let i = 0; i < SEEDED_DATA.length; i++) { + const data = SEEDED_DATA[i]; + const ref = mutationRef(instance, 'removePost', { id: data.id }); + await executeMutation(ref); + } +} + +describe('DataConnect Tests', async () => { + let dc: DataConnect; + beforeEach(async () => { + dc = initDatabase(); + await setupQueries('queries.schema.gql', [ + { type: 'query', name: 'post' }, + { type: 'mutation', name: 'mutations' } + ]); + await seedDatabase(dc); + }); + afterEach(async () => { + await deleteDatabase(dc); + await terminate(dc); + }); + it('Can get all posts', async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const taskListRes = await executeQuery(taskListQuery); + expect(taskListRes.data).to.deep.eq({ + posts: REAL_DATA + }); + }); + it(`instantly executes a query if one hasn't been subscribed to`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const promise = new Promise>( + (resolve, reject) => { + const unsubscribe = subscribe(taskListQuery, { + onNext: res => { + unsubscribe(); + resolve(res); + }, + onErr: () => { + unsubscribe(); + reject(res); + } + }); + } + ); + const res = await promise; + expect(res.data).to.deep.eq({ + posts: REAL_DATA + }); + expect(res.source).to.eq(SOURCE_SERVER); + }); + it(`returns the result source as cache when data already exists`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const queryResult = await executeQuery(taskListQuery); + const result = await waitForFirstEvent(taskListQuery); + expect(result.data).to.eq(queryResult.data); + expect(result.source).to.eq(SOURCE_CACHE); + }); + it(`returns the proper JSON when calling .toJSON()`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + await executeQuery(taskListQuery); + const result = await waitForFirstEvent(taskListQuery); + const serializedRef: SerializedRef = { + data: { + posts: REAL_DATA + }, + fetchTime: Date.now().toLocaleString(), + refInfo: { + connectorConfig: { + ...getConnectionConfig(), + projectId: PROJECT_ID + }, + name: taskListQuery.name, + variables: undefined + }, + source: SOURCE_CACHE + }; + expect(result.toJSON()).to.deep.eq(serializedRef); + expect(result.source).to.deep.eq(SOURCE_CACHE); + }); + it(`throws an error when the user can't connect to the server`, async () => { + // You can't point an existing data connect instance to a new emulator port, so we have to create a new one + const fakeInstance = getDataConnect({ + connector: 'wrong', + location: 'wrong', + service: 'wrong' + }); + connectDataConnectEmulator(fakeInstance, 'localhost', 3512); + const taskListQuery = queryRef(fakeInstance, 'listPosts'); + await expect(executeQuery(taskListQuery)).to.eventually.be.rejectedWith( + 'ECONNREFUSED' + ); + }); + it('throws an error with just the message when the server responds with an error', async () => { + const invalidTaskListQuery = queryRef(dc, 'listPosts2'); + const message = + 'unauthorized: you are not authorized to perform this operation'; + await expect( + executeQuery(invalidTaskListQuery) + ).to.eventually.be.rejectedWith(message); + }); +}); +async function waitForFirstEvent( + query: QueryRef +): Promise> { + return new Promise<{ + result: QueryResult; + unsubscribe: () => void; + }>((resolve, reject) => { + const onResult: (result: QueryResult) => void = ( + result: QueryResult + ) => { + setTimeout(() => { + resolve({ + result, + unsubscribe + }); + }); + }; + const unsubscribe = subscribe(query, { + onNext: onResult, + onErr: e => { + reject({ e, unsubscribe }); + } + }); + }).then( + ({ result, unsubscribe }) => { + unsubscribe(); + return result; + }, + ({ e, unsubscribe }) => { + unsubscribe(); + throw e; + } + ); +} diff --git a/packages/data-connect/test/unit/dataconnect.test.ts b/packages/data-connect/test/unit/dataconnect.test.ts new file mode 100644 index 00000000000..314c8a068dc --- /dev/null +++ b/packages/data-connect/test/unit/dataconnect.test.ts @@ -0,0 +1,53 @@ +/** + * @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 { deleteApp, initializeApp } from '@firebase/app'; +import { expect } from 'chai'; + +import { ConnectorConfig, getDataConnect } from '../../src'; + +describe('Data Connect Test', () => { + it('should throw an error if `projectId` is not provided', async () => { + const app = initializeApp({ projectId: undefined }, 'a'); + expect(() => + getDataConnect(app, { connector: 'c', location: 'l', service: 's' }) + ).to.throw( + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + await deleteApp(app); + }); + it('should not throw an error if `projectId` is provided', () => { + const projectId = 'p'; + initializeApp({ projectId }); + expect(() => + getDataConnect({ connector: 'c', location: 'l', service: 's' }) + ).to.not.throw( + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + const dc = getDataConnect({ connector: 'c', location: 'l', service: 's' }); + expect(dc.app.options.projectId).to.eq(projectId); + }); + it('should throw an error if `connectorConfig` is not provided', () => { + const projectId = 'p'; + initializeApp({ projectId }); + expect(() => getDataConnect({} as ConnectorConfig)).to.throw( + 'DC Option Required' + ); + const dc = getDataConnect({ connector: 'c', location: 'l', service: 's' }); + expect(dc.app.options.projectId).to.eq(projectId); + }); +}); diff --git a/packages/data-connect/test/unit/fetch.test.ts b/packages/data-connect/test/unit/fetch.test.ts new file mode 100644 index 00000000000..a50ac188724 --- /dev/null +++ b/packages/data-connect/test/unit/fetch.test.ts @@ -0,0 +1,73 @@ +/** + * @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 { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; + +import { dcFetch, initializeFetch } from '../../src/network/fetch'; +use(chaiAsPromised); +function mockFetch(json: object): void { + const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) + ); + initializeFetch(fakeFetchImpl); +} +describe('fetch', () => { + it('should throw an error with just the message when the server responds with an error with a message property in the body', async () => { + const message = 'Failed to connect to Postgres instance'; + mockFetch({ + code: 401, + message + }); + await expect( + dcFetch( + 'http://localhost', + {}, + {} as AbortController, + null, + null, + null, + false + ) + ).to.eventually.be.rejectedWith(message); + }); + it('should throw a stringified message when the server responds with an error without a message property in the body', async () => { + const message = 'Failed to connect to Postgres instance'; + const json = { + code: 401, + message1: message + }; + mockFetch(json); + await expect( + dcFetch( + 'http://localhost', + {}, + {} as AbortController, + null, + null, + null, + false + ) + ).to.eventually.be.rejectedWith(JSON.stringify(json)); + }); +}); diff --git a/packages/data-connect/test/unit/gmpid.test.ts b/packages/data-connect/test/unit/gmpid.test.ts new file mode 100644 index 00000000000..77b9f8bcac4 --- /dev/null +++ b/packages/data-connect/test/unit/gmpid.test.ts @@ -0,0 +1,84 @@ +/** + * @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 { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; +import { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { DataConnect, executeQuery, getDataConnect, queryRef } from '../../src'; +import { initializeFetch } from '../../src/network/fetch'; + +use(sinonChai); +const json = { + message: 'unauthorized' +}; +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); + +describe('GMPID Tests', () => { + let dc: DataConnect; + let app: FirebaseApp; + const APPID = 'MYAPPID'; + beforeEach(() => { + initializeFetch(fakeFetchImpl); + app = initializeApp({ projectId: 'p', appId: APPID }, 'fdsasdf'); // TODO(mtewani): Replace with util function + dc = getDataConnect(app, { connector: 'c', location: 'l', service: 's' }); + }); + afterEach(async () => { + await dc._delete(); + await deleteApp(app); + }); + it('should send a request with the corresponding gmpid if using the app id is specified', async () => { + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['x-firebase-gmpid']: APPID + } + } + ); + }); + it('should send a request with no gmpid if using the app id is not specified', async () => { + const app2 = initializeApp({ projectId: 'p' }, 'def'); // TODO(mtewani): Replace with util function + const dc2 = getDataConnect(app2, { + connector: 'c', + location: 'l', + service: 's' + }); + // @ts-ignore + await executeQuery(queryRef(dc2, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['x-firebase-gmpid']: APPID + } + } + ); + await dc2._delete(); + await deleteApp(app2); + }); +}); diff --git a/packages/data-connect/test/unit/queries.test.ts b/packages/data-connect/test/unit/queries.test.ts new file mode 100644 index 00000000000..68bd96268a6 --- /dev/null +++ b/packages/data-connect/test/unit/queries.test.ts @@ -0,0 +1,96 @@ +/** + * @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 { FirebaseAuthTokenData } from '@firebase/auth-interop-types'; +import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; + +import { DataConnectOptions } from '../../src'; +import { + AuthTokenListener, + AuthTokenProvider +} from '../../src/core/FirebaseAuthProvider'; +import { initializeFetch } from '../../src/network/fetch'; +import { RESTTransport } from '../../src/network/transport/rest'; +chai.use(chaiAsPromised); +const options: DataConnectOptions = { + connector: 'c', + location: 'l', + projectId: 'p', + service: 's' +}; +const INITIAL_TOKEN = 'initial token'; +class FakeAuthProvider implements AuthTokenProvider { + private token: string | null = INITIAL_TOKEN; + addTokenChangeListener(listener: AuthTokenListener): void {} + getToken(forceRefresh: boolean): Promise { + if (!forceRefresh) { + return Promise.resolve({ accessToken: this.token! }); + } + return Promise.resolve({ accessToken: 'testToken' }); + } + setToken(_token: string | null): void { + this.token = _token; + } +} +const json = { + message: 'unauthorized' +}; + +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); +describe('Queries', () => { + afterEach(() => { + fakeFetchImpl.resetHistory(); + }); + it('[QUERY] should retry auth whenever the fetcher returns with unauthorized', async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + await expect(rt.invokeQuery('test', null)).to.eventually.be.rejectedWith( + json.message + ); + expect(fakeFetchImpl.callCount).to.eq(2); + }); + it('[MUTATION] should retry auth whenever the fetcher returns with unauthorized', async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + await expect(rt.invokeMutation('test', null)).to.eventually.be.rejectedWith( + json.message + ); + expect(fakeFetchImpl.callCount).to.eq(2); + }); + it("should not retry auth whenever the fetcher returns with unauthorized and the token doesn't change", async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + rt._setLastToken('initial token'); + await expect( + rt.invokeQuery('test', null) as Promise + ).to.eventually.be.rejectedWith(json.message); + expect(fakeFetchImpl.callCount).to.eq(1); + }); +}); diff --git a/packages/data-connect/test/unit/userAgent.test.ts b/packages/data-connect/test/unit/userAgent.test.ts new file mode 100644 index 00000000000..d218969fb75 --- /dev/null +++ b/packages/data-connect/test/unit/userAgent.test.ts @@ -0,0 +1,77 @@ +/** + * @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 { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; +import { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { DataConnect, executeQuery, getDataConnect, queryRef } from '../../src'; +import { SDK_VERSION } from '../../src/core/version'; +import { initializeFetch } from '../../src/network/fetch'; + +use(sinonChai); +const json = { + message: 'unauthorized' +}; +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); + +describe('User Agent Tests', () => { + let dc: DataConnect; + let app: FirebaseApp; + beforeEach(() => { + initializeFetch(fakeFetchImpl); + app = initializeApp({ projectId: 'p' }, 'abc'); // TODO(mtewani): Replace with util function + dc = getDataConnect(app, { connector: 'c', location: 'l', service: 's' }); + }); + afterEach(async () => { + await dc._delete(); + await deleteApp(app); + }); + it('should send a request with the corresponding user agent if using the generated SDK', async () => { + dc._useGeneratedSdk(); + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['X-Goog-Api-Client']: 'gl-js/ fire/' + SDK_VERSION + ' web/gen' + } + } + ); + }); + it('should send a request with the corresponding user agent if not using the generated SDK', async () => { + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['X-Goog-Api-Client']: 'gl-js/ fire/' + SDK_VERSION + } + } + ); + }); +}); diff --git a/packages/data-connect/test/unit/utils.test.ts b/packages/data-connect/test/unit/utils.test.ts new file mode 100644 index 00000000000..c69c1c8f511 --- /dev/null +++ b/packages/data-connect/test/unit/utils.test.ts @@ -0,0 +1,48 @@ +/** + * @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 { expect } from 'chai'; + +import { getDataConnect } from '../../src'; +import { validateArgs } from '../../src/util/validateArgs'; +describe('Utils', () => { + it('[Vars required: true] should throw if no arguments are provided', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + expect(() => + validateArgs(connectorConfig, undefined, undefined, true) + ).to.throw('Variables required'); + }); + it('[vars required: false, vars provided: false] should return data connect instance and no variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + expect(validateArgs(connectorConfig)).to.deep.eq({ dc, vars: undefined }); + }); + it('[vars required: false, vars provided: false, data connect provided: true] should return data connect instance and no variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + expect(validateArgs(connectorConfig, dc)).to.deep.eq({ + dc, + vars: undefined + }); + }); + it('[vars required: true, vars provided: true, data connect provided: true] should return data connect instance and variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + const vars = { a: 1 }; + expect(validateArgs(connectorConfig, dc, vars)).to.deep.eq({ dc, vars }); + }); +}); diff --git a/packages/data-connect/test/util.ts b/packages/data-connect/test/util.ts new file mode 100644 index 00000000000..cd9149ed41e --- /dev/null +++ b/packages/data-connect/test/util.ts @@ -0,0 +1,53 @@ +/** + * @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 { initializeApp } from '@firebase/app'; + +import { + connectDataConnectEmulator, + ConnectorConfig, + DataConnect, + getDataConnect +} from '../src'; + +export const USE_EMULATOR = true; +export const EMULATOR_PORT = process.env.DC_EMULATOR_PORT; +// export const EMULATOR_PROJECT = process.env.PROJECT; +export const CONNECTOR_NAME = 'c'; +export const LOCATION_NAME = 'l'; +export const SERVICE_NAME = 'l'; +export const PROJECT_ID = 'p'; +export function getConnectionConfig(): ConnectorConfig { + return { + connector: CONNECTOR_NAME, + location: LOCATION_NAME, + service: SERVICE_NAME + }; +} + +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()); + if (!instance.isEmulator) { + connectDataConnectEmulator(instance, 'localhost', Number(EMULATOR_PORT)); + } + return instance; +} diff --git a/packages/data-connect/tsconfig.eslint.json b/packages/data-connect/tsconfig.eslint.json new file mode 100644 index 00000000000..09f747b4d46 --- /dev/null +++ b/packages/data-connect/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/data-connect/tsconfig.json b/packages/data-connect/tsconfig.json new file mode 100644 index 00000000000..838f5c0d3c3 --- /dev/null +++ b/packages/data-connect/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "strict": false + }, + "exclude": [ + "dist/**/*", + "test/**/*" + ] +} diff --git a/packages/database-compat/package.json b/packages/database-compat/package.json index be50b28f6fa..b329f92e2e7 100644 --- a/packages/database-compat/package.json +++ b/packages/database-compat/package.json @@ -57,7 +57,7 @@ "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "typescript": "4.7.4" }, "repository": { diff --git a/packages/database/package.json b/packages/database/package.json index 14f30231f17..f917c5df2d7 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -57,7 +57,7 @@ "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "rollup-plugin-typescript2": "0.31.2", "typescript": "4.7.4" diff --git a/packages/firebase/CHANGELOG.md b/packages/firebase/CHANGELOG.md index bb0350f1640..e69286edfe2 100644 --- a/packages/firebase/CHANGELOG.md +++ b/packages/firebase/CHANGELOG.md @@ -1,5 +1,20 @@ # firebase +## 10.14.0 + +### Minor Changes + +- [`beaa4dffb`](https://github.com/firebase/firebase-js-sdk/commit/beaa4dffb7f48cb12ccc6c1d1b7cdc9c3605fc04) [#8480](https://github.com/firebase/firebase-js-sdk/pull/8480) - Included Data Connect product. + +### Patch Changes + +- Updated dependencies [[`beaa4dffb`](https://github.com/firebase/firebase-js-sdk/commit/beaa4dffb7f48cb12ccc6c1d1b7cdc9c3605fc04), [`ff0475c41`](https://github.com/firebase/firebase-js-sdk/commit/ff0475c41bfdac19872934f68b7f4e2651fd9a63), [`47b091324`](https://github.com/firebase/firebase-js-sdk/commit/47b09132463d6a038b441d4623c24ca61e56505d)]: + - @firebase/app@0.10.12 + - @firebase/data-connect@0.1.0 + - @firebase/firestore@4.7.3 + - @firebase/app-compat@0.2.42 + - @firebase/firestore-compat@0.3.38 + ## 10.13.2 ### Patch Changes diff --git a/packages/firebase/data-connect/index.ts b/packages/firebase/data-connect/index.ts new file mode 100644 index 00000000000..cc3ce65c24a --- /dev/null +++ b/packages/firebase/data-connect/index.ts @@ -0,0 +1,18 @@ +/** + * @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. + */ + +export * from '@firebase/data-connect'; diff --git a/packages/firebase/data-connect/package.json b/packages/firebase/data-connect/package.json new file mode 100644 index 00000000000..950ea5670a5 --- /dev/null +++ b/packages/firebase/data-connect/package.json @@ -0,0 +1,7 @@ +{ + "name": "firebase/data-connect", + "main": "dist/index.cjs.js", + "browser": "dist/esm/index.esm.js", + "module": "dist/esm/index.esm.js", + "typings": "dist/data-connect/index.d.ts" +} \ No newline at end of file diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 1565ce91337..c853b15d7c7 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -1,6 +1,6 @@ { "name": "firebase", - "version": "10.13.2", + "version": "10.14.0", "description": "Firebase JavaScript library for web and Node.js", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", @@ -107,6 +107,18 @@ }, "default": "./database/dist/esm/index.esm.js" }, + "./data-connect": { + "types": "./data-connect/dist/data-connect/index.d.ts", + "node": { + "require": "./data-connect/dist/index.cjs.js", + "import": "./data-connect/dist/index.mjs" + }, + "browser": { + "require": "./data-connect/dist/index.cjs.js", + "import": "./data-connect/dist/esm/index.esm.js" + }, + "default": "./data-connect/dist/esm/index.esm.js" + }, "./firestore": { "types": "./firestore/dist/firestore/index.d.ts", "node": { @@ -387,15 +399,16 @@ "trusted-type-check": "tsec -p tsconfig.json --noEmit" }, "dependencies": { - "@firebase/app": "0.10.11", - "@firebase/app-compat": "0.2.41", + "@firebase/app": "0.10.12", + "@firebase/app-compat": "0.2.42", "@firebase/app-types": "0.9.2", "@firebase/auth": "1.7.9", "@firebase/auth-compat": "0.5.14", + "@firebase/data-connect": "0.1.0", "@firebase/database": "1.0.8", "@firebase/database-compat": "1.0.8", - "@firebase/firestore": "4.7.2", - "@firebase/firestore-compat": "0.3.37", + "@firebase/firestore": "4.7.3", + "@firebase/firestore-compat": "0.3.38", "@firebase/functions": "0.11.8", "@firebase/functions-compat": "0.3.14", "@firebase/installations": "0.6.9", @@ -446,7 +459,8 @@ "messaging", "messaging/sw", "database", - "vertexai" + "vertexai", + "data-connect" ], "typings": "empty.d.ts" } diff --git a/packages/firestore-compat/CHANGELOG.md b/packages/firestore-compat/CHANGELOG.md index ec4bda71559..bb3a65af17c 100644 --- a/packages/firestore-compat/CHANGELOG.md +++ b/packages/firestore-compat/CHANGELOG.md @@ -1,5 +1,12 @@ # @firebase/firestore-compat +## 0.3.38 + +### Patch Changes + +- Updated dependencies [[`ff0475c41`](https://github.com/firebase/firebase-js-sdk/commit/ff0475c41bfdac19872934f68b7f4e2651fd9a63), [`47b091324`](https://github.com/firebase/firebase-js-sdk/commit/47b09132463d6a038b441d4623c24ca61e56505d)]: + - @firebase/firestore@4.7.3 + ## 0.3.37 ### Patch Changes diff --git a/packages/firestore-compat/package.json b/packages/firestore-compat/package.json index 2994067a633..799a13fcd99 100644 --- a/packages/firestore-compat/package.json +++ b/packages/firestore-compat/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/firestore-compat", - "version": "0.3.37", + "version": "0.3.38", "description": "The Cloud Firestore component of the Firebase JS SDK.", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.node.cjs.js", @@ -47,13 +47,13 @@ }, "dependencies": { "@firebase/component": "0.6.9", - "@firebase/firestore": "4.7.2", + "@firebase/firestore": "4.7.3", "@firebase/util": "1.10.0", "@firebase/firestore-types": "3.0.2", "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "@types/eslint": "7.29.0", "rollup": "2.79.1", "rollup-plugin-sourcemaps": "0.6.3", diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index 5fddaa183b1..dd17934f0d4 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -1,5 +1,13 @@ # @firebase/firestore +## 4.7.3 + +### Patch Changes + +- [`ff0475c41`](https://github.com/firebase/firebase-js-sdk/commit/ff0475c41bfdac19872934f68b7f4e2651fd9a63) [#8259](https://github.com/firebase/firebase-js-sdk/pull/8259) - Re-enable useFetchStreams with the latest WebChannel implementation. This reduces the memory usage of WebChannel. + +- [`47b091324`](https://github.com/firebase/firebase-js-sdk/commit/47b09132463d6a038b441d4623c24ca61e56505d) [#8430](https://github.com/firebase/firebase-js-sdk/pull/8430) - Refactor Firestore client instantiation. This prepares for future features that require client to restart. + ## 4.7.2 ### Patch Changes diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 5268cad97cb..0257d89ae02 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/firestore", - "version": "4.7.2", + "version": "4.7.3", "engines": { "node": ">=18.0.0" }, @@ -106,8 +106,8 @@ "@firebase/app": "0.x" }, "devDependencies": { - "@firebase/app": "0.10.11", - "@firebase/app-compat": "0.2.41", + "@firebase/app": "0.10.12", + "@firebase/app-compat": "0.2.42", "@firebase/auth": "1.7.9", "@rollup/plugin-alias": "5.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/functions-compat/package.json b/packages/functions-compat/package.json index e51aa90e791..2408cc55058 100644 --- a/packages/functions-compat/package.json +++ b/packages/functions-compat/package.json @@ -29,7 +29,7 @@ "@firebase/app-compat": "0.x" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "rollup": "2.79.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.31.2", diff --git a/packages/functions/package.json b/packages/functions/package.json index c739600b5ed..2a42321b2cc 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -49,7 +49,7 @@ "@firebase/app": "0.x" }, "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.31.2", diff --git a/packages/installations-compat/package.json b/packages/installations-compat/package.json index 44115221348..1d37289c9fa 100644 --- a/packages/installations-compat/package.json +++ b/packages/installations-compat/package.json @@ -44,7 +44,7 @@ "url": "https://github.com/firebase/firebase-js-sdk/issues" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "rollup": "2.79.1", "@rollup/plugin-commonjs": "21.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/installations/package.json b/packages/installations/package.json index e6093c759d6..c7d2206744c 100644 --- a/packages/installations/package.json +++ b/packages/installations/package.json @@ -49,7 +49,7 @@ "url": "https://github.com/firebase/firebase-js-sdk/issues" }, "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "@rollup/plugin-commonjs": "21.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/messaging-compat/package.json b/packages/messaging-compat/package.json index 628d5801668..e84620cbd1f 100644 --- a/packages/messaging-compat/package.json +++ b/packages/messaging-compat/package.json @@ -44,7 +44,7 @@ "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.31.2", "ts-essentials": "9.3.0", diff --git a/packages/messaging/package.json b/packages/messaging/package.json index e191118271f..3253fe0e16d 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -60,7 +60,7 @@ "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "rollup-plugin-typescript2": "0.31.2", "@rollup/plugin-json": "4.1.0", diff --git a/packages/messaging/src/helpers/logToFirelog.test.ts b/packages/messaging/src/helpers/logToFirelog.test.ts index 04e201bfc3a..fcc3402dd19 100644 --- a/packages/messaging/src/helpers/logToFirelog.test.ts +++ b/packages/messaging/src/helpers/logToFirelog.test.ts @@ -31,10 +31,7 @@ import { MessagingService } from '../messaging-service'; import { Stub } from '../testing/sinon-types'; import { getFakeMessagingService } from '../testing/fakes/messaging-service'; -const FIRELOG_ENDPOINT = LogModule._mergeStrings( - 'hts/frbslgigp.ogepscmv/ieo/eaylg', - 'tp:/ieaeogn-agolai.o/1frlglgc/o' -); +const LOG_ENDPOINT = 'https://play.google.com/log?format=json_proto3'; const FCM_TRANSPORT_KEY = LogModule._mergeStrings( 'AzSCbw63g1R0nCw85jG8', @@ -68,7 +65,7 @@ describe('logToFirelog', () => { // assert expect(fetchStub).to.be.calledOnceWith( - FIRELOG_ENDPOINT.concat('?key=', FCM_TRANSPORT_KEY), + LOG_ENDPOINT.concat('&key=', FCM_TRANSPORT_KEY), { method: 'POST', body: JSON.stringify(LogModule._createLogRequest([getFakeLogEvent()])) diff --git a/packages/messaging/src/helpers/logToFirelog.ts b/packages/messaging/src/helpers/logToFirelog.ts index 86b334ca91b..0e9ab096d87 100644 --- a/packages/messaging/src/helpers/logToFirelog.ts +++ b/packages/messaging/src/helpers/logToFirelog.ts @@ -36,10 +36,7 @@ import { import { MessagePayloadInternal } from '../interfaces/internal-message-payload'; import { MessagingService } from '../messaging-service'; -const FIRELOG_ENDPOINT = _mergeStrings( - 'hts/frbslgigp.ogepscmv/ieo/eaylg', - 'tp:/ieaeogn-agolai.o/1frlglgc/o' -); +const LOG_ENDPOINT = 'https://play.google.com/log?format=json_proto3'; const FCM_TRANSPORT_KEY = _mergeStrings( 'AzSCbw63g1R0nCw85jG8', @@ -97,7 +94,7 @@ export async function _dispatchLogEvents( do { try { response = await fetch( - FIRELOG_ENDPOINT.concat('?key=', FCM_TRANSPORT_KEY), + LOG_ENDPOINT.concat('&key=', FCM_TRANSPORT_KEY), { method: 'POST', body: JSON.stringify(logRequest) @@ -216,7 +213,9 @@ function createAndEnqueueLogEvent( /* eslint-disable camelcase */ logEvent.event_time_ms = Math.floor(Date.now()).toString(); - logEvent.source_extension_json_proto3 = JSON.stringify(fcmEvent); + logEvent.source_extension_json_proto3 = JSON.stringify({ + messaging_client_event: fcmEvent + }); if (!!productId) { logEvent.compliance_data = buildComplianceData(productId); diff --git a/packages/performance-compat/package.json b/packages/performance-compat/package.json index 0e657021ff6..acf1638d4ed 100644 --- a/packages/performance-compat/package.json +++ b/packages/performance-compat/package.json @@ -51,7 +51,7 @@ "rollup-plugin-replace": "2.2.0", "rollup-plugin-typescript2": "0.31.2", "typescript": "4.7.4", - "@firebase/app-compat": "0.2.41" + "@firebase/app-compat": "0.2.42" }, "repository": { "directory": "packages/performance-compat", diff --git a/packages/performance/package.json b/packages/performance/package.json index 7748cf3d3aa..9d4eaacff74 100644 --- a/packages/performance/package.json +++ b/packages/performance/package.json @@ -46,7 +46,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.31.2", diff --git a/packages/remote-config-compat/package.json b/packages/remote-config-compat/package.json index 40c7bd6fc9c..1d58d21a350 100644 --- a/packages/remote-config-compat/package.json +++ b/packages/remote-config-compat/package.json @@ -50,7 +50,7 @@ "rollup-plugin-replace": "2.2.0", "rollup-plugin-typescript2": "0.31.2", "typescript": "4.7.4", - "@firebase/app-compat": "0.2.41" + "@firebase/app-compat": "0.2.42" }, "repository": { "directory": "packages/remote-config-compat", diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index f089e972b49..7efabc1a62f 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -48,7 +48,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "rollup-plugin-typescript2": "0.31.2", "typescript": "4.7.4" diff --git a/packages/storage-compat/package.json b/packages/storage-compat/package.json index 35bb7d57f9a..4faa1c287d2 100644 --- a/packages/storage-compat/package.json +++ b/packages/storage-compat/package.json @@ -44,7 +44,7 @@ "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app-compat": "0.2.41", + "@firebase/app-compat": "0.2.42", "@firebase/auth-compat": "0.5.14", "rollup": "2.79.1", "@rollup/plugin-json": "4.1.0", diff --git a/packages/storage/package.json b/packages/storage/package.json index 2ca5f2fff12..8fe795cdc04 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -54,7 +54,7 @@ "@firebase/app": "0.x" }, "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "@firebase/auth": "1.7.9", "rollup": "2.79.1", "@rollup/plugin-alias": "5.1.0", diff --git a/packages/template/package.json b/packages/template/package.json index 4c4e0b017ea..d03ed71e42e 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -48,7 +48,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "rollup": "2.79.1", "rollup-plugin-typescript2": "0.31.2", "typescript": "4.7.4" diff --git a/packages/vertexai/package.json b/packages/vertexai/package.json index 4881ea92381..381b6387cb6 100644 --- a/packages/vertexai/package.json +++ b/packages/vertexai/package.json @@ -56,7 +56,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.10.11", + "@firebase/app": "0.10.12", "@rollup/plugin-json": "4.1.0", "rollup": "2.79.1", "rollup-plugin-replace": "2.2.0", diff --git a/repo-scripts/prune-dts/tests/resolves-generics-through-inheritence.output.d.ts b/repo-scripts/prune-dts/tests/resolves-generics-through-inheritance.output.d.ts similarity index 100% rename from repo-scripts/prune-dts/tests/resolves-generics-through-inheritence.output.d.ts rename to repo-scripts/prune-dts/tests/resolves-generics-through-inheritance.output.d.ts diff --git a/repo-scripts/size-analysis/package.json b/repo-scripts/size-analysis/package.json index e0712390298..a45792981f7 100644 --- a/repo-scripts/size-analysis/package.json +++ b/repo-scripts/size-analysis/package.json @@ -42,7 +42,7 @@ "license": "Apache-2.0", "devDependencies": { "@firebase/logger": "0.4.2", - "@firebase/app": "0.10.11" + "@firebase/app": "0.10.12" }, "repository": { "directory": "repo-scripts/size-analysis", diff --git a/scripts/ci-test/testConfig.ts b/scripts/ci-test/testConfig.ts index 5f458a07605..0202c699154 100644 --- a/scripts/ci-test/testConfig.ts +++ b/scripts/ci-test/testConfig.ts @@ -28,11 +28,25 @@ export interface TestConfig { alwaysIncludePackages?: string[]; } +// These tests are flaky on WebkitHeadless for some reason, so skip them. +// TODO (dlarocque): Fix the flakes and remove this +const ignoredWebkitCoreTests = process.env?.BROWSERS?.includes('WebkitHeadless') + ? [ + '@firebase/app-check', + '@firebase/installations', + '@firebase/storage', + '@firebase/storage-compat', + '@firebase/database', + '@firebase/database-compat' + ] + : []; + export const testConfig: { [key: string]: TestConfig | undefined; } = { 'core': { 'ignorePackages': [ + ...ignoredWebkitCoreTests, '@firebase/firestore', '@firebase/firestore-compat', 'firebase-firestore-integration-test', diff --git a/scripts/docgen/docgen.ts b/scripts/docgen/docgen.ts index ab335256145..113e4a6b339 100644 --- a/scripts/docgen/docgen.ts +++ b/scripts/docgen/docgen.ts @@ -37,7 +37,12 @@ https://github.com/firebase/firebase-js-sdk `; const tmpDir = `${projectRoot}/temp`; -const EXCLUDED_PACKAGES = ['app-compat', 'util', 'rules-unit-testing']; +const EXCLUDED_PACKAGES = [ + 'app-compat', + 'util', + 'rules-unit-testing', + 'data-connect' +]; /** * When ordering functions, will prioritize these first params at diff --git a/scripts/emulator-testing/dataconnect-test-runner.ts b/scripts/emulator-testing/dataconnect-test-runner.ts new file mode 100644 index 00000000000..e362ef59cbe --- /dev/null +++ b/scripts/emulator-testing/dataconnect-test-runner.ts @@ -0,0 +1,47 @@ +/** + * @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 { DataConnectEmulator } from './emulators/dataconnect-emulator'; +import { spawn } from 'child-process-promise'; +import * as path from 'path'; +function runTest(port: number) { + console.log( + 'path: ' + path.resolve(__dirname, '../../packages/data-connect') + ); + const options = { + cwd: path.resolve(__dirname, '../../packages/data-connect'), + env: Object.assign({}, process.env, { + DC_EMULATOR_PORT: port + }), + stdio: 'inherit' as const + }; + return spawn('yarn', ['test:all'], options); +} +async function run(): Promise { + const emulator = new DataConnectEmulator(); + try { + await emulator.download(); + await emulator.setUp(); + await runTest(emulator.port); + } finally { + await emulator.tearDown(); + } +} +run().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/scripts/emulator-testing/emulators/dataconnect-emulator.ts b/scripts/emulator-testing/emulators/dataconnect-emulator.ts new file mode 100644 index 00000000000..efe5bdbe52c --- /dev/null +++ b/scripts/emulator-testing/emulators/dataconnect-emulator.ts @@ -0,0 +1,57 @@ +/** + * @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 { platform } from 'os'; +import { Emulator } from './emulator'; + +const DATABASE_EMULATOR_VERSION = '1.3.7'; + +export class DataConnectEmulator extends Emulator { + // namespace: string; + + constructor(port = 3628) { + const os = platform(); + let urlString = ''; + switch (os) { + case 'darwin': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-macos-v1.3.7?alt=media&token=2cf32435-d479-4929-b963-a97ae1ac3f0b'; + break; + case 'linux': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-linux-v1.3.7?alt=media&token=fd33b4fc-2e27-4874-893a-2d1f0ecbf116'; + break; + case 'win32': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-windows-v1.3.7?alt=media&token=bd6e60b0-50b4-46db-aa6c-5fcc6e991f39'; + break; + default: + throw new Error( + `We are unable to support your environment ${os} at this time.` + ); + } + super( + `cli-v${DATABASE_EMULATOR_VERSION}`, + // Use locked version of emulator for test to be deterministic. + // The latest version can be found from database emulator doc: + // https://firebase.google.com/docs/database/security/test-rules-emulator + urlString, + port + ); + this.isDataConnect = true; + } +} diff --git a/scripts/emulator-testing/emulators/emulator.ts b/scripts/emulator-testing/emulators/emulator.ts index ba69e049ad6..85f190bcba9 100644 --- a/scripts/emulator-testing/emulators/emulator.ts +++ b/scripts/emulator-testing/emulators/emulator.ts @@ -16,7 +16,11 @@ */ // @ts-ignore -import { spawn } from 'child-process-promise'; +import { + ChildProcessPromise, + spawn, + SpawnPromiseResult +} from 'child-process-promise'; import { ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -31,6 +35,8 @@ export abstract class Emulator { cacheDirectory: string; cacheBinaryPath: string; + isDataConnect = false; + constructor( private binaryName: string, private binaryUrl: string, @@ -113,19 +119,29 @@ export abstract class Emulator { if (!this.binaryPath) { throw new Error('You must call download() before setUp()'); } - const promise = spawn( - 'java', - [ - '-jar', - path.basename(this.binaryPath), - '--port', - this.port.toString() - ], - { - cwd: path.dirname(this.binaryPath), - stdio: 'inherit' - } - ); + let promise: ChildProcessPromise; + if (this.isDataConnect) { + promise = spawn(this.binaryPath, [ + 'dev', + '--local_connection_string', + "'postgresql://postgres:secretpassword@localhost:5432/postgres?sslmode=disable'" + ]); + } else { + promise = spawn( + 'java', + [ + '-jar', + path.basename(this.binaryPath), + '--port', + this.port.toString() + ], + { + cwd: path.dirname(this.binaryPath), + stdio: 'inherit' + } + ); + } + promise.catch(reject); this.emulator = promise.childProcess; diff --git a/yarn.lock b/yarn.lock index f3fc015f9ec..dcd2c209a99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8636,6 +8636,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2, fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^1.2.7: version "1.2.13" resolved "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz" @@ -8644,11 +8649,6 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@^2.3.2, fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - ftp@^0.3.10: version "0.3.10" resolved "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz" @@ -9565,7 +9565,14 @@ hasha@^5.0.0: is-stream "^2.0.0" type-fest "^0.8.0" -hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: +hasown@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa" + integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA== + dependencies: + function-bind "^1.1.2" + +hasown@^2.0.1, hasown@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -11315,11 +11322,6 @@ karma-mocha@2.0.1: dependencies: minimist "^1.2.3" -karma-safari-launcher@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/karma-safari-launcher/-/karma-safari-launcher-1.0.0.tgz" - integrity sha1-lpgqLMR9BmquccVTursoMZEVos4= - karma-sourcemap-loader@0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz" @@ -11390,6 +11392,14 @@ karma-typescript@5.5.4: util "^0.12.1" vm-browserify "^1.1.2" +karma-webkit-launcher@2.6.0: + version "2.6.0" + resolved "https://registry.npmjs.org/karma-webkit-launcher/-/karma-webkit-launcher-2.6.0.tgz#2e3ba096b69139e608d9ce6e89816a17f358b700" + integrity sha512-IDURopxJ1SbuqnvPaE+lP2qiP2Ie7I+ojwJRBpr0tfGwObsaVdjMkUkmZ1BcXUtYRt5ogs9cyCH2Wb9sNv0BbQ== + dependencies: + is-ci "^3.0.1" + uuid "^10.0.0" + karma-webpack@5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz" @@ -14095,6 +14105,20 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.46.1: + version "1.46.1" + resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz#28f3ab35312135dda75b0c92a3e5c0e7edb9cc8b" + integrity sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A== + +playwright@1.46.1: + version "1.46.1" + resolved "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz#ea562bc48373648e10420a10c16842f0b227c218" + integrity sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng== + dependencies: + playwright-core "1.46.1" + optionalDependencies: + fsevents "2.3.2" + plugin-error@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz" @@ -15116,7 +15140,7 @@ resolve@^1.22.0, resolve@^1.22.4: resolve@~1.17.0: version "1.17.0" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== dependencies: path-parse "^1.0.6" @@ -17628,6 +17652,11 @@ utils-merge@1.0.1: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz"