@comms-id/asx 0.4.1 → 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/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 +1 -1
package/dist/live.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { suggestDirectory } from "./directory-search.js";
|
|
2
|
+
import { Effect, Result, Schema } from "effect";
|
|
3
|
+
import { boundedText } from "./http.js";
|
|
4
|
+
import { failure, Failure } from "./model.js";
|
|
5
|
+
import { normalizeTicker, parseCompany, parseDirectory } from "./parse.js";
|
|
6
|
+
import { ASX_COMPANY_BASE, asxSourceObservation } from "./source-record.js";
|
|
7
|
+
const BASE = ASX_COMPANY_BASE;
|
|
8
|
+
const Missing = Schema.Struct({
|
|
9
|
+
error: Schema.Struct({
|
|
10
|
+
code: Schema.Literal(400),
|
|
11
|
+
message: Schema.Literal("Bad Request: Symbol not found"),
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
function parseJson(text) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw failure("PARSE_FAILED");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const request = Effect.fn("ASX.http")(function* (transport, path, limit) {
|
|
23
|
+
return yield* Effect.tryPromise({
|
|
24
|
+
try: async (signal) => {
|
|
25
|
+
const response = await transport(`${BASE}${path}`, {
|
|
26
|
+
signal,
|
|
27
|
+
redirect: "manual",
|
|
28
|
+
headers: { Accept: path === "directory/file" ? "text/csv" : "application/json" },
|
|
29
|
+
});
|
|
30
|
+
if (response.status === 401 || response.status === 403) {
|
|
31
|
+
await response.body?.cancel();
|
|
32
|
+
throw failure("ACCESS_DENIED");
|
|
33
|
+
}
|
|
34
|
+
if (response.status === 429) {
|
|
35
|
+
await response.body?.cancel();
|
|
36
|
+
throw failure("RATE_LIMITED");
|
|
37
|
+
}
|
|
38
|
+
if (response.status >= 500 || response.status === 408) {
|
|
39
|
+
await response.body?.cancel();
|
|
40
|
+
throw failure("UPSTREAM_UNAVAILABLE");
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok && response.status !== 400) {
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
throw failure("UPSTREAM_REJECTED");
|
|
45
|
+
}
|
|
46
|
+
const body = await boundedText(response, response.ok ? limit : 4096, signal);
|
|
47
|
+
return { status: response.status, body };
|
|
48
|
+
},
|
|
49
|
+
catch: (error) => Schema.is(Failure)(error) ? failure(error.code) : failure("TRANSPORT_FAILED"),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
function run(program, timeoutMs, signal) {
|
|
53
|
+
return Effect.runPromise(Effect.result(program.pipe(Effect.timeoutOrElse({
|
|
54
|
+
duration: timeoutMs,
|
|
55
|
+
orElse: () => Effect.fail(failure("TIMEOUT")),
|
|
56
|
+
}))), { signal })
|
|
57
|
+
.then((result) => {
|
|
58
|
+
if (Result.isFailure(result))
|
|
59
|
+
throw result.failure;
|
|
60
|
+
return result.success;
|
|
61
|
+
})
|
|
62
|
+
.catch((error) => {
|
|
63
|
+
if (signal?.aborted)
|
|
64
|
+
throw failure("CANCELLED");
|
|
65
|
+
throw Schema.is(Failure)(error) ? failure(error.code) : failure("INTERNAL_ERROR");
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** No persistence, source composition, background work or network call at construction. */
|
|
69
|
+
export function createAsxLive(config = {}) {
|
|
70
|
+
const timeoutMs = config.timeoutMs ?? 3000;
|
|
71
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
|
|
72
|
+
throw failure("CONFIGURATION_ERROR");
|
|
73
|
+
const transport = config.fetch ?? globalThis.fetch;
|
|
74
|
+
const lookup = Effect.fn("ASX.profile")(function* (input) {
|
|
75
|
+
const ticker = yield* Effect.try({
|
|
76
|
+
try: () => normalizeTicker(input),
|
|
77
|
+
catch: () => failure("INVALID_INPUT"),
|
|
78
|
+
});
|
|
79
|
+
const startedAt = new Date().toISOString();
|
|
80
|
+
const response = yield* request(transport, `${ticker}/about`, 1024 * 1024);
|
|
81
|
+
const payload = yield* Effect.try({
|
|
82
|
+
try: () => parseJson(response.body),
|
|
83
|
+
catch: () => failure("PARSE_FAILED"),
|
|
84
|
+
});
|
|
85
|
+
const observedAt = new Date().toISOString();
|
|
86
|
+
if (response.status === 400) {
|
|
87
|
+
if (Schema.is(Missing)(payload) && Schema.is(Schema.JsonObject)(payload))
|
|
88
|
+
return { status: "missing", startedAt, observedAt, raw: structuredClone(payload) };
|
|
89
|
+
return yield* Effect.fail(failure("UPSTREAM_REJECTED"));
|
|
90
|
+
}
|
|
91
|
+
const company = yield* Effect.try({
|
|
92
|
+
try: () => parseCompany(ticker, payload),
|
|
93
|
+
catch: (error) => (Schema.is(Failure)(error) ? failure(error.code) : failure("PARSE_FAILED")),
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
status: "found",
|
|
97
|
+
observation: { company, startedAt, observedAt, lastChangedAt: observedAt },
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
const directory = Effect.fn("ASX.directory")(function* () {
|
|
101
|
+
const response = yield* request(transport, "directory/file", 4 * 1024 * 1024);
|
|
102
|
+
if (response.status !== 200)
|
|
103
|
+
return yield* Effect.fail(failure("UPSTREAM_REJECTED"));
|
|
104
|
+
const entries = yield* Effect.try({
|
|
105
|
+
try: () => parseDirectory(response.body),
|
|
106
|
+
catch: () => failure("PARSE_FAILED"),
|
|
107
|
+
});
|
|
108
|
+
return { entries, observedAt: new Date().toISOString() };
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
source: async (input, signal) => {
|
|
112
|
+
const ticker = normalizeTicker(input);
|
|
113
|
+
const result = await run(lookup(ticker), timeoutMs, signal);
|
|
114
|
+
return asxSourceObservation(ticker, result);
|
|
115
|
+
},
|
|
116
|
+
lookup: (ticker, signal) => run(lookup(ticker), timeoutMs, signal),
|
|
117
|
+
directory: (signal) => run(directory(), timeoutMs, signal),
|
|
118
|
+
suggest: async (query, limit = 8, signal) => {
|
|
119
|
+
// Validate before acquiring the directory.
|
|
120
|
+
suggestDirectory([], query, limit);
|
|
121
|
+
const value = await run(directory(), timeoutMs, signal);
|
|
122
|
+
return {
|
|
123
|
+
source: "live",
|
|
124
|
+
directoryObservedAt: value.observedAt,
|
|
125
|
+
suggestions: suggestDirectory(value.entries, query, limit),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const Ticker: Schema.String;
|
|
3
|
+
export declare const Person: Schema.Struct<{
|
|
4
|
+
readonly sourceName: Schema.String;
|
|
5
|
+
readonly displayName: Schema.String;
|
|
6
|
+
readonly title: Schema.String;
|
|
7
|
+
readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
|
|
8
|
+
}>;
|
|
9
|
+
export interface Person extends Schema.Schema.Type<typeof Person> {
|
|
10
|
+
}
|
|
11
|
+
export declare const Company: Schema.Struct<{
|
|
12
|
+
/** Complete public ASX profile response, including fields unknown to this package version. */
|
|
13
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
14
|
+
readonly ticker: Schema.String;
|
|
15
|
+
readonly sourceId: Schema.String;
|
|
16
|
+
readonly companyName: Schema.String;
|
|
17
|
+
readonly address: Schema.NullOr<Schema.String>;
|
|
18
|
+
readonly tel: Schema.NullOr<Schema.String>;
|
|
19
|
+
readonly fax: Schema.NullOr<Schema.String>;
|
|
20
|
+
readonly website: Schema.NullOr<Schema.String>;
|
|
21
|
+
readonly description: Schema.NullOr<Schema.String>;
|
|
22
|
+
readonly issueType: Schema.NullOr<Schema.String>;
|
|
23
|
+
readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
|
|
24
|
+
readonly people: Schema.$Array<Schema.Struct<{
|
|
25
|
+
readonly sourceName: Schema.String;
|
|
26
|
+
readonly displayName: Schema.String;
|
|
27
|
+
readonly title: Schema.String;
|
|
28
|
+
readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
|
|
29
|
+
}>>;
|
|
30
|
+
}>;
|
|
31
|
+
export interface Company extends Schema.Schema.Type<typeof Company> {
|
|
32
|
+
}
|
|
33
|
+
export declare const DirectoryEntry: Schema.Struct<{
|
|
34
|
+
readonly ticker: Schema.String;
|
|
35
|
+
readonly companyName: Schema.String;
|
|
36
|
+
readonly industryGroup: Schema.NullOr<Schema.String>;
|
|
37
|
+
readonly listingDate: Schema.NullOr<Schema.String>;
|
|
38
|
+
readonly marketCapitalisation: Schema.NullOr<Schema.Number>;
|
|
39
|
+
}>;
|
|
40
|
+
export interface DirectoryEntry extends Schema.Schema.Type<typeof DirectoryEntry> {
|
|
41
|
+
}
|
|
42
|
+
export declare const Failure: Schema.Struct<{
|
|
43
|
+
readonly _tag: Schema.Literal<"AsxError">;
|
|
44
|
+
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"]>;
|
|
45
|
+
}>;
|
|
46
|
+
export type AsxError = typeof Failure.Type;
|
|
47
|
+
export declare const failure: (code: AsxError["code"]) => AsxError;
|
|
48
|
+
export declare const isFallbackEligible: (code: AsxError["code"]) => boolean;
|
|
49
|
+
export declare const Timestamp: Schema.String;
|
|
50
|
+
export declare const Observation: Schema.Struct<{
|
|
51
|
+
readonly company: Schema.Struct<{
|
|
52
|
+
/** Complete public ASX profile response, including fields unknown to this package version. */
|
|
53
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
54
|
+
readonly ticker: Schema.String;
|
|
55
|
+
readonly sourceId: Schema.String;
|
|
56
|
+
readonly companyName: Schema.String;
|
|
57
|
+
readonly address: Schema.NullOr<Schema.String>;
|
|
58
|
+
readonly tel: Schema.NullOr<Schema.String>;
|
|
59
|
+
readonly fax: Schema.NullOr<Schema.String>;
|
|
60
|
+
readonly website: Schema.NullOr<Schema.String>;
|
|
61
|
+
readonly description: Schema.NullOr<Schema.String>;
|
|
62
|
+
readonly issueType: Schema.NullOr<Schema.String>;
|
|
63
|
+
readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
|
|
64
|
+
readonly people: Schema.$Array<Schema.Struct<{
|
|
65
|
+
readonly sourceName: Schema.String;
|
|
66
|
+
readonly displayName: Schema.String;
|
|
67
|
+
readonly title: Schema.String;
|
|
68
|
+
readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
|
|
69
|
+
}>>;
|
|
70
|
+
}>;
|
|
71
|
+
readonly startedAt: Schema.String;
|
|
72
|
+
readonly observedAt: Schema.String;
|
|
73
|
+
readonly lastChangedAt: Schema.String;
|
|
74
|
+
}>;
|
|
75
|
+
export interface Observation extends Schema.Schema.Type<typeof Observation> {
|
|
76
|
+
}
|
|
77
|
+
export declare const LiveOutcome: Schema.Union<readonly [Schema.Struct<{
|
|
78
|
+
readonly status: Schema.Literal<"found">;
|
|
79
|
+
readonly observation: Schema.Struct<{
|
|
80
|
+
readonly company: Schema.Struct<{
|
|
81
|
+
/** Complete public ASX profile response, including fields unknown to this package version. */
|
|
82
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
83
|
+
readonly ticker: Schema.String;
|
|
84
|
+
readonly sourceId: Schema.String;
|
|
85
|
+
readonly companyName: Schema.String;
|
|
86
|
+
readonly address: Schema.NullOr<Schema.String>;
|
|
87
|
+
readonly tel: Schema.NullOr<Schema.String>;
|
|
88
|
+
readonly fax: Schema.NullOr<Schema.String>;
|
|
89
|
+
readonly website: Schema.NullOr<Schema.String>;
|
|
90
|
+
readonly description: Schema.NullOr<Schema.String>;
|
|
91
|
+
readonly issueType: Schema.NullOr<Schema.String>;
|
|
92
|
+
readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
|
|
93
|
+
readonly people: Schema.$Array<Schema.Struct<{
|
|
94
|
+
readonly sourceName: Schema.String;
|
|
95
|
+
readonly displayName: Schema.String;
|
|
96
|
+
readonly title: Schema.String;
|
|
97
|
+
readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
|
|
98
|
+
}>>;
|
|
99
|
+
}>;
|
|
100
|
+
readonly startedAt: Schema.String;
|
|
101
|
+
readonly observedAt: Schema.String;
|
|
102
|
+
readonly lastChangedAt: Schema.String;
|
|
103
|
+
}>;
|
|
104
|
+
}>, Schema.Struct<{
|
|
105
|
+
readonly status: Schema.Literal<"missing">;
|
|
106
|
+
readonly startedAt: Schema.String;
|
|
107
|
+
readonly observedAt: Schema.String;
|
|
108
|
+
readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
109
|
+
}>]>;
|
|
110
|
+
export type LiveOutcome = typeof LiveOutcome.Type;
|
|
111
|
+
export declare function isTimestamp(value: string): boolean;
|
|
112
|
+
export declare function validObservation(value: Observation): boolean;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const Ticker = Schema.String.check(Schema.isPattern(/^[A-Z0-9]{1,12}$/));
|
|
3
|
+
const Text = Schema.NullOr(Schema.String);
|
|
4
|
+
export const Person = Schema.Struct({
|
|
5
|
+
sourceName: Schema.String,
|
|
6
|
+
displayName: Schema.String,
|
|
7
|
+
title: Schema.String,
|
|
8
|
+
sourceCategory: Schema.Literals(["directors", "secretaries"]),
|
|
9
|
+
});
|
|
10
|
+
export const Company = Schema.Struct({
|
|
11
|
+
/** Complete public ASX profile response, including fields unknown to this package version. */
|
|
12
|
+
raw: Schema.optionalKey(Schema.JsonObject),
|
|
13
|
+
ticker: Ticker,
|
|
14
|
+
sourceId: Schema.String,
|
|
15
|
+
companyName: Schema.String,
|
|
16
|
+
address: Text,
|
|
17
|
+
tel: Text,
|
|
18
|
+
fax: Text,
|
|
19
|
+
website: Text,
|
|
20
|
+
description: Text,
|
|
21
|
+
issueType: Text,
|
|
22
|
+
foreignExempt: Schema.NullOr(Schema.Boolean),
|
|
23
|
+
people: Schema.Array(Person),
|
|
24
|
+
});
|
|
25
|
+
export const DirectoryEntry = Schema.Struct({
|
|
26
|
+
ticker: Ticker,
|
|
27
|
+
companyName: Schema.String,
|
|
28
|
+
industryGroup: Text,
|
|
29
|
+
listingDate: Text,
|
|
30
|
+
marketCapitalisation: Schema.NullOr(Schema.Number),
|
|
31
|
+
});
|
|
32
|
+
export const Failure = Schema.Struct({
|
|
33
|
+
_tag: Schema.Literal("AsxError"),
|
|
34
|
+
code: Schema.Literals([
|
|
35
|
+
"INVALID_INPUT",
|
|
36
|
+
"PARSE_FAILED",
|
|
37
|
+
"SOURCE_MISMATCH",
|
|
38
|
+
"TRANSPORT_FAILED",
|
|
39
|
+
"TIMEOUT",
|
|
40
|
+
"RATE_LIMITED",
|
|
41
|
+
"UPSTREAM_UNAVAILABLE",
|
|
42
|
+
"CONFIGURATION_ERROR",
|
|
43
|
+
"SNAPSHOT_UNAVAILABLE",
|
|
44
|
+
"CANCELLED",
|
|
45
|
+
"INTERNAL_ERROR",
|
|
46
|
+
"ACCESS_DENIED",
|
|
47
|
+
"UPSTREAM_REJECTED",
|
|
48
|
+
"PERSISTENCE_FAILED",
|
|
49
|
+
"PUBLICATION_CONFLICT",
|
|
50
|
+
"COVERAGE_REJECTED",
|
|
51
|
+
]),
|
|
52
|
+
});
|
|
53
|
+
export const failure = (code) => ({ _tag: "AsxError", code });
|
|
54
|
+
export const isFallbackEligible = (code) => ["TIMEOUT", "TRANSPORT_FAILED", "PARSE_FAILED", "RATE_LIMITED", "UPSTREAM_UNAVAILABLE"].includes(code);
|
|
55
|
+
export const Timestamp = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/));
|
|
56
|
+
export const Observation = Schema.Struct({
|
|
57
|
+
company: Company,
|
|
58
|
+
startedAt: Timestamp,
|
|
59
|
+
observedAt: Timestamp,
|
|
60
|
+
lastChangedAt: Timestamp,
|
|
61
|
+
});
|
|
62
|
+
export const LiveOutcome = Schema.Union([
|
|
63
|
+
Schema.Struct({ status: Schema.Literal("found"), observation: Observation }),
|
|
64
|
+
Schema.Struct({
|
|
65
|
+
status: Schema.Literal("missing"),
|
|
66
|
+
startedAt: Timestamp,
|
|
67
|
+
observedAt: Timestamp,
|
|
68
|
+
raw: Schema.optionalKey(Schema.JsonObject),
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
export function isTimestamp(value) {
|
|
72
|
+
if (!Schema.is(Timestamp)(value))
|
|
73
|
+
return false;
|
|
74
|
+
const date = new Date(value);
|
|
75
|
+
return Number.isFinite(date.getTime()) && date.toISOString() === value;
|
|
76
|
+
}
|
|
77
|
+
export function validObservation(value) {
|
|
78
|
+
return (isTimestamp(value.startedAt) &&
|
|
79
|
+
isTimestamp(value.observedAt) &&
|
|
80
|
+
isTimestamp(value.lastChangedAt) &&
|
|
81
|
+
value.lastChangedAt <= value.observedAt &&
|
|
82
|
+
value.startedAt <= value.observedAt &&
|
|
83
|
+
value.company.sourceId.trim().length > 0 &&
|
|
84
|
+
value.company.companyName.trim().length > 0 &&
|
|
85
|
+
value.company.people.every((person) => person.sourceName.trim() && person.displayName.trim() && person.title.trim()));
|
|
86
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Company, type DirectoryEntry } from "./model.js";
|
|
2
|
+
export declare function normalizeTicker(input: string): string;
|
|
3
|
+
export declare function parseCompany(tickerInput: string, input: unknown): Company;
|
|
4
|
+
export declare function parseDirectory(input: string): readonly DirectoryEntry[];
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parse } from "csv-parse/browser/esm/sync";
|
|
2
|
+
import { Result, Schema } from "effect";
|
|
3
|
+
import { failure, Ticker } from "./model.js";
|
|
4
|
+
const SourcePerson = Schema.Struct({ name: Schema.String, title: Schema.String });
|
|
5
|
+
const OptionalText = Schema.optionalKey(Schema.NullOr(Schema.String));
|
|
6
|
+
const Payload = Schema.Struct({
|
|
7
|
+
data: Schema.Struct({
|
|
8
|
+
symbol: Ticker,
|
|
9
|
+
xid: Schema.String,
|
|
10
|
+
displayName: Schema.String,
|
|
11
|
+
addressContact: Schema.Struct({
|
|
12
|
+
address: OptionalText,
|
|
13
|
+
phone: OptionalText,
|
|
14
|
+
fax: OptionalText,
|
|
15
|
+
}),
|
|
16
|
+
websiteUrl: OptionalText,
|
|
17
|
+
description: OptionalText,
|
|
18
|
+
issueType: OptionalText,
|
|
19
|
+
foreignExempt: Schema.optionalKey(Schema.NullOr(Schema.Boolean)),
|
|
20
|
+
directors: Schema.Array(SourcePerson),
|
|
21
|
+
secretaries: Schema.Array(SourcePerson),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
const text = (value) => (value?.trim() ? value : null);
|
|
25
|
+
export function normalizeTicker(input) {
|
|
26
|
+
if (typeof input !== "string" || input.length > 128)
|
|
27
|
+
throw failure("INVALID_INPUT");
|
|
28
|
+
const ticker = input.trim().toUpperCase();
|
|
29
|
+
if (!Schema.is(Ticker)(ticker))
|
|
30
|
+
throw failure("INVALID_INPUT");
|
|
31
|
+
return ticker;
|
|
32
|
+
}
|
|
33
|
+
function person(value, sourceCategory) {
|
|
34
|
+
if (!value.name.trim() || !value.title.trim())
|
|
35
|
+
throw failure("PARSE_FAILED");
|
|
36
|
+
return {
|
|
37
|
+
sourceName: value.name,
|
|
38
|
+
displayName: value.name.trim().replace(/^(?:Mr|Mrs|Ms|Miss|Dr|Prof|Sir|Dame)\.?\s+/i, ""),
|
|
39
|
+
title: value.title,
|
|
40
|
+
sourceCategory,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function parseCompany(tickerInput, input) {
|
|
44
|
+
const ticker = normalizeTicker(tickerInput);
|
|
45
|
+
const decoded = Schema.decodeUnknownResult(Payload)(input);
|
|
46
|
+
if (Result.isFailure(decoded))
|
|
47
|
+
throw failure("PARSE_FAILED");
|
|
48
|
+
const data = decoded.success.data;
|
|
49
|
+
if (data.symbol !== ticker)
|
|
50
|
+
throw failure("SOURCE_MISMATCH");
|
|
51
|
+
if (!data.xid.trim() || !data.displayName.trim())
|
|
52
|
+
throw failure("PARSE_FAILED");
|
|
53
|
+
const raw = Schema.decodeUnknownResult(Schema.JsonObject)(input);
|
|
54
|
+
if (Result.isFailure(raw))
|
|
55
|
+
throw failure("PARSE_FAILED");
|
|
56
|
+
return {
|
|
57
|
+
raw: structuredClone(raw.success),
|
|
58
|
+
ticker,
|
|
59
|
+
sourceId: data.xid,
|
|
60
|
+
companyName: data.displayName,
|
|
61
|
+
address: text(data.addressContact.address),
|
|
62
|
+
tel: text(data.addressContact.phone),
|
|
63
|
+
fax: text(data.addressContact.fax),
|
|
64
|
+
website: text(data.websiteUrl),
|
|
65
|
+
description: text(data.description),
|
|
66
|
+
issueType: text(data.issueType),
|
|
67
|
+
foreignExempt: data.foreignExempt ?? null,
|
|
68
|
+
people: [
|
|
69
|
+
...data.directors.map((value) => person(value, "directors")),
|
|
70
|
+
...data.secretaries.map((value) => person(value, "secretaries")),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const HEADERS = ["ASX code", "Company name", "GICs industry group", "Listing date", "Market Cap"];
|
|
75
|
+
function listingDate(value) {
|
|
76
|
+
if (!value.trim())
|
|
77
|
+
return null;
|
|
78
|
+
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value);
|
|
79
|
+
if (!match)
|
|
80
|
+
throw failure("PARSE_FAILED");
|
|
81
|
+
const iso = `${match[3]}-${match[2]}-${match[1]}`;
|
|
82
|
+
const date = new Date(`${iso}T00:00:00.000Z`);
|
|
83
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== iso)
|
|
84
|
+
throw failure("PARSE_FAILED");
|
|
85
|
+
return iso;
|
|
86
|
+
}
|
|
87
|
+
function directoryRow(row) {
|
|
88
|
+
if (row.length !== HEADERS.length || !row[1]?.trim())
|
|
89
|
+
throw failure("PARSE_FAILED");
|
|
90
|
+
const ticker = row[0];
|
|
91
|
+
if (!Schema.is(Ticker)(ticker))
|
|
92
|
+
throw failure("PARSE_FAILED");
|
|
93
|
+
const cap = row[4].trim();
|
|
94
|
+
const marketCapitalisation = cap === "" || cap === "--" ? null : Number(cap);
|
|
95
|
+
if (marketCapitalisation !== null &&
|
|
96
|
+
(!/^\d+(?:\.\d+)?$/.test(cap) || !Number.isFinite(marketCapitalisation)))
|
|
97
|
+
throw failure("PARSE_FAILED");
|
|
98
|
+
return {
|
|
99
|
+
ticker,
|
|
100
|
+
companyName: row[1],
|
|
101
|
+
industryGroup: text(row[2]),
|
|
102
|
+
listingDate: listingDate(row[3]),
|
|
103
|
+
marketCapitalisation,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function parseDirectory(input) {
|
|
107
|
+
if (typeof input !== "string" || input.length > 4 * 1024 * 1024)
|
|
108
|
+
throw failure("PARSE_FAILED");
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = parse(input, { bom: true, skip_empty_lines: true, max_record_size: 16_384 });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw failure("PARSE_FAILED");
|
|
115
|
+
}
|
|
116
|
+
const decoded = Schema.decodeUnknownResult(Schema.Array(Schema.Array(Schema.String)))(raw);
|
|
117
|
+
if (Result.isFailure(decoded))
|
|
118
|
+
throw failure("PARSE_FAILED");
|
|
119
|
+
const [headers, ...rows] = decoded.success;
|
|
120
|
+
if (!headers ||
|
|
121
|
+
headers.length !== HEADERS.length ||
|
|
122
|
+
HEADERS.some((header, i) => headers[i] !== header) ||
|
|
123
|
+
rows.length === 0 ||
|
|
124
|
+
rows.length > 20_000)
|
|
125
|
+
throw failure("PARSE_FAILED");
|
|
126
|
+
const entries = rows.map(directoryRow);
|
|
127
|
+
if (new Set(entries.map((entry) => entry.ticker)).size !== entries.length)
|
|
128
|
+
throw failure("PARSE_FAILED");
|
|
129
|
+
return entries;
|
|
130
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CompanySources, SourceName } from "./source-record.js";
|
|
2
|
+
export interface ProfileContact {
|
|
3
|
+
readonly address: string | null;
|
|
4
|
+
readonly tel: string | null;
|
|
5
|
+
readonly website: string | null;
|
|
6
|
+
/** Which collected source supplied each value; absent when the value is null. */
|
|
7
|
+
readonly sources: Partial<Record<"address" | "tel" | "website", SourceName>>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Contact details from collected public profiles, used only where the ASX profile is blank.
|
|
11
|
+
* Values are never inferred: each comes verbatim from one named source.
|
|
12
|
+
*/
|
|
13
|
+
export declare function profileContact(sources: CompanySources): ProfileContact;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const PHONE = /^T\s+(\+?[\d\s()-]{6,})$/;
|
|
2
|
+
const WEB = /^(https?:\/\/|www\.)/i;
|
|
3
|
+
const AU_TAIL = /^(.+),\s*([^,]+?),?\s+(ACT|NSW|NT|QLD|SA|TAS|VIC|WA)\s+(\d{4})$/i;
|
|
4
|
+
/** Morningstar writes "street, suburb, STATE 3000"; ASX's own field is "street, suburb, STATE, Australia, 3000". */
|
|
5
|
+
const asxAddressFormat = (line) => {
|
|
6
|
+
const match = AU_TAIL.exec(line);
|
|
7
|
+
return match
|
|
8
|
+
? `${match[1]}, ${match[2]}, ${match[3].toUpperCase()}, Australia, ${match[4]}`
|
|
9
|
+
: line;
|
|
10
|
+
};
|
|
11
|
+
const profileText = (payload, selector) => {
|
|
12
|
+
const response = payload?.response;
|
|
13
|
+
const match = response?.result?.find((entry) => entry.selector === selector);
|
|
14
|
+
return match?.results?.map((result) => result.text ?? "").join("\n") ?? "";
|
|
15
|
+
};
|
|
16
|
+
/** Morningstar's contact block: "Contact", the address line, "T <phone>", then the website. */
|
|
17
|
+
const morningstarContact = (payload) => {
|
|
18
|
+
const lines = profileText(payload, ".sal-component-body:has(.sal-dp-pair-contact)")
|
|
19
|
+
.split("\n")
|
|
20
|
+
.map((line) => line.trim());
|
|
21
|
+
const start = lines.indexOf("Contact");
|
|
22
|
+
if (start < 0) {
|
|
23
|
+
return { address: null, tel: null };
|
|
24
|
+
}
|
|
25
|
+
let address = null;
|
|
26
|
+
let tel = null;
|
|
27
|
+
for (const line of lines.slice(start + 1, start + 5)) {
|
|
28
|
+
const phone = PHONE.exec(line);
|
|
29
|
+
if (phone?.[1]) {
|
|
30
|
+
tel = phone[1].trim();
|
|
31
|
+
}
|
|
32
|
+
else if (!(address || WEB.test(line)) && line.length > 5 && /\d/.test(line)) {
|
|
33
|
+
address = asxAddressFormat(line);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { address, tel };
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Contact details from collected public profiles, used only where the ASX profile is blank.
|
|
40
|
+
* Values are never inferred: each comes verbatim from one named source.
|
|
41
|
+
*/
|
|
42
|
+
export function profileContact(sources) {
|
|
43
|
+
const found = (name) => sources[name]?.status === "found" ? sources[name] : undefined;
|
|
44
|
+
const morningstar = found("morningstar");
|
|
45
|
+
const google = found("google-finance");
|
|
46
|
+
const contact = morningstar
|
|
47
|
+
? morningstarContact(morningstar.payload)
|
|
48
|
+
: { address: null, tel: null };
|
|
49
|
+
const website = morningstar?.websites[0] ?? google?.websites[0] ?? null;
|
|
50
|
+
return {
|
|
51
|
+
address: contact.address,
|
|
52
|
+
tel: contact.tel,
|
|
53
|
+
website,
|
|
54
|
+
sources: {
|
|
55
|
+
...(contact.address ? { address: "morningstar" } : {}),
|
|
56
|
+
...(contact.tel ? { tel: "morningstar" } : {}),
|
|
57
|
+
...(website
|
|
58
|
+
? {
|
|
59
|
+
website: morningstar?.websites[0]
|
|
60
|
+
? "morningstar"
|
|
61
|
+
: "google-finance",
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
declare const Current: Schema.Struct<{
|
|
3
|
+
readonly generation: Schema.String;
|
|
4
|
+
readonly version: Schema.NonEmptyString;
|
|
5
|
+
}>;
|
|
6
|
+
export type SnapshotPointer = typeof Current.Type;
|
|
7
|
+
/** Implementations must use immutable writes and an atomic conditional pointer update. */
|
|
8
|
+
export interface SnapshotPublicationStore {
|
|
9
|
+
readonly readCurrent: (signal: AbortSignal) => Promise<unknown>;
|
|
10
|
+
readonly readGeneration: (generation: string, signal: AbortSignal) => Promise<string | null>;
|
|
11
|
+
readonly writeGeneration: (generation: string, text: string, signal: AbortSignal) => Promise<void>;
|
|
12
|
+
readonly selectGeneration: (expectedVersion: string | null, generation: string, signal: AbortSignal) => Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createSnapshotPublisher(store: SnapshotPublicationStore): {
|
|
15
|
+
publish: (dataset: unknown, expectedGeneration: string | null, signal?: AbortSignal) => Promise<{
|
|
16
|
+
generation: string;
|
|
17
|
+
status: "published" | "already-current";
|
|
18
|
+
}>;
|
|
19
|
+
/** Restore retained bytes after full validation; never rewrite a historical generation. */
|
|
20
|
+
rollback: (generation: string, expectedGeneration: string, signal?: AbortSignal) => Promise<{
|
|
21
|
+
generation: string;
|
|
22
|
+
status: "restored" | "already-current";
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
import { failure, Failure } from "./model.js";
|
|
3
|
+
import { encodeSnapshot, openSnapshot } from "./snapshot.js";
|
|
4
|
+
const Current = Schema.Struct({
|
|
5
|
+
generation: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
|
|
6
|
+
version: Schema.NonEmptyString,
|
|
7
|
+
});
|
|
8
|
+
const io = (operation) => Effect.tryPromise({ try: operation, catch: () => failure("PERSISTENCE_FAILED") });
|
|
9
|
+
const readCurrent = Effect.fn("ASX.publication.current")(function* (store) {
|
|
10
|
+
const current = yield* io(store.readCurrent);
|
|
11
|
+
return yield* Schema.decodeUnknownEffect(Schema.NullOr(Current))(current).pipe(Effect.mapError(() => failure("PERSISTENCE_FAILED")));
|
|
12
|
+
});
|
|
13
|
+
const verify = Effect.fn("ASX.publication.verify")(function* (store, generation, expectedText) {
|
|
14
|
+
const text = yield* io((signal) => store.readGeneration(generation, signal));
|
|
15
|
+
if (text !== expectedText)
|
|
16
|
+
return yield* Effect.fail(failure("PERSISTENCE_FAILED"));
|
|
17
|
+
yield* Effect.tryPromise({
|
|
18
|
+
try: () => openSnapshot(text, generation),
|
|
19
|
+
catch: () => failure("PERSISTENCE_FAILED"),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
const publish = Effect.fn("ASX.publication.publish")(function* (store, dataset, expectedGeneration) {
|
|
23
|
+
const candidate = yield* Effect.tryPromise({
|
|
24
|
+
try: () => encodeSnapshot(dataset),
|
|
25
|
+
catch: () => failure("SNAPSHOT_UNAVAILABLE"),
|
|
26
|
+
});
|
|
27
|
+
const current = yield* readCurrent(store);
|
|
28
|
+
if (current?.generation === candidate.generation) {
|
|
29
|
+
yield* verify(store, candidate.generation, candidate.text);
|
|
30
|
+
return { generation: candidate.generation, status: "already-current" };
|
|
31
|
+
}
|
|
32
|
+
if ((current?.generation ?? null) !== expectedGeneration)
|
|
33
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
34
|
+
yield* io((signal) => store.writeGeneration(candidate.generation, candidate.text, signal));
|
|
35
|
+
yield* verify(store, candidate.generation, candidate.text);
|
|
36
|
+
const selected = yield* io((signal) => store.selectGeneration(current?.version ?? null, candidate.generation, signal));
|
|
37
|
+
if (!selected)
|
|
38
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
39
|
+
const selectedPointer = yield* readCurrent(store);
|
|
40
|
+
if (selectedPointer?.generation !== candidate.generation)
|
|
41
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
42
|
+
return { generation: candidate.generation, status: "published" };
|
|
43
|
+
});
|
|
44
|
+
const rollback = Effect.fn("ASX.publication.rollback")(function* (store, generation, expectedGeneration) {
|
|
45
|
+
if (!/^[a-f0-9]{64}$/.test(generation) || !/^[a-f0-9]{64}$/.test(expectedGeneration))
|
|
46
|
+
return yield* Effect.fail(failure("INVALID_INPUT"));
|
|
47
|
+
const current = yield* readCurrent(store);
|
|
48
|
+
if (current?.generation !== generation && current?.generation !== expectedGeneration)
|
|
49
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
50
|
+
const text = yield* io((signal) => store.readGeneration(generation, signal));
|
|
51
|
+
if (text === null)
|
|
52
|
+
return yield* Effect.fail(failure("SNAPSHOT_UNAVAILABLE"));
|
|
53
|
+
yield* Effect.tryPromise({
|
|
54
|
+
try: () => openSnapshot(text, generation),
|
|
55
|
+
catch: () => failure("SNAPSHOT_UNAVAILABLE"),
|
|
56
|
+
});
|
|
57
|
+
if (current?.generation === generation)
|
|
58
|
+
return { generation, status: "already-current" };
|
|
59
|
+
const selected = yield* io((signal) => store.selectGeneration(current?.version ?? null, generation, signal));
|
|
60
|
+
if (!selected)
|
|
61
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
62
|
+
const selectedPointer = yield* readCurrent(store);
|
|
63
|
+
if (selectedPointer?.generation !== generation)
|
|
64
|
+
return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
|
|
65
|
+
return { generation, status: "restored" };
|
|
66
|
+
});
|
|
67
|
+
async function run(program, signal) {
|
|
68
|
+
try {
|
|
69
|
+
const result = await Effect.runPromise(Effect.result(program), { signal });
|
|
70
|
+
if (Result.isFailure(result))
|
|
71
|
+
throw result.failure;
|
|
72
|
+
return result.success;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const code = signal?.aborted
|
|
76
|
+
? "CANCELLED"
|
|
77
|
+
: Schema.is(Failure)(error)
|
|
78
|
+
? error.code
|
|
79
|
+
: "INTERNAL_ERROR";
|
|
80
|
+
throw failure(code);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function createSnapshotPublisher(store) {
|
|
84
|
+
return {
|
|
85
|
+
publish: (dataset, expectedGeneration, signal) => run(publish(store, dataset, expectedGeneration), signal),
|
|
86
|
+
/** Restore retained bytes after full validation; never rewrite a historical generation. */
|
|
87
|
+
rollback: (generation, expectedGeneration, signal) => run(rollback(store, generation, expectedGeneration), signal),
|
|
88
|
+
};
|
|
89
|
+
}
|