Skip to content

Backend Guide — IMDF Storage and RTLS History

Decision: Option B — architecture guide only. @bloomsparkagency/core remains a pure-frontend library. This document describes the recommended server-side schema and deployment patterns for customers who need to persist IMDF floor plans and RTLS position history. No opinionated backend package is shipped at this time.


Overview

@bloomsparkagency/core processes all data in the browser. It does not include a backend server. Customers who need the following must provision their own:

  • IMDF persistence — storing and serving .imdf.zip archives across sessions and users
  • RTLS history — recording high-frequency asset positions for replay, heatmaps, and popular-times analytics
  • Multi-tenant access control — scoping floor plans and sensor data per tenant or building

The reference architecture below uses Postgres + TimescaleDB (for time-series RTLS data) behind a FastAPI (Python) or Express (Node.js) REST API. Any equivalent stack works; the schema is the authoritative artifact.


buildings

sql
CREATE TABLE buildings (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

imdf_archives

One row per .imdf.zip. The binary is stored in object storage (S3 / R2 / GCS); the table holds the reference and extracted metadata.

sql
CREATE TABLE imdf_archives (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  building_id  UUID NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
  version      INT  NOT NULL DEFAULT 1,
  object_key   TEXT NOT NULL,          -- S3/R2 key for the .imdf.zip
  object_url   TEXT NOT NULL,          -- Pre-signed or CDN URL served to the frontend
  manifest     JSONB NOT NULL,         -- IMDFManifest (version, created, language, origin)
  level_count  INT  NOT NULL,
  unit_count   INT  NOT NULL,
  is_active    BOOLEAN NOT NULL DEFAULT true,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Only one active archive per building at a time
CREATE UNIQUE INDEX imdf_archives_active_building
  ON imdf_archives (building_id)
  WHERE is_active = true;

rtls_positions (TimescaleDB hypertable)

High-frequency asset position log. Convert to a TimescaleDB hypertable to keep inserts fast and enable automatic time-based partitioning and compression.

sql
CREATE TABLE rtls_positions (
  time        TIMESTAMPTZ NOT NULL,
  asset_id    TEXT        NOT NULL,
  building_id UUID        NOT NULL REFERENCES buildings(id),
  level_id    TEXT,                   -- IMDF Level feature ID
  longitude   DOUBLE PRECISION,
  latitude    DOUBLE PRECISION,
  x           DOUBLE PRECISION,       -- meter-mode local coordinate
  y           DOUBLE PRECISION,
  accuracy    DOUBLE PRECISION,
  vendor      TEXT,                   -- e.g. 'sewio', 'quuppa'
  meta        JSONB
);

-- Convert to hypertable (requires TimescaleDB extension)
SELECT create_hypertable('rtls_positions', 'time');

-- Compress chunks older than 7 days
ALTER TABLE rtls_positions SET (
  timescaledb.compress,
  timescaledb.compress_segmentby = 'asset_id, building_id'
);
SELECT add_compression_policy('rtls_positions', INTERVAL '7 days');

-- Retain 90 days of raw data; drop older chunks automatically
SELECT add_retention_policy('rtls_positions', INTERVAL '90 days');

rtls_assets

sql
CREATE TABLE rtls_assets (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  building_id  UUID NOT NULL REFERENCES buildings(id),
  external_id  TEXT NOT NULL,          -- vendor's tag/badge ID
  label        TEXT,
  category     TEXT,                   -- e.g. 'person', 'forklift', 'equipment'
  meta         JSONB,
  last_seen_at TIMESTAMPTZ,
  UNIQUE (building_id, external_id)
);

API Endpoints

Minimum surface for frontend integration with @bloomsparkagency/core:

MethodPathDescription
GET/buildings/:id/imdfReturns pre-signed URL to the active .imdf.zip
PUT/buildings/:id/imdfUpload a new archive (multipart); marks previous inactive
GET/buildings/:id/assetsList tracked assets with last_seen_at
POST/buildings/:id/positionsIngest a batch of PositionUpdate objects
GET/buildings/:id/positions?asset=X&from=T1&to=T2Query position history
GET/buildings/:id/heatmap?from=T1&to=T2&resolution=hAggregated [lng, lat, weight] triples for HeatmapLayer

IMDF upload flow

SpatialEditor.exportIMDF('apple')
  → POST /buildings/:id/imdf  (multipart, Content-Type: application/zip)
  → server validates Content-Type, stores to S3, upserts imdf_archives row
  → returns { url: "https://..." }  ← pass directly to <IMDFSource archive={url} />

Position ingest flow

RTLSAdapter (Comlink Worker)
  → POST /buildings/:id/positions  [{ assetId, longitude, latitude, floorId, ts }, ...]
  → server bulk-inserts into rtls_positions via COPY or unnest()
  → frontend reads live from adapter directly (not from API)
  → API is write-only for live data; read-only for history

Deployment Patterns

Minimal (single-server)

Postgres + TimescaleDB  ←  FastAPI / Express

                             @bloomsparkagency/core (browser)

One Postgres instance with TimescaleDB extension. Suitable for single-tenant deployments up to ~5,000 position updates/sec (TimescaleDB handles ~100k rows/sec on a c5.2xlarge).

Scalable (multi-tenant)

                   ┌─────────────┐
Browser  ──────►  │  API (n×)   │──► Postgres primary (IMDF, assets, tenants)
                   │  FastAPI /  │
                   │  Express    │──► TimescaleDB replica (rtls_positions reads)
                   └─────────────┘


                    S3 / R2 / GCS  (IMDF .imdf.zip blobs)
  • Separate the IMDF metadata DB (Postgres) from the time-series DB (TimescaleDB) at high write volume (>50k positions/sec).
  • Use a message queue (SQS, Kafka) between the position ingest endpoint and the DB writer to absorb bursts.
  • Store .imdf.zip files in object storage; serve via pre-signed URLs or a CDN.

Connecting @bloomsparkagency/core to the backend

tsx
// Fetch the IMDF archive URL from your API
const { data: { url } } = await fetch(`/buildings/${buildingId}/imdf`).then(r => r.json());

// Pass directly to SpatialMap
<SpatialMap buildings={[building]}>
  <IMDFSource archive={url} />
</SpatialMap>

// Wire RTLS adapter to ingest endpoint
const adapter = new GenericWebSocketAdapter({
  url: `wss://your-rtls-host/stream/${buildingId}`,
  mapPayload: (raw) => ({ assetId: raw.id, longitude: raw.lng, latitude: raw.lat, ts: raw.t }),
});
// Position history for heatmap
const { data: heatmap } = await fetch(`/buildings/${buildingId}/heatmap?from=...&to=...`);
<HeatmapLayer data={heatmap} />

Open Questions

  • Multi-tenancy isolation: row-level security (Postgres RLS) vs. separate schemas vs. separate databases. RLS is recommended for most deployments.
  • Auth: JWT issued by your IdP, validated in API middleware. Pass as Authorization: Bearer header. The library does not handle auth — wire it in your API client.
  • RTLS ingest at >50k/sec: consider a dedicated time-series DB (InfluxDB, QuestDB) and remove TimescaleDB if Postgres write pressure becomes the bottleneck at that scale.

Reference

Released under commercial licensing.