Concepts/App Data

🗂️ App Data

An app usually needs data that is not a robot’s: the places it drives to, the parts it inspects, what the operator wrote down. Collections keep that data in Fleetless, so the app needs no backend of its own for it.

A collection is a named set of records with typed fields. The app reads and writes them through REST, the SDK or MCP, and every change is kept.

What collections are for

  • Master data. Locations with their bounds, chargers with a dock position, thousands of components searched by name.
  • Reference lists. The categories and options a form offers.
  • Records users write. A logbook entry per robot, a maintenance note, a setting an operator may change.

Collections belong to one app and are never shared between apps. They are open on every plan, with no feature gate; Plans and Limits counts their records and bytes.

They are not telemetry and not file storage. Both are covered in the patterns below.

The schema document

One YAML document per app holds all its collections. It lives the way a robot’s fleetless.yaml does: draft → publish → versions → restore. Records have no draft; they are live the moment they are written.

yaml
format: 1
collections:
  locations:
    label: Locations
    binding: global
    display: "{{name}}"
    fields:
      name: { type: text, label: Name, required: true, unique: true, searchable: true, max_length: 80 }
      tunnel_from_m: { type: number, unit: m }
      tunnel_to_m: { type: number, unit: m }
  components:
    label: Components
    binding: global
    display: "{{name}} ({{code}})"
    access:
      read: [observe, operate]
      create: operate
      update: operate
      delete: operate
    fields:
      code: { type: text, required: true, unique: true, searchable: true }
      name: { type: text, required: true, searchable: true }
      kind: { type: select, options: [{ value: quadrupole, label: Quadrupole }, { value: bpm, label: BPM }] }
      location: { type: reference, to: locations, on_delete: prevent }
      position_m: { type: number, unit: m }
      approach: { type: json, schema: pose2d }
  robot_settings:
    label: Robot settings
    binding: one_per_robot
    display: "{{robot.name}}"
    access:
      read: [observe, operate]
      update: operate
    fields:
      max_linear_speed: { type: number, unit: m/s, min: 0, max: 1.5, required: true, default: 0.8 }
      home: { type: reference, to: locations, on_delete: clear }
  logbook:
    label: Logbook
    binding: per_robot
    display: "{{created_at}}"
    access:
      read: [observe, operate]
      create: operate
      update: { all: operate }
      delete: { all: operate }
    fields:
      text: { type: long_text, required: true, searchable: true }
      category: { type: select, options: [{ value: note }, { value: fault }, { value: maintenance }] }
  • Keys are the API. A collection key and a field key match ^[a-z][a-z0-9_]{0,62}$ and appear in URLs, filters and record bodies. label and description are free text for people and may change at any time.
  • Reserved field keys: id, robot, robot_id, created_at, created_by, updated_at, updated_by and revision.
  • Field order is the order of the record form in the console.
  • locations has no access. Only developers and server keys reach its records, which is what “nobody by default” means.
  • display is a template that names a record in lists, references and titles. It may use the collection’s own fields, its system fields, robot.name on a collection that belongs to robots, and one hop through a reference: {{location.name}}.

A document is invalid, and refused with 400 validation_error naming the path, only when the document itself is wrong. Nothing about the stored data makes it invalid; the data’s fate is previewed and confirmed instead, see Publishing a schema. The schema document reference lists every key, the field types’ options and the findings that make a document invalid.

The schema is for developers: the console and the console’s MCP tools. No role of an app user and no server key can be given it. The console edits it as YAML only, and records in a side panel; developers mostly let an assistant write the YAML, see Use it from an assistant.

Bindings

A collection says how it relates to robots. That is a property of the collection, not a field type, so a record’s robot can neither be forgotten nor changed.

Binding Records System field
global Belong to the app and to no robot. none
per_robot Each belongs to one robot. robot_id, set on create and fixed afterwards
one_per_robot Every robot of the app has exactly one, which Fleetless creates. robot_id
  • Robot-bound records exist only for robots attached to the app. Detaching a robot hides its records from every caller, developers and server keys included; attaching it again shows them again, and detaching from one app touches no other.
  • Fleetless creates the one_per_robot record, with the actor fleetless, when a robot is attached and has none, and when the collection is published. A record kept while the robot was detached is shown again, not replaced.
  • The API never creates or deletes a one_per_robot record. It reads and updates it, and get by robot always finds one.
  • A required field of a one_per_robot collection needs a default. Fleetless creates the record itself and cannot ask anyone to fill it.
  • Deleting a robot deletes its records in every app, with their history, at once. Deleting an app deletes all its collections the same way.

