@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/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
+ }
@@ -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,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
+ }
@@ -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
+ }