@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.
Files changed (51) 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 +178 -0
  5. package/dist/client-replies.js +57 -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 +60 -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/contract/http-api.d.ts +139 -0
  19. package/dist/contract/http-api.js +100 -0
  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/http.d.ts +1 -0
  25. package/dist/http.js +49 -0
  26. package/dist/live.d.ts +56 -0
  27. package/dist/live.js +129 -0
  28. package/dist/model.d.ts +112 -0
  29. package/dist/model.js +86 -0
  30. package/dist/parse.d.ts +4 -0
  31. package/dist/parse.js +130 -0
  32. package/dist/profile-contact.d.ts +13 -0
  33. package/dist/profile-contact.js +66 -0
  34. package/dist/publication.d.ts +25 -0
  35. package/dist/publication.js +89 -0
  36. package/dist/refresh-coordinator.d.ts +96 -0
  37. package/dist/refresh-coordinator.js +194 -0
  38. package/dist/refresh.d.ts +126 -0
  39. package/dist/refresh.js +157 -0
  40. package/dist/requests.d.ts +10 -0
  41. package/dist/requests.js +21 -0
  42. package/dist/runtime/http.js +8 -1
  43. package/dist/search-service.d.ts +16 -0
  44. package/dist/search-service.js +64 -0
  45. package/dist/service.d.ts +35 -0
  46. package/dist/service.js +99 -0
  47. package/dist/snapshot.d.ts +328 -0
  48. package/dist/snapshot.js +151 -0
  49. package/dist/source-record.d.ts +63 -0
  50. package/dist/source-record.js +91 -0
  51. package/package.json +3 -3
