@loams/client 0.0.0-stage → 0.1.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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +137 -2
  3. package/dist/gen/facade.d.ts +446 -0
  4. package/dist/gen/facade.js +441 -0
  5. package/dist/index.d.ts +12 -0
  6. package/dist/index.js +32 -0
  7. package/dist/loams.d.ts +110 -0
  8. package/dist/loams.js +203 -0
  9. package/dist/node.d.ts +12 -0
  10. package/dist/node.js +43 -0
  11. package/dist/runtime/call.d.ts +132 -0
  12. package/dist/runtime/call.js +273 -0
  13. package/dist/runtime/consistency.d.ts +22 -0
  14. package/dist/runtime/consistency.js +68 -0
  15. package/dist/runtime/errors.d.ts +100 -0
  16. package/dist/runtime/errors.js +165 -0
  17. package/dist/runtime/pagination.d.ts +25 -0
  18. package/dist/runtime/pagination.js +47 -0
  19. package/dist/runtime/retry.d.ts +30 -0
  20. package/dist/runtime/retry.js +75 -0
  21. package/dist/runtime/streams.d.ts +30 -0
  22. package/dist/runtime/streams.js +61 -0
  23. package/dist/runtime/token-source.d.ts +57 -0
  24. package/dist/runtime/token-source.js +117 -0
  25. package/dist/runtime/transports.d.ts +58 -0
  26. package/dist/runtime/transports.js +66 -0
  27. package/dist/runtime/uuidv7.d.ts +10 -0
  28. package/dist/runtime/uuidv7.js +57 -0
  29. package/dist/system.d.ts +87 -0
  30. package/dist/system.js +152 -0
  31. package/dist/vendor/proto/admin.d.ts +6 -0
  32. package/dist/vendor/proto/admin.js +12 -0
  33. package/dist/vendor/proto/gen/loams/admin/v1/agent_pb.d.ts +902 -0
  34. package/dist/vendor/proto/gen/loams/admin/v1/agent_pb.js +215 -0
  35. package/dist/vendor/proto/gen/loams/admin/v1/audit_pb.d.ts +289 -0
  36. package/dist/vendor/proto/gen/loams/admin/v1/audit_pb.js +76 -0
  37. package/dist/vendor/proto/gen/loams/admin/v1/common_pb.d.ts +203 -0
  38. package/dist/vendor/proto/gen/loams/admin/v1/common_pb.js +118 -0
  39. package/dist/vendor/proto/gen/loams/admin/v1/key_pb.d.ts +284 -0
  40. package/dist/vendor/proto/gen/loams/admin/v1/key_pb.js +62 -0
  41. package/dist/vendor/proto/gen/loams/admin/v1/org_pb.d.ts +634 -0
  42. package/dist/vendor/proto/gen/loams/admin/v1/org_pb.js +142 -0
  43. package/dist/vendor/proto/gen/loams/admin/v1/project_pb.d.ts +662 -0
  44. package/dist/vendor/proto/gen/loams/admin/v1/project_pb.js +132 -0
  45. package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.d.ts +435 -0
  46. package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.js +156 -0
  47. package/dist/vendor/proto/gen/loams/errors/v1/errors_pb.d.ts +45 -0
  48. package/dist/vendor/proto/gen/loams/errors/v1/errors_pb.js +17 -0
  49. package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.d.ts +501 -0
  50. package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.js +170 -0
  51. package/dist/vendor/proto/gen/loams/options/v1/options_pb.d.ts +97 -0
  52. package/dist/vendor/proto/gen/loams/options/v1/options_pb.js +47 -0
  53. package/package.json +49 -4
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Where a call's bearer comes from.
3
+ *
4
+ * `token()` is called once per attempt, so a source may return a different
5
+ * token each time. `refresh()` is called only after the server said the token
6
+ * expired; a source without one is a source whose token does not expire
7
+ * (an API key), and the runtime then does not retry.
8
+ */
9
+ export interface TokenSource {
10
+ /** The bearer to send, or undefined to send no credential at all. */
11
+ token(): Promise<string | undefined>;
12
+ /** Fetch a new token after the server reported the current one expired. */
13
+ refresh?(): Promise<void>;
14
+ }
15
+ /** A Loams API key. The key does not expire, so there is nothing to refresh. */
16
+ export declare function apiKey(key: string): TokenSource;
17
+ /** A token that is already valid, for a caller that manages its own. */
18
+ export declare function staticToken(token: string): TokenSource;
19
+ /**
20
+ * `LOAMS_API_KEY`, then `LOAMS_TOKEN`, then nothing.
21
+ *
22
+ * The environment is read on every call rather than once at construction, so a
23
+ * process that receives its credentials after the client is built (a
24
+ * sidecar, a test) still authenticates.
25
+ */
26
+ export declare function envToken(environment?: Record<string, string | undefined>): TokenSource;
27
+ /**
28
+ * A source that caches a token and calls `fetch` when it is asked to refresh.
29
+ *
30
+ * This is the shape every refreshing source has: one in-flight refresh shared
31
+ * by concurrent callers, so a burst of `401`s produces one token exchange
32
+ * rather than one per request.
33
+ */
34
+ export declare function refreshing(fetchToken: () => Promise<string>): TokenSource;
35
+ /** The RFC 8693 token exchange a person signed in through Authentik needs
36
+ * (design §44 §7.4, D608; §19 §5.2).
37
+ *
38
+ * The instance's `/oauth/token` protocol endpoint takes the identity token and
39
+ * answers with a Loams access token, which is then cached until it expires.
40
+ *
41
+ * Not covered by the conformance suite: the instance serves no OAuth endpoint
42
+ * yet (the auth plan, MT, and API1 Task 7 build it), so this path is written
43
+ * to the documented request and response and cannot be exercised against a
44
+ * live server. `typescript_token_source_refresh` covers the caching and
45
+ * refresh-once-and-retry behaviour that `refreshing()` implements, which is the
46
+ * part the SDK owns.
47
+ */
48
+ export declare function oidcExchange(options: {
49
+ /** The instance's `/oauth/token` endpoint. */
50
+ endpoint: string;
51
+ /** The public OAuth client id (§44 §7.4: the gateway exchanges the token). */
52
+ clientId: string;
53
+ /** Mints the current identity token, from the host's OIDC session. */
54
+ subjectToken: () => Promise<string>;
55
+ /** Injected for tests and for hosts with their own `fetch`. */
56
+ fetch?: typeof globalThis.fetch;
57
+ }): TokenSource;
@@ -0,0 +1,117 @@
1
+ // Token sources (design §44 §7.4, D608; runtime contract R1).
2
+ //
3
+ // A `TokenSource` returns a bearer and can be asked for a new one. Tokens
4
+ // travel in `Authorization: Bearer` and never in the query string. A `401`
5
+ // carrying `reason = token_expired` triggers one refresh and one retry; that
6
+ // logic lives in the call path, not here, so a source stays a source.
7
+ /** A Loams API key. The key does not expire, so there is nothing to refresh. */
8
+ export function apiKey(key) {
9
+ if (key === '') {
10
+ throw new Error('apiKey: the key is empty');
11
+ }
12
+ return { token: () => Promise.resolve(key) };
13
+ }
14
+ /** A token that is already valid, for a caller that manages its own. */
15
+ export function staticToken(token) {
16
+ if (token === '') {
17
+ throw new Error('staticToken: the token is empty');
18
+ }
19
+ return { token: () => Promise.resolve(token) };
20
+ }
21
+ /**
22
+ * `LOAMS_API_KEY`, then `LOAMS_TOKEN`, then nothing.
23
+ *
24
+ * The environment is read on every call rather than once at construction, so a
25
+ * process that receives its credentials after the client is built (a
26
+ * sidecar, a test) still authenticates.
27
+ */
28
+ export function envToken(environment = processEnvironment()) {
29
+ return {
30
+ token: () => Promise.resolve(environment.LOAMS_API_KEY ?? environment.LOAMS_TOKEN ?? undefined),
31
+ };
32
+ }
33
+ /**
34
+ * A source that caches a token and calls `fetch` when it is asked to refresh.
35
+ *
36
+ * This is the shape every refreshing source has: one in-flight refresh shared
37
+ * by concurrent callers, so a burst of `401`s produces one token exchange
38
+ * rather than one per request.
39
+ */
40
+ export function refreshing(fetchToken) {
41
+ let cached;
42
+ let inFlight;
43
+ const source = {
44
+ // The first `token()` fetches: a source whose cache starts empty would send
45
+ // no credential at all, and an instance that requires one answers
46
+ // `unauthenticated`, which the call path treats as "the token expired" and
47
+ // retries — with still no credential.
48
+ token: () => {
49
+ if (cached !== undefined) {
50
+ return Promise.resolve(cached);
51
+ }
52
+ return (source.refresh?.() ?? Promise.resolve()).then(() => cached);
53
+ },
54
+ refresh: () => {
55
+ inFlight ??= fetchToken()
56
+ .then((next) => {
57
+ cached = next;
58
+ })
59
+ .finally(() => {
60
+ inFlight = undefined;
61
+ });
62
+ return inFlight;
63
+ },
64
+ };
65
+ return source;
66
+ }
67
+ /** The RFC 8693 token exchange a person signed in through Authentik needs
68
+ * (design §44 §7.4, D608; §19 §5.2).
69
+ *
70
+ * The instance's `/oauth/token` protocol endpoint takes the identity token and
71
+ * answers with a Loams access token, which is then cached until it expires.
72
+ *
73
+ * Not covered by the conformance suite: the instance serves no OAuth endpoint
74
+ * yet (the auth plan, MT, and API1 Task 7 build it), so this path is written
75
+ * to the documented request and response and cannot be exercised against a
76
+ * live server. `typescript_token_source_refresh` covers the caching and
77
+ * refresh-once-and-retry behaviour that `refreshing()` implements, which is the
78
+ * part the SDK owns.
79
+ */
80
+ export function oidcExchange(options) {
81
+ const doFetch = options.fetch ?? globalThis.fetch;
82
+ const inner = refreshing(async () => {
83
+ const response = await doFetch(options.endpoint, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
86
+ body: new URLSearchParams({
87
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
88
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
89
+ requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
90
+ subject_token: await options.subjectToken(),
91
+ client_id: options.clientId,
92
+ }).toString(),
93
+ });
94
+ if (!response.ok) {
95
+ throw new Error(`the token exchange answered ${response.status} ${response.statusText}`);
96
+ }
97
+ const body = (await response.json());
98
+ if (body.access_token === undefined) {
99
+ throw new Error('the token exchange answered no access_token');
100
+ }
101
+ return body.access_token;
102
+ });
103
+ return {
104
+ token: () => inner.token(),
105
+ refresh: async () => {
106
+ await inner.refresh?.();
107
+ },
108
+ };
109
+ }
110
+ /** `process.env` where there is one. In a browser there is not, and an
111
+ * `envToken()` there resolves to no credential rather than throwing at import
112
+ * time. */
113
+ function processEnvironment() {
114
+ const candidate = globalThis
115
+ .process;
116
+ return candidate?.env ?? {};
117
+ }
@@ -0,0 +1,58 @@
1
+ import { createConnectTransport } from '@connectrpc/connect-web';
2
+ import type { Interceptor, Transport } from '@connectrpc/connect';
3
+ /** What a caller may configure on the transport. */
4
+ export interface TransportOptions {
5
+ /**
6
+ * The instance's base URL. `Loams` sets this per call from its own
7
+ * `endpoint`, so a caller building a transport by hand rarely needs it.
8
+ */
9
+ baseUrl?: string;
10
+ /**
11
+ * Extra headers on every request, unary and streaming: for a gateway, a
12
+ * proxy, or a routing header such as the Live app a call is for. They are
13
+ * attached by an interceptor (`headersInterceptor`), because neither
14
+ * connect-web's nor connect-node's transport has a `headers` option; a
15
+ * header the call already sets (a credential, a per-call header) wins.
16
+ */
17
+ headers?: Record<string, string>;
18
+ /**
19
+ * `useBinaryFormat` picks the encoding: `true` is protobuf, `false` is the
20
+ * proto3 JSON mapping. Default `true`. JSON is what `curl` sends
21
+ * (design §44 §4) and what a proxy that logs bodies can read; binary is
22
+ * smaller and does not need a JSON codec on the wire.
23
+ */
24
+ useBinaryFormat?: boolean;
25
+ /**
26
+ * The deadline for a call that does not set its own, passed to the
27
+ * transport as `defaultTimeoutMs`. A per-call `timeoutMs` still wins.
28
+ */
29
+ defaultTimeoutMs?: number;
30
+ /**
31
+ * CORS preflights for the browser: custom headers and the credential mode.
32
+ * A browser sending `Authorization` needs the server's CORS policy to allow
33
+ * it; this only tells the transport what the request will look like.
34
+ */
35
+ interceptors?: Parameters<typeof createConnectTransport>[0]['interceptors'];
36
+ }
37
+ /**
38
+ * The default transport: Connect over `fetch`.
39
+ *
40
+ * Works in a browser, in Node 18+ (which has `fetch`), in Deno and in Bun, and
41
+ * speaks both the Connect unary protocol and gRPC-Web, which is what the server
42
+ * serves on the same port (D600). This is the browser path as a first-class
43
+ * case, not a fallback: it is the default in every runtime.
44
+ */
45
+ export declare function createLoamsTransport(options?: TransportOptions): Transport;
46
+ /**
47
+ * An interceptor that sets `headers` on every request, unary and streaming.
48
+ *
49
+ * A header already on the request is left alone, so a per-call header or the
50
+ * credential the invoker attaches is never overwritten by a static default.
51
+ */
52
+ export declare function headersInterceptor(headers: Record<string, string>): Interceptor;
53
+ /**
54
+ * The interceptors a transport built from `options` runs: the static headers
55
+ * first (outermost), then the caller's own, so a caller's interceptor sees the
56
+ * headers and may still change them.
57
+ */
58
+ export declare function transportInterceptors(options: TransportOptions): Interceptor[];
@@ -0,0 +1,66 @@
1
+ // Transports (design §44 §4, D612).
2
+ //
3
+ // One port serves the Connect protocol, gRPC and gRPC-Web (D600), so the only
4
+ // question a client has is which of those it can speak:
5
+ //
6
+ // - **Browsers** cannot do HTTP/2 gRPC. They speak gRPC-Web and Connect, both
7
+ // over `fetch`, which is what `@connectrpc/connect-web` implements. Because
8
+ // the API serves gRPC-Web on the same port as everything else, the same
9
+ // client code runs in a browser with no proxy and no sidecar.
10
+ // - **Node, Deno and Bun** can do HTTP/2 gRPC. `@connectrpc/connect-node`
11
+ // implements it over `node:http2`, and the server's plaintext listener means
12
+ // no ALPN to negotiate — the client asks for HTTP/2 with prior knowledge.
13
+ //
14
+ // The default is the `fetch` transport, deliberately: it is the one case where
15
+ // the *same* code path works everywhere, so it is what an application gets
16
+ // unless it asks for the Node one through `@loams/client/node`. Choosing the
17
+ // HTTP/2 transport in a browser bundle would be a build error, which is the
18
+ // right failure.
19
+ import { createConnectTransport } from '@connectrpc/connect-web';
20
+ /**
21
+ * The default transport: Connect over `fetch`.
22
+ *
23
+ * Works in a browser, in Node 18+ (which has `fetch`), in Deno and in Bun, and
24
+ * speaks both the Connect unary protocol and gRPC-Web, which is what the server
25
+ * serves on the same port (D600). This is the browser path as a first-class
26
+ * case, not a fallback: it is the default in every runtime.
27
+ */
28
+ export function createLoamsTransport(options = {}) {
29
+ const interceptors = transportInterceptors(options);
30
+ return createConnectTransport({
31
+ baseUrl: options.baseUrl ?? '',
32
+ useBinaryFormat: options.useBinaryFormat ?? true,
33
+ ...(options.defaultTimeoutMs === undefined
34
+ ? {}
35
+ : { defaultTimeoutMs: options.defaultTimeoutMs }),
36
+ ...(interceptors.length === 0 ? {} : { interceptors }),
37
+ });
38
+ }
39
+ /**
40
+ * An interceptor that sets `headers` on every request, unary and streaming.
41
+ *
42
+ * A header already on the request is left alone, so a per-call header or the
43
+ * credential the invoker attaches is never overwritten by a static default.
44
+ */
45
+ export function headersInterceptor(headers) {
46
+ const entries = Object.entries(headers);
47
+ return (next) => (request) => {
48
+ for (const [name, value] of entries) {
49
+ if (!request.header.has(name)) {
50
+ request.header.set(name, value);
51
+ }
52
+ }
53
+ return next(request);
54
+ };
55
+ }
56
+ /**
57
+ * The interceptors a transport built from `options` runs: the static headers
58
+ * first (outermost), then the caller's own, so a caller's interceptor sees the
59
+ * headers and may still change them.
60
+ */
61
+ export function transportInterceptors(options) {
62
+ const headers = options.headers === undefined || Object.keys(options.headers).length === 0
63
+ ? []
64
+ : [headersInterceptor(options.headers)];
65
+ return [...headers, ...(options.interceptors ?? [])];
66
+ }
@@ -0,0 +1,10 @@
1
+ /** A fresh UUIDv7 as a canonical string. */
2
+ export declare function uuidv7(): string;
3
+ /**
4
+ * The Unix milliseconds a UUIDv7 encodes, or undefined for anything else.
5
+ *
6
+ * The timestamp is the first **twelve** hex digits, not eight: 48 bits, and
7
+ * milliseconds since the epoch use 41 of them. Reading eight digits would
8
+ * return a number around 2^25, which is January 1970.
9
+ */
10
+ export declare function uuidv7Time(value: string): number | undefined;
@@ -0,0 +1,57 @@
1
+ // UUIDv7 (design §44 §7.4, D610).
2
+ //
3
+ // An idempotency key has to be unique across every client that has ever talked
4
+ // to an instance and sort by creation time, because a key that sorts is one
5
+ // an operator can correlate in a log. UUIDv4 is unique but unordered; ULIDs
6
+ // would do, but the SDKs across thirteen languages would each need their own
7
+ // implementation, and `crypto.randomUUID` is in every runtime this SDK targets.
8
+ //
9
+ // Layout: 48 bits of Unix milliseconds, 4 bits of version (7), 12 bits of
10
+ // counter within the millisecond, 2 bits of variant, 62 random bits.
11
+ const HEX = [...'0123456789abcdef'];
12
+ function randomBytes(length) {
13
+ const bytes = new Uint8Array(length);
14
+ globalThis.crypto.getRandomValues(bytes);
15
+ return bytes;
16
+ }
17
+ /** A fresh UUIDv7 as a canonical string. */
18
+ export function uuidv7() {
19
+ const random = randomBytes(16);
20
+ // The 48-bit timestamp is big-endian, so it is read out with shifts rather
21
+ // than by dividing and masking: dividing keeps the fractional bits of the
22
+ // lower digits and truncates the carry, which puts the wrong byte in.
23
+ const now = BigInt(Date.now());
24
+ for (let index = 0; index < 6; index += 1) {
25
+ random[index] = Number((now >> BigInt((5 - index) * 8)) & 0xffn);
26
+ }
27
+ const version = (random[6] ?? 0) & 0x0f;
28
+ const variant = (random[8] ?? 0) & 0x3f;
29
+ random[6] = version | 0x70;
30
+ random[8] = variant | 0x80;
31
+ let out = '';
32
+ for (let index = 0; index < 16; index += 1) {
33
+ if (index === 4 || index === 6 || index === 8 || index === 10) {
34
+ out += '-';
35
+ }
36
+ const byte = random[index] ?? 0;
37
+ out += `${HEX[byte >> 4]}${HEX[byte & 0x0f]}`;
38
+ }
39
+ return out;
40
+ }
41
+ /**
42
+ * The Unix milliseconds a UUIDv7 encodes, or undefined for anything else.
43
+ *
44
+ * The timestamp is the first **twelve** hex digits, not eight: 48 bits, and
45
+ * milliseconds since the epoch use 41 of them. Reading eight digits would
46
+ * return a number around 2^25, which is January 1970.
47
+ */
48
+ export function uuidv7Time(value) {
49
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
50
+ return undefined;
51
+ }
52
+ let time = 0;
53
+ for (const character of value.replaceAll('-', '').slice(0, 12)) {
54
+ time = time * 16 + Number.parseInt(character, 16);
55
+ }
56
+ return time;
57
+ }
@@ -0,0 +1,87 @@
1
+ import type { GetInstanceResponse, ServiceStatus } from './vendor/proto/gen/loams/instance/v1/instance_pb.js';
2
+ import type { CallOptions } from './runtime/call.js';
3
+ /** `GetInstance.services[]`, sorted by what an SDK can use. */
4
+ export interface Catalogue {
5
+ /** Every package the instance knows, served or not. */
6
+ readonly services: readonly ServiceStatus[];
7
+ /** The packages this binary serves. */
8
+ readonly served: readonly string[];
9
+ /** The packages this binary knows but does not serve, which is what makes
10
+ * `unavailable` a plan rather than a surprise. */
11
+ readonly unavailable: readonly string[];
12
+ }
13
+ /** What `loams.version()` reports (runtime contract R9). */
14
+ export interface VersionReport {
15
+ /** The proto revision this SDK was generated from. */
16
+ readonly protoRev: string;
17
+ /** The server's own semver. */
18
+ readonly serverVersion: string;
19
+ /** The proto packages the server says it serves. */
20
+ readonly apiVersions: readonly string[];
21
+ /** The SDK's packages the server does not serve. */
22
+ readonly missing: readonly string[];
23
+ /** True when every package the SDK speaks is served. */
24
+ readonly compatible: boolean;
25
+ }
26
+ /** What a `Loams` hands to its `system`. */
27
+ export interface SystemConfig {
28
+ /** The instance's URL, for error messages and for a caller reading `endpoint`. */
29
+ readonly endpoint: string;
30
+ readonly maxRetries?: number;
31
+ /** The client's deadline for the catalogue call. */
32
+ readonly timeoutMs?: number;
33
+ }
34
+ /** The catalogue, the version check and the guard. */
35
+ export declare class SystemApi {
36
+ #private;
37
+ /** `loams.instance.getInstance`, which is the one RPC the system API uses.
38
+ * Injected rather than reached for, so the system API is testable without
39
+ * a client. */
40
+ private readonly getInstance;
41
+ private readonly config;
42
+ constructor(
43
+ /** `loams.instance.getInstance`, which is the one RPC the system API uses.
44
+ * Injected rather than reached for, so the system API is testable without
45
+ * a client. */
46
+ getInstance: (request: object, options?: CallOptions) => Promise<GetInstanceResponse>, config: SystemConfig);
47
+ /**
48
+ * The service catalogue, cached.
49
+ *
50
+ * Cached because it is asked on a cold start, when a UI is deciding which
51
+ * features to show, and again on every feature check: the instance's services
52
+ * do not change while a process runs. `invalidate()` drops it.
53
+ */
54
+ catalogue(): Promise<Catalogue>;
55
+ private fetch;
56
+ /** Drops the cached catalogue. */
57
+ invalidate(): void;
58
+ /** The client's retry and deadline settings, applied to the catalogue call. */
59
+ private callOptions;
60
+ /**
61
+ * Whether this binary serves a module or a proto package.
62
+ *
63
+ * Takes either, so a caller holding `loams.live` can pass `'live'` and a
64
+ * caller reading a `ServiceStatus` can pass `'loams.live.v1'`.
65
+ */
66
+ available(moduleOrPackage: string): Promise<boolean>;
67
+ /** The packages this binary serves. */
68
+ served(): Promise<readonly string[]>;
69
+ /** The packages it knows but does not serve. */
70
+ unavailable(): Promise<readonly string[]>;
71
+ /**
72
+ * Throws `FeatureNotInVariantError` unless this binary serves the module.
73
+ *
74
+ * This is the guard to put in front of a feature: the error is the same
75
+ * class the server's own refusal maps to, so one `catch` covers both "the
76
+ * instance does not have this" and "the guard said no", and it costs no RPC
77
+ * once the catalogue is cached.
78
+ */
79
+ guard(moduleOrPackage: string): Promise<void>;
80
+ /**
81
+ * The proto revision check (R9).
82
+ *
83
+ * A missing package is a warning, not an exception: the SDK still works for
84
+ * the modules that are there, and the caller decides what a missing one means.
85
+ */
86
+ version(): Promise<VersionReport>;
87
+ }
package/dist/system.js ADDED
@@ -0,0 +1,152 @@
1
+ // Feature detection and the version check (design §44 §4 and §7.4; runtime
2
+ // contract R9).
3
+ //
4
+ // The unavailable-service path has two halves, and an SDK needs both:
5
+ //
6
+ // 1. **Without calling.** `GetInstance.services[]` says which packages this
7
+ // binary carries and which it does not (D600). One call, no auth, cheap, so
8
+ // an application asks first and hides a feature it cannot use. `loams.system`
9
+ // wraps it: `available`, `served`, `guard`.
10
+ // 2. **When the caller calls anyway.** A call to a package the variant does not
11
+ // carry answers `unimplemented` with `ErrorInfo.reason =
12
+ // feature_not_in_variant` and the variant in `metadata.variant` (D600). The
13
+ // runtime turns that into a `FeatureNotInVariantError`, so the branch is on
14
+ // `err instanceof FeatureNotInVariantError` or on
15
+ // `err.reason === 'feature_not_in_variant'` — never on a message, and never
16
+ // on the package name, which is a proto detail.
17
+ //
18
+ // Half 1 is the one to use. Half 2 is the safety net for a caller who skipped
19
+ // it, or whose instance changed variant underneath a long-lived client.
20
+ import { MODULES, PROTO_PACKAGES, PROTO_REV } from './gen/facade.js';
21
+ import { Code } from '@connectrpc/connect';
22
+ import { FeatureNotInVariantError, LoamsError } from './runtime/errors.js';
23
+ /** The catalogue, the version check and the guard. */
24
+ export class SystemApi {
25
+ getInstance;
26
+ config;
27
+ #catalogue;
28
+ #inFlight;
29
+ constructor(
30
+ /** `loams.instance.getInstance`, which is the one RPC the system API uses.
31
+ * Injected rather than reached for, so the system API is testable without
32
+ * a client. */
33
+ getInstance, config) {
34
+ this.getInstance = getInstance;
35
+ this.config = config;
36
+ }
37
+ /**
38
+ * The service catalogue, cached.
39
+ *
40
+ * Cached because it is asked on a cold start, when a UI is deciding which
41
+ * features to show, and again on every feature check: the instance's services
42
+ * do not change while a process runs. `invalidate()` drops it.
43
+ */
44
+ async catalogue() {
45
+ this.#catalogue ??= await this.fetch();
46
+ return this.#catalogue;
47
+ }
48
+ async fetch() {
49
+ // One in-flight fetch shared by concurrent callers, so a cold start with
50
+ // twenty availability checks makes one call, not twenty.
51
+ this.#inFlight ??= this.getInstance({}, this.callOptions())
52
+ .then((info) => toCatalogue(info.services ?? []))
53
+ .finally(() => {
54
+ this.#inFlight = undefined;
55
+ });
56
+ return this.#inFlight;
57
+ }
58
+ /** Drops the cached catalogue. */
59
+ invalidate() {
60
+ this.#catalogue = undefined;
61
+ }
62
+ /** The client's retry and deadline settings, applied to the catalogue call. */
63
+ callOptions() {
64
+ return {
65
+ ...(this.config.maxRetries === undefined ? {} : { maxRetries: this.config.maxRetries }),
66
+ ...(this.config.timeoutMs === undefined ? {} : { timeoutMs: this.config.timeoutMs }),
67
+ };
68
+ }
69
+ /**
70
+ * Whether this binary serves a module or a proto package.
71
+ *
72
+ * Takes either, so a caller holding `loams.live` can pass `'live'` and a
73
+ * caller reading a `ServiceStatus` can pass `'loams.live.v1'`.
74
+ */
75
+ async available(moduleOrPackage) {
76
+ const name = packageOf(moduleOrPackage);
77
+ const { served } = await this.catalogue();
78
+ return served.includes(name);
79
+ }
80
+ /** The packages this binary serves. */
81
+ async served() {
82
+ return (await this.catalogue()).served;
83
+ }
84
+ /** The packages it knows but does not serve. */
85
+ async unavailable() {
86
+ return (await this.catalogue()).unavailable;
87
+ }
88
+ /**
89
+ * Throws `FeatureNotInVariantError` unless this binary serves the module.
90
+ *
91
+ * This is the guard to put in front of a feature: the error is the same
92
+ * class the server's own refusal maps to, so one `catch` covers both "the
93
+ * instance does not have this" and "the guard said no", and it costs no RPC
94
+ * once the catalogue is cached.
95
+ */
96
+ async guard(moduleOrPackage) {
97
+ const name = packageOf(moduleOrPackage);
98
+ if (await this.available(name)) {
99
+ return;
100
+ }
101
+ throw new FeatureNotInVariantError(`${name} is not in this instance's build variant`, {
102
+ code: Code.Unimplemented,
103
+ reason: 'feature_not_in_variant',
104
+ metadata: { package: name, variant: 'standard' },
105
+ rpc: 'loams.instance.v1.InstanceService/GetInstance',
106
+ variant: 'standard',
107
+ });
108
+ }
109
+ /**
110
+ * The proto revision check (R9).
111
+ *
112
+ * A missing package is a warning, not an exception: the SDK still works for
113
+ * the modules that are there, and the caller decides what a missing one means.
114
+ */
115
+ async version() {
116
+ const info = await this.getInstance({}, this.callOptions());
117
+ const apiVersions = info.apiVersions ?? [];
118
+ const spoken = PROTO_PACKAGES.filter((name) => name.startsWith('loams.'));
119
+ const missing = spoken.filter((name) => !apiVersions.includes(name));
120
+ return {
121
+ protoRev: PROTO_REV,
122
+ serverVersion: info.serverVersion ?? '',
123
+ apiVersions,
124
+ missing,
125
+ compatible: missing.length === 0,
126
+ };
127
+ }
128
+ }
129
+ /** The proto package behind a module name or a package name. */
130
+ function packageOf(moduleOrPackage) {
131
+ if (moduleOrPackage.startsWith('loams.')) {
132
+ return moduleOrPackage;
133
+ }
134
+ const found = MODULES.find((module) => module.name === moduleOrPackage);
135
+ if (found === undefined) {
136
+ throw new LoamsError(`no generated module named ${moduleOrPackage}`, {
137
+ code: Code.Internal,
138
+ });
139
+ }
140
+ return found.package;
141
+ }
142
+ /** Splits `GetInstance.services[]` into served and not. */
143
+ function toCatalogue(services) {
144
+ const sorted = [...services].sort((left, right) => left.package.localeCompare(right.package));
145
+ return {
146
+ services: sorted,
147
+ served: sorted.filter((service) => service.available).map((service) => service.package),
148
+ unavailable: sorted
149
+ .filter((service) => !service.available)
150
+ .map((service) => service.package),
151
+ };
152
+ }
@@ -0,0 +1,6 @@
1
+ export * from './gen/loams/admin/v1/agent_pb.js';
2
+ export * from './gen/loams/admin/v1/audit_pb.js';
3
+ export * from './gen/loams/admin/v1/common_pb.js';
4
+ export * from './gen/loams/admin/v1/key_pb.js';
5
+ export * from './gen/loams/admin/v1/org_pb.js';
6
+ export * from './gen/loams/admin/v1/project_pb.js';
@@ -0,0 +1,12 @@
1
+ // `loams.admin.v1` in one module (API2 Task 7): the package is six proto files
2
+ // (common, org, project, agent, key and audit), and protoc-gen-es writes one
3
+ // TypeScript module per file. Every name is unique across the package, so
4
+ // they re-export together as `@loams/proto/admin`, which is the module the SDK
5
+ // facade imports the package from. Each file is also exported on its own path
6
+ // (`@loams/proto/admin/org`).
7
+ export * from './gen/loams/admin/v1/agent_pb.js';
8
+ export * from './gen/loams/admin/v1/audit_pb.js';
9
+ export * from './gen/loams/admin/v1/common_pb.js';
10
+ export * from './gen/loams/admin/v1/key_pb.js';
11
+ export * from './gen/loams/admin/v1/org_pb.js';
12
+ export * from './gen/loams/admin/v1/project_pb.js';