@neilberkman/sidereon 0.29.1 → 0.30.0

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
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.30.0 - 2026-07-16
4
+
5
+ - Exposes analysis center, parsed format version, and the canonical all-field
6
+ cache key on exact product identities.
7
+ - Adds the shared schema-v3 commit builder and verifier, binding the full
8
+ identity, explicit source, and all immutable byte objects.
9
+ - Adds `@neilberkman/sidereon/exact-cache`, using Web Locks for bounded
10
+ same-origin tab/worker coordination and one strict-durability IndexedDB
11
+ transaction for atomic immutable-entry publication.
12
+ - Builds against `sidereon` and `sidereon-core` 0.30.0.
13
+
14
+ ## 0.29.2 - 2026-07-16
15
+
16
+ - Adds `GnssExactProductSet`, a fail-closed gate for a declared exact identity
17
+ inventory. Empty declarations, duplicates, missing products, and undeclared
18
+ products are rejected.
19
+ - Preserves prediction-tier identity during exact-set comparison. SP3
20
+ observed/predicted timing remains available from the parser's authoritative
21
+ record-flag summary.
22
+ - Builds against `sidereon` and `sidereon-core` 0.29.2.
4
23
 
5
24
  ## 0.29.1 - 2026-07-15
6
25
 
package/README.md CHANGED
@@ -108,7 +108,11 @@ Product identity and distribution are separate. The pure derivation functions
108
108
  perform no network or file IO:
109
109
 
110
110
  ```js
111
- import { distributionLocation, productIdentity } from "@neilberkman/sidereon";
111
+ import {
112
+ distributionLocation,
113
+ GnssExactProductSet,
114
+ productIdentity,
115
+ } from "@neilberkman/sidereon";
112
116
 
113
117
  const identity = productIdentity("cod", "sp3", 2026, 7, 12);
114
118
  console.log(identity.officialFilename);
@@ -126,6 +130,11 @@ const cddis = distributionLocation(
126
130
  );
127
131
  console.log(cddis.originalUrl);
128
132
  console.log(cddis.compression); // gzip
133
+
134
+ const exactSet = new GnssExactProductSet();
135
+ exactSet.addExpected(identity);
136
+ exactSet.addAvailable(identity); // add only after content validation
137
+ exactSet.validate();
129
138
  ```
130
139
 
131
140
  `productIdentity` returns publisher, solution class, campaign, date, issue,
@@ -136,8 +145,36 @@ Changing the source cannot change the exact product. Unsupported combinations
136
145
  throw instead of selecting another center, tier, issue, date, cadence, or
137
146
  family.
138
147
 
148
+ `GnssExactProductSet.validate()` rejects an empty declaration, duplicates,
149
+ missing products, undeclared products, and same-filename identities with
150
+ different prediction metadata. Start dependent processing only after it
151
+ returns successfully. For SP3 observed/predicted timing, use
152
+ `sp3.predictionSummary()`; do not infer the boundary from issue times or catalog
153
+ fields.
154
+
139
155
  Browser and Node callers retain control of `fetch`, Earthdata credentials,
140
- retries, and cache policy. Send credentials only to NASA's documented hosts;
156
+ retries, and product-format validation. The browser cache uses the same WASM
157
+ commit builder/verifier as the other interfaces:
158
+
159
+ ```js
160
+ import { BrowserExactProductCache } from "@neilberkman/sidereon/exact-cache";
161
+
162
+ const cache = await BrowserExactProductCache.open();
163
+ const entry = await cache.withLock(identity, "nasa_cddis", async (locked) => {
164
+ const hit = await locked.read();
165
+ if (hit) return hit;
166
+ // Fetch and validate first; these are the resulting immutable bytes.
167
+ return locked.publish(productBytes, archiveBytes, provenanceBytes);
168
+ });
169
+ ```
170
+
171
+ Web Locks coordinate same-origin tabs and workers with a bounded wait.
172
+ IndexedDB publishes the immutable entry and marker in one strict-durability
173
+ transaction; reads return bytes only after shared schema-v3 verification. Node
174
+ hosts can call `buildExactCacheCommit` and `verifyExactCacheCommit` with their
175
+ own storage transaction.
176
+
177
+ Send credentials only to NASA's documented hosts;
141
178
  remove URL queries from diagnostics; reject HTML success bodies; validate
