@aeroswarm/core 0.0.0-stage → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,169 @@
1
- # Temporary Holding Version
1
+ # @aeroswarm/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Core infrastructure library shared across all AeroSwarm backend services. Provides configuration, logging, database, caching, messaging, authentication, and observability primitives — plus the canonical event types and validation schemas that define the pipeline's data contract.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @aeroswarm/core
9
+ ```
10
+
11
+ ## Entry points — server vs browser
12
+
13
+ The **root import is server-only**: it pulls in `postgres`, `ioredis`, `kafkajs`, `pino`, and `prom-client`, which must never reach a browser bundle. Browser-safe subsets are published as subpaths:
14
+
15
+ | Import | Contents | Browser-safe |
16
+ |--------|----------|:---:|
17
+ | `@aeroswarm/core` | Everything (all modules below) | ❌ |
18
+ | `@aeroswarm/core/types` | TypeScript types only | ✅ |
19
+ | `@aeroswarm/core/utils` | Pure helpers (`toCurrentTrack`, `isRenderableTrack`, geo math, ID factories, `TOPICS`) | ✅ |
20
+ | `@aeroswarm/core/validation` | Zod schemas | ✅ |
21
+
22
+ ```ts
23
+ // ✅ frontend / shared code
24
+ import { toCurrentTrack, isRenderableTrack } from "@aeroswarm/core/utils";
25
+ import type { CurrentTrack, FusedTrackEvent } from "@aeroswarm/core/types";
26
+
27
+ // ✅ backend services only
28
+ import { createLogger, publishEvent, getRedis } from "@aeroswarm/core";
29
+ ```
30
+
31
+ > Importing the bare root from frontend code once shipped the entire server stack (~500 kB) into the dashboard bundle and crashed it with `process is not defined`. Always use the subpaths from anything that runs in a browser.
32
+
33
+ ## Modules & exposed API
34
+
35
+ ### `types` — canonical event model
36
+
37
+ `Position`, `Velocity`, `ConfidenceBudget`, `Protocol`, `Bbox`, `FusedTrackEvent`, `CurrentTrack`, `TrackStatus`, `TrackSource`, `SourceHealthEvent`, `SourceStatus`, `AlertEvent`, `AlertType`, `AlertSeverity`, `StreamEnvelope` (the WS wire format union: `snapshot | track.updated | track.removed | source.status | stream.heartbeat | stream.degraded | stream.error | alert`), `AeroConnectionStatus`, `AeroRecoverableError`.
38
+
39
+ Both `FusedTrackEvent` and `AlertEvent` carry an optional `airportId` (ICAO code, e.g. `"WARR"`) for multi-airport tenancy — set by the ingesting service (`AIRPORT_ID` env) and propagated through fusion and safety-engine alerts. `CurrentTrack.airportId` mirrors it for WS filtering (`client.airportId` subscribe param).
40
+
41
+ ### `utils` — pure helpers (browser-safe)
42
+
43
+ | Export | Purpose |
44
+ |--------|---------|
45
+ | `toCurrentTrack(fused, staleAfterMs?, lostAfterMs?)` | Normalize a `FusedTrackEvent` into a `CurrentTrack` with derived `status` |
46
+ | `isRenderableTrack(t)` | Type guard: track has finite, in-range lat/lon |
47
+ | `isInsideBbox(lat, lon, bbox)` / `haversineKm(lat1, lon1, lat2, lon2)` / `clamp(v, min, max)` | Geo/math helpers |
48
+ | `createTrackId()` / `createAlertId()` / `createSessionId()` / `createEventUid()` / `createNanoid(size?)` / `createEventId(sourceId, icao24, ts)` | ID factories (`trk_`, `alt_`, … prefixes) |
49
+ | `TOPICS` / `TopicName` | Kafka topic name constants (see table below) |
50
+ | `EMERGENCY_SQUAWKS` | `Set{"7500","7600","7700"}` |
51
+ | `STALE_AFTER_MS` / `LOST_AFTER_MS` / `MACH1_KT` | Domain constants |
52
+
53
+ ### `validation` — Zod schemas (browser-safe)
54
+
55
+ `positionSchema`, `velocitySchema`, `currentTrackSchema`, `streamEnvelopeSchema`, `sourceHeartbeatSchema`, `wsSubscribeSchema`, `apiSuccessSchema`, `apiErrorSchema`, `apiResponseSchema` + inferred input types (`CurrentTrackInput`, `WsSubscribeInput`, `StreamEnvelopeInput`).
56
+
57
+ ### `config` — environment access
58
+
59
+ | Export | Purpose |
60
+ |--------|---------|
61
+ | `getEnv(name)` / `getEnvNumber(name)` | Typed env access with defaults from `ENV_DEFAULTS` |
62
+ | `ENV_VARS` / `EnvVarName` | Canonical env var name constants |
63
+ | `KAFKA_GROUPS` / `KafkaGroupId` | Consumer group id constants |
64
+ | `SERVICE_PORTS`, `LIFECYCLE_STATES`, `PROTOCOL_TYPES` | Misc constant registries |
65
+
66
+ ### `logger`
67
+
68
+ `createLogger(name)` → namespaced Pino logger (respects `LOG_LEVEL`); `logger` — the root instance.
69
+
70
+ ### `cache` — Redis client, pub/sub, and domain caches
71
+
72
+ | Export | Purpose |
73
+ |--------|---------|
74
+ | `getRedis()` / `disconnectRedis()` | Singleton ioredis client (Sentinel-aware via `REDIS_SENTINEL_URLS`) |
75
+ | `PUB_SUB_CHANNELS` | `{ ALERT: "aero.alert" }` — the only channel with a live subscriber (client-service's real-time alert banner); FUSED/HEALTH moved to Kafka-only after a double-processing bug (see federation docs 006) |
76
+ | `pubAlert(event)` | Fire-and-forget pub/sub publish (failures logged, rate-limited) |
77
+ | `subChannels(channels, handler)` | Subscribe to pub/sub channels; returns unsubscribe fn |
78
+ | `setTrack` / `getTrack` / `deleteTrack` | Per-track cache (60 s TTL) |
79
+ | `setSourceHealth` / `getSourceHealth` | Source health cache (120 s TTL) |
80
+ | `cacheSourceToken` / `validateSourceToken(token, fallbackFn?)` / `revokeSourceToken` | Source token auth cache |
81
+ | `checkRateLimit(key, limit, windowS)` | Sliding-window rate limiter |
82
+ | `registerWsClient` / `unregisterWsClient` / `getWsClientCount` | WS client registry |
83
+ | `setSnapshot` / `getSnapshot` | Short-TTL (5 s) response snapshot cache |
84
+ | `getSystemConfig` / `setSystemConfig` | Runtime-tunable system config KV |
85
+
86
+ ### `messaging` — Kafka producer/consumer
87
+
88
+ | Export | Purpose |
89
+ |--------|---------|
90
+ | `publishEvent(topic, value, key?)` | JSON+gzip publish with backpressure cap and auto-reconnect; delivery failures are logged (rate-limited), never thrown |
91
+ | `createConsumer({ groupId, topics, handler, fromBeginning?, … })` | Connected consumer; returns `{ disconnect, pause, resume }` |
92
+ | `getProducer()` / `getKafka()` / `disconnectProducer()` | Lower-level access |
93
+ | `ensureTopics()` | Create all registry topics if missing |
94
+ | `TOPIC_REGISTRY` / `getTopicInfo` / `isValidTopic` / `validateTopics` | Topic metadata & validation |
95
+ | `partitionKeys` | Key builders: `icao(…)`, `track(…)`, `source(…)`, `target(…)`, `alert(…)` |
96
+ | `getConsumerGroupHealth(groupId)` / `isConsumerGroupHealthy(groupId)` | Consumer group introspection |
97
+
98
+ ### `metrics` — Prometheus
99
+
100
+ `registry` plus pre-registered instruments: `wsClients`, `trackUpdateRate`.
101
+
102
+ ### `health`
103
+
104
+ `buildHealthResponse(checks)` + `HealthStatus` — each service wires its own dependency checks (DB/Kafka/Redis) through this shared contract; see each service's `src/health/index.ts`.
105
+
106
+ ## Kafka topics (`TOPICS`)
107
+
108
+ | Constant | Topic |
109
+ |----------|-------|
110
+ | `TOPICS.FUSED` | `aero.fused.track.v1` |
111
+ | `TOPICS.TRUST` | `aero.trust.assessment.v1` |
112
+ | `TOPICS.ALERT` | `aero.alert.v1` |
113
+ | `TOPICS.ALERT_PRIORITY` | `aero.alert.priority.v1` |
114
+ | `TOPICS.HEALTH` | `aero.source.health.v1` |
115
+ | `TOPICS.LIFECYCLE` | `aero.track.lifecycle.v1` |
116
+ | `TOPICS.DEADLETTER` | `aero.deadletter.v1` |
117
+
118
+ Topic names are the public contract between services (and external consumers) — breaking changes require a new `.vN` topic, never an in-place change.
119
+
120
+ ## Usage examples
121
+
122
+ ```ts
123
+ import {
124
+ createLogger, publishEvent, createConsumer, TOPICS, KAFKA_GROUPS,
125
+ pubFused, subChannels, PUB_SUB_CHANNELS, getEnv, ENV_VARS,
126
+ } from "@aeroswarm/core";
127
+ import type { FusedTrackEvent } from "@aeroswarm/core";
128
+
129
+ const log = createLogger("my-service");
130
+
131
+ // Publish a fused track to both transports (Kafka = durable, Redis = low-latency fan-out)
132
+ function emit(event: FusedTrackEvent) {
133
+ pubFused(event);
134
+ publishEvent(TOPICS.FUSED, event, event.trackId).catch(() => null);
135
+ }
136
+
137
+ // Consume
138
+ const consumer = await createConsumer({
139
+ groupId: KAFKA_GROUPS.REALTIME,
140
+ topics: [TOPICS.FUSED, TOPICS.HEALTH],
141
+ async handler({ topic, message }) {
142
+ if (!message.value) return;
143
+ log.info({ topic }, "event received");
144
+ },
145
+ });
146
+ // … consumer.disconnect() on shutdown
147
+ ```
148
+
149
+ ## Environment variables
150
+
151
+ | Variable | Default | Description |
152
+ |----------|---------|-------------|
153
+ | `DATABASE_URL` | `postgres://aero:aero@localhost:5432/aeroswarm` | PostgreSQL connection string |
154
+ | `DATABASE_POOL_MAX` | `10` | Max pool connections |
155
+ | `REDIS_URL` | `redis://localhost:6379` | Redis URL (or use `REDIS_SENTINEL_URLS` + `REDIS_SENTINEL_MASTER`) |
156
+ | `REDIS_KEY_PREFIX` | `aero:` | Key namespace |
157
+ | `KAFKA_BROKERS` | `localhost:19092` | Comma-separated broker list |
158
+ | `KAFKA_CLIENT_ID` | `aeroswarm` | Kafka client id |
159
+ | `KAFKA_ALLOW_CUSTOM_TOPICS` | unset | Allow publishing to topics outside the registry |
160
+ | `LOG_LEVEL` | `info` | Pino level |
161
+ | `NODE_ENV` | `development` | Runtime environment |
162
+
163
+ ## Building & testing
164
+
165
+ ```bash
166
+ pnpm run build # tsup → dist/ (root + /utils /validation /types subpath bundles)
167
+ pnpm run typecheck
168
+ pnpm test
169
+ ```
File without changes
@@ -0,0 +1,128 @@
1
+ // src/validation/index.ts
2
+ import { z } from "zod";
3
+ var positionSchema = z.object({
4
+ lat: z.number().min(-90).max(90),
5
+ lon: z.number().min(-180).max(180),
6
+ alt: z.number().optional(),
7
+ altSource: z.enum(["baro", "gnss", "mlat"]).optional(),
8
+ onGround: z.boolean().optional()
9
+ });
10
+ var velocitySchema = z.object({
11
+ groundSpeed: z.number().nonnegative().optional(),
12
+ heading: z.number().min(-1).max(361).optional(),
13
+ verticalRate: z.number().optional()
14
+ });
15
+ var currentTrackSchema = z.object({
16
+ trackId: z.string(),
17
+ source: z.enum(["ADSB", "ASTERIX_CAT010", "ASTERIX_CAT011", "ASTERIX_CAT020", "ASTERIX_CAT021", "ASTERIX_CAT048", "ASTERIX_CAT062", "FUSED"]),
18
+ icao24: z.string().nullable(),
19
+ callsign: z.string().nullable(),
20
+ squawk: z.string().nullable(),
21
+ targetType: z.enum(["aircraft", "vehicle", "unknown"]).optional(),
22
+ vehicleFleet: z.number().optional(),
23
+ airportId: z.string().optional(),
24
+ lat: z.number().min(-90).max(90).nullable(),
25
+ lon: z.number().min(-180).max(180).nullable(),
26
+ altitudeFt: z.number().nullable(),
27
+ groundSpeedKt: z.number().nullable(),
28
+ headingDeg: z.number().min(-1).max(361).nullable(),
29
+ verticalRateFpm: z.number().nullable(),
30
+ confidence: z.number().min(0).max(1),
31
+ lastSeenAt: z.number(),
32
+ status: z.enum(["active", "stale", "lost"])
33
+ });
34
+ var streamEnvelopeSchema = z.discriminatedUnion("type", [
35
+ z.object({
36
+ type: z.literal("snapshot"),
37
+ seq: z.number(),
38
+ serverTime: z.number(),
39
+ payload: z.object({ tracks: z.array(currentTrackSchema) })
40
+ }),
41
+ z.object({
42
+ type: z.literal("track.updated"),
43
+ seq: z.number(),
44
+ serverTime: z.number(),
45
+ payload: z.object({ track: currentTrackSchema })
46
+ }),
47
+ z.object({
48
+ type: z.literal("track.removed"),
49
+ seq: z.number(),
50
+ serverTime: z.number(),
51
+ payload: z.object({ trackId: z.string(), reason: z.string() })
52
+ }),
53
+ z.object({
54
+ type: z.literal("source.status"),
55
+ seq: z.number(),
56
+ serverTime: z.number(),
57
+ payload: z.object({ sourceId: z.string(), status: z.enum(["healthy", "degraded", "offline"]) })
58
+ }),
59
+ z.object({
60
+ type: z.literal("stream.heartbeat"),
61
+ seq: z.number(),
62
+ serverTime: z.number(),
63
+ payload: z.object({}).strict()
64
+ }),
65
+ z.object({
66
+ type: z.literal("stream.degraded"),
67
+ seq: z.number(),
68
+ serverTime: z.number(),
69
+ payload: z.object({ reason: z.string(), sourceId: z.string().optional() })
70
+ }),
71
+ z.object({
72
+ type: z.literal("stream.error"),
73
+ seq: z.number(),
74
+ serverTime: z.number(),
75
+ payload: z.object({ code: z.string(), message: z.string().optional(), recoverable: z.boolean() })
76
+ })
77
+ ]);
78
+ var sourceHeartbeatSchema = z.object({
79
+ sourceId: z.string(),
80
+ timestamp: z.number(),
81
+ packetsReceived: z.number().int(),
82
+ packetsSent: z.number().int(),
83
+ bufferFillRatio: z.number().min(0).max(1),
84
+ latencyMs: z.number().nonnegative(),
85
+ protocol: z.enum(["beast", "sbs", "asterix-cat021", "asterix-cat048"]),
86
+ receiverVersion: z.string().optional()
87
+ });
88
+ var wsSubscribeSchema = z.object({
89
+ type: z.literal("subscribe"),
90
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
91
+ source: z.enum(["fused", "normalized"]).default("fused"),
92
+ minConfidence: z.number().min(0).max(1).optional(),
93
+ maxTracks: z.number().int().min(1).max(1e4).optional(),
94
+ mode: z.enum(["delta", "full"]).default("delta"),
95
+ maxUpdateHz: z.number().min(1).max(60).default(20),
96
+ encoding: z.enum(["json", "msgpack"]).default("json"),
97
+ airportId: z.string().min(2).max(8).optional()
98
+ });
99
+ var wsUpdateFilterSchema = z.object({
100
+ type: z.literal("update-filter"),
101
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
102
+ minConfidence: z.number().min(0).max(1).optional(),
103
+ maxUpdateHz: z.number().min(1).max(60).optional(),
104
+ maxTracks: z.number().int().min(1).max(1e4).nullable().optional()
105
+ });
106
+ var apiSuccessSchema = (dataSchema) => z.object({ ok: z.literal(true), data: dataSchema });
107
+ var apiErrorSchema = z.object({
108
+ ok: z.literal(false),
109
+ error: z.object({
110
+ code: z.string(),
111
+ message: z.string(),
112
+ recoverable: z.boolean()
113
+ })
114
+ });
115
+ var apiResponseSchema = (dataSchema) => z.union([apiSuccessSchema(dataSchema), apiErrorSchema]);
116
+
117
+ export {
118
+ positionSchema,
119
+ velocitySchema,
120
+ currentTrackSchema,
121
+ streamEnvelopeSchema,
122
+ sourceHeartbeatSchema,
123
+ wsSubscribeSchema,
124
+ wsUpdateFilterSchema,
125
+ apiSuccessSchema,
126
+ apiErrorSchema,
127
+ apiResponseSchema
128
+ };
@@ -0,0 +1,137 @@
1
+ // src/utils/index.ts
2
+ import { createHash, randomBytes } from "crypto";
3
+ function createEventId(sourceId, icao24, ts) {
4
+ return createHash("sha256").update(`${sourceId}:${icao24}:${ts}`).digest("hex").slice(0, 16);
5
+ }
6
+ function createNanoid(size = 12) {
7
+ return randomBytes(size).toString("base64url").slice(0, size);
8
+ }
9
+ function createTrackId() {
10
+ return `trk_${createNanoid()}`;
11
+ }
12
+ function createSessionId() {
13
+ return `session_${createNanoid()}`;
14
+ }
15
+ function createEventUid() {
16
+ return `evt_${createNanoid(16)}`;
17
+ }
18
+ function isInsideBbox(lat, lon, bbox) {
19
+ const [minLon, minLat, maxLon, maxLat] = bbox;
20
+ return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat;
21
+ }
22
+ function haversineKm(lat1, lon1, lat2, lon2) {
23
+ const R = 6371;
24
+ const dLat = (lat2 - lat1) * Math.PI / 180;
25
+ const dLon = (lon2 - lon1) * Math.PI / 180;
26
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
27
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
28
+ }
29
+ function clamp(val, min, max) {
30
+ return Math.min(Math.max(val, min), max);
31
+ }
32
+ function distanceToPolylineM(lat, lon, points) {
33
+ if (points.length < 2) return null;
34
+ const kY = 111320;
35
+ const kX = 111320 * Math.cos(lat * Math.PI / 180);
36
+ let best = null;
37
+ let cumLen = 0;
38
+ let totalLen = 0;
39
+ const segLens = [];
40
+ for (let i = 0; i < points.length - 1; i++) {
41
+ const dx = (points[i + 1][1] - points[i][1]) * kX;
42
+ const dy = (points[i + 1][0] - points[i][0]) * kY;
43
+ const len = Math.hypot(dx, dy);
44
+ segLens.push(len);
45
+ totalLen += len;
46
+ }
47
+ if (totalLen === 0) return null;
48
+ for (let i = 0; i < points.length - 1; i++) {
49
+ const [aLat, aLon] = points[i];
50
+ const [bLat, bLon] = points[i + 1];
51
+ const px = (lon - aLon) * kX;
52
+ const py = (lat - aLat) * kY;
53
+ const bx = (bLon - aLon) * kX;
54
+ const by = (bLat - aLat) * kY;
55
+ const L2 = bx * bx + by * by;
56
+ const t = L2 === 0 ? 0 : clamp((px * bx + py * by) / L2, 0, 1);
57
+ const distM = Math.hypot(px - t * bx, py - t * by);
58
+ if (!best || distM < best.distM) {
59
+ best = {
60
+ distM,
61
+ segmentIndex: i,
62
+ progress: (cumLen + t * segLens[i]) / totalLen
63
+ };
64
+ }
65
+ cumLen += segLens[i];
66
+ }
67
+ return best;
68
+ }
69
+ function isRenderableTrack(t) {
70
+ return typeof t.lat === "number" && typeof t.lon === "number" && Number.isFinite(t.lat) && Number.isFinite(t.lon) && t.lat >= -90 && t.lat <= 90 && t.lon >= -180 && t.lon <= 180;
71
+ }
72
+ function toCurrentTrack(fused, staleAfterMs = 15e3, lostAfterMs = 9e4) {
73
+ const age = Date.now() - fused.updatedAt;
74
+ const status = age > lostAfterMs ? "lost" : age > staleAfterMs ? "stale" : "active";
75
+ const src = fused.sources.length > 1 ? "FUSED" : protocolToSource(fused.protocol);
76
+ const rawHeading = fused.velocity?.heading;
77
+ const headingDeg = rawHeading != null ? (rawHeading % 360 + 360) % 360 : null;
78
+ return {
79
+ trackId: fused.trackId,
80
+ source: src,
81
+ icao24: fused.identity.icao24 ?? null,
82
+ callsign: fused.identity.callsign ?? null,
83
+ squawk: fused.identity.squawk ?? null,
84
+ ...fused.identity.targetType !== void 0 && { targetType: fused.identity.targetType },
85
+ ...fused.identity.vehicleFleet !== void 0 && { vehicleFleet: fused.identity.vehicleFleet },
86
+ ...fused.airportId !== void 0 && { airportId: fused.airportId },
87
+ lat: fused.position?.lat ?? null,
88
+ lon: fused.position?.lon ?? null,
89
+ altitudeFt: fused.position?.alt ?? null,
90
+ groundSpeedKt: fused.velocity?.groundSpeed ?? null,
91
+ headingDeg,
92
+ verticalRateFpm: fused.velocity?.verticalRate ?? null,
93
+ confidence: fused.confidence.overall,
94
+ lastSeenAt: fused.updatedAt,
95
+ status
96
+ };
97
+ }
98
+ function protocolToSource(protocol) {
99
+ if (protocol === "asterix-cat010") return "ASTERIX_CAT010";
100
+ if (protocol === "asterix-cat011") return "ASTERIX_CAT011";
101
+ if (protocol === "asterix-cat020") return "ASTERIX_CAT020";
102
+ if (protocol === "asterix-cat021") return "ASTERIX_CAT021";
103
+ if (protocol === "asterix-cat048") return "ASTERIX_CAT048";
104
+ if (protocol === "asterix-cat062") return "ASTERIX_CAT062";
105
+ return "ADSB";
106
+ }
107
+ var EMERGENCY_SQUAWKS = /* @__PURE__ */ new Set(["7500", "7600", "7700"]);
108
+ var MACH1_KT = 661.47;
109
+ var TOPICS = {
110
+ FUSED: "aero.fused.track.v1",
111
+ TRUST: "aero.trust.assessment.v1",
112
+ HEALTH: "aero.source.health.v1",
113
+ LIFECYCLE: "aero.track.lifecycle.v1",
114
+ DEADLETTER: "aero.deadletter.v1",
115
+ MOVEMENT: "aero.movement.v1"
116
+ };
117
+ var STALE_AFTER_MS = 15e3;
118
+ var LOST_AFTER_MS = 9e4;
119
+
120
+ export {
121
+ createEventId,
122
+ createNanoid,
123
+ createTrackId,
124
+ createSessionId,
125
+ createEventUid,
126
+ isInsideBbox,
127
+ haversineKm,
128
+ clamp,
129
+ distanceToPolylineM,
130
+ isRenderableTrack,
131
+ toCurrentTrack,
132
+ EMERGENCY_SQUAWKS,
133
+ MACH1_KT,
134
+ TOPICS,
135
+ STALE_AFTER_MS,
136
+ LOST_AFTER_MS
137
+ };