@alpamayo-solutions/colca-client 0.0.0 → 0.2.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.
package/README.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # @alpamayo-solutions/colca-client
2
2
 
3
- The TypeScript client for [Colca](https://github.com/alpamayo-solutions/colca).
4
- This version only holds the name. The first real release is published from the
5
- repository's CI, with provenance.
3
+ A Colca node's door, in TypeScript: records over HTTP — `fetch`, `ack`,
4
+ `publish`, `kv`, `self` — and live values over MQTT. No runtime dependencies
5
+ for the door, no framework, no opinion about how your service is built.
6
+
7
+ ```sh
8
+ npm install @alpamayo-solutions/colca-client
9
+ ```
10
+
11
+ ## A consumer
12
+
13
+ ```ts
14
+ import { Door, Stream } from "@alpamayo-solutions/colca-client";
15
+
16
+ const door = new Door({ baseUrl: "http://colca", service: "my-app" });
17
+ const panels = new Stream(door, "annotations", door.cursorName("panels"), {
18
+ prefix: "wisewoods/line1",
19
+ });
20
+
21
+ for await (const record of panels.follow()) {
22
+ // Acked page by page: a handler that throws sees its page again.
23
+ console.log(record.topic, record.payload);
24
+ }
25
+ ```
26
+
27
+ ## A producer
28
+
29
+ ```ts
30
+ await door.publishTo(
31
+ { root: "steine", contract: "_Metric", node: "n-technikum", path: "wisewoods/line1/mas2/grit" },
32
+ { signal_id: "01M2AB…", timestamp: Date.now() / 1000, value: 60 },
33
+ );
34
+ ```
35
+
36
+ ## Live values
37
+
38
+ `@alpamayo-solutions/colca-client/live` keeps one MQTT connection to the node's
39
+ WebSocket door for people and applications, and hands out values as they change.
40
+
41
+ ```sh
42
+ npm install mqtt
43
+ ```
44
+
45
+ ```ts
46
+ import { Live } from "@alpamayo-solutions/colca-client/live";
47
+
48
+ const live = new Live({
49
+ url: "wss://node:8885",
50
+ // Asked before every connection, so hand back a token that is valid now.
51
+ token: () => auth.freshToken(),
52
+ });
53
+
54
+ const stop = live.subscribe("steine/v1/_Metric/n-technikum/wisewoods/#", (value) => {
55
+ show(value.topic, value.payload);
56
+ });
57
+ live.onState((state) => showOffline(state !== "online"));
58
+ ```
59
+
60
+ Data and entity paths are retained at the node, so a subscription starts with
61
+ the current values and continues with the changes; nothing has to be fetched
62
+ first. Around that the client does what a page left open all day needs:
63
+
64
+ - **One connection for the whole page.** `subscribe` returns the function that
65
+ ends that subscription and no other; the node's subscription goes when the
66
+ last listener on a filter does.
67
+ - **A second subscriber gets the value at once.** The client keeps the last value
68
+ of every topic, and `latest()` and `values()` read it.
69
+ - **A fresh token before the old one runs out.** The node ends a session when its
70
+ token expires. The client reconnects shortly before, with a new token and every
71
+ subscription sent again, and stays `online` while doing so.
72
+ - **Waits that grow after a drop**, jittered, each attempt with a fresh token.
73
+
74
+ These are values, not a log. A change during a reconnect is superseded by the
75
+ retained value that follows it. Whatever must see every record reads a stream
76
+ through the door.
77
+
78
+ ### Commands
79
+
80
+ A person's session sends commands, not values. `command()` sends one and waits
81
+ for the executor's `_Ack`:
82
+
83
+ ```ts
84
+ const ack = await live.command("steine/v1/_CmdParam/n-technikum/wisewoods/line1/mas2/sta1/aggos/setGrit", {
85
+ params: { signal: "grit", value: 120 },
86
+ });
87
+ if (ack.result_code !== 200) showRefusal(ack.message);
88
+ ```
89
+
90
+ It adds the correlation id and the expiry, subscribes to the acknowledgements
91
+ before it sends, so an executor that answers at once is not missed, and matches
92
+ the answer by its id wherever in the tree it arrives. The promise settles with
93
+ the `_Ack` whatever its result code, and rejects with `CommandTimeout` when
94
+ nobody answers in time (30 s by default, which is also the command's expiry).
95
+ Offline, nothing is queued: a setpoint sent minutes late is a different
96
+ setpoint.
97
+
98
+ `publish()` sends a single record without waiting, and `newUlid()` makes ids
99
+ that sort by the time they were made, as the node's own do.
100
+
101
+ ## Which door, which credential
102
+
103
+ | Door | URL | Credential |
104
+ | --------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
105
+ | local | `http://colca` (port 80, inside the deployment network) | none — reachability is the credential; `service` names the caller |
106
+ | published | `https://node:443` | a person's bearer token, or a machine's pinned client certificate |
107
+
108
+ A client certificate is a TLS matter, so it belongs to the runtime rather than
109
+ to this package: hand in a `fetch` that carries it (`options.fetch`).
110
+
111
+ ## Things the node insists on
112
+
113
+ - **Cursors belong to their caller.** They are named `c/{service}/…`, which is
114
+ what `door.cursorName()` builds; the node refuses any other name.
115
+ - **Fetching never moves a cursor.** Only `ack` does, and only forward. `tail`
116
+ reads the end of a stream without touching it, which is what a view wants.
117
+ - **A gap is not an error.** When records were pruned below the cursor, the page
118
+ says so. `Stream` passes it to `onGap` and acks past it, so it is reported
119
+ once rather than forever.
120
+ - **The node decides what you see.** `/fetch` and `/kv` filter by the caller's
121
+ read grants; a publish is judged against its zone and grants. A 403 carries
122
+ the node's reason.
123
+
124
+ ## Ids
125
+
126
+ `deriveAnnotationId()` computes an annotation's id the way the node's own
127
+ contracts package does. It is derived rather than drawn so that create, update
128
+ and delete of one annotation are appends under the same id — a producer that
129
+ runs twice overwrites its own record instead of doubling it.
130
+
131
+ The agreement is checked against `clients/spec/vectors.json`, generated from
132
+ `colca-data-contracts`. If a rule changes there, the tests here fail.
133
+
134
+ ## Development
135
+
136
+ ```sh
137
+ npm install
138
+ npm run lint
139
+ npm run typecheck
140
+ npm test
141
+ npm run build
142
+ ```
143
+
144
+ The wire format itself is documented in
145
+ [HTTP API](https://alpamayo-solutions.github.io/colca/http-api/).
package/dist/door.d.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The door's HTTP client: `GET /fetch`, `POST /ack`, `GET /kv`, `GET /self`
3
+ * and `POST /publish`. One class, no runtime dependencies — the platform's
4
+ * `fetch` does the work.
5
+ *
6
+ * Which door a client talks to is decided by the URL and the credential:
7
+ *
8
+ * - the local door (`http://colca`, port 80 inside the deployment network) has
9
+ * no credential. The caller names itself with `X-Colca-Service` and the door
10
+ * registers it on first sight;
11
+ * - the published door (`https://node:443`) wants a credential: a person's
12
+ * bearer token, or a machine's pinned client certificate — the latter is a
13
+ * TLS matter and therefore the runtime's, not this client's (see `agent`).
14
+ *
15
+ * Wire fields arrive in snake_case and are handed on in camelCase; the payload
16
+ * itself is passed through untouched, because decoding it into a contract type
17
+ * is the caller's business.
18
+ */
19
+ import { type TopicOptions } from "./topics.js";
20
+ export interface DoorRecord {
21
+ offset: number;
22
+ originOffset: number;
23
+ topic: string;
24
+ payload: unknown;
25
+ /** Colca's record timestamp, in unix **milliseconds** (payload times are seconds). */
26
+ ts: number;
27
+ writtenBy: string;
28
+ actorId: string;
29
+ actorLabel: string;
30
+ actorKind: string;
31
+ }
32
+ /**
33
+ * A pruned range. Present only when the cursor sits below the stream's
34
+ * low-water mark, and reported again until the consumer acks `toOffset`.
35
+ */
36
+ export interface Gap {
37
+ stream: string;
38
+ fromOffset: number;
39
+ toOffset: number;
40
+ firstTs?: number;
41
+ lastTs?: number;
42
+ approx: boolean;
43
+ }
44
+ export interface Page {
45
+ records: DoorRecord[];
46
+ /** Where a following read starts. Fetching never moves the cursor; acking does. */
47
+ next: number;
48
+ gap?: Gap;
49
+ }
50
+ export interface KvEntry {
51
+ path: string;
52
+ nodeId: string;
53
+ topic: string;
54
+ payload: unknown;
55
+ ts: number;
56
+ offset: number;
57
+ }
58
+ export interface SelfInfo {
59
+ ulid: string;
60
+ name: string;
61
+ node: string;
62
+ element: string;
63
+ mount: string;
64
+ limits: {
65
+ maxRecordBytes: number;
66
+ maxBlobBytes: number;
67
+ };
68
+ }
69
+ export interface PublishResult {
70
+ stream: string;
71
+ offset: number;
72
+ topic: string;
73
+ command?: unknown;
74
+ }
75
+ export interface FetchOptions {
76
+ /** Records per page, 1…1000. */
77
+ max?: number;
78
+ /** Narrows to a subtree of the UNS path — the part after the node, not the raw topic. */
79
+ prefix?: string;
80
+ /** Only for the `metrics` stream, at most 1000 ids. */
81
+ signalIds?: readonly string[];
82
+ /**
83
+ * Read the *end* of the stream instead of the cursor's position, without
84
+ * moving it. What a view wants when it opens: the last `max` records.
85
+ */
86
+ tail?: boolean;
87
+ signal?: AbortSignal;
88
+ }
89
+ /**
90
+ * Who a record is attributed to, when the publisher acts for someone else.
91
+ * A service that cannot forward a person's token names their groups instead
92
+ * and must say why — the node logs every such use.
93
+ */
94
+ export interface Attribution {
95
+ actorId?: string;
96
+ actorLabel?: string;
97
+ actorKind?: string;
98
+ actorGroups?: readonly string[];
99
+ fallbackReason?: string;
100
+ writtenBy?: string;
101
+ }
102
+ export interface DoorOptions {
103
+ /** The door's origin, e.g. `http://colca` or `https://node:443`. */
104
+ baseUrl: string;
105
+ /** This caller's name on the local door. Also owns the `c/{service}/…` cursors. */
106
+ service?: string;
107
+ /** A person's token for the published door. */
108
+ token?: string;
109
+ /** The node's admin token (`X-Colca-Token`), for administrative callers. */
110
+ adminToken?: string;
111
+ timeoutMs?: number;
112
+ /** For tests, proxies, or a Node `Agent` that carries a client certificate. */
113
+ fetch?: typeof globalThis.fetch;
114
+ }
115
+ /** A non-2xx answer from the door, with the node's own words kept. */
116
+ export declare class DoorError extends Error {
117
+ readonly status: number;
118
+ readonly route: string;
119
+ /** The node's denial reason, on a 403. */
120
+ readonly reason?: string | undefined;
121
+ constructor(status: number, route: string, message: string,
122
+ /** The node's denial reason, on a 403. */
123
+ reason?: string | undefined);
124
+ }
125
+ export declare class Door {
126
+ #private;
127
+ readonly service?: string;
128
+ constructor(options: DoorOptions);
129
+ /** The cursor namespace this caller owns. The door refuses any other. */
130
+ cursorName(suffix: string): string;
131
+ /** `GET /fetch` — read forward from the cursor's stored position. */
132
+ fetchPage(stream: string, cursor: string, options?: FetchOptions): Promise<Page>;
133
+ /**
134
+ * `POST /ack` — ack the last **processed** offset. Monotonic: the door never
135
+ * moves a cursor backwards, and answers whether this one moved.
136
+ */
137
+ ack(stream: string, cursor: string, offset: number): Promise<boolean>;
138
+ /** `POST /ack` with `delete` — retire a cursor. Fine when it never existed. */
139
+ deleteCursor(stream: string, cursor: string): Promise<void>;
140
+ /** `POST /publish` — one record under this caller's identity. */
141
+ publish(topic: string, payload: unknown, attribution?: Attribution): Promise<PublishResult>;
142
+ /** The same, with the topic built from its parts. */
143
+ publishTo(parts: TopicOptions, payload: unknown, attribution?: Attribution): Promise<PublishResult>;
144
+ /**
145
+ * `GET /kv` — the retained entries under `prefix`, every page followed.
146
+ * `contract` narrows the scan at the node, before payloads are decoded.
147
+ */
148
+ kv(prefix?: string, options?: {
149
+ contract?: string | readonly string[];
150
+ signal?: AbortSignal;
151
+ }): Promise<KvEntry[]>;
152
+ /** `GET /self` — this caller's minted identity and the limits it must respect. */
153
+ self(): Promise<SelfInfo>;
154
+ }
155
+ //# sourceMappingURL=door.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door.d.ts","sourceRoot":"","sources":["../src/door.ts"],"names":[],"mappings":"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"}
package/dist/door.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The door's HTTP client: `GET /fetch`, `POST /ack`, `GET /kv`, `GET /self`
3
+ * and `POST /publish`. One class, no runtime dependencies — the platform's
4
+ * `fetch` does the work.
5
+ *
6
+ * Which door a client talks to is decided by the URL and the credential:
7
+ *
8
+ * - the local door (`http://colca`, port 80 inside the deployment network) has
9
+ * no credential. The caller names itself with `X-Colca-Service` and the door
10
+ * registers it on first sight;
11
+ * - the published door (`https://node:443`) wants a credential: a person's
12
+ * bearer token, or a machine's pinned client certificate — the latter is a
13
+ * TLS matter and therefore the runtime's, not this client's (see `agent`).
14
+ *
15
+ * Wire fields arrive in snake_case and are handed on in camelCase; the payload
16
+ * itself is passed through untouched, because decoding it into a contract type
17
+ * is the caller's business.
18
+ */
19
+ import { topic as buildTopic } from "./topics.js";
20
+ /** A non-2xx answer from the door, with the node's own words kept. */
21
+ export class DoorError extends Error {
22
+ status;
23
+ route;
24
+ reason;
25
+ constructor(status, route, message,
26
+ /** The node's denial reason, on a 403. */
27
+ reason) {
28
+ super(`${route}: ${String(status)} ${message}`);
29
+ this.status = status;
30
+ this.route = route;
31
+ this.reason = reason;
32
+ this.name = "DoorError";
33
+ }
34
+ }
35
+ export class Door {
36
+ #base;
37
+ #headers;
38
+ #timeout;
39
+ #fetch;
40
+ service;
41
+ constructor(options) {
42
+ this.#base = options.baseUrl.replace(/\/+$/, "");
43
+ this.service = options.service;
44
+ this.#timeout = options.timeoutMs ?? 10_000;
45
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
46
+ this.#headers = { "content-type": "application/json" };
47
+ if (options.service)
48
+ this.#headers["x-colca-service"] = options.service;
49
+ if (options.token)
50
+ this.#headers.authorization = `Bearer ${options.token}`;
51
+ if (options.adminToken)
52
+ this.#headers["x-colca-token"] = options.adminToken;
53
+ }
54
+ /** The cursor namespace this caller owns. The door refuses any other. */
55
+ cursorName(suffix) {
56
+ if (!this.service) {
57
+ throw new Error("cursorName() needs the service name the cursors are namespaced by");
58
+ }
59
+ return `c/${this.service}/${suffix}`;
60
+ }
61
+ /** `GET /fetch` — read forward from the cursor's stored position. */
62
+ async fetchPage(stream, cursor, options = {}) {
63
+ const query = new URLSearchParams({ stream, cursor });
64
+ if (options.max !== undefined)
65
+ query.set("max", String(options.max));
66
+ if (options.prefix)
67
+ query.set("prefix", options.prefix);
68
+ if (options.tail)
69
+ query.set("tail", "1");
70
+ for (const id of options.signalIds ?? [])
71
+ query.append("signal_id", id);
72
+ const body = await this.#call("GET", `/fetch?${query.toString()}`, undefined, options.signal);
73
+ return {
74
+ records: (body.records ?? []).map(toRecord),
75
+ next: body.next,
76
+ gap: body.gap ? toGap(body.gap) : undefined,
77
+ };
78
+ }
79
+ /**
80
+ * `POST /ack` — ack the last **processed** offset. Monotonic: the door never
81
+ * moves a cursor backwards, and answers whether this one moved.
82
+ */
83
+ async ack(stream, cursor, offset) {
84
+ const body = await this.#call("POST", "/ack", { cursor, stream, offset });
85
+ return body.moved;
86
+ }
87
+ /** `POST /ack` with `delete` — retire a cursor. Fine when it never existed. */
88
+ async deleteCursor(stream, cursor) {
89
+ await this.#call("POST", "/ack", { cursor, stream, delete: true });
90
+ }
91
+ /** `POST /publish` — one record under this caller's identity. */
92
+ async publish(topic, payload, attribution = {}) {
93
+ return this.#call("POST", "/publish", {
94
+ topic,
95
+ payload,
96
+ written_by: attribution.writtenBy,
97
+ actor_id: attribution.actorId,
98
+ actor_label: attribution.actorLabel,
99
+ actor_kind: attribution.actorKind,
100
+ actor_groups: attribution.actorGroups,
101
+ fallback_reason: attribution.fallbackReason,
102
+ });
103
+ }
104
+ /** The same, with the topic built from its parts. */
105
+ async publishTo(parts, payload, attribution = {}) {
106
+ return this.publish(buildTopic(parts), payload, attribution);
107
+ }
108
+ /**
109
+ * `GET /kv` — the retained entries under `prefix`, every page followed.
110
+ * `contract` narrows the scan at the node, before payloads are decoded.
111
+ */
112
+ async kv(prefix = "", options = {}) {
113
+ const contracts = typeof options.contract === "string" ? [options.contract] : (options.contract ?? []);
114
+ const entries = [];
115
+ let after = "";
116
+ for (;;) {
117
+ const query = new URLSearchParams({ prefix, max: "10000" });
118
+ for (const name of contracts)
119
+ query.append("contract", name);
120
+ if (after)
121
+ query.set("after", after);
122
+ const body = await this.#call("GET", `/kv?${query.toString()}`, undefined, options.signal);
123
+ for (const entry of body.entries ?? []) {
124
+ entries.push({
125
+ path: entry.path,
126
+ nodeId: entry.node_id,
127
+ topic: entry.topic,
128
+ payload: entry.payload,
129
+ ts: entry.ts,
130
+ offset: entry.offset,
131
+ });
132
+ }
133
+ const next = body.next ?? "";
134
+ if (!next)
135
+ return entries;
136
+ if (next === after)
137
+ throw new Error("GET /kv: the node repeated a page token");
138
+ after = next;
139
+ }
140
+ }
141
+ /** `GET /self` — this caller's minted identity and the limits it must respect. */
142
+ async self() {
143
+ const body = await this.#call("GET", "/self");
144
+ return {
145
+ ulid: body.ulid,
146
+ name: body.name,
147
+ node: body.node,
148
+ element: body.element,
149
+ mount: body.mount,
150
+ limits: {
151
+ maxRecordBytes: body.limits.max_record_bytes,
152
+ maxBlobBytes: body.limits.max_blob_bytes,
153
+ },
154
+ };
155
+ }
156
+ async #call(method, route, body, signal) {
157
+ // One timeout per call, dropped again as soon as the answer is in, so a
158
+ // long-lived client does not collect timers.
159
+ const timer = AbortSignal.timeout(this.#timeout);
160
+ const response = await this.#fetch(`${this.#base}${route}`, {
161
+ method,
162
+ headers: this.#headers,
163
+ body: body === undefined ? undefined : JSON.stringify(stripUndefined(body)),
164
+ signal: signal ? AbortSignal.any([signal, timer]) : timer,
165
+ });
166
+ const text = await response.text();
167
+ const parsed = text ? safeJson(text) : undefined;
168
+ if (!response.ok) {
169
+ const error = parsed;
170
+ throw new DoorError(response.status, route.split("?")[0], error?.error ?? text, error?.reason);
171
+ }
172
+ return parsed;
173
+ }
174
+ }
175
+ function toRecord(r) {
176
+ return {
177
+ offset: r.offset,
178
+ originOffset: r.origin_offset,
179
+ topic: r.topic,
180
+ payload: r.payload,
181
+ ts: r.ts,
182
+ writtenBy: r.written_by ?? "",
183
+ actorId: r.actor_id ?? "",
184
+ actorLabel: r.actor_label ?? "",
185
+ actorKind: r.actor_kind ?? "",
186
+ };
187
+ }
188
+ function toGap(g) {
189
+ return {
190
+ stream: g.stream,
191
+ fromOffset: g.from_offset,
192
+ toOffset: g.to_offset,
193
+ firstTs: g.first_ts,
194
+ lastTs: g.last_ts,
195
+ approx: g.approx ?? false,
196
+ };
197
+ }
198
+ /** Attribution fields left unset must not reach the node as nulls. */
199
+ function stripUndefined(value) {
200
+ if (value === null || typeof value !== "object")
201
+ return value;
202
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
203
+ }
204
+ function safeJson(text) {
205
+ try {
206
+ return JSON.parse(text);
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ //# sourceMappingURL=door.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door.js","sourceRoot":"","sources":["../src/door.ts"],"names":[],"mappings":"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"}