@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
package/dist/loams.js ADDED
@@ -0,0 +1,203 @@
1
+ // The `Loams` object: one client with namespaced modules (design §44 §7.1).
2
+ //
3
+ // const loams = new Loams({ endpoint, auth: apiKey(process.env.LOAMS_API_KEY) });
4
+ // const info = await loams.instance.getInstance({});
5
+ // for await (const t of loams.live.watch({ start: { case: 'initial', value: querySet } })) {}
6
+ //
7
+ // What is generated and what is hand-written, once more, because it decides
8
+ // where a change goes. The **module surface** is generated: `gen/facade.ts` has
9
+ // one interface per annotated service and one method per `FacadeOptions`
10
+ // call, with the generated message types, and `MODULES` says which service and
11
+ // which retry class each call has. The **runtime** behind those methods is
12
+ // hand-written, once, in `runtime/`: transport, credentials, retry, errors,
13
+ // tokens, pagination, streams. This file is the thin join — it reads the
14
+ // generated table, builds one object per module out of it, and hands every call
15
+ // to the same invoker. It contains no method names and no RPC paths, which is
16
+ // why annotating a proto is enough to add an SDK method.
17
+ import { Code } from '@connectrpc/connect';
18
+ import { MODULES, PROTO_PACKAGES, PROTO_REV, SERVICE_DESCRIPTORS, } from './gen/facade.js';
19
+ import { CallInvoker } from './runtime/call.js';
20
+ import { ConsistencySession } from './runtime/consistency.js';
21
+ import { LoamsError } from './runtime/errors.js';
22
+ import { paginate } from './runtime/pagination.js';
23
+ import { SystemApi } from './system.js';
24
+ import { DEFAULT_MAX_RETRIES } from './runtime/retry.js';
25
+ import { apiKey } from './runtime/token-source.js';
26
+ import { createLoamsTransport } from './runtime/transports.js';
27
+ import { watch } from './runtime/streams.js';
28
+ /** One SDK, over one instance. */
29
+ export class Loams {
30
+ /** `loams.instance` — what this instance is, and who the caller is. */
31
+ instance;
32
+ /** `loams.live` — the live sync session half, and `loams.live.admin`.
33
+ * Its package is `unstable`. */
34
+ live;
35
+ /** `loams.tables` — the table half of the same service (design §44 §7.2). */
36
+ tables;
37
+ /** `loams.admin` — org, projects, agents, keys and audit. */
38
+ admin;
39
+ /** `loams.auth` — sessions and sign-in. */
40
+ auth;
41
+ /** The module catalogue, feature detection and the version check. */
42
+ system;
43
+ /** Every generated module, by its dotted name (`'live.admin'`): the
44
+ * catalogue a caller iterates. The same objects are reachable as
45
+ * properties, `loams.live.admin` for `'live.admin'`. */
46
+ modules;
47
+ #invoker;
48
+ #consistency;
49
+ constructor(options) {
50
+ // The endpoint lives on the transport, which is where connect-es reads it.
51
+ const transport = options.transport ??
52
+ createLoamsTransport({ baseUrl: options.endpoint, ...options.transportOptions });
53
+ const tokenSource = options.auth === undefined
54
+ ? undefined
55
+ : typeof options.auth === 'string'
56
+ ? apiKey(options.auth)
57
+ : options.auth;
58
+ // The session store is built first so the invoker can hold it: a call
59
+ // that asks for the session's consistency token reads and records through
60
+ // the same store the client exposes.
61
+ this.#consistency = options.sessionConsistency === true ? new ConsistencySession() : undefined;
62
+ this.#invoker = new CallInvoker(transport, tokenSource, options.maxRetries ?? DEFAULT_MAX_RETRIES, this.#consistency);
63
+ this.modules = buildModules(this.#invoker);
64
+ const tree = nestModules(this.modules);
65
+ this.instance = tree.instance;
66
+ this.live = tree.live;
67
+ this.tables = tree.tables;
68
+ this.admin = tree.admin;
69
+ this.auth = tree.auth;
70
+ this.system = new SystemApi((request, callOptions) => this.instance.getInstance(request, callOptions), {
71
+ endpoint: options.endpoint,
72
+ maxRetries: options.maxRetries,
73
+ timeoutMs: options.timeoutMs,
74
+ });
75
+ }
76
+ /** The session consistency token store, when `sessionConsistency` is on. */
77
+ get consistency() {
78
+ return this.#consistency;
79
+ }
80
+ /** The generated module bindings, as the SDK sees them. */
81
+ get bindings() {
82
+ return MODULES;
83
+ }
84
+ /** Every item of a paged call (D617's iterator).
85
+ *
86
+ * `loams.paginate('collections', 'listCollections', { namespace })`. The
87
+ * per-call alias §44 §7.4 sketches (`loams.collections.listAll`) arrives with
88
+ * the first paged RPC, when there is a generated signature to hang it on;
89
+ * until then this is the same iterator under its module and call name.
90
+ */
91
+ paginate(module, call, request, options = {}) {
92
+ const binding = this.binding(module, call);
93
+ const method = this.modules[module];
94
+ const fn = method?.[call];
95
+ if (typeof fn !== 'function') {
96
+ throw new LoamsError(`loams.${module} has no call ${call}`, { code: Code.Internal });
97
+ }
98
+ return paginate(binding, fn, request, options);
99
+ }
100
+ /** A server stream that reconnects from its cursor (runtime contract R7). */
101
+ stream(module, call, request, options) {
102
+ const binding = this.binding(module, call);
103
+ const method = this.modules[module];
104
+ const fn = method?.[call];
105
+ if (typeof fn !== 'function') {
106
+ throw new LoamsError(`loams.${module} has no call ${call}`, { code: Code.Internal });
107
+ }
108
+ return watch(binding, fn, request, options);
109
+ }
110
+ /** The binding a module and call name identify, or a clear error. */
111
+ binding(module, call) {
112
+ const found = MODULES.find((entry) => entry.name === module)?.calls.find((entry) => entry.name === call);
113
+ if (found === undefined) {
114
+ throw new LoamsError(`loams.${module} has no generated call ${call}`, {
115
+ code: Code.Internal,
116
+ });
117
+ }
118
+ return found;
119
+ }
120
+ /** The proto revision this SDK declares (§44 §10.3). */
121
+ get protoRev() {
122
+ return PROTO_REV;
123
+ }
124
+ /** The proto packages this SDK speaks. */
125
+ get protoPackages() {
126
+ return PROTO_PACKAGES.filter((name) => name.startsWith('loams.'));
127
+ }
128
+ /** Forgets the cached service catalogue, so the next check calls again. */
129
+ invalidateCatalogue() {
130
+ this.system.invalidate();
131
+ }
132
+ }
133
+ /**
134
+ * Builds one object per generated module: one function per generated call,
135
+ * each delegating to the same invoker.
136
+ *
137
+ * A server-streaming call returns the `AsyncIterable` directly rather than a
138
+ * promise of one, so `for await (const t of loams.live.watch(...))` reads the
139
+ * way design §44 §7.1 says it should.
140
+ */
141
+ function buildModules(invoker) {
142
+ const modules = {};
143
+ for (const module of MODULES) {
144
+ const descriptor = SERVICE_DESCRIPTORS[module.service];
145
+ if (descriptor === undefined) {
146
+ continue;
147
+ }
148
+ invoker.register(module.service, descriptor);
149
+ const calls = {
150
+ module: module.name,
151
+ service: module.service,
152
+ unstable: module.unstable,
153
+ };
154
+ for (const binding of module.calls) {
155
+ // A server-streaming call returns the `AsyncIterable` directly rather
156
+ // than a promise of one, so `for await (const t of loams.live.watch(..))`
157
+ // reads the way design §44 §7.1 says it should. `loams.stream()` wraps
158
+ // the same call with cursor resume.
159
+ calls[binding.name] =
160
+ binding.streaming === 'server'
161
+ ? (request, options) => invoker.stream(binding, request, options)
162
+ : (request, options) => invoker.unary(binding, request, options);
163
+ }
164
+ modules[module.name] = calls;
165
+ }
166
+ return modules;
167
+ }
168
+ /**
169
+ * The module tree a dotted name implies: `'live.admin'` becomes `live.admin`
170
+ * and `'admin.org'` becomes `admin.org`.
171
+ *
172
+ * A child is attached to its parent module object when the parent exists
173
+ * (`live` is a module with calls of its own), and to a plain namespace object
174
+ * when it does not (`admin` has only children). The flat catalogue is left as
175
+ * it is; the tree reuses its objects, so `loams.live.admin` and
176
+ * `loams.modules['live.admin']` are the same object.
177
+ */
178
+ function nestModules(flat) {
179
+ const tree = {};
180
+ // Parents before children, so a module with calls of its own is the node
181
+ // its children hang from rather than being replaced by a namespace.
182
+ const names = Object.keys(flat).sort((a, b) => a.split('.').length - b.split('.').length);
183
+ for (const name of names) {
184
+ const path = name.split('.');
185
+ const leaf = path.pop();
186
+ let node = tree;
187
+ for (const part of path) {
188
+ let next = node[part];
189
+ if (next === undefined) {
190
+ next = {};
191
+ node[part] = next;
192
+ }
193
+ node = next;
194
+ }
195
+ if (leaf in node) {
196
+ throw new LoamsError(`the module ${name} collides with a call of the same name`, {
197
+ code: Code.Internal,
198
+ });
199
+ }
200
+ node[leaf] = flat[name];
201
+ }
202
+ return tree;
203
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Transport } from '@connectrpc/connect';
2
+ import { type TransportOptions } from './runtime/transports.js';
3
+ export * from './index.js';
4
+ /**
5
+ * HTTP/2 gRPC, over `node:http2`.
6
+ *
7
+ * The server's loopback listener is plaintext, so there is no ALPN to
8
+ * negotiate and the client uses HTTP/2 with prior knowledge. Against a TLS
9
+ * endpoint this is ordinary gRPC: HTTP/2, the same credentials, the same
10
+ * retries.
11
+ */
12
+ export declare function createNodeTransport(options?: TransportOptions): Transport;
package/dist/node.js ADDED
@@ -0,0 +1,43 @@
1
+ // `@loams/client/node`: the Node, Deno and Bun entry point.
2
+ //
3
+ // import { Loams, createNodeTransport } from '@loams/client/node';
4
+ //
5
+ // const loams = new Loams({ endpoint, transport: createNodeTransport() });
6
+ //
7
+ // The difference from the default entry point is the transport, and only the
8
+ // transport: it lives here rather than in `@loams/client` because
9
+ // `@connectrpc/connect-node` reaches for `node:http2`, which a browser bundler
10
+ // would follow into a build it cannot satisfy. Keeping it in a subpath means
11
+ // the main entry stays browser-safe by construction rather than by a
12
+ // conditional import that a bundler has to resolve correctly.
13
+ //
14
+ // Which to use. One port serves the Connect protocol, gRPC and gRPC-Web
15
+ // (design §44 §4, D600), so both entries reach the same server with the same
16
+ // code. The `fetch` transport is HTTP/1.1 Connect or gRPC-Web and works
17
+ // everywhere, including through a proxy that only speaks HTTP/1.1; the Node
18
+ // transport is HTTP/2 gRPC, which is fewer round trips and multiplexes
19
+ // connections, at the cost of requiring an HTTP/2-capable path all the way
20
+ // down. Prefer this one on a loopback stack or inside the cluster; prefer the
21
+ // default one in a browser, an edge function, or behind an HTTP/1.1 proxy.
22
+ import { createGrpcTransport } from '@connectrpc/connect-node';
23
+ import { transportInterceptors } from './runtime/transports.js';
24
+ export * from './index.js';
25
+ /**
26
+ * HTTP/2 gRPC, over `node:http2`.
27
+ *
28
+ * The server's loopback listener is plaintext, so there is no ALPN to
29
+ * negotiate and the client uses HTTP/2 with prior knowledge. Against a TLS
30
+ * endpoint this is ordinary gRPC: HTTP/2, the same credentials, the same
31
+ * retries.
32
+ */
33
+ export function createNodeTransport(options = {}) {
34
+ const interceptors = transportInterceptors(options);
35
+ return createGrpcTransport({
36
+ baseUrl: options.baseUrl ?? '',
37
+ useBinaryFormat: options.useBinaryFormat ?? true,
38
+ ...(options.defaultTimeoutMs === undefined
39
+ ? {}
40
+ : { defaultTimeoutMs: options.defaultTimeoutMs }),
41
+ ...(interceptors.length === 0 ? {} : { interceptors }),
42
+ });
43
+ }
@@ -0,0 +1,132 @@
1
+ import type { DescService } from '@bufbuild/protobuf';
2
+ import { type CallOptions as ConnectCallOptions, type Transport } from '@connectrpc/connect';
3
+ import type { CallBinding } from '../gen/facade.js';
4
+ import type { TokenSource } from './token-source.js';
5
+ /**
6
+ * Per-call overrides. Everything here is optional: the client's defaults apply,
7
+ * and the generated binding supplies the retry class.
8
+ *
9
+ * The instance's URL is *not* here: in connect-es it belongs to the transport,
10
+ * so a `Loams` built without a `transport` makes one from its `endpoint`, and
11
+ * one built with a `transport` expects that transport to know the URL.
12
+ */
13
+ export interface CallOptions extends ConnectCallOptions {
14
+ /** Retries after the first attempt. `0` disables retrying for this call. */
15
+ maxRetries?: number;
16
+ /** Extra request headers. `Authorization` is set by the client and wins. */
17
+ headers?: Record<string, string>;
18
+ /**
19
+ * The idempotency key for a mutating call. Supply your own to make a retry
20
+ * yours rather than the SDK's; omit it and the SDK mints one UUIDv7 per
21
+ * logical call and reuses it on every retry.
22
+ */
23
+ idempotencyKey?: string;
24
+ /** Overrides the call's retry class for this call only. */
25
+ retrySafe?: boolean;
26
+ /** The consistency token to read at, and the store to record writes in
27
+ * (§44 §7.4, D609). */
28
+ consistency?: ConsistencyOptions;
29
+ }
30
+ /** How consistency tokens are carried (D609). */
31
+ export interface ConsistencyOptions {
32
+ /** A `ConsistencyToken` from a previous write, or `'strong'` (the default)
33
+ * / `'eventual'` to read without waiting. */
34
+ token?: string;
35
+ /** Record the consistency token every response carries, so later reads are
36
+ * read-your-writes without the caller threading tokens by hand. */
37
+ session?: ConsistencyTokenStore | true | false;
38
+ }
39
+ /** A session's merged consistency token (D609). Off unless a call opts in. */
40
+ export interface ConsistencyTokenStore {
41
+ /** The token to attach to the next read, or undefined for none. */
42
+ current(): string | undefined;
43
+ /** Folds a token the server returned into the session's. */
44
+ record(token: string | undefined): void;
45
+ }
46
+ /** One attempt of a call, as the transport sees it. */
47
+ export type Send = (attempt: Attempt) => Promise<unknown>;
48
+ /** What one attempt carries: the request (with the idempotency key already
49
+ * set) and the bearer to send, if any. */
50
+ export interface Attempt {
51
+ readonly request: unknown;
52
+ readonly headers: Record<string, string>;
53
+ readonly attempt: number;
54
+ readonly refreshed: boolean;
55
+ }
56
+ /** Everything the retry loop needs, so it can be tested without a transport. */
57
+ export interface RetryPlan {
58
+ /** The call's retry class from the generated binding. */
59
+ readonly retrySafe: boolean;
60
+ readonly maxRetries: number;
61
+ readonly signal?: AbortSignal;
62
+ readonly onRefresh?: () => Promise<void>;
63
+ }
64
+ /**
65
+ * Runs `send` until it answers or the plan says stop, and returns whatever it
66
+ * last threw as a `LoamsError`.
67
+ *
68
+ * Exported so the tests can drive it with a stub `send`: the retry policy,
69
+ * the idempotency-key lifecycle and the refresh-once behaviour are the SDK's
70
+ * own logic and are pinned without a server.
71
+ */
72
+ export declare function callWithRetry(request: unknown, send: Send, plan: RetryPlan, rpc?: string): Promise<unknown>;
73
+ /**
74
+ * Gives a mutating call its idempotency key, once per logical call.
75
+ *
76
+ * D610: a mutation may only be retried when it carries one, and the same key
77
+ * has to go out on every attempt or the retry is a second write. So the key is
78
+ * decided here, before the first attempt, and the request object is not
79
+ * rewritten afterwards.
80
+ *
81
+ * A message without an `idempotency_key` field is left alone: proto3 messages
82
+ * are plain objects here, so the field's presence is the test.
83
+ */
84
+ export declare function withIdempotencyKey(request: unknown, supplied: string | undefined,
85
+ /** Whether the request *schema* declares an `idempotency_key` field. */
86
+ declared?: boolean): {
87
+ request: unknown;
88
+ keyed: boolean;
89
+ };
90
+ /**
91
+ * The client's call path: a generated `CallBinding` plus a request becomes a
92
+ * response message, with everything the SDK owns applied on the way.
93
+ */
94
+ export declare class CallInvoker {
95
+ private readonly transport;
96
+ private readonly tokenSource;
97
+ private readonly maxRetries;
98
+ private readonly consistency;
99
+ private readonly clients;
100
+ private readonly descriptors;
101
+ /** Whether a call's request schema declares `idempotency_key`, per RPC. */
102
+ private readonly keyed;
103
+ constructor(transport: Transport, tokenSource: TokenSource | undefined, maxRetries: number, consistency: ConsistencyTokenStore | undefined);
104
+ /**
105
+ * A module's Connect client, built once from the service descriptor
106
+ * `protoc-gen-es` generated. This is what makes the facade generated rather
107
+ * than hand-written: the SDK never writes a client, it wraps the stubs.
108
+ */
109
+ register(service: string, descriptor: DescService): void;
110
+ /** Whether this call's request message declares an `idempotency_key`. */
111
+ private takesIdempotencyKey;
112
+ /** The connect-es method a binding names, or a clear internal error. */
113
+ private method;
114
+ /** One unary call: the whole runtime contract, applied to one binding. */
115
+ unary(binding: CallBinding, request: unknown, options?: CallOptions): Promise<Record<string, unknown> | undefined>;
116
+ /** The headers a call carries: the caller's, plus the bearer and the
117
+ * consistency token the runtime owns. */
118
+ private headers;
119
+ /**
120
+ * One server stream, with the errors mapped: no resume, no cursor tracking.
121
+ *
122
+ * The mapping is not optional here. A refusal on a stream arrives inside the
123
+ * Connect envelope, not as an HTTP status, so a caller that iterated the raw
124
+ * iterable would see a `ConnectError` with no `reason` — the one place in the
125
+ * SDK where `reason` could go missing. `watch()` adds the resume on top.
126
+ */
127
+ stream(binding: CallBinding, request: unknown, options?: CallOptions): AsyncIterable<unknown>;
128
+ /** Wraps an iterable so every throw becomes a `LoamsError`. */
129
+ private mapped;
130
+ /** Merges the consistency token a write answered with into the session. */
131
+ private recordConsistency;
132
+ }
@@ -0,0 +1,273 @@
1
+ // The call path: the one place a facade method becomes an RPC (design §44 §7.4;
2
+ // runtime contract R1-R4).
3
+ //
4
+ // It does four things the generated facade cannot, and nothing else:
5
+ //
6
+ // - attaches the bearer from the client's `TokenSource`;
7
+ // - retries on the call's class from the generated bindings, with M1.6's
8
+ // backoff numbers, and refreshes the token once on `token_expired`;
9
+ // - gives a mutating call an idempotency key once per logical call and reuses
10
+ // it on every retry, so a retried write is the same write (D610);
11
+ // - turns whatever is thrown into the typed `LoamsError` hierarchy, so a
12
+ // caller branches on `reason` and never on a message.
13
+ import { Code, createClient, } from '@connectrpc/connect';
14
+ import { LoamsError, TokenExpiredError, toLoamsError } from './errors.js';
15
+ import { DEFAULT_MAX_RETRIES, backoffMs, shouldRetry, sleep } from './retry.js';
16
+ import { uuidv7 } from './uuidv7.js';
17
+ /**
18
+ * Runs `send` until it answers or the plan says stop, and returns whatever it
19
+ * last threw as a `LoamsError`.
20
+ *
21
+ * Exported so the tests can drive it with a stub `send`: the retry policy,
22
+ * the idempotency-key lifecycle and the refresh-once behaviour are the SDK's
23
+ * own logic and are pinned without a server.
24
+ */
25
+ export async function callWithRetry(request, send, plan, rpc) {
26
+ let refreshed = false;
27
+ for (let attempt = 0;; attempt += 1) {
28
+ try {
29
+ return await send({ request, headers: {}, attempt, refreshed });
30
+ }
31
+ catch (thrown) {
32
+ const error = toLoamsError(thrown, rpc);
33
+ // R1: a 401 whose reason is `token_expired` gets exactly one refresh and
34
+ // one retry. A source that cannot refresh (`apiKey`) makes this a no-op,
35
+ // and a second expiry is reported rather than looped on.
36
+ if (error instanceof TokenExpiredError && !refreshed && plan.onRefresh !== undefined) {
37
+ refreshed = true;
38
+ await plan.onRefresh();
39
+ attempt -= 1;
40
+ continue;
41
+ }
42
+ if (!shouldRetry(error, plan.retrySafe, attempt, plan.maxRetries)) {
43
+ throw error;
44
+ }
45
+ await sleep(backoffMs(attempt), plan.signal);
46
+ }
47
+ }
48
+ }
49
+ /**
50
+ * Gives a mutating call its idempotency key, once per logical call.
51
+ *
52
+ * D610: a mutation may only be retried when it carries one, and the same key
53
+ * has to go out on every attempt or the retry is a second write. So the key is
54
+ * decided here, before the first attempt, and the request object is not
55
+ * rewritten afterwards.
56
+ *
57
+ * A message without an `idempotency_key` field is left alone: proto3 messages
58
+ * are plain objects here, so the field's presence is the test.
59
+ */
60
+ export function withIdempotencyKey(request, supplied,
61
+ /** Whether the request *schema* declares an `idempotency_key` field. */
62
+ declared = false) {
63
+ if (typeof request !== 'object' || request === null) {
64
+ return { request, keyed: false };
65
+ }
66
+ const message = request;
67
+ // Either the schema declares the field or the object carries it; without one
68
+ // of those two there is nothing to key, and a request that has no key field
69
+ // must be left exactly as the caller wrote it.
70
+ if (!declared && !('idempotencyKey' in message)) {
71
+ return { request, keyed: false };
72
+ }
73
+ if (typeof message.idempotencyKey === 'string' && message.idempotencyKey !== '') {
74
+ return { request, keyed: true };
75
+ }
76
+ return {
77
+ request: { ...message, idempotencyKey: supplied ?? uuidv7() },
78
+ keyed: true,
79
+ };
80
+ }
81
+ /**
82
+ * A stream method's return value, as one async iterable.
83
+ *
84
+ * connect-es types a server stream as an iterable that may be awaited; the SDK
85
+ * hands callers a plain `AsyncIterable`, which is what `for await` wants
86
+ * (design §44 §7.1).
87
+ */
88
+ async function* streamOf(source) {
89
+ yield* source;
90
+ }
91
+ /** The request-message field a mutation is keyed by (D610). Read from the
92
+ * generated schema rather than guessed from the object a caller built. */
93
+ const IDEMPOTENCY_KEY = 'idempotencyKey';
94
+ /**
95
+ * The session a call asked for: its own store if it brought one, the client's
96
+ * if it asked for the session's, none otherwise.
97
+ */
98
+ function resolveSession(options, clientSession) {
99
+ const session = options?.session;
100
+ if (session === undefined || session === false) {
101
+ return undefined;
102
+ }
103
+ return session === true ? clientSession : session;
104
+ }
105
+ /**
106
+ * The client's call path: a generated `CallBinding` plus a request becomes a
107
+ * response message, with everything the SDK owns applied on the way.
108
+ */
109
+ export class CallInvoker {
110
+ transport;
111
+ tokenSource;
112
+ maxRetries;
113
+ consistency;
114
+ clients = new Map();
115
+ descriptors = new Map();
116
+ /** Whether a call's request schema declares `idempotency_key`, per RPC. */
117
+ keyed = new Map();
118
+ constructor(transport, tokenSource, maxRetries, consistency) {
119
+ this.transport = transport;
120
+ this.tokenSource = tokenSource;
121
+ this.maxRetries = maxRetries;
122
+ this.consistency = consistency;
123
+ }
124
+ /**
125
+ * A module's Connect client, built once from the service descriptor
126
+ * `protoc-gen-es` generated. This is what makes the facade generated rather
127
+ * than hand-written: the SDK never writes a client, it wraps the stubs.
128
+ */
129
+ register(service, descriptor) {
130
+ if (this.clients.has(service)) {
131
+ return;
132
+ }
133
+ this.descriptors.set(service, descriptor);
134
+ for (const method of Object.values(descriptor.method)) {
135
+ // Whether this RPC takes an idempotency key is a property of its request
136
+ // message, so it is read from the generated schema rather than inferred
137
+ // from the object a caller happened to build. A caller who omits an
138
+ // `optional` field still gets a key, and `MutateRequest` (which has one)
139
+ // is never confused with `DeployRequest` (which does not).
140
+ this.keyed.set(`${service}/${method.name}`, method.input.field[IDEMPOTENCY_KEY] !== undefined);
141
+ }
142
+ this.clients.set(service, createClient(descriptor, this.transport));
143
+ }
144
+ /** Whether this call's request message declares an `idempotency_key`. */
145
+ takesIdempotencyKey(binding) {
146
+ return this.keyed.get(`${binding.service}/${binding.method}`) ?? false;
147
+ }
148
+ /** The connect-es method a binding names, or a clear internal error. */
149
+ method(binding) {
150
+ const client = this.clients.get(binding.service);
151
+ const method = client?.[binding.name];
152
+ if (typeof method !== 'function') {
153
+ throw new LoamsError(`the generated client for ${binding.service} has no method ${binding.name}`, { code: Code.Internal, rpc: binding.rpc });
154
+ }
155
+ return method;
156
+ }
157
+ /** One unary call: the whole runtime contract, applied to one binding. */
158
+ async unary(binding, request, options = {}) {
159
+ const { request: keyed, keyed: hasKey } = withIdempotencyKey(request, options.idempotencyKey, this.takesIdempotencyKey(binding));
160
+ const maxRetries = options.maxRetries ?? this.maxRetries;
161
+ // D610: a `safe` call always retries; a mutation retries once it carries an
162
+ // idempotency key, which `withIdempotencyKey` has just decided.
163
+ const retrySafe = options.retrySafe ?? (binding.retry === 'safe' || hasKey);
164
+ const session = resolveSession(options.consistency, this.consistency);
165
+ // An explicit token wins; otherwise the session's, which is what makes
166
+ // `consistency: { session: true }` read-your-writes rather than record it
167
+ // and never send it (R4).
168
+ const consistency = options.consistency?.token ?? session?.current();
169
+ const method = this.method(binding);
170
+ const result = (await callWithRetry(keyed, async () => method(keyed, { ...options, headers: await this.headers(options, consistency) }), {
171
+ retrySafe,
172
+ maxRetries,
173
+ signal: options.signal,
174
+ onRefresh: this.tokenSource?.refresh?.bind(this.tokenSource),
175
+ }, binding.rpc));
176
+ this.recordConsistency(session, result);
177
+ return result;
178
+ }
179
+ /** The headers a call carries: the caller's, plus the bearer and the
180
+ * consistency token the runtime owns. */
181
+ async headers(options, consistency) {
182
+ const headers = { ...options.headers };
183
+ const bearer = await this.tokenSource?.token();
184
+ if (bearer !== undefined) {
185
+ headers.authorization = `Bearer ${bearer}`;
186
+ }
187
+ if (consistency !== undefined) {
188
+ headers['loams-consistency'] = consistency;
189
+ }
190
+ return headers;
191
+ }
192
+ /**
193
+ * One server stream, with the errors mapped: no resume, no cursor tracking.
194
+ *
195
+ * The mapping is not optional here. A refusal on a stream arrives inside the
196
+ * Connect envelope, not as an HTTP status, so a caller that iterated the raw
197
+ * iterable would see a `ConnectError` with no `reason` — the one place in the
198
+ * SDK where `reason` could go missing. `watch()` adds the resume on top.
199
+ */
200
+ stream(binding, request, options = {}) {
201
+ const method = this.method(binding);
202
+ const session = resolveSession(options.consistency, this.consistency);
203
+ const consistency = options.consistency?.token ?? session?.current();
204
+ const self = this;
205
+ const refresh = this.tokenSource?.refresh?.bind(this.tokenSource);
206
+ // Synchronous, because `loams.live.watch(...)` has to hand back an
207
+ // `AsyncIterable` rather than a promise of one (design §44 §7.1). The
208
+ // bearer is fetched when the caller first steps the stream rather than when
209
+ // the call is made: a stream that never authenticates is not a stream.
210
+ const invoke = async function* () {
211
+ let refreshed = false;
212
+ let yielded = false;
213
+ for (;;) {
214
+ const headers = await self.headers(options, consistency);
215
+ const source = self.mapped(streamOf(method(request, { ...options, headers })), binding.rpc);
216
+ try {
217
+ for await (const message of source) {
218
+ yielded = true;
219
+ yield message;
220
+ }
221
+ return;
222
+ }
223
+ catch (thrown) {
224
+ const error = toLoamsError(thrown, binding.rpc);
225
+ // R1 on a stream: one refresh and one re-open, and only if nothing
226
+ // has been yielded yet. Once messages are flowing the caller is
227
+ // holding a position in the stream, and re-opening it is `watch()`'s
228
+ // job (it resumes from the cursor); replaying from the start here
229
+ // would duplicate everything the caller has already seen.
230
+ if (error instanceof TokenExpiredError &&
231
+ !refreshed &&
232
+ refresh !== undefined &&
233
+ !yielded) {
234
+ refreshed = true;
235
+ await refresh();
236
+ continue;
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ };
242
+ return invoke();
243
+ }
244
+ /** Wraps an iterable so every throw becomes a `LoamsError`. */
245
+ mapped(source, rpc) {
246
+ const invoke = async function* () {
247
+ try {
248
+ yield* source;
249
+ }
250
+ catch (thrown) {
251
+ throw toLoamsError(thrown, rpc);
252
+ }
253
+ };
254
+ return invoke();
255
+ }
256
+ /** Merges the consistency token a write answered with into the session. */
257
+ recordConsistency(session, result) {
258
+ if (session === undefined || result === undefined) {
259
+ return;
260
+ }
261
+ const token = result.consistencyToken;
262
+ try {
263
+ session.record(typeof token === 'string' && token !== '' ? token : undefined);
264
+ }
265
+ catch {
266
+ // The RPC already succeeded. A token the session cannot merge — the
267
+ // encoding is still opaque, see `ConsistencySession` — must not turn a
268
+ // committed write into a thrown error, because a caller that retries on
269
+ // that error performs the write twice. `ConsistencySession.conflicts`
270
+ // counts it instead.
271
+ }
272
+ }
273
+ }