@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  4. package/dist/adapters/http/client/transport.js +9 -6
  5. package/dist/adapters/http/client/transport.js.map +1 -1
  6. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  7. package/dist/adapters/http/server/handler.js +5 -23
  8. package/dist/adapters/http/server/handler.js.map +1 -1
  9. package/dist/adapters/http/server/ndjson.d.ts +3 -1
  10. package/dist/adapters/http/server/ndjson.d.ts.map +1 -1
  11. package/dist/adapters/http/server/ndjson.js +3 -1
  12. package/dist/adapters/http/server/ndjson.js.map +1 -1
  13. package/dist/adapters/http/server/transport.d.ts +2 -2
  14. package/dist/adapters/http/server/transport.js +2 -2
  15. package/dist/adapters/websocket/shared/rpc.d.ts +5 -1
  16. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  17. package/dist/adapters/websocket/shared/rpc.js +9 -3
  18. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  19. package/dist/browser.d.ts +1 -1
  20. package/dist/browser.d.ts.map +1 -1
  21. package/dist/browser.js +2 -0
  22. package/dist/browser.js.map +1 -1
  23. package/dist/client/middleware/items.d.ts.map +1 -1
  24. package/dist/client/middleware/items.js +2 -0
  25. package/dist/client/middleware/items.js.map +1 -1
  26. package/dist/procedures/collection/index.d.ts +1 -0
  27. package/dist/procedures/collection/index.d.ts.map +1 -1
  28. package/dist/procedures/collection/index.js +2 -0
  29. package/dist/procedures/collection/index.js.map +1 -1
  30. package/dist/procedures/collection/partition.d.ts +48 -0
  31. package/dist/procedures/collection/partition.d.ts.map +1 -1
  32. package/dist/procedures/collection/partition.js +66 -0
  33. package/dist/procedures/collection/partition.js.map +1 -1
  34. package/dist/procedures/collection/topic.d.ts +2 -0
  35. package/dist/procedures/collection/topic.d.ts.map +1 -1
  36. package/dist/procedures/collection/topic.js.map +1 -1
  37. package/dist/procedures/index.d.ts +1 -1
  38. package/dist/procedures/index.d.ts.map +1 -1
  39. package/dist/procedures/index.js +2 -0
  40. package/dist/procedures/index.js.map +1 -1
  41. package/dist/protocol.d.ts.map +1 -1
  42. package/dist/protocol.js +24 -0
  43. package/dist/protocol.js.map +1 -1
  44. package/dist/server/browser-guard.d.ts +13 -3
  45. package/dist/server/browser-guard.d.ts.map +1 -1
  46. package/dist/server/browser-guard.js +11 -5
  47. package/dist/server/browser-guard.js.map +1 -1
  48. package/dist/server/procedure-server.d.ts +24 -1
  49. package/dist/server/procedure-server.d.ts.map +1 -1
  50. package/dist/server/procedure-server.js +29 -1
  51. package/dist/server/procedure-server.js.map +1 -1
  52. package/dist/server/server.d.ts.map +1 -1
  53. package/dist/server/server.js +3 -2
  54. package/dist/server/server.js.map +1 -1
  55. package/dist/server/types.d.ts +2 -1
  56. package/dist/server/types.d.ts.map +1 -1
  57. package/dist/server/types.js +3 -2
  58. package/dist/server/types.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/adapters/http/client/transport.ts +9 -6
  61. package/src/adapters/http/server/handler.ts +9 -28
  62. package/src/adapters/http/server/ndjson.ts +3 -1
  63. package/src/adapters/http/server/transport.ts +2 -2
  64. package/src/adapters/websocket/shared/rpc.ts +9 -3
  65. package/src/browser.ts +2 -0
  66. package/src/client/middleware/items.ts +2 -0
  67. package/src/procedures/collection/index.ts +2 -0
  68. package/src/procedures/collection/partition.ts +75 -0
  69. package/src/procedures/collection/topic.ts +2 -0
  70. package/src/procedures/index.ts +2 -0
  71. package/src/protocol.ts +26 -0
  72. package/src/server/browser-guard.ts +27 -7
  73. package/src/server/procedure-server.ts +35 -2
  74. package/src/server/server.ts +3 -2
  75. 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
- /** The error of a failed stream or handler: its code (a string) and its retryable flag. */
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
- status = { type: "error", code: String(frame.status.code), message: textOf(frame.status.message), retryable: frame.status.retryable || false };
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
 
@@ -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
- /** The result of the checks: the request passes, or the HTTP status and the error to send. */
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
- | { readonly ok: false; readonly status: 403 | 405 | 415; readonly code: string; readonly message: string };
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 { ok: false, status: 403, code: "FORBIDDEN_HOST", message: `Forbidden: the host "${name}" is not allowed` };
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 { ok: false, status: 403, code: "FORBIDDEN_ORIGIN", message: `Forbidden: the origin "${origin}" is not allowed` };
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 { ok: false, status: 403, code: "FORBIDDEN_ORIGIN", message: "Forbidden: a request from another site" };
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 { ok: false, status: 405, code: "METHOD_NOT_ALLOWED", message: "Call a procedure with POST" };
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 { ok: false, status: 415, code: "UNSUPPORTED_MEDIA_TYPE", message: "The request body must be JSON (Content-Type: application/json)" };
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 { CollectionPartitions } from "../procedures/collection/partition.js";
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
  }
@@ -349,12 +349,13 @@ export class Server {
349
349
  };
350
350
  }
351
351
 
352
- // Generic error
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: "500",
358
+ code: "INTERNAL_ERROR",
358
359
  message: error.message || "Internal server error",
359
360
  retryable: false,
360
361
  },
@@ -265,12 +265,13 @@ export class HandlerNotFoundError extends Error {
265
265
  }
266
266
 
267
267
  /**
268
- * Server error with status code.
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 = "500",
274
+ public readonly code: string = "INTERNAL_ERROR",
274
275
  public readonly retryable: boolean = false
275
276
  ) {
276
277
  super(message);