ironflock
v1.8.4
Published
Collect data in the IronFlock Storage architecture
Downloads
387
Readme
ironflock
About
With this library you can publish data from your apps on your IoT edge hardware to the fleet data storage of the IronFlock devops platform. When this library is used on a certain device the library automatically uses the private messaging realm (Unified Name Space) of the device's fleet and the data is collected in the respective fleet database.
So if you use the library in your app, the data collection will always be private to the app user's fleet.
For more information on the IronFlock IoT Devops Platform for engineers and developers visit our IronFlock home page.
Requirements
- Node.js: 18 or higher
- Browser: Any modern browser with WebSocket support (Chrome, Firefox, Safari, Edge)
Installation
npm install ironflockUsage
import { IronFlock } from "ironflock";
// Create an IronFlock instance to connect to the IronFlock platform data infrastructure.
// The IronFlock instance handles authentication when run on a device registered in IronFlock.
const ironflock = new IronFlock();
// Start the connection to the platform
await ironflock.start();
// Publish an event
await ironflock.publish("test.publish.example", [{ temperature: 20 }]);
// Stop the connection when done
await ironflock.stop();Options
The IronFlock constructor accepts an options object. In Node.js, values fall back to environment variables. In the browser, all config must be passed explicitly.
{
serialNumber?: string; // Device serial number (env: DEVICE_SERIAL_NUMBER)
deviceName?: string; // Device display name (env: DEVICE_NAME)
deviceKey?: string; // Device key for auth (env: DEVICE_KEY)
appName?: string; // Application name (env: APP_NAME)
swarmKey?: number; // Fleet/swarm key (env: SWARM_KEY)
appKey?: number; // Application key (env: APP_KEY)
env?: string; // Environment: "DEV" or "PROD" (env: ENV)
reswarmUrl?: string; // Studio URL to resolve WebSocket URI (env: RESWARM_URL)
cburl?: string; // Direct WebSocket URI override
}All fields are optional in the type, but serialNumber is required at runtime (either via the option or the DEVICE_SERIAL_NUMBER environment variable).
Browser Usage
The library works in modern browsers. Since browsers don't have environment variables, pass all config via the constructor:
import { IronFlock } from "ironflock";
const ironflock = new IronFlock({
serialNumber: "device-serial-from-server",
deviceKey: "my-device-key",
appName: "MyWebApp",
swarmKey: 10,
appKey: 20,
env: "PROD",
});
await ironflock.start();
await ironflock.publish("sensor.readings", [{ temperature: 22 }]);Loading config from a server
Use IronFlock.fromServer() to fetch configuration from your backend:
import { IronFlock } from "ironflock";
// Your backend endpoint returns a JSON object matching IronFlockOptions
const ironflock = await IronFlock.fromServer("/api/ironflock-config");
await ironflock.start();Your backend endpoint should return JSON like:
{
"serialNumber": "device-serial-123",
"deviceKey": "dev-key-1",
"appName": "MyApp",
"swarmKey": 10,
"appKey": 20,
"env": "PROD"
}Normally the server should extracts these values from the environment variables of the server process, since they depend on the execution environment. i.e. the edge device the server is running on.
Security note: The endpoint providing device credentials should be protected by authentication. This is normally already provided by the IronFlock remote access proxy when executed in an IronFlock project.
API Reference
publish(topic, args?, kwargs?)
Publishes an event to a topic on the IronFlock message router.
const publication = await ironflock.publish("com.myapp.mytopic", [{ temperature: 20 }]);| Parameter | Type | Description |
|-----------|------|-------------|
| topic | string | The URI of the topic to publish to |
| args | unknown[], optional | Payload arguments |
| kwargs | Record<string, unknown>, optional | Payload keyword arguments |
Returns: Promise<unknown> — The publication object (an acknowledged publish receipt).
publishToTable(tablename, args?, kwargs?)
Convenience function to publish data to a fleet table in the IronFlock platform. Automatically constructs the correct topic using the SWARM_KEY and APP_KEY environment variables.
await ironflock.publishToTable("sensordata", [{ temperature: 22.5, humidity: 60 }]);| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table, e.g. "sensordata" |
| args | unknown[], optional | Row data to publish |
| kwargs | Record<string, unknown>, optional | Row data as keyword arguments |
Returns: Promise<unknown> — The publication object (an acknowledged publish receipt).
appendToTable(tablename, args?, kwargs?)
Appends data to a fleet table by calling the registered append procedure at append.<SWARM_KEY>.<APP_KEY>.<tablename>. Unlike publishToTable, this uses a remote procedure call rather than a pub/sub event.
await ironflock.appendToTable("sensordata", [{ temperature: 22.5, humidity: 60 }]);| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table, e.g. "sensordata" |
| args | unknown[], optional | Row data to append |
| kwargs | Record<string, unknown>, optional | Row data as keyword arguments |
Returns: Promise<unknown> — The result of the remote procedure call.
publishRowsToTable(tablename, rows, kwargs?)
Publishes many rows in a single message (bulk insert) to the dedicated topic bulk.<SWARM_KEY>.<APP_KEY>.<tablename>. The platform inserts the whole batch atomically (all-or-nothing) in one operation. Use this for high-frequency data where one round-trip per row is too costly. Like publishToTable, this is fire-and-forget — the ack confirms delivery to the router, not the DB insert.
await ironflock.publishRowsToTable("sensordata", [
{ tsp: "2024-01-15T10:30:00.000Z", temperature: 22.5 },
{ tsp: "2024-01-15T10:30:01.000Z", temperature: 22.7 },
]);| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table, e.g. "sensordata" |
| rows | Record<string, unknown>[] | Non-empty array of row objects to insert |
| kwargs | Record<string, unknown>, optional | Extra arguments shared by the whole batch |
Returns: Promise<unknown> — The publication object (an acknowledged publish receipt).
appendRowsToTable(tablename, rows, kwargs?)
Appends many rows in a single RPC (bulk insert) by calling the dedicated procedure appendBulk.<SWARM_KEY>.<APP_KEY>.<tablename>. The platform inserts the whole batch atomically (all-or-nothing): if any row is invalid the entire batch is rejected and nothing is persisted. Prefer this over publishRowsToTable when you need the insert outcome.
const result = await ironflock.appendRowsToTable("sensordata", [
{ tsp: "2024-01-15T10:30:00.000Z", temperature: 22.5 },
{ tsp: "2024-01-15T10:30:01.000Z", temperature: 22.7 },
]);
// result -> { success: true, count: 2 }| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table, e.g. "sensordata" |
| rows | Record<string, unknown>[] | Non-empty array of row objects to insert |
| kwargs | Record<string, unknown>, optional | Extra arguments shared by the whole batch |
Returns: Promise<unknown> — The result of the remote procedure call (e.g. { success: true, count: N }).
reportError(error, opts?)
Reports an application error into the fleet's error-logs table. This is a convenience wrapper over publishToTable / appendToTable: it stamps the row with source: "app", a severity level, and a timestamp, then writes it like any normal table row. The error lands in the same per-databackend error-logs table that fleetdb system errors use (tagged source: "system"), so it is queryable with getHistory, streamable with subscribeToTable, usable in board-templates, and delivered in realtime on transformed.error-logs — without firing the platform's system-error toast.
// Fire-and-forget (default): publishes to the error-logs table
await ironflock.reportError("Sensor read timed out", { level: "warn" });
// Pass an Error to capture its stack (falls back to the message)
try {
riskyOperation();
} catch (err) {
await ironflock.reportError(err as Error);
}
// Use the append RPC when you want to await the insert outcome
await ironflock.reportError("Calibration failed", { level: "error", append: true });| Parameter | Type | Description |
|-----------|------|-------------|
| error | string \| Error | The error message, or an Error whose stack (or message) is recorded |
| opts | ReportErrorOptions, optional | Options (see below) |
opts fields:
| Field | Type | Description |
|-------|------|-------------|
| level | ErrorLevel, optional | Severity: "error", "warn", "info" or "debug". Defaults to "error" |
| append | boolean, optional | When true, use the append RPC (resolves with the insert outcome). Defaults to false (fire-and-forget publish) |
| tsp | string, optional | ISO-8601 timestamp override. Defaults to the current time |
Returns: Promise<unknown> — The publication object, or — with append: true — the result of the remote procedure call.
subscribe(topic, handler)
Subscribes to a topic on the IronFlock message router.
function onMessage(...args: any[]) {
console.log("Received:", args);
}
const subscription = await ironflock.subscribe("com.myapp.mytopic", onMessage);| Parameter | Type | Description |
|-----------|------|-------------|
| topic | string | The URI of the topic to subscribe to |
| handler | (...args: any[]) => void | Function called when a message is received |
Returns: Promise<Subscription | undefined> — The subscription object.
subscribeToTable(tablename, handler)
Convenience function to subscribe to a fleet table. Automatically constructs the correct topic using the SWARM_KEY and APP_KEY environment variables. Receives rows written via both the single-row and the bulk insert paths — rows from a bulk insert are delivered to your handler one at a time, so handler code stays the same.
function onTableData(...args: any[]) {
console.log("New row:", args);
}
await ironflock.subscribeToTable("sensordata", onTableData);| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table to subscribe to |
| handler | (...args: any[]) => void | Function called when new data arrives |
Returns: Promise<Subscription | undefined> — The subscription object.
getHistory(tablename, queryParams?)
Retrieves historical data from a fleet table.
// Simple query with limit
const data = await ironflock.getHistory("sensordata", { limit: 100 });
// Query with time range and filters
const data = await ironflock.getHistory("sensordata", {
limit: 500,
offset: 0,
timeRange: ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
filterAnd: [
{ column: "temperature", operator: ">", value: 20 },
{ column: "humidity", operator: "<=", value: 80 },
],
});
// Current value(s) only: add the `latest` marker. The data backend derives
// the latest row per entity in SQL (entity = the table's maintainLatestFlagFor
// columns from the data-template; without one, the single most recent row).
const current = await ironflock.getHistory("sensordata", {
limit: 100,
filterAnd: [{ latest: true }],
});| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table to query |
| queryParams | TableQueryParams | Query parameters (see below) |
queryParams fields:
| Field | Type | Description |
|-------|------|-------------|
| limit | number | Maximum number of rows to return (1–10000, required) |
| offset | number, optional | Offset for pagination |
| timeRange | TimeRangePair, optional | [start, end] — ISO strings or epoch-ms numbers; null = open end. A legacy { start, end } object is still accepted and sent as the pair |
| filterAnd | TableFilter[], optional | List of AND filter conditions { column: string, operator: string, value: ... }, and/or the { latest: true } mode marker (see below) |
| columns | string[], optional | Columns to return (tsp, device_key and authid are always included). Omit for all columns |
Supported filter operators: =, !=, <>, >, <, >=, <=, LIKE, ILIKE, NOT LIKE, NOT ILIKE, IN, NOT IN, IS NULL, IS NOT NULL.
IS NULL and IS NOT NULL take no value, and they are the only way to ask about NULL: every other operator yields unknown on a NULL column and excludes the row, exactly as it does in SQL. LIKE is case-sensitive; use ILIKE to match case-insensitively.
Combining with OR: entries of filterAnd are AND-ed. A group entry combines its own entries with one operator, so the soft-delete pattern becomes:
filterAnd: [
{
combinator: "OR",
filters: [
{ column: "deleted", operator: "IS NULL" },
{ column: "deleted", operator: "=", value: false },
],
},
]A data backend that predates groups rejects this shape rather than applying part of it, so it fails loudly instead of quietly returning rows the filter excludes.
Latest values: a { latest: true } entry in filterAnd is not a WHERE predicate but a mode switch: the data backend returns only the latest row per entity, derived on the fly in SQL (DISTINCT ON over the entity key declared as maintainLatestFlagFor in the table's data-template; a table without an entity key yields the single most recent row). The former physical latest_flag column no longer exists — a legacy { column: "latest_flag", operator: "=", value: true } filter is still accepted and treated as the marker, but new code should use { latest: true }. Other predicates combine with the marker as expected: entity-key predicates narrow which entities are returned, all other predicates and timeRange filter the resulting latest rows.
Returns: Promise<unknown> — The query result data (typically an array of row objects).
Throws: Error with a descriptive message on invalid parameters, when the history procedure is not registered (table not declared / data backend not running), or when the router rejects the call.
getSeriesHistory(tablename, params)
Retrieves down-sampled time-series data from a fleet table — numeric columns aggregated into time namespaces (e.g. hourly averages). Ideal for charts over long time ranges. Available for tables (not transforms).
const series = await ironflock.getSeriesHistory("sensordata", {
metrics: ["temperature", "humidity"],
method: "AVG",
limit: 500,
timeRange: ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
groupBy: ["device_id"],
});| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The name of the table to query |
| params | SeriesQueryParams | Series query parameters (see below) |
params fields:
| Field | Type | Description |
|-------|------|-------------|
| metrics | string[] | Numeric columns to down-sample |
| method | DownSampleMethod | Aggregation per namespace: "AVG", "SUM", "COUNT", "MIN", "MAX", "FIRST" or "LAST" |
| limit | number | Maximum number of namespaces (1–10000) |
| timeRange | SeriesTimeRange | [start, end] — ISO strings or epoch-ms numbers; null = open end (required) |
| groupBy | string[], optional | Columns to group the series by |
| filterAnd | SQLFilterAnd[], optional | AND filter conditions (WHERE predicates only — the { latest: true } marker is not supported in series queries; use getHistory for latest values) |
Returns: Promise<unknown> — The down-sampled series rows.
Throws: Error with a descriptive message on invalid parameters, when the series procedure is not registered, or when the router rejects the call.
Secret columns
A column declared secret: true in the app's data-template (string columns only, never tsp, and never a column used as an entity key via maintainLatestFlagFor — the template is rejected otherwise) is encrypted at rest with AES-256-GCM. No normal read path ever returns the plaintext, not even to the app that wrote it:
| Read path | What a secret column looks like |
|-----------|--------------------------------|
| getHistory, subscribeToTable, and everything else over WAMP | the redaction sentinel "__secret__" (exported as SECRET_PLACEHOLDER) |
| SQL — sys.dataservice.select_query, the FleetDB Access Postgres login | the raw stored ciphertext, "ifsec:1:<base64url>" |
| revealSecrets | the decrypted plaintext |
NULL is passed through as NULL on every path, so "set but hidden" stays distinguishable from "never written".
// Written like any other column — the data backend encrypts on insert.
await ironflock.publishToTable("credentials", [{ device_id: "d-1", api_token: "hunter2" }]);
// A normal read redacts it.
const rows = await ironflock.getHistory("credentials", { limit: 1 });
// [{ tsp: "...", device_id: "d-1", api_token: "__secret__" }]Each row is encrypted under its own random IV, so two rows holding the same plaintext store different ciphertext. Equality filters, GROUP BY, ORDER BY, DISTINCT and entity keys (maintainLatestFlagFor) over a secret column are therefore impossible rather than merely discouraged — use verifySecret instead of an = filter.
Both functions below are callable only from the app's own containers. The router denies them to every browser role and to consuming apps, which is why ConsumedApp has no equivalent: a consumed app's secret columns arrive redacted and stay that way.
revealSecrets(tablename, queryParams?)
Reads rows of an own table with its secret columns decrypted, via secret.reveal.{tablename}.
const rows = await ironflock.revealSecrets("credentials", {
limit: 1,
filterAnd: [{ column: "device_id", operator: "=", value: "d-1" }],
});
// [{ tsp: "...", device_id: "d-1", api_token: "hunter2" }]| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The table to read |
| queryParams | SecretQueryParams | Same fields as getHistory, except that limit may not exceed 100 — the data backend rejects a larger one rather than clamping it |
Returns: Promise<any[]> — Rows in the same shape as getHistory, with secret columns in clear.
Throws: Error on invalid parameters, when the reveal procedure is not registered (a data backend predating secret columns), or when the router rejects the call.
verifySecret(tablename, column, candidate, queryParams?)
Checks a candidate value against a secret column without revealing it — the password-check counterpart of revealSecrets. The comparison runs server-side in constant time.
const { match, checked } = await ironflock.verifySecret(
"credentials",
"api_token",
submittedToken,
{
limit: 1,
filterAnd: [{ column: "device_id", operator: "=", value: "d-1" }],
}
);
if (checked === 0) {
// No row matched the selector at all — not a wrong token, an unknown device.
}| Parameter | Type | Description |
|-----------|------|-------------|
| tablename | string | The table to check against |
| column | string | The secret column to compare |
| candidate | string | The plaintext being tested |
| queryParams | SecretQueryParams | Which rows take part. Defaults to { limit: 1 } — the most recent row. limit may not exceed 100; a larger one is rejected, not clamped |
Returns: Promise<SecretVerifyResult> — { match, checked }. match is true when any selected row's column decrypts equal to candidate; checked is how many rows were compared, which is what distinguishes a wrong candidate (checked > 0) from a selector that matched no rows (checked === 0).
Throws: Error on invalid parameters, when the verify procedure is not registered, or when the router rejects the call.
call(topic, args?, kwargs?)
Calls a remote procedure on the IronFlock message router using a full WAMP topic URI.
const result = await ironflock.call("some.full.wamp.topic", [42]);| Parameter | Type | Description |
|-----------|------|-------------|
| topic | string | The full WAMP URI of the procedure to call |
| args | unknown[], optional | Positional arguments |
| kwargs | Record<string, unknown>, optional | Keyword arguments |
Returns: Promise<unknown> — The result of the remote procedure call.
callDeviceFunction(deviceKey, topic, args?, kwargs?)
Calls a remote procedure registered by another IronFlock device. Automatically assembles the full WAMP topic as {swarmKey}.{deviceKey}.{appKey}.{env}.{topic}.
const result = await ironflock.callDeviceFunction(42, "com.myapp.myprocedure", [42]);| Parameter | Type | Description |
|-----------|------|-------------|
| deviceKey | number | The device key of the target device |
| topic | string | The URI of the procedure to call |
| args | unknown[], optional | Positional arguments |
| kwargs | Record<string, unknown>, optional | Keyword arguments |
Returns: Promise<unknown> — The result of the remote procedure call.
callFunction()is a deprecated alias forcallDeviceFunction().
IronFlock.fromServer(url) (static)
Fetches configuration from a server endpoint and creates an IronFlock instance. Useful in browser environments where the backend provides device credentials.
const ironflock = await IronFlock.fromServer("/api/ironflock-config");
await ironflock.start();| Parameter | Type | Description |
|-----------|------|-------------|
| url | string | URL of the endpoint returning IronFlockOptions JSON |
Returns: Promise<IronFlock> — A configured IronFlock instance.
registerDeviceFunction(topic, endpoint)
Registers a procedure that can be called by other devices in the fleet. Automatically constructs the full WAMP topic as {swarmKey}.{deviceKey}.{appKey}.{env}.{topic}.
function add(args: any[]) {
return args[0] + args[1];
}
await ironflock.registerDeviceFunction("com.myapp.add", add);| Parameter | Type | Description |
|-----------|------|-------------|
| topic | string | The URI of the procedure to register |
| endpoint | (...args: any[]) => any | The function to register |
Returns: Promise<Registration | undefined> — The registration object.
register()is an alias forregisterDeviceFunction().registerFunction()is a deprecated alias.
setDeviceLocation(long, lat)
Updates the device's location in the platform master data. The maps in device or group overviews will reflect the new location in realtime.
await ironflock.setDeviceLocation(8.6821, 50.1109);| Parameter | Type | Description |
|-----------|------|-------------|
| long | number | Longitude (-180 to 180) |
| lat | number | Latitude (-90 to 90) |
Returns: Promise<unknown> — The result of the location update call.
Throws: Error with a descriptive message on invalid coordinates or when the location service call fails.
Note: Location history is not stored. If you need location history, create a dedicated table and use
publishToTable.
getRemoteAccessUrlForPort(port)
Returns the remote access URL for a given port on the device.
const url = ironflock.getRemoteAccessUrlForPort(8080);
// e.g. "https://<device_key>-<app_name>-8080.app.ironflock.com"| Parameter | Type | Description |
|-----------|------|-------------|
| port | number | The port number |
Returns: string | null — The remote access URL string, or null if the device key or app name is not available.
Properties
| Property | Type | Description |
|----------|------|-------------|
| isConnected | boolean | true if the connection to the platform is established |
| files | FileStore | The app's managed object storage — see Managed File Storage. Safe to access before start(); each call waits for the connection just like the table API |
| connection | CrossbarConnection | The underlying connection instance (for advanced use) |
Lifecycle Methods
| Method | Description |
|--------|-------------|
| await start(cburl?) | Configures and starts the connection to the platform |
| await stop() | Stops the connection |
Connection reliability
The connection reconnects on its own. When the socket drops, the SDK retries until the router is back, then restores every subscription and every registered device function — you do not need to re-subscribe after a reconnect. If one topic cannot be restored — a permission that changed, a transient router error — the rest still are, and the failed one is retried on the next reconnect.
A special case is a router that is reachable but has no realm for the app yet.
The concurrent-install race boots the app container before the databackend has
provisioned its realm, so the first joins are refused with
wamp.error.no_such_realm; the SDK keeps retrying every couple of seconds and
the app comes up the moment the realm does. A realm still missing after a
minute is most likely never going to appear — the app was deleted, or has no
databackend for this stage — so from then on the SDK slows to one attempt every
two minutes rather than hitting the router every second forever. The fast
cadence returns as soon as the realm appears, or the router itself goes away
and comes back.
The harder case is a socket that dies silently: an idle NAT or proxy cuts the connection without telling either side, or a router restarts behind a load balancer. Nothing arrives to signal the loss. A connection that only publishes finds out on its next failing write, but a connection that only subscribes never writes at all, so without help it sits dead until the process restarts.
A WAMP heartbeat closes that gap. Every 30 seconds the SDK makes a small round trip to the router. If the router answers — with a result or an error, either proves the link carries traffic — the connection is healthy. Only silence counts as failure: if nothing comes back within 10 seconds, the SDK drops the connection so the normal reconnect path runs.
This is on by default and needs no configuration. The knobs live on the
connection object and must be set before start():
const ironflock = new IronFlock();
ironflock.connection.heartbeatIntervalMs = 60000; // probe every 60s
ironflock.connection.heartbeatTimeoutMs = 15000; // allow 15s to answer
await ironflock.start();| Property | Type | Default | Description |
|----------|------|---------|-------------|
| heartbeatIntervalMs | number | 30000 | How often to probe the router. Set to 0 to disable the heartbeat |
| heartbeatTimeoutMs | number | 10000 | How long a probe may go unanswered before the connection is dropped and reconnected |
| heartbeatProcedure | string | "wamp.session.get" | The procedure called as the probe |
Raise the interval to cut idle traffic on constrained links; lower it to notice a dead connection sooner. The timeout should stay well above your worst-case round trip, or a slow link will be mistaken for a dead one.
The heartbeat exists because the browser WebSocket API exposes no ping, so there is no transport-level keepalive available to this SDK. The Python SDK solves the same problem with real WebSocket pings and therefore has no equivalent setting.
Cross-app connections from connectToApp() are typically subscribe-only, which
is exactly the case the heartbeat protects. They are covered automatically —
each ConsumedApp exposes its own connection with the same properties.
Cross-App Data Access
Read another app's fleet data from within your app, in the same project and fleet. The provider app must list your app in its data-template consumes: section, and the project user must grant access. Access is read-only: you can query history and subscribe to realtime rows of the tables and transforms (views) the provider shares — you cannot write to them.
import { IronFlock, CrossAppAccessError } from "ironflock";
const ironflock = new IronFlock();
await ironflock.start();
// Open a read-only handle on another app's data backend
const weather = await ironflock.connectToApp("weather-app");
// Inspect what the provider shares (non-private tables / transforms)
console.log(weather.tables.map((t) => t.tablename));
// Query history, just like your own tables
const rows = await weather.getHistory("forecasts", { limit: 100 });
// Subscribe to realtime rows
await weather.subscribeToTable("forecasts", (...args) => {
console.log("New forecast:", args);
});
// Access errors carry a machine-readable code
try {
await ironflock.connectToApp("unshared-app");
} catch (err) {
if (err instanceof CrossAppAccessError) {
console.error(err.code); // e.g. "NO_GRANT"
}
}Consumed-app connections are cached per app + stage and are closed automatically by ironflock.stop().
If your app holds the wildcard grant (consumes: [{ app: "*" }]), use listConsumableApps to discover every provider in the project and connectToAllApps to open them all at once.
connectToApp(appName, opts?)
Opens a read-only connection to another app's data backend in the same project and returns a ConsumedApp handle. Resolves the provider and connects to its realm using this device's credentials.
const weather = await ironflock.connectToApp("weather-app", { stage: "prod" });| Parameter | Type | Description |
|-----------|------|-------------|
| appName | string | Provider app name, as declared in your consumes: section |
| opts | ConnectToAppOptions, optional | Options (see below) |
opts fields:
| Field | Type | Description |
|-------|------|-------------|
| stage | "dev" \| "prod", optional | Provider stage to connect to. Defaults to this app's own stage (ENV) |
| onError | (error: CrossAppAccessError) => void, optional | Called when the connection is fatally denied after connectToApp resolved (e.g. the grant is later revoked) |
Returns: Promise<ConsumedApp> — A read-only handle on the provider's data backend.
Throws: CrossAppAccessError (code: NO_GRANT, PROVIDER_NOT_INSTALLED, UNKNOWN_APP, or NOT_AUTHORIZED).
listConsumableApps()
Lists every non-private provider in the project — the discovery primitive for apps that hold the wildcard consume grant (consumes: [{ app: "*" }] in your data-template, granted by the project user). Performs a single sys.appaccess.list call and opens no connections: render the returned catalogs in a picker, then call connectToApp for the ones you want — or connectToAllApps to open them all at once.
Note: Declare the grant in your app's
data-template.yml, and quote the*— a bare*is a YAML alias and won't parse:consumes: - app: "*"
const providers = await ironflock.listConsumableApps();
for (const p of providers) {
console.log(p.app, Object.keys(p.stages)); // e.g. "weather-app" ["dev", "prod"]
}Returns: Promise<ConsumedAppInfo[]> — one entry per non-private provider:
| Field | Type | Description |
|-------|------|-------------|
| app | string | Provider app name |
| provider_app_key | number | The provider's app key |
| stages | { dev?, prod? } | Per-stage catalog; a stage is present only if the provider has a data backend for it. Each catalog is the non-private { tables, transforms } it shares |
Throws: CrossAppAccessError (code: NO_GRANT) when your app holds no wildcard grant.
connectToAllApps(opts?)
Opens read-only connections to every non-private provider in the project in one go (wildcard consumers only). Enumerates providers via listConsumableApps and opens each one, skipping any that lack a data backend for the requested stage. Each handle is cached under the same app + stage key as connectToApp, so a later connectToApp(name) returns the already-warmed handle instead of opening a duplicate.
const apps = await ironflock.connectToAllApps({
onError: (err) => console.warn("Provider skipped:", err),
});
for (const app of apps) {
const rows = await app.getHistory(app.tables[0]?.tablename, { limit: 10 });
console.log(app.app, rows);
}| Parameter | Type | Description |
|-----------|------|-------------|
| opts | ConnectToAllAppsOptions, optional | Options (see below) |
opts fields:
| Field | Type | Description |
|-------|------|-------------|
| stage | "dev" \| "prod", optional | Provider stage to connect to. Defaults to this app's own stage (ENV) |
| continueOnError | boolean, optional | When true (the default), a provider that fails to open is reported via onError and omitted from the result. When false, the first failure rejects the whole call |
| onError | (error: unknown) => void, optional | Called with each provider that could not be opened (while continueOnError is true), and with a CrossAppAccessError if an already-opened connection is later fatally denied (e.g. the grant is revoked) |
Returns: Promise<ConsumedApp[]> — the successfully-opened provider handles. Closed together by ironflock.stop().
Throws: CrossAppAccessError (code: NO_GRANT) when your app holds no wildcard grant. With continueOnError: false, also rejects with the first provider's open failure.
ConsumedApp handle
Returned by connectToApp. A read-only view of a provider app's shared tables and transforms.
Properties:
| Property | Type | Description |
|----------|------|-------------|
| app | string | Provider app name |
| stage | "dev" \| "prod" | Provider stage this handle is connected to |
| tables | ProviderTableInfo[] | Non-private tables the provider shares |
| transforms | ProviderTableInfo[] | Non-private transforms (views) the provider shares |
| isConnected | boolean | true while the connection to the provider is open |
| connection | CrossbarConnection | The underlying connection (advanced use) |
consumedApp.getHistory(tablename, queryParams?)
Queries history rows of a shared table or transform. Takes the same query parameters as getHistory (limit, offset, timeRange, filterAnd, columns) — including the { latest: true } marker in filterAnd for reading the provider's current values.
const rows = await weather.getHistory("forecasts", { limit: 100 });
const current = await weather.getHistory("forecasts", {
limit: 100,
filterAnd: [{ latest: true }],
});Columns the provider marks secret arrive redacted as "__secret__" and can never be read in clear by a consumer — decrypting is the owning app's privilege, and the router refuses secret.reveal / secret.verify to consuming apps. Naming such a column in filterAnd or columns throws CrossAppAccessError with code SECRET_COLUMN rather than quietly returning ciphertext or a filter that can never match.
Returns: Promise<unknown> — The query result rows.
consumedApp.subscribeToTable(tablename, handler)
Subscribes to realtime rows of a shared table or transform. Bulk-inserted rows are delivered one at a time, exactly like subscribeToTable.
await weather.subscribeToTable("forecasts", (...args) => console.log(args));Returns: Promise<Subscription | undefined> — The subscription object.
consumedApp.getSeriesHistory(tablename, params)
Queries down-sampled time-series history of a shared table (not available for transforms).
const series = await weather.getSeriesHistory("forecasts", {
metrics: ["temperature"],
method: "AVG",
limit: 500,
timeRange: ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
});params fields (SeriesQueryParams):
| Field | Type | Description |
|-------|------|-------------|
| metrics | string[] | Numeric columns to down-sample |
| method | DownSampleMethod | Aggregation per namespace: "AVG", "SUM", "COUNT", "MIN", "MAX", "FIRST" or "LAST" |
| limit | number | Maximum number of rows (1–10000) |
| timeRange | SeriesTimeRange | [start, end] — ISO strings or epoch-ms numbers; null = open end (required) |
| groupBy | string[], optional | Columns to group the series by |
| filterAnd | SQLFilterAnd[], optional | AND filter conditions (WHERE predicates only — no { latest: true } marker) |
Returns: Promise<unknown> — The down-sampled series rows.
consumedApp.close()
Closes this handle's connection to the provider. (All consumed-app connections are also closed by ironflock.stop().)
Returns: Promise<void>
CrossAppAccessError
Thrown by connectToApp and the ConsumedApp methods when cross-app access is denied or misused. Exposes a machine-readable code:
| code | Meaning |
|--------|---------|
| NO_GRANT | The project user has not granted your app access to the provider |
| PROVIDER_NOT_INSTALLED | The provider app has no data backend for that stage in this project |
| UNKNOWN_APP | No app by that name |
| PRIVATE_TABLE | The requested table/transform is not in the provider's shared catalog |
| SECRET_COLUMN | The query filters on or projects a column the provider marks secret — its values are encrypted per row and never readable by a consumer |
| NOT_AUTHORIZED | The router/provider denied access (e.g. the grant was revoked) |
WampError
All SDK methods fail with a descriptive Error — nothing is silently swallowed. When the failure originates from the WAMP router or a remote handler, the error is a WampError (a native Error subclass) whose message names the operation, the topic and the reason, and which preserves the raw WAMP payload:
| Property | Type | Description |
|----------|------|-------------|
| message | string | e.g. Call of procedure 'history.transformed.foo' failed with WAMP error 'wamp.error.no_such_procedure' |
| error | string | The WAMP error URI |
| args | unknown[] | The WAMP error's positional payload |
| kwargs | Record<string, unknown> | The WAMP error's keyword payload |
Operations attempted while not connected fail with a plain Error explaining that no session is available and pointing to start().
Managed File Storage
Every app databackend gets private object storage alongside its tables. With no
files: section in the data template you still get one namespace named default,
so this works against apps released before FleetFiles existed.
// Store an object and get a permanent URL back in the same call
const info = await ironflock.files.put("part-1.jpg", jpegBytes, {
contentType: "image/jpeg",
});
// That URL is safe to put in a FleetDB column — a dashboard widget can render
// <img src="{{photo_url}}"> and it just works
await ironflock.publishToTable("inspections", { part_id: "1", photo_url: info.url });
const data = await ironflock.files.get("part-1.jpg");
for await (const obj of ironflock.files.iterate({ prefix: "2026/" })) {
console.log(obj.key, obj.size);
}The URL never expires, yet stays readable only to an authenticated requestor holding READ on this databackend — an auth proxy re-checks on every request, so it is safe to store but not a public link.
A namespace is a key prefix that carries policy — retention, sharing,
allowed content types. It is not a separate S3 bucket; every namespace lives in
the app's one bucket. Declare one only when a set of objects needs different
rules; otherwise stay in default and organise with key paths.
Declare additional namespaces in data-template.yml:
files:
# Budget the app suggests for itself; the project user's setting is what gets
# enforced. catalog() reports both as quotaBytes and suggestedQuotaBytes.
quotaBytes: 5368709120
namespaces:
- name: frames
description: Raw camera frames, one JPEG per inspected part.
contentTypes: ["image/jpeg"]
maxObjectBytes: 20971520
retention: { deleteAfter: 30 days }The budget is app-wide, not per namespace — a namespace is only a key prefix inside the app's one storage area, so there is nothing for a per-prefix budget to be enforced against. maxObjectBytes is per namespace: it caps a single object, not a total.
files.put(key, data, options?)
Stores a Uint8Array. Options: namespace, contentType. Returns ObjectInfo
with key, size, etag, contentType and url.
files.get(key, namespace?)
Returns a Uint8Array.
files.list(options?)
One page: .objects, .prefixes, .isTruncated, .cursor. Options: namespace,
prefix, limit, cursor.
files.iterate(options?)
Async iterator over every object under a prefix, paginating for you.
files.stat(key, namespace?) / files.exists(key, namespace?)
Metadata without transferring; exists returns a boolean.
files.delete(key, namespace?) / files.copy(key, to, namespace?, toNamespace?) / files.move(key, to, namespace?, toNamespace?)
move is copy-then-delete on the client (the service has no move verb), so it
is not atomic — a failed delete leaves both copies.
files.url(key, namespace?, version?)
The permanent URL. Resolves to undefined where the deployment has no HTTP edge
(a plain-HTTP appliance) — the signal to fall back to files.get(). Pass an ETag
as version to let browsers cache it immutably.
files.usage(options?)
What the object store reports, in one call: sizeBytes, objectCount,
quotaBytes (the enforced budget) and freeBytes. freeBytes is -1 when
there is no quota — a quota of 0 means unlimited, and 0 free would read as
full.
Pass { detail: true } for a perNamespace breakdown. That costs one listing per
namespace (the store accounts per bucket; a namespace is a prefix), so it is off
by default.
files.namespaces() / files.catalog()
What this app may use. catalog() reports quotaBytes (enforced, read from
the object store) alongside suggestedQuotaBytes (what the app's data template
asked for) — the quota is a user setting, so those two can differ. catalog() also reports
inlineMaxBytes and publicBaseUrl; it is cached after the first call.
files.shareUrl(key, namespace?, ttl?)
An expiring link anyone holding it can fetch. Unlike files.url() this is a
bearer capability — nothing re-checks authorization when it is used. Hand it
to a person; do not store it in a column. The server clamps ttl.
files.uploadUrl(key, options?)
An expiring URL that accepts a direct upload. Returns url, method, headers
and expiresIn; send exactly those headers or the signature will not verify.
Options: namespace, contentType, ttl, size.
Large objects
The SDK picks the transport by size, automatically:
| Size | Path |
| --- | --- |
| ≤ inlineMaxBytes (6 MiB) | one WAMP call |
| larger | direct to object storage over HTTPS, bypassing the router |
put/get work on Uint8Array, so the object is held in memory. For very large
files in Node, stream it yourself against uploadUrl() / shareUrl() rather
than materialising it.
Two ceilings remain, and both throw TOO_LARGE with a reason naming which one:
- 5 GiB — S3's single-upload limit. Multipart is not implemented yet.
inlineMaxByteswhere there is no direct endpoint — an air-gapped appliance cannot transfer a large object at all. The message says so, because no retry or smaller chunk can help.
The direct path needs to reach the object store host, not just the router. Two
field failures have their own codes rather than looking like auth problems:
PRESIGN_UNREACHABLE (a proxy allowing only the router) and CLOCK_SKEW (S3
rejects requests more than 15 minutes out of step — check NTP).
There are no Node-only entry points: put/get work on Uint8Array in both
Node and the browser, so read or write local files yourself with fs.
FileStoreError
Every failure throws FileStoreError with a stable .code and a human-readable
.reason. Branch on .code, never on .reason.
import { FileStoreError } from "ironflock";
try {
await ironflock.files.put("huge.bin", payload);
} catch (e) {
if (e instanceof FileStoreError && e.code === "QUOTA_EXCEEDED") {
// ...
}
}| Code | Meaning |
| --- | --- |
| NOT_AUTHORIZED | Caller may not perform this operation |
| NO_SUCH_NAMESPACE | Namespace is not declared in the data template |
| NO_SUCH_OBJECT | Key does not exist |
| TOO_LARGE | Exceeds the single-call transfer limit |
| OBJECT_TOO_LARGE | Exceeds the namespace's own maxObjectBytes |
| QUOTA_EXCEEDED | Filestore is full |
| CONTENT_TYPE_NOT_ALLOWED | Namespace restricts contentTypes |
| NOT_SUPPORTED | Backend cannot do this |
| NOT_AVAILABLE | No file service on this deployment |
| PRESIGN_UNREACHABLE | Object store not reachable directly (proxy?) |
| CLOCK_SKEW | Device clock too far out of step for S3 |
| INTERNAL | Anything else |
A newer server may add codes; unknown ones pass through as .code rather than
throwing something else, so treat anything unrecognised as a generic failure.
Development
Install dependencies:
npm installRun tests:
npm test # Node.js tests
npm run test:browser # Browser environment tests
npm run test:all # BothType check:
npm run typecheckBuild:
npm run buildPublish a new release:
npm run release