Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Added support for emulating multiple Firestore databases at once (#9742).
- Fixed an issue causing errors when multiple Firestore databases were configured in `firebase.json` (#8114)
49 changes: 24 additions & 25 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

/**
* Exports emulator data on clean exit (SIGINT or process end)
* @param options

Check warning on line 76 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc @param "options" description
*/
export async function exportOnExit(options: Options): Promise<void> {
// Note: options.exportOnExit is coerced to a string before this point in commandUtils.ts#setExportOnExitOptions
Expand All @@ -86,7 +86,7 @@
);
await exportEmulatorData(exportOnExitDir, options, /* initiatedBy= */ "exit");
} catch (e: unknown) {
utils.logWarning(`${e}`);

Check warning on line 89 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
utils.logWarning(`Automatic export to "${exportOnExitDir}" failed, going to exit now...`);
}
}
Expand All @@ -94,10 +94,10 @@

/**
* Hook to do things when we're exiting cleanly (this does not include errors). Will be skipped on a second SIGINT
* @param options

Check warning on line 97 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc @param "options" description
*/
export async function onExit(options: any) {

Check warning on line 99 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 99 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
await exportOnExit(options);

Check warning on line 100 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Options`
}

/**
Expand All @@ -116,9 +116,9 @@

/**
* Filters a list of emulators to only those specified in the config
* @param options

Check warning on line 119 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc @param "options" description

Check warning on line 119 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.config"

Check warning on line 119 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.only"
*/
export function filterEmulatorTargets(options: { only: string; config: any }): Emulators[] {

Check warning on line 121 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
let targets = [...ALL_SERVICE_EMULATORS];
targets.push(Emulators.EXTENSIONS);
targets = targets.filter((e) => {
Expand Down Expand Up @@ -671,10 +671,8 @@
}

const config = options.config;
// emulator does not support multiple databases yet
// TODO(VicVer09): b/269787702
let rulesLocalPath;
let rulesFileFound;
const rules: { database: string; rules: string }[] = [];
Copy link
Contributor

@aalej aalej Jan 21, 2026

Choose a reason for hiding this comment

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

I don't think the Firestore emulator binary accepts an array of objects for rules(I could be wrong though, I can't find any docs on how to use the binary).

Running the emulator with the code below(from what I can tell, this is what we do with the CLI):

minimal firestore emulator spawn code
const childProcess = require("node:child_process")

const BINARY_PATH = "/Users/alejandromarco/.cache/firebase/emulators/cloud-firestore-emulator-v1.20.2.jar"

let emulator = {}

emulator.instance = childProcess.spawn("java", [
    '-Dgoogle.cloud_firestore.debug_log_level=FINE',
    '-Duser.language=en',
    '-jar',
    BINARY_PATH,
    '--host',
    '127.0.0.1',
    '--port',
    8080,
    '--websocket_port',
    9150,
    '--project_id',
    'demo-project',
    '--rules',
    [
        {
            database: '(default)',
            rules: '/Users/alejandromarco/Desktop/firebase-tools/issues/9770/firestore.rules'
        },
        {
            database: 'non-default',
            rules: '/Users/alejandromarco/Desktop/firebase-tools/issues/9770/firestore.non-default.rules'
        }
    ],
    '--single_project_mode',
    true
])

emulator.instance.stderr?.on("data", (data) => {
    console.log("DEBUG", data.toString());
});

emulator.instance.once("exit", async (code, signal) => {
    if (signal) {
        console.log(`Firestore emulator has exited upon receiving signal: ${signal}`);
    } else if (code && code !== 0 && code !== /* SIGINT */ 130) {
        console.log("Firestore emulator", `has exited with code: ${code}`);
    }
});

Will raise an error java.io.FileNotFoundException: [object Object],[object Object] (No such file or directory)

error output
$ node .
DEBUG Jan 21, 2026 10:19:04 PM com.google.cloud.datastore.emulator.firestore.CloudFirestore main
SEVERE: Exiting due to unexpected exception.
java.io.FileNotFoundException: [object Object],[object Object] (No such file or directory)
        at java.base/java.io.FileInputStream.open0(Native Method)
        at java.base/java.io.FileInputStream.open(FileInputStream.java:213)
        at java.base/java.io.FileInputStream.<init>(FileInputStream.java:152)
        at com.google.common.io.Files$FileByteSource.openStream(Files.java:140)
        at com.google.common.io.Files$FileByteSource.read(Files.java:170)
        at com.google.common.io.ByteSource$AsCharSource.read(ByteSource.java:534)
        at com.google.cloud.datastore.emulator.firestore.CloudFirestore.init(CloudFirestore.java:186)
        at com.google.cloud.datastore.emulator.firestore.CloudFirestore.startLocally(CloudFirestore.java:120)
        at com.google.cloud.datastore.emulator.firestore.CloudFirestore.main(CloudFirestore.java:101)


DEBUG Exception in thread "main" java.lang.NoClassDefFoundError: com/google/common/util/LegacySystemExit
        at com.google.cloud.datastore.emulator.firestore.CloudFirestore.main(CloudFirestore.java:106)
Caused by: java.lang.ClassNotFoundException: com.google.common.util.LegacySystemExit
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
        at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:525)
        ... 1 more

Firestore emulator has exited with code: 1

Changing the code the use a string for rules makes the process run again

minimal firestore spawn code
const childProcess = require("node:child_process")

const BINARY_PATH = "/Users/alejandromarco/.cache/firebase/emulators/cloud-firestore-emulator-v1.20.2.jar"

let emulator = {}

emulator.instance = childProcess.spawn("java", [
    '-Dgoogle.cloud_firestore.debug_log_level=FINE',
    '-Duser.language=en',
    '-jar',
    BINARY_PATH,
    '--host',
    '127.0.0.1',
    '--port',
    8080,
    '--websocket_port',
    9150,
    '--project_id',
    'demo-project',
    '--rules',
    '/Users/alejandromarco/Desktop/firebase-tools/issues/9770/firestore.rules',
    '--single_project_mode',
    true
])

emulator.instance.stderr?.on("data", (data) => {
    console.log("DEBUG", data.toString());
});

emulator.instance.once("exit", async (code, signal) => {
    if (signal) {
        console.log(`Firestore emulator has exited upon receiving signal: ${signal}`);
    } else if (code && code !== 0 && code !== /* SIGINT */ 130) {
        console.log("Firestore emulator", `has exited with code: ${code}`);
    }
});

outputs

log output
$ node working.js 
DEBUG Jan 21, 2026 10:18:45 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start
INFO: Started WebSocket server on ws://127.0.0.1:9150

const firestoreConfigs: fsConfig.ParsedFirestoreConfig[] = fsConfig.getFirestoreConfig(
projectId,
options,
Expand All @@ -685,36 +683,37 @@
"firestore",
`Cloud Firestore config does not exist in firebase.json.`,
);
} else if (firestoreConfigs.length !== 1) {
firestoreLogger.logLabeled(
"WARN",
"firestore",
`Cloud Firestore Emulator does not support multiple databases yet.`,
);
} else if (firestoreConfigs[0].rules) {
rulesLocalPath = firestoreConfigs[0].rules;
}
if (rulesLocalPath) {
const rules: string = config.path(rulesLocalPath);
rulesFileFound = fs.existsSync(rules);
if (rulesFileFound) {
args.rules = rules;
} else {
firestoreLogger.logLabeled(
"WARN",
"firestore",
`Cloud Firestore rules file ${clc.bold(rules)} specified in firebase.json does not exist.`,
);
}
} else {
for (const firestoreConfig of firestoreConfigs) {
if (firestoreConfig.rules) {
const rulesLocalPath = firestoreConfig.rules;
const rulesAbsolutePath = config.path(rulesLocalPath);
rulesFileFound = fs.existsSync(rulesAbsolutePath);
if (rulesFileFound) {
rules.push({ database: firestoreConfig.database, rules: rulesAbsolutePath });
} else {
firestoreLogger.logLabeled(
"WARN",
"firestore",
`Cloud Firestore rules file ${clc.bold(
rulesAbsolutePath,
)} specified in firebase.json does not exist.`,
);
}
}
}
}
args.rules = rules;

if (rules.length === 0) {
firestoreLogger.logLabeled(
"WARN",
"firestore",
"Did not find a Cloud Firestore rules file specified in a firebase.json config file.",
);
}

if (!rulesFileFound) {
if (rules.length === 0) {
firestoreLogger.logLabeled(
"WARN",
"firestore",
Expand Down
53 changes: 29 additions & 24 deletions src/emulator/firestoreEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface FirestoreEmulatorArgs {
host?: string;
websocket_port?: number;
project_id?: string;
rules?: string;
rules?: { database: string; rules: string }[];
functions_emulator?: string;
auto_download?: boolean;
seed_from_export?: string;
Expand All @@ -42,28 +42,33 @@ export class FirestoreEmulator implements EmulatorInstance {
}

if (this.args.rules && this.args.project_id) {
const rulesPath = this.args.rules;
this.rulesWatcher = chokidar.watch(rulesPath, { persistent: true, ignoreInitial: true });
this.rulesWatcher.on("change", async () => {
// There have been some race conditions reported (on Windows) where reading the
// file too quickly after the watcher fires results in an empty file being read.
// Adding a small delay prevents that at very little cost.
await new Promise((res) => setTimeout(res, 5));

utils.logLabeledBullet("firestore", "Change detected, updating rules...");
const newContent = fs.readFileSync(rulesPath, "utf8").toString();
const issues = await this.updateRules(newContent);
if (issues) {
for (const issue of issues) {
utils.logWarning(this.prettyPrintRulesIssue(rulesPath, issue));
for (const c of this.args.rules) {
const rulesPath = c.rules;
this.rulesWatcher = chokidar.watch(rulesPath, { persistent: true, ignoreInitial: true });
this.rulesWatcher.on("change", async () => {
// There have been some race conditions reported (on Windows) where reading the
// file too quickly after the watcher fires results in an empty file being read.
// Adding a small delay prevents that at very little cost.
await new Promise((res) => setTimeout(res, 5));

utils.logLabeledBullet(
"firestore",
`Change detected, updating rules for ${c.database}...`,
);
const newContent = fs.readFileSync(rulesPath, "utf8").toString();
const issues = await this.updateRules(c.database, newContent);
if (issues) {
for (const issue of issues) {
utils.logWarning(this.prettyPrintRulesIssue(rulesPath, issue));
}
}
}
if (issues.some((issue) => issue.severity === Severity.ERROR)) {
utils.logWarning("Failed to update rules");
} else {
utils.logLabeledSuccess("firestore", "Rules updated.");
}
});
if (issues.some((issue) => issue.severity === Severity.ERROR)) {
utils.logWarning("Failed to update rules");
} else {
utils.logLabeledSuccess("firestore", "Rules updated.");
}
});
}
Comment on lines +45 to +71
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The this.rulesWatcher is reassigned within the for...of loop. This means that only the chokidar.watch instance created for the last firestoreConfig in the this.args.rules array will be active. Consequently, changes to rule files for other databases will not be detected or trigger updates. To correctly watch all rule files, each chokidar.watch instance should be stored in a collection (e.g., an array of FSWatcher instances) and then iterated over when stopping the emulator.

      const rulesWatchers: chokidar.FSWatcher[] = [];
      for (const c of this.args.rules) {
        const rulesPath = c.rules;
        const watcher = chokidar.watch(rulesPath, { persistent: true, ignoreInitial: true });
        watcher.on("change", async () => {
          // There have been some race conditions reported (on Windows) where reading the
          // file too quickly after the watcher fires results in an empty file being read.
          // Adding a small delay prevents that at very little cost.
          await new Promise((res) => setTimeout(res, 5));

          utils.logLabeledBullet(
            "firestore",
            `Change detected, updating rules for ${c.database}...`
          );
          const newContent = fs.readFileSync(rulesPath, "utf8").toString();
          const issues = await this.updateRules(c.database, newContent);
          if (issues) {
            for (const issue of issues) {
              utils.logWarning(this.prettyPrintRulesIssue(rulesPath, issue));
            }
          }
          if (issues.some((issue) => issue.severity === Severity.ERROR)) {
            utils.logWarning("Failed to update rules");
          } else {
            utils.logLabeledSuccess("firestore", "Rules updated.");
          }
        });
        rulesWatchers.push(watcher);
      }
      this.rulesWatcher = rulesWatchers; // Update the type of rulesWatcher to be FSWatcher[]

}

return downloadableEmulators.start(Emulators.FIRESTORE, this.args);
Expand Down Expand Up @@ -101,7 +106,7 @@ export class FirestoreEmulator implements EmulatorInstance {
return Emulators.FIRESTORE;
}

private async updateRules(content: string): Promise<Issue[]> {
private async updateRules(databaseId: string, content: string): Promise<Issue[]> {
const projectId = this.args.project_id;

const body = {
Expand All @@ -118,7 +123,7 @@ export class FirestoreEmulator implements EmulatorInstance {
};

const res = await EmulatorRegistry.client(Emulators.FIRESTORE).put<any, { issues?: Issue[] }>(
`/emulator/v1/projects/${projectId}:securityRules`,
`/emulator/v1/projects/${projectId}/databases/${databaseId}:securityRules`,
body,
);
if (res.body && Array.isArray(res.body.issues)) {
Expand Down
50 changes: 50 additions & 0 deletions src/firestore/fsConfig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect } from "chai";
import { getFirestoreConfig } from "./fsConfig";

describe("getFirestoreConfig", () => {
it("should return all configs when firestore:indexes is specified in only", () => {
const options: any = {
config: {
src: {
firestore: [
{ database: "(default)", rules: "firestore.rules" },
{ database: "second", rules: "firestore.second.rules" },
],
},
},
rc: {
requireTarget: () => {
return;
},
target: () => [],
},
only: "firestore:indexes",
};

const result = getFirestoreConfig("project", options);
expect(result).to.have.length(2);
});

it("should return all configs when firestore:rules is specified in only", () => {
const options: any = {
config: {
src: {
firestore: [
{ database: "(default)", rules: "firestore.rules" },
{ database: "second", rules: "firestore.second.rules" },
],
},
},
rc: {
requireTarget: () => {
return;
},
target: () => [],
},
only: "firestore:rules",
};

const result = getFirestoreConfig("project", options);
expect(result).to.have.length(2);
});
});
10 changes: 10 additions & 0 deletions src/firestore/fsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ export function getFirestoreConfig(projectId: string, options: Options): ParsedF
}
}

// If user specifies firestore:rules or firestore:indexes make sure we don't throw an error if this doesn't match a database name
if (onlyDatabases.has("rules")) {
onlyDatabases.delete("rules");
allDatabases = true;
}
if (onlyDatabases.has("indexes")) {
onlyDatabases.delete("indexes");
allDatabases = true;
}

const results: ParsedFirestoreConfig[] = [];
for (const c of fsConfig) {
const { database, target } = c;
Expand Down
Loading