@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.
- package/node/wrappers/models/hydrate.test.d.ts +1 -0
- package/node/wrappers/models/hydrate.test.js +204 -0
- package/node/wrappers/models/hydrate.test.js.map +1 -0
- package/node/wrappers/models/hydrate.test.ts +214 -0
- package/node/wrappers/models/lazy-hydrate-race.test.d.ts +1 -0
- package/node/wrappers/models/lazy-hydrate-race.test.js +153 -0
- package/node/wrappers/models/lazy-hydrate-race.test.js.map +1 -0
- package/node/wrappers/models/lazy-hydrate-race.test.ts +144 -0
- package/node/wrappers/models/lazy-hydrate.bench.test.d.ts +1 -0
- package/node/wrappers/models/lazy-hydrate.bench.test.js +121 -0
- package/node/wrappers/models/lazy-hydrate.bench.test.js.map +1 -0
- package/node/wrappers/models/lazy-hydrate.bench.test.ts +103 -0
- package/node/wrappers/models/portfolio/portfolio.d.ts +12 -2
- package/node/wrappers/models/portfolio/portfolio.js +42 -3
- package/node/wrappers/models/portfolio/portfolio.js.map +1 -1
- package/node/wrappers/models/portfolio/portfolio.ts +27 -3
- package/node/wrappers/models/security/identifier-validation.test.d.ts +1 -0
- package/node/wrappers/models/security/identifier-validation.test.js +104 -0
- package/node/wrappers/models/security/identifier-validation.test.js.map +1 -0
- package/node/wrappers/models/security/identifier-validation.test.ts +133 -0
- package/node/wrappers/models/security/identifier.d.ts +26 -0
- package/node/wrappers/models/security/identifier.js +54 -1
- package/node/wrappers/models/security/identifier.js.map +1 -1
- package/node/wrappers/models/security/identifier.test.js +6 -9
- package/node/wrappers/models/security/identifier.test.js.map +1 -1
- package/node/wrappers/models/security/identifier.test.ts +6 -9
- package/node/wrappers/models/security/identifier.ts +58 -0
- package/node/wrappers/models/security/security.d.ts +23 -5
- package/node/wrappers/models/security/security.js +53 -6
- package/node/wrappers/models/security/security.js.map +1 -1
- package/node/wrappers/models/security/security.ts +38 -6
- package/node/wrappers/models/transaction/transaction.d.ts +12 -1
- package/node/wrappers/models/transaction/transaction.js +39 -2
- package/node/wrappers/models/transaction/transaction.js.map +1 -1
- package/node/wrappers/models/transaction/transaction.ts +27 -2
- package/node/wrappers/services/security-service/SecurityService.js +8 -0
- package/node/wrappers/services/security-service/SecurityService.js.map +1 -1
- package/node/wrappers/services/security-service/SecurityService.ts +10 -0
- package/node/wrappers/services/security.identifier-guard.test.d.ts +1 -0
- package/node/wrappers/services/security.identifier-guard.test.js +63 -0
- package/node/wrappers/services/security.identifier-guard.test.js.map +1 -0
- package/node/wrappers/services/security.identifier-guard.test.ts +70 -0
- package/node/wrappers/util/link-cache.d.ts +13 -6
- package/node/wrappers/util/link-cache.js +51 -15
- package/node/wrappers/util/link-cache.js.map +1 -1
- package/node/wrappers/util/link-cache.ts +51 -17
- package/node/wrappers/util/link-resolver.d.ts +39 -31
- package/node/wrappers/util/link-resolver.js +132 -124
- package/node/wrappers/util/link-resolver.js.map +1 -1
- package/node/wrappers/util/link-resolver.test.js +13 -2
- package/node/wrappers/util/link-resolver.test.js.map +1 -1
- package/node/wrappers/util/link-resolver.test.ts +14 -2
- package/node/wrappers/util/link-resolver.ts +141 -151
- 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('
|
|
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
|
|
57
|
-
* to swap the link sub-message for the resolved
|
|
58
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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 → (
|
|
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(
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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, (
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
363
|
-
|
|
364
|
-
const cached =
|
|
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: ${
|
|
324
|
+
throw new Error(`Security not found: ${key}`);
|
|
373
325
|
}
|
|
374
326
|
const proto = protos[0];
|
|
375
|
-
|
|
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
|
|
388
|
-
|
|
389
|
-
const cached =
|
|
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: ${
|
|
349
|
+
throw new Error(`Portfolio not found: ${key}`);
|
|
398
350
|
}
|
|
399
351
|
const proto = protos[0];
|
|
400
|
-
|
|
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();
|