@@ -0,0 +1,96 @@
1
+ import { type CollectionSource, type RefreshCheckpoint } from "./collection.js";
2
+ import { Failure } from "./model.js";
3
+ import { type SnapshotPublicationStore } from "./publication.js";
4
+ export interface RefreshState {
5
+ readonly nextAttemptAt: string;
6
+ readonly checkpoint: RefreshCheckpoint | null;
7
+ readonly lastCompletedAt: string | null;
8
+ readonly halted: typeof Failure.Type | null;
9
+ }
10
+ export interface RefreshStateStore {
11
+ readonly read: (signal: AbortSignal) => Promise<{
12
+ value: unknown;
13
+ version: string;
14
+ } | null>;
15
+ /** Atomic conditional write; return the new version, or null if another writer won. */
16
+ readonly save: (expectedVersion: string | null, value: RefreshState, signal: AbortSignal) => Promise<string | null>;
17
+ }
18
+ export interface RefreshCoordinatorOptions {
19
+ readonly enabled: boolean;
20
+ readonly batchSize: number;
21
+ readonly intervalMs: number;
22
+ readonly retryAfterMs: number;
23
+ }
24
+ export interface RefreshRecovery {
25
+ /** Version observed by the operator; stale recovery commands must not change newer progress. */
26
+ readonly expectedVersion: string;
27
+ readonly action: "resume" | "restart";
28
+ }
29
+ export declare function decodeRefreshState(input: unknown): RefreshState;
30
+ /** One step per scheduled tick. The host supplies admitted storage, source permission and pacing. */
31
+ export declare function createRefreshCoordinator(source: CollectionSource, progress: RefreshStateStore, publication: SnapshotPublicationStore): {
32
+ /** The host authenticates the operator and serializes this with maintenance. No source or pointer I/O. */
33
+ recover: (input: RefreshRecovery, now: string, signal?: AbortSignal) => Promise<{
34
+ status: "recovered";
35
+ action: "restart" | "resume";
36
+ version: string;
37
+ nextAttemptAt: string;
38
+ }>;
39
+ step: (options: RefreshCoordinatorOptions, now: string, signal?: AbortSignal) => Promise<{
40
+ status: "disabled";
41
+ nextAttemptAt?: undefined;
42
+ completed?: undefined;
43
+ total?: undefined;
44
+ reason?: undefined;
45
+ generation?: undefined;
46
+ report?: undefined;
47
+ } | {
48
+ status: "halted";
49
+ reason: "ACCESS_DENIED" | "CANCELLED" | "CONFIGURATION_ERROR" | "COVERAGE_REJECTED" | "INTERNAL_ERROR" | "INVALID_INPUT" | "PARSE_FAILED" | "PERSISTENCE_FAILED" | "PUBLICATION_CONFLICT" | "SNAPSHOT_UNAVAILABLE" | "SOURCE_MISMATCH" | "TIMEOUT" | "TRANSPORT_FAILED" | "UPSTREAM_REJECTED" | "UPSTREAM_UNAVAILABLE";
50
+ nextAttemptAt?: undefined;
51
+ completed?: undefined;
52
+ total?: undefined;
53
+ generation?: undefined;
54
+ report?: undefined;
55
+ } | {
56
+ status: "waiting";
57
+ nextAttemptAt: string;
58
+ completed?: undefined;
59
+ total?: undefined;
60
+ reason?: undefined;
61
+ generation?: undefined;
62
+ report?: undefined;
63
+ } | {
64
+ nextAttemptAt?: undefined;
65
+ status: "collecting" | "halted";
66
+ completed: number;
67
+ total: number;
68
+ reason?: undefined;
69
+ generation?: undefined;
70
+ report?: undefined;
71
+ } | {
72
+ nextAttemptAt?: undefined;
73
+ completed?: undefined;
74
+ total?: undefined;
75
+ status: "halted";
76
+ reason: "COVERAGE_REJECTED";
77
+ report: import("./refresh.js").RefreshReport;
78
+ generation?: undefined;
79
+ } | {
80
+ nextAttemptAt?: undefined;
81
+ completed?: undefined;
82
+ total?: undefined;
83
+ reason?: undefined;
84
+ status: "retrying";
85
+ report: import("./refresh.js").RefreshReport;
86
+ generation?: undefined;
87
+ } | {
88
+ nextAttemptAt?: undefined;
89
+ completed?: undefined;
90
+ total?: undefined;
91
+ reason?: undefined;
92
+ status: "published";
93
+ generation: string;
94
+ report: import("./refresh.js").RefreshReport;
95
+ }>;
96
+ };
@@ -0,0 +1,194 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { createRefreshCollector, decodeRefreshCheckpoint, } from "./collection.js";
3
+ import { failure, Failure, isTimestamp, Timestamp } from "./model.js";
4
+ import { createSnapshotPublisher } from "./publication.js";
5
+ import { applyRefresh } from "./refresh.js";
6
+ import { openSnapshot } from "./snapshot.js";
7
+ const State = Schema.Struct({
8
+ nextAttemptAt: Timestamp,
9
+ checkpoint: Schema.NullOr(Schema.Unknown),
10
+ lastCompletedAt: Schema.NullOr(Timestamp),
11
+ halted: Schema.NullOr(Failure),
12
+ });
13
+ export function decodeRefreshState(input) {
14
+ const decoded = Schema.decodeUnknownResult(State)(input);
15
+ if (Result.isFailure(decoded) ||
16
+ !isTimestamp(decoded.success.nextAttemptAt) ||
17
+ (decoded.success.lastCompletedAt !== null && !isTimestamp(decoded.success.lastCompletedAt)))
18
+ throw failure("PERSISTENCE_FAILED");
19
+ return {
20
+ ...decoded.success,
21
+ checkpoint: decoded.success.checkpoint === null
22
+ ? null
23
+ : decodeRefreshCheckpoint(decoded.success.checkpoint),
24
+ };
25
+ }
26
+ const safe = (error) => Schema.is(Failure)(error) ? error : failure("PERSISTENCE_FAILED");
27
+ const io = (operation) => Effect.tryPromise({ try: operation, catch: safe });
28
+ const save = Effect.fn("ASX.refresh.save")(function* (store, version, state) {
29
+ const next = yield* io((signal) => store.save(version, state, signal));
30
+ if (!next)
31
+ return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
32
+ return next;
33
+ });
34
+ const recover = Effect.fn("ASX.refresh.recover")(function* (progress, input, now) {
35
+ if (!isTimestamp(now) ||
36
+ typeof input.expectedVersion !== "string" ||
37
+ !input.expectedVersion.trim() ||
38
+ input.expectedVersion.length > 1024 ||
39
+ !["resume", "restart"].includes(input.action))
40
+ return yield* Effect.fail(failure("INVALID_INPUT"));
41
+ const stored = yield* io(progress.read);
42
+ if (stored === null || stored.version !== input.expectedVersion)
43
+ return yield* Effect.fail(failure("PUBLICATION_CONFLICT"));
44
+ const state = yield* Effect.try({ try: () => decodeRefreshState(stored.value), catch: safe });
45
+ if (!state.halted || !state.checkpoint)
46
+ return yield* Effect.fail(failure("INVALID_INPUT"));
47
+ // A rejected directory must be fetched again; recovery never waives publication validation.
48
+ if (state.halted.code === "COVERAGE_REJECTED" && input.action === "resume")
49
+ return yield* Effect.fail(failure("COVERAGE_REJECTED"));
50
+ const nextAttemptAt = state.halted.code === "RATE_LIMITED" && state.nextAttemptAt > now ? state.nextAttemptAt : now;
51
+ const version = yield* save(progress, stored.version, {
52
+ ...state,
53
+ checkpoint: input.action === "restart" ? null : state.checkpoint,
54
+ halted: null,
55
+ nextAttemptAt,
56
+ });
57
+ return { status: "recovered", action: input.action, version, nextAttemptAt };
58
+ });
59
+ const step = Effect.fn("ASX.refresh.step")(function* (source, progress, publication, options, now) {
60
+ if (!options.enabled)
61
+ return { status: "disabled" };
62
+ if (!isTimestamp(now) ||
63
+ !Number.isInteger(options.batchSize) ||
64
+ options.batchSize < 1 ||
65
+ options.batchSize > 100 ||
66
+ !Number.isInteger(options.intervalMs) ||
67
+ options.intervalMs < 0 ||
68
+ options.intervalMs > 60_000 ||
69
+ !Number.isInteger(options.retryAfterMs) ||
70
+ options.retryAfterMs < 60_000 ||
71
+ options.retryAfterMs > 86_400_000)
72
+ return yield* Effect.fail(failure("CONFIGURATION_ERROR"));
73
+ const stored = yield* io(progress.read);
74
+ let state = stored === null
75
+ ? { nextAttemptAt: now, checkpoint: null, lastCompletedAt: null, halted: null }
76
+ : yield* Effect.try({ try: () => decodeRefreshState(stored.value), catch: safe });
77
+ if (state.halted && state.halted.code !== "RATE_LIMITED")
78
+ return { status: "halted", reason: state.halted.code };
79
+ if (now < state.nextAttemptAt)
80
+ return { status: "waiting", nextAttemptAt: state.nextAttemptAt };
81
+ let version = stored?.version ?? null;
82
+ const collector = createRefreshCollector(source);
83
+ if (state.checkpoint === null) {
84
+ const pointerInput = yield* io(publication.readCurrent);
85
+ const pointer = yield* Schema.decodeUnknownEffect(Schema.NullOr(Schema.Struct({
86
+ generation: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
87
+ version: Schema.NonEmptyString,
88
+ })))(pointerInput).pipe(Effect.mapError(() => failure("PERSISTENCE_FAILED")));
89
+ const checkpoint = yield* io((signal) => collector.begin(pointer?.generation ?? null, signal));
90
+ state = { ...state, checkpoint, halted: null };
91
+ version = yield* save(progress, version, state);
92
+ }
93
+ const checkpoint = state.checkpoint;
94
+ if (!checkpoint)
95
+ return yield* Effect.fail(failure("INTERNAL_ERROR"));
96
+ const collected = yield* io((signal) => collector.batch(checkpoint, { ...options, signal }));
97
+ state = {
98
+ ...state,
99
+ checkpoint: collected.checkpoint,
100
+ halted: collected.halted,
101
+ nextAttemptAt: collected.halted
102
+ ? new Date(Date.parse(now) + options.retryAfterMs).toISOString()
103
+ : now,
104
+ };
105
+ version = yield* save(progress, version, state);
106
+ if (!collected.complete)
107
+ return {
108
+ status: collected.halted ? "halted" : "collecting",
109
+ completed: collected.checkpoint.attempts.length,
110
+ total: collected.checkpoint.directory.entries.length,
111
+ };
112
+ let previous = null;
113
+ let previousCount = 0;
114
+ if (checkpoint.baseGeneration) {
115
+ const text = yield* io((signal) => publication.readGeneration(checkpoint.baseGeneration ?? "", signal));
116
+ if (text === null)
117
+ return yield* Effect.fail(failure("SNAPSHOT_UNAVAILABLE"));
118
+ const priorSnapshot = yield* io(() => openSnapshot(text, checkpoint.baseGeneration ?? ""));
119
+ previousCount = priorSnapshot.metadata().records;
120
+ previous = yield* Effect.try({
121
+ try: () => JSON.parse(text),
122
+ catch: () => failure("SNAPSHOT_UNAVAILABLE"),
123
+ });
124
+ }
125
+ const prepared = yield* Effect.try({
126
+ try: () => applyRefresh(previous, collected.checkpoint.directory, collected.checkpoint.attempts),
127
+ catch: safe,
128
+ });
129
+ // A syntactically valid truncated directory must not silently withdraw many issuers.
130
+ // This is a publication safety limit, not an ASX completeness or freshness guarantee.
131
+ if (previousCount > 0 && prepared.report.removedFromDirectory / previousCount > 0.05) {
132
+ yield* save(progress, version, { ...state, halted: failure("COVERAGE_REJECTED") });
133
+ return {
134
+ status: "halted",
135
+ reason: "COVERAGE_REJECTED",
136
+ report: prepared.report,
137
+ };
138
+ }
139
+ // An initial directory is useful even with missing profiles. Health reports its actual coverage.
140
+ // A completely failed later profile pass keeps the existing selected dataset.
141
+ if (previous !== null && prepared.report.found === 0) {
142
+ yield* save(progress, version, {
143
+ ...state,
144
+ checkpoint: null,
145
+ halted: null,
146
+ nextAttemptAt: new Date(Date.parse(now) + options.retryAfterMs).toISOString(),
147
+ });
148
+ return { status: "retrying", report: prepared.report };
149
+ }
150
+ const published = yield* io((signal) => createSnapshotPublisher(publication).publish(prepared.dataset, checkpoint.baseGeneration, signal));
151
+ yield* save(progress, version, {
152
+ checkpoint: null,
153
+ lastCompletedAt: now,
154
+ halted: null,
155
+ nextAttemptAt: new Date(Date.parse(collected.checkpoint.directory.observedAt) + 86_400_000).toISOString(),
156
+ });
157
+ return {
158
+ status: "published",
159
+ generation: published.generation,
160
+ report: prepared.report,
161
+ };
162
+ });
163
+ /** One step per scheduled tick. The host supplies admitted storage, source permission and pacing. */
164
+ export function createRefreshCoordinator(source, progress, publication) {
165
+ return {
166
+ /** The host authenticates the operator and serializes this with maintenance. No source or pointer I/O. */
167
+ recover: async (input, now, signal) => {
168
+ if (signal?.aborted)
169
+ throw failure("CANCELLED");
170
+ try {
171
+ const result = await Effect.runPromise(Effect.result(recover(progress, input, now)), {
172
+ signal,
173
+ });
174
+ if (Result.isFailure(result))
175
+ throw result.failure;
176
+ return result.success;
177
+ }
178
+ catch (error) {
179
+ throw signal?.aborted ? failure("CANCELLED") : safe(error);
180
+ }
181
+ },
182
+ step: async (options, now, signal) => {
183
+ try {
184
+ const result = await Effect.runPromise(Effect.result(step(source, progress, publication, options, now)), { signal });
185
+ if (Result.isFailure(result))
186
+ throw result.failure;
187
+ return result.success;
188
+ }
189
+ catch (error) {
190
+ throw signal?.aborted ? failure("CANCELLED") : safe(error);
191
+ }
192
+ },
193
+ };
194
+ }
@@ -0,0 +1,126 @@
1
+ import { Schema } from "effect";
2
+ import { type AsxDataset } from "./snapshot.js";
3
+ export declare const RefreshAttempt: Schema.Struct<{
4
+ readonly ticker: Schema.String;
5
+ readonly outcome: Schema.Union<readonly [Schema.Union<readonly [Schema.Struct<{
6
+ readonly status: Schema.Literal<"found">;
7
+ readonly observation: Schema.Struct<{
8
+ readonly company: Schema.Struct<{
9
+ readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
10
+ readonly ticker: Schema.String;
11
+ readonly sourceId: Schema.String;
12
+ readonly companyName: Schema.String;
13
+ readonly address: Schema.NullOr<Schema.String>;
14
+ readonly tel: Schema.NullOr<Schema.String>;
15
+ readonly fax: Schema.NullOr<Schema.String>;
16
+ readonly website: Schema.NullOr<Schema.String>;
17
+ readonly description: Schema.NullOr<Schema.String>;
18
+ readonly issueType: Schema.NullOr<Schema.String>;
19
+ readonly foreignExempt: Schema.NullOr<Schema.Boolean>;
20
+ readonly people: Schema.$Array<Schema.Struct<{
21
+ readonly sourceName: Schema.String;
22
+ readonly displayName: Schema.String;
23
+ readonly title: Schema.String;
24
+ readonly sourceCategory: Schema.Literals<readonly ["directors", "secretaries"]>;
25
+ }>>;
26
+ }>;
27
+ readonly startedAt: Schema.String;
28
+ readonly observedAt: Schema.String;
29
+ readonly lastChangedAt: Schema.String;
30
+ }>;
31
+ }>, Schema.Struct<{
32
+ readonly status: Schema.Literal<"missing">;
33
+ readonly startedAt: Schema.String;
34
+ readonly observedAt: Schema.String;
35
+ readonly raw: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
36
+ }>]>, Schema.Struct<{
37
+ readonly _tag: Schema.Literal<"AsxError">;
38
+ readonly code: Schema.Literals<readonly ["INVALID_INPUT", "PARSE_FAILED", "SOURCE_MISMATCH", "TRANSPORT_FAILED", "TIMEOUT", "RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "CONFIGURATION_ERROR", "SNAPSHOT_UNAVAILABLE", "CANCELLED", "INTERNAL_ERROR", "ACCESS_DENIED", "UPSTREAM_REJECTED", "PERSISTENCE_FAILED", "PUBLICATION_CONFLICT", "COVERAGE_REJECTED"]>;
39
+ }>]>;
40
+ readonly enrichment: Schema.optionalKey<Schema.Union<readonly [Schema.Union<readonly [Schema.Struct<{
41
+ readonly status: Schema.Literal<"confirmed">;
42
+ readonly enrichment: Schema.Struct<{
43
+ readonly ticker: Schema.String;
44
+ readonly sourceId: Schema.NonEmptyString;
45
+ readonly abn: Schema.String;
46
+ readonly acn: Schema.NullOr<Schema.String>;
47
+ readonly legalName: Schema.NonEmptyString;
48
+ readonly identityEvidence: Schema.Struct<{
49
+ readonly url: Schema.String;
50
+ readonly sha256: Schema.String;
51
+ readonly observedAt: Schema.String;
52
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
53
+ }>;
54
+ readonly abrEvidence: Schema.Struct<{
55
+ readonly source: Schema.Literals<readonly ["abr-live", "abr-snapshot"]>;
56
+ readonly observedAt: Schema.String;
57
+ }>;
58
+ readonly website: Schema.NullOr<Schema.Struct<{
59
+ readonly url: Schema.String;
60
+ readonly evidence: Schema.Struct<{
61
+ readonly url: Schema.String;
62
+ readonly sha256: Schema.String;
63
+ readonly observedAt: Schema.String;
64
+ readonly kind: Schema.Literals<readonly ["issuer-filing", "issuer-website"]>;
65
+ }>;
66
+ }>>;
67
+ }>;
68
+ }>, Schema.Struct<{
69
+ readonly status: Schema.Literal<"unresolved">;
70
+ readonly reason: Schema.Literals<readonly ["NO_EVIDENCE", "AMBIGUOUS", "INVALID_EVIDENCE", "IDENTITY_MISMATCH"]>;
71
+ }>]>, Schema.Struct<{
72
+ readonly _tag: Schema.Literal<"AsxError">;
73
+ readonly code: Schema.Literals<readonly ["INVALID_INPUT", "PARSE_FAILED", "SOURCE_MISMATCH", "TRANSPORT_FAILED", "TIMEOUT", "RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "CONFIGURATION_ERROR", "SNAPSHOT_UNAVAILABLE", "CANCELLED", "INTERNAL_ERROR", "ACCESS_DENIED", "UPSTREAM_REJECTED", "PERSISTENCE_FAILED", "PUBLICATION_CONFLICT", "COVERAGE_REJECTED"]>;
74
+ }>]>>;
75
+ readonly sources: Schema.optionalKey<Schema.Struct<{
76
+ readonly asx: Schema.optionalKey<Schema.Struct<{
77
+ readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
78
+ readonly ticker: Schema.String;
79
+ readonly sourceUrl: Schema.String;
80
+ readonly sourceId: Schema.NonEmptyString;
81
+ readonly status: Schema.Literals<readonly ["found", "missing"]>;
82
+ readonly startedAt: Schema.String;
83
+ readonly observedAt: Schema.String;
84
+ readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
85
+ readonly websites: Schema.$Array<Schema.String>;
86
+ }>>;
87
+ readonly morningstar: Schema.optionalKey<Schema.Struct<{
88
+ readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
89
+ readonly ticker: Schema.String;
90
+ readonly sourceUrl: Schema.String;
91
+ readonly sourceId: Schema.NonEmptyString;
92
+ readonly status: Schema.Literals<readonly ["found", "missing"]>;
93
+ readonly startedAt: Schema.String;
94
+ readonly observedAt: Schema.String;
95
+ readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
96
+ readonly websites: Schema.$Array<Schema.String>;
97
+ }>>;
98
+ readonly "google-finance": Schema.optionalKey<Schema.Struct<{
99
+ readonly source: Schema.Literals<readonly ["asx", "morningstar", "google-finance"]>;
100
+ readonly ticker: Schema.String;
101
+ readonly sourceUrl: Schema.String;
102
+ readonly sourceId: Schema.NonEmptyString;
103
+ readonly status: Schema.Literals<readonly ["found", "missing"]>;
104
+ readonly startedAt: Schema.String;
105
+ readonly observedAt: Schema.String;
106
+ readonly payload: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
107
+ readonly websites: Schema.$Array<Schema.String>;
108
+ }>>;
109
+ }>>;
110
+ }>;
111
+ export type RefreshAttempt = typeof RefreshAttempt.Type;
112
+ export interface RefreshReport {
113
+ readonly attempted: number;
114
+ readonly found: number;
115
+ readonly missing: number;
116
+ readonly failed: number;
117
+ readonly retained: number;
118
+ readonly unavailable: number;
119
+ readonly removedFromDirectory: number;
120
+ readonly superseded: number;
121
+ }
122
+ /** Pure publication preparation. All directory members must have an explicit attempt. */
123
+ export declare function applyRefresh(previousInput: unknown, directoryInput: unknown, attemptsInput: unknown): {
124
+ dataset: AsxDataset;
125
+ report: RefreshReport;
126
+ };
@@ -0,0 +1,157 @@
1
+ import { Result, Schema } from "effect";
2
+ import { DirectoryEntry, failure, Failure, isTimestamp, LiveOutcome, Ticker, validObservation, } from "./model.js";
3
+ import { decodeDataset } from "./snapshot.js";
4
+ import { EnrichmentResult, validEnrichment } from "./enrichment.js";
5
+ import { CompanySources, mergeCompanySources } from "./source-record.js";
6
+ const DirectoryObservation = Schema.Struct({
7
+ entries: Schema.Array(DirectoryEntry),
8
+ observedAt: Schema.String,
9
+ });
10
+ export const RefreshAttempt = Schema.Struct({
11
+ ticker: Ticker,
12
+ outcome: Schema.Union([LiveOutcome, Failure]),
13
+ enrichment: Schema.optionalKey(Schema.Union([EnrichmentResult, Failure])),
14
+ sources: Schema.optionalKey(CompanySources),
15
+ });
16
+ /** Pure publication preparation. All directory members must have an explicit attempt. */
17
+ export function applyRefresh(previousInput, directoryInput, attemptsInput) {
18
+ const previous = previousInput === null ? null : decodeDataset(previousInput);
19
+ const directoryResult = Schema.decodeUnknownResult(DirectoryObservation)(directoryInput);
20
+ const attemptsResult = Schema.decodeUnknownResult(Schema.Array(RefreshAttempt))(attemptsInput);
21
+ if (Result.isFailure(directoryResult) || Result.isFailure(attemptsResult))
22
+ throw failure("PARSE_FAILED");
23
+ const directory = directoryResult.success;
24
+ if (!isTimestamp(directory.observedAt) ||
25
+ (previous && directory.observedAt < previous.directoryObservedAt))
26
+ throw failure("SOURCE_MISMATCH");
27
+ const attempts = new Map(attemptsResult.success.map((attempt) => [attempt.ticker, attempt]));
28
+ const directoryTickers = new Set(directory.entries.map((entry) => entry.ticker));
29
+ if (directory.entries.length === 0 ||
30
+ directory.entries.length > 20_000 ||
31
+ directoryTickers.size !== directory.entries.length ||
32
+ attempts.size !== attemptsResult.success.length ||
33
+ attempts.size !== directory.entries.length ||
34
+ [...attempts.keys()].some((ticker) => !directoryTickers.has(ticker)))
35
+ throw failure("PARSE_FAILED");
36
+ const old = new Map(previous?.records.map((record) => [record.directory.ticker, record]) ?? []);
37
+ const report = {
38
+ attempted: attempts.size,
39
+ found: 0,
40
+ missing: 0,
41
+ failed: 0,
42
+ retained: 0,
43
+ unavailable: 0,
44
+ removedFromDirectory: [...old.keys()].filter((ticker) => !directoryTickers.has(ticker)).length,
45
+ superseded: 0,
46
+ };
47
+ const records = directory.entries.map((entry) => {
48
+ const attempt = attempts.get(entry.ticker);
49
+ if (!attempt)
50
+ throw failure("PARSE_FAILED");
51
+ const { outcome } = attempt;
52
+ if (attempt.enrichment !== undefined) {
53
+ if ("_tag" in outcome || outcome.status !== "found")
54
+ throw failure("SOURCE_MISMATCH");
55
+ if (!("_tag" in attempt.enrichment) &&
56
+ attempt.enrichment.status === "confirmed" &&
57
+ !validEnrichment(attempt.enrichment.enrichment, outcome.observation.company))
58
+ throw failure("SOURCE_MISMATCH");
59
+ }
60
+ const prior = old.get(entry.ticker);
61
+ let acceptedProfile = false;
62
+ let observation = null;
63
+ let missingAt = prior?.missingAt;
64
+ if ("_tag" in outcome) {
65
+ report.failed += 1;
66
+ // A changed directory name cannot establish continuity of a reused ticker.
67
+ if (outcome.code !== "SOURCE_MISMATCH" &&
68
+ prior?.directory.companyName === entry.companyName &&
69
+ prior.observation) {
70
+ observation = prior.observation;
71
+ report.retained += 1;
72
+ }
73
+ }
74
+ else if (outcome.status === "missing") {
75
+ if (!isTimestamp(outcome.observedAt) ||
76
+ !isTimestamp(outcome.startedAt) ||
77
+ outcome.startedAt > outcome.observedAt)
78
+ throw failure("PARSE_FAILED");
79
+ report.missing += 1;
80
+ if (prior?.observation &&
81
+ outcome.startedAt < prior.observation.startedAt &&
82
+ prior.directory.companyName === entry.companyName) {
83
+ observation = prior.observation;
84
+ report.superseded += 1;
85
+ }
86
+ else if (!missingAt || outcome.startedAt >= missingAt.startedAt) {
87
+ missingAt = { startedAt: outcome.startedAt, observedAt: outcome.observedAt };
88
+ }
89
+ }
90
+ else {
91
+ if (!validObservation(outcome.observation) ||
92
+ outcome.observation.company.ticker !== entry.ticker)
93
+ throw failure("SOURCE_MISMATCH");
94
+ report.found += 1;
95
+ observation = outcome.observation;
96
+ acceptedProfile = true;
97
+ const oldObservation = prior?.observation;
98
+ if (missingAt && observation.startedAt <= missingAt.startedAt) {
99
+ observation = null;
100
+ acceptedProfile = false;
101
+ report.superseded += 1;
102
+ }
103
+ else if (oldObservation) {
104
+ missingAt = undefined;
105
+ if (observation.startedAt < oldObservation.startedAt) {
106
+ acceptedProfile = false;
107
+ observation = prior?.directory.companyName === entry.companyName ? oldObservation : null;
108
+ report.superseded += 1;
109
+ }
110
+ else if (observation.company.sourceId === oldObservation.company.sourceId &&
111
+ JSON.stringify(observation.company) === JSON.stringify(oldObservation.company)) {
112
+ observation = { ...observation, lastChangedAt: oldObservation.lastChangedAt };
113
+ }
114
+ }
115
+ else {
116
+ missingAt = undefined;
117
+ }
118
+ }
119
+ if (observation === null)
120
+ report.unavailable += 1;
121
+ let enrichment = observation && prior?.enrichment && validEnrichment(prior.enrichment, observation.company)
122
+ ? prior.enrichment
123
+ : undefined;
124
+ if (acceptedProfile && attempt.enrichment && !("_tag" in attempt.enrichment)) {
125
+ // A completed check can remove an automatic match; transport failure cannot.
126
+ enrichment =
127
+ attempt.enrichment.status === "confirmed" ? attempt.enrichment.enrichment : undefined;
128
+ }
129
+ return {
130
+ directory: entry,
131
+ observation,
132
+ ...(attempt.sources || prior?.sources
133
+ ? {
134
+ sources: mergeCompanySources(entry.ticker, prior?.directory.companyName === entry.companyName &&
135
+ (!observation ||
136
+ prior.observation?.company.sourceId === observation.company.sourceId)
137
+ ? (prior.sources ?? {})
138
+ : {}, attempt.sources ?? {}),
139
+ }
140
+ : {}),
141
+ ...(missingAt ? { missingAt } : {}),
142
+ ...(enrichment ? { enrichment } : {}),
143
+ // A name match belongs to the directory name it was made for (#385).
144
+ ...(prior?.abnNameMatch && prior.directory.companyName === entry.companyName
145
+ ? { abnNameMatch: prior.abnNameMatch }
146
+ : {}),
147
+ };
148
+ });
149
+ return {
150
+ dataset: decodeDataset({
151
+ formatVersion: 1,
152
+ directoryObservedAt: directory.observedAt,
153
+ records,
154
+ }),
155
+ report,
156
+ };
157
+ }
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ export declare const AsxLookupRequestSchema: Schema.Struct<{
3
+ readonly ticker: Schema.String;
4
+ readonly mode: Schema.optionalKey<Schema.Literals<readonly ["live", "snapshot", "live-first"]>>;
5
+ }>;
6
+ export declare const AsxSuggestRequestSchema: Schema.Struct<{
7
+ readonly mode: Schema.optionalKey<Schema.Literals<readonly ["live", "snapshot", "live-first"]>>;
8
+ readonly query: Schema.String;
9
+ readonly limit: Schema.optionalKey<Schema.Number>;
10
+ }>;
@@ -0,0 +1,21 @@
1
+ import { Schema } from "effect";
2
+ // Request bodies for the hosted ASX service (platform #515): the worker decodes with these,
3
+ // and the v1 contract is built from them.
4
+ const Mode = Schema.Literals(["live", "snapshot", "live-first"]).annotate({
5
+ description: "live: ASX now; snapshot: the stored directory; live-first: ASX, then the snapshot on failure.",
6
+ });
7
+ export const AsxLookupRequestSchema = Schema.Struct({
8
+ ticker: Schema.String.check(Schema.isMaxLength(128)).annotate({
9
+ description: "The ASX ticker, for example 'BHP'.",
10
+ }),
11
+ mode: Schema.optionalKey(Mode),
12
+ });
13
+ export const AsxSuggestRequestSchema = Schema.Struct({
14
+ mode: Schema.optionalKey(Mode),
15
+ query: Schema.String.check(Schema.isMaxLength(200)).annotate({
16
+ description: "A ticker or company name prefix.",
17
+ }),
18
+ limit: Schema.optionalKey(Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 10 })).annotate({
19
+ description: "How many suggestions to return, 1 to 10.",
20
+ })),
21
+ });
@@ -142,7 +142,9 @@ const prepare = (operation, input) => {
142
142
  /** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
143
143
  export const call = async (transport, operation, input, options = {}) => {
144
144
  const body = prepare(operation, input);
145
- const deadline = AbortSignal.timeout(options.deadlineMs ?? transport.deadlineMs ?? DEFAULT_DEADLINE_MS);
145
+ const deadlineMs = options.deadlineMs ?? transport.deadlineMs ?? DEFAULT_DEADLINE_MS;
146
+ const startedAt = performance.now();
147
+ const deadline = AbortSignal.timeout(deadlineMs);
146
148
  const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]);
147
149
  const maxRetries = operation.safeToRetry ? (transport.maxRetries ?? DEFAULT_MAX_RETRIES) : 0;
148
150
  const sleep = transport.sleep ?? defaultSleep;
@@ -155,6 +157,11 @@ export const call = async (transport, operation, input, options = {}) => {
155
157
  throw result.error;
156
158
  }
157
159
  const delay = result.retryAfterMs ?? Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
160
+ // A retry that cannot start within this call's budget must not replace a real
161
+ // service failure (notably 429) with a later timeout or violate Retry-After.
162
+ if (delay >= deadlineMs - (performance.now() - startedAt)) {
163
+ throw result.error;
164
+ }
158
165
  try {
159
166
  await sleep(delay, signal);
160
167
  }
@@ -0,0 +1,16 @@
1
+ import { type SuggestionsReply } from "./client-replies.js";
2
+ import type { LookupMode } from "./service.js";
3
+ type Source = (query: string, limit: number, signal: AbortSignal) => Promise<unknown>;
4
+ export interface AsxSearchSources {
5
+ readonly live?: Source;
6
+ readonly snapshot?: Source;
7
+ }
8
+ /** Read-only search composition. Live-first acquires saved evidence only after an eligible outage. */
9
+ export declare function createAsxSearchService(sources: AsxSearchSources): {
10
+ suggest: (query: string, options?: {
11
+ mode?: LookupMode;
12
+ limit?: number;
13
+ signal?: AbortSignal;
14
+ }) => Promise<SuggestionsReply>;
15
+ };
16
+ export {};