@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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/browser-profile.d.ts +21 -0
  3. package/dist/browser-profile.js +115 -0
  4. package/dist/client-replies.d.ts +161 -0
  5. package/dist/client-replies.js +54 -0
  6. package/dist/client.d.ts +25 -0
  7. package/dist/client.js +179 -0
  8. package/dist/collection.d.ts +555 -0
  9. package/dist/collection.js +194 -0
  10. package/dist/company-events.d.ts +2 -0
  11. package/dist/company-events.js +48 -0
  12. package/dist/company-record.d.ts +4 -0
  13. package/dist/company-record.js +58 -0
  14. package/dist/company-stream-client.d.ts +4 -0
  15. package/dist/company-stream-client.js +139 -0
  16. package/dist/company-stream.d.ts +46 -0
  17. package/dist/company-stream.js +117 -0
  18. package/dist/descriptor.d.ts +2 -2
  19. package/dist/descriptor.js +2 -2
  20. package/dist/directory-search.d.ts +2 -0
  21. package/dist/directory-search.js +33 -0
  22. package/dist/enrichment.d.ts +75 -0
  23. package/dist/enrichment.js +114 -0
  24. package/dist/generated/fixtures.js +2 -2
  25. package/dist/generated/operations.js +23 -4
  26. package/dist/generated/types.d.ts +2 -1
  27. package/dist/http.d.ts +1 -0
  28. package/dist/http.js +49 -0
  29. package/dist/live.d.ts +56 -0
  30. package/dist/live.js +129 -0
  31. package/dist/model.d.ts +112 -0
  32. package/dist/model.js +86 -0
  33. package/dist/parse.d.ts +4 -0
  34. package/dist/parse.js +130 -0
  35. package/dist/publication.d.ts +25 -0
  36. package/dist/publication.js +89 -0
  37. package/dist/refresh-coordinator.d.ts +96 -0
  38. package/dist/refresh-coordinator.js +194 -0
  39. package/dist/refresh.d.ts +126 -0
  40. package/dist/refresh.js +153 -0
  41. package/dist/runtime/http.js +8 -1
  42. package/dist/search-service.d.ts +16 -0
  43. package/dist/search-service.js +64 -0
  44. package/dist/service.d.ts +35 -0
  45. package/dist/service.js +99 -0
  46. package/dist/snapshot.d.ts +295 -0
  47. package/dist/snapshot.js +131 -0
  48. package/dist/source-record.d.ts +63 -0
  49. package/dist/source-record.js +91 -0
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Typed client for the Comms.ID ASX API (`https://api.comms.id/asx/v1`): ASX-listed companies: suggest as a user types, and look one up.
4
4
 
5
- `src/` is generated from the ASX contract (`packages/asx-service/openapi/asx.v1.json`) by `@comms-id/forge-transformers`. Do not edit it: change the contract and run `pnpm --filter @comms-id/forge-transformers generate`.
5
+ `src/` is generated from the ASX contract (`packages/asx-service/openapi/asx.v2.json`) by `@comms-id/forge-transformers`. Do not edit it: change the contract and run `pnpm --filter @comms-id/forge-transformers generate`.
6
6
 
