@fintekkers/ledger-models 0.4.10 → 0.4.11

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.
Files changed (54) hide show
  1. package/node/wrappers/models/hydrate.test.d.ts +1 -0
  2. package/node/wrappers/models/hydrate.test.js +204 -0
  3. package/node/wrappers/models/hydrate.test.js.map +1 -0
  4. package/node/wrappers/models/hydrate.test.ts +214 -0
  5. package/node/wrappers/models/lazy-hydrate-race.test.d.ts +1 -0
  6. package/node/wrappers/models/lazy-hydrate-race.test.js +153 -0
  7. package/node/wrappers/models/lazy-hydrate-race.test.js.map +1 -0
  8. package/node/wrappers/models/lazy-hydrate-race.test.ts +144 -0
  9. package/node/wrappers/models/lazy-hydrate.bench.test.d.ts +1 -0
  10. package/node/wrappers/models/lazy-hydrate.bench.test.js +121 -0
  11. package/node/wrappers/models/lazy-hydrate.bench.test.js.map +1 -0
  12. package/node/wrappers/models/lazy-hydrate.bench.test.ts +103 -0
  13. package/node/wrappers/models/portfolio/portfolio.d.ts +12 -2
  14. package/node/wrappers/models/portfolio/portfolio.js +42 -3
  15. package/node/wrappers/models/portfolio/portfolio.js.map +1 -1
  16. package/node/wrappers/models/portfolio/portfolio.ts +27 -3
  17. package/node/wrappers/models/security/identifier-validation.test.d.ts +1 -0
  18. package/node/wrappers/models/security/identifier-validation.test.js +104 -0
  19. package/node/wrappers/models/security/identifier-validation.test.js.map +1 -0
  20. package/node/wrappers/models/security/identifier-validation.test.ts +133 -0
  21. package/node/wrappers/models/security/identifier.d.ts +26 -0
  22. package/node/wrappers/models/security/identifier.js +54 -1
  23. package/node/wrappers/models/security/identifier.js.map +1 -1
  24. package/node/wrappers/models/security/identifier.test.js +6 -9
  25. package/node/wrappers/models/security/identifier.test.js.map +1 -1
  26. package/node/wrappers/models/security/identifier.test.ts +6 -9
  27. package/node/wrappers/models/security/identifier.ts +58 -0
  28. package/node/wrappers/models/security/security.d.ts +23 -5
  29. package/node/wrappers/models/security/security.js +53 -6
  30. package/node/wrappers/models/security/security.js.map +1 -1
  31. package/node/wrappers/models/security/security.ts +38 -6
  32. package/node/wrappers/models/transaction/transaction.d.ts +12 -1
  33. package/node/wrappers/models/transaction/transaction.js +39 -2
  34. package/node/wrappers/models/transaction/transaction.js.map +1 -1
  35. package/node/wrappers/models/transaction/transaction.ts +27 -2
  36. package/node/wrappers/services/security-service/SecurityService.js +8 -0
  37. package/node/wrappers/services/security-service/SecurityService.js.map +1 -1
  38. package/node/wrappers/services/security-service/SecurityService.ts +10 -0
  39. package/node/wrappers/services/security.identifier-guard.test.d.ts +1 -0
  40. package/node/wrappers/services/security.identifier-guard.test.js +63 -0
  41. package/node/wrappers/services/security.identifier-guard.test.js.map +1 -0
  42. package/node/wrappers/services/security.identifier-guard.test.ts +70 -0
  43. package/node/wrappers/util/link-cache.d.ts +13 -6
  44. package/node/wrappers/util/link-cache.js +51 -15
  45. package/node/wrappers/util/link-cache.js.map +1 -1
  46. package/node/wrappers/util/link-cache.ts +51 -17
  47. package/node/wrappers/util/link-resolver.d.ts +39 -31
  48. package/node/wrappers/util/link-resolver.js +132 -124
  49. package/node/wrappers/util/link-resolver.js.map +1 -1
  50. package/node/wrappers/util/link-resolver.test.js +13 -2
  51. package/node/wrappers/util/link-resolver.test.js.map +1 -1
  52. package/node/wrappers/util/link-resolver.test.ts +14 -2
  53. package/node/wrappers/util/link-resolver.ts +141 -151
  54. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import LinkResolver from './link-resolver';
2
2
  import Price from '../models/price/Price';
