@opennodes/registry 0.1.20 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/search.js CHANGED
@@ -4,8 +4,47 @@ import { inJurisdiction } from '@opennodes/core';
4
4
  export const RANK_COMPONENTS = { price: 0.35, tier: 0.35, probe_success: 0.30 };
5
5
  const TIER_SCORE = { unverified: 0, community: 0.4, verified: 0.8, attested: 1.0 };
6
6
 
7
- /** Flatten indexed nodes into searchable offering summaries. */
7
+ // The assembled catalog (10k+ offerings, ~one store read each) is kept in memory per store and
8
+ // rebuilt only after a write (see watchStoreWrites) or after MAX_AGE_MS as a safety net. Concurrent
9
+ // requests share one rebuild. Callers must not mutate the returned offerings.
10
+ const MAX_AGE_MS = 60_000;
11
+ const catalogs = new WeakMap(); // store -> { items, at, stale, building }
12
+
13
+ /** Mark the cached catalog stale: the next read rebuilds it. */
14
+ export function markCatalogStale(store) {
15
+ const entry = catalogs.get(store);
16
+ if (entry) entry.stale = true;
17
+ }
18
+
19
+ /** Wrap the store's write methods so any change to nodes, cards, probes or observations marks the catalog stale. */
20
+ export function watchStoreWrites(store) {
21
+ for (const name of ['upsertNode', 'setState', 'saveRevision', 'recordProbe', 'addFingerprints', 'setObservation']) {
22
+ const write = store[name];
23
+ if (typeof write !== 'function' || write.__marksCatalogStale) continue;
24
+ const wrapped = async function (...args) {
25
+ try { return await write.apply(this, args); } finally { markCatalogStale(store); }
26
+ };
27
+ wrapped.__marksCatalogStale = true;
28
+ store[name] = wrapped;
29
+ }
30
+ return store;
31
+ }
32
+
33
+ /** Searchable offering summaries, from memory when nothing has changed. */
8
34
  export async function collectOfferings(store) {
35
+ let entry = catalogs.get(store);
36
+ if (entry?.items && !entry.stale && Date.now() - entry.at < MAX_AGE_MS) return entry.items;
37
+ if (entry?.building) return entry.building;
38
+ if (!entry) { entry = {}; catalogs.set(store, entry); }
39
+ entry.stale = false; // a write landing during the rebuild sets it again, so the next read rebuilds
40
+ entry.building = buildOfferings(store).then(
41
+ (items) => { entry.items = items; entry.at = Date.now(); entry.building = null; return items; },
42
+ (err) => { entry.building = null; throw err; });
43
+ return entry.building;
44
+ }
45
+
46
+ /** Flatten indexed nodes into searchable offering summaries. */
47
+ async function buildOfferings(store) {
9
48
  const out = [];
10
49
  for (const node of await store.allNodes()) {
11
50
  if (['delisted', 'suspended', 'submitted', 'challenged', 'disputed', 'claimed'].includes(node.state)) continue;
@@ -103,6 +142,8 @@ export async function searchOfferingsPaged(store, query) {
103
142
  || o.offering_id.includes(needle) || o.node_id.includes(needle));
104
143
  }
105
144
 
145
+ // Rank depends on the filtered set: annotate copies, never the shared cached offerings.
146
+ items = items.map((o) => ({ ...o }));
106
147
  const maxPrice = Math.max(...items.map((o) => o.pricing.input_per_mtok ?? 0), 0.000001);
107
148
  for (const o of items) {
108
149
  const components = {
package/src/server.js CHANGED
@@ -15,7 +15,7 @@ import { runStageB, captureFingerprints } from './stageb.js';
15
15
  import { startStageC } from './stagec.js';
16
16
  import { startReimport } from './reimport.js';
17
17
  import { makeSafeFetch } from './safefetch.js';
18
- import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENTS } from './search.js';
18
+ import { searchOfferings, searchOfferingsPaged, collectOfferings, watchStoreWrites, RANK_COMPONENTS } from './search.js';
19
19
  import { mcpRoutes } from './mcp.js';
20
20
  import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
21
21
  import { exportRoutes } from './export.js';
@@ -40,7 +40,7 @@ export async function startRegistry({
40
40
  admitCooldownMs = 0, // minimum gap between Stage B runs per node (0 = none; set in production)
41
41
  trustProxy = false, // read the client address from X-Forwarded-For (behind Caddy / a CDN)
42
42
  } = {}) {
43
- const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
43
+ const store = watchStoreWrites(await createStore(dbPath)); // sqlite path/:memory: or postgres:// URL; writes refresh the cached catalog
44
44
  const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
45
45
  const webApp = readFileSync(fileURLToPath(new URL('./web/index.html', import.meta.url)), 'utf8');
46
46
  let registryKeys;