Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ TX_TIMEOUT_SECONDS=30
# --- IoT simulation ---
# Optional: Maximum simulated solar power output in kW. Default: 1000
MAX_POWER_KW=1000
# Optional: Set to "true" to disable the in-memory IoT reading cache.
# Readings are deterministic per (project_id, hour), so the cache only skips
# redundant recomputation — disabling it changes performance, not responses.
IOT_CACHE_DISABLED=
# Optional: Max entries retained by the IoT reading cache. Default: 1000
# Oldest entries are evicted first once the cap is reached.
IOT_CACHE_MAX_SIZE=1000

# --- Input validation ---
# Optional: Inclusive upper bound accepted for a :id project parameter.
# Requests outside 1..MAX_PROJECT_ID are rejected with HTTP 400. Default: 1000000
MAX_PROJECT_ID=1000000

# --- Secrets Management ---
# Provider: env | aws | vault | azure
Expand Down
6 changes: 3 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Simulated solar-panel reading for project `id`. Readings are deterministic per
}
```

**Errors:** `400` if `id` is not a positive integer.
**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`).

---

Expand All @@ -100,7 +100,7 @@ Simulated satellite / vegetation reading for project `id`.
}
```

**Errors:** `400` if `id` is not a positive integer.
**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`).

---

Expand Down Expand Up @@ -164,7 +164,7 @@ Detail for a single project.
}
```

**Errors:** `400` if `id` is not a positive integer.
**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`).