3
3
  import { UUID } from '../models/utils/uuid';
4
+ import * as LinkCacheModuleTop from './link-cache';
4
5
 
5
6
  import { SecurityProto } from '../../fintekkers/models/security/security_pb';
6
7
  import { PortfolioProto } from '../../fintekkers/models/portfolio/portfolio_pb';
@@ -121,6 +122,14 @@ function linkPrice(securityUuid: UUID, priceValue: string, asOf?: LocalTimestamp
121
122
  }
122
123
 
123
124
  describe('LinkResolver', () => {
125
+ // Process-wide LinkCache singletons survive across tests, so clear them
126
+ // between cases to keep tests independent (post-W4 the resolver no longer
127
+ // owns its own cache).
128
+ beforeEach(() => {
129
+ LinkCacheModuleTop.SECURITY.clear();
130
+ LinkCacheModuleTop.PORTFOLIO.clear();
131
+ });
132
+
124
133
  test('bulk resolveSecurities dedupes UUIDs (5 prices, 3 unique → 1 RPC, 3 UUIDs)', async () => {
125
134
  const uuidA = UUID.random();
126
135
  const uuidB = UUID.random();
@@ -203,7 +212,10 @@ describe('LinkResolver', () => {
203
212
  for (const r of results) expect(r.getIssuerName()).toBe('AAPL');
204
213
  });
205
214
 
206
- test('caching disabled (cacheSize=0) re-RPCs every call', async () => {
215
+ test('LinkCache evict between calls forces refetch', async () => {
216
+ // Post-W4 the resolver no longer owns its cache. Evict the entry from
217
+ // LinkCache.SECURITY between calls to force a refetch — the equivalent
218
+ // of the old `cacheSize=0` semantic.
207
219
  const uuid = UUID.random();
208
220
  const store = new Map<string, SecurityProto>([
209
221
  [uuid.toString(), fullSecurity(uuid, 'AAPL')],
@@ -211,12 +223,12 @@ describe('LinkResolver', () => {
211
223
  const callLog = newCallLog();
212
224
 
213
225
  const resolver = new LinkResolver({
214
- cacheSize: 0,
215
226
  securityClient: mockSecurityClient(store, callLog),
216
227
  portfolioClient: mockPortfolioClient(new Map(), newCallLog()),
217
228
  });
218
229
 
219
230
  await resolver.getSecurity(uuid);
231
+ LinkCacheModuleTop.SECURITY.evict(uuid.toString());
220
232
  await resolver.getSecurity(uuid);
221
233
 
222
234
  expect(callLog.count).toBe(2);
@@ -2,78 +2,50 @@ import { promisify } from 'util';
2
2
 
3
3
  import { SecurityProto } from '../../fintekkers/models/security/security_pb';
4
4
  import { PortfolioProto } from '../../fintekkers/models/portfolio/portfolio_pb';
5
+ import { TransactionProto } from '../../fintekkers/models/transaction/transaction_pb';
5
6
  import { UUIDProto } from '../../fintekkers/models/util/uuid_pb';
6
7
  import { LocalTimestampProto } from '../../fintekkers/models/util/local_timestamp_pb';
7
8
 
8
9
  import { SecurityClient } from '../../fintekkers/services/security-service/security_service_grpc_pb';
9
10
  import { PortfolioClient } from '../../fintekkers/services/portfolio-service/portfolio_service_grpc_pb';
11
+ import { TransactionClient } from '../../fintekkers/services/transaction-service/transaction_service_grpc_pb';
10
12
  import { QuerySecurityRequestProto } from '../../fintekkers/requests/security/query_security_request_pb';
11
13
  import { QuerySecurityResponseProto } from '../../fintekkers/requests/security/query_security_response_pb';
12
14
  import { QueryPortfolioRequestProto } from '../../fintekkers/requests/portfolio/query_portfolio_request_pb';
13
15
  import { QueryPortfolioResponseProto } from '../../fintekkers/requests/portfolio/query_portfolio_response_pb';
16
+ import { QueryTransactionRequestProto } from '../../fintekkers/requests/transaction/query_transaction_request_pb';
17
+ import { QueryTransactionResponseProto } from '../../fintekkers/requests/transaction/query_transaction_response_pb';
14
18
 
15
19
  import Security from '../models/security/security';
16
20
  import Portfolio from '../models/portfolio/portfolio';
21
+ import Transaction from '../models/transaction/transaction';
17
22
  import { UUID } from '../models/utils/uuid';
18
23
  import EnvConfig from '../models/utils/requestcontext';
19
24
  import { ZonedDateTime } from '../models/utils/datetime';
20
25
  import * as LinkCacheModule from './link-cache';
21
26
 
22
- /**
23
- * Mirror a freshly-fetched SecurityProto into the process-wide
24
- * `LinkCache.SECURITY`. The lazy-hydrate `SecurityWrapper.ensureHydrated()`
25
- * reads from this cache, so pre-warming via LinkResolver immediately
26
- * benefits accessor reads. Skips silently when the resolved proto lacks
27
- * uuid or as_of — `LinkCache.put` requires both.
28
- */
29
- function populateSecurityLinkCache(proto: SecurityProto): void {
30
- const uuidProto = proto.getUuid();
31
- const asOfProto = proto.getAsOf();
32
- if (!uuidProto || !asOfProto) return;
33
- const uuidKey = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
34
- LinkCacheModule.SECURITY.put(uuidKey, proto, new ZonedDateTime(asOfProto));
35
- }
36
-
37
- function populatePortfolioLinkCache(proto: PortfolioProto): void {
38
- const uuidProto = proto.getUuid();
39
- const asOfProto = proto.getAsOf();
40
- if (!uuidProto || !asOfProto) return;
41
- const uuidKey = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
42
- LinkCacheModule.PORTFOLIO.put(uuidKey, proto, new ZonedDateTime(asOfProto));
43
- }
44
-
45
27
  /**
46
28
  * LinkResolver — bulk hydration of `is_link=true` entity references into
47
29
  * full entities. Implements the consumer side of the `is_link` pattern
48
30
  * documented in `docs/adr/is_link_pattern.md`.
49
31
  *
32
+ * W4: the resolver no longer owns its own LRU. Cache reads/writes route
33
+ * through the process-wide `LinkCache.SECURITY` / `LinkCache.PORTFOLIO`
34
+ * singletons. The resolver still does concurrent-call dedup (single
35
+ * in-flight RPC per (uuid, as_of)) and bulk per-bucket batching; storage
36
+ * and eviction live in LinkCache.
37
+ *
50
38
  * Two surface methods:
51
39
  * - getSecurity(uuid) / getPortfolio(uuid): single-UUID resolution. Cached
52
40
  * and concurrent-deduped.
53
41
  * - resolveSecurities(items) / resolvePortfolios(items): bulk in-place
54
42
  * mutation across a heterogeneous list of items that each have a
55
43
  * proto-style getter+setter for the embedded entity. Collects unique
56
- * link UUIDs, fires one batched GetByIds RPC, mutates each item's proto
57
- * to swap the link sub-message for the resolved full entity (with
58
- * is_link=false on the embedded copy).
59
- *
60
- * Caching:
61
- * - Process-level LRU keyed on UUID string. Default 1000 entries, no TTL
62
- * (entries live until evicted by LRU). Long-running services that need
63
- * freshness should pass `{ ttlMs: <ms> }`. Tests can disable with
64
- * `{ cacheSize: 0 }`.
65
- * - Concurrent same-UUID requests are deduped via an in-flight promise
66
- * map — N parallel callers for the same UUID share one RPC.
44
+ * link UUIDs, fires one batched GetByIds RPC per as_of bucket, mutates
45
+ * each item's proto to swap the link sub-message for the resolved
46
+ * full entity.
67
47
  *
68
- * RPC choice: uses `GetByIds` (unary, UUID-keyed bulk) per the ADR. The
69
- * existing `SecurityService.search` (streaming) would also work but
70
- * requires more wrapper plumbing for batched-by-UUID semantics.
71
- *
72
- * Mutation semantic: when bulk-resolving, the embedded sub-message is
73
- * replaced (not the outer entity). Outer Price.proto.is_link is unchanged;
74
- * only the inner SecurityProto is swapped from link-stub to full entity.
75
- * Wrapper objects that read through the proto (`price.getSecurity()`)
76
- * automatically see the resolved data.
48
+ * RPC choice: uses `GetByIds` (unary, UUID-keyed bulk) per the ADR.
77
49
  *
78
50
  * Time-travel (`as_of`) semantic: per is_link_pattern.md addendum, when
79
51
  * a link sub-message has only `uuid` set the resolver fetches the latest
@@ -86,104 +58,74 @@ function populatePortfolioLinkCache(proto: PortfolioProto): void {
86
58
  export interface LinkResolverOptions {
87
59
  /** Optional API key. If omitted, EnvConfig.apiCredentials is used. */
88
60
  apiKey?: string;
89
- /** LRU max entries. Default 1000. Set to 0 to disable caching. */
90
- cacheSize?: number;
91
- /** Per-entry TTL in ms. Default undefined (no expiry). */
92
- ttlMs?: number;
93
61
  /**
94
62
  * Test injection: clients to use instead of constructing real ones.
95
63
  * Production callers should not set these.
96
64
  */
97
65
  securityClient?: SecurityClient;
98
66
  portfolioClient?: PortfolioClient;
99
- }
100
-
101
- interface CacheEntry<V> {
102
- value: V;
103
- insertedAt: number;
67
+ transactionClient?: TransactionClient;
104
68
  }
105
69
 
106
70
  /**
107
- * Tiny LRU. Map keeps insertion order; on get-hit we delete + re-insert
108
- * to bump to the end (most recently used). On overflow we drop the
109
- * oldest entry (first key in the Map). Avoids pulling in lru-cache as a
110
- * dependency for ~30 lines of logic.
111
- */
112
- class TinyLRU<V> {
113
- private map = new Map<string, CacheEntry<V>>();
114
- constructor(private maxSize: number, private ttlMs?: number) {}
115
-
116
- get(key: string): V | undefined {
117
- if (this.maxSize === 0) return undefined;
118
- const entry = this.map.get(key);
119
- if (!entry) return undefined;
120
- if (this.ttlMs !== undefined && Date.now() - entry.insertedAt > this.ttlMs) {
121
- this.map.delete(key);
122
- return undefined;
123
- }
124
- // Bump to most-recently-used.
125
- this.map.delete(key);
126
- this.map.set(key, entry);
127
- return entry.value;
128
- }
129
-
130
- set(key: string, value: V): void {
131
- if (this.maxSize === 0) return;
132
- if (this.map.has(key)) this.map.delete(key);
133
- this.map.set(key, { value, insertedAt: Date.now() });
134
- while (this.map.size > this.maxSize) {
135
- const oldest = this.map.keys().next().value;
136
- if (oldest === undefined) break;
137
- this.map.delete(oldest);
138
- }
139
- }
140
-
141
- size(): number {
142
- return this.map.size;
143
- }
144
-
145
- clear(): void {
146
- this.map.clear();
147
- }
148
- }
149
-
150
- /**
151
- * Stable serialization of a LocalTimestampProto for use in cache keys
152
- * and as_of-bucket grouping. Uses the proto's binary form (Uint8Array
71
+ * Stable serialization of a LocalTimestampProto for use in in-flight dedup
72
+ * keys and as_of-bucket grouping. Uses the proto's binary form (Uint8Array
153
73
  * → base64). Returns the literal "latest" when as_of is undefined so
154
74
  * unset and explicit-undefined collapse to the same bucket.
155
- *
156
- * Two LocalTimestampProto instances representing the same moment will
157
- * produce the same key as long as the underlying nanos/seconds match —
158
- * proto3 binary encoding is canonical for unset fields.
159
75
  */
160
76
  function asOfKey(asOf: LocalTimestampProto | undefined): string {
161
77
  if (!asOf) return 'latest';
162
- // serializeBinary returns Uint8Array.
163
78
  const bytes = asOf.serializeBinary();
164
79
  return Buffer.from(bytes).toString('base64');
165
80
  }
166
81
 
82
+ function asOfToZdt(asOf: LocalTimestampProto | undefined): ZonedDateTime | null {
83
+ return asOf ? new ZonedDateTime(asOf) : null;
84
+ }
85
+
86
+ // Process-wide default singleton — lazily constructed on first
87
+ // `LinkResolver.getDefault()` call. Used by the wrapper `hydrate()`
88
+ // methods so callers don't have to thread a resolver instance through
89
+ // their code. Override the default by calling `LinkResolver.setDefault(...)`
90
+ // at process start (tests with mocked clients, alternate endpoints, etc.).
91
+ let defaultLinkResolver: LinkResolver | undefined;
92
+
167
93
  class LinkResolver {
168
94
  private securityClient: SecurityClient;
169
95
  private portfolioClient: PortfolioClient;
170
-
171
- private securityCache: TinyLRU<SecurityProto>;
172
- private portfolioCache: TinyLRU<PortfolioProto>;
96
+ private transactionClient: TransactionClient;
173
97
 
174
98
  // Concurrent-call dedupe: a UUID currently being fetched maps to the
175
99
  // promise the *first* caller is awaiting. Subsequent callers for the
176
100
  // same UUID receive that same promise.
177
101
  private securityInFlight = new Map<string, Promise<SecurityProto>>();
178
102
  private portfolioInFlight = new Map<string, Promise<PortfolioProto>>();
103
+ private transactionInFlight = new Map<string, Promise<TransactionProto>>();
179
104
 
180
- constructor(opts: LinkResolverOptions = {}) {
181
- const cacheSize = opts.cacheSize ?? 1000;
182
- const ttlMs = opts.ttlMs;
105
+ /**
106
+ * Process-wide singleton lazily constructed with default options
107
+ * (env-derived endpoint via `EnvConfig`). The wrapper `hydrate()`
108
+ * methods reach for this when no resolver is passed explicitly, so
109
+ * users get auto-resolve on link-mode wrappers without threading a
110
+ * resolver through every call site.
111
+ */
112
+ static getDefault(): LinkResolver {
113
+ if (!defaultLinkResolver) {
114
+ defaultLinkResolver = new LinkResolver();
115
+ }
116
+ return defaultLinkResolver;
117
+ }
183
118
 
184
- this.securityCache = new TinyLRU(cacheSize, ttlMs);
185
- this.portfolioCache = new TinyLRU(cacheSize, ttlMs);
119
+ /**
120
+ * Replace the process-wide default. Call once at process start for
121
+ * tests with mocked clients or to point at a non-default endpoint.
122
+ * Pass `undefined` to clear (next `getDefault()` call rebuilds).
123
+ */
124
+ static setDefault(resolver: LinkResolver | undefined): void {
125
+ defaultLinkResolver = resolver;
126
+ }
186
127
 
128
+ constructor(opts: LinkResolverOptions = {}) {
187
129
  if (opts.securityClient) {
188
130
  this.securityClient = opts.securityClient;
189
131
  } else if (opts.apiKey) {
@@ -201,6 +143,15 @@ class LinkResolver {
201
143
  } else {
202
144
  this.portfolioClient = new PortfolioClient(EnvConfig.apiURL, EnvConfig.apiCredentials);
203
145
  }
146
+
147
+ if (opts.transactionClient) {
148
+ this.transactionClient = opts.transactionClient;
149
+ } else if (opts.apiKey) {
150
+ const { credentials, interceptors } = EnvConfig.getAuthenticatedClientOptions(opts.apiKey);
151
+ this.transactionClient = new TransactionClient(EnvConfig.apiURL, credentials, { interceptors });
152
+ } else {
153
+ this.transactionClient = new TransactionClient(EnvConfig.apiURL, EnvConfig.apiCredentials);
154
+ }
204
155
  }
205
156
 
206
157
  /**
@@ -223,6 +174,15 @@ class LinkResolver {
223
174
  return new Portfolio(proto);
224
175
  }
225
176
 
177
+ /**
178
+ * Resolve a single TransactionProto by UUID, optionally as of `asOf`.
179
+ * Cached + concurrent-deduped on (uuid, asOf).
180
+ */
181
+ async getTransaction(uuid: UUID, asOf?: LocalTimestampProto): Promise<Transaction> {
182
+ const proto = await this.fetchTransactionProto(uuid, asOf);
183
+ return new Transaction(proto);
184
+ }
185
+
226
186
  /**
227
187
  * Walk `items`, find the ones whose embedded security is `is_link=true`,
228
188
  * batch-fetch the unique (uuid, as_of) pairs (grouped by as_of so each
@@ -230,17 +190,13 @@ class LinkResolver {
230
190
  * place so subsequent `item.getSecurity()` calls return the full entity.
231
191
  * Returns the same array for chaining.
232
192
  *
233
- * Honors per-link `as_of`: if the embedded sub-message has `as_of` set,
234
- * the resolver fetches the version of the entity at that timestamp,
235
- * not the latest.
236
- *
237
193
  * `T` is structural: anything with a `proto` field that exposes
238
194
  * `getSecurity()` / `setSecurity()` works (Price, Transaction, etc).
239
195
  */
240
196
  async resolveSecurities<T extends ResolvableSecurity>(items: T[]): Promise<T[]> {
241
197
  if (items.length === 0) return items;
242
198
 
243
- // Group: as_of bucket → (cacheKey → UUID) for items not yet cached.
199
+ // Group: as_of bucket → (uuid string → UUID) for items not yet cached.
244
200
  const buckets = new Map<string, Map<string, UUID>>();
245
201
  for (const item of items) {
246
202
  const sec = item.proto.getSecurity();
@@ -249,32 +205,28 @@ class LinkResolver {
249
205
  if (!uuidProto) continue;
250
206
  const uuid = UUID.fromU8Array(uuidProto.getRawUuid_asU8());
251
207
  const asOf = sec.getAsOf();
208
+ const uuidStr = uuid.toString();
209
+ if (LinkCacheModule.SECURITY.get(uuidStr, asOfToZdt(asOf))) continue;
252
210
  const bucketKey = asOfKey(asOf);
253
- const cacheKey = `${uuid.toString()}@${bucketKey}`;
254
- // Skip if already cached for this exact (uuid, as_of).
255
- if (this.securityCache.get(cacheKey)) continue;
256
211
  let bucket = buckets.get(bucketKey);
257
212
  if (!bucket) {
258
213
  bucket = new Map<string, UUID>();
259
214
  buckets.set(bucketKey, bucket);
260
215
  }
261
- if (!bucket.has(cacheKey)) bucket.set(cacheKey, uuid);
216
+ if (!bucket.has(uuidStr)) bucket.set(uuidStr, uuid);
262
217
  }
263
218
 
264
219
  // One GetByIds RPC per as_of bucket. Fire in parallel.
265
220
  await Promise.all(
266
221
  Array.from(buckets.entries()).map(async ([bucketKey, uuidMap]) => {
267
- // Recover the LocalTimestampProto for this bucket from the first
268
- // item whose serialized as_of matches. We could store it alongside
269
- // but it's cheap to re-find.
270
- const asOf = bucketKey === 'latest' ? undefined : findAsOfForBucket(items, (sec) => sec.getSecurity(), bucketKey);
222
+ const asOf = bucketKey === 'latest' ? undefined : findAsOfForBucket(items, (proto) => proto.getSecurity(), bucketKey);
223
+ const asOfZdt = asOfToZdt(asOf);
271
224
  const fetched = await this.batchFetchSecurities(Array.from(uuidMap.values()), asOf);
272
225
  for (const proto of fetched) {
273
226
  const uuidProto = proto.getUuid();
274
227
  if (!uuidProto) continue;
275
228
  const uuidStr = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
276
- this.securityCache.set(`${uuidStr}@${bucketKey}`, proto);
277
- populateSecurityLinkCache(proto);
229
+ LinkCacheModule.SECURITY.put(uuidStr, proto, asOfZdt);
278
230
  }
279
231
  }),
280
232
  );
@@ -286,8 +238,7 @@ class LinkResolver {
286
238
  const uuidProto = sec.getUuid();
287
239
  if (!uuidProto) continue;
288
240
  const uuidStr = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
289
- const bucketKey = asOfKey(sec.getAsOf());
290
- const resolved = this.securityCache.get(`${uuidStr}@${bucketKey}`);
241
+ const resolved = LinkCacheModule.SECURITY.get(uuidStr, asOfToZdt(sec.getAsOf()));
291
242
  if (resolved) item.proto.setSecurity(resolved);
292
243
  }
293
244
 
@@ -309,27 +260,27 @@ class LinkResolver {
309
260
  if (!uuidProto) continue;
310
261
  const uuid = UUID.fromU8Array(uuidProto.getRawUuid_asU8());
311
262
  const asOf = port.getAsOf();
263
+ const uuidStr = uuid.toString();
264
+ if (LinkCacheModule.PORTFOLIO.get(uuidStr, asOfToZdt(asOf))) continue;
312
265
  const bucketKey = asOfKey(asOf);
313
- const cacheKey = `${uuid.toString()}@${bucketKey}`;
314
- if (this.portfolioCache.get(cacheKey)) continue;
315
266
  let bucket = buckets.get(bucketKey);
316
267
  if (!bucket) {
317
268
  bucket = new Map<string, UUID>();
318
269
  buckets.set(bucketKey, bucket);
319
270
  }
320
- if (!bucket.has(cacheKey)) bucket.set(cacheKey, uuid);
271
+ if (!bucket.has(uuidStr)) bucket.set(uuidStr, uuid);
321
272
  }
322
273
 
323
274
  await Promise.all(
324
275
  Array.from(buckets.entries()).map(async ([bucketKey, uuidMap]) => {
325
- const asOf = bucketKey === 'latest' ? undefined : findAsOfForBucket(items, (it) => it.getPortfolio(), bucketKey);
276
+ const asOf = bucketKey === 'latest' ? undefined : findAsOfForBucket(items, (proto) => proto.getPortfolio(), bucketKey);
277
+ const asOfZdt = asOfToZdt(asOf);
326
278
  const fetched = await this.batchFetchPortfolios(Array.from(uuidMap.values()), asOf);
327
279
  for (const proto of fetched) {
328
280
  const uuidProto = proto.getUuid();
329
281
  if (!uuidProto) continue;
330
282
  const uuidStr = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
331
- this.portfolioCache.set(`${uuidStr}@${bucketKey}`, proto);
332
- populatePortfolioLinkCache(proto);
283
+ LinkCacheModule.PORTFOLIO.put(uuidStr, proto, asOfZdt);
333
284
  }
334
285
  }),
335
286
  );
@@ -340,40 +291,40 @@ class LinkResolver {
340
291
  const uuidProto = port.getUuid();
341
292
  if (!uuidProto) continue;
342
293
  const uuidStr = UUID.fromU8Array(uuidProto.getRawUuid_asU8()).toString();
343
- const bucketKey = asOfKey(port.getAsOf());
344
- const resolved = this.portfolioCache.get(`${uuidStr}@${bucketKey}`);
294
+ const resolved = LinkCacheModule.PORTFOLIO.get(uuidStr, asOfToZdt(port.getAsOf()));
345
295
  if (resolved) item.proto.setPortfolio(resolved);
346
296
  }
347
297
 
348
298
  return items;
349
299
  }
350
300
 
351
- /** Test/debug helper. Not part of the stable API. */
301
+ /** Test/debug helper. Clears in-flight maps; the process-wide LinkCache
302
+ * is left alone (tests that need to drop a specific cached entry call
303
+ * `LinkCache.SECURITY.evict(uuid)` directly). */
352
304
  clearCache(): void {
353
- this.securityCache.clear();
354
- this.portfolioCache.clear();
355
305
  this.securityInFlight.clear();
356
306
  this.portfolioInFlight.clear();
307
+ this.transactionInFlight.clear();
357
308
  }
358
309
 
359
310
  // ---------- internals ----------
360
311
 
361
312
  private async fetchSecurityProto(uuid: UUID, asOf?: LocalTimestampProto): Promise<SecurityProto> {
362
- const key = `${uuid.toString()}@${asOfKey(asOf)}`;
363
-
364
- const cached = this.securityCache.get(key);
313
+ const uuidStr = uuid.toString();
314
+ const asOfZdt = asOfToZdt(asOf);
315
+ const cached = LinkCacheModule.SECURITY.get(uuidStr, asOfZdt);
365
316
  if (cached) return cached;
366
317
 
318
+ const key = `${uuidStr}@${asOfKey(asOf)}`;
367
319
  const inFlight = this.securityInFlight.get(key);
368
320
  if (inFlight) return inFlight;
369
321
 
370
322
  const promise = this.batchFetchSecurities([uuid], asOf).then((protos) => {
371
323
  if (protos.length === 0) {
372
- throw new Error(`Security not found: ${uuid.toString()}@${asOfKey(asOf)}`);
324
+ throw new Error(`Security not found: ${key}`);
373
325
  }
374
326
  const proto = protos[0];
375
- this.securityCache.set(key, proto);
376
- populateSecurityLinkCache(proto);
327
+ LinkCacheModule.SECURITY.put(uuidStr, proto, asOfZdt);
377
328
  return proto;
378
329
  }).finally(() => {
379
330
  this.securityInFlight.delete(key);
@@ -384,21 +335,21 @@ class LinkResolver {
384
335
  }
385
336
 
386
337
  private async fetchPortfolioProto(uuid: UUID, asOf?: LocalTimestampProto): Promise<PortfolioProto> {
387
- const key = `${uuid.toString()}@${asOfKey(asOf)}`;
388
-
389
- const cached = this.portfolioCache.get(key);
338
+ const uuidStr = uuid.toString();
339
+ const asOfZdt = asOfToZdt(asOf);
340
+ const cached = LinkCacheModule.PORTFOLIO.get(uuidStr, asOfZdt);
390
341
  if (cached) return cached;
391
342
 
343
+ const key = `${uuidStr}@${asOfKey(asOf)}`;
392
344
  const inFlight = this.portfolioInFlight.get(key);
393
345
  if (inFlight) return inFlight;
394
346
 
395
347
  const promise = this.batchFetchPortfolios([uuid], asOf).then((protos) => {
396
348
  if (protos.length === 0) {
397
- throw new Error(`Portfolio not found: ${uuid.toString()}@${asOfKey(asOf)}`);
349
+ throw new Error(`Portfolio not found: ${key}`);
398
350
  }
399
351
  const proto = protos[0];
400
- this.portfolioCache.set(key, proto);
401
- populatePortfolioLinkCache(proto);
352
+ LinkCacheModule.PORTFOLIO.put(uuidStr, proto, asOfZdt);
402
353
  return proto;
403
354
  }).finally(() => {
404
355
  this.portfolioInFlight.delete(key);
@@ -408,6 +359,31 @@ class LinkResolver {
408
359
  return promise;
409
360
  }
410
361
 
362
+ private async fetchTransactionProto(uuid: UUID, asOf?: LocalTimestampProto): Promise<TransactionProto> {
363
+ const uuidStr = uuid.toString();
364
+ const asOfZdt = asOfToZdt(asOf);
365
+ const cached = LinkCacheModule.TRANSACTION.get(uuidStr, asOfZdt);
366
+ if (cached) return cached;
367
+
368
+ const key = `${uuidStr}@${asOfKey(asOf)}`;
369
+ const inFlight = this.transactionInFlight.get(key);
370
+ if (inFlight) return inFlight;
371
+
372
+ const promise = this.batchFetchTransactions([uuid], asOf).then((protos) => {
373
+ if (protos.length === 0) {
374
+ throw new Error(`Transaction not found: ${key}`);
375
+ }
376
+ const proto = protos[0];
377
+ LinkCacheModule.TRANSACTION.put(uuidStr, proto, asOfZdt);
378
+ return proto;
379
+ }).finally(() => {
380
+ this.transactionInFlight.delete(key);
381
+ });
382
+
383
+ this.transactionInFlight.set(key, promise);
384
+ return promise;
385
+ }
386
+
411
387
  private async batchFetchSecurities(uuids: UUID[], asOf?: LocalTimestampProto): Promise<SecurityProto[]> {
412
388
  if (uuids.length === 0) return [];
413
389
  const request = new QuerySecurityRequestProto();
@@ -422,6 +398,20 @@ class LinkResolver {
422
398
  return response.getSecurityResponseList();
423
399
  }
424
400
 
401
+ private async batchFetchTransactions(uuids: UUID[], asOf?: LocalTimestampProto): Promise<TransactionProto[]> {
402
+ if (uuids.length === 0) return [];
403
+ const request = new QueryTransactionRequestProto();
404
+ request.setObjectClass('TransactionRequest');
405
+ request.setVersion('0.0.1');
406
+ const uuidProtos: UUIDProto[] = uuids.map((u) => u.toUUIDProto());
407
+ request.setUuidsList(uuidProtos);
408
+ if (asOf) request.setAsOf(asOf);
409
+
410
+ const getByIdsAsync = promisify(this.transactionClient.getByIds.bind(this.transactionClient));
411
+ const response = (await getByIdsAsync(request)) as QueryTransactionResponseProto;
412
+ return response.getTransactionResponseList();
413
+ }
414
+
425
415
  private async batchFetchPortfolios(uuids: UUID[], asOf?: LocalTimestampProto): Promise<PortfolioProto[]> {
426
416
  if (uuids.length === 0) return [];
427
417
  const request = new QueryPortfolioRequestProto();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fintekkers/ledger-models",
3
3
  "todo": "Replace the version with build script version number",
4
- "version": "0.4.10",
4
+ "version": "0.4.11",
5
5
  "description": "ledger model protos ",
6
6
  "authors": [
7
7
  "David Doherty",