@procedures/client 0.17.4 → 0.18.1
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/CHANGELOG.md +77 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +9 -6
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +5 -23
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/http/server/ndjson.d.ts +3 -1
- package/dist/adapters/http/server/ndjson.d.ts.map +1 -1
- package/dist/adapters/http/server/ndjson.js +3 -1
- package/dist/adapters/http/server/ndjson.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts +2 -2
- package/dist/adapters/http/server/transport.js +2 -2
- package/dist/adapters/websocket/shared/rpc.d.ts +5 -1
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js +9 -3
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -1
- package/dist/client/middleware/items.d.ts.map +1 -1
- package/dist/client/middleware/items.js +2 -0
- package/dist/client/middleware/items.js.map +1 -1
- package/dist/procedures/collection/index.d.ts +1 -0
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +2 -0
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/partition.d.ts +48 -0
- package/dist/procedures/collection/partition.d.ts.map +1 -1
- package/dist/procedures/collection/partition.js +66 -0
- package/dist/procedures/collection/partition.js.map +1 -1
- package/dist/procedures/collection/topic.d.ts +2 -0
- package/dist/procedures/collection/topic.d.ts.map +1 -1
- package/dist/procedures/collection/topic.js.map +1 -1
- package/dist/procedures/index.d.ts +1 -1
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +2 -0
- package/dist/procedures/index.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +24 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/browser-guard.d.ts +13 -3
- package/dist/server/browser-guard.d.ts.map +1 -1
- package/dist/server/browser-guard.js +11 -5
- package/dist/server/browser-guard.js.map +1 -1
- package/dist/server/procedure-server.d.ts +24 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +29 -1
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +3 -2
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +2 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +3 -2
- package/dist/server/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/client/transport.ts +9 -6
- package/src/adapters/http/server/handler.ts +9 -28
- package/src/adapters/http/server/ndjson.ts +3 -1
- package/src/adapters/http/server/transport.ts +2 -2
- package/src/adapters/websocket/shared/rpc.ts +9 -3
- package/src/browser.ts +2 -0
- package/src/client/middleware/items.ts +2 -0
- package/src/procedures/collection/index.ts +2 -0
- package/src/procedures/collection/partition.ts +75 -0
- package/src/procedures/collection/topic.ts +2 -0
- package/src/procedures/index.ts +2 -0
- package/src/protocol.ts +26 -0
- package/src/server/browser-guard.ts +27 -7
- package/src/server/procedure-server.ts +35 -2
- package/src/server/server.ts +3 -2
- package/src/server/types.ts +3 -2
|
@@ -17,6 +17,7 @@ import type { WebSocketMessage } from "../server/types.js";
|
|
|
17
17
|
import { withoutInternalKeys } from "../../metadata.js";
|
|
18
18
|
import { StreamSequence, gapOf, itemMetadata } from "../../sequence.js";
|
|
19
19
|
import { DELIVERY_METADATA_KEY } from "../../../client/middleware/items.js";
|
|
20
|
+
import { foldedErrorCode } from "../../../protocol.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* A queue of response items that one reader takes in order: push, then end or fail. The reader
|
|
@@ -99,7 +100,11 @@ export class StreamCredit {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The error of a failed stream or handler: its code (a string) and its retryable flag. An error
|
|
105
|
+
* without a flag gets false, not the flag of its code: the end of a stream is in-band, and a retry
|
|
106
|
+
* can send the items again (CLI-58, `WIRE-PROTOCOL.md`, section "Error codes").
|
|
107
|
+
*/
|
|
103
108
|
export function errorStatus(error: unknown): { type: "error"; code: string; message: string; retryable: boolean } {
|
|
104
109
|
const code = (error as { code?: unknown } | undefined)?.code;
|
|
105
110
|
const retryable = (error as { retryable?: unknown } | undefined)?.retryable;
|
|
@@ -122,13 +127,14 @@ function textOf(value: unknown): string {
|
|
|
122
127
|
export function itemOfFrame(frame: WebSocketMessage, messageId: string): ResponseItem<unknown> {
|
|
123
128
|
let status: ResponseItem<unknown>["status"];
|
|
124
129
|
if (frame.status && frame.status.type === "error") {
|
|
125
|
-
|
|
130
|
+
// A code of an older server becomes its code of wire v1 (CLI-4): "500" is INTERNAL_ERROR
|
|
131
|
+
status = { type: "error", code: foldedErrorCode(String(frame.status.code)).code, message: textOf(frame.status.message), retryable: frame.status.retryable || false };
|
|
126
132
|
} else if (frame.status && frame.status.type === "success") {
|
|
127
133
|
status = { type: "success", code: Number(frame.status.code) };
|
|
128
134
|
} else if (frame.type === "error") {
|
|
129
135
|
status = {
|
|
130
136
|
type: "error",
|
|
131
|
-
code: String(frame.error?.code || "UNKNOWN_ERROR"),
|
|
137
|
+
code: foldedErrorCode(String(frame.error?.code || "UNKNOWN_ERROR")).code,
|
|
132
138
|
message: textOf(frame.error?.message),
|
|
133
139
|
retryable: frame.error?.retryable || false,
|
|
134
140
|
};
|
package/src/browser.ts
CHANGED
|
@@ -134,6 +134,8 @@ export {
|
|
|
134
134
|
collectionTopicPartition,
|
|
135
135
|
collectionTopicTarget,
|
|
136
136
|
collectionTopicRule,
|
|
137
|
+
// The refusal of a storage whose topic is the topic of a partition (roadmap CLI-57)
|
|
138
|
+
CollectionTopicCollisionError,
|
|
137
139
|
// The rules of the rows of a collection (roadmap CLI-41)
|
|
138
140
|
admittedStorage,
|
|
139
141
|
admitsRow,
|
|
@@ -55,6 +55,8 @@ export function retryAfterOf(item: ResponseItem<unknown>): number | undefined {
|
|
|
55
55
|
const AVAILABILITY_CODES = new Set([
|
|
56
56
|
"UNAVAILABLE",
|
|
57
57
|
"TIMEOUT",
|
|
58
|
+
// The code of a stopped server before CLI-4. It is UNAVAILABLE now. The next breaking release
|
|
59
|
+
// of the client group removes it.
|
|
58
60
|
"SERVER_STOPPED",
|
|
59
61
|
"SERVICE_UNAVAILABLE",
|
|
60
62
|
"BAD_GATEWAY",
|
|
@@ -72,6 +72,8 @@ export type { CollectionChangeEvent } from "./changes.js";
|
|
|
72
72
|
|
|
73
73
|
// The partitions of a collection (roadmap CLI-17)
|
|
74
74
|
export { PARTITION_PATTERN, collectionTopicPartition, collectionTopicTarget, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
|
|
75
|
+
// The refusal of a storage whose topic is the topic of a partition (roadmap CLI-57, decision D23)
|
|
76
|
+
export { CollectionTopicCollisionError } from "./partition.js";
|
|
75
77
|
// The topic rule of a collection for EventPublisher (roadmap CLI-42)
|
|
76
78
|
export { collectionTopicRule } from "./topic.js";
|
|
77
79
|
export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
|
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
* - In the topic of a partition, the name encodes `%` and `.` (`topic.ts`, roadmap CLI-42). Thus
|
|
15
15
|
* the name of a collection with partitions can have a dot.
|
|
16
16
|
* - A collection without partitions refuses a call or a topic with a partition.
|
|
17
|
+
* - The plain topic of a dotted name, `collections.a.b`, is also the topic of the partition `b` of
|
|
18
|
+
* `a`. Thus the server refuses a storage of `a.b` while `a` has partitions (roadmap CLI-57,
|
|
19
|
+
* decision D23).
|
|
17
20
|
*/
|
|
18
21
|
import type { ZodLike } from "../../client/validation/types.js";
|
|
19
22
|
import { InvocationError } from "../invoke.js";
|
|
20
23
|
import type { ProcedureContext } from "../types.js";
|
|
21
24
|
import type { CollectionStorage } from "./storage.js";
|
|
25
|
+
import { collectionChangeTopic, collectionTopicPartition } from "./topic.js";
|
|
22
26
|
|
|
23
27
|
/** The characters of a partition: letters, digits and `_ . : @ ~ + | / -`, 256 at most. */
|
|
24
28
|
export const PARTITION_PATTERN: RegExp = /^[A-Za-z0-9_.:@~+|/-]{1,256}$/;
|
|
@@ -131,5 +135,76 @@ export function storageOfCall<T>(ctx: ProcedureContext, collection: string, part
|
|
|
131
135
|
return repository.getPartitionStorage<T>(collection, partition);
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The error of a server that serves two collections with the same topic (roadmap CLI-57, decision
|
|
140
|
+
* D23). The plain topic of a collection with a storage, `collections.a.b`, is also the topic of the
|
|
141
|
+
* partition `b` of the partitioned collection `a`. Thus the readers of `a.b` get the events of the
|
|
142
|
+
* tenant `b` of `a`. To correct it, rename the collection `a.b` to a name without the prefix `a.`.
|
|
143
|
+
*/
|
|
144
|
+
export class CollectionTopicCollisionError extends Error {
|
|
145
|
+
override readonly name = "CollectionTopicCollisionError";
|
|
146
|
+
/** The collection with a storage without a partition. Its topic is `collections.<collection>`. */
|
|
147
|
+
readonly collection: string;
|
|
148
|
+
/** The collection with partitions. */
|
|
149
|
+
readonly partitioned: string;
|
|
150
|
+
/** The partition of `partitioned` whose topic is the topic of `collection`. */
|
|
151
|
+
readonly partition: string;
|
|
152
|
+
/** The topic of the two. */
|
|
153
|
+
readonly topic: string;
|
|
154
|
+
|
|
155
|
+
constructor(collection: string, partitioned: string, partition: string) {
|
|
156
|
+
super(collisionMessage(collection, partitioned, partition));
|
|
157
|
+
this.collection = collection;
|
|
158
|
+
this.partitioned = partitioned;
|
|
159
|
+
this.partition = partition;
|
|
160
|
+
this.topic = collectionChangeTopic(collection);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The message of a `CollectionTopicCollisionError`: the two collections, the topic and the fix. */
|
|
165
|
+
function collisionMessage(collection: string, partitioned: string, partition: string): string {
|
|
166
|
+
// The plain name is the encoded name of the partitioned collection, a dot and the partition
|
|
167
|
+
const prefix = collection.slice(0, collection.length - partition.length);
|
|
168
|
+
return (
|
|
169
|
+
`The collection "${collection}" and the partition "${partition}" of the collection "${partitioned}" have the same topic "${collectionChangeTopic(collection)}", ` +
|
|
170
|
+
`so the readers of "${collection}" get the events of that partition (roadmap CLI-57). ` +
|
|
171
|
+
`Rename the collection "${collection}" to a name that does not start with "${prefix}", for example "${collection.replace(/\./g, "_")}".`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* This function gives the partition whose topic is the plain topic of a collection, or undefined
|
|
177
|
+
* (roadmap CLI-57). The plain topic of `a.b` is `collections.a.b`, the topic of the partition `b`
|
|
178
|
+
* of `a`. A name without a dot gives undefined. It uses the parse of the publisher, so the two agree.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```typescript
|
|
182
|
+
* partitionOfPlainTopic("a.b.c"); // { collection: "a", partition: "b.c" }
|
|
183
|
+
* partitionOfPlainTopic("a%2Eb.c"); // { collection: "a.b", partition: "c" }
|
|
184
|
+
* partitionOfPlainTopic("ab"); // undefined
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function partitionOfPlainTopic(collection: string): { collection: string; partition: string } | undefined {
|
|
188
|
+
const target = collectionTopicPartition(collectionChangeTopic(collection));
|
|
189
|
+
return target?.partition === undefined ? undefined : { collection: target.collection, partition: target.partition };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* This function refuses two collections with the same topic (roadmap CLI-57, decision D23). It
|
|
194
|
+
* examines each collection with a storage: its plain topic must not be the topic of a partition of
|
|
195
|
+
* a collection with partitions. `ProcedureServer` uses it for each new storage and for each new
|
|
196
|
+
* collection with partitions, in each order.
|
|
197
|
+
*
|
|
198
|
+
* @param plain - The collections with a storage without a partition
|
|
199
|
+
* @param hasPartitions - True for a collection with partitions
|
|
200
|
+
* @throws CollectionTopicCollisionError for the first pair with the same topic
|
|
201
|
+
*/
|
|
202
|
+
export function assertTopicsApart(plain: Iterable<string>, hasPartitions: (collection: string) => boolean): void {
|
|
203
|
+
for (const collection of plain) {
|
|
204
|
+
const shared = partitionOfPlainTopic(collection);
|
|
205
|
+
if (shared && hasPartitions(shared.collection)) throw new CollectionTopicCollisionError(collection, shared.collection, shared.partition);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
134
209
|
// The parse of a topic lives in topic.ts, with the encode (roadmap CLI-42)
|
|
135
210
|
export { collectionTopicPartition, collectionTopicTarget } from "./topic.js";
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - The topic of a collection whose name has a dot, `collections.a.b`, has the same form as the
|
|
17
17
|
* topic of the partition `b` of the collection `a`. Only the repository knows the collection, so
|
|
18
18
|
* `collectionTopicTarget()` decides with it.
|
|
19
|
+
* - `ProcedureServer` refuses a storage of `a.b` while `a` has partitions, in each order (roadmap
|
|
20
|
+
* CLI-57, decision D23). Thus the readers of `a.b` do not get the events of a partition of `a`.
|
|
19
21
|
*/
|
|
20
22
|
import type { RepositoryProvider } from "../types.js";
|
|
21
23
|
|
package/src/procedures/index.ts
CHANGED
|
@@ -134,6 +134,8 @@ export {
|
|
|
134
134
|
collectionTopicPartition,
|
|
135
135
|
collectionTopicTarget,
|
|
136
136
|
collectionTopicRule,
|
|
137
|
+
// The refusal of a storage whose topic is the topic of a partition (roadmap CLI-57)
|
|
138
|
+
CollectionTopicCollisionError,
|
|
137
139
|
// The rules of the rows of a collection (roadmap CLI-41)
|
|
138
140
|
admittedStorage,
|
|
139
141
|
admitsRow,
|
package/src/protocol.ts
CHANGED
|
@@ -101,6 +101,32 @@ export function errorCodeOf(error: unknown): string {
|
|
|
101
101
|
return typeof code === "string" ? code : "HANDLER_ERROR";
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** The codes that a server before CLI-4 gave, and the code and the reason of wire v1 for each. */
|
|
105
|
+
const FOLDED_ERROR_CODES: ReadonlyMap<string, { readonly code: ProtocolErrorCode; readonly reason?: string }> = new Map([
|
|
106
|
+
["SERVER_STOPPED", { code: "UNAVAILABLE" }],
|
|
107
|
+
["FORBIDDEN_HOST", { code: "FORBIDDEN", reason: "host-header-not-allowed" }],
|
|
108
|
+
["FORBIDDEN_ORIGIN", { code: "FORBIDDEN", reason: "origin-not-allowed" }],
|
|
109
|
+
["500", { code: "INTERNAL_ERROR" }],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This function gives the code of wire v1 for an error code that a server before CLI-4 gave, and
|
|
114
|
+
* the reason of a `FORBIDDEN` error. For each other code, it gives the code and no reason. The
|
|
115
|
+
* client reads an old code as its new code, so an application can match the new code also with an
|
|
116
|
+
* older server.
|
|
117
|
+
*
|
|
118
|
+
* `METHOD_NOT_ALLOWED` and `UNSUPPORTED_MEDIA_TYPE` stay: they are also the codes of the HTTP
|
|
119
|
+
* statuses 405 and 415 of each server.
|
|
120
|
+
*
|
|
121
|
+
* The client reads the old codes for one release. The next breaking release of the client group
|
|
122
|
+
* removes this function.
|
|
123
|
+
*
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
export function foldedErrorCode(code: string): { readonly code: string; readonly reason?: string | undefined } {
|
|
127
|
+
return FOLDED_ERROR_CODES.get(code) ?? { code };
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
/** The HTTP status of an error code, or undefined for a code that is not in the list. */
|
|
105
131
|
export function httpStatusOf(code: unknown): number | undefined {
|
|
106
132
|
return isProtocolErrorCode(code) ? ERROR_CODES[code].http : undefined;
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* - A request with ambient credentials (the cookies that a browser sends by itself) is trusted
|
|
19
19
|
* only from a named origin. `"*"` does not open the origin check for it (CSRF). A list or a
|
|
20
20
|
* function of origins still does.
|
|
21
|
+
*
|
|
22
|
+
* Each refusal is the error `FORBIDDEN` of wire v1, with the status 403 and a reason (CLI-4).
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import type { IncomingHttpHeaders } from "node:http";
|
|
@@ -53,10 +55,28 @@ export interface GuardedRequest {
|
|
|
53
55
|
socket?: { localAddress?: string | undefined } | undefined;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The result of the checks: the request passes, or the HTTP status and the error to send.
|
|
60
|
+
*
|
|
61
|
+
* A refusal is the error `FORBIDDEN` of wire v1 with the status 403. Its `reason` tells which
|
|
62
|
+
* check refused the request. Before CLI-4, the checks gave the codes `FORBIDDEN_HOST`,
|
|
63
|
+
* `FORBIDDEN_ORIGIN`, `METHOD_NOT_ALLOWED` (405) and `UNSUPPORTED_MEDIA_TYPE` (415).
|
|
64
|
+
*/
|
|
57
65
|
export type GuardResult =
|
|
58
66
|
| { readonly ok: true }
|
|
59
|
-
| {
|
|
67
|
+
| {
|
|
68
|
+
readonly ok: false;
|
|
69
|
+
readonly status: 403;
|
|
70
|
+
readonly code: "FORBIDDEN";
|
|
71
|
+
/** The check that refused the request, as in `documentation/WIRE-PROTOCOL.md`. */
|
|
72
|
+
readonly reason: "host-header-not-allowed" | "origin-not-allowed" | "method-not-allowed" | "media-type-not-allowed";
|
|
73
|
+
readonly message: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The refusal of a check: the error `FORBIDDEN` of wire v1 with its reason (CLI-4). */
|
|
77
|
+
function refusal(reason: Extract<GuardResult, { ok: false }>["reason"], message: string): Extract<GuardResult, { ok: false }> {
|
|
78
|
+
return { ok: false, status: 403, code: "FORBIDDEN", reason, message };
|
|
79
|
+
}
|
|
60
80
|
|
|
61
81
|
/** True for a host name or a bind address that only this computer can reach. */
|
|
62
82
|
export function isLoopbackHost(host: string): boolean {
|
|
@@ -159,29 +179,29 @@ export function checkBrowserRequest(
|
|
|
159
179
|
if (options.allowedHosts !== "*" && isLoopbackAddress(req.socket?.localAddress)) {
|
|
160
180
|
const name = hostNameOf(headers.host);
|
|
161
181
|
if (!LOOPBACK_NAMES.has(name) && !(options.allowedHosts ?? []).includes(name)) {
|
|
162
|
-
return
|
|
182
|
+
return refusal("host-header-not-allowed", `Forbidden: the host "${name}" is not allowed`);
|
|
163
183
|
}
|
|
164
184
|
}
|
|
165
185
|
|
|
166
186
|
const origin = typeof headers.origin === "string" ? headers.origin : undefined;
|
|
167
187
|
if (origin !== undefined) {
|
|
168
188
|
if (!sameOrigin(origin, headers.host) && !originAllowedBy(origin, options.allowedOrigins, ambient)) {
|
|
169
|
-
return
|
|
189
|
+
return refusal("origin-not-allowed", `Forbidden: the origin "${origin}" is not allowed`);
|
|
170
190
|
}
|
|
171
191
|
} else {
|
|
172
192
|
const site = headers["sec-fetch-site"];
|
|
173
193
|
if ((site === "cross-site" || site === "same-site") && (options.allowedOrigins !== "*" || ambient)) {
|
|
174
|
-
return
|
|
194
|
+
return refusal("origin-not-allowed", "Forbidden: a request from another site");
|
|
175
195
|
}
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
if (http) {
|
|
179
199
|
const method = (req.method ?? "GET").toUpperCase();
|
|
180
200
|
if ((method === "GET" || method === "HEAD") && !options.allowGet) {
|
|
181
|
-
return
|
|
201
|
+
return refusal("method-not-allowed", "Forbidden: call a procedure with POST");
|
|
182
202
|
}
|
|
183
203
|
if (hasBody(headers) && !isJsonType(headers["content-type"])) {
|
|
184
|
-
return
|
|
204
|
+
return refusal("media-type-not-allowed", "Forbidden: the request body must be JSON (Content-Type: application/json)");
|
|
185
205
|
}
|
|
186
206
|
}
|
|
187
207
|
|
|
@@ -19,7 +19,7 @@ import { ProcedureRegistry, PROCEDURE_REGISTRY } from "../procedures/registry.js
|
|
|
19
19
|
import { createCollectionProcedures } from "../procedures/collection/procedures.js";
|
|
20
20
|
import { InvocationError, invokeProcedure, type AdmitItem, type Interceptor, type Invocation } from "../procedures/invoke.js";
|
|
21
21
|
import type { Capability, ClearanceOrder, ProcedureContext, ResourcesOf } from "../procedures/types.js";
|
|
22
|
-
import type
|
|
22
|
+
import { assertTopicsApart, type CollectionPartitions } from "../procedures/collection/partition.js";
|
|
23
23
|
import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../procedures/collection/precondition.js";
|
|
24
24
|
import { TIMEOUT_METADATA_KEY, effectsOf, joinNarrowing, narrowCapability } from "../procedures/capability.js";
|
|
25
25
|
import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptions } from "../procedures/idempotency.js";
|
|
@@ -343,6 +343,8 @@ function withRequestTimeout(
|
|
|
343
343
|
* (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
|
|
344
344
|
* when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
|
|
345
345
|
* An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
|
|
346
|
+
* This default applies only before the stream starts. An in-band end without a flag gets false
|
|
347
|
+
* (CLI-58, `WIRE-PROTOCOL.md`, section "Error codes").
|
|
346
348
|
*/
|
|
347
349
|
export function errorResponse(id: string, error: unknown): ServerResponse<unknown> {
|
|
348
350
|
// An error that says it is retryable stays retryable: before, every error lost the flag
|
|
@@ -495,7 +497,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
495
497
|
this.publisher = events instanceof EventPublisher ? events : events ? new EventPublisher({ ...events, clock: events.clock ?? this.clock }) : undefined;
|
|
496
498
|
if (this.publisher) this.registerProcedure(this.publisher.procedure, { alwaysOn: true });
|
|
497
499
|
|
|
498
|
-
// Configure collections
|
|
500
|
+
// Configure collections. A new server has no partitions yet: usePartitions() examines these
|
|
501
|
+
// storages for a shared topic (roadmap CLI-57)
|
|
499
502
|
if (options.collections) {
|
|
500
503
|
for (const [name, storage] of Object.entries(options.collections)) {
|
|
501
504
|
this.storages.set(name, storage);
|
|
@@ -577,15 +580,31 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
577
580
|
* The name can have a dot (roadmap CLI-42). The topic of a partition encodes the name, so the
|
|
578
581
|
* name and the partition of a topic stay apart (`collectionChangeTopic()`).
|
|
579
582
|
*
|
|
583
|
+
* The server refuses partitions of `a` while it has a storage of `a.b` (roadmap CLI-57, decision
|
|
584
|
+
* D23). The plain topic of `a.b` is the topic of the partition `b` of `a`. `useStorage` refuses
|
|
585
|
+
* the other order.
|
|
586
|
+
*
|
|
580
587
|
* @param name - Collection name
|
|
581
588
|
* @param partitions - The storage of each partition, and the resolver
|
|
582
589
|
* @returns this (for chaining)
|
|
590
|
+
* @throws CollectionTopicCollisionError when the topic of a partition is the topic of a collection with a storage
|
|
583
591
|
*/
|
|
584
592
|
usePartitions<T>(name: string, partitions: CollectionPartitions<T>): this {
|
|
593
|
+
assertTopicsApart(this.storages.keys(), (collection) => collection === name);
|
|
585
594
|
this.partitions.set(name, partitions as CollectionPartitions<unknown>);
|
|
586
595
|
return this;
|
|
587
596
|
}
|
|
588
597
|
|
|
598
|
+
/**
|
|
599
|
+
* The check of the topics of new storages (roadmap CLI-57, decision D23). The plain topic of each
|
|
600
|
+
* one must not be the topic of a partition of a collection with partitions.
|
|
601
|
+
*
|
|
602
|
+
* @throws CollectionTopicCollisionError for the first pair with the same topic
|
|
603
|
+
*/
|
|
604
|
+
private assertPlainTopicsApart(names: Iterable<string>): void {
|
|
605
|
+
assertTopicsApart(names, (collection) => this.partitions.has(collection));
|
|
606
|
+
}
|
|
607
|
+
|
|
589
608
|
// ===========================================================================
|
|
590
609
|
// Storage Configuration
|
|
591
610
|
// ===========================================================================
|
|
@@ -606,15 +625,21 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
606
625
|
* that took the old storage, for the time of `grace` at most. Before, such a call could fail
|
|
607
626
|
* because the storage was closed.
|
|
608
627
|
*
|
|
628
|
+
* The server refuses a storage of `a.b` while `a` has partitions (roadmap CLI-57, decision D23).
|
|
629
|
+
* The plain topic of `a.b` is the topic of the partition `b` of `a`. With options, the promise
|
|
630
|
+
* rejects, and the old storage stays as it is.
|
|
631
|
+
*
|
|
609
632
|
* @param name - Collection name
|
|
610
633
|
* @param storage - Storage backend
|
|
611
634
|
* @param options - Drain and close the old storage
|
|
612
635
|
* @returns this (for chaining), or a promise of it when options are given
|
|
636
|
+
* @throws CollectionTopicCollisionError when the topic of the collection is the topic of a partition
|
|
613
637
|
*/
|
|
614
638
|
useStorage<T>(name: string, storage: CollectionStorage<T>): this;
|
|
615
639
|
useStorage<T>(name: string, storage: CollectionStorage<T>, options: UseStorageOptions): Promise<this>;
|
|
616
640
|
useStorage<T>(name: string, storage: CollectionStorage<T>, options?: UseStorageOptions): this | Promise<this> {
|
|
617
641
|
if (options === undefined) {
|
|
642
|
+
this.assertPlainTopicsApart([name]);
|
|
618
643
|
this.storages.set(name, storage as CollectionStorage<unknown>);
|
|
619
644
|
return this;
|
|
620
645
|
}
|
|
@@ -626,6 +651,9 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
626
651
|
* the old storage, and close it.
|
|
627
652
|
*/
|
|
628
653
|
private async replaceStorage(name: string, storage: CollectionStorage<unknown>, options: UseStorageOptions): Promise<this> {
|
|
654
|
+
// Before the flush: a refused storage changes nothing (roadmap CLI-57). With an old storage,
|
|
655
|
+
// usePartitions() sees the name during the flush. Without one, no await comes before the set.
|
|
656
|
+
this.assertPlainTopicsApart([name]);
|
|
629
657
|
const graceMs = optionalMs(options.grace, "grace") ?? TIMER_DEFAULTS.server.storageGraceMs;
|
|
630
658
|
const old = this.storages.get(name);
|
|
631
659
|
const other = old !== undefined && old !== storage ? old : undefined;
|
|
@@ -697,10 +725,15 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
697
725
|
/**
|
|
698
726
|
* Configure multiple storages at once.
|
|
699
727
|
*
|
|
728
|
+
* The server examines all the names before it sets a storage: a refused name sets none (roadmap
|
|
729
|
+
* CLI-57, decision D23).
|
|
730
|
+
*
|
|
700
731
|
* @param storages - Map of collection names to storage backends
|
|
701
732
|
* @returns this (for chaining)
|
|
733
|
+
* @throws CollectionTopicCollisionError when the topic of a collection is the topic of a partition
|
|
702
734
|
*/
|
|
703
735
|
useStorages(storages: Record<string, CollectionStorage<unknown>>): this {
|
|
736
|
+
this.assertPlainTopicsApart(Object.keys(storages));
|
|
704
737
|
for (const [name, storage] of Object.entries(storages)) {
|
|
705
738
|
this.storages.set(name, storage);
|
|
706
739
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -349,12 +349,13 @@ export class Server {
|
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
-
//
|
|
352
|
+
// Another error: the server failed outside a handler of the protocol. The code is of wire v1
|
|
353
|
+
// (CLI-4: before, the code was the string "500").
|
|
353
354
|
return {
|
|
354
355
|
id,
|
|
355
356
|
status: {
|
|
356
357
|
type: "error",
|
|
357
|
-
code: "
|
|
358
|
+
code: "INTERNAL_ERROR",
|
|
358
359
|
message: error.message || "Internal server error",
|
|
359
360
|
retryable: false,
|
|
360
361
|
},
|
package/src/server/types.ts
CHANGED
|
@@ -265,12 +265,13 @@ export class HandlerNotFoundError extends Error {
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
/**
|
|
268
|
-
*
|
|
268
|
+
* A server error with an error code. The default code is `INTERNAL_ERROR` of wire v1 (CLI-4:
|
|
269
|
+
* before, the default was the string `"500"`).
|
|
269
270
|
*/
|
|
270
271
|
export class ServerError extends Error {
|
|
271
272
|
constructor(
|
|
272
273
|
message: string,
|
|
273
|
-
public readonly code: string = "
|
|
274
|
+
public readonly code: string = "INTERNAL_ERROR",
|
|
274
275
|
public readonly retryable: boolean = false
|
|
275
276
|
) {
|
|
276
277
|
super(message);
|