---

Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Released sections are generated by [semantic-release](https://github.com/semantic-release/semantic-release)
from Conventional Commit messages on `main` (see `.releaserc.json`), so they do not
need to be written by hand. Anything merged but not yet released belongs under
[Unreleased](#unreleased); copy the [template](#template) at the bottom of this
file when starting a new section.

## [Unreleased]

### Added

- In-memory TTL cache for the deterministic IoT readings. Entries are keyed
`solar:<projectId>:<hourSeed>` and `satellite:<projectId>:<hourSeed>` and
expire at the next hour boundary, so repeated reads within an hour no longer
recompute the simulation. Disable with `IOT_CACHE_DISABLED=true`; cap retained
entries with `IOT_CACHE_MAX_SIZE` (default `1000`, oldest evicted first)
([#221](https://github.com/heliobond/backend/issues/221))
- Upper bound on the `:id` project parameter. IDs outside `1..MAX_PROJECT_ID`
(default `1000000`) are rejected with a `400`, alongside the existing rejection
of floats, signed and non-numeric values
([#223](https://github.com/heliobond/backend/issues/223))
- `bun run dev:no-watch`, which keeps the previous non-watching development server
([#222](https://github.com/heliobond/backend/issues/222))
- Changelog conventions and a copyable entry template, linked from the README
([#224](https://github.com/heliobond/backend/issues/224))

### Changed

- `bun run dev` now runs under `node --watch`, so the server restarts on save
([#222](https://github.com/heliobond/backend/issues/222))
- `src/routes/iot.ts` re-exports the simulation from `src/lib/iot.ts` instead of
keeping a second copy of it, so the HTTP routes and the hourly cron read
through one implementation and therefore one cache
([#221](https://github.com/heliobond/backend/issues/221))

## [1.0.0] - 2026-07-29

Initial version.

### Added

- Health check endpoint with uptime and last cron run status
- IoT simulation endpoints for solar panel and satellite readings
- Soroban ProjectRegistry contract integration for impact score updates
Expand Down Expand Up @@ -37,3 +73,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bearer token authentication for admin endpoints
- Rate limiting to prevent abuse
- Security policy documentation

## Template

Copy this into a new `## [Unreleased]` section and drop the categories that do not
apply. Keep them in this order, and write entries for the person upgrading — what
changed and what they need to do about it, rather than which files moved. Link the
issue or PR at the end of each entry.

```markdown
## [Unreleased]

### Added <!-- new features -->
### Changed <!-- changes in existing behaviour -->
### Deprecated <!-- soon-to-be-removed features -->
### Removed <!-- features removed in this release -->
### Fixed <!-- bug fixes -->
### Security <!-- vulnerabilities addressed -->
```

[unreleased]: https://github.com/heliobond/backend/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/heliobond/backend/releases/tag/v1.0.0
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ exceeded.
{ "status": "ok" }
```

### Project IDs

Every `:id` path parameter is a project ID: a whole number in `1..1000000`
inclusive. The upper bound is configurable via `MAX_PROJECT_ID`.

Anything else is rejected with `400 bad_request` before the route runs — floats
(`1.5`), signed values (`-5`, `+5`), exponent notation (`1e6`), surrounding
whitespace, non-numeric strings, and IDs above the bound.

### `GET /v1/iot/solar/:id`

```json
Expand All @@ -112,6 +121,11 @@ exceeded.

Readings are deterministic per `(project_id, hour)` — the same id returns the same values within a given clock hour.

Because they are deterministic, readings are cached in memory for the remainder
of the clock hour instead of being recomputed per request; `timestamp` is still
the time of the request. Set `IOT_CACHE_DISABLED=true` to recompute every time,
and `IOT_CACHE_MAX_SIZE` to cap retained entries.

### `GET /v1/iot/satellite/:id`

```json
Expand Down Expand Up @@ -224,6 +238,9 @@ Create a `.env` file (see `.env.example`):
| `POLL_MAX_ATTEMPTS` | No | `20` | Max polling attempts before timing out |
| `TX_TIMEOUT_SECONDS` | No | `30` | Soroban transaction timeout (seconds) |
| `MAX_POWER_KW` | No | `1000` | Maximum simulated solar power output (kW) |
| `IOT_CACHE_DISABLED` | No | — | `true` bypasses the in-memory IoT reading cache |
| `IOT_CACHE_MAX_SIZE` | No | `1000` | Max cached IoT readings; oldest are evicted first |
| `MAX_PROJECT_ID` | No | `1000000` | Inclusive upper bound accepted for a `:id` project param |

---

Expand All @@ -242,6 +259,8 @@ cp .env.example .env

# 3. Development (ts-node + hourly cron + 5-min indexer)
bun run dev # -> Heliobond backend listening on port 3001
# watches src/ and restarts on save
bun run dev:no-watch # same, without file watching

# Verify it's up
curl http://localhost:3001/health
Expand Down Expand Up @@ -351,6 +370,14 @@ The full OpenAPI specification is available at `http://localhost:3001/api-docs.j
### API.md Reference
Detailed API reference with examples and error codes is available in [API.md](./API.md).

## Changelog

Notable changes for each version are recorded in [CHANGELOG.md](./CHANGELOG.md),
which follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Released
sections are generated by semantic-release from Conventional Commits; add
unreleased work under `## [Unreleased]` using the template at the bottom of the
file.

## Contributing

Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node src/index.ts",
"dev": "node --watch -r ts-node/register src/index.ts",
"dev:no-watch": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest",
Expand Down
126 changes: 123 additions & 3 deletions src/__tests__/iot-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { withIotCache, clearIotCache } from "../lib/iot";
import {
withIotCache,
clearIotCache,
getIotCacheStats,
getHourSeed,
getSolarData,
getSatelliteData,
} from "../lib/iot";

beforeEach(() => {
clearIotCache();
delete process.env.IOT_CACHE_DISABLED;
delete process.env.IOT_CACHE_MAX_SIZE;
});

afterEach(() => {
delete process.env.IOT_CACHE_DISABLED;
delete process.env.IOT_CACHE_MAX_SIZE;
});

describe("withIotCache — cache miss", () => {
Expand Down Expand Up @@ -50,8 +59,8 @@ describe("withIotCache — TTL expiry", () => {

const fn = jest.fn().mockReturnValueOnce("first").mockReturnValueOnce("second");

withIotCache("solar:5:99999", fn, 1_000); // prime cache
jest.advanceTimersByTime(1_001); // expire TTL
withIotCache("solar:5:99999", fn, 1_000); // prime cache
jest.advanceTimersByTime(1_001); // expire TTL
const result = withIotCache("solar:5:99999", fn, 1_000);

expect(fn).toHaveBeenCalledTimes(2);
Expand Down Expand Up @@ -99,3 +108,114 @@ describe("withIotCache — IOT_CACHE_DISABLED", () => {
expect(fn).toHaveBeenCalledTimes(2);
});
});

describe("withIotCache — IOT_CACHE_MAX_SIZE", () => {
it("never grows past the configured cap", () => {
process.env.IOT_CACHE_MAX_SIZE = "3";

for (let i = 0; i < 10; i++) {
withIotCache(`solar:${i}:99999`, () => i, 60_000);
}

expect(getIotCacheStats().entries).toBeLessThanOrEqual(3);
});

it("evicts the oldest entry first, keeping the newest", () => {
process.env.IOT_CACHE_MAX_SIZE = "2";

withIotCache("solar:100:99999", () => "oldest", 60_000);
withIotCache("solar:101:99999", () => "middle", 60_000);
withIotCache("solar:102:99999", () => "newest", 60_000);

// The newest key survives and is served from cache...
expect(withIotCache("solar:102:99999", () => "recomputed", 60_000)).toBe("newest");
// ...while the oldest was evicted and has to be recomputed.
expect(withIotCache("solar:100:99999", () => "recomputed", 60_000)).toBe("recomputed");
});

it("falls back to the default cap when the value is not a usable number", () => {
process.env.IOT_CACHE_MAX_SIZE = "not-a-number";
expect(getIotCacheStats().maxSize).toBe(1000);

process.env.IOT_CACHE_MAX_SIZE = "0";
expect(getIotCacheStats().maxSize).toBe(1000);
});

it("reports the configured cap through getIotCacheStats", () => {
process.env.IOT_CACHE_MAX_SIZE = "42";
expect(getIotCacheStats()).toMatchObject({ maxSize: 42, enabled: true });
});
});

describe("getSolarData / getSatelliteData caching", () => {
it("caches solar readings under solar:<projectId>:<hourSeed>", () => {
getSolarData(7);
expect(getIotCacheStats().entries).toBe(1);

getSolarData(7);
expect(getIotCacheStats().entries).toBe(1);
});

it("caches satellite readings under a separate key from solar", () => {
getSolarData(7);
getSatelliteData(7);
expect(getIotCacheStats().entries).toBe(2);
});

it("keys cache entries per project id", () => {
getSolarData(1);
getSolarData(2);
expect(getIotCacheStats().entries).toBe(2);
});

it("returns identical readings within the hour (deterministic as before)", () => {
const first = getSolarData(9);
const second = getSolarData(9);

expect(second.power_output_kw).toBe(first.power_output_kw);
expect(second.efficiency_pct).toBe(first.efficiency_pct);
expect(second.max_power_kw).toBe(first.max_power_kw);
});

it("produces the same readings whether or not the cache is enabled", () => {
const cached = getSolarData(11);

process.env.IOT_CACHE_DISABLED = "true";
const uncached = getSolarData(11);

expect(uncached.efficiency_pct).toBe(cached.efficiency_pct);
expect(uncached.power_output_kw).toBe(cached.power_output_kw);
});

it("does not cache anything when the cache is disabled", () => {
process.env.IOT_CACHE_DISABLED = "true";
getSolarData(12);
getSatelliteData(12);
expect(getIotCacheStats().entries).toBe(0);
});

it("stamps a fresh timestamp on a cache hit rather than replaying the cached one", () => {
const nowSpy = jest.spyOn(Date, "now");
nowSpy.mockReturnValue(1_000);
const first = getSolarData(13);

nowSpy.mockReturnValue(2_000);
const second = getSolarData(13);

expect(first.timestamp).toBe(1_000);
expect(second.timestamp).toBe(2_000);
nowSpy.mockRestore();
});

it("does not let a caller mutate the shared cache entry", () => {
const first = getSolarData(14);
first.efficiency_pct = -999;

expect(getSolarData(14).efficiency_pct).not.toBe(-999);
});

it("exposes a numeric hour seed for cache keys", () => {
expect(typeof getHourSeed()).toBe("number");
expect(Number.isNaN(getHourSeed())).toBe(false);
});
});
Loading
Loading