The same robot has independent records in each app that uses it.

Field types

Every field has a type. Every type takes label, description, required, unique and default; the table lists what each adds.

Type Holds Options of its own
text One line of text max_length (default 255, at most 1,000), pattern (RE2 syntax), searchable
long_text Text with line breaks max_length (default 10,000, at most 100,000), searchable
number A JSON number integer, min, max, unit (shown, never converted)
boolean true or false none
select One option value, or a list of them options (value and an optional label, at most 500), multiple, searchable (matches values and labels)
date YYYY-MM-DD none
datetime ISO 8601 in UTC, millisecond precision none
json Any JSON value schema: a template name or an inline JSON Schema (draft 2020-12)
reference The id of a record in another collection to, on_delete
  • Empty is absent. A field without a value has no key in the record, and a write that sends null clears the field.
  • unique: true means unique within the collection (global) or within one robot (per_robot, one_per_robot). unique: collection on a robot-bound collection means across all its robots. Empty values never collide, and boolean and json fields cannot be unique.
  • default must itself be a valid value of the field. A new record gets it when it does not set the field.
  • pattern is matched anywhere in the value; anchor it with ^ and $ to match all of it. Lookahead, lookbehind and backreferences are not part of the syntax, and a pattern the cloud judges too complex is refused when the draft is saved.

JSON fields

A json field names one of four templates, or carries an inline JSON Schema. The templates use metres and radians, and frame_id is a string; each has an example in the reference.

Template Shape
pose2d { x, y, yaw, frame_id }
point2d { x, y, frame_id }
polygon2d { points: [{ x, y }], frame_id }, at least three points
range1d { from, to }

An inline schema has limits, so that one value cannot cost the server a second:

  • A pattern in it needs a maxLength of at most 10,000 in the same schema object. The cloud may refuse a pattern as too complex there as well.
  • uniqueItems on items that are not plain scalars needs a maxItems of at most 100.
  • Object keys inside a value are at most 1,000 characters.

References

A reference field holds the id of a record in the collection named by to. The record must exist when the reference is written, and between two robot-bound collections it must belong to the same robot.

From To Allowed
per_robot, one_per_robot global yes
per_robot, one_per_robot per_robot, one_per_robot yes, to a record of the same robot
global per_robot, one_per_robot never; the document is invalid

on_delete says what happens when the target record is deleted through the record API or an import:

  • prevent (the default) refuses the delete with 409 record_referenced, naming the referencing collections and how many records each has.
  • clear deletes it and clears the reference in each record that pointed at it, one revision per record.

on_delete never governs a schema publish. Removing a collection removes its records, and the references to them are cleared.

An app user who sets a reference must be allowed to read the target collection, or the write is refused with reference_invalid.

Who may do what

Rights are written into the schema, per collection, as access:

yaml
access:
  read: [observe, operate]
  create: operate
  update: { all: lead, own: operate }
  delete: lead

Here lead stands for a role you created in your organisation; observe and operate are two that a new organisation starts with.

  • A value is a role key or a list of role keys, the keys of the organisation’s roles. A role also holds what it inherits, so a right given to operate is held by admin too, and with a list any one role is enough. update and delete also take { all, own }: all for every record, own for records whose created_by is the caller.
  • Nobody by default. A missing key, or a collection without access, grants that action to nobody. The console marks such a collection “No role has access”.
  • Developers and the app’s server keys may do everything with records.
  • An unknown role key is a warning, not an error. It grants nothing and shows at preview, at publish and in the console. Renaming or deleting a role therefore never breaks a published schema.
  • A one_per_robot collection takes read and update only. create and delete there are a warning and grant nothing.
  • Rights take effect at publish. A changed right closes the realtime subscriptions it no longer covers.

