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.ziparchives 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.
Recommended Schema
buildings
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.
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.
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
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:
| Method | Path | Description |
|---|---|---|
GET | /buildings/:id/imdf | Returns pre-signed URL to the active .imdf.zip |
PUT | /buildings/:id/imdf | Upload a new archive (multipart); marks previous inactive |
GET | /buildings/:id/assets | List tracked assets with last_seen_at |
POST | /buildings/:id/positions | Ingest a batch of PositionUpdate objects |
GET | /buildings/:id/positions?asset=X&from=T1&to=T2 | Query position history |
GET | /buildings/:id/heatmap?from=T1&to=T2&resolution=h | Aggregated [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 historyDeployment 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.zipfiles in object storage; serve via pre-signed URLs or a CDN.
Connecting @bloomsparkagency/core to the backend
// 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: Bearerheader. 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.