142
179
  content length, gzip completion, and hashes; then parse with `loadSp3` or
143
180
  `loadIonex`. See [NASA CDDIS archive access](https://www.earthdata.nasa.gov/centers/cddis-daac/archive-access)
package/exact-cache.js ADDED
@@ -0,0 +1,242 @@
1
+ import { buildExactCacheCommit, verifyExactCacheCommit } from "./pkg/sidereon.js";
2
+
3
+ const MARKERS = "markers";
4
+ const ENTRIES = "entries";
5
+
6
+ function request(request) {
7
+ return new Promise((resolve, reject) => {
8
+ request.onsuccess = () => resolve(request.result);
9
+ request.onerror = () => reject(request.error);
10
+ });
11
+ }
12
+
13
+ function complete(transaction) {
14
+ return new Promise((resolve, reject) => {
15
+ transaction.oncomplete = () => resolve();
16
+ transaction.onerror = () => reject(transaction.error);
17
+ transaction.onabort = () => reject(transaction.error ?? new Error("exact-cache transaction aborted"));
18
+ });
19
+ }
20
+
21
+ function bytes(value) {
22
+ return value instanceof Uint8Array ? value : new Uint8Array(value);
23
+ }
24
+
25
+ function entryId() {
26
+ const random = new Uint8Array(16);
27
+ globalThis.crypto.getRandomValues(random);
28
+ return Array.from(random, (value) => value.toString(16).padStart(2, "0")).join("");
29
+ }
30
+
31
+ function baseKey(identity, source) {
32
+ return `${source}\0${identity.cacheKey}`;
33
+ }
34
+
35
+ function entryKey(base, entry) {
36
+ return `${base}\0${entry}`;
37
+ }
38
+
39
+ async function openDatabase(name) {
40
+ if (typeof globalThis.indexedDB === "undefined") {
41
+ throw new Error("BrowserExactProductCache requires IndexedDB");
42
+ }
43
+ const open = globalThis.indexedDB.open(name, 3);
44
+ open.onupgradeneeded = () => {
45
+ if (!open.result.objectStoreNames.contains(MARKERS)) {
46
+ open.result.createObjectStore(MARKERS);
47
+ }
48
+ if (!open.result.objectStoreNames.contains(ENTRIES)) {
49
+ open.result.createObjectStore(ENTRIES);
50
+ }
51
+ };
52
+ return request(open);
53
+ }
54
+
55
+ class LockedExactCache {
56
+ constructor(owner, identity, source, base) {
57
+ this.owner = owner;
58
+ this.identity = identity;
59
+ this.source = source;
60
+ this.base = base;
61
+ }
62
+
63
+ read() {
64
+ return this.owner._read(this.identity, this.source, this.base);
65
+ }
66
+
67
+ async publish(product, archive, provenance) {
68
+ product = bytes(product);
69
+ archive = bytes(archive);
70
+ provenance = bytes(provenance);
71
+ const entry = entryId();
72
+ const marker = buildExactCacheCommit(
73
+ this.identity,
74
+ this.source,
75
+ entry,
76
+ product,
77
+ archive,
78
+ provenance,
79
+ );
80
+ const transaction = this.owner.database.transaction([MARKERS, ENTRIES], "readwrite", {
81
+ durability: "strict",
82
+ });
83
+ transaction.objectStore(ENTRIES).add(
84
+ {
85
+ product: product.slice(),
86
+ archive: archive.slice(),
87
+ provenance: provenance.slice(),
88
+ },
89
+ entryKey(this.base, entry),
90
+ );
91
+ transaction.objectStore(MARKERS).put({ entry, marker: marker.slice() }, this.base);
92
+ await complete(transaction);
93
+ return { entryId: entry, product, archive, provenance, marker };
94
+ }
95
+
96
+ async cleanupAbandoned() {
97
+ const transaction = this.owner.database.transaction([MARKERS, ENTRIES], "readwrite", {
98
+ durability: "strict",
99
+ });
100
+ const marker = await request(transaction.objectStore(MARKERS).get(this.base));
101
+ const current = marker?.entry;
102
+ const store = transaction.objectStore(ENTRIES);
103
+ const range = globalThis.IDBKeyRange.bound(`${this.base}\0`, `${this.base}\0\uffff`);
104
+ await new Promise((resolve, reject) => {
105
+ const cursor = store.openCursor(range);
106
+ cursor.onerror = () => reject(cursor.error);
107
+ cursor.onsuccess = () => {
108
+ const item = cursor.result;
109
+ if (!item) {
110
+ resolve();
111
+ return;
112
+ }
113
+ if (item.key !== entryKey(this.base, current)) item.delete();
114
+ item.continue();
115
+ };
116
+ });
117
+ await complete(transaction);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Browser exact-product cache using Web Locks for cross-tab/worker acquisition
123
+ * coordination and one durable IndexedDB transaction for entry publication.
124
+ */
125
+ export class BrowserExactProductCache {
126
+ static async open({ name = "sidereon-exact-products-v3" } = {}) {
127
+ return new BrowserExactProductCache(await openDatabase(name));
128
+ }
129
+
130
+ constructor(database) {
131
+ this.database = database;
132
+ }
133
+
134
+ async withLock(identity, source, operation, { timeoutMs = 30_000 } = {}) {
135
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
136
+ throw new TypeError("timeoutMs must be finite and non-negative");
137
+ }
138
+ if (!globalThis.navigator?.locks) {
139
+ throw new Error("BrowserExactProductCache requires the Web Locks API");
140
+ }
141
+ const base = baseKey(identity, source);
142
+ const lockName = `sidereon-exact-cache:${base}`;
143
+ if (timeoutMs === 0) {
144
+ return globalThis.navigator.locks.request(
145
+ lockName,
146
+ { mode: "exclusive", ifAvailable: true },
147
+ (lock) => {
148
+ if (lock === null) throw new Error("timed out waiting for exact-cache lock");
149
+ return operation(new LockedExactCache(this, identity, source, base));
150
+ },
151
+ );
152
+ }
153
+ const controller = new globalThis.AbortController();
154
+ const timer = globalThis.setTimeout(() => controller.abort(), timeoutMs);
155
+ let acquired = false;
156
+ try {
157
+ return await globalThis.navigator.locks.request(
158
+ lockName,
159
+ { mode: "exclusive", signal: controller.signal },
160
+ () => {
161
+ acquired = true;
162
+ globalThis.clearTimeout(timer);
163
+ return operation(new LockedExactCache(this, identity, source, base));
164
+ },
165
+ );
166
+ } catch (error) {
167
+ if (!acquired && controller.signal.aborted) {
168
+ throw new Error("timed out waiting for exact-cache lock", { cause: error });
169
+ }
170
+ throw error;
171
+ } finally {
172
+ globalThis.clearTimeout(timer);
173
+ }
174
+ }
175
+
176
+ read(identity, source) {
177
+ return this._read(identity, source, baseKey(identity, source));
178
+ }
179
+
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(
209
+ identity,
210
+ 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,
230
+ };
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);
236
+ });
237
+ }
238
+
239
+ close() {
240
+ this.database.close();
241
+ }
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neilberkman/sidereon",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
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",
@@ -16,6 +16,10 @@
16
16
  "import": "./pkg/sidereon.js",
17
17
  "require": "./pkg-node/sidereon.js"
18
18
  },
19
+ "./exact-cache": {
20
+ "types": "./types/exact-cache.d.ts",
21
+ "import": "./exact-cache.js"
22
+ },
19
23
  "./types": "./types/sidereon-extra.d.ts"
20
24
  },
21
25
  "types": "./pkg/sidereon.d.ts",
@@ -30,6 +34,8 @@
30
34
  "pkg-node/sidereon.d.ts",
31
35
  "pkg-node/sidereon_bg.wasm",
32
36
  "pkg-node/sidereon_bg.wasm.d.ts",
37
+ "exact-cache.js",
38
+ "types/exact-cache.d.ts",
33
39
  "types/sidereon-extra.d.ts",
34
40
  "README.md"
35
41
  ],
@@ -43,6 +49,7 @@
43
49
  "devDependencies": {
44
50
  "@eslint/js": "^10.0.1",
45
51
  "eslint": "^10.6.0",
52
+ "fake-indexeddb": "^6.2.5",
46
53
  "globals": "^17.7.0",
47
54
  "prettier": "^3.9.1",
48
55
  "typescript": "^6.0.3"