For a collection that belongs to robots there is one more filter, in front of the rights. Only the records of robots attached to the app are reached at all, by developers and server keys too, and an app user also needs a role that reaches the robot: at least one slug or capability of it, once the app’s locks are applied (see Roles and Rights). Every read, write, subscription and history read goes through the same check.

A caller with none of the four rights on a collection gets 404 collection_not_found, as for a collection that does not exist. A caller with some right, but not this one, gets 403 forbidden.

Reading and writing records

Records are read with list and get. A list takes these, all optional:

Parameter What it does
filter { "<field>": { "<op>": value } }, combined with { "and": [...] } and { "or": [...] }. Operators: eq, ne, lt, lte, gt, gte, contains (case-insensitive; text, long text and select), in (at most 100 values) and empty.
search Words separated by whitespace, at most 10. Every word must occur, in any letter case, in at least one searchable field.
sort Up to 3 fields, a leading - for descending. id is the last tie-break.
robot Only the records of one robot, on a robot-bound collection.
limit, cursor 50 records by default, at most 500. The cursor is opaque and stays valid while records are written.
count true adds total, the number of matches.
expand Reference fields to embed as records, one hop.
deleted true lists deleted records instead. Needs the right to create.
  • A filter goes one hop through a reference: { "location.name": { "eq": "XTD1" } }. The system fields id, robot_id, created_at, updated_at and created_by can be filtered and sorted on. A filter nests at most 3 levels and holds at most 50 conditions.
  • Sorting is refused on long_text, on json and on a select with multiple: they have no order that fits in a page cursor.
  • Page until next_cursor is null, not until a page looks short.
  • An embedded record appears only when the caller may read its collection; otherwise the field keeps the id.

A record on the wire is an envelope: id, robot_id, created_at, created_by, updated_at, updated_by, revision, display, values, and missing_required when it has any. The actors are { kind, id, label }, with the kinds developer, app_user, server_key and fleetless.

Writes follow these rules:

  • Updates are partial. Fields left out keep their value; null clears one.
  • required: a create sets every required field, and an update may not clear one. A record whose required fields went empty after a schema change stays updatable in its other fields, and every response names the empty ones in missing_required. A write that breaks this answers 400 required_missing with the fields.
  • expected_revision answers 409 record_changed, with the current revision, when the record moved on since you read it.
  • A unique value that is taken answers 400 validation_error.
  • A record is at most 1 MiB as serialized JSON, and a value may not contain the NUL character (U+0000).

The refusals specific to collections are listed under Collection errors.

Publishing a schema

A developer saves the whole document as the draft, which is validated on the way in. A preview then computes what publishing would do to the data as it is now, and returns a confirmation token over exactly that.

  • Without effects, publish succeeds at once. With effects it needs the token of the current preview. A missing or stale token answers 409 confirmation_required with the current preview and its token.
  • Data that changed between preview and publish yields a new token. The caller confirms again.
  • Publish is one transaction: the new version, every effect, every revision and the audit entry. Afterwards the realtime event collections_schema_published reaches every subscriber of the app.
  • The preview also lists, as information, every change of access and every warning. Neither needs a confirmation.

Collections and fields are matched by key only. There is no rename detection, so a changed key is a removal plus an addition. Each effect is computed per collection and field:

Change Effect
Collection removed Its records are deleted; references to them in other collections are cleared.
Binding changed Treated as removal and re-creation: all its records are deleted.
Collection added, one_per_robot One record per attached robot is created.
Field removed Its values are removed from all records.
Field added Records get the field’s default, if it has one.
Type changed Values are converted (table below); those that cannot be are discarded.
Reference target changed All values of the field are discarded.
Select option removed Values using it are discarded; for multiple, only that element.
max_length, pattern, min, max, integer or JSON schema tightened Values that no longer satisfy the field are discarded.
unique added or widened Among duplicates the record created first keeps its value, then the lowest id; the others’ values are discarded. The preview lists up to 1,000 of them, each with its id, display text and value, and states the exact total; the token covers all of them.
required added Empty values get the default when there is one. Otherwise they stay empty, and the preview counts them per field.

