@comms-id/asx 0.4.0 → 0.4.2
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 +178 -0
- package/dist/client-replies.js +57 -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 +60 -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/contract/http-api.d.ts +139 -0
- package/dist/contract/http-api.js +100 -0
- 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/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/profile-contact.d.ts +13 -0
- package/dist/profile-contact.js +66 -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 +157 -0
- package/dist/requests.d.ts +10 -0
- package/dist/requests.js +21 -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 +328 -0
- package/dist/snapshot.js +151 -0
- package/dist/source-record.d.ts +63 -0
- package/dist/source-record.js +91 -0
- package/package.json +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
import { DirectoryEntry, failure, Failure, LiveOutcome, Timestamp } from "./model.js";
|
|
3
|
+
import { EnrichmentResult } from "./enrichment.js";
|
|
4
|
+
import { applyRefresh, RefreshAttempt } from "./refresh.js";
|
|
5
|
+
import { asxSourceObservation, decodeCompanySources, mergeCompanySources, } from "./source-record.js";
|
|
6
|
+
const Checkpoint = Schema.Struct({
|
|
7
|
+
formatVersion: Schema.Literal(1),
|
|
8
|
+
runId: Schema.String.check(Schema.isPattern(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/)),
|
|
9
|
+
baseGeneration: Schema.NullOr(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))),
|
|
10
|
+
directory: Schema.Struct({ entries: Schema.Array(DirectoryEntry), observedAt: Timestamp }),
|
|
11
|
+
attempts: Schema.Array(RefreshAttempt),
|
|
12
|
+
});
|
|
13
|
+
/** Validate persisted progress before resuming; the directory and completed prefix cannot diverge. */
|
|
14
|
+
export function decodeRefreshCheckpoint(input) {
|
|
15
|
+
const decoded = Schema.decodeUnknownResult(Checkpoint)(input);
|
|
16
|
+
if (Result.isFailure(decoded))
|
|
17
|
+
throw failure("PARSE_FAILED");
|
|
18
|
+
const checkpoint = decoded.success;
|
|
19
|
+
if (checkpoint.attempts.length > checkpoint.directory.entries.length ||
|
|
20
|
+
checkpoint.attempts.some((attempt, index) => attempt.ticker !== checkpoint.directory.entries[index].ticker))
|
|
21
|
+
throw failure("SOURCE_MISMATCH");
|
|
22
|
+
// The same publication validation owns both complete and unfinished passes.
|
|
23
|
+
applyRefresh(null, checkpoint.directory, [
|
|
24
|
+
...checkpoint.attempts,
|
|
25
|
+
...checkpoint.directory.entries
|
|
26
|
+
.slice(checkpoint.attempts.length)
|
|
27
|
+
.map((entry) => ({ ticker: entry.ticker, outcome: failure("UPSTREAM_UNAVAILABLE") })),
|
|
28
|
+
]);
|
|
29
|
+
return structuredClone(checkpoint);
|
|
30
|
+
}
|
|
31
|
+
const sourceError = (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("TRANSPORT_FAILED");
|
|
32
|
+
const begin = Effect.fn("ASX.collection.begin")(function* (source, baseGeneration) {
|
|
33
|
+
if (baseGeneration !== null && !/^[a-f0-9]{64}$/.test(baseGeneration))
|
|
34
|
+
return yield* Effect.fail(failure("INVALID_INPUT"));
|
|
35
|
+
const directory = yield* Effect.tryPromise({ try: source.directory, catch: sourceError });
|
|
36
|
+
return yield* Effect.try({
|
|
37
|
+
try: () => decodeRefreshCheckpoint({
|
|
38
|
+
formatVersion: 1,
|
|
39
|
+
runId: crypto.randomUUID(),
|
|
40
|
+
baseGeneration,
|
|
41
|
+
directory,
|
|
42
|
+
attempts: [],
|
|
43
|
+
}),
|
|
44
|
+
catch: (error) => (Schema.is(Failure)(error) ? error : failure("PARSE_FAILED")),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const batch = Effect.fn("ASX.collection.batch")(function* (source, input, batchSize, intervalMs) {
|
|
48
|
+
if (!Number.isInteger(batchSize) ||
|
|
49
|
+
batchSize < 1 ||
|
|
50
|
+
batchSize > 100 ||
|
|
51
|
+
!Number.isInteger(intervalMs) ||
|
|
52
|
+
intervalMs < 0 ||
|
|
53
|
+
intervalMs > 60_000)
|
|
54
|
+
return yield* Effect.fail(failure("INVALID_INPUT"));
|
|
55
|
+
const checkpoint = yield* Effect.try({
|
|
56
|
+
try: () => decodeRefreshCheckpoint(input),
|
|
57
|
+
catch: sourceError,
|
|
58
|
+
});
|
|
59
|
+
const attempts = [...checkpoint.attempts];
|
|
60
|
+
const entries = checkpoint.directory.entries.slice(attempts.length, attempts.length + batchSize);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
// Delay before each call also bounds consecutive resumed batches. Host chooses the permitted rate.
|
|
63
|
+
if (intervalMs > 0)
|
|
64
|
+
yield* Effect.sleep(intervalMs);
|
|
65
|
+
const profile = Effect.tryPromise({
|
|
66
|
+
try: (signal) => source.lookup(entry.ticker, signal),
|
|
67
|
+
catch: sourceError,
|
|
68
|
+
}).pipe(Effect.result);
|
|
69
|
+
const collectSources = source.collectSources;
|
|
70
|
+
const [result, collectedSources] = yield* Effect.all([
|
|
71
|
+
profile,
|
|
72
|
+
collectSources
|
|
73
|
+
? Effect.tryPromise({
|
|
74
|
+
try: async (signal) => decodeCompanySources(await collectSources(entry, signal), entry.ticker),
|
|
75
|
+
catch: sourceError,
|
|
76
|
+
}).pipe(Effect.result)
|
|
77
|
+
: Effect.succeed(Result.succeed({})),
|
|
78
|
+
], { concurrency: 2 });
|
|
79
|
+
// Profile-source acquisition failures do not discard ASX or block the next issuer.
|
|
80
|
+
let sources = collectSources && Result.isSuccess(collectedSources) ? collectedSources.success : undefined;
|
|
81
|
+
let outcome;
|
|
82
|
+
if (Result.isFailure(result)) {
|
|
83
|
+
// Permission/configuration failures stop collection instead of producing thousands of failed calls.
|
|
84
|
+
if ([
|
|
85
|
+
"ACCESS_DENIED",
|
|
86
|
+
"CONFIGURATION_ERROR",
|
|
87
|
+
"CANCELLED",
|
|
88
|
+
"UPSTREAM_REJECTED",
|
|
89
|
+
"RATE_LIMITED",
|
|
90
|
+
].includes(result.failure.code))
|
|
91
|
+
return { checkpoint: { ...checkpoint, attempts }, complete: false, halted: result.failure };
|
|
92
|
+
outcome = result.failure;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const decoded = Schema.decodeUnknownResult(LiveOutcome)(result.success);
|
|
96
|
+
outcome = Result.isFailure(decoded) ? failure("PARSE_FAILED") : decoded.success;
|
|
97
|
+
// Validate each result at the existing publication boundary; bad identity cannot be retained as success.
|
|
98
|
+
try {
|
|
99
|
+
applyRefresh(null, { entries: [entry], observedAt: checkpoint.directory.observedAt }, [
|
|
100
|
+
{ ticker: entry.ticker, outcome },
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
outcome = Schema.is(Failure)(error) ? failure(error.code) : failure("PARSE_FAILED");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!("_tag" in outcome) &&
|
|
108
|
+
(outcome.status === "found" ? outcome.observation.company.raw : outcome.raw)) {
|
|
109
|
+
sources = mergeCompanySources(entry.ticker, sources ?? {}, {
|
|
110
|
+
asx: asxSourceObservation(entry.ticker, outcome),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
let enrichment;
|
|
114
|
+
const storeSources = source.storeSources;
|
|
115
|
+
if (sources && storeSources) {
|
|
116
|
+
const observations = sources;
|
|
117
|
+
yield* Effect.tryPromise({
|
|
118
|
+
try: (signal) => storeSources(entry, observations, signal),
|
|
119
|
+
catch: sourceError,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (source.enrich && !("_tag" in outcome) && outcome.status === "found") {
|
|
123
|
+
const company = outcome.observation.company;
|
|
124
|
+
const enrich = source.enrich;
|
|
125
|
+
const result = yield* Effect.tryPromise({
|
|
126
|
+
try: (signal) => enrich(company, signal, sources),
|
|
127
|
+
catch: sourceError,
|
|
128
|
+
}).pipe(Effect.result);
|
|
129
|
+
if (Result.isFailure(result)) {
|
|
130
|
+
if ([
|
|
131
|
+
"ACCESS_DENIED",
|
|
132
|
+
"CONFIGURATION_ERROR",
|
|
133
|
+
"CANCELLED",
|
|
134
|
+
"UPSTREAM_REJECTED",
|
|
135
|
+
"RATE_LIMITED",
|
|
136
|
+
].includes(result.failure.code))
|
|
137
|
+
return {
|
|
138
|
+
checkpoint: { ...checkpoint, attempts },
|
|
139
|
+
complete: false,
|
|
140
|
+
halted: result.failure,
|
|
141
|
+
};
|
|
142
|
+
enrichment = result.failure;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const decoded = Schema.decodeUnknownResult(EnrichmentResult)(result.success);
|
|
146
|
+
enrichment = Result.isFailure(decoded)
|
|
147
|
+
? { status: "unresolved", reason: "INVALID_EVIDENCE" }
|
|
148
|
+
: decoded.success;
|
|
149
|
+
try {
|
|
150
|
+
applyRefresh(null, { entries: [entry], observedAt: checkpoint.directory.observedAt }, [
|
|
151
|
+
{ ticker: entry.ticker, outcome, enrichment },
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
enrichment = { status: "unresolved", reason: "INVALID_EVIDENCE" };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
attempts.push({
|
|
160
|
+
ticker: entry.ticker,
|
|
161
|
+
outcome,
|
|
162
|
+
...(enrichment ? { enrichment } : {}),
|
|
163
|
+
...(sources && !storeSources ? { sources } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const next = { ...checkpoint, attempts };
|
|
167
|
+
return {
|
|
168
|
+
checkpoint: next,
|
|
169
|
+
complete: attempts.length === checkpoint.directory.entries.length,
|
|
170
|
+
halted: null,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
async function run(program, signal) {
|
|
174
|
+
try {
|
|
175
|
+
const result = await Effect.runPromise(Effect.result(program), { signal });
|
|
176
|
+
if (Result.isFailure(result))
|
|
177
|
+
throw result.failure;
|
|
178
|
+
return result.success;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw signal?.aborted
|
|
182
|
+
? failure("CANCELLED")
|
|
183
|
+
: Schema.is(Failure)(error)
|
|
184
|
+
? error
|
|
185
|
+
: failure("INTERNAL_ERROR");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** The host owns source permission, durable checkpoints, scheduling and publication after completion. */
|
|
189
|
+
export function createRefreshCollector(source) {
|
|
190
|
+
return {
|
|
191
|
+
begin: (baseGeneration, signal) => run(begin(source, baseGeneration), signal),
|
|
192
|
+
batch: (checkpoint, options) => run(batch(source, checkpoint, options.batchSize, options.intervalMs), options.signal),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Result, Schema } from "effect";
|
|
2
|
+
import { failure, Failure, Ticker, validObservation } from "./model.js";
|
|
3
|
+
import { CompanyRecord } from "./snapshot.js";
|
|
4
|
+
import { SourceName, SourceObservation, decodeCompanySources, decodeSourceObservation, } from "./source-record.js";
|
|
5
|
+
const Event = Schema.Union([
|
|
6
|
+
Schema.Struct({
|
|
7
|
+
type: Schema.Literal("snapshot"),
|
|
8
|
+
source: Schema.Literal("snapshot"),
|
|
9
|
+
ticker: Ticker,
|
|
10
|
+
generation: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
|
|
11
|
+
record: Schema.NullOr(CompanyRecord),
|
|
12
|
+
}),
|
|
13
|
+
Schema.Struct({
|
|
14
|
+
type: Schema.Literal("source"),
|
|
15
|
+
source: SourceName,
|
|
16
|
+
ticker: Ticker,
|
|
17
|
+
observation: SourceObservation,
|
|
18
|
+
}),
|
|
19
|
+
Schema.Struct({
|
|
20
|
+
type: Schema.Literal("error"),
|
|
21
|
+
source: Schema.Union([SourceName, Schema.Literal("snapshot")]),
|
|
22
|
+
ticker: Ticker,
|
|
23
|
+
operation: Schema.Literals(["read", "persist"]),
|
|
24
|
+
error: Failure,
|
|
25
|
+
}),
|
|
26
|
+
Schema.Struct({ type: Schema.Literal("complete"), ticker: Ticker }),
|
|
27
|
+
]);
|
|
28
|
+
export function decodeCompanyEvent(input, ticker, mode) {
|
|
29
|
+
const decoded = Schema.decodeUnknownResult(Event)(input);
|
|
30
|
+
if (Result.isFailure(decoded))
|
|
31
|
+
throw failure("PARSE_FAILED");
|
|
32
|
+
const event = decoded.success;
|
|
33
|
+
if (event.ticker !== ticker ||
|
|
34
|
+
(mode === "snapshot" && event.type !== "complete" && event.source !== "snapshot"))
|
|
35
|
+
throw failure("SOURCE_MISMATCH");
|
|
36
|
+
if (event.type === "source")
|
|
37
|
+
decodeSourceObservation(event.observation, ticker, event.source);
|
|
38
|
+
if (event.type === "snapshot" && event.record) {
|
|
39
|
+
const record = event.record;
|
|
40
|
+
if (record.directory.ticker !== ticker ||
|
|
41
|
+
(record.observation &&
|
|
42
|
+
(record.observation.company.ticker !== ticker || !validObservation(record.observation))))
|
|
43
|
+
throw failure("SOURCE_MISMATCH");
|
|
44
|
+
if (record.sources)
|
|
45
|
+
decodeCompanySources(record.sources, ticker);
|
|
46
|
+
}
|
|
47
|
+
return event;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CompanyRecord } from "./snapshot.js";
|
|
2
|
+
import { type CompanySources } from "./source-record.js";
|
|
3
|
+
/** Reconcile a newer saved ASX result before exposing a row or using outage fallback. */
|
|
4
|
+
export declare function mergeCompanyRecord(record: CompanyRecord, incoming: CompanySources): CompanyRecord;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { validEnrichment } from "./enrichment.js";
|
|
2
|
+
import { failure } from "./model.js";
|
|
3
|
+
import { parseCompany } from "./parse.js";
|
|
4
|
+
import { mergeCompanySources } from "./source-record.js";
|
|
5
|
+
/** Reconcile a newer saved ASX result before exposing a row or using outage fallback. */
|
|
6
|
+
export function mergeCompanyRecord(record, incoming) {
|
|
7
|
+
const ticker = record.directory.ticker;
|
|
8
|
+
const sources = mergeCompanySources(ticker, record.sources ?? {}, incoming);
|
|
9
|
+
const asx = sources.asx;
|
|
10
|
+
const prior = record.observation ?? record.missingAt;
|
|
11
|
+
if (!asx ||
|
|
12
|
+
(prior &&
|
|
13
|
+
(asx.startedAt < prior.startedAt ||
|
|
14
|
+
(asx.startedAt === prior.startedAt && asx.observedAt <= prior.observedAt)))) {
|
|
15
|
+
if (asx?.status === "found" &&
|
|
16
|
+
record.observation &&
|
|
17
|
+
asx.sourceId !== record.observation.company.sourceId) {
|
|
18
|
+
return Object.assign({}, structuredClone(record), { sources: {} });
|
|
19
|
+
}
|
|
20
|
+
return Object.assign({}, structuredClone(record), { sources });
|
|
21
|
+
}
|
|
22
|
+
if (asx.status === "missing")
|
|
23
|
+
return {
|
|
24
|
+
directory: structuredClone(record.directory),
|
|
25
|
+
sources,
|
|
26
|
+
observation: null,
|
|
27
|
+
missingAt: { startedAt: asx.startedAt, observedAt: asx.observedAt },
|
|
28
|
+
...(record.abnNameMatch ? { abnNameMatch: structuredClone(record.abnNameMatch) } : {}),
|
|
29
|
+
};
|
|
30
|
+
const company = parseCompany(ticker, asx.payload);
|
|
31
|
+
if (company.sourceId !== asx.sourceId)
|
|
32
|
+
throw failure("SOURCE_MISMATCH");
|
|
33
|
+
const changedIssuer = record.observation && record.observation.company.sourceId !== company.sourceId;
|
|
34
|
+
const currentSources = changedIssuer
|
|
35
|
+
? {
|
|
36
|
+
asx,
|
|
37
|
+
...(sources.morningstar && sources.morningstar.startedAt >= asx.startedAt
|
|
38
|
+
? { morningstar: sources.morningstar }
|
|
39
|
+
: {}),
|
|
40
|
+
...(sources["google-finance"] && sources["google-finance"].startedAt >= asx.startedAt
|
|
41
|
+
? { "google-finance": sources["google-finance"] }
|
|
42
|
+
: {}),
|
|
43
|
+
}
|
|
44
|
+
: sources;
|
|
45
|
+
const unchanged = record.observation && JSON.stringify(company) === JSON.stringify(record.observation.company);
|
|
46
|
+
return {
|
|
47
|
+
directory: structuredClone(record.directory),
|
|
48
|
+
sources: currentSources,
|
|
49
|
+
observation: {
|
|
50
|
+
company,
|
|
51
|
+
startedAt: asx.startedAt,
|
|
52
|
+
observedAt: asx.observedAt,
|
|
53
|
+
lastChangedAt: unchanged && record.observation ? record.observation.lastChangedAt : asx.observedAt,
|
|
54
|
+
},
|
|
55
|
+
...(record.enrichment && validEnrichment(record.enrichment, company)
|
|
56
|
+
? { enrichment: structuredClone(record.enrichment) }
|
|
57
|
+
: {}),
|
|
58
|
+
...(record.abnNameMatch ? { abnNameMatch: structuredClone(record.abnNameMatch) } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AsxClientConfig } from "./client.js";
|
|
2
|
+
import type { CompanyEvent, CompanyStreamOptions } from "./company-stream.js";
|
|
3
|
+
/** NDJSON facade: each validated event is yielded as soon as it arrives. */
|
|
4
|
+
export declare function createCompanyStreamClient(config: AsxClientConfig, base: URL): (input: string, options?: CompanyStreamOptions) => AsyncGenerator<CompanyEvent>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
import { failure, Failure } from "./model.js";
|
|
3
|
+
import { normalizeTicker } from "./parse.js";
|
|
4
|
+
import { decodeCompanyEvent } from "./company-events.js";
|
|
5
|
+
/** NDJSON facade: each validated event is yielded as soon as it arrives. */
|
|
6
|
+
export function createCompanyStreamClient(config, base) {
|
|
7
|
+
return async function* observe(input, options = {}) {
|
|
8
|
+
const ticker = normalizeTicker(input);
|
|
9
|
+
const mode = options.mode ?? "revalidate";
|
|
10
|
+
if (!["snapshot", "revalidate", "all-live"].includes(mode))
|
|
11
|
+
throw failure("INVALID_INPUT");
|
|
12
|
+
const cancelled = new AbortController();
|
|
13
|
+
const deadline = AbortSignal.timeout(config.timeoutMs ?? 60_000);
|
|
14
|
+
const signal = AbortSignal.any([
|
|
15
|
+
cancelled.signal,
|
|
16
|
+
deadline,
|
|
17
|
+
...(options.signal ? [options.signal] : []),
|
|
18
|
+
]);
|
|
19
|
+
let reader;
|
|
20
|
+
const abort = () => {
|
|
21
|
+
void reader?.cancel().catch(() => undefined);
|
|
22
|
+
};
|
|
23
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
24
|
+
try {
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const opened = await Effect.runPromise(Effect.result(Effect.tryPromise({
|
|
27
|
+
try: async (requestSignal) => {
|
|
28
|
+
const combined = AbortSignal.any([signal, requestSignal]);
|
|
29
|
+
const token = await config.capability(combined);
|
|
30
|
+
combined.throwIfAborted();
|
|
31
|
+
if (typeof token !== "string" ||
|
|
32
|
+
!token.trim() ||
|
|
33
|
+
token.length > 8192 ||
|
|
34
|
+
/\s/.test(token.trim()))
|
|
35
|
+
throw failure("CONFIGURATION_ERROR");
|
|
36
|
+
return (config.fetch ?? globalThis.fetch)(new URL("company/stream", base), {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: JSON.stringify({ ticker, mode }),
|
|
39
|
+
signal: combined,
|
|
40
|
+
redirect: "manual",
|
|
41
|
+
cache: "no-store",
|
|
42
|
+
headers: {
|
|
43
|
+
authorization: `Bearer ${token.trim()}`,
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
accept: "application/x-ndjson",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
catch: (error) => (Schema.is(Failure)(error) ? error : failure("TRANSPORT_FAILED")),
|
|
50
|
+
}).pipe(Effect.timeoutOrElse({
|
|
51
|
+
duration: config.timeoutMs ?? 60_000,
|
|
52
|
+
orElse: () => Effect.fail(failure("TIMEOUT")),
|
|
53
|
+
}))), { signal });
|
|
54
|
+
if (Result.isFailure(opened))
|
|
55
|
+
throw opened.failure;
|
|
56
|
+
const response = opened.success;
|
|
57
|
+
if (!response.ok ||
|
|
58
|
+
!response.headers.get("content-type")?.startsWith("application/x-ndjson") ||
|
|
59
|
+
!response.body) {
|
|
60
|
+
await response.body?.cancel();
|
|
61
|
+
throw failure(response.status === 401 || response.status === 403
|
|
62
|
+
? "ACCESS_DENIED"
|
|
63
|
+
: response.status === 429
|
|
64
|
+
? "RATE_LIMITED"
|
|
65
|
+
: "UPSTREAM_UNAVAILABLE");
|
|
66
|
+
}
|
|
67
|
+
reader = response.body.getReader();
|
|
68
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
69
|
+
let buffer = "";
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
let count = 0;
|
|
72
|
+
let complete = false;
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (;;) {
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
const next = await reader.read();
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
if (next.done)
|
|
79
|
+
break;
|
|
80
|
+
bytes += next.value.byteLength;
|
|
81
|
+
if (bytes > 16 * 1024 * 1024)
|
|
82
|
+
throw failure("PARSE_FAILED");
|
|
83
|
+
buffer += decoder.decode(next.value, { stream: true });
|
|
84
|
+
let end;
|
|
85
|
+
while ((end = buffer.indexOf("\n")) !== -1) {
|
|
86
|
+
const line = buffer.slice(0, end);
|
|
87
|
+
buffer = buffer.slice(end + 1);
|
|
88
|
+
if (complete || !line || ++count > 12 || line.length > 4 * 1024 * 1024)
|
|
89
|
+
throw failure("PARSE_FAILED");
|
|
90
|
+
let payload;
|
|
91
|
+
try {
|
|
92
|
+
payload = JSON.parse(line);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw failure("PARSE_FAILED");
|
|
96
|
+
}
|
|
97
|
+
const event = decodeCompanyEvent(payload, ticker, mode);
|
|
98
|
+
const key = event.type === "source"
|
|
99
|
+
? `source:${event.source}`
|
|
100
|
+
: event.type === "snapshot"
|
|
101
|
+
? "snapshot"
|
|
102
|
+
: null;
|
|
103
|
+
if (key && seen.has(key))
|
|
104
|
+
throw failure("PARSE_FAILED");
|
|
105
|
+
if (key)
|
|
106
|
+
seen.add(key);
|
|
107
|
+
complete = event.type === "complete";
|
|
108
|
+
yield event;
|
|
109
|
+
}
|
|
110
|
+
if (buffer.length > 4 * 1024 * 1024)
|
|
111
|
+
throw failure("PARSE_FAILED");
|
|
112
|
+
}
|
|
113
|
+
buffer += decoder.decode();
|
|
114
|
+
if (!complete || buffer.length)
|
|
115
|
+
throw failure("PARSE_FAILED");
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw options.signal?.aborted
|
|
119
|
+
? failure("CANCELLED")
|
|
120
|
+
: deadline.aborted
|
|
121
|
+
? failure("TIMEOUT")
|
|
122
|
+
: Schema.is(Failure)(error)
|
|
123
|
+
? error
|
|
124
|
+
: failure("TRANSPORT_FAILED");
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
signal.removeEventListener("abort", abort);
|
|
128
|
+
cancelled.abort();
|
|
129
|
+
if (reader) {
|
|
130
|
+
try {
|
|
131
|
+
await reader.cancel();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
reader.releaseLock();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type AsxError } from "./model.js";
|
|
2
|
+
import { CompanyRecord } from "./snapshot.js";
|
|
3
|
+
import { type SourceName, type SourceObservation } from "./source-record.js";
|
|
4
|
+
export type CompanyStreamMode = "snapshot" | "revalidate" | "all-live";
|
|
5
|
+
export type CompanyEvent = {
|
|
6
|
+
readonly type: "snapshot";
|
|
7
|
+
readonly source: "snapshot";
|
|
8
|
+
readonly ticker: string;
|
|
9
|
+
readonly generation: string;
|
|
10
|
+
readonly record: CompanyRecord | null;
|
|
11
|
+
} | {
|
|
12
|
+
readonly type: "source";
|
|
13
|
+
readonly source: SourceName;
|
|
14
|
+
readonly ticker: string;
|
|
15
|
+
readonly observation: SourceObservation;
|
|
16
|
+
} | {
|
|
17
|
+
readonly type: "error";
|
|
18
|
+
readonly source: SourceName | "snapshot";
|
|
19
|
+
readonly ticker: string;
|
|
20
|
+
readonly operation: "read" | "persist";
|
|
21
|
+
readonly error: AsxError;
|
|
22
|
+
} | {
|
|
23
|
+
readonly type: "complete";
|
|
24
|
+
readonly ticker: string;
|
|
25
|
+
};
|
|
26
|
+
export interface CompanyStreamSources {
|
|
27
|
+
readonly snapshot: (ticker: string, signal: AbortSignal) => Promise<{
|
|
28
|
+
readonly generation: string;
|
|
29
|
+
readonly record: CompanyRecord | null;
|
|
30
|
+
}>;
|
|
31
|
+
readonly live: Partial<Record<SourceName, (ticker: string, signal: AbortSignal) => Promise<unknown>>>;
|
|
32
|
+
/** Host-owned conditional merge. A completed live result is emitted before this is awaited. */
|
|
33
|
+
readonly persist?: (observation: SourceObservation, signal: AbortSignal) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface CompanyStreamOptions {
|
|
36
|
+
readonly mode?: CompanyStreamMode;
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
/** Independent source streams; completion order never establishes field precedence. */
|
|
40
|
+
export declare function createCompanyStream(sources: CompanyStreamSources, config?: {
|
|
41
|
+
readonly timeoutMs?: number;
|
|
42
|
+
readonly refreshAfterMs?: number;
|
|
43
|
+
readonly now?: () => number;
|
|
44
|
+
}): {
|
|
45
|
+
observe(this: void, input: string, options?: CompanyStreamOptions): AsyncIterable<CompanyEvent>;
|
|
46
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Effect, Result, Schema, Stream } from "effect";
|
|
2
|
+
import { failure, Failure, validObservation } from "./model.js";
|
|
3
|
+
import { normalizeTicker } from "./parse.js";
|
|
4
|
+
import { CompanyRecord } from "./snapshot.js";
|
|
5
|
+
import { decodeCompanySources, decodeSourceObservation, SOURCE_NAMES, } from "./source-record.js";
|
|
6
|
+
const safe = (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("TRANSPORT_FAILED");
|
|
7
|
+
/** Independent source streams; completion order never establishes field precedence. */
|
|
8
|
+
export function createCompanyStream(sources, config = {}) {
|
|
9
|
+
const timeoutMs = config.timeoutMs ?? 15_000;
|
|
10
|
+
const refreshAfterMs = config.refreshAfterMs ?? 86_400_000;
|
|
11
|
+
const now = config.now ?? Date.now;
|
|
12
|
+
if (!Number.isInteger(timeoutMs) ||
|
|
13
|
+
timeoutMs < 1 ||
|
|
14
|
+
timeoutMs > 60_000 ||
|
|
15
|
+
!Number.isInteger(refreshAfterMs) ||
|
|
16
|
+
refreshAfterMs < 1)
|
|
17
|
+
throw failure("CONFIGURATION_ERROR");
|
|
18
|
+
const io = (operation) => Effect.tryPromise({ try: operation, catch: safe }).pipe(Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => Effect.fail(failure("TIMEOUT")) }));
|
|
19
|
+
return {
|
|
20
|
+
observe(input, options = {}) {
|
|
21
|
+
const ticker = normalizeTicker(input);
|
|
22
|
+
const mode = options.mode ?? "revalidate";
|
|
23
|
+
if (!["snapshot", "revalidate", "all-live"].includes(mode))
|
|
24
|
+
throw failure("INVALID_INPUT");
|
|
25
|
+
if (options.signal?.aborted)
|
|
26
|
+
throw failure("CANCELLED");
|
|
27
|
+
const stream = Stream.unwrap(Effect.gen(function* () {
|
|
28
|
+
const saved = yield* Effect.cached(io(async (signal) => {
|
|
29
|
+
const saved = await sources.snapshot(ticker, signal).catch((error) => {
|
|
30
|
+
throw Schema.is(Failure)(error) ? error : failure("SNAPSHOT_UNAVAILABLE");
|
|
31
|
+
});
|
|
32
|
+
if (!/^[a-f0-9]{64}$/.test(saved.generation))
|
|
33
|
+
throw failure("SNAPSHOT_UNAVAILABLE");
|
|
34
|
+
const record = saved.record;
|
|
35
|
+
if (record === null)
|
|
36
|
+
return { generation: saved.generation, record: null };
|
|
37
|
+
const result = Schema.decodeUnknownResult(CompanyRecord)(record);
|
|
38
|
+
if (Result.isFailure(result))
|
|
39
|
+
throw failure("SNAPSHOT_UNAVAILABLE");
|
|
40
|
+
if (result.success.directory.ticker !== ticker ||
|
|
41
|
+
(result.success.observation &&
|
|
42
|
+
(!validObservation(result.success.observation) ||
|
|
43
|
+
result.success.observation.company.ticker !== ticker)))
|
|
44
|
+
throw failure("SOURCE_MISMATCH");
|
|
45
|
+
if (result.success.sources)
|
|
46
|
+
decodeCompanySources(result.success.sources, ticker);
|
|
47
|
+
return { generation: saved.generation, record: structuredClone(result.success) };
|
|
48
|
+
}).pipe(Effect.result));
|
|
49
|
+
const snapshot = Stream.fromEffect(saved).pipe(Stream.map((result) => Result.isSuccess(result)
|
|
50
|
+
? { type: "snapshot", source: "snapshot", ticker, ...result.success }
|
|
51
|
+
: {
|
|
52
|
+
type: "error",
|
|
53
|
+
source: "snapshot",
|
|
54
|
+
ticker,
|
|
55
|
+
operation: "read",
|
|
56
|
+
error: result.failure,
|
|
57
|
+
}));
|
|
58
|
+
if (mode === "snapshot")
|
|
59
|
+
return snapshot;
|
|
60
|
+
const live = SOURCE_NAMES.map((source) => Stream.unwrap(Effect.gen(function* () {
|
|
61
|
+
if (mode === "revalidate" && source !== "asx") {
|
|
62
|
+
const result = yield* saved;
|
|
63
|
+
const prior = Result.isSuccess(result)
|
|
64
|
+
? result.success.record?.sources?.[source]
|
|
65
|
+
: undefined;
|
|
66
|
+
const age = prior ? now() - Date.parse(prior.observedAt) : Infinity;
|
|
67
|
+
if (age >= 0 && age < refreshAfterMs)
|
|
68
|
+
return Stream.empty;
|
|
69
|
+
}
|
|
70
|
+
const lookup = sources.live[source];
|
|
71
|
+
const result = yield* (lookup
|
|
72
|
+
? io(async (signal) => decodeSourceObservation(await lookup(ticker, signal), ticker, source))
|
|
73
|
+
: Effect.fail(failure("CONFIGURATION_ERROR"))).pipe(Effect.result);
|
|
74
|
+
if (Result.isFailure(result))
|
|
75
|
+
return Stream.succeed({
|
|
76
|
+
type: "error",
|
|
77
|
+
source,
|
|
78
|
+
ticker,
|
|
79
|
+
operation: "read",
|
|
80
|
+
error: result.failure,
|
|
81
|
+
});
|
|
82
|
+
const observation = result.success;
|
|
83
|
+
const reply = Stream.succeed({
|
|
84
|
+
type: "source",
|
|
85
|
+
source,
|
|
86
|
+
ticker,
|
|
87
|
+
observation,
|
|
88
|
+
});
|
|
89
|
+
const persist = sources.persist;
|
|
90
|
+
if (!persist)
|
|
91
|
+
return reply;
|
|
92
|
+
return Stream.concat(reply, Stream.fromEffect(io((signal) => persist(observation, signal)).pipe(Effect.result)).pipe(Stream.flatMap((result) => Result.isSuccess(result)
|
|
93
|
+
? Stream.empty
|
|
94
|
+
: Stream.succeed({
|
|
95
|
+
type: "error",
|
|
96
|
+
source,
|
|
97
|
+
ticker,
|
|
98
|
+
operation: "persist",
|
|
99
|
+
error: failure("PERSISTENCE_FAILED"),
|
|
100
|
+
}))));
|
|
101
|
+
})));
|
|
102
|
+
return Stream.mergeAll([snapshot, ...live], { concurrency: 4 });
|
|
103
|
+
})).pipe(Stream.concat(Stream.succeed({ type: "complete", ticker })));
|
|
104
|
+
const signal = options.signal;
|
|
105
|
+
const abort = Effect.callback((resume) => {
|
|
106
|
+
if (!signal)
|
|
107
|
+
return;
|
|
108
|
+
const stop = () => resume(Effect.void);
|
|
109
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
110
|
+
if (signal.aborted)
|
|
111
|
+
stop();
|
|
112
|
+
return Effect.sync(() => signal.removeEventListener("abort", stop));
|
|
113
|
+
});
|
|
114
|
+
return Stream.toAsyncIterable(signal ? stream.pipe(Stream.interruptWhen(abort)) : stream);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|