@neilberkman/sidereon 0.39.1 → 1.0.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.0.1 - 2026-08-22
6
+
7
+ ### Changed
8
+
9
+ - engine update: sidereon-core 1.0.1 with trust-region-least-squares 0.10.0 (unified fail-closed HostNumerics backend seam; host power dispatch reproduces NumPy's stride-0 scalar-exponent fast paths bit-for-bit). No interface API changes.
10
+
11
+ ## 1.0.0 - 2026-08-21
12
+
13
+ Sidereon 1.0.0 across every interface; additions arrive without breaking
14
+ existing callers from here.
15
+
16
+ ### Added
17
+
18
+ - Exact-cache single-flight opens in the browser cache (IndexedDB
19
+ transaction election; liveness rules stay in Rust).
20
+ - Window-scoped continuity verdicts (`Sp3.stencilExtent`,
21
+ `continuityVerdict`, merge-report equivalent) and `nextIssueDue`.
22
+
23
+ ### Changed
24
+
25
+ - Engine pinned to `sidereon-core` 1.0.0.
26
+
5
27
  ## 0.39.1 - 2026-08-11
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -243,19 +243,30 @@ commit builder/verifier as the other interfaces:
243
243
  import { BrowserExactProductCache } from "@neilberkman/sidereon/exact-cache";
244
244
 
245
245
  const cache = await BrowserExactProductCache.open();
246
- const entry = await cache.withLock(identity, "nasa_cddis", async (locked) => {
247
- const hit = await locked.read();
248
- if (hit) return hit;
249
- // Fetch and validate first; these are the resulting immutable bytes.
250
- return locked.publish(productBytes, archiveBytes, provenanceBytes);
251
- });
246
+ const opened = await cache.openSingleFlight(identity, "nasa_cddis");
247
+ let entry;
248
+ if (opened.kind === "hit") {
249
+ entry = opened.entry;
250
+ } else {
251
+ const owner = opened.owner;
252
+ try {
253
+ // Only the owner fetches and validates. These are the resulting immutable bytes.
254
+ entry = await owner.publish(productBytes, archiveBytes, provenanceBytes);
255
+ } catch (error) {
256
+ await owner.abandon().catch(() => {});
257
+ throw error;
258
+ }
259
+ }
252
260
  ```
253
261
 
254
- Web Locks coordinate same-origin tabs and workers with a bounded wait.
255
- IndexedDB publishes the immutable entry and marker in one strict-durability
256
- transaction; reads return bytes only after shared schema-v3 verification. Node
257
- hosts can call `buildExactCacheCommit` and `verifyExactCacheCommit` with their
258
- own storage transaction.
262
+ IndexedDB transactions elect one owner across same-origin tabs and workers.
263
+ The owner heartbeat and every takeover decision use the engine's bounded
264
+ single-flight policy. Publication writes the immutable entry and marker in one
265
+ strict-durability transaction; reads return bytes only after shared schema-v3
266
+ verification. Call `owner.abandon()` when acquisition stops before publication.
267
+ The legacy `withLock` method remains available for Web Locks callers. Node hosts
268
+ can call `buildExactCacheCommit` and `verifyExactCacheCommit` with their own
269
+ storage transaction.
259
270
 
260
271
  Send credentials only to NASA's documented hosts;
261
272
  remove URL queries from diagnostics; reject HTML success bodies; validate
package/exact-cache.js CHANGED
@@ -1,7 +1,43 @@
1
- import { buildExactCacheCommit, verifyExactCacheCommit } from "./pkg/sidereon.js";
1
+ import {
2
+ buildExactCacheCommit,
3
+ ExactCacheSingleFlightWait as CoreExactCacheSingleFlightWait,
4
+ verifyExactCacheCommit,
5
+ } from "./pkg/sidereon.js";
2
6
 
3
7
  const MARKERS = "markers";
4
8
  const ENTRIES = "entries";
9
+ const IN_FLIGHT = "inFlight";
10
+ const HEARTBEATS = "heartbeats";
11
+ const REVISION_ENCODER = new globalThis.TextEncoder();
12
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
13
+
14
+ const SINGLE_FLIGHT_DEFAULTS = Object.freeze({
15
+ pollIntervalMs: 50,
16
+ heartbeatIntervalMs: 5_000,
17
+ livenessTimeoutMs: 30_000,
18
+ waitTimeoutMs: 30 * 60 * 1_000,
19
+ });
20
+
21
+ export class ExactCacheSingleFlightOptionsError extends TypeError {
22
+ constructor(message = "invalid exact-cache single-flight options", options) {
23
+ super(message, options);
24
+ this.name = "ExactCacheSingleFlightOptionsError";
25
+ }
26
+ }
27
+
28
+ export class ExactCacheSingleFlightTimeoutError extends Error {
29
+ constructor(message = "timed out waiting for the exact-cache in-flight owner", options) {
30
+ super(message, options);
31
+ this.name = "ExactCacheSingleFlightTimeoutError";
32
+ }
33
+ }
34
+
35
+ export class ExactCacheSingleFlightOwnershipLostError extends Error {
36
+ constructor(message = "exact-cache single-flight ownership was lost", options) {
37
+ super(message, options);
38
+ this.name = "ExactCacheSingleFlightOwnershipLostError";
39
+ }
40
+ }
5
41
 
6
42
  function request(request) {
7
43
  return new Promise((resolve, reject) => {
@@ -14,10 +50,28 @@ function complete(transaction) {
14
50
  return new Promise((resolve, reject) => {
15
51
  transaction.oncomplete = () => resolve();
16
52
  transaction.onerror = () => reject(transaction.error);
17
- transaction.onabort = () => reject(transaction.error ?? new Error("exact-cache transaction aborted"));
53
+ transaction.onabort = () =>
54
+ reject(transaction.error ?? new Error("exact-cache transaction aborted"));
18
55
  });
19
56
  }
20
57
 
58
+ async function runTransaction(transaction, operation) {
59
+ const completion = complete(transaction);
60
+ try {
61
+ const result = await operation();
62
+ await completion;
63
+ return result;
64
+ } catch (error) {
65
+ try {
66
+ transaction.abort();
67
+ } catch {
68
+ // The request may already have aborted or completed the transaction.
69
+ }
70
+ await completion.catch(() => {});
71
+ throw error;
72
+ }
73
+ }
74
+
21
75
  function bytes(value) {
22
76
  return value instanceof Uint8Array ? value : new Uint8Array(value);
23
77
  }
@@ -36,11 +90,83 @@ function entryKey(base, entry) {
36
90
  return `${base}\0${entry}`;
37
91
  }
38
92
 
93
+ function monotonicNow() {
94
+ if (typeof globalThis.performance?.now !== "function") {
95
+ throw new Error("BrowserExactProductCache requires performance.now()");
96
+ }
97
+ return globalThis.performance.now();
98
+ }
99
+
100
+ function timerDelay(milliseconds) {
101
+ return Math.min(milliseconds, MAX_TIMER_DELAY_MS);
102
+ }
103
+
104
+ function waitFor(milliseconds) {
105
+ return new Promise((resolve) => globalThis.setTimeout(resolve, timerDelay(milliseconds)));
106
+ }
107
+
108
+ function ownerRevision(inFlight, heartbeat) {
109
+ return JSON.stringify([inFlight, heartbeat]);
110
+ }
111
+
112
+ function singleFlightWait(options, startedMs) {
113
+ if (options === undefined) options = {};
114
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
115
+ throw new ExactCacheSingleFlightOptionsError();
116
+ }
117
+ const normalized = {
118
+ pollIntervalMs: options.pollIntervalMs ?? SINGLE_FLIGHT_DEFAULTS.pollIntervalMs,
119
+ heartbeatIntervalMs: options.heartbeatIntervalMs ?? SINGLE_FLIGHT_DEFAULTS.heartbeatIntervalMs,
120
+ livenessTimeoutMs: options.livenessTimeoutMs ?? SINGLE_FLIGHT_DEFAULTS.livenessTimeoutMs,
121
+ waitTimeoutMs: options.waitTimeoutMs ?? SINGLE_FLIGHT_DEFAULTS.waitTimeoutMs,
122
+ };
123
+ try {
124
+ return {
125
+ heartbeatIntervalMs: normalized.heartbeatIntervalMs,
126
+ wait: new CoreExactCacheSingleFlightWait(
127
+ startedMs,
128
+ normalized.pollIntervalMs,
129
+ normalized.heartbeatIntervalMs,
130
+ normalized.livenessTimeoutMs,
131
+ normalized.waitTimeoutMs,
132
+ ),
133
+ };
134
+ } catch (cause) {
135
+ throw new ExactCacheSingleFlightOptionsError(undefined, { cause });
136
+ }
137
+ }
138
+
139
+ async function readCommitted(transaction, identity, source, base) {
140
+ const committed = await request(transaction.objectStore(MARKERS).get(base));
141
+ if (committed === undefined) return null;
142
+ const entry = await request(
143
+ transaction.objectStore(ENTRIES).get(entryKey(base, committed.entry)),
144
+ );
145
+ if (entry === undefined) {
146
+ throw new Error("committed exact-cache entry is incomplete");
147
+ }
148
+ const product = bytes(entry.product);
149
+ const archive = bytes(entry.archive);
150
+ const provenance = bytes(entry.provenance);
151
+ const marker = bytes(committed.marker);
152
+ const verified = verifyExactCacheCommit(identity, source, marker, product, archive, provenance);
153
+ if (verified !== committed.entry) {
154
+ throw new Error("exact-cache entry identifier mismatch");
155
+ }
156
+ return {
157
+ entryId: verified,
158
+ product,
159
+ archive,
160
+ provenance,
161
+ marker,
162
+ };
163
+ }
164
+
39
165
  async function openDatabase(name) {
40
166
  if (typeof globalThis.indexedDB === "undefined") {
41
167
  throw new Error("BrowserExactProductCache requires IndexedDB");
42
168
  }
43
- const open = globalThis.indexedDB.open(name, 3);
169
+ const open = globalThis.indexedDB.open(name, 4);
44
170
  open.onupgradeneeded = () => {
45
171
  if (!open.result.objectStoreNames.contains(MARKERS)) {
46
172
  open.result.createObjectStore(MARKERS);
@@ -48,6 +174,12 @@ async function openDatabase(name) {
48
174
  if (!open.result.objectStoreNames.contains(ENTRIES)) {
49
175
  open.result.createObjectStore(ENTRIES);
50
176
  }
177
+ if (!open.result.objectStoreNames.contains(IN_FLIGHT)) {
178
+ open.result.createObjectStore(IN_FLIGHT);
179
+ }
180
+ if (!open.result.objectStoreNames.contains(HEARTBEATS)) {
181
+ open.result.createObjectStore(HEARTBEATS);
182
+ }
51
183
  };
52
184
  return request(open);
53
185
  }
@@ -118,9 +250,130 @@ class LockedExactCache {
118
250
  }
119
251
  }
120
252
 
253
+ export class ExactCacheSingleFlightOwner {
254
+ constructor(cache, identity, source, base, ownerToken, heartbeatIntervalMs) {
255
+ this.cache = cache;
256
+ this.identity = identity;
257
+ this.source = source;
258
+ this.base = base;
259
+ this.ownerToken = ownerToken;
260
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
261
+ this.state = "active";
262
+ this.heartbeatError = null;
263
+ this.heartbeatTail = Promise.resolve();
264
+ this.heartbeatTimer = undefined;
265
+ this._scheduleHeartbeat();
266
+ }
267
+
268
+ _ownershipLost(cause) {
269
+ return cause instanceof ExactCacheSingleFlightOwnershipLostError
270
+ ? cause
271
+ : new ExactCacheSingleFlightOwnershipLostError(undefined, { cause });
272
+ }
273
+
274
+ _ensureActive() {
275
+ if (this.state !== "active") {
276
+ throw new ExactCacheSingleFlightOwnershipLostError();
277
+ }
278
+ if (this.heartbeatError !== null) throw this.heartbeatError;
279
+ }
280
+
281
+ _stopHeartbeat() {
282
+ if (this.heartbeatTimer !== undefined) {
283
+ globalThis.clearTimeout(this.heartbeatTimer);
284
+ this.heartbeatTimer = undefined;
285
+ }
286
+ }
287
+
288
+ _scheduleHeartbeat() {
289
+ if (this.state !== "active" || this.heartbeatError !== null) return;
290
+ this.heartbeatTimer = globalThis.setTimeout(() => {
291
+ this.heartbeatTimer = undefined;
292
+ void this._queueHeartbeat().then(
293
+ () => this._scheduleHeartbeat(),
294
+ () => {},
295
+ );
296
+ }, timerDelay(this.heartbeatIntervalMs));
297
+ }
298
+
299
+ _queueHeartbeat() {
300
+ try {
301
+ this._ensureActive();
302
+ } catch (error) {
303
+ return Promise.reject(error);
304
+ }
305
+ const attempt = this.heartbeatTail.then(() =>
306
+ this.cache._heartbeatSingleFlight(this.base, this.ownerToken),
307
+ );
308
+ this.heartbeatTail = attempt.catch((error) => {
309
+ this.heartbeatError = this._ownershipLost(error);
310
+ this._stopHeartbeat();
311
+ });
312
+ return attempt;
313
+ }
314
+
315
+ heartbeat() {
316
+ return this._queueHeartbeat();
317
+ }
318
+
319
+ async publish(product, archive, provenance) {
320
+ if (this.state !== "active") {
321
+ throw new ExactCacheSingleFlightOwnershipLostError();
322
+ }
323
+ this.state = "publishing";
324
+ this._stopHeartbeat();
325
+ try {
326
+ await this.heartbeatTail;
327
+ if (this.heartbeatError !== null) throw this.heartbeatError;
328
+ product = bytes(product);
329
+ archive = bytes(archive);
330
+ provenance = bytes(provenance);
331
+ const entry = entryId();
332
+ const marker = buildExactCacheCommit(
333
+ this.identity,
334
+ this.source,
335
+ entry,
336
+ product,
337
+ archive,
338
+ provenance,
339
+ );
340
+ const published = await this.cache._publishSingleFlight(
341
+ this.base,
342
+ this.ownerToken,
343
+ entry,
344
+ product,
345
+ archive,
346
+ provenance,
347
+ marker,
348
+ );
349
+ this.state = "published";
350
+ return published;
351
+ } catch (error) {
352
+ await this.cache._abandonSingleFlight(this.base, this.ownerToken).catch(() => {});
353
+ this.state = "abandoned";
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ async abandon() {
359
+ if (this.state !== "active") {
360
+ throw new ExactCacheSingleFlightOwnershipLostError();
361
+ }
362
+ this.state = "abandoning";
363
+ this._stopHeartbeat();
364
+ await this.heartbeatTail;
365
+ try {
366
+ await this.cache._abandonSingleFlight(this.base, this.ownerToken);
367
+ } finally {
368
+ this.state = "abandoned";
369
+ }
370
+ }
371
+ }
372
+
121
373
  /**
122
- * Browser exact-product cache using Web Locks for cross-tab/worker acquisition
123
- * coordination and one durable IndexedDB transaction for entry publication.
374
+ * Browser exact-product cache using IndexedDB transactions for single-flight
375
+ * coordination and durable entry publication. `withLock` retains its legacy
376
+ * Web Locks behavior.
124
377
  */
125
378
  export class BrowserExactProductCache {
126
379
  static async open({ name = "sidereon-exact-products-v3" } = {}) {
@@ -177,62 +430,141 @@ export class BrowserExactProductCache {
177
430
  return this._read(identity, source, baseKey(identity, source));
178
431
  }
179
432
 
180
- _read(identity, source, base) {
181
- return new Promise((resolve, reject) => {
182
- const transaction = this.database.transaction([MARKERS, ENTRIES], "readonly");
183
- const markerRequest = transaction.objectStore(MARKERS).get(base);
184
- let result;
185
- markerRequest.onerror = () => reject(markerRequest.error);
186
- markerRequest.onsuccess = () => {
187
- const committed = markerRequest.result;
188
- if (!committed) {
189
- result = null;
190
- return;
191
- }
192
- const entryRequest = transaction
193
- .objectStore(ENTRIES)
194
- .get(entryKey(base, committed.entry));
195
- entryRequest.onerror = () => reject(entryRequest.error);
196
- entryRequest.onsuccess = () => {
197
- const entry = entryRequest.result;
198
- if (!entry) {
199
- reject(new Error("committed exact-cache entry is incomplete"));
200
- return;
201
- }
202
- const product = bytes(entry.product);
203
- const archive = bytes(entry.archive);
204
- const provenance = bytes(entry.provenance);
205
- const marker = bytes(committed.marker);
206
- let verified;
207
- try {
208
- verified = verifyExactCacheCommit(
433
+ async _read(identity, source, base) {
434
+ const transaction = this.database.transaction([MARKERS, ENTRIES], "readonly");
435
+ return runTransaction(transaction, () => readCommitted(transaction, identity, source, base));
436
+ }
437
+
438
+ async openSingleFlight(identity, source, options) {
439
+ const startedMs = monotonicNow();
440
+ const { heartbeatIntervalMs, wait } = singleFlightWait(options, startedMs);
441
+ try {
442
+ const base = baseKey(identity, source);
443
+ const ownerToken = entryId();
444
+ let takeoverRevision;
445
+ while (true) {
446
+ const result = await this._openSingleFlightTransaction(
447
+ identity,
448
+ source,
449
+ base,
450
+ ownerToken,
451
+ takeoverRevision,
452
+ );
453
+ takeoverRevision = undefined;
454
+ if (result.kind === "hit") return result;
455
+ if (result.kind === "owner") {
456
+ return {
457
+ kind: "owner",
458
+ owner: new ExactCacheSingleFlightOwner(
459
+ this,
209
460
  identity,
210
461
  source,
211
- marker,
212
- product,
213
- archive,
214
- provenance,
215
- );
216
- } catch (error) {
217
- reject(error);
218
- return;
219
- }
220
- if (verified !== committed.entry) {
221
- reject(new Error("exact-cache entry identifier mismatch"));
222
- return;
223
- }
224
- result = {
225
- entryId: verified,
226
- product,
227
- archive,
228
- provenance,
229
- marker,
462
+ base,
463
+ ownerToken,
464
+ heartbeatIntervalMs,
465
+ ),
230
466
  };
231
- };
232
- };
233
- transaction.onerror = () => reject(transaction.error);
234
- transaction.onabort = () => reject(transaction.error ?? new Error("exact-cache read aborted"));
235
- transaction.oncomplete = () => resolve(result);
467
+ }
468
+
469
+ const decision = wait.observe(monotonicNow(), REVISION_ENCODER.encode(result.revision));
470
+ if (decision.action === "wait") {
471
+ await waitFor(decision.delayMs);
472
+ } else if (decision.action === "takeover") {
473
+ takeoverRevision = result.revision;
474
+ } else {
475
+ throw new ExactCacheSingleFlightTimeoutError();
476
+ }
477
+ }
478
+ } finally {
479
+ wait.free();
480
+ }
481
+ }
482
+
483
+ _openSingleFlightTransaction(identity, source, base, ownerToken, takeoverRevision) {
484
+ const transaction = this.database.transaction(
485
+ [MARKERS, ENTRIES, IN_FLIGHT, HEARTBEATS],
486
+ "readwrite",
487
+ { durability: "strict" },
488
+ );
489
+ return runTransaction(transaction, async () => {
490
+ const committed = await readCommitted(transaction, identity, source, base);
491
+ if (committed !== null) return { kind: "hit", entry: committed };
492
+
493
+ const inFlightStore = transaction.objectStore(IN_FLIGHT);
494
+ const heartbeatStore = transaction.objectStore(HEARTBEATS);
495
+ const inFlight = await request(inFlightStore.get(base));
496
+ const heartbeat = await request(heartbeatStore.get(base));
497
+ const revision = ownerRevision(inFlight, heartbeat);
498
+ if (
499
+ inFlight === undefined ||
500
+ (takeoverRevision !== undefined && takeoverRevision === revision)
501
+ ) {
502
+ inFlightStore.put({ ownerToken }, base);
503
+ heartbeatStore.put({ ownerToken, revision: 0 }, base);
504
+ return { kind: "owner" };
505
+ }
506
+ return { kind: "waiter", revision };
507
+ });
508
+ }
509
+
510
+ _heartbeatSingleFlight(base, ownerToken) {
511
+ const transaction = this.database.transaction([IN_FLIGHT, HEARTBEATS], "readwrite", {
512
+ durability: "strict",
513
+ });
514
+ return runTransaction(transaction, async () => {
515
+ const inFlight = await request(transaction.objectStore(IN_FLIGHT).get(base));
516
+ const heartbeatStore = transaction.objectStore(HEARTBEATS);
517
+ const heartbeat = await request(heartbeatStore.get(base));
518
+ if (
519
+ inFlight?.ownerToken !== ownerToken ||
520
+ heartbeat?.ownerToken !== ownerToken ||
521
+ !Number.isSafeInteger(heartbeat.revision) ||
522
+ heartbeat.revision < 0 ||
523
+ heartbeat.revision === Number.MAX_SAFE_INTEGER
524
+ ) {
525
+ throw new ExactCacheSingleFlightOwnershipLostError();
526
+ }
527
+ heartbeatStore.put({ ownerToken, revision: heartbeat.revision + 1 }, base);
528
+ });
529
+ }
530
+
531
+ _publishSingleFlight(base, ownerToken, entry, product, archive, provenance, marker) {
532
+ const transaction = this.database.transaction(
533
+ [MARKERS, ENTRIES, IN_FLIGHT, HEARTBEATS],
534
+ "readwrite",
535
+ { durability: "strict" },
536
+ );
537
+ return runTransaction(transaction, async () => {
538
+ const inFlight = await request(transaction.objectStore(IN_FLIGHT).get(base));
539
+ if (inFlight?.ownerToken !== ownerToken) {
540
+ throw new ExactCacheSingleFlightOwnershipLostError();
541
+ }
542
+ transaction.objectStore(ENTRIES).add(
543
+ {
544
+ product: product.slice(),
545
+ archive: archive.slice(),
546
+ provenance: provenance.slice(),
547
+ },
548
+ entryKey(base, entry),
549
+ );
550
+ transaction.objectStore(MARKERS).put({ entry, marker: marker.slice() }, base);
551
+ transaction.objectStore(IN_FLIGHT).delete(base);
552
+ transaction.objectStore(HEARTBEATS).delete(base);
553
+ return { entryId: entry, product, archive, provenance, marker };
554
+ });
555
+ }
556
+
557
+ _abandonSingleFlight(base, ownerToken) {
558
+ const transaction = this.database.transaction([IN_FLIGHT, HEARTBEATS], "readwrite", {
559
+ durability: "strict",
560
+ });
561
+ return runTransaction(transaction, async () => {
562
+ const inFlightStore = transaction.objectStore(IN_FLIGHT);
563
+ const inFlight = await request(inFlightStore.get(base));
564
+ if (inFlight?.ownerToken === ownerToken) {
565
+ inFlightStore.delete(base);
566
+ transaction.objectStore(HEARTBEATS).delete(base);
567
+ }
236
568
  });
237
569
  }
238
570
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neilberkman/sidereon",
3
- "version": "0.39.1",
3
+ "version": "1.0.1",
4
4
  "description": "WebAssembly / JavaScript interface over the sidereon GNSS + astrodynamics engine: SP3 loading and multi-product merge, SPP / RTK (float + fixed) / PPP (float + fixed) positioning, IONEX slant delay, SGP4 and numerical orbit propagation, RINEX observation/navigation/clock parsing, CRINEX (Hatanaka) decoding, observable-domain math (carrier combinations, cycle slips, Hatch smoothing, weighting, velocity, GPS C/A signal), and the astro domain (scale-tagged time, frame transforms, Sun/Moon ephemerides, eclipse / body angles, DOP, passes / visibility, conjunction + collision probability, CDM / OMM / ANTEX parsing, RF link budget)",
5
5
  "type": "module",
6
6
  "license": "MIT",