7
7
  ```ts
8
8
  import { createAsxClient } from "@comms-id/asx/server";
@@ -0,0 +1,21 @@
1
+ export type BrowserProfileSource = "morningstar" | "google-finance";
2
+ export interface ProfileScrapeRequest {
3
+ readonly url: string;
4
+ readonly elements: readonly {
5
+ readonly selector: string;
6
+ }[];
7
+ readonly waitForSelector: {
8
+ readonly selector: string;
9
+ readonly timeout: number;
10
+ readonly visible: true;
11
+ };
12
+ readonly gotoOptions: {
13
+ readonly waitUntil: "domcontentloaded" | "networkidle2";
14
+ readonly timeout: number;
15
+ };
16
+ }
17
+ export type ProfileScraper = (request: ProfileScrapeRequest, signal: AbortSignal) => Promise<unknown>;
18
+ /** Fixed public-page selectors; no LLM, login, cookies, provider token extraction or inferred URLs. */
19
+ export declare function profileScrapeRequest(source: BrowserProfileSource, input: string): ProfileScrapeRequest;
20
+ /** The host supplies an authorized browser runner. This adapter never chooses or provisions one. */
21
+ export declare function createBrowserProfileSource(source: BrowserProfileSource, scrape: ProfileScraper): (input: string, signal?: AbortSignal) => Promise<import("./source-record.js").SourceObservation>;
@@ -0,0 +1,115 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { failure, Failure } from "./model.js";
3
+ import { normalizeTicker } from "./parse.js";
4
+ import { decodeSourceObservation } from "./source-record.js";
5
+ const Scrape = Schema.Struct({
6
+ success: Schema.Literal(true),
7
+ result: Schema.Array(Schema.Struct({
8
+ selector: Schema.String,
9
+ results: Schema.Array(Schema.Struct({
10
+ text: Schema.String,
11
+ html: Schema.String,
12
+ attributes: Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String })),
13
+ })),
14
+ })),
15
+ });
16
+ const selectors = {
17
+ morningstar: {
18
+ profile: ".sal-component-body:has(.sal-dp-pair-contact)",
19
+ links: ".sal-dp-pair-contact a",
20
+ },
21
+ "google-finance": { profile: ".LuLFhc", links: ".LuLFhc a" },
22
+ };
23
+ /** Fixed public-page selectors; no LLM, login, cookies, provider token extraction or inferred URLs. */
24
+ export function profileScrapeRequest(source, input) {
25
+ const ticker = normalizeTicker(input);
26
+ const selected = selectors[source];
27
+ if (!selected)
28
+ throw failure("INVALID_INPUT");
29
+ return {
30
+ url: source === "morningstar"
31
+ ? `https://www.morningstar.com.au/investments/security/ASX/${ticker}/profile`
32
+ : `https://www.google.com/finance/beta/quote/${ticker}:ASX`,
33
+ elements: [{ selector: "title" }, { selector: selected.profile }, { selector: selected.links }],
34
+ waitForSelector: {
35
+ selector: source === "morningstar" ? ".sal-dp-pair-contact .sal-dp-value" : selected.profile,
36
+ timeout: 15_000,
37
+ visible: true,
38
+ },
39
+ // Morningstar renders its contact section after the initial document loads.
40
+ gotoOptions: {
41
+ waitUntil: source === "morningstar" ? "networkidle2" : "domcontentloaded",
42
+ timeout: 15_000,
43
+ },
44
+ };
45
+ }
46
+ /** The host supplies an authorized browser runner. This adapter never chooses or provisions one. */
47
+ export function createBrowserProfileSource(source, scrape) {
48
+ return async (input, signal) => {
49
+ const ticker = normalizeTicker(input);
50
+ const request = profileScrapeRequest(source, ticker);
51
+ const startedAt = new Date().toISOString();
52
+ const program = Effect.tryPromise({
53
+ try: (signal) => scrape(request, signal),
54
+ catch: (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("TRANSPORT_FAILED"),
55
+ }).pipe(Effect.flatMap((input) => Effect.try({
56
+ try: () => {
57
+ const decoded = Schema.decodeUnknownResult(Scrape)(input);
58
+ const raw = Schema.decodeUnknownResult(Schema.JsonObject)(input);
59
+ if (Result.isFailure(decoded) || Result.isFailure(raw))
60
+ throw failure("PARSE_FAILED");
61
+ const sections = decoded.success.result;
62
+ const title = sections.find((section) => section.selector === "title")?.results;
63
+ const profile = sections.find((section) => section.selector === selectors[source].profile)?.results;
64
+ const links = sections.find((section) => section.selector === selectors[source].links)?.results;
65
+ const marker = source === "morningstar" ? `(ASX:${ticker})` : `(${ticker})`;
66
+ if (title?.length !== 1 || !title[0].text.includes(marker))
67
+ throw failure("SOURCE_MISMATCH");
68
+ if (!profile?.length ||
69
+ !links ||
70
+ !profile.some((item) => source === "morningstar"
71
+ ? /\bContact\b/.test(item.text)
72
+ : item.text.trim().startsWith("Website")))
73
+ throw failure("PARSE_FAILED");
74
+ const websites = [
75
+ ...new Set(links
76
+ .filter((link) => source === "morningstar" ||
77
+ (link.text.trim() &&
78
+ profile.some((item) => item.text.trim().startsWith("Website") &&
79
+ item.text.includes(link.text.trim()))))
80
+ .flatMap((link) => link.attributes
81
+ .filter((attribute) => attribute.name === "href" && /^https?:\/\//i.test(attribute.value))
82
+ .map((attribute) => attribute.value))),
83
+ ];
84
+ return decodeSourceObservation({
85
+ source,
86
+ ticker,
87
+ sourceId: `${ticker}:ASX`,
88
+ sourceUrl: request.url,
89
+ startedAt,
90
+ observedAt: new Date().toISOString(),
91
+ status: "found",
92
+ websites,
93
+ payload: { format: "rendered-profile", response: raw.success },
94
+ }, ticker, source);
95
+ },
96
+ catch: (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("PARSE_FAILED"),
97
+ })),
98
+ // A partially rendered public profile may recover on one fresh navigation.
99
+ // Access/rate refusals and identity mismatches must not trigger a retry.
100
+ Effect.retry({ times: 1, while: (error) => error.code === "PARSE_FAILED" }), Effect.timeoutOrElse({ duration: 30_000, orElse: () => Effect.fail(failure("TIMEOUT")) }));
101
+ try {
102
+ const result = await Effect.runPromise(Effect.result(program), { signal });
103
+ if (Result.isFailure(result))
104
+ throw result.failure;
105
+ return result.success;
106
+ }
107
+ catch (error) {
108
+ throw signal?.aborted
109
+ ? failure("CANCELLED")
110
+ : Schema.is(Failure)(error)
111
+ ? error
112
+ : failure("INTERNAL_ERROR");
113
+ }
114
+ };
115
+ }
@@ -0,0 +1,161 @@
1
+ import { Schema } from "effect";
2
+ export declare const LookupResponse: Schema.Union<readonly [Schema.Struct<{
3
+ readonly status: Schema.Literal<"found">;
4
+ readonly source: Schema.Literal<"live">;
5
+ readonly observation: Schema.Struct<{
6
+ readonly company: Schema.Struct<{
7
+ readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
8
+ readonly ticker: Schema.String;
9
+ readonly sourceId: Schema.String;
10
+ readonly companyName: Schema.String;
11
+ readonly address: Schema.NullOr<Schema.String>;
12
+ readonly tel: Schema.NullOr<Schema.String>;
13
+ readonly fax: Schema.NullOr<Schema.String>;
14
+ readonly website: Schema.NullOr<Schema.String>;
15
+ readonly description: Schema.NullOr<Schema.String>;
16
+ readonly issueType: Schema.NullOr<Schema.String>;
17
+ readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
18
+ readonly people: Schema.$Array<Schema.Struct<{
19
+ readonly sourceName: Schema.String;
20
+ readonly displayName: Schema.String;
21
+ readonly title: Schema.String;
22
+ readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
23
+ }>>;
24
+ }>;
25
+ readonly startedAt: Schema.String;
26
+ readonly observedAt: Schema.String;
27
+ readonly lastChangedAt: Schema.String;
28
+ }>;
29
+ }>, Schema.Struct<{
30
+ readonly status: Schema.Literal<"missing">;
31
+ readonly source: Schema.Literal<"live">;
32
+ readonly startedAt: Schema.String;
33
+ readonly observedAt: Schema.String;
34
+ }>, Schema.Struct<{
35
+ readonly status: Schema.Literal<"found">;
36
+ readonly source: Schema.Literal<"snapshot">;
37
+ readonly observation: Schema.Struct<{
38
+ readonly company: Schema.Struct<{
39
+ readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
40
+ readonly ticker: Schema.String;
41
+ readonly sourceId: Schema.String;
42
+ readonly companyName: Schema.String;
43
+ readonly address: Schema.NullOr<Schema.String>;
44
+ readonly tel: Schema.NullOr<Schema.String>;
45
+ readonly fax: Schema.NullOr<Schema.String>;
46
+ readonly website: Schema.NullOr<Schema.String>;
47
+ readonly description: Schema.NullOr<Schema.String>;
48
+ readonly issueType: Schema.NullOr<Schema.String>;
49
+ readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
50
+ readonly people: Schema.$Array<Schema.Struct<{
51
+ readonly sourceName: Schema.String;
52
+ readonly displayName: Schema.String;
53
+ readonly title: Schema.String;
54
+ readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
55
+ }>>;
56
+ }>;
57
+ readonly startedAt: Schema.String;
58
+ readonly observedAt: Schema.String;
59
+ readonly lastChangedAt: Schema.String;
60
+ }>;
61
+ readonly generation: Schema.String;
62
+ readonly enrichment: Schema.optionalKey<Schema.Struct<{
63
+ readonly ticker: Schema.String;
64
+ readonly sourceId: Schema.NonEmptyString;
65
+ readonly abn: Schema.String;
66
+ readonly acn: Schema.NullOr<Schema.String>;
67
+ readonly legalName: Schema.NonEmptyString;
68
+ readonly identityEvidence: Schema.Struct<{
69
+ readonly url: Schema.String;
70
+ readonly sha256: Schema.String;
71
+ readonly observedAt: Schema.String;
72
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
73
+ }>;
74
+ readonly abrEvidence: Schema.Struct<{
75
+ readonly source: Schema.Literals<readonly ["abr-live", "abr-snapshot"]>;
76
+ readonly observedAt: Schema.String;
77
+ }>;
78
+ readonly website: Schema.NullOr<Schema.Struct<{
79
+ readonly url: Schema.String;
80
+ readonly evidence: Schema.Struct<{
81
+ readonly url: Schema.String;
82
+ readonly sha256: Schema.String;
83
+ readonly observedAt: Schema.String;
84
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
85
+ }>;
86
+ }>>;
87
+ }>>;
88
+ readonly requiresRecheck: Schema.Literal<true>;
89
+ readonly fallbackReason: Schema.optionalKey<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"]>>;
90
+ }>, Schema.Struct<{
91
+ readonly status: Schema.Literal<"missing">;
92
+ readonly source: Schema.Literal<"snapshot">;
93
+ readonly generation: Schema.String;
94
+ }>]>;
95
+ export declare const SuggestionsResponse: Schema.Union<readonly [Schema.Struct<{
96
+ readonly source: Schema.Literal<"live">;
97
+ readonly directoryObservedAt: Schema.String;
98
+ readonly suggestions: Schema.$Array<Schema.Struct<{
99
+ readonly ticker: Schema.String;
100
+ readonly companyName: Schema.String;
101
+ readonly industryGroup: Schema.NullOr<Schema.String>;
102
+ readonly listingDate: Schema.NullOr<Schema.String>;
103
+ readonly marketCapitalisation: Schema.NullOr<Schema.Number>;
104
+ }>>;
105
+ }>, Schema.Struct<{
106
+ readonly source: Schema.Literal<"snapshot">;
107
+ readonly generation: Schema.String;
108
+ readonly directoryObservedAt: Schema.String;
109
+ readonly requiresRecheck: Schema.Literal<true>;
110
+ readonly suggestions: Schema.$Array<Schema.Struct<{
111
+ readonly ticker: Schema.String;
112
+ readonly companyName: Schema.String;
113
+ readonly industryGroup: Schema.NullOr<Schema.String>;
114
+ readonly listingDate: Schema.NullOr<Schema.String>;
115
+ readonly marketCapitalisation: Schema.NullOr<Schema.Number>;
116
+ }>>;
117
+ readonly fallbackReason: Schema.optionalKey<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"]>>;
118
+ }>]>;
119
+ export type SuggestionsReply = typeof SuggestionsResponse.Type;
120
+ export declare const AbnMatchesResponse: Schema.Struct<{
121
+ readonly source: Schema.Literal<"snapshot">;
122
+ readonly generation: Schema.String;
123
+ readonly directoryObservedAt: Schema.String;
124
+ readonly requiresRecheck: Schema.Literal<true>;
125
+ readonly matches: Schema.$Array<Schema.Struct<{
126
+ readonly directory: Schema.Struct<{
127
+ readonly ticker: Schema.String;
128
+ readonly companyName: Schema.String;
129
+ readonly industryGroup: Schema.NullOr<Schema.String>;
130
+ readonly listingDate: Schema.NullOr<Schema.String>;
131
+ readonly marketCapitalisation: Schema.NullOr<Schema.Number>;
132
+ }>;
133
+ readonly enrichment: Schema.Struct<{
134
+ readonly ticker: Schema.String;
135
+ readonly sourceId: Schema.NonEmptyString;
136
+ readonly abn: Schema.String;
137
+ readonly acn: Schema.NullOr<Schema.String>;
138
+ readonly legalName: Schema.NonEmptyString;
139
+ readonly identityEvidence: Schema.Struct<{
140
+ readonly url: Schema.String;
141
+ readonly sha256: Schema.String;
142
+ readonly observedAt: Schema.String;
143
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
144
+ }>;
145
+ readonly abrEvidence: Schema.Struct<{
146
+ readonly source: Schema.Literals<readonly ["abr-live", "abr-snapshot"]>;
147
+ readonly observedAt: Schema.String;
148
+ }>;
149
+ readonly website: Schema.NullOr<Schema.Struct<{
150
+ readonly url: Schema.String;
151
+ readonly evidence: Schema.Struct<{
152
+ readonly url: Schema.String;
153
+ readonly sha256: Schema.String;
154
+ readonly observedAt: Schema.String;
155
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
156
+ }>;
157
+ }>>;
158
+ }>;
159
+ }>>;
160
+ }>;
161
+ export type AbnMatchesReply = typeof AbnMatchesResponse.Type;
@@ -0,0 +1,54 @@
1
+ import { Schema } from "effect";
2
+ import { ConfirmedEnrichment } from "./enrichment.js";
3
+ import { DirectoryEntry, Failure, Observation, Timestamp } from "./model.js";
4
+ const Generation = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/));
5
+ export const LookupResponse = Schema.Union([
6
+ Schema.Struct({
7
+ status: Schema.Literal("found"),
8
+ source: Schema.Literal("live"),
9
+ observation: Observation,
10
+ }),
11
+ Schema.Struct({
12
+ status: Schema.Literal("missing"),
13
+ source: Schema.Literal("live"),
14
+ startedAt: Timestamp,
15
+ observedAt: Timestamp,
16
+ }),
17
+ Schema.Struct({
18
+ status: Schema.Literal("found"),
19
+ source: Schema.Literal("snapshot"),
20
+ observation: Observation,
21
+ generation: Generation,
22
+ enrichment: Schema.optionalKey(ConfirmedEnrichment),
23
+ requiresRecheck: Schema.Literal(true),
24
+ fallbackReason: Schema.optionalKey(Failure.fields.code),
25
+ }),
26
+ Schema.Struct({
27
+ status: Schema.Literal("missing"),
28
+ source: Schema.Literal("snapshot"),
29
+ generation: Generation,
30
+ }),
31
+ ]);
32
+ const Saved = {
33
+ source: Schema.Literal("snapshot"),
34
+ generation: Generation,
35
+ directoryObservedAt: Timestamp,
36
+ requiresRecheck: Schema.Literal(true),
37
+ };
38
+ const Suggestions = Schema.Array(DirectoryEntry).check(Schema.isMaxLength(10));
39
+ export const SuggestionsResponse = Schema.Union([
40
+ Schema.Struct({
41
+ source: Schema.Literal("live"),
42
+ directoryObservedAt: Timestamp,
43
+ suggestions: Suggestions,
44
+ }),
45
+ Schema.Struct({
46
+ ...Saved,
47
+ suggestions: Suggestions,
48
+ fallbackReason: Schema.optionalKey(Failure.fields.code),
49
+ }),
50
+ ]);
51
+ export const AbnMatchesResponse = Schema.Struct({
52
+ ...Saved,
53
+ matches: Schema.Array(Schema.Struct({ directory: DirectoryEntry, enrichment: ConfirmedEnrichment })).check(Schema.isMaxLength(20_000)),
54
+ });
@@ -0,0 +1,25 @@
1
+ import { type AbnMatchesReply, type SuggestionsReply } from "./client-replies.js";
2
+ import type { LookupMode, LookupReply } from "./service.js";
3
+ export type { AbnMatchesReply, SuggestionsReply } from "./client-replies.js";
4
+ export interface AsxClientConfig {
5
+ readonly baseUrl: string;
6
+ /** Return a short-lived service capability. Honour cancellation while acquiring it. */
7
+ readonly capability: (signal: AbortSignal) => Promise<string>;
8
+ readonly fetch?: typeof globalThis.fetch;
9
+ readonly timeoutMs?: number;
10
+ }
11
+ export interface AsxRequestOptions {
12
+ readonly signal?: AbortSignal;
13
+ }
14
+ /** Read-only hosted-service client. This does not contact ASX directly or supply fallback itself. */
15
+ export declare function createAsxClient(config: AsxClientConfig): {
16
+ observe: (input: string, options?: import("./company-stream.js").CompanyStreamOptions) => AsyncGenerator<import("./company-stream.js").CompanyEvent>;
17
+ lookup: (input: string, options?: AsxRequestOptions & {
18
+ readonly mode?: LookupMode;
19
+ }) => Promise<LookupReply>;
20
+ suggest: (query: string, options?: AsxRequestOptions & {
21
+ readonly limit?: number;
22
+ readonly mode?: LookupMode;
23
+ }) => Promise<SuggestionsReply>;
24
+ findByAbn: (abn: string, options?: AsxRequestOptions) => Promise<AbnMatchesReply>;
25
+ };
package/dist/client.js ADDED
@@ -0,0 +1,179 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { AbnMatchesResponse, LookupResponse, SuggestionsResponse, } from "./client-replies.js";
3
+ import { validAbn, validEnrichment } from "./enrichment.js";
4
+ import { boundedText } from "./http.js";
5
+ import { failure, Failure, isFallbackEligible, isTimestamp, validObservation, } from "./model.js";
6
+ import { normalizeTicker } from "./parse.js";
7
+ import { createCompanyStreamClient } from "./company-stream-client.js";
8
+ const safe = (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("TRANSPORT_FAILED");
9
+ const ErrorResponse = Schema.Struct({ code: Failure.fields.code });
10
+ /** Read-only hosted-service client. This does not contact ASX directly or supply fallback itself. */
11
+ export function createAsxClient(config) {
12
+ let base;
13
+ try {
14
+ base = new URL(config.baseUrl.endsWith("/") ? config.baseUrl : `${config.baseUrl}/`);
15
+ if (base.protocol !== "https:" ||
16
+ base.username ||
17
+ base.password ||
18
+ base.search ||
19
+ base.hash ||
20
+ typeof config.capability !== "function")
21
+ throw new Error();
22
+ }
23
+ catch {
24
+ throw failure("CONFIGURATION_ERROR");
25
+ }
26
+ const timeoutMs = config.timeoutMs ?? 20_000;
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
28
+ throw failure("CONFIGURATION_ERROR");
29
+ const transport = config.fetch ?? globalThis.fetch;
30
+ const request = async (path, body, schema, options) => {
31
+ if (options.signal?.aborted)
32
+ throw failure("CANCELLED");
33
+ const program = Effect.gen(function* () {
34
+ const token = yield* Effect.tryPromise({
35
+ try: (signal) => config.capability(signal),
36
+ catch: () => failure("ACCESS_DENIED"),
37
+ });
38
+ if (typeof token !== "string" ||
39
+ !token.trim() ||
40
+ token.length > 8192 ||
41
+ /\s/.test(token.trim()))
42
+ return yield* Effect.fail(failure("CONFIGURATION_ERROR"));
43
+ const input = yield* Effect.tryPromise({
44
+ try: async (signal) => {
45
+ const response = await transport(new URL(path, base), {
46
+ method: "POST",
47
+ body: JSON.stringify(body),
48
+ signal,
49
+ redirect: "manual",
50
+ cache: "no-store",
51
+ headers: {
52
+ "content-type": "application/json",
53
+ accept: "application/json",
54
+ authorization: `Bearer ${token.trim()}`,
55
+ },
56
+ });
57
+ if (response.status === 401 ||
58
+ response.status === 403 ||
59
+ (response.status >= 300 && response.status < 400)) {
60
+ await response.body?.cancel();
61
+ throw failure(response.status < 400 ? "UPSTREAM_REJECTED" : "ACCESS_DENIED");
62
+ }
63
+ const text = await boundedText(response, response.ok ? 1024 * 1024 : 4096, signal);
64
+ let payload;
65
+ try {
66
+ payload = JSON.parse(text);
67
+ }
68
+ catch {
69
+ throw failure(response.ok ? "PARSE_FAILED" : "UPSTREAM_UNAVAILABLE");
70
+ }
71
+ if (!response.ok) {
72
+ const decoded = Schema.decodeUnknownResult(ErrorResponse)(payload);
73
+ if (Result.isSuccess(decoded))
74
+ throw failure(decoded.success.code);
75
+ throw failure(response.status === 429
76
+ ? "RATE_LIMITED"
77
+ : response.status >= 500
78
+ ? "UPSTREAM_UNAVAILABLE"
79
+ : "UPSTREAM_REJECTED");
80
+ }
81
+ return payload;
82
+ },
83
+ catch: safe,
84
+ });
85
+ return yield* Schema.decodeUnknownEffect(schema)(input).pipe(Effect.mapError(() => failure("PARSE_FAILED")));
86
+ }).pipe(Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => Effect.fail(failure("TIMEOUT")) }));
87
+ try {
88
+ const result = await Effect.runPromise(Effect.result(program), { signal: options.signal });
89
+ if (Result.isFailure(result))
90
+ throw result.failure;
91
+ return result.success;
92
+ }
93
+ catch (error) {
94
+ throw options.signal?.aborted ? failure("CANCELLED") : safe(error);
95
+ }
96
+ };
97
+ return {
98
+ observe: createCompanyStreamClient(config, base),
99
+ lookup: async (input, options = {}) => {
100
+ const ticker = normalizeTicker(input);
101
+ const mode = options.mode ?? "live-first";
102
+ if (!["live", "snapshot", "live-first"].includes(mode))
103
+ throw failure("INVALID_INPUT");
104
+ const reply = await request("lookup", { ticker, mode }, LookupResponse, options);
105
+ if ((mode === "live" && reply.source !== "live") ||
106
+ (mode === "snapshot" && reply.source !== "snapshot") ||
107
+ (mode === "live-first" && reply.source === "snapshot" && reply.status === "missing"))
108
+ throw failure("SOURCE_MISMATCH");
109
+ if (reply.status === "found") {
110
+ if (!validObservation(reply.observation))
111
+ throw failure("PARSE_FAILED");
112
+ if (reply.observation.company.ticker !== ticker)
113
+ throw failure("SOURCE_MISMATCH");
114
+ if (reply.source === "snapshot") {
115
+ if (reply.enrichment && !validEnrichment(reply.enrichment, reply.observation.company))
116
+ throw failure("SOURCE_MISMATCH");
117
+ if (reply.fallbackReason && !isFallbackEligible(reply.fallbackReason))
118
+ throw failure("PARSE_FAILED");
119
+ if ((mode === "live-first" && reply.fallbackReason === undefined) ||
120
+ (mode === "snapshot" && reply.fallbackReason !== undefined))
121
+ throw failure("SOURCE_MISMATCH");
122
+ }
123
+ }
124
+ else if (reply.source === "live" &&
125
+ (!isTimestamp(reply.startedAt) ||
126
+ !isTimestamp(reply.observedAt) ||
127
+ reply.startedAt > reply.observedAt))
128
+ throw failure("PARSE_FAILED");
129
+ return reply;
130
+ },
131
+ suggest: async (query, options = {}) => {
132
+ const limit = options.limit ?? 8;
133
+ const mode = options.mode ?? "snapshot";
134
+ if (!["live", "snapshot", "live-first"].includes(mode))
135
+ throw failure("INVALID_INPUT");
136
+ if (typeof query !== "string" ||
137
+ query.length > 200 ||
138
+ !Number.isInteger(limit) ||
139
+ limit < 1 ||
140
+ limit > 10)
141
+ throw failure("INVALID_INPUT");
142
+ const reply = await request("suggest", { query, limit, ...(options.mode ? { mode } : {}) }, SuggestionsResponse, options);
143
+ if (!isTimestamp(reply.directoryObservedAt) ||
144
+ reply.suggestions.length > limit ||
145
+ new Set(reply.suggestions.map((entry) => entry.ticker)).size !== reply.suggestions.length ||
146
+ reply.suggestions.some((entry) => !entry.companyName.trim()))
147
+ throw failure("PARSE_FAILED");
148
+ if ((mode === "live" && reply.source !== "live") ||
149
+ (mode === "snapshot" && reply.source !== "snapshot"))
150
+ throw failure("SOURCE_MISMATCH");
151
+ if (reply.source === "snapshot") {
152
+ if (reply.fallbackReason !== undefined && !isFallbackEligible(reply.fallbackReason))
153
+ throw failure("PARSE_FAILED");
154
+ if ((mode === "live-first" && reply.fallbackReason === undefined) ||
155
+ (mode === "snapshot" && reply.fallbackReason !== undefined))
156
+ throw failure("SOURCE_MISMATCH");
157
+ }
158
+ return reply;
159
+ },
160
+ findByAbn: async (abn, options = {}) => {
161
+ if (typeof abn !== "string" || !validAbn(abn))
162
+ throw failure("INVALID_INPUT");
163
+ const reply = await request("abn", { abn }, AbnMatchesResponse, options);
164
+ if (!isTimestamp(reply.directoryObservedAt) ||
165
+ new Set(reply.matches.map((match) => match.directory.ticker)).size !== reply.matches.length)
166
+ throw failure("PARSE_FAILED");
167
+ for (const { directory, enrichment } of reply.matches) {
168
+ if (enrichment.abn !== abn || enrichment.ticker !== directory.ticker)
169
+ throw failure("SOURCE_MISMATCH");
170
+ if (!directory.companyName.trim() ||
171
+ !isTimestamp(enrichment.identityEvidence.observedAt) ||
172
+ !isTimestamp(enrichment.abrEvidence.observedAt) ||
173
+ (enrichment.website && !isTimestamp(enrichment.website.evidence.observedAt)))
174
+ throw failure("PARSE_FAILED");
175
+ }
176
+ return reply;
177
+ },
178
+ };
179
+ }