Data Operations
Since v2.1.0Custom Scripts
Write small JavaScript snippets that run inside the app to compute values in bulk updates, to reshape documents during export and import, and to transform Firebase Authentication users during users export and import. Scripts can query the database, call external APIs and generate IDs — with clear limits and a per-job error policy.
What it is
Some transformations cannot be expressed with a fixed value or a built-in conversion: joining data from another collection, calling an external API, normalizing a value with your own rules. For those cases Fuego lets you write a custom script — a small piece of JavaScript executed by an embedded engine inside the app, once per document.
Custom scripts are available in five flows:
| Flow | Where | The script returns |
|---|---|---|
| Update documents | The Custom script value type, and Use custom function in Convert value | The new value for the target field |
| Export collection | The Transform script panel | The object written to the file |
| Import collection | The Transform script panel | The data to import |
| Export users | The Transform script panel | The object written to the file |
| Import users | The Transform script panel | The row to import |
In every flow, returning undefined means “do nothing here”: leave the
document unchanged (update), omit the document or user (export), or skip the
row (import).
Writing a script
Scripts are written as a body: statements and a top-level return, no
function wrapper needed. await is allowed.
const data = doc.data();
if (!data.email) return FieldValue.delete();
return data.email.trim().toLowerCase();
Two rules to keep in mind:
- Only the async APIs provided by Fuego (
db,database(),fetch) actually resolve.setTimeoutdoes not exist, and awaiting any other promise source fails the script with “script did not resolve synchronously”. - Each invocation has a 30 second timeout.
The editor is a full Monaco editor with autocomplete for every injected global, and syntax errors are checked live by the same engine that runs the job.
You are not left guessing what is available:
- Every script editor starts with a foldable comment header documenting the globals of that flow, with examples — fold it away once you know the API.
- The question-mark button above the editor opens the Script reference dialog, listing the data available in this script, the available functions and complete examples.
- The folder button opens the Manage scripts dialog (see Script library below).
The Test button at the bottom of every editor runs the script against real data without writing anything — see Testing a script below.
Testing a script
Every script editor — inline and fullscreen — has a Test button at the bottom. It opens a dialog that runs the script against the first documents the job would process: up to 10, or fewer if the query has a smaller limit. For the import flow, the test runs against the first rows of the import file, parsed exactly like the real import — including CSV “skip conversion” columns — and empty rows are reported as errors, just as in the real job.
Each row of the result shows the document (or row) path and one of:
- the script’s output, rendered as JSON;
- a Skipped tag, when the script returned
undefined; - the error message, when the script failed on that document.
Database writes are always disabled during a test, so a test never touches your data. A re-run button executes the test again after you edit the script.
For exports the test shapes data exactly like the real export before running
the script: with Anonymize data enabled it masks the values (scripts never
see unmasked ones), and with Include all attributes off it narrows the row
to the picked attributes. The preview therefore matches the exported file.
In the users flows the test works the same way: the users export test runs against the first 10 users of the selected tenant, with each row shaped by the current export options; the users import test runs against the first 10 rows of the file, and also shows the error the import would report for each transformed row. Writes stay disabled here too.
Globals per flow
Each flow injects its own inputs on top of the common globals:
| Flow | Globals |
|---|---|
Update — Custom script | doc (the document), field (the target field path) |
Update — Use custom function | value (the current field value), plus doc and field |
Export — Transform script | doc (the whole document, whatever the attribute selection), data (the row the export options produce: masked when anonymization is on, narrowed to the picked attributes) |
Import — Transform script | docId, collection, data (the raw file row, including Fuego entity values such as {"__time__": …}) |
Users export — Transform script | user (the Firebase Auth user record), data (the row as the export options serialize it) |
Users import — Transform script | uid (the user id read from the row, may be empty), data (the raw file row) |
doc behaves like a Firestore DocumentSnapshot: doc.id, doc.exists,
doc.ref, doc.data(), doc.get('a.b'), and doc.createTime /
doc.updateTime / doc.readTime as Date.
Firebase Authentication users
The same transform scripts are available in the users export and import jobs: enable Transform each user with a custom script in the Export users or Import users dialog.
Users export
The script runs once per user and receives:
user— the Firebase Auth user record, in the same shape as the Node Admin SDK’sUserRecord:uid,email,emailVerified,displayName,photoURL,phoneNumber,disabled,tenantId,customClaims,providerData,passwordHash,passwordSalt, andmetadata.creationTime/metadata.lastSignInTime/metadata.lastRefreshTimeasDatevalues.data— the row exactly as the export options serialize it (format, included columns, timestamp format).
Return the object to write to the file; return undefined to omit the user
from the export.
// Only active users, with a claim-derived column
if (user.disabled) return undefined;
return { ...data, admin: !!get(user, 'customClaims.admin') };
Users import
The script runs on each file row before the row is parsed into a user, and receives:
uid— the user id read from the row (an empty string when the row has none).data— the raw file row, with the same field names as the file:uid,email,emailVerified,customClaims,__passwordHash__,__creationTime__, and so on.
Because the script runs before parsing, the returned object uses the same
field vocabulary as the file. Return the row to import; return undefined to
skip it. The import preview runs the script too, so the preview numbers
already reflect skipped and transformed rows.
// Normalize emails and skip rows without one
if (!data.email) return undefined;
return { ...data, email: data.email.trim().toLowerCase() };
Everything else on this page applies to the users flows as well: the same
editor (starter template, autocomplete, Script reference dialog, fullscreen),
the script library — with the dedicated Users export
and Users import contexts — the db / database() and fetch access,
the helpers, and the Test button.
Common globals
These are available in every script, in every flow.
db — the job’s database
A Node-Admin-SDK-like surface over the database the job runs on:
// Queries
const snap = await db
.collection('users')
.where('age', '>=', 18)
.orderBy('name')
.limit(10)
.select('name', 'email')
.get(); // QuerySnapshot: .docs, .empty, .size, .forEach
const one = await db.doc('users/u1').get(); // DocumentSnapshot
const group = await db.collectionGroup('posts').where('draft', '==', false).get();
// Writes
await db.collection('logs').add({ at: new Date() });
await db.doc('users/u1').set({ active: true }, { merge: true });
await db.doc('users/u1').update({ 'stats.visits': 10 });
await db.doc('users/u1').delete();
- Queries without a
limit()are capped at 1000 documents. - Writes respect the database’s read-only flag and refuse anonymized data.
- There are no batches, transactions or
count()aggregations yet.
database(id) — another database
Returns the same surface as db for another database of the same project:
const staging = database('staging');
const snap = await staging.collection('users').get();
fetch(url, options) — HTTP requests
const res = await fetch('https://api.example.com/rates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currency: 'EUR' }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rates = await res.json(); // also: await res.text(), res.headers.get('x-…')
Response bodies are capped at 20 MB.
util — ID and number helpers
| Function | Returns |
|---|---|
util.uuid() | UUID v4 |
util.uuidv7() | UUID v7 (time sorted) |
util.ksuid() | KSUID (K-sortable unique ID) |
util.nanoid() | NanoID |
util.firestoreId() | A Firestore-style random document ID |
util.randomNumber(min, max) | A random number in the given range |
get, pick, omit — object helpers
Lodash-like helpers for reshaping plain objects, handy in every flow:
get(data, 'profile.full_name', ''); // safe deep read with a default
return pick(data, 'title', 'owner.email'); // keep only the listed paths
return { ...omit(data, 'internalNotes'), exportedAt: new Date() };
get(object, path, defaultValue)supports lodash-style paths such as'a.b[0].c'.pick(object, ...paths)andomit(object, ...paths)support deep paths too, and also accept arrays of paths.omitreturns a copy of the object; class values it does not touch (Date,GeoPoint,DocumentReference, …) are left intact.
Firestore value classes
GeoPoint(lat, lng)— build a geopoint.VectorValue— the class vector fields arrive as.FieldValue—FieldValue.delete()andFieldValue.serverTimestamp()are supported return values in the update flow.
How values map to JavaScript
Firestore values arrive in the script as their natural JavaScript counterpart:
| In Firestore | In the script |
|---|---|
| Timestamp | Date |
| Integer | number; values beyond ±2^53 arrive as BigInt |
| Double | number |
| Geopoint | GeoPoint |
| Reference | DocumentReference |
| Bytes | Uint8Array |
| Vector | VectorValue |
When writing back, whole JavaScript numbers are written as integers — unless the stored field is a double, in which case the type is preserved on update.
Limits
| Limit | Value |
|---|---|
| Script timeout | 30 seconds per invocation |
Queries without limit() | Capped at 1000 documents |
fetch response body | 20 MB |
| Timers | No setTimeout; only the provided async APIs resolve |
| Database features | No batches, transactions or count() yet |
| Writes | Respect the read-only flag; refuse anonymized data |
Error policy
Every job with a script has an If the script fails on a document setting:
- Skip the document and continue (default) — failures are counted, and the job details list every failed document with its path and the error message.
- Stop the job — the first failure aborts the whole job.
The users export and import jobs have the same setting, worded per user: If the script fails on a user, with Skip the user and continue or Stop the job.
Script library
Scripts can be saved and reused across jobs:
- Save to library stores the current script under a name.
- Load script… opens the library from any of the script dialogs; the picker only shows scripts saved for the same flow.
Manage scripts
The Manage scripts dialog — opened with the folder button above any script editor — lists every saved script with its name, its context (the flow it was saved for), its description and when it was last modified. From there you can view a script, edit it — name, description, context and code, with the same live syntax validation as the job editors — or delete it.
Synchronization
Saved scripts are included in settings synchronization. They are global, not project-scoped, so they travel with every synced project, with the same conflict handling as queries and bookmarks.