Every changed value is written as a revision with the actor fleetless and the reason schema v<N>. An effect that would change nothing is not listed.

A type change converts like this, and the target’s constraints apply afterwards:

From → to Rule
text ↔ long_text Copied, then max_length applies.
text, long_text → number A trimmed decimal with .; anything else is discarded.
number → text, long_text The shortest decimal.
text → boolean true, false, yes, no, 1, 0, case-insensitive; else discarded. boolean → text is true or false.
number ↔ boolean 0 and 1 only.
text → select Kept when it equals an option value. select → text is the value, joined with , when multiple.
select single → multiple [value]. multiple → single is kept when exactly one element is left.
text → date, datetime ISO 8601, else discarded. date → datetime is 00:00:00Z; datetime → date is the UTC date; both → text is ISO 8601.
any → json The JSON value itself. json → other is converted as from the value’s own kind, else discarded.
anything ↔ reference Discarded.

Restoring a version

Every publish creates a numbered version with its document, author, time and a summary of its effects. Documents are kept for the life of the app.

Restore version N publishes N’s document as a new version. With data: true it also puts the records back to their state at N’s restore point, the moment immediately before the version that replaced N was published:

  • values change back, records deleted since come back, and records created since are deleted;
  • only in the collections the schema difference touches. A logbook whose schema did not change keeps every record;
  • the preview names the touched collections and counts what changes, and the caller may deselect any of them with collections;
  • the changes are reverted by new revisions. They stay in the history and can be restored one by one.

Restoring with data is possible while the restore point is at most 90 days old. Older versions, and restores with data: false, put the document back only, with the ordinary effects of a publish. Records of robots deleted since cannot come back, and the preview counts them as such.

A restore is confirmed with a token like a publish, and it is a new version, so it can be undone the same way.

History

Every change to a record is a revision: the operation (create, update, delete, restore), the actor, the time, the reason (schema v5, import, restore v4, empty for an ordinary write), each changed field with its old and new value, and on a delete the whole record.

  • It is append-only. Users and developers cannot remove revisions. Fleetless’s own changes are revisions too.
  • Whoever may update a record may read its history and restore a revision. Restoring a deleted record, and listing deleted records, need the right to create.
  • Revisions are kept while the record exists and after it is deleted. They go with the robot or the app, 90 days after a publish removed or re-created their collection, or by an owner’s purge.
  • An owner may purge the revisions older than a chosen date, for one collection or the whole app. Current values are untouched, the preview names the restore points that stop working, and the history of every affected record then starts with a note saying who purged what and when. A purge is an owner function in the console and is not in MCP.
  • A user who no longer exists is labelled Deleted user.
  • A history that grows fast is flagged when a collection holds more than 10 times as many revisions as records and at least 10,000. It usually means a client writing in a loop.

The history is not counted against the plan limits, but its size is shown.

Import and export

Developers import and export, in the console, over the console’s MCP tools and with a server key. The role of an app user cannot.

An import takes a CSV (UTF-8, a header row, RFC 4180; a json field as JSON text) or a JSON array of objects, at most 10 MB or 50,000 rows.

  • Mapping. Each source column goes to a field, or to “do not import”. A reference column maps to a unique field of the target, a robot-bound collection takes a robot column (name or id) or one target robot.
  • Key. With a unique field as key, existing records are updated and the rest created. Without one, every row is created. A one_per_robot collection only updates, keyed by robot.
  • Mode mirror needs a key and also deletes the records missing from the file, within a scope you name (a filter or a robot). Without a scope it covers the whole collection, and the preview says so.
  • All or nothing. One invalid row, an unresolvable reference, a limit overrun or a mirror delete blocked by a prevent reference fails the whole import. The preview lists the errors with row, column, value and reason.
  • Preview, then confirm. Every import starts as a preview, also a file that only creates records: without the token nothing is imported. The preview counts unchanged, new, updated and deleted records and errors, and returns a token. The import, called again with that token, runs in one transaction, writes revisions with the reason import, one audit entry, and counts as one write for the rate limit.

An export writes CSV or JSON with the current filter, search and sort and a chosen set of fields. References are written as an id or as a unique field of the target, so the file can be imported again.

