@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.
- package/LICENSE +202 -0
- package/README.md +137 -2
- package/dist/gen/facade.d.ts +446 -0
- package/dist/gen/facade.js +441 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +32 -0
- package/dist/loams.d.ts +110 -0
- package/dist/loams.js +203 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.js +43 -0
- package/dist/runtime/call.d.ts +132 -0
- package/dist/runtime/call.js +273 -0
- package/dist/runtime/consistency.d.ts +22 -0
- package/dist/runtime/consistency.js +68 -0
- package/dist/runtime/errors.d.ts +100 -0
- package/dist/runtime/errors.js +165 -0
- package/dist/runtime/pagination.d.ts +25 -0
- package/dist/runtime/pagination.js +47 -0
- package/dist/runtime/retry.d.ts +30 -0
- package/dist/runtime/retry.js +75 -0
- package/dist/runtime/streams.d.ts +30 -0
- package/dist/runtime/streams.js +61 -0
- package/dist/runtime/token-source.d.ts +57 -0
- package/dist/runtime/token-source.js +117 -0
- package/dist/runtime/transports.d.ts +58 -0
- package/dist/runtime/transports.js +66 -0
- package/dist/runtime/uuidv7.d.ts +10 -0
- package/dist/runtime/uuidv7.js +57 -0
- package/dist/system.d.ts +87 -0
- package/dist/system.js +152 -0
- package/dist/vendor/proto/admin.d.ts +6 -0
- package/dist/vendor/proto/admin.js +12 -0
- package/dist/vendor/proto/gen/loams/admin/v1/agent_pb.d.ts +902 -0
- package/dist/vendor/proto/gen/loams/admin/v1/agent_pb.js +215 -0
- package/dist/vendor/proto/gen/loams/admin/v1/audit_pb.d.ts +289 -0
- package/dist/vendor/proto/gen/loams/admin/v1/audit_pb.js +76 -0
- package/dist/vendor/proto/gen/loams/admin/v1/common_pb.d.ts +203 -0
- package/dist/vendor/proto/gen/loams/admin/v1/common_pb.js +118 -0
- package/dist/vendor/proto/gen/loams/admin/v1/key_pb.d.ts +284 -0
- package/dist/vendor/proto/gen/loams/admin/v1/key_pb.js +62 -0
- package/dist/vendor/proto/gen/loams/admin/v1/org_pb.d.ts +634 -0
- package/dist/vendor/proto/gen/loams/admin/v1/org_pb.js +142 -0
- package/dist/vendor/proto/gen/loams/admin/v1/project_pb.d.ts +662 -0
- package/dist/vendor/proto/gen/loams/admin/v1/project_pb.js +132 -0
- package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.d.ts +435 -0
- package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.js +156 -0
- package/dist/vendor/proto/gen/loams/errors/v1/errors_pb.d.ts +45 -0
- package/dist/vendor/proto/gen/loams/errors/v1/errors_pb.js +17 -0
- package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.d.ts +501 -0
- package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.js +170 -0
- package/dist/vendor/proto/gen/loams/options/v1/options_pb.d.ts +97 -0
- package/dist/vendor/proto/gen/loams/options/v1/options_pb.js +47 -0
- package/package.json +49 -4
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The prefix every token carries (§44 §7.4). */
|
|
2
|
+
export declare const TOKEN_PREFIX = "v1:";
|
|
3
|
+
/** Whether a string looks like a consistency token. */
|
|
4
|
+
export declare function isConsistencyToken(value: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* A session's consistency token.
|
|
7
|
+
*
|
|
8
|
+
* Merging is deliberately conservative while the encoding is opaque: the store
|
|
9
|
+
* keeps what it has, and a second, different token is a conflict the caller has
|
|
10
|
+
* to resolve, not something to merge blindly.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ConsistencySession {
|
|
13
|
+
#private;
|
|
14
|
+
/** The token to attach to the next read. */
|
|
15
|
+
current(): string | undefined;
|
|
16
|
+
/** Folds a token the server returned in. */
|
|
17
|
+
record(token: string | undefined): void;
|
|
18
|
+
/** How many unmergeable pairs the session has seen. */
|
|
19
|
+
get conflicts(): number;
|
|
20
|
+
/** Forgets the token, so the next read is not held to it. */
|
|
21
|
+
clear(): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Consistency tokens (design §44 §7.4, D609; runtime contract R4).
|
|
2
|
+
//
|
|
3
|
+
// A write answers with a `consistency_token`; a read accepts one, so a caller
|
|
4
|
+
// that just wrote can read its own write. Threading those by hand is the
|
|
5
|
+
// caller's job today. The session store is the alternative §44 §7.4 asks for:
|
|
6
|
+
// off by default, and when a call opts in, every response's token is merged
|
|
7
|
+
// into the session and attached to later reads.
|
|
8
|
+
//
|
|
9
|
+
// **The token's encoding is not in the protos yet.** §05 §5 defines the
|
|
10
|
+
// semantics (offsets per stream and partition, `STRONG` as the default,
|
|
11
|
+
// `EVENTUAL`, `AT_LEAST{token}`) and API1's write paths carry it as an opaque
|
|
12
|
+
// `v1:` string; §44 §7.4 says it merges by "max offset per stream and
|
|
13
|
+
// partition", which needs the encoding to be parsed. Until that lands this
|
|
14
|
+
// store keeps the token it was given, refuses to merge two different tokens
|
|
15
|
+
// into a wrong one, and says so — a silently-wrong consistency token reads
|
|
16
|
+
// stale data, which is worse than an error.
|
|
17
|
+
import { LoamsError } from './errors.js';
|
|
18
|
+
import { Code } from '@connectrpc/connect';
|
|
19
|
+
/** The prefix every token carries (§44 §7.4). */
|
|
20
|
+
export const TOKEN_PREFIX = 'v1:';
|
|
21
|
+
/** Whether a string looks like a consistency token. */
|
|
22
|
+
export function isConsistencyToken(value) {
|
|
23
|
+
return value.startsWith(TOKEN_PREFIX) && value.length > TOKEN_PREFIX.length;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A session's consistency token.
|
|
27
|
+
*
|
|
28
|
+
* Merging is deliberately conservative while the encoding is opaque: the store
|
|
29
|
+
* keeps what it has, and a second, different token is a conflict the caller has
|
|
30
|
+
* to resolve, not something to merge blindly.
|
|
31
|
+
*/
|
|
32
|
+
export class ConsistencySession {
|
|
33
|
+
#token;
|
|
34
|
+
/** How many times two different tokens met. Surfaced so the limitation is
|
|
35
|
+
* visible rather than silent. */
|
|
36
|
+
#conflicts = 0;
|
|
37
|
+
/** The token to attach to the next read. */
|
|
38
|
+
current() {
|
|
39
|
+
return this.#token;
|
|
40
|
+
}
|
|
41
|
+
/** Folds a token the server returned in. */
|
|
42
|
+
record(token) {
|
|
43
|
+
if (token === undefined || token === '') {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!isConsistencyToken(token)) {
|
|
47
|
+
this.#conflicts += 1;
|
|
48
|
+
throw new LoamsError(`not a consistency token: ${token}`, { code: Code.Internal });
|
|
49
|
+
}
|
|
50
|
+
if (this.#token === undefined || this.#token === token) {
|
|
51
|
+
this.#token = token;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.#conflicts += 1;
|
|
55
|
+
throw new LoamsError('two different consistency tokens met and the encoding cannot merge them yet; ' +
|
|
56
|
+
'the session keeps the first. Merging by stream and partition offset arrives with ' +
|
|
57
|
+
'the write paths that carry offsets (§44 §7.4, D609).', { code: Code.FailedPrecondition });
|
|
58
|
+
}
|
|
59
|
+
/** How many unmergeable pairs the session has seen. */
|
|
60
|
+
get conflicts() {
|
|
61
|
+
return this.#conflicts;
|
|
62
|
+
}
|
|
63
|
+
/** Forgets the token, so the next read is not held to it. */
|
|
64
|
+
clear() {
|
|
65
|
+
this.#token = undefined;
|
|
66
|
+
this.#conflicts = 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Code, ConnectError } from '@connectrpc/connect';
|
|
2
|
+
import { type Reason } from '../gen/facade.js';
|
|
3
|
+
/** What every Loams failure carries. */
|
|
4
|
+
export declare class LoamsError extends Error {
|
|
5
|
+
/** The Connect code, the canonical classification. */
|
|
6
|
+
readonly code: Code;
|
|
7
|
+
/** The stable cause, when the server sent an `ErrorInfo`. Undefined means
|
|
8
|
+
* the failure came from below the API (a socket, a timeout, a CORS
|
|
9
|
+
* rejection), not from a Loams service. */
|
|
10
|
+
readonly reason: Reason | undefined;
|
|
11
|
+
/** Structured context the server sent. Never secrets. */
|
|
12
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
13
|
+
/** A short next step in the caller's locale, when the server sent one. */
|
|
14
|
+
readonly hint: string | undefined;
|
|
15
|
+
/** The RPC that failed, as `package.Service/Method`. */
|
|
16
|
+
readonly rpc: string | undefined;
|
|
17
|
+
/** True when the reason is not in this SDK's registry: the server is newer
|
|
18
|
+
* than the SDK, so the reason is carried as text and the union is not. */
|
|
19
|
+
readonly unknownReason: string | undefined;
|
|
20
|
+
constructor(message: string, init: {
|
|
21
|
+
code: Code;
|
|
22
|
+
reason?: Reason;
|
|
23
|
+
unknownReason?: string;
|
|
24
|
+
metadata?: Record<string, string>;
|
|
25
|
+
hint?: string;
|
|
26
|
+
rpc?: string;
|
|
27
|
+
cause?: unknown;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export declare class InvalidArgumentError extends LoamsError {
|
|
31
|
+
}
|
|
32
|
+
export declare class NotFoundError extends LoamsError {
|
|
33
|
+
}
|
|
34
|
+
export declare class AlreadyExistsError extends LoamsError {
|
|
35
|
+
}
|
|
36
|
+
export declare class PermissionDeniedError extends LoamsError {
|
|
37
|
+
}
|
|
38
|
+
export declare class UnauthenticatedError extends LoamsError {
|
|
39
|
+
}
|
|
40
|
+
export declare class FailedPreconditionError extends LoamsError {
|
|
41
|
+
}
|
|
42
|
+
export declare class ResourceExhaustedError extends LoamsError {
|
|
43
|
+
}
|
|
44
|
+
export declare class UnavailableError extends LoamsError {
|
|
45
|
+
}
|
|
46
|
+
export declare class DeadlineExceededError extends LoamsError {
|
|
47
|
+
}
|
|
48
|
+
export declare class AbortedError extends LoamsError {
|
|
49
|
+
}
|
|
50
|
+
export declare class InternalError extends LoamsError {
|
|
51
|
+
}
|
|
52
|
+
export declare class UnimplementedError extends LoamsError {
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A package this build variant does not carry (design §44 §4, D600).
|
|
56
|
+
*
|
|
57
|
+
* The server answers `unimplemented` with `ErrorInfo.reason =
|
|
58
|
+
* feature_not_in_variant` and names the variant in `metadata.variant`, which
|
|
59
|
+
* is what this class reads. A caller usually never gets here: `loams.system`
|
|
60
|
+
* feature-detects from `GetInstance.services[]` before calling, so an
|
|
61
|
+
* unavailable module throws `FeatureNotInVariantError` from
|
|
62
|
+
* `loams.system.guard()` with no request spent. This class is the path for a
|
|
63
|
+
* caller who skipped the guard, or whose instance changed variant.
|
|
64
|
+
*/
|
|
65
|
+
export declare class FeatureNotInVariantError extends UnimplementedError {
|
|
66
|
+
/** The build variant that was asked for, from `metadata.variant`. */
|
|
67
|
+
readonly variant: string | undefined;
|
|
68
|
+
constructor(message: string, init: ConstructorParameters<typeof UnimplementedError>[1] & {
|
|
69
|
+
variant?: string;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** A token the server rejected as expired: `unauthenticated` with reason
|
|
73
|
+
* `token_expired`. The runtime refreshes once and retries once (D608, R1). */
|
|
74
|
+
export declare class TokenExpiredError extends UnauthenticatedError {
|
|
75
|
+
}
|
|
76
|
+
/** Whether a value is a Loams error rather than a raw `ConnectError`. */
|
|
77
|
+
export declare function isLoamsError(value: unknown): value is LoamsError;
|
|
78
|
+
/**
|
|
79
|
+
* The `ErrorInfo` a Connect error carries, if any.
|
|
80
|
+
*
|
|
81
|
+
* The detail is looked up by its type URL rather than by position, so a
|
|
82
|
+
* service that adds a detail of its own does not move `reason` out from under
|
|
83
|
+
* a caller.
|
|
84
|
+
*/
|
|
85
|
+
export declare function errorInfo(error: ConnectError): ErrorInfoShape | undefined;
|
|
86
|
+
/** The fields of `loams.errors.v1.ErrorInfo` this package reads. */
|
|
87
|
+
export interface ErrorInfoShape {
|
|
88
|
+
readonly reason: string;
|
|
89
|
+
readonly metadata: {
|
|
90
|
+
readonly [key: string]: string;
|
|
91
|
+
};
|
|
92
|
+
readonly hint: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Turns any thrown value into the typed hierarchy: a `ConnectError` becomes the
|
|
96
|
+
* class its code names, with `reason` and `metadata` lifted out of the
|
|
97
|
+
* `ErrorInfo` detail; anything else (a fetch rejection, an abort, a bug in
|
|
98
|
+
* the SDK) becomes a `LoamsError` with `Code.Unknown`.
|
|
99
|
+
*/
|
|
100
|
+
export declare function toLoamsError(value: unknown, rpc?: string): LoamsError;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The typed error hierarchy of design §44 §7.4, decision D611.
|
|
2
|
+
//
|
|
3
|
+
// A failed RPC carries a Connect code and one `loams.errors.v1.ErrorInfo` in
|
|
4
|
+
// its details. The `reason` is what callers branch on: it is a stable
|
|
5
|
+
// `snake_case` string, registered in `docs/api/reasons.md` and generated into
|
|
6
|
+
// this package's `Reason` union, so `error.reason === 'feature_not_in_variant'`
|
|
7
|
+
// narrows the type and a reason the registry has lost stops compiling. The
|
|
8
|
+
// `message` is for people and may change; nothing in an SDK branches on it.
|
|
9
|
+
import { Code, ConnectError } from '@connectrpc/connect';
|
|
10
|
+
import { ErrorInfoSchema } from '../vendor/proto/gen/loams/errors/v1/errors_pb.js';
|
|
11
|
+
import { REASONS } from '../gen/facade.js';
|
|
12
|
+
/** The registry, as a runtime guard: a reason off the wire that the generated
|
|
13
|
+
* union does not have is a server newer than this SDK, and is surfaced rather
|
|
14
|
+
* than dropped. */
|
|
15
|
+
const KNOWN_REASONS = new Set(REASONS);
|
|
16
|
+
/** What every Loams failure carries. */
|
|
17
|
+
export class LoamsError extends Error {
|
|
18
|
+
/** The Connect code, the canonical classification. */
|
|
19
|
+
code;
|
|
20
|
+
/** The stable cause, when the server sent an `ErrorInfo`. Undefined means
|
|
21
|
+
* the failure came from below the API (a socket, a timeout, a CORS
|
|
22
|
+
* rejection), not from a Loams service. */
|
|
23
|
+
reason;
|
|
24
|
+
/** Structured context the server sent. Never secrets. */
|
|
25
|
+
metadata;
|
|
26
|
+
/** A short next step in the caller's locale, when the server sent one. */
|
|
27
|
+
hint;
|
|
28
|
+
/** The RPC that failed, as `package.Service/Method`. */
|
|
29
|
+
rpc;
|
|
30
|
+
/** True when the reason is not in this SDK's registry: the server is newer
|
|
31
|
+
* than the SDK, so the reason is carried as text and the union is not. */
|
|
32
|
+
unknownReason;
|
|
33
|
+
constructor(message, init) {
|
|
34
|
+
super(message, init.cause === undefined ? undefined : { cause: init.cause });
|
|
35
|
+
this.name = new.target.name;
|
|
36
|
+
this.code = init.code;
|
|
37
|
+
this.reason = init.reason;
|
|
38
|
+
this.unknownReason = init.unknownReason;
|
|
39
|
+
this.metadata = init.metadata ?? {};
|
|
40
|
+
this.hint = init.hint;
|
|
41
|
+
this.rpc = init.rpc;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class InvalidArgumentError extends LoamsError {
|
|
45
|
+
}
|
|
46
|
+
export class NotFoundError extends LoamsError {
|
|
47
|
+
}
|
|
48
|
+
export class AlreadyExistsError extends LoamsError {
|
|
49
|
+
}
|
|
50
|
+
export class PermissionDeniedError extends LoamsError {
|
|
51
|
+
}
|
|
52
|
+
export class UnauthenticatedError extends LoamsError {
|
|
53
|
+
}
|
|
54
|
+
export class FailedPreconditionError extends LoamsError {
|
|
55
|
+
}
|
|
56
|
+
export class ResourceExhaustedError extends LoamsError {
|
|
57
|
+
}
|
|
58
|
+
export class UnavailableError extends LoamsError {
|
|
59
|
+
}
|
|
60
|
+
export class DeadlineExceededError extends LoamsError {
|
|
61
|
+
}
|
|
62
|
+
export class AbortedError extends LoamsError {
|
|
63
|
+
}
|
|
64
|
+
export class InternalError extends LoamsError {
|
|
65
|
+
}
|
|
66
|
+
export class UnimplementedError extends LoamsError {
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A package this build variant does not carry (design §44 §4, D600).
|
|
70
|
+
*
|
|
71
|
+
* The server answers `unimplemented` with `ErrorInfo.reason =
|
|
72
|
+
* feature_not_in_variant` and names the variant in `metadata.variant`, which
|
|
73
|
+
* is what this class reads. A caller usually never gets here: `loams.system`
|
|
74
|
+
* feature-detects from `GetInstance.services[]` before calling, so an
|
|
75
|
+
* unavailable module throws `FeatureNotInVariantError` from
|
|
76
|
+
* `loams.system.guard()` with no request spent. This class is the path for a
|
|
77
|
+
* caller who skipped the guard, or whose instance changed variant.
|
|
78
|
+
*/
|
|
79
|
+
export class FeatureNotInVariantError extends UnimplementedError {
|
|
80
|
+
/** The build variant that was asked for, from `metadata.variant`. */
|
|
81
|
+
variant;
|
|
82
|
+
constructor(message, init) {
|
|
83
|
+
super(message, init);
|
|
84
|
+
this.variant = init.variant;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** A token the server rejected as expired: `unauthenticated` with reason
|
|
88
|
+
* `token_expired`. The runtime refreshes once and retries once (D608, R1). */
|
|
89
|
+
export class TokenExpiredError extends UnauthenticatedError {
|
|
90
|
+
}
|
|
91
|
+
/** The code-to-class mapping of D611. Codes D611 does not name (`canceled`,
|
|
92
|
+
* `out_of_range`, `data_loss`) fall through to the base `LoamsError`. */
|
|
93
|
+
const BY_CODE = {
|
|
94
|
+
[Code.Canceled]: LoamsError,
|
|
95
|
+
[Code.Unknown]: LoamsError,
|
|
96
|
+
[Code.InvalidArgument]: InvalidArgumentError,
|
|
97
|
+
[Code.DeadlineExceeded]: DeadlineExceededError,
|
|
98
|
+
[Code.NotFound]: NotFoundError,
|
|
99
|
+
[Code.AlreadyExists]: AlreadyExistsError,
|
|
100
|
+
[Code.PermissionDenied]: PermissionDeniedError,
|
|
101
|
+
[Code.ResourceExhausted]: ResourceExhaustedError,
|
|
102
|
+
[Code.FailedPrecondition]: FailedPreconditionError,
|
|
103
|
+
[Code.Aborted]: AbortedError,
|
|
104
|
+
[Code.OutOfRange]: LoamsError,
|
|
105
|
+
[Code.Unimplemented]: UnimplementedError,
|
|
106
|
+
[Code.Internal]: InternalError,
|
|
107
|
+
[Code.Unavailable]: UnavailableError,
|
|
108
|
+
[Code.DataLoss]: LoamsError,
|
|
109
|
+
[Code.Unauthenticated]: UnauthenticatedError,
|
|
110
|
+
};
|
|
111
|
+
/** Whether a value is a Loams error rather than a raw `ConnectError`. */
|
|
112
|
+
export function isLoamsError(value) {
|
|
113
|
+
return value instanceof LoamsError;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The `ErrorInfo` a Connect error carries, if any.
|
|
117
|
+
*
|
|
118
|
+
* The detail is looked up by its type URL rather than by position, so a
|
|
119
|
+
* service that adds a detail of its own does not move `reason` out from under
|
|
120
|
+
* a caller.
|
|
121
|
+
*/
|
|
122
|
+
export function errorInfo(error) {
|
|
123
|
+
const found = error.findDetails(ErrorInfoSchema);
|
|
124
|
+
return found.length > 0 ? found[0] : undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Turns any thrown value into the typed hierarchy: a `ConnectError` becomes the
|
|
128
|
+
* class its code names, with `reason` and `metadata` lifted out of the
|
|
129
|
+
* `ErrorInfo` detail; anything else (a fetch rejection, an abort, a bug in
|
|
130
|
+
* the SDK) becomes a `LoamsError` with `Code.Unknown`.
|
|
131
|
+
*/
|
|
132
|
+
export function toLoamsError(value, rpc) {
|
|
133
|
+
if (value instanceof LoamsError) {
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
if (!(value instanceof ConnectError)) {
|
|
137
|
+
return new LoamsError(value instanceof Error ? value.message : String(value), {
|
|
138
|
+
code: Code.Unknown,
|
|
139
|
+
rpc,
|
|
140
|
+
cause: value,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const info = errorInfo(value);
|
|
144
|
+
const raw = info?.reason ?? '';
|
|
145
|
+
const init = {
|
|
146
|
+
code: value.code,
|
|
147
|
+
reason: KNOWN_REASONS.has(raw) ? raw : undefined,
|
|
148
|
+
unknownReason: raw !== '' && !KNOWN_REASONS.has(raw) ? raw : undefined,
|
|
149
|
+
metadata: info?.metadata ?? {},
|
|
150
|
+
hint: info?.hint === '' ? undefined : info?.hint,
|
|
151
|
+
rpc,
|
|
152
|
+
cause: value,
|
|
153
|
+
};
|
|
154
|
+
if (init.reason === 'feature_not_in_variant') {
|
|
155
|
+
return new FeatureNotInVariantError(value.rawMessage, {
|
|
156
|
+
...init,
|
|
157
|
+
variant: init.metadata.variant,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (init.code === Code.Unauthenticated && init.reason === 'token_expired') {
|
|
161
|
+
return new TokenExpiredError(value.rawMessage, init);
|
|
162
|
+
}
|
|
163
|
+
const Constructor = BY_CODE[value.code] ?? LoamsError;
|
|
164
|
+
return new Constructor(value.rawMessage, init);
|
|
165
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CallBinding } from '../gen/facade.js';
|
|
2
|
+
import type { CallOptions } from './call.js';
|
|
3
|
+
/** The field a paged call is given the page token in, on the request. */
|
|
4
|
+
export interface PageRequestOptions {
|
|
5
|
+
/** The request field carrying `page_size`; defaults to `pageSize`. */
|
|
6
|
+
pageSizeField?: string;
|
|
7
|
+
/** The request field carrying `page_token`; defaults to `pageToken`. */
|
|
8
|
+
pageTokenField?: string;
|
|
9
|
+
}
|
|
10
|
+
/** One page request, as the iterator makes it. */
|
|
11
|
+
export type PageFetcher<Request, Response> = (request: Request, options?: CallOptions) => Promise<Response>;
|
|
12
|
+
/**
|
|
13
|
+
* Every item of a paged call, following the tokens (D617's "the paging
|
|
14
|
+
* iterator").
|
|
15
|
+
*
|
|
16
|
+
* The caller gets items, not pages: `for await (const c of loams.paginate(...))`.
|
|
17
|
+
* The raw page call is still available on the module, so a caller that wants
|
|
18
|
+
* pages, or wants to stop after one, does not have to use this.
|
|
19
|
+
*/
|
|
20
|
+
export declare function paginate<Request extends object, Response extends object, Item>(binding: CallBinding, fetch: PageFetcher<Request, Response>, request: Request, options?: CallOptions & PageRequestOptions): AsyncGenerator<Item, void, undefined>;
|
|
21
|
+
/** The request field names a paged call uses, from its binding. */
|
|
22
|
+
export declare function pageFields(binding: CallBinding): {
|
|
23
|
+
pageSizeField: string;
|
|
24
|
+
pageTokenField: string;
|
|
25
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Pagination (design §44 §7.4; runtime contract R6).
|
|
2
|
+
//
|
|
3
|
+
// AIP-158: `page_size` and `page_token` in, `next_page_token` out. A generated
|
|
4
|
+
// binding says which two fields those are (`FacadeOptions.pagination` is
|
|
5
|
+
// `"<items>:<next page token>"`), so the iterator is one function for every
|
|
6
|
+
// paged RPC rather than one per list RPC.
|
|
7
|
+
/**
|
|
8
|
+
* Every item of a paged call, following the tokens (D617's "the paging
|
|
9
|
+
* iterator").
|
|
10
|
+
*
|
|
11
|
+
* The caller gets items, not pages: `for await (const c of loams.paginate(...))`.
|
|
12
|
+
* The raw page call is still available on the module, so a caller that wants
|
|
13
|
+
* pages, or wants to stop after one, does not have to use this.
|
|
14
|
+
*/
|
|
15
|
+
export async function* paginate(binding, fetch, request, options = {}) {
|
|
16
|
+
const pagination = binding.pagination;
|
|
17
|
+
if (pagination === null) {
|
|
18
|
+
throw new Error(`${binding.module}.${binding.name} is not a paged call: the proto's facade options name no pagination`);
|
|
19
|
+
}
|
|
20
|
+
const pageSizeField = options.pageSizeField ?? 'pageSize';
|
|
21
|
+
const pageTokenField = options.pageTokenField ?? 'pageToken';
|
|
22
|
+
let pageToken;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const page = (await fetch({
|
|
25
|
+
...request,
|
|
26
|
+
...(pageToken === undefined ? {} : { [pageTokenField]: pageToken }),
|
|
27
|
+
}, options));
|
|
28
|
+
const items = page[pagination.items];
|
|
29
|
+
if (Array.isArray(items)) {
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
yield item;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const next = page[pagination.nextPageToken];
|
|
35
|
+
if (typeof next !== 'string' || next === '') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
pageToken = next;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The request field names a paged call uses, from its binding. */
|
|
42
|
+
export function pageFields(binding) {
|
|
43
|
+
if (binding.pagination === null) {
|
|
44
|
+
throw new Error(`${binding.module}.${binding.name} is not a paged call`);
|
|
45
|
+
}
|
|
46
|
+
return { pageSizeField: 'pageSize', pageTokenField: 'pageToken' };
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Code } from '@connectrpc/connect';
|
|
2
|
+
import { LoamsError } from './errors.js';
|
|
3
|
+
/** The first backoff, and the multiplier's base. */
|
|
4
|
+
export declare const BASE_DELAY_MS = 100;
|
|
5
|
+
/** The ceiling on one backoff. */
|
|
6
|
+
export declare const MAX_DELAY_MS = 2000;
|
|
7
|
+
/** Retries after the first attempt, when nothing overrides it. */
|
|
8
|
+
export declare const DEFAULT_MAX_RETRIES = 3;
|
|
9
|
+
/** `RetryInfo.retry_delay` is honoured up to this, when the server sends one. */
|
|
10
|
+
export declare const MAX_SERVER_DELAY_MS = 30000;
|
|
11
|
+
/** Whether the code is one a retry may answer. */
|
|
12
|
+
export declare function isRetryableCode(code: Code): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The backoff before retry number `attempt` (0 for the first retry), with full
|
|
15
|
+
* jitter: uniform over `[0, min(cap, base × 2^attempt)]`. A server-sent
|
|
16
|
+
* `RetryInfo.retry_delay` replaces it, up to {@link MAX_SERVER_DELAY_MS}.
|
|
17
|
+
*/
|
|
18
|
+
export declare function backoffMs(attempt: number, serverDelayMs?: number): number;
|
|
19
|
+
/** Sleeps, for the retry loop and for the tests that would otherwise wait. */
|
|
20
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Whether one more attempt is allowed.
|
|
23
|
+
*
|
|
24
|
+
* `retrySafe` is the call's class from the generated bindings: `safe` for
|
|
25
|
+
* reads and idempotent RPCs, `manual` for a mutation. A mutation becomes
|
|
26
|
+
* retryable once it carries an idempotency key, because the key is what makes
|
|
27
|
+
* the repeat safe — see `callWithRetry` in `call.ts`, which sets one before the
|
|
28
|
+
* first attempt and reuses it on every retry.
|
|
29
|
+
*/
|
|
30
|
+
export declare function shouldRetry(error: LoamsError, retrySafe: boolean, attempt: number, maxRetries: number): boolean;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Retry classes and backoff (design §44 §7.4, D610; runtime contract R2).
|
|
2
|
+
//
|
|
3
|
+
// The numbers are M1.6 Ruling 5's and are the same in every SDK: base 100 ms,
|
|
4
|
+
// doubling, capped at 2 s, 3 retries, full jitter. Full jitter rather than
|
|
5
|
+
// exponential backoff alone, because every client retrying at the same instant
|
|
6
|
+
// after a node restart is how a recovering node gets knocked over again.
|
|
7
|
+
import { Code } from '@connectrpc/connect';
|
|
8
|
+
import { LoamsError } from './errors.js';
|
|
9
|
+
/** The first backoff, and the multiplier's base. */
|
|
10
|
+
export const BASE_DELAY_MS = 100;
|
|
11
|
+
/** The ceiling on one backoff. */
|
|
12
|
+
export const MAX_DELAY_MS = 2_000;
|
|
13
|
+
/** Retries after the first attempt, when nothing overrides it. */
|
|
14
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
15
|
+
/** `RetryInfo.retry_delay` is honoured up to this, when the server sends one. */
|
|
16
|
+
export const MAX_SERVER_DELAY_MS = 30_000;
|
|
17
|
+
/** The codes a retry may answer (D610). */
|
|
18
|
+
const RETRYABLE = new Set([
|
|
19
|
+
Code.Unavailable,
|
|
20
|
+
Code.DeadlineExceeded,
|
|
21
|
+
Code.ResourceExhausted,
|
|
22
|
+
]);
|
|
23
|
+
/** Whether the code is one a retry may answer. */
|
|
24
|
+
export function isRetryableCode(code) {
|
|
25
|
+
return RETRYABLE.has(code);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The backoff before retry number `attempt` (0 for the first retry), with full
|
|
29
|
+
* jitter: uniform over `[0, min(cap, base × 2^attempt)]`. A server-sent
|
|
30
|
+
* `RetryInfo.retry_delay` replaces it, up to {@link MAX_SERVER_DELAY_MS}.
|
|
31
|
+
*/
|
|
32
|
+
export function backoffMs(attempt, serverDelayMs) {
|
|
33
|
+
if (serverDelayMs !== undefined && serverDelayMs > 0) {
|
|
34
|
+
return Math.min(serverDelayMs, MAX_SERVER_DELAY_MS);
|
|
35
|
+
}
|
|
36
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
37
|
+
return Math.floor(Math.random() * ceiling);
|
|
38
|
+
}
|
|
39
|
+
/** Sleeps, for the retry loop and for the tests that would otherwise wait. */
|
|
40
|
+
export function sleep(ms, signal) {
|
|
41
|
+
if (ms <= 0) {
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
signal?.removeEventListener('abort', onAbort);
|
|
47
|
+
resolve();
|
|
48
|
+
}, ms);
|
|
49
|
+
const onAbort = () => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
reject(signal?.reason ?? new Error('aborted'));
|
|
52
|
+
};
|
|
53
|
+
if (signal?.aborted === true) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether one more attempt is allowed.
|
|
63
|
+
*
|
|
64
|
+
* `retrySafe` is the call's class from the generated bindings: `safe` for
|
|
65
|
+
* reads and idempotent RPCs, `manual` for a mutation. A mutation becomes
|
|
66
|
+
* retryable once it carries an idempotency key, because the key is what makes
|
|
67
|
+
* the repeat safe — see `callWithRetry` in `call.ts`, which sets one before the
|
|
68
|
+
* first attempt and reuses it on every retry.
|
|
69
|
+
*/
|
|
70
|
+
export function shouldRetry(error, retrySafe, attempt, maxRetries) {
|
|
71
|
+
if (attempt >= maxRetries) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return retrySafe && isRetryableCode(error.code);
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CallBinding } from '../gen/facade.js';
|
|
2
|
+
import type { CallOptions } from './call.js';
|
|
3
|
+
/** Opens a stream. The runtime owns retries, so this is the raw call. */
|
|
4
|
+
export type StreamOpener<Message, Request = unknown> = (request: Request, options?: CallOptions) => AsyncIterable<Message>;
|
|
5
|
+
/** The cursor a stream message carries, when it carries one (R7). */
|
|
6
|
+
export type CursorOf<Message> = (message: Message) => string | undefined;
|
|
7
|
+
/** How a stream re-opens from a cursor. */
|
|
8
|
+
export interface ResumeOptions<Message, Request> {
|
|
9
|
+
/**
|
|
10
|
+
* The request to re-open with, given the last cursor seen. Return the same
|
|
11
|
+
* request to reconnect from the beginning (which is correct, and loses
|
|
12
|
+
* nothing but time, for a stream whose snapshot is complete).
|
|
13
|
+
*/
|
|
14
|
+
resume: (lastCursor: string | undefined, request: Request) => Request;
|
|
15
|
+
/** Reads the cursor off a message. Defaults to a `cursor` field. */
|
|
16
|
+
cursor?: CursorOf<Message>;
|
|
17
|
+
/** Retries after the first failure. Defaults to the client's. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
/** Called after each message, with the cursor it carried. */
|
|
20
|
+
onCursor?: (cursor: string | undefined, message: Message) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A server stream that reconnects from its cursor.
|
|
24
|
+
*
|
|
25
|
+
* `loams.live.watch()` returns one of these. It yields until the stream ends;
|
|
26
|
+
* if the stream fails with a code a retry may answer and the call is retryable,
|
|
27
|
+
* it re-opens from the last cursor and carries on, so a node restart is a
|
|
28
|
+
* hiccup rather than a gap.
|
|
29
|
+
*/
|
|
30
|
+
export declare function watch<Message, Request extends object>(binding: CallBinding, open: StreamOpener<Message, Request>, request: Request, options: CallOptions & ResumeOptions<Message, Request>): AsyncGenerator<Message, void, undefined>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Server streams (design §44 §7.4, D610; runtime contract R7).
|
|
2
|
+
//
|
|
3
|
+
// The API has server streams only (D420): no client streaming, no bidi, because
|
|
4
|
+
// a browser cannot do it over fetch and half-duplex works through every proxy.
|
|
5
|
+
// In TypeScript a server stream is an `AsyncIterable` (design §44 §7.1).
|
|
6
|
+
//
|
|
7
|
+
// A stream is the one call where a retry is not just "send it again": the
|
|
8
|
+
// server hands out cursors, and a reconnect has to resume from the last one or
|
|
9
|
+
// the client silently misses the changes in between. So `watch` tracks the
|
|
10
|
+
// cursor of every message and, on a retryable failure, re-opens the stream from
|
|
11
|
+
// it. The request that resumes is the stream's own business — `loams.live`'s
|
|
12
|
+
// `WatchRequest.resume`, for instance — so the caller passes a `resume`
|
|
13
|
+
// function and the runtime supplies the cursor.
|
|
14
|
+
import { toLoamsError } from './errors.js';
|
|
15
|
+
import { DEFAULT_MAX_RETRIES, backoffMs, shouldRetry, sleep } from './retry.js';
|
|
16
|
+
/** The default cursor reader: a `cursor` field, which is what every stream in
|
|
17
|
+
* the API uses (§44 §7.4). */
|
|
18
|
+
function cursorField(message) {
|
|
19
|
+
const value = message.cursor;
|
|
20
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A server stream that reconnects from its cursor.
|
|
24
|
+
*
|
|
25
|
+
* `loams.live.watch()` returns one of these. It yields until the stream ends;
|
|
26
|
+
* if the stream fails with a code a retry may answer and the call is retryable,
|
|
27
|
+
* it re-opens from the last cursor and carries on, so a node restart is a
|
|
28
|
+
* hiccup rather than a gap.
|
|
29
|
+
*/
|
|
30
|
+
export async function* watch(binding, open, request, options) {
|
|
31
|
+
const cursorOf = options.cursor ?? cursorField;
|
|
32
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
33
|
+
const retrySafe = options.retrySafe ?? true;
|
|
34
|
+
let current = request;
|
|
35
|
+
let cursor;
|
|
36
|
+
let attempt = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
try {
|
|
39
|
+
for await (const message of open(current, options)) {
|
|
40
|
+
// Progress earns a fresh budget: `maxRetries` bounds the retries in
|
|
41
|
+
// one *run* of disconnects, not for the life of the stream. A watch
|
|
42
|
+
// that recovers from a node restart and then runs for days must not
|
|
43
|
+
// spend its budget on the first failure of each of those days.
|
|
44
|
+
attempt = 0;
|
|
45
|
+
cursor = cursorOf(message) ?? cursor;
|
|
46
|
+
options.onCursor?.(cursor, message);
|
|
47
|
+
yield message;
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
catch (thrown) {
|
|
52
|
+
const error = toLoamsError(thrown, binding.rpc);
|
|
53
|
+
if (!shouldRetry(error, retrySafe, attempt, maxRetries)) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
current = options.resume(cursor, request);
|
|
57
|
+
await sleep(backoffMs(attempt), options.signal);
|
|
58
|
+
attempt += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|