From 27a986f40f10aa2f12f58cea5637d08fdbbc4b1b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 20:50:19 -0700 Subject: [PATCH] Accept authoritative empty vessel coverage without an outage --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 6 ++++ src/layers/vessels/ingestion.js | 2 +- src/layers/vessels/ownership.test.mjs | 44 +++++++++++++++++++++++++++ src/layers/vessels/queries.js | 16 ++++++++-- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a93296425..68ab1c52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Treat explicitly current, complete empty vessel coverage as a successful update, + clearing obsolete contacts while retaining stale or incomplete snapshots. + - Custom Street Level provider switches survive share links and stored state using URL-safe stable provider IDs and a shared boolean-switch codec; existing Mapillary links keep their meaning. Explicit provider toggles reach durable diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc9a9278b..274294b5d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Vessel sources can report healthy empty coverage using a current, complete +snapshot with zero raw rows, a healthy transport and a positive last-message +time. This clears obsolete contacts and settles startup without manufacturing +position timestamps. Stale, incomplete and malformed empty results retain the +existing recovery behavior. + Catalog source contracts and their consuming layers are declared in `src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer registration and serialization, report unavailable, and are refused by the existing diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index fb7597a31..408e3bec2 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -109,7 +109,7 @@ export function createIngestion({ feed.acceptedRowCount = snapshot.acceptedRowCount; feed.partial = payload?.complete === false; - if (snapshot.acceptedRowCount === 0) { + if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) { feed.count = readCount(); feed.stale = feed.count > 0 || Boolean(payload?.refreshing); if (isDefinitiveTransportFailure(snapshot.transportStatus)) { diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index ce55aaf0f..28455a293 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u layer.destroy(); assert.equal(layer.getStats().partial, false); }); + +test('authoritative empty coverage clears old contacts without declaring an outage', async () => { + let current = snapshot([observation('111')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([], { observedAtMs: null, lastMessageAt: 500 }); + await layer.update(); + assert.ok(!layer.hasContact('111')); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().lastUpdate, null); +}); + +test('empty coverage cannot hide stale, incomplete or rejected source data', async () => { + for (const patch of [ + { freshness: 'unknown' }, + { freshness: 'stale' }, + { complete: false }, + { rawRowCount: 1 }, + { transportStatus: 'stale' }, + { lastMessageAt: null }, + ]) { + let current = snapshot([observation('111')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([], { + observedAtMs: null, + lastMessageAt: 500, + ...patch, + }); + await layer.update(); + assert.equal(layer.hasContact('111'), true, JSON.stringify(patch)); + assert.ok(layer.getStats().error, JSON.stringify(patch)); + } +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 520af4b0b..7b06ee23b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -119,7 +119,16 @@ export function createQueries({ typeof payload?.status === 'string' ? payload.status : null; const lastMessageAt = payload?.lastMessageAt ?? null; const acceptedRowCount = acceptedRows.length; + const emptyCoverage = + rawRows.length === 0 && + payload?.rawRowCount === 0 && + payload?.complete === true && + payload?.freshness === 'current' && + AIS_HEALTHY_STATUSES.has(transportStatus) && + Number.isFinite(Number(lastMessageAt)) && + Number(lastMessageAt) > 0; return { + emptyCoverage, transportStatus, lastMessageAt, rawRows, @@ -130,9 +139,10 @@ export function createQueries({ ? payload.rawRowCount : rawRows.length, acceptedRowCount, - error: - deriveAisFeedError(payload, acceptedRowCount) || - (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null), + error: emptyCoverage + ? null + : deriveAisFeedError(payload, acceptedRowCount) || + (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null), }; }