flutterflow_cloud
The FlutterFlow Cloud SDK for Flutter: sign-in and accounts, database records and queries, and file storage for apps built on FlutterFlow Cloud.
The runtime contract is 14 — Dart SDK.
Profile values accept bounded scalar lists declared in auth.profileFields.
Pass Dart lists to sign-up profile or auth.updateProfile(patch: ...); a
patch replaces the whole list, [] stores an empty list and null clears an
optional field. Typed endUser query rows are FFXUsers too, and
their profile (with the generated profile… getters) carries the declared
fields of the flat record row. Mark permissions.endUser.fields.email and phoneNumber as
serverOnly before sharing user records: record/query/watch results omit them,
while auth.me() retains the signed-in person's own identity. A query may
filter a hidden field only by equality, which still tells every caller its
list rule admits who has the value they guess; ranges and ordering on hidden
fields fail publish (queryFieldNotDisclosable).
Declare models.*.unique in project config for race-safe scalar tuples.
Existing create/update/batch methods return FFXApiError with code
uniqueConstraintViolation (409, choose different values) or
uniqueConstraintBuilding (503, honor Retry-After). Batches check final
images and support atomic swaps. A query before create cannot enforce
uniqueness. A tuple's normalize: {"handle": "caseFold"} makes a string
member compare case- and Unicode-form-insensitively (Mike collides with
mike) while the record keeps its spelling. See config §4.1.
Auth events and listener ownership
app.auth.authStateChanges() delivers the initial user immediately. While
signed in, the first listener owns one GET /v1/auth/events fetch stream;
additional listeners share it. A newer matching revision refreshes the token
and emits. Cancel the final subscription or call app.close() to release the
request and reader. Sign-out, account switches and deletion do the same.
Transient failures reconnect without requiring a new listener, backing off to
30 s (a Retry-After can lengthen the wait, never shorten it). A refusal the
backend repeats, such as an expired App Preview ticket or a stopped thread,
waits for a new session or a renewed base URL instead of asking again. The transport
uses http 1.5 or later AbortableRequest; see
06 §7.1 and 14 §6.
Terminal auth.sessionRevoked notices bind to the required
FFX-Auth-Session-Id response header. The SDK validates its strict session ID,
keeps refresh tokens opaque, and clears only the original matching auth epoch.
A stale stream cannot sign out a newer account; an ordinary transport close
does not imply sign-out. Disable/update/revoke/delete reach active listeners.
final subscription = app.auth.authStateChanges().listen((user) {
print(user?.id);
});
// When the owning UI is disposed:
await subscription.cancel();
app.close();
FFXPersistedSession and auth responses require a valid claimsRevision.
Use matching server/SDK builds and reauthenticate dev sessions after a format
change; there is no upgrade-on-read path. Exact refresh replay retains its
original token/expiry only while the session remains live, then a still-newer
claims notice causes a fresh successor rotation. Record/channel watch and
general profile-change events remain deferred.
Mutation recovery
Mutations keep the same idempotency key across network failures and ordinary
retries. Stored error statuses are terminal, including malformed 5xx bodies. An unreadable
success replays the same key to recover its receipt; the SDK never replaces its
identity automatically. An in-flight 409 idempotencyKeyInUse permits two extra same-key attempts. A lost response may
hide a committed mutation, so inspect its original outcome before starting a
new logical call. 14 §11
and 18 §8
define the contract.
HTML/empty proxy errors and body resets use status-aware retries. The total
FFXRetryPolicy.requestTimeoutMs defaults to 60 seconds and covers credentials,
body consumption and waits; preview cold starts retain the documented five-minute
default allowance. Error bodies are bounded to 64 KiB, JSON successes to 32 MiB.
Retry-After accepts seconds or HTTP-dates. Timeout/cancellation releases body
consumption without claiming a dispatched mutation did not commit.
The transport regression suite in test/idempotency_test.dart
covers recovery, mixed failure budgets, caller-supplied initial keys, and auth
refresh. Run bun run dart:analyze and bun run dart:test from the repository
root with the pinned Flutter SDK installed.
Account deletion
await app.auth.deleteAccount() deletes the signed-in full or guest identity
across all environments in its project. The future completes after server
cleanup confirms, clears local auth state and identity caches, attempts
token-storage cleanup, and emits signed-out. Customer records and files retain
their existing ownership. 06 §5.10
defines the exact removal, retained snapshots/backups, and existing JWT read
window.
Concurrent calls share one operation. A retry after an ambiguous failure in
the same client reuses its original key and bearer; the server continues
cleanup independently, and receipt replay still requires an unexpired bearer.
A refusal the server will repeat (a 4xx problem the SDK does not retry, such as
the 403 permissionDenied for drip's test account) ends the deletion: the
session carries on as before, and the next call is a new deletion with a new
key. A deletion started during a refresh waits for it to land, so a refused
one hands back the refreshed session. Late responses cannot overwrite a newer
session. These lifecycle and storage
races are covered by test/account_deletion_test.dart.
Password reset
// Forgot-password screen:
await app.auth.sendPasswordResetEmail(email: email);
// The page the emailed link opens, with the link's oobCode parameter:
await app.auth.completePasswordReset(oobCode: oobCode, newPassword: password);
await app.auth.signInWithEmail(email: email, password: password);
Both are API-key-only mutations: they send no bearer, carry one
Idempotency-Key across their retries, complete with void, and leave the
session, currentUser and session storage untouched.
sendPasswordResetEmail completes the same way whether or not an account uses
the email, so the app never learns which emails are registered.
completePasswordReset signs no one in; the server revokes every session of
the reset account, which a local session of that account learns through its
auth event or next refresh. A used or unknown code throws
FFXValidationError with code oobCodeInvalid, an expired one
oobCodeExpired, a short password weakPassword (the code stays usable).
06 §5.8
defines the flow and 14 §6 the surface;
test/password_reset_test.dart covers the
request shapes, keys and errors. Against the emulator, the reset email lands
in its outbox instead of a mailbox
(emulator README).
Connectors
await app.connectors.call('slack', 'postMessage', params: {...}) calls a
third-party service through the platform, which holds the credential. The
maker connects the service in Project Settings › Connections and declares the
operations the app uses under connectors in .ffx/project.json; app code
names only the connector and the operation (lowerCamelCase identifiers, as
declared) and receives the operation's result object. On the wire this is
POST /v1/connectors/{connector}/{operation} with {"params": {...}}, a
mutation with the usual idempotency key and retry budget. Problems map as
everywhere else:
| Code | Class | userMessage says |
|---|---|---|
connectorParamsInvalid (400) |
FFXValidationError |
the request's details aren't valid, so it wasn't sent |
connectorUnknown, connectorNotEnabled, connectorOperationUnknown, connectionNotLinked (404) |
FFXNotFoundError |
the feature isn't set up in this app yet |
connectionNeedsReconnect (409) |
FFXConflictError |
the feature isn't available right now |
connectorUpstreamFailed (503) |
FFXServiceUnavailableError; the provider's text is in problem['upstream'] |
the service couldn't complete the request; try again |
connectorOutcomeUnknown (500, retry: never) |
FFXTimeoutError |
it may have gone through; check before trying again |
A connector problem's detail and fix are written for the maker ("Link one
in Project Settings › Connections."), so userMessage has copy of its own for
each code. None of these is retried by the SDK. connectorOutcomeUnknown
means an operation that acts sent its request and either no answer came back
or the provider failed partway: it may have happened, and a new call would do
it again, so the app checks the provider before calling again. Against the emulator the call is forwarded to
FFX_CONNECTORS_URL, where the preview's door holds it to the served config's
declaration and rule with the data plane's own check — see the
emulator README.
test/connectors_test.dart covers the surface.
Purchases
cloud.purchases is the app's own App Store and Google Play sales
(payments-ux §11). The app buys
through the store itself (in_app_purchase); the SDK
only reads and checks. It is an extension in a library of its own, which the
main library doesn't export, so it never shadows the purchases getter of an
app that sells nothing and has a purchase model. When the config sells (it
has a payments section), the generator names that model's getter
purchaseCollection instead, so cloud.purchases is always the store's in
a file that imports both:
import 'package:flutterflow_cloud/purchases.dart';
final state = await cloud.purchases.read();
// Pass state.purchaseId to the store as applicationUserName, buy one of
// state.products by its appStoreId or googlePlayId, then:
final after = await cloud.purchases.checkAppStore(signedTransaction);
if (after.upgraded) …; // or after.owns('firstAidGuide')
final credits = after.credits; // a pack's credits are in credits.balance
Finish the store's transaction only after the check answers. Check a
purchase the store gave back on Restore purchases with restored: true: an
upgrade or unlock another account of the app holds then moves to the
signed-in one. A check that
changes the upgrade refreshes the session, so its claim reaches the next
request's rules. current is the signed-in user's last answer and changes
streams them. credits (FFXCredits) is the balance the backend keeps:
bought plus this month's free, which are spent first and refill on the 1st;
the app shows it and never counts it itself.
cloud.usesEmulator is true when the instance talks to an emulator, App
Preview's backend or a local one (FlutterFlowCloud.insidePreview is true in
App Preview alone), where the kit offers its pretend store: checkPretend(item: 'plus', plan: 'yearly'), or
checkPretend(item: 'vetQuestions', amount: 10) for a pack, records its
purchase as a test purchase (FFXStore.pretend). Only the preview's backend,
the emulator, records one; the platform refuses it. The emulator refuses App
Store and Google Play checks.
recordEvents sends what the payments kit saw, for Payments' Reports: the
paywall or the shop shown and what opened it, a plan or pack picked, a
purchase started or cancelled (FFXPurchaseEvent, no content, up to 50 at a
time). Each event's id makes a repeat record nothing. Only the production
environment's are kept; the emulator keeps none.
| Code | Class | userMessage says |
|---|---|---|
purchaseNotVerified (422) |
FFXValidationError |
the store couldn't confirm this purchase |
purchaseNotSold (422) |
FFXValidationError |
this purchase isn't available in this app |
paymentsNotConfigured (409) |
FFXConflictError |
purchases aren't set up in this app yet |
purchaseHeldByAnother (409) |
FFXConflictError |
it belongs to another account; sign in with that account, or, for a subscription or unlock, restore purchases to move it here |
insufficientCredits (409) |
FFXInsufficientCreditsError |
you don't have enough credits for this |
The backend spends credits, never the app: a write whose rule calls
spend("vetQuestions", 1) takes them with the write, and a short balance
fails it with FFXInsufficientCreditsError (credits, needed, balance;
nothing was written or spent). Offer a pack, then send the same write again.
test/purchases_test.dart covers the surface.
Dynamic Links
cloud.dynamicLinks makes and opens short links on the project's link host,
https://<host>/<code>, that open the app on a route
(specs/34 §11). It is an extension in a
library of its own, like purchases, so it never shadows the generated
dynamicLinks getter of an app with a dynamicLink model. A file that
imports this library and such a model's bindings names the extension, since
cloud.dynamicLinks is ambiguous there:
FFXCloudDynamicLinks(cloud).dynamicLinks. Status: implemented, unpublished
(this package's next release on pub.dev).
import 'package:flutterflow_cloud/dynamic_links.dart';
final links = cloud.dynamicLinks;
if (links.isConfigured) {
final url = await links.createLinkToLocation(
GoRouterState.of(context).uri.toString(), // utm_* keys are left out
title: product.name,
campaign: const FFXLinkCampaign(source: 'share'),
);
// hand url to the share sheet
}
// The router's link resolver: the location a link opens, '/' for a link
// that doesn't resolve, null for a URL that isn't one.
linkResolver: links.locationOf,
The build carries the link host and the app's URL scheme as
--dart-define=FFX_LINK_DOMAIN=… and FFX_LINK_SCHEME=…, only for a
project registered for Dynamic Links. Without them the module is off:
isConfigured is false, nothing is ever sent, open answers FFXNotALink
and locationOf null, and createLink, createLinkToLocation and
resolve throw FFXDynamicLinksNotConfiguredError. Against an emulator
the app was pointed at (useEmulator, emulatorUrl, FFX_EMULATOR_URL)
the emulator is the link host with or without them. A backend a preview
injected (App Preview's bridge, a shared app's __ffxPreviewBackend) is
never one, since behind it may be the data plane, which has no link routes:
there the module follows the build's defines alone, and is off without
them.
Flutter delivers a link to the router with its whole URL. open(uri)
answers what it is, a sealed FFXLinkOpen:
| Result | When | location |
Reported |
|---|---|---|---|
FFXLinkOpened (link) |
a link on this host that resolved | the link's route, query keys sorted | DYNAMIC_LINK_OPEN |
FFXSchemeOpened |
<scheme>:///route?…, the iOS page's "Open in app" |
that route (<scheme>://// is /) |
nothing |
FFXLinkNotFound |
a link the host doesn't know, or turned off | / |
DYNAMIC_LINK_RESOLVE_FAILED |
FFXLinkUnavailable (error) |
the host couldn't be reached after its retries | / |
nothing |
FFXNotALink |
anything else, any bare path, or the module off | null | nothing |
open never throws, and the same URL asked again while it resolves or
within 2 s answers the first result (the router may ask twice for one link).
resolve(code) answers the FFXDynamicLink (route, parameters, social
title, description and image, campaign) or null, and reports nothing.
Events go out in the background in batches the host takes (at most five,
each code once), sent once, never thrown.
The link host takes no API key, so the key is never sent to it. A create is
sent again once after a lost connection, a 429 or a 5xx (a Retry-After
over 2 s is not waited for), which can rarely make a second link to the
same route; a resolve gets up to four attempts with at most 3 s of waiting
between them. Problems map to typed errors with end-user copy:
| Code | Class | userMessage says |
|---|---|---|
dynamicLinksNotConfigured (409, or thrown before sending) |
FFXDynamicLinksNotConfiguredError / FFXConflictError |
sharing links isn't set up in this app yet |
dynamicLinksAppNotRegistered (400) |
FFXConflictError |
sharing links isn't set up in this app yet |
dynamicLinksRateLimited (429) |
FFXRateLimitedError |
too many links made right now |
test/dynamic_links_test.dart and test/dynamic_links_emulator_test.dart cover the surface.
Emulator URLs
FlutterFlowCloud.initialize(emulatorUrl: 'https://host/path/backend')
selects the emulator through an absolute HTTP(S) URL, including an optional
path prefix. Standalone Flutter builds can supply the same value with
--dart-define=FFX_EMULATOR_URL=https://host/path/backend. A nonempty URL
selects emulator mode even in release builds. Without it, useEmulator,
emulatorHost, and emulatorPort retain their local-development behavior.
The preview runtime's configurePreviewBackend(url, scope) bridge takes
precedence over those settings and can rotate the URL on existing clients.
Browser wasm builds read globalThis.__ffxPreviewBackend = {url, scope}.
Sessions and caches are scoped to the emulator endpoint or stable preview
thread identity, separately from deployed environments. Preview tickets
are temporary capabilities: use the preview launcher's renewal flow rather
than committing them to app code. See emulator spec §10.
The host renews a ticket in the base URL before it runs out, and the SDK reads
the base URL for every request. When the preview proxy still answers 401 previewTicketExpired (a ticket that ran out first, say in a background tab),
the SDK waits up to two seconds for the renewed base URL and sends the request
once more. A second refusal surfaces as FFXServiceUnavailableError (code
previewTicketExpired, status 401): a transient error that never clears the
session, from a request, a refresh, the background reconcile or the auth
events stream alike.
Sessions shared by several tabs
Every tab of a web app holds the session stored under one scope
(localStorage ffx.<scope>.session) and keeps its tokens in memory. Each
refresh rotates the refresh token, and presenting a spent one revokes the
session for every holder (06 AUTH-R2), so tabs
refresh one at a time: under the Web Lock ffx.<scope>.refresh
(navigator.locks) where the browser has it, otherwise (an insecure context,
a frame the API refuses, every other platform) under a lock local to the
isolate with the same name. Under the lock a refresh reads the stored session
first. When another tab has rotated it since (another refresh token for the
same end user, with a later access-token expiry), the refresh takes the stored
tokens and goes to the network only when they are due, with the stored refresh
token. A tab that saves its session for another reason (a fetched profile)
keeps the tokens another tab stored since rather than writing back the spent
ones. test/shared_session_test.dart covers
both this and the preview ticket.
Screen previews
A screen preview never runs main(), so nothing initializes the SDK there.
Inside App Preview (the host called configurePreviewBackend, or the page
carries __ffxPreviewBackend), the first read of FlutterFlowCloud.instance
before any initialize connects an instance itself: emulator mode with
pk_emulator_local, the key drip apps pass in previews, on the injected
backend or else the emulator defaults. It restores the session the whole-app
preview persisted (the same options keep the same session namespace), or else
signs in as a guest, and every request that carries a session waits for that;
a refused guest sign-in leaves the screen signed out. The whole-app preview
and the other screens share that session, so this instance borrows it: it
never presents the refresh token (a second instance rotating it would trip the
reuse tripwire and revoke the session for all of them), takes the session
another instance stored since when its access token runs out or else signs in
as a guest, stores only sessions it signed in itself, and clears the stored
session only while it is still the one it holds. A later initialize
(the app's own bootstrap) replaces that instance as the default instead of
failing as already initialized. Outside App Preview, reading instance
before initialize throws. test/preview_instance_test.dart
covers it.
A retired published pin raises FFXConfigVersionRetiredError once and keeps the
original pin. Its problem retains requestedConfigVersion and minimumVersion;
the SDK neither retries a terminal 410 nor silently upgrades the requested config.
Shared values and packaging
The SDK depends on flutterflow_cloud_core for common values, typed API errors and strict function codecs. Its public app exports retain the same names. The pure core package has no Flutter or network dependency, so the pure Dart emulator uses it directly. Preview and native app builds embed both packages and bind both sources into their recipe identity.
Callable Functions transport
app.functions.call(name, data, options: ...) sends the callable envelope and
returns the output object. FFXFunctionCallOptions accepts a cancellation future,
an absolute deadline and a metadata callback. The deadline defaults to two minutes
and is sent as FFX-Function-Deadline. Metadata carries the response's status and
its invocation, release, revision and config IDs. The generic transport enforces
bounded strict JSON; project schema enforcement also runs in generated codecs and
the attached local emulator; deployed calls pass the gateway’s admission checks.
A function call is sent once and never again. The platform keeps no idempotency
record for function calls, so a second request could run the function twice. The SDK
sends no Idempotency-Key, ignores FFXRetryPolicy, and surfaces every failure
after one request: a lost connection, a broken or malformed body, a 5xx and a
Retry-After included. A failure without a valid answer is an
FFXFunctionTransportError, and the function may have run. The one second request
renews authentication after a definite pre-admission tokenExpired (a 401 problem
naming no invocation), which started nothing; a second refusal surfaces. Making a
call safe to retry is the app's job: it sends its own request ID in data, and the
function ignores an ID it has already handled. Database, auth and connector
mutations keep their idempotency keys and retries (Mutation recovery).
The request freezes its input bytes, credentials, auth epoch, route and config when
the call starts; a route or identity change cancels the call. Retry-After is read
as the TypeScript client reads it: delta-seconds (digits with an optional fraction,
capped at 2^53 - 1 ms) or an RFC 9110 HTTP date in any of its three forms, in UTC;
any other value is ignored. The SDK never waits it out: a function, quota, rate or
availability error carries it as retryAfterMs, and the app decides whether to call
again. A metadata config version is read only from plain decimal digits up to
2^53 - 1. Cancellation, deadlines and app.close() abort the request and release
its body reader. Redirects cannot forward credentials. Cancelling or timing out does
not undo effects that already started; check whether the call took effect before
making another. test/functions_test.dart runs every
server reply sequence and Retry-After vector of the shared client corpus
(functions-client.json), which the
TypeScript client runs too, and covers input freezing, a refused renewal,
cancellation and deadlines itself.
Local calls reach the emulator, which hands its Functions routes to an attached local
Functions host. The Bun local host runs TypeScript
handlers there. ffx-functions serve pairs it with the emulator on a laptop, and the
agent supervisor starts the same host beside the emulator. Preview execution uses
synthetic secret substitutes and outbound fixtures; saving a real server secret does
not deliver it to the emulator. Deployed calls use the gateway's serving release and
the shared regional runtime, which resolves authorized secret bindings through its
broker. A project without a serving release answers functionsNotDeployed. See
Functions API.
Typed Functions generation
dart run flutterflow_cloud:generate runs the shared pure
core generator, re-exported through
generator.dart. It emits callable models and extension methods
in lib/ffx/functions.g.dart, exported by ffx.dart, alongside the existing
models/queries/options. It never reads .ffx/functions.json or writes into a Functions
source package: the TypeScript handler types and test targets come from
ffx-functions generate
(functions/02 §5).
Models validate bounds on construction and decoding, reject unknown fields and
closed-enum values, copy every list level, and preserve missing versus explicit
null through WireValue. Type and member names share the existing deterministic
collision registry. Typed methods extend the existing app.functions instance.
--check reads every output location and rejects drift or stale generated files;
symlinked output paths fail before any write. No dependency resolution or customer code
execution occurs during generation.
The function section's structural schema comes from TypeBox through
codegen into the
shared core. Dart semantic validation
and the TypeScript compiler run a shared adversarial
corpus. Root config requires an explicit functions map; declarations do not
establish a ready deployed release.
test/function_generator_test.dart runs the formatted CLI over a project that declares
functions and maps them to TypeScript, the generated models through JIT and native HTTP
codec fixtures and web compilation, drift and stale-output checks and a linked output
path, and proves the CLI neither reads the source mapping (current, retired Dart, malformed
or escaping) nor touches the Functions package. It establishes local generation only; no
deployed endpoint is claimed.
The app SDK re-exports canonical batch operations/results and ffxBatchOperation
from flutterflow_cloud_core/batch.dart, the pure Dart library that declares them,
so their type identity is the core library's. No Dart server code consumes it: functions
are written in TypeScript
(directive §3).
Transport resilience verification
test/http_resilience_test.dart verifies real loopback HTTP malformed errors, response resets, deadline/cancellation and same-key replay.
The opt-in deployed acceptance gate is flutter test test/deployed_resilience_test.dart from this
package directory with FFX_AV108_DEPLOYED=1 and FFX_AV108_DATA_URL, FFX_AV108_API_KEY, FFX_AV108_DATA_PATH (a readable /v1/records/... fixture), and optional FFX_AV108_DATA_TOKEN.
Use the deployed HTTPS endpoint and a dedicated test tenant. The gate performs
only reads: baseline, a real upstream response whose local body is deliberately
reset, and successful recovery after injected HTML 502, empty 503 and 429. It
never prints credentials or creates/deletes customer resources. CI skips this
gate without explicit credentials; loopback regression tests run normally.
For the mutation acceptance gate, additionally set FFX_AV108_MUTATIONS=1,
FFX_AV108_MODEL, and FFX_AV108_FIELDS_JSON to fields valid for that dedicated
test model, containing the literal __AV108_MARKER__ (for example a title).
The test creates a uniquely marked record, drops a real successful PATCH body,
then verifies replay returns the same record and exactly one version increment.
Readback verifies the changed fields and cleanup deletes only the created ID.
This gate exercises successfully stored receipts. It does not establish
crash/receipt-persistence atomicity, which remains AV-002's separate gate;
local fixtures alone do not establish remote exactly-once behavior.
Live data
Watch one record when the screen shows one known record — a game, a profile, a
document. It needs the model's get and subscribe rules, and only that
record's writes reach it. Generated bindings give every collection a typed
watch(id):
StreamBuilder<GameState?>(
stream: cloud.gameStates.watch(gameStateId),
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorView(snapshot.error!);
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
final state = snapshot.data; // null while missing or hidden
return state == null ? const GameGone() : Board(state: state);
},
);
It emits the record, then each change, and null while the record is missing
or outside either rule; a deletion is null, not an error. Untyped, the same
watch is app.database.collection('gameState').watch(id) (Stream<FFXRecord?>).
Watch a subscribable named list query when the screen shows a list:
final listener = app.database.query('tasksInList',
parameters: {'listId': 'list_1'}, pageSize: 25,
).watch().listen((snapshot) => render(snapshot.records));
// Later: await listener.cancel(); app.close();
Query watches maintain the authorized first page, including sorting, refill
after deletions and scope changes. Identical query name, canonical JSON
parameters and page size, or model and record ID, share one subscription and
read within the app. Ordered, server-proven
updates can replace an already-visible row without a database read; changed
membership/order, missing proofs, gaps and pokes trigger a coalesced strong query.
Reconnect subscribes and bootstraps current state. Canceling one observer leaves
other observers running; the last observer releases the subscription. .withConverter(...) also
supports .watch(). Page-token and aggregate watches are rejected. Snapshot
changes are relative to the listener's previous delivery; slow consumers keep
one latest pending snapshot. Auth changes clear old state before fresh admission.
One lazy socket is shared by the app. app.realtime.connectionState and
watchConnectionState() expose its lifecycle. The socket uses the existing
platform base URL, including emulator/preview URLs; realtimeUrl is optional.
Cancel the listener or close the app to release resources. See
08 for limits, quota and recovery details.
Platform builds include the committed runtime/ (native) and runtime/web/
dependency locks alongside the pinned SDK. These standalone closures supply SDK
imports missing from older app lockfiles; private package configs preserve
existing customer package resolutions. The web closure excludes the native
authentication plugin, which is imported only on IO hosts. Resolve the web
closure with flutter pub get --enforce-lockfile in runtime/web/ when using
a local APP_PREVIEW_CLOUD_SDK override.
Lookup-protected pages and batches
Rules with known-record lookups (exists/get) give each query row and batch
operation its own 10-path limit: a row past it is hidden, an operation past it
is denied. Pages and atomic batches are not limited to 20 policy records. One
request reads at most 1,000 policy records, re-reads included, which typical
feeds (one or two per row) never reach. A page that reaches it ends early with
hasMore true and a nextPageToken that resumes at the first undecided row,
so keep paging on hasMore rather than treating a short page as the end. A
batch that reaches it fails whole with 413 batchTooLarge
(FFXRequestTooLargeError, failedOperationIndex in its problem) and applies
nothing; split it into smaller batches. Query and batch calls and response
shapes are unchanged. See 05 §3.8.
Atomic numeric updates
FFXFieldValue.increment(delta) is exported by both SDK barrels for numeric values in collection update and batch-update patches: {'count': FFXFieldValue.increment(1)}. Generated collections expose increment(id, count: 1) with typed optional deltas for writable top-level numeric fields. The ordinary retry identity, permissions and field bounds apply; see the contract.
App claim delegation
await app.auth.setCustomClaim(endUserId: id, claim: "role", value: "seller") sets one explicitly delegated claim; value: null revokes it. Other claims remain unchanged. The result is FFXAppClaimResult with id, claim, value, revision; optional expectedRevision rejects stale changes and idempotencyKey preserves a deliberate operation across calls. The current serving app policy (published or draft) decides access; the key is publishable and no admin credential belongs in an app. Success confirms source assignment; auth listeners refresh after outbox delivery. See auth delegation.
Lookup-protected record and query watches use the same watch APIs. Policy changes trigger a fresh authorized snapshot; revoked records become null and revoked query rows disappear. See database realtime.
Expression-authorized aggregates use the existing count/sum/avg API. The origin collects the
records the query matches and evaluates the list rule on each before it contributes, so totals
are per viewer: only records the caller may list count. More than 1000 matches or 1,000
policy lookup reads fail the whole request with the non-retryable 422 aggregateAuthorizationLimitExceeded, never a partial total. The SDK throws it as
FFXValidationError and never retries it. See 05 §4.4.
Google sign-in
Google sign-in requires flutterflow_cloud 0.7.0 or newer. Upgrade an existing app
with flutter pub add flutterflow_cloud:^0.7.0, then run
dart run flutterflow_cloud:generate. Check the resolved version in pubspec.lock;
the preview service bundles its own SDK and is not proof of the app dependency.
Release acceptance: after publishing, run
bun backend/tools/check-published-google-sdk.ts from the repository root. It upgrades
a standalone app from 0.6.0, verifies hosted SDK/core/emulator resolution, analyzes
the public system-browser adapter and runs Google sign-in tests against the published
emulator. It uses no Google account, browser session or cloud credentials.
auth.signInWithGoogle(redirectUri: uri, browser: browser) implements the same
PKCE/state/grant protocol as the TypeScript SDK and persists an ordinary FFX
session. The widget-free core.dart exports the injectable
FFXGoogleSignInBrowser port. Flutter apps use FFXSystemGoogleSignInBrowser
from flutterflow_cloud.dart or google_sign_in.dart; construct it in the
button handler before awaiting network work. profile supplies initial
profile fields and the optional abort future cancels the flow.
The system adapter uses a same-origin popup on web and flutter_web_auth_2
with embedded webviews disabled on native platforms. Android needs the
plugin's callback activity registered for the configured app scheme. Windows
and Linux require an allowed loopback HTTP URL with a port. Automatic generated
app configuration remains tracked in the product integration plan.
crypto implements S256, and web supplies the conditional browser adapter;
the pure core import does not import Flutter's native browser plugin. Google
client credentials remain on the server. See 06 §5.4.
For web hosting, serve the return document with
Cross-Origin-Opener-Policy: unsafe-none (or omit that header), Cache-Control: no-store, and Referrer-Policy: no-referrer. The opening app may use
unsafe-none or same-origin-allow-popups. A restrictive opener policy on the
return document severs the popup handle when Google navigates back, preventing
the SDK from receiving the grant.
Libraries
- core
- The widget-free SDK surface, for Dart CLIs and tools.
- dynamic_links
- Dynamic Links:
cloud.dynamicLinks(FFXCloudDynamicLinks), short links on the project's link host,https://<host>/<code>, that open the app on a route (specs/34). - flutterflow_cloud
- The FlutterFlow Cloud SDK for Flutter.
- generator
- Project generation uses the shared pure Dart core implementation.
Run it with
dart run flutterflow_cloud:generate; app runtime code importsflutterflow_cloud.dartinstead. - google_sign_in
- System-browser Google sign-in for Flutter. The widget-free core.dart keeps its injectable browser port and never imports native platform channels.
- purchases
- Store purchases for apps that sell:
cloud.purchases(FFXCloudPurchases), what the app sells in the App Store and Google Play and what the signed-in end user bought. - wire
- The raw client-plane wire models generated from packages/schema.