@comms-id/asx 0.4.1 → 1.0.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/README.md +1 -1
- package/dist/browser-profile.d.ts +21 -0
- package/dist/browser-profile.js +115 -0
- package/dist/client-replies.d.ts +161 -0
- package/dist/client-replies.js +54 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +179 -0
- package/dist/collection.d.ts +555 -0
- package/dist/collection.js +194 -0
- package/dist/company-events.d.ts +2 -0
- package/dist/company-events.js +48 -0
- package/dist/company-record.d.ts +4 -0
- package/dist/company-record.js +58 -0
- package/dist/company-stream-client.d.ts +4 -0
- package/dist/company-stream-client.js +139 -0
- package/dist/company-stream.d.ts +46 -0
- package/dist/company-stream.js +117 -0
- package/dist/descriptor.d.ts +2 -2
- package/dist/descriptor.js +2 -2
- package/dist/directory-search.d.ts +2 -0
- package/dist/directory-search.js +33 -0
- package/dist/enrichment.d.ts +75 -0
- package/dist/enrichment.js +114 -0
- package/dist/generated/fixtures.js +2 -2
- package/dist/generated/operations.js +23 -4
- package/dist/generated/types.d.ts +2 -1
- package/dist/http.d.ts +1 -0
- package/dist/http.js +49 -0
- package/dist/live.d.ts +56 -0
- package/dist/live.js +129 -0
- package/dist/model.d.ts +112 -0
- package/dist/model.js +86 -0
- package/dist/parse.d.ts +4 -0
- package/dist/parse.js +130 -0
- package/dist/publication.d.ts +25 -0
- package/dist/publication.js +89 -0
- package/dist/refresh-coordinator.d.ts +96 -0
- package/dist/refresh-coordinator.js +194 -0
- package/dist/refresh.d.ts +126 -0
- package/dist/refresh.js +153 -0
- package/dist/runtime/http.js +8 -1
- package/dist/search-service.d.ts +16 -0
- package/dist/search-service.js +64 -0
- package/dist/service.d.ts +35 -0
- package/dist/service.js +99 -0
- package/dist/snapshot.d.ts +295 -0
- package/dist/snapshot.js +131 -0
- package/dist/source-record.d.ts +63 -0
- package/dist/source-record.js +91 -0
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { type AsxDataset } from "./snapshot.js";
|
|
3
|
+
export declare const RefreshAttempt: Schema.Struct<{
|
|
4
|
+
readonly ticker: Schema.String;
|
|
5
|
+
readonly outcome: Schema.Union<readonly [Schema.Union<readonly [Schema.Struct<{
|
|
6
|
+
readonly status: Schema.Literal<"found">;
|
|
7
|
+
readonly observation: Schema.Struct<{
|
|
8
|
+
readonly company: Schema.Struct<{
|
|
9
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
10
|
+
readonly ticker: Schema.String;
|
|
11
|
+
readonly sourceId: Schema.String;
|
|
12
|
+
readonly companyName: Schema.String;
|
|
13
|
+
readonly address: Schema.NullOr<Schema.String>;
|
|
14
|
+
readonly tel: Schema.NullOr<Schema.String>;
|
|
15
|
+
readonly fax: Schema.NullOr<Schema.String>;
|
|
16
|
+
readonly website: Schema.NullOr<Schema.String>;
|
|
17
|
+
readonly description: Schema.NullOr<Schema.String>;
|
|
18
|
+
readonly issueType: Schema.NullOr<Schema.String>;
|
|
19
|
+
readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
|
|
20
|
+
readonly people: Schema.$Array<Schema.Struct<{
|
|
21
|
+
readonly sourceName: Schema.String;
|
|
22
|
+
readonly displayName: Schema.String;
|
|
23
|
+
readonly title: Schema.String;
|
|
24
|
+
readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
|
|
25
|
+
}>>;
|
|
26
|
+
}>;
|
|
27
|
+
readonly startedAt: Schema.String;
|
|
28
|
+
readonly observedAt: Schema.String;
|
|
29
|
+
readonly lastChangedAt: Schema.String;
|
|
30
|
+
}>;
|
|
31
|
+
}>, Schema.Struct<{
|
|
32
|
+
readonly status: Schema.Literal<"missing">;
|
|
33
|
+
readonly startedAt: Schema.String;
|
|
34
|
+
readonly observedAt: Schema.String;
|
|
35
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
36
|
+
}>]>, Schema.Struct<{
|
|
37
|
+
readonly _tag: Schema.Literal<"AsxError">;
|
|
38
|
+
readonly code: Schema.Literals<readonly ["INVALID_INPUT", "PARSE_FAILED", "SOURCE_MISMATCH", "TRANSPORT_FAILED", "TIMEOUT", "RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "CONFIGURATION_ERROR", "SNAPSHOT_UNAVAILABLE", "CANCELLED", "INTERNAL_ERROR", "ACCESS_DENIED", "UPSTREAM_REJECTED", "PERSISTENCE_FAILED", "PUBLICATION_CONFLICT", "COVERAGE_REJECTED"]>;
|
|
39
|
+
}>]>;
|
|
40
|
+
readonly enrichment: Schema.optionalKey<Schema.Union<readonly [Schema.Union<readonly [Schema.Struct<{
|
|
41
|
+
readonly status: Schema.Literal<"confirmed">;
|
|
42
|
+
readonly enrichment: Schema.Struct<{
|
|
43
|
+
readonly ticker: Schema.String;
|
|
44
|
+
readonly sourceId: Schema.NonEmptyString;
|
|
45
|
+
readonly abn: Schema.String;
|
|
46
|
+
readonly acn: Schema.NullOr<Schema.String>;
|
|
47
|
+
readonly legalName: Schema.NonEmptyString;
|
|
48
|
+
readonly identityEvidence: Schema.Struct<{
|
|
49
|
+
readonly url: Schema.String;
|
|
50
|
+
readonly sha256: Schema.String;
|
|
51
|
+
readonly observedAt: Schema.String;
|
|
52
|
+
readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
|
|
53
|
+
}>;
|
|
54
|
+
readonly abrEvidence: Schema.Struct<{
|
|
55
|
+
readonly source: Schema.Literals<readonly ["abr-live", "abr-snapshot"]>;
|
|
56
|
+
readonly observedAt: Schema.String;
|
|
57
|
+
}>;
|
|
58
|
+
readonly website: Schema.NullOr<Schema.Struct<{
|
|
59
|
+
readonly url: Schema.String;
|
|
60
|
+
readonly evidence: Schema.Struct<{
|
|
61
|
+
readonly url: Schema.String;
|
|
62
|
+
readonly sha256: Schema.String;
|
|
63
|
+
readonly observedAt: Schema.String;
|
|
64
|
+
readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
|
|
65
|
+
}>;
|
|
66
|
+
}>>;
|
|
67
|
+
}>;
|
|
68
|
+
}>, Schema.Struct<{
|
|
69
|
+
readonly status: Schema.Literal<"unresolved">;
|
|
70
|
+
readonly reason: Schema.Literals<readonly ["NO_EVIDENCE", "AMBIGUOUS", "INVALID_EVIDENCE", "IDENTITY_MISMATCH"]>;
|
|
71
|
+
}>]>, Schema.Struct<{
|
|
72
|
+
readonly _tag: Schema.Literal<"AsxError">;
|
|
73
|
+
readonly code: Schema.Literals<readonly ["INVALID_INPUT", "PARSE_FAILED", "SOURCE_MISMATCH", "TRANSPORT_FAILED", "TIMEOUT", "RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "CONFIGURATION_ERROR", "SNAPSHOT_UNAVAILABLE", "CANCELLED", "INTERNAL_ERROR", "ACCESS_DENIED", "UPSTREAM_REJECTED", "PERSISTENCE_FAILED", "PUBLICATION_CONFLICT", "COVERAGE_REJECTED"]>;
|
|
74
|
+
}>]>>;
|
|
75
|
+
readonly sources: Schema.optionalKey<Schema.Struct<{
|
|
76
|
+
readonly asx: Schema.optionalKey<Schema.Struct<{
|
|
77
|
+
readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
|
|
78
|
+
readonly ticker: Schema.String;
|
|
79
|
+
readonly sourceUrl: Schema.String;
|
|
80
|
+
readonly sourceId: Schema.NonEmptyString;
|
|
81
|
+
readonly status: Schema.Literals<readonly ["found", "missing"]>;
|
|
82
|
+
readonly startedAt: Schema.String;
|
|
83
|
+
readonly observedAt: Schema.String;
|
|
84
|
+
readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
85
|
+
readonly websites: Schema.$Array<Schema.String>;
|
|
86
|
+
}>>;
|
|
87
|
+
readonly morningstar: Schema.optionalKey<Schema.Struct<{
|
|
88
|
+
readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
|
|
89
|
+
readonly ticker: Schema.String;
|
|
90
|
+
readonly sourceUrl: Schema.String;
|
|
91
|
+
readonly sourceId: Schema.NonEmptyString;
|
|
92
|
+
readonly status: Schema.Literals<readonly ["found", "missing"]>;
|
|
93
|
+
readonly startedAt: Schema.String;
|
|
94
|
+
readonly observedAt: Schema.String;
|
|
95
|
+
readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
96
|
+
readonly websites: Schema.$Array<Schema.String>;
|
|
97
|
+
}>>;
|
|
98
|
+
readonly "google-finance": Schema.optionalKey<Schema.Struct<{
|
|
99
|
+
readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
|
|
100
|
+
readonly ticker: Schema.String;
|
|
101
|
+
readonly sourceUrl: Schema.String;
|
|
102
|
+
readonly sourceId: Schema.NonEmptyString;
|
|
103
|
+
readonly status: Schema.Literals<readonly ["found", "missing"]>;
|
|
104
|
+
readonly startedAt: Schema.String;
|
|
105
|
+
readonly observedAt: Schema.String;
|
|
106
|
+
readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
107
|
+
readonly websites: Schema.$Array<Schema.String>;
|
|
108
|
+
}>>;
|
|
109
|
+
}>>;
|
|
110
|
+
}>;
|
|
111
|
+
export type RefreshAttempt = typeof RefreshAttempt.Type;
|
|
112
|
+
export interface RefreshReport {
|
|
113
|
+
readonly attempted: number;
|
|
114
|
+
readonly found: number;
|
|
115
|
+
readonly missing: number;
|
|
116
|
+
readonly failed: number;
|
|
117
|
+
readonly retained: number;
|
|
118
|
+
readonly unavailable: number;
|
|
119
|
+
readonly removedFromDirectory: number;
|
|
120
|
+
readonly superseded: number;
|
|
121
|
+
}
|
|
122
|
+
/** Pure publication preparation. All directory members must have an explicit attempt. */
|
|
123
|
+
export declare function applyRefresh(previousInput: unknown, directoryInput: unknown, attemptsInput: unknown): {
|
|
124
|
+
dataset: AsxDataset;
|
|
125
|
+
report: RefreshReport;
|
|
126
|
+
};
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Result, Schema } from "effect";
|
|
2
|
+
import { DirectoryEntry, failure, Failure, isTimestamp, LiveOutcome, Ticker, validObservation, } from "./model.js";
|
|
3
|
+
import { decodeDataset } from "./snapshot.js";
|
|
4
|
+
import { EnrichmentResult, validEnrichment } from "./enrichment.js";
|
|
5
|
+
import { CompanySources, mergeCompanySources } from "./source-record.js";
|
|
6
|
+
const DirectoryObservation = Schema.Struct({
|
|
7
|
+
entries: Schema.Array(DirectoryEntry),
|
|
8
|
+
observedAt: Schema.String,
|
|
9
|
+
});
|
|
10
|
+
export const RefreshAttempt = Schema.Struct({
|
|
11
|
+
ticker: Ticker,
|
|
12
|
+
outcome: Schema.Union([LiveOutcome, Failure]),
|
|
13
|
+
enrichment: Schema.optionalKey(Schema.Union([EnrichmentResult, Failure])),
|
|
14
|
+
sources: Schema.optionalKey(CompanySources),
|
|
15
|
+
});
|
|
16
|
+
/** Pure publication preparation. All directory members must have an explicit attempt. */
|
|
17
|
+
export function applyRefresh(previousInput, directoryInput, attemptsInput) {
|
|
18
|
+
const previous = previousInput === null ? null : decodeDataset(previousInput);
|
|
19
|
+
const directoryResult = Schema.decodeUnknownResult(DirectoryObservation)(directoryInput);
|
|
20
|
+
const attemptsResult = Schema.decodeUnknownResult(Schema.Array(RefreshAttempt))(attemptsInput);
|
|
21
|
+
if (Result.isFailure(directoryResult) || Result.isFailure(attemptsResult))
|
|
22
|
+
throw failure("PARSE_FAILED");
|
|
23
|
+
const directory = directoryResult.success;
|
|
24
|
+
if (!isTimestamp(directory.observedAt) ||
|
|
25
|
+
(previous && directory.observedAt < previous.directoryObservedAt))
|
|
26
|
+
throw failure("SOURCE_MISMATCH");
|
|
27
|
+
const attempts = new Map(attemptsResult.success.map((attempt) => [attempt.ticker, attempt]));
|
|
28
|
+
const directoryTickers = new Set(directory.entries.map((entry) => entry.ticker));
|
|
29
|
+
if (directory.entries.length === 0 ||
|
|
30
|
+
directory.entries.length > 20_000 ||
|
|
31
|
+
directoryTickers.size !== directory.entries.length ||
|
|
32
|
+
attempts.size !== attemptsResult.success.length ||
|
|
33
|
+
attempts.size !== directory.entries.length ||
|
|
34
|
+
[...attempts.keys()].some((ticker) => !directoryTickers.has(ticker)))
|
|
35
|
+
throw failure("PARSE_FAILED");
|
|
36
|
+
const old = new Map(previous?.records.map((record) => [record.directory.ticker, record]) ?? []);
|
|
37
|
+
const report = {
|
|
38
|
+
attempted: attempts.size,
|
|
39
|
+
found: 0,
|
|
40
|
+
missing: 0,
|
|
41
|
+
failed: 0,
|
|
42
|
+
retained: 0,
|
|
43
|
+
unavailable: 0,
|
|
44
|
+
removedFromDirectory: [...old.keys()].filter((ticker) => !directoryTickers.has(ticker)).length,
|
|
45
|
+
superseded: 0,
|
|
46
|
+
};
|
|
47
|
+
const records = directory.entries.map((entry) => {
|
|
48
|
+
const attempt = attempts.get(entry.ticker);
|
|
49
|
+
if (!attempt)
|
|
50
|
+
throw failure("PARSE_FAILED");
|
|
51
|
+
const { outcome } = attempt;
|
|
52
|
+
if (attempt.enrichment !== undefined) {
|
|
53
|
+
if ("_tag" in outcome || outcome.status !== "found")
|
|
54
|
+
throw failure("SOURCE_MISMATCH");
|
|
55
|
+
if (!("_tag" in attempt.enrichment) &&
|
|
56
|
+
attempt.enrichment.status === "confirmed" &&
|
|
57
|
+
!validEnrichment(attempt.enrichment.enrichment, outcome.observation.company))
|
|
58
|
+
throw failure("SOURCE_MISMATCH");
|
|
59
|
+
}
|
|
60
|
+
const prior = old.get(entry.ticker);
|
|
61
|
+
let acceptedProfile = false;
|
|
62
|
+
let observation = null;
|
|
63
|
+
let missingAt = prior?.missingAt;
|
|
64
|
+
if ("_tag" in outcome) {
|
|
65
|
+
report.failed += 1;
|
|
66
|
+
// A changed directory name cannot establish continuity of a reused ticker.
|
|
67
|
+
if (outcome.code !== "SOURCE_MISMATCH" &&
|
|
68
|
+
prior?.directory.companyName === entry.companyName &&
|
|
69
|
+
prior.observation) {
|
|
70
|
+
observation = prior.observation;
|
|
71
|
+
report.retained += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (outcome.status === "missing") {
|
|
75
|
+
if (!isTimestamp(outcome.observedAt) ||
|
|
76
|
+
!isTimestamp(outcome.startedAt) ||
|
|
77
|
+
outcome.startedAt > outcome.observedAt)
|
|
78
|
+
throw failure("PARSE_FAILED");
|
|
79
|
+
report.missing += 1;
|
|
80
|
+
if (prior?.observation &&
|
|
81
|
+
outcome.startedAt < prior.observation.startedAt &&
|
|
82
|
+
prior.directory.companyName === entry.companyName) {
|
|
83
|
+
observation = prior.observation;
|
|
84
|
+
report.superseded += 1;
|
|
85
|
+
}
|
|
86
|
+
else if (!missingAt || outcome.startedAt >= missingAt.startedAt) {
|
|
87
|
+
missingAt = { startedAt: outcome.startedAt, observedAt: outcome.observedAt };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
if (!validObservation(outcome.observation) ||
|
|
92
|
+
outcome.observation.company.ticker !== entry.ticker)
|
|
93
|
+
throw failure("SOURCE_MISMATCH");
|
|
94
|
+
report.found += 1;
|
|
95
|
+
observation = outcome.observation;
|
|
96
|
+
acceptedProfile = true;
|
|
97
|
+
const oldObservation = prior?.observation;
|
|
98
|
+
if (missingAt && observation.startedAt <= missingAt.startedAt) {
|
|
99
|
+
observation = null;
|
|
100
|
+
acceptedProfile = false;
|
|
101
|
+
report.superseded += 1;
|
|
102
|
+
}
|
|
103
|
+
else if (oldObservation) {
|
|
104
|
+
missingAt = undefined;
|
|
105
|
+
if (observation.startedAt < oldObservation.startedAt) {
|
|
106
|
+
acceptedProfile = false;
|
|
107
|
+
observation = prior?.directory.companyName === entry.companyName ? oldObservation : null;
|
|
108
|
+
report.superseded += 1;
|
|
109
|
+
}
|
|
110
|
+
else if (observation.company.sourceId === oldObservation.company.sourceId &&
|
|
111
|
+
JSON.stringify(observation.company) === JSON.stringify(oldObservation.company)) {
|
|
112
|
+
observation = { ...observation, lastChangedAt: oldObservation.lastChangedAt };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
missingAt = undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (observation === null)
|
|
120
|
+
report.unavailable += 1;
|
|
121
|
+
let enrichment = observation && prior?.enrichment && validEnrichment(prior.enrichment, observation.company)
|
|
122
|
+
? prior.enrichment
|
|
123
|
+
: undefined;
|
|
124
|
+
if (acceptedProfile && attempt.enrichment && !("_tag" in attempt.enrichment)) {
|
|
125
|
+
// A completed check can remove an automatic match; transport failure cannot.
|
|
126
|
+
enrichment =
|
|
127
|
+
attempt.enrichment.status === "confirmed" ? attempt.enrichment.enrichment : undefined;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
directory: entry,
|
|
131
|
+
observation,
|
|
132
|
+
...(attempt.sources || prior?.sources
|
|
133
|
+
? {
|
|
134
|
+
sources: mergeCompanySources(entry.ticker, prior?.directory.companyName === entry.companyName &&
|
|
135
|
+
(!observation ||
|
|
136
|
+
prior.observation?.company.sourceId === observation.company.sourceId)
|
|
137
|
+
? (prior.sources ?? {})
|
|
138
|
+
: {}, attempt.sources ?? {}),
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
...(missingAt ? { missingAt } : {}),
|
|
142
|
+
...(enrichment ? { enrichment } : {}),
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
dataset: decodeDataset({
|
|
147
|
+
formatVersion: 1,
|
|
148
|
+
directoryObservedAt: directory.observedAt,
|
|
149
|
+
records,
|
|
150
|
+
}),
|
|
151
|
+
report,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/runtime/http.js
CHANGED
|
@@ -142,7 +142,9 @@ const prepare = (operation, input) => {
|
|
|
142
142
|
/** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
|
|
143
143
|
export const call = async (transport, operation, input, options = {}) => {
|
|
144
144
|
const body = prepare(operation, input);
|
|
145
|
-
const
|
|
145
|
+
const deadlineMs = options.deadlineMs ?? transport.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
146
|
+
const startedAt = performance.now();
|
|
147
|
+
const deadline = AbortSignal.timeout(deadlineMs);
|
|
146
148
|
const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]);
|
|
147
149
|
const maxRetries = operation.safeToRetry ? (transport.maxRetries ?? DEFAULT_MAX_RETRIES) : 0;
|
|
148
150
|
const sleep = transport.sleep ?? defaultSleep;
|
|
@@ -155,6 +157,11 @@ export const call = async (transport, operation, input, options = {}) => {
|
|
|
155
157
|
throw result.error;
|
|
156
158
|
}
|
|
157
159
|
const delay = result.retryAfterMs ?? Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
160
|
+
// A retry that cannot start within this call's budget must not replace a real
|
|
161
|
+
// service failure (notably 429) with a later timeout or violate Retry-After.
|
|
162
|
+
if (delay >= deadlineMs - (performance.now() - startedAt)) {
|
|
163
|
+
throw result.error;
|
|
164
|
+
}
|
|
158
165
|
try {
|
|
159
166
|
await sleep(delay, signal);
|
|
160
167
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SuggestionsReply } from "./client-replies.js";
|
|
2
|
+
import type { LookupMode } from "./service.js";
|
|
3
|
+
type Source = (query: string, limit: number, signal: AbortSignal) => Promise<unknown>;
|
|
4
|
+
export interface AsxSearchSources {
|
|
5
|
+
readonly live?: Source;
|
|
6
|
+
readonly snapshot?: Source;
|
|
7
|
+
}
|
|
8
|
+
/** Read-only search composition. Live-first acquires saved evidence only after an eligible outage. */
|
|
9
|
+
export declare function createAsxSearchService(sources: AsxSearchSources): {
|
|
10
|
+
suggest: (query: string, options?: {
|
|
11
|
+
mode?: LookupMode;
|
|
12
|
+
limit?: number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}) => Promise<SuggestionsReply>;
|
|
15
|
+
};
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
import { SuggestionsResponse } from "./client-replies.js";
|
|
3
|
+
import { suggestDirectory } from "./directory-search.js";
|
|
4
|
+
import { failure, Failure, isFallbackEligible, isTimestamp } from "./model.js";
|
|
5
|
+
const searchSource = Effect.fn("ASX.searchSource")(function* (sources, source, query, limit) {
|
|
6
|
+
const read = sources[source];
|
|
7
|
+
if (!read)
|
|
8
|
+
return yield* Effect.fail(failure(source === "live" ? "CONFIGURATION_ERROR" : "SNAPSHOT_UNAVAILABLE"));
|
|
9
|
+
const input = yield* Effect.tryPromise({
|
|
10
|
+
try: (signal) => read(query, limit, signal),
|
|
11
|
+
catch: (error) => source === "snapshot"
|
|
12
|
+
? failure("SNAPSHOT_UNAVAILABLE")
|
|
13
|
+
: Schema.is(Failure)(error)
|
|
14
|
+
? failure(error.code)
|
|
15
|
+
: failure("TRANSPORT_FAILED"),
|
|
16
|
+
});
|
|
17
|
+
const reply = yield* Schema.decodeUnknownEffect(SuggestionsResponse)(input).pipe(Effect.mapError(() => failure(source === "snapshot" ? "SNAPSHOT_UNAVAILABLE" : "PARSE_FAILED")));
|
|
18
|
+
if (reply.source !== source)
|
|
19
|
+
return yield* Effect.fail(failure("SOURCE_MISMATCH"));
|
|
20
|
+
if (!isTimestamp(reply.directoryObservedAt) ||
|
|
21
|
+
reply.suggestions.length > limit ||
|
|
22
|
+
new Set(reply.suggestions.map((entry) => entry.ticker)).size !== reply.suggestions.length ||
|
|
23
|
+
reply.suggestions.some((entry) => !entry.companyName.trim()) ||
|
|
24
|
+
(reply.source === "snapshot" && reply.fallbackReason !== undefined))
|
|
25
|
+
return yield* Effect.fail(failure(source === "snapshot" ? "SNAPSHOT_UNAVAILABLE" : "PARSE_FAILED"));
|
|
26
|
+
return reply;
|
|
27
|
+
});
|
|
28
|
+
const search = Effect.fn("ASX.search")(function* (sources, query, limit, mode) {
|
|
29
|
+
yield* Effect.try({
|
|
30
|
+
try: () => suggestDirectory([], query, limit),
|
|
31
|
+
catch: () => failure("INVALID_INPUT"),
|
|
32
|
+
});
|
|
33
|
+
if (!["live", "snapshot", "live-first"].includes(mode))
|
|
34
|
+
return yield* Effect.fail(failure("INVALID_INPUT"));
|
|
35
|
+
if (mode === "snapshot")
|
|
36
|
+
return yield* searchSource(sources, "snapshot", query, limit);
|
|
37
|
+
const live = yield* searchSource(sources, "live", query, limit).pipe(Effect.result);
|
|
38
|
+
if (Result.isSuccess(live))
|
|
39
|
+
return live.success;
|
|
40
|
+
if (mode === "live" || !isFallbackEligible(live.failure.code))
|
|
41
|
+
return yield* Effect.fail(live.failure);
|
|
42
|
+
const saved = yield* searchSource(sources, "snapshot", query, limit);
|
|
43
|
+
return { ...saved, fallbackReason: live.failure.code };
|
|
44
|
+
});
|
|
45
|
+
/** Read-only search composition. Live-first acquires saved evidence only after an eligible outage. */
|
|
46
|
+
export function createAsxSearchService(sources) {
|
|
47
|
+
return {
|
|
48
|
+
suggest: async (query, options = {}) => {
|
|
49
|
+
try {
|
|
50
|
+
const result = await Effect.runPromise(Effect.result(search(sources, query, options.limit ?? 8, options.mode ?? "snapshot")), { signal: options.signal });
|
|
51
|
+
if (Result.isFailure(result))
|
|
52
|
+
throw result.failure;
|
|
53
|
+
return result.success;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw options.signal?.aborted
|
|
57
|
+
? failure("CANCELLED")
|
|
58
|
+
: Schema.is(Failure)(error)
|
|
59
|
+
? failure(error.code)
|
|
60
|
+
: failure("INTERNAL_ERROR");
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AsxError, Observation } from "./model.js";
|
|
2
|
+
import { type ConfirmedEnrichment } from "./enrichment.js";
|
|
3
|
+
export type LookupMode = "live" | "snapshot" | "live-first";
|
|
4
|
+
export type LookupReply = {
|
|
5
|
+
readonly status: "found";
|
|
6
|
+
readonly source: "live";
|
|
7
|
+
readonly observation: Observation;
|
|
8
|
+
} | {
|
|
9
|
+
readonly status: "missing";
|
|
10
|
+
readonly source: "live";
|
|
11
|
+
readonly startedAt: string;
|
|
12
|
+
readonly observedAt: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly status: "found";
|
|
15
|
+
readonly source: "snapshot";
|
|
16
|
+
readonly observation: Observation;
|
|
17
|
+
readonly generation: string;
|
|
18
|
+
readonly enrichment?: ConfirmedEnrichment;
|
|
19
|
+
readonly requiresRecheck: true;
|
|
20
|
+
readonly fallbackReason?: AsxError["code"];
|
|
21
|
+
} | {
|
|
22
|
+
readonly status: "missing";
|
|
23
|
+
readonly source: "snapshot";
|
|
24
|
+
readonly generation: string;
|
|
25
|
+
};
|
|
26
|
+
export interface AsxSources {
|
|
27
|
+
readonly live?: (ticker: string, signal: AbortSignal) => Promise<unknown>;
|
|
28
|
+
readonly snapshot?: (ticker: string, signal: AbortSignal) => Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createAsxService(sources: AsxSources): {
|
|
31
|
+
lookup: (ticker: string, options?: {
|
|
32
|
+
mode?: LookupMode;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}) => Promise<LookupReply>;
|
|
35
|
+
};
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Effect, Fiber, Result, Schema } from "effect";
|
|
2
|
+
import { failure, Failure, isFallbackEligible, isTimestamp, LiveOutcome, Observation, validObservation, } from "./model.js";
|
|
3
|
+
import { normalizeTicker } from "./parse.js";
|
|
4
|
+
import { SnapshotOutcome } from "./snapshot.js";
|
|
5
|
+
import { validEnrichment } from "./enrichment.js";
|
|
6
|
+
function safeError(error, fallback) {
|
|
7
|
+
return Schema.is(Failure)(error) ? failure(error.code) : failure(fallback);
|
|
8
|
+
}
|
|
9
|
+
const liveLookup = Effect.fn("ASX.live")(function* (sources, ticker) {
|
|
10
|
+
const live = sources.live;
|
|
11
|
+
if (!live)
|
|
12
|
+
return yield* Effect.fail(failure("CONFIGURATION_ERROR"));
|
|
13
|
+
const input = yield* Effect.tryPromise({
|
|
14
|
+
try: (signal) => live(ticker, signal),
|
|
15
|
+
catch: (error) => safeError(error, "TRANSPORT_FAILED"),
|
|
16
|
+
});
|
|
17
|
+
const decoded = yield* Schema.decodeUnknownEffect(LiveOutcome)(input).pipe(Effect.mapError(() => failure("PARSE_FAILED")));
|
|
18
|
+
if (decoded.status === "missing") {
|
|
19
|
+
if (!isTimestamp(decoded.observedAt) ||
|
|
20
|
+
!isTimestamp(decoded.startedAt) ||
|
|
21
|
+
decoded.startedAt > decoded.observedAt)
|
|
22
|
+
return yield* Effect.fail(failure("PARSE_FAILED"));
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
if (!validObservation(decoded.observation))
|
|
26
|
+
return yield* Effect.fail(failure("PARSE_FAILED"));
|
|
27
|
+
if (decoded.observation.company.ticker !== ticker)
|
|
28
|
+
return yield* Effect.fail(failure("SOURCE_MISMATCH"));
|
|
29
|
+
}
|
|
30
|
+
return decoded;
|
|
31
|
+
});
|
|
32
|
+
const snapshotLookup = Effect.fn("ASX.snapshot")(function* (sources, ticker) {
|
|
33
|
+
const snapshot = sources.snapshot;
|
|
34
|
+
if (!snapshot)
|
|
35
|
+
return yield* Effect.fail(failure("SNAPSHOT_UNAVAILABLE"));
|
|
36
|
+
const input = yield* Effect.tryPromise({
|
|
37
|
+
try: (signal) => snapshot(ticker, signal),
|
|
38
|
+
catch: () => failure("SNAPSHOT_UNAVAILABLE"),
|
|
39
|
+
});
|
|
40
|
+
const reply = yield* Schema.decodeUnknownEffect(SnapshotOutcome)(input).pipe(Effect.mapError(() => failure("SNAPSHOT_UNAVAILABLE")));
|
|
41
|
+
if (!/^[a-f0-9]{64}$/.test(reply.generation) || reply.status === "unavailable")
|
|
42
|
+
return yield* Effect.fail(failure("SNAPSHOT_UNAVAILABLE"));
|
|
43
|
+
if (reply.status === "found" &&
|
|
44
|
+
(!Schema.is(Observation)(reply.observation) ||
|
|
45
|
+
!validObservation(reply.observation) ||
|
|
46
|
+
reply.observation.company.ticker !== ticker ||
|
|
47
|
+
(reply.enrichment !== undefined &&
|
|
48
|
+
!validEnrichment(reply.enrichment, reply.observation.company))))
|
|
49
|
+
return yield* Effect.fail(failure("SNAPSHOT_UNAVAILABLE"));
|
|
50
|
+
return reply;
|
|
51
|
+
});
|
|
52
|
+
const lookup = Effect.fn("ASX.lookup")(function* (sources, input, mode) {
|
|
53
|
+
const ticker = yield* Effect.try({
|
|
54
|
+
try: () => normalizeTicker(input),
|
|
55
|
+
catch: () => failure("INVALID_INPUT"),
|
|
56
|
+
});
|
|
57
|
+
if (!["live", "snapshot", "live-first"].includes(mode))
|
|
58
|
+
return yield* Effect.fail(failure("INVALID_INPUT"));
|
|
59
|
+
if (mode === "snapshot") {
|
|
60
|
+
const saved = yield* snapshotLookup(sources, ticker);
|
|
61
|
+
return saved.status === "missing"
|
|
62
|
+
? { ...saved, source: "snapshot" }
|
|
63
|
+
: { ...saved, source: "snapshot", requiresRecheck: true };
|
|
64
|
+
}
|
|
65
|
+
const saved = mode === "live-first" && sources.snapshot
|
|
66
|
+
? yield* snapshotLookup(sources, ticker).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
|
67
|
+
: undefined;
|
|
68
|
+
const live = yield* liveLookup(sources, ticker).pipe(Effect.result);
|
|
69
|
+
if (Result.isSuccess(live))
|
|
70
|
+
return { ...live.success, source: "live" };
|
|
71
|
+
if (!isFallbackEligible(live.failure.code) || !saved)
|
|
72
|
+
return yield* Effect.fail(live.failure);
|
|
73
|
+
const result = yield* Fiber.join(saved);
|
|
74
|
+
if (Result.isFailure(result))
|
|
75
|
+
return yield* Effect.fail(result.failure);
|
|
76
|
+
if (result.success.status === "missing")
|
|
77
|
+
return yield* Effect.fail(live.failure);
|
|
78
|
+
return {
|
|
79
|
+
...result.success,
|
|
80
|
+
source: "snapshot",
|
|
81
|
+
requiresRecheck: true,
|
|
82
|
+
fallbackReason: live.failure.code,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
export function createAsxService(sources) {
|
|
86
|
+
return {
|
|
87
|
+
lookup: async (ticker, options = {}) => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await Effect.runPromise(Effect.result(lookup(sources, ticker, options.mode ?? "live-first")), { signal: options.signal });
|
|
90
|
+
if (Result.isFailure(result))
|
|
91
|
+
throw result.failure;
|
|
92
|
+
return result.success;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw options.signal?.aborted ? failure("CANCELLED") : safeError(error, "INTERNAL_ERROR");
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|