Realtime

An app follows a collection over the realtime connection it already has. collection_subscribe takes the collection and, optionally, a robot and a filter. The SDK wraps it as collections.subscribe.

Event Means
created, updated, deleted The record was written.
removed The record left the subscriber’s view: its values stopped matching, the subscriber’s rights changed, or the robot was detached.
entered The record came into view the same way.
  • Every event is checked against the subscriber’s current rights and filter, deletes included, and carries the full record.
  • There is no first snapshot, but the cloud confirms a subscription. Once it has accepted and registered one, it sends collection_subscribed with the request_id, before any event of that subscription. Wait for it, then list: every change after the confirmation reaches you as an event, so none falls between the list and the events. An event can arrive while the list is still being read and may or may not be in its result; a record’s revision tells a newer state from an older one.
  • A refusal is collection_subscribe_error, with the codes the REST API uses, and no collection_subscribed. A collection that does not exist, or is not readable, is refused.
  • The same frame ends a running subscription: collection_removed, forbidden, robot_not_attached, validation_error when its filter no longer fits the collection, and internal_error when the cloud could not judge the subscription again. Nothing more arrives after that; subscribe again to follow the collection.
  • A subscribe frame is at most 16,384 bytes. A larger one closes the connection with code 1009 instead of being answered, so a filter with many long values may not fit. The SDK refuses such a subscription with invalid_option before it sends it.
  • A developer’s connection names the app with app_id. An app user’s or a server key’s may leave it out, and a value it sends must be its own app.
  • collections_schema_published with the new version reaches every subscriber of the app. A subscription to a collection that no longer exists ends with collection_subscribe_error and the code collection_removed.
  • Re-roling an app user closes their subscriptions, and so does a right that no longer covers them.
  • A connection that falls too far behind is closed with code 1013, try again later: more than 2,000 changes wait to be delivered, or 8 MiB are unsent. The SDK connects again and subscribes again, and changes made in the meantime are not replayed: the new subscription is confirmed with a new collection_subscribed, and the SDK’s onReady callback is called again, so the same list in the same place refreshes what the app holds.

Limits and rates

The plan limits count current records and their bytes, across all apps of the organisation.

Basic Plus Pro Enterprise
Collection records 25,000 250,000 1,000,000 by contract
Collection storage 1 GB 10 GB 50 GB by contract
  • Over a limit, a write or an import that would add data is refused with 409 plan_limit naming collection_records or collection_bytes. Only growth is refused: an update that shrinks a record, and a delete, always go through, so an organisation over its limit can work its way back under it. Reads continue, and the import preview warns beforehand.
  • What Fleetless does itself is never refused: attaching a robot, or publishing and restoring a schema that creates one_per_robot records or fills defaults. Those records count, and the limit applies at the next user write.
  • The write rate is 600 writes a minute per app and 120 a minute per actor. An import counts as one. Over it, the answer is 429 rate_limited with details.retry_after_ms and a Retry-After header.

These caps are the same on every plan:

Cap Value
Collections per app 50
Fields per collection 100
Options of a select 500
One record, as serialized JSON 1 MiB
The schema document 1,000,000 bytes of UTF-8
Filter 3 levels, 50 conditions, 100 values in in
Words in a search 10
Fields in a sort 3
Import 10 MB or 50,000 rows

Use it from an assistant

A developer’s AI tool writes the schema and edits records through the console endpoint, and an app user’s tool reaches the records the role allows. See MCP for the two endpoints and docs.describe for the exact parameters of an action.

Tool Actions
console_collections_read list, draft_get, preview, versions, version_get
console_collections_write draft_put, draft_discard, publish, restore
console_collection_records_read list, get, history, export
console_collection_records_write create, update, delete, restore, import
collections_read definitions, list, get, history
collections_write create, update, delete, restore
  • The first four are developer tools. draft_put takes the whole YAML and answers with the validation errors or the preview.
  • The last two are app tools, listed in every app session, with the rights decided per call: an action the role lacks answers 403 forbidden.
  • Every import takes two calls, and a publish or a restore takes two when it changes stored data. The first answers the preview and its token, the second confirms.
  • The purge is not in MCP.
  • MCP for app users needs the Plus plan or above. On Basic, app users reach collections through the app and the SDK only.

Use it from an app

The SDK has a collections namespace on the client. It reads and writes as the signed-in user, or with a server key.

ts
// What this caller may read, with the fields and what they may do with each,
// and the schema version they belong to.
const { version, collections } = await client.collections.definitions()

// A subscription delivers changes, not a snapshot. `onReady` is called when the cloud has
// confirmed it: list there, and every change after that reaches the callback. After a
// dropped connection nothing is replayed, but `onReady` is called again. The SDK does not
// handle what `onReady` returns, so a failing `list` is caught here.
const read = async () => {
  try {
    const { records } = await client.collections.list('logbook', { robot: robotId, sort: ['-created_at'], limit: 20 })
    console.log(`${records.length} entries`)
  } catch (error) {
    console.error('Could not read the logbook', error)
  }
}
const unsubscribe = client.collections.subscribe('logbook', { robot: robotId, onReady: read }, (change) => {
  console.log(change.event, change.id, change.record?.display)
})

// Search and filter run in the cloud.
const parts = await client.collections.list('components', { search: 'quad xtd1', filter: { position_m: { gte: 2 } }, sort: ['position_m'] })
const part = await client.collections.get('components', parts.records[0].id, { expand: ['location'] })

// Write. A `one_per_robot` collection has exactly one record per robot.
await client.collections.create('logbook', { text: 'Replaced the left wheel' }, { robot: robotId })
const settings = await client.collections.get('robot_settings', { robot: robotId })
await client.collections.update('robot_settings', settings.id, { max_linear_speed: 0.6 }, { expectedRevision: settings.revision })

unsubscribe()
  • A list resolves { records, nextCursor, total? }.
  • Errors are the SDK’s typed errors with the REST codes. A caller branches on error.code, and recordChangedDetails.parse(error.details) reads the revision a record_changed refusal found.
  • expand on get embeds reference fields the way it does on list.

Four patterns follow. Each is a way to use collections that works for a week.

Keep telemetry out of collections

A collection is for records that people or an app write now and then. A measurement that arrives every second is a datapoint, which has a rate, retention and a history built for it.

Collections are the wrong place for it for three reasons:

  • every write is a revision that is kept, so a record updated every second gains 86,400 revisions a day and trips the growth warning;
  • the write rate is 600 a minute per app and 120 per actor, which one writer at 10 Hz spends in twelve seconds;
  • records stored as a series fill the plan’s record limit.

If the app needs the last known value of something, read the datapoint. A setting an operator edits, such as a speed limit the app enforces, is a record, and fits a one_per_robot collection.

Records without a robot belong in a global collection

A per_robot record exists only while its robot does. It is hidden when the robot is detached from the app, and deleted with the robot in every app.

Master data that outlives or spans robots, such as locations, chargers and parts, belongs in a global collection. A per_robot collection can point at it with a reference. The other direction is not allowed: a global collection cannot reference a record that belongs to a robot.

Binary data belongs in assets

A record is at most 1 MiB of JSON and there is no file field. Do not put an image or a document in a long_text or json field as base64.

The files Fleetless stores are assets: a robot’s URDF, meshes and textures, described under URDF & meshes. Any other file lives in your own storage, and the record holds its URL or its key.

Renaming is removal plus addition

The schema matches collections and fields by key, so a renamed key is a removal and an addition. Publishing it deletes the old field’s values and leaves the new field empty, or at its default. The preview shows exactly that, which is how you notice.

Move the values first. There are two ways:

  1. Copy by export and import. Publish the new field next to the old one. Export the collection with its unique field and the old field, and import the file with the old column mapped to the new field and the unique field as key. Then ship the app that reads the new field, and publish without the old one.
  2. Switch the app over between two publishes. Publish the new field, ship an app version that writes both fields and reads the new one, falling back to the old. Copy the remaining records with a one-off list and update, and publish without the old field once nothing reads it.

Renaming a collection works the same way: publish the new collection, copy the records into it, switch the app, and remove the old one. Records of a removed collection are purged 90 days after the publish, and a restore with data brings them back until then.