@procedures/client 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  4. package/dist/adapters/http/server/handler.js +5 -3
  5. package/dist/adapters/http/server/handler.js.map +1 -1
  6. package/dist/browser.d.ts +3 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +1 -1
  9. package/dist/browser.js.map +1 -1
  10. package/dist/client/errors/registry.d.ts.map +1 -1
  11. package/dist/client/errors/registry.js +12 -0
  12. package/dist/client/errors/registry.js.map +1 -1
  13. package/dist/client/middleware/rate-limit.d.ts +17 -0
  14. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  15. package/dist/client/middleware/rate-limit.js +94 -39
  16. package/dist/client/middleware/rate-limit.js.map +1 -1
  17. package/dist/events/index.d.ts +1 -0
  18. package/dist/events/index.d.ts.map +1 -1
  19. package/dist/events/index.js.map +1 -1
  20. package/dist/events/publisher.d.ts +50 -5
  21. package/dist/events/publisher.d.ts.map +1 -1
  22. package/dist/events/publisher.js +99 -10
  23. package/dist/events/publisher.js.map +1 -1
  24. package/dist/events/recheck.d.ts +84 -0
  25. package/dist/events/recheck.d.ts.map +1 -0
  26. package/dist/events/recheck.js +135 -0
  27. package/dist/events/recheck.js.map +1 -0
  28. package/dist/procedures/collection/changes.d.ts +9 -8
  29. package/dist/procedures/collection/changes.d.ts.map +1 -1
  30. package/dist/procedures/collection/changes.js +11 -11
  31. package/dist/procedures/collection/changes.js.map +1 -1
  32. package/dist/procedures/collection/index.d.ts +1 -0
  33. package/dist/procedures/collection/index.d.ts.map +1 -1
  34. package/dist/procedures/collection/index.js +2 -0
  35. package/dist/procedures/collection/index.js.map +1 -1
  36. package/dist/procedures/collection/partition.d.ts +4 -23
  37. package/dist/procedures/collection/partition.d.ts.map +1 -1
  38. package/dist/procedures/collection/partition.js +2 -28
  39. package/dist/procedures/collection/partition.js.map +1 -1
  40. package/dist/procedures/collection/topic.d.ts +86 -0
  41. package/dist/procedures/collection/topic.d.ts.map +1 -0
  42. package/dist/procedures/collection/topic.js +94 -0
  43. package/dist/procedures/collection/topic.js.map +1 -0
  44. package/dist/procedures/index.d.ts +1 -1
  45. package/dist/procedures/index.d.ts.map +1 -1
  46. package/dist/procedures/index.js +1 -1
  47. package/dist/procedures/index.js.map +1 -1
  48. package/dist/protocol.d.ts +1 -1
  49. package/dist/protocol.d.ts.map +1 -1
  50. package/dist/protocol.js +1 -0
  51. package/dist/protocol.js.map +1 -1
  52. package/dist/server/host-policy.d.ts +17 -0
  53. package/dist/server/host-policy.d.ts.map +1 -1
  54. package/dist/server/host-policy.js +7 -0
  55. package/dist/server/host-policy.js.map +1 -1
  56. package/dist/server/limits.d.ts +122 -0
  57. package/dist/server/limits.d.ts.map +1 -0
  58. package/dist/server/limits.js +153 -0
  59. package/dist/server/limits.js.map +1 -0
  60. package/dist/server/procedure-server.d.ts +35 -3
  61. package/dist/server/procedure-server.d.ts.map +1 -1
  62. package/dist/server/procedure-server.js +42 -8
  63. package/dist/server/procedure-server.js.map +1 -1
  64. package/dist/server/request-auth.d.ts +5 -1
  65. package/dist/server/request-auth.d.ts.map +1 -1
  66. package/dist/server/request-auth.js +19 -2
  67. package/dist/server/request-auth.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/adapters/http/server/handler.ts +5 -3
  70. package/src/browser.ts +5 -0
  71. package/src/client/errors/registry.ts +13 -0
  72. package/src/client/middleware/rate-limit.ts +116 -41
  73. package/src/events/index.ts +1 -0
  74. package/src/events/publisher.ts +121 -12
  75. package/src/events/recheck.ts +182 -0
  76. package/src/procedures/collection/changes.ts +12 -11
  77. package/src/procedures/collection/index.ts +2 -0
  78. package/src/procedures/collection/partition.ts +5 -27
  79. package/src/procedures/collection/topic.ts +115 -0
  80. package/src/procedures/index.ts +1 -0
  81. package/src/protocol.ts +3 -1
  82. package/src/server/host-policy.ts +22 -0
  83. package/src/server/limits.ts +254 -0
  84. package/src/server/procedure-server.ts +59 -9
  85. package/src/server/request-auth.ts +19 -2
@@ -28,6 +28,8 @@ import type { EventBus } from "../../events/types.js";
28
28
  import { withRows } from "../../events/rows.js";
29
29
  import type { AdmitItem } from "../invoke.js";
30
30
  import type { StorageChanges } from "./storage.js";
31
+ import type { RepositoryProvider } from "../types.js";
32
+ import { collectionTopicTarget } from "./topic.js";
31
33
 
32
34
  /**
33
35
  * The change event of the writes of a collection, on the topic `collections.<name>` (roadmap
@@ -56,22 +58,21 @@ export interface CollectionChangeEvent {
56
58
  readonly origin?: string | undefined;
57
59
  }
58
60
 
59
- /**
60
- * This function gives the topic of the change events of a collection: `collections.<name>`, or
61
- * `collections.<name>.<partition>` for a partition (roadmap CLI-17).
62
- */
63
- export function collectionChangeTopic(collection: string, partition?: string): string {
64
- return partition === undefined ? `collections.${collection}` : `collections.${collection}.${partition}`;
65
- }
61
+ export { collectionChangeTopic } from "./topic.js";
66
62
 
67
63
  /**
68
64
  * This function gives the resources that a subscriber of a topic reads: `collection:<name>` for
69
65
  * the topic `collections.<name>` and for a topic of a partition, `collections.<name>.<partition>`.
70
- * Another topic gives no resource. `EventPublisher` uses it as its default `reads`.
66
+ * Another topic gives no resource. `EventPublisher` uses it as its default `reads`, with the
67
+ * repository of the call.
68
+ *
69
+ * It reads the topic with `collectionTopicTarget()`, the parse of the listener of the publisher
70
+ * (roadmap CLI-42). With the repository, the plain topic of a dotted name, `collections.graph.nodes`,
71
+ * reads `collection:graph.nodes`. Without it, the first dot ends the name.
71
72
  */
72
- export function collectionTopicReads(topic: string): readonly string[] {
73
- const match = /^collections\.([^.]+)(?:\.|$)/.exec(topic);
74
- return match ? [`collection:${match[1]}`] : [];
73
+ export function collectionTopicReads(topic: string, repository?: RepositoryProvider): readonly string[] {
74
+ const target = collectionTopicTarget(topic, repository);
75
+ return target ? [`collection:${target.collection}`] : [];
75
76
  }
76
77
 
77
78
  /** The part of a storage that the change events read. */
@@ -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 topic rule of a collection for EventPublisher (roadmap CLI-42)
76
+ export { collectionTopicRule } from "./topic.js";
75
77
  export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
76
78
 
77
79
  // The rules of the rows of a collection: a filter of the reads and a guard of the writes (roadmap CLI-41)
@@ -11,11 +11,13 @@
11
11
  * - A subscriber names the partition that it asks for in the topic, as an input does:
12
12
  * `collections.<name>.<requested>`. The publisher resolves it in the same way and listens to the
13
13
  * topic of the resolved partition, so a subscriber cannot name the topic of another tenant.
14
+ * - In the topic of a partition, the name encodes `%` and `.` (`topic.ts`, roadmap CLI-42). Thus
15
+ * the name of a collection with partitions can have a dot.
14
16
  * - A collection without partitions refuses a call or a topic with a partition.
15
17
  */
16
18
  import type { ZodLike } from "../../client/validation/types.js";
17
19
  import { InvocationError } from "../invoke.js";
18
- import type { ProcedureContext, RepositoryProvider } from "../types.js";
20
+ import type { ProcedureContext } from "../types.js";
19
21
  import type { CollectionStorage } from "./storage.js";
20
22
 
21
23
  /** The characters of a partition: letters, digits and `_ . : @ ~ + | / -`, 256 at most. */
@@ -129,29 +131,5 @@ export function storageOfCall<T>(ctx: ProcedureContext, collection: string, part
129
131
  return repository.getPartitionStorage<T>(collection, partition);
130
132
  }
131
133
 
132
- /**
133
- * This function gives the collection and the partition part of a topic of a call, with the
134
- * repository of the call: one rule for the read check and for the listener of the publisher.
135
- *
136
- * - A collection of the repository without partitions whose name is the whole rest of the topic
137
- * wins: its name can have a dot.
138
- * - Else the first part after `collections.` names the collection, and the rest is the partition
139
- * that the call asks for. A collection with partitions has no dot in its name.
140
- */
141
- export function collectionTopicTarget(topic: string, repository: RepositoryProvider | undefined): { collection: string; partition: string | undefined } | undefined {
142
- if (!topic.startsWith("collections.")) return undefined;
143
- const whole = topic.slice("collections.".length);
144
- if (whole !== "" && repository?.hasCollection(whole) && !repository.hasPartitions?.(whole)) return { collection: whole, partition: undefined };
145
- return collectionTopicPartition(topic);
146
- }
147
-
148
- /**
149
- * This function gives the collection and the partition part of a topic `collections.<name>` or
150
- * `collections.<name>.<partition>`, or undefined for another topic. It reads the topic alone:
151
- * `collectionTopicTarget` also knows the collections of a repository.
152
- */
153
- export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
154
- const match = /^collections\.([^.]+)(?:\.(.+))?$/.exec(topic);
155
- if (!match) return undefined;
156
- return { collection: match[1]!, partition: match[2] };
157
- }
134
+ // The parse of a topic lives in topic.ts, with the encode (roadmap CLI-42)
135
+ export { collectionTopicPartition, collectionTopicTarget } from "./topic.js";
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The topics of the change events of a collection (roadmap CLI-15, CLI-17 and CLI-42). This module
3
+ * has the one function that makes a topic, the functions that read it, and the topic rule of a
4
+ * collection. Each part of the client that makes or reads the topic of a collection or of a
5
+ * partition uses them.
6
+ *
7
+ * - `collections.<name>` is the topic of a collection. The name stays as it is, also with a dot.
8
+ * - `collections.<name>.<partition>` is the topic of a partition. In this form, the name encodes
9
+ * `%` as `%25` and `.` as `%2E`. Thus the first dot after `collections.` ends the name, and the
10
+ * partition after that dot can have each character, also `.`, `/` and `%`.
11
+ * - The partition stays as it is, and so does a name without `.` and `%`. Thus each topic of a
12
+ * partition of a name without `.` and `%` stays the same as before CLI-42.
13
+ * - Only the canonical form of a topic counts: a parse gives a collection and a partition only when
14
+ * `collectionChangeTopic(collection, partition)` gives the same topic. Thus `%2e` is no alias of
15
+ * `%2E`, and a rule or an `allow` function that compares the strings sees each topic one way.
16
+ * - The topic of a collection whose name has a dot, `collections.a.b`, has the same form as the
17
+ * topic of the partition `b` of the collection `a`. Only the repository knows the collection, so
18
+ * `collectionTopicTarget()` decides with it.
19
+ */
20
+ import type { RepositoryProvider } from "../types.js";
21
+
22
+ /** The prefix of the topic of each collection. */
23
+ const PREFIX = "collections.";
24
+
25
+ /** This function encodes the name of a collection for a topic with a partition: `%` and `.` become `%25` and `%2E`. */
26
+ function encodeTopicName(name: string): string {
27
+ return name.replace(/[%.]/g, (char) => (char === "%" ? "%25" : "%2E"));
28
+ }
29
+
30
+ /** This function decodes the name of a topic with a partition. The parse then checks the canonical form. */
31
+ function decodeTopicName(segment: string): string {
32
+ return segment.replace(/%(25|2E)/g, (_sequence, code: string) => (code === "25" ? "%" : "."));
33
+ }
34
+
35
+ /**
36
+ * This function gives the topic of the change events of a collection: `collections.<name>`, or
37
+ * `collections.<name>.<partition>` for a partition (roadmap CLI-17). In the topic of a partition,
38
+ * the name encodes `%` and `.` (roadmap CLI-42), so `collectionTopicPartition()` gives back the
39
+ * same collection and the same partition.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * collectionChangeTopic("notes", "t1/board:b1"); // "collections.notes.t1/board:b1"
44
+ * collectionChangeTopic("graph.nodes", "acme/space"); // "collections.graph%2Enodes.acme/space"
45
+ * collectionChangeTopic("graph.nodes"); // "collections.graph.nodes"
46
+ * ```
47
+ */
48
+ export function collectionChangeTopic(collection: string, partition?: string): string {
49
+ return partition === undefined ? `${PREFIX}${collection}` : `${PREFIX}${encodeTopicName(collection)}.${partition}`;
50
+ }
51
+
52
+ /**
53
+ * This function gives the collection and the partition of a topic `collections.<name>` or
54
+ * `collections.<name>.<partition>`, or undefined for another topic. It reads the topic alone:
55
+ * `collectionTopicTarget()` also knows the collections of a repository.
56
+ *
57
+ * - The first dot after the name ends the name. In a topic with a partition, the name is decoded:
58
+ * `%2E` is a dot and `%25` is a `%` (roadmap CLI-42).
59
+ * - Each character after that dot is the partition. A partition has one character at least.
60
+ * - A topic that is not in the canonical form gives undefined: `collectionChangeTopic()` of the
61
+ * result always gives the topic again. Thus `collections.a%2eb.t1` names no collection.
62
+ * - For each name and each partition, `collectionTopicPartition(collectionChangeTopic(name,
63
+ * partition))` gives the same name and the same partition.
64
+ */
65
+ export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
66
+ if (!topic.startsWith(PREFIX)) return undefined;
67
+ const rest = topic.slice(PREFIX.length);
68
+ const dot = rest.indexOf(".");
69
+ if (dot === -1) return rest === "" ? undefined : { collection: rest, partition: undefined };
70
+ if (dot === 0 || dot === rest.length - 1) return undefined;
71
+ const collection = decodeTopicName(rest.slice(0, dot));
72
+ const partition = rest.slice(dot + 1);
73
+ // Only the canonical form counts: an alias of an escape names no collection
74
+ return collectionChangeTopic(collection, partition) === topic ? { collection, partition } : undefined;
75
+ }
76
+
77
+ /**
78
+ * This function gives the collection and the partition part of a topic of a call, with the
79
+ * repository of the call: one rule for the read check and for the listener of the publisher.
80
+ *
81
+ * - A collection of the repository whose name is the whole rest of the topic wins, with or without
82
+ * partitions: its name can have a dot. The call then asks for no partition.
83
+ * - Else `collectionTopicPartition()` reads the topic: the first dot after the name ends the name,
84
+ * and the rest is the partition that the call asks for. In this form, the name encodes `%` and
85
+ * `.`, so the name of a collection with partitions can have a dot (roadmap CLI-42).
86
+ */
87
+ export function collectionTopicTarget(topic: string, repository: RepositoryProvider | undefined): { collection: string; partition: string | undefined } | undefined {
88
+ if (!topic.startsWith(PREFIX)) return undefined;
89
+ const whole = topic.slice(PREFIX.length);
90
+ if (whole !== "" && repository?.hasCollection(whole)) return { collection: whole, partition: undefined };
91
+ return collectionTopicPartition(topic);
92
+ }
93
+
94
+ /**
95
+ * This function gives the topic rule of a collection for `EventPublisher` (the option `topics`):
96
+ * the topic of the collection, and the pattern of the topics of its partitions (roadmap CLI-42).
97
+ * Build the rule of a collection with it. Do not join the parts yourself: a dotted name needs its
98
+ * encoded form in the pattern.
99
+ *
100
+ * - The rule always has the two forms. For a collection with partitions, the plain topic asks for
101
+ * no partition, and the resolver of the partition decides.
102
+ * - For a collection without partitions, a topic of a partition passes the rule. The publisher then
103
+ * refuses it with `FORBIDDEN` (reason `partition-not-allowed`).
104
+ * - The pattern matches only the canonical form of a topic, so an alias of an escape gets
105
+ * `TOPIC_NOT_FOUND`.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * collectionTopicRule("graph.nodes"); // ["collections.graph.nodes", "collections.graph%2Enodes.*"]
110
+ * new EventPublisher({ bus, topics: ["orders.*", ...collectionTopicRule("graph.nodes")] });
111
+ * ```
112
+ */
113
+ export function collectionTopicRule(collection: string): string[] {
114
+ return [collectionChangeTopic(collection), `${PREFIX}${encodeTopicName(collection)}.*`];
115
+ }
@@ -133,6 +133,7 @@ export {
133
133
  PARTITION_PATTERN,
134
134
  collectionTopicPartition,
135
135
  collectionTopicTarget,
136
+ collectionTopicRule,
136
137
  // The rules of the rows of a collection (roadmap CLI-41)
137
138
  admittedStorage,
138
139
  admitsRow,
package/src/protocol.ts CHANGED
@@ -49,7 +49,8 @@ export type ProtocolErrorCode =
49
49
  | "CONNECTION_NOT_FOUND"
50
50
  | "TOPIC_NOT_FOUND"
51
51
  | "TOPIC_CLEARED"
52
- | "VERSION_CONFLICT";
52
+ | "VERSION_CONFLICT"
53
+ | "RATE_LIMITED";
53
54
 
54
55
  /**
55
56
  * The error codes of the protocol, on each face (HTTP, WebSocket, MCP, local). An error that a
@@ -82,6 +83,7 @@ export const ERROR_CODES: Readonly<Record<ProtocolErrorCode, ErrorCodeInfo>> = {
82
83
  TOPIC_NOT_FOUND: { http: 404, retryable: false, meaning: "The topic rule of the host has no topic with this name" },
83
84
  TOPIC_CLEARED: { http: 410, retryable: false, meaning: "The host cleared a topic of the subscription: clear() of its bus removed the listener" },
84
85
  VERSION_CONFLICT: { http: 409, retryable: false, meaning: "The current item does not have the version that the write expects. The metadata key conflicts has the current items" },
86
+ RATE_LIMITED: { http: 429, retryable: true, meaning: "A limit of the host refuses the call. The metadata key retry-after gives the wait, and reason gives the cause" },
85
87
  };
86
88
 
87
89
  /** True when a code is an error code of the protocol. */
@@ -18,6 +18,8 @@
18
18
  * call and what the host gives for it (CAPABILITIES-2026-10).
19
19
  * - `events`: the remote events of the host (`documentation/EVENTS.md`). The server pins
20
20
  * `events.subscribe` with this topic rule, and its procedures get the bus as `ctx.bus`.
21
+ * - `limits`, `limitStores`: the limits of the calls (CLI-40). Each server of the policy makes one
22
+ * keyed limiter for each limit, and refuses a call above a limit with `RATE_LIMITED`.
21
23
  */
22
24
 
23
25
  import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
@@ -30,6 +32,8 @@ import type { ProcedureRegistry } from "../procedures/registry.js";
30
32
  import type { ProcedureServerOptions } from "./procedure-server.js";
31
33
  import type { EventPublisherOptions } from "../events/publisher.js";
32
34
  import { toMs, type Duration } from "../duration.js";
35
+ import type { LimiterStore } from "@mark1russell7/service/limiter";
36
+ import type { CallLimit } from "./limits.js";
33
37
 
34
38
  /** The procedures of a host that are tools: namespaces (first path segments), less some keys. */
35
39
  export interface ToolSurface {
@@ -102,6 +106,15 @@ export interface HostPolicyOptions {
102
106
  * `ctx.bus`. Without this option, the host does not serve `events.subscribe`.
103
107
  */
104
108
  events?: EventPublisherOptions | undefined;
109
+ /**
110
+ * The limits of the calls (CLI-40): each limit counts the calls of one key, for example the
111
+ * writes of each tenant. A refusal is `RATE_LIMITED` on each face. Without it: no limits.
112
+ * Each limit that covers a call takes one unit, in the sequence of the limits. A limit before a
113
+ * refusal keeps its unit: the server does not give it back.
114
+ */
115
+ limits?: readonly CallLimit[] | undefined;
116
+ /** The stores that the replicas share, by the name of their dependency, for the limits with a shared store. */
117
+ limitStores?: Readonly<Record<string, LimiterStore>> | undefined;
105
118
  }
106
119
 
107
120
  /** What a host lets its callers do. */
@@ -120,6 +133,10 @@ export interface HostPolicy {
120
133
  readonly admitItem: AdmitItem | undefined;
121
134
  /** The remote events: each server of the policy makes its own publisher from these options. */
122
135
  readonly events: EventPublisherOptions | undefined;
136
+ /** The limits of the calls (CLI-40). Each server of the policy makes its own keyed limiters from them. */
137
+ readonly limits?: readonly CallLimit[] | undefined;
138
+ /** The shared stores of the limits, by the name of their dependency. */
139
+ readonly limitStores?: Readonly<Record<string, LimiterStore>> | undefined;
123
140
  }
124
141
 
125
142
  /** The capability function of a policy: the capability of the options, narrowed by `readOnly`. */
@@ -167,6 +184,8 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
167
184
  resources: options.resources,
168
185
  admitItem: options.admitItem,
169
186
  events: options.events,
187
+ ...(options.limits && options.limits.length > 0 ? { limits: options.limits } : {}),
188
+ ...(options.limitStores ? { limitStores: options.limitStores } : {}),
170
189
  };
171
190
  }
172
191
 
@@ -188,5 +207,8 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
188
207
  ...(policy.admitItem ? { admitItem: policy.admitItem } : {}),
189
208
  // The remote events: the server makes its publisher and pins events.subscribe (EVENTS.md)
190
209
  ...(policy.events ? { events: policy.events } : {}),
210
+ // The limits of the calls: the server makes one keyed limiter for each one (CLI-40)
211
+ ...(policy.limits ? { limits: policy.limits } : {}),
212
+ ...(policy.limitStores ? { limitStores: policy.limitStores } : {}),
191
213
  };
192
214
  }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The limits of the calls of a server (CLI-40): one admission step for each face.
3
+ *
4
+ * A limit is data: a key over the call, the calls that it covers, a limit policy, a store and a
5
+ * bound on its keys. Each limit is a keyed limiter of `@mark1russell7/service/limiter` (SYS-28) on
6
+ * the clock of the server. The server takes one unit of each limit that covers a call, after the
7
+ * authentication and before the run. Thus the key can read the principal of the call. It takes the
8
+ * units in the sequence of the limits, and the first refusal stops the call. A limit before the
9
+ * refusal keeps its unit: the server does not give it back.
10
+ *
11
+ * A refusal is `RATE_LIMITED` (retryable), with the wait in the metadata key `retry-after` (in
12
+ * milliseconds) and the cause in `reason` (`limit`, `cap` or `store`). The HTTP face answers 429
13
+ * with `Retry-After` in whole seconds. The WebSocket face and the MCP face give the same code and
14
+ * the same wait. The work did not start, so a retry after the wait is safe.
15
+ */
16
+
17
+ import { valueIn } from "@mark1russell7/quantity";
18
+ import {
19
+ keyedLimiter,
20
+ memoryStore,
21
+ retryAfterSeconds,
22
+ type KeyedLimiter,
23
+ type LimitPolicy,
24
+ type LimiterEviction,
25
+ type LimiterStore,
26
+ type LimiterStoreSpec,
27
+ type Refused,
28
+ } from "@mark1russell7/service/limiter";
29
+ import type { Clock } from "../clock.js";
30
+ import { effectsOf, isReadOnly } from "../procedures/capability.js";
31
+ import { pathToKey, type Effects, type ProcedurePath } from "../procedures/types.js";
32
+ import { exposePatterns } from "./host-policy.js";
33
+ import type { Principal, ServerRequest, ServerResponse } from "./types.js";
34
+
35
+ /** The facts of a call that a limit reads. The server gives them after the authentication of the call. */
36
+ export interface LimitedCall {
37
+ /** The path of the procedure of the call. */
38
+ readonly path: ProcedurePath;
39
+ /** The route of the call: the key of its procedure, for example `collections.notes.set`. */
40
+ readonly route: string;
41
+ /** The effects that the procedure declares, or undefined when it declares none. */
42
+ readonly effects: Effects | undefined;
43
+ /** True when the procedure can change data: its effects do not say `writes: []`. A procedure without effects can change data. */
44
+ readonly writes: boolean;
45
+ /** The principal of the call: from the request hook, the token check or the authenticate function. Undefined for a call without one. */
46
+ readonly principal: Principal | undefined;
47
+ /** The request. Its payload and its metadata come from the caller, thus a key from them is a claim of the caller. */
48
+ readonly request: ServerRequest<unknown>;
49
+ }
50
+
51
+ /**
52
+ * A part of the key of a limit, as data:
53
+ * - `principal`: the id of the principal of the call.
54
+ * - `route`: the key of the procedure of the call.
55
+ * - `{ claim }`: a claim of the principal, for example `{ claim: "tenant" }`. A string or a number.
56
+ */
57
+ export type CallLimitKeyPart = "principal" | "route" | { readonly claim: string };
58
+
59
+ /**
60
+ * The key of a limit: one part, a list of parts, or a function of the call. A call whose key has
61
+ * no value (a part without a value, or a function that gives undefined) is outside the limit.
62
+ */
63
+ export type CallLimitKey = CallLimitKeyPart | readonly CallLimitKeyPart[] | ((call: LimitedCall) => string | undefined);
64
+
65
+ /** The calls that a limit covers, as data. Each given field must agree. Without a field, the limit covers each call. */
66
+ export interface CallLimitCovers {
67
+ /** True: only the calls of a procedure that can change data. False: only the calls of a procedure that changes nothing. */
68
+ readonly writes?: boolean | undefined;
69
+ /** Only the calls of these procedures: an exact key (`notes.set`), a prefix (`collections.*`) or `*`. */
70
+ readonly routes?: readonly string[] | undefined;
71
+ }
72
+
73
+ /**
74
+ * A limit of the calls of a server (CLI-40): a keyed limiter of `@mark1russell7/service/limiter`
75
+ * with a key over the call. The policy, the store and the bound on the keys have no defaults, as
76
+ * in the spec of a keyed limiter (`LimiterSpec`).
77
+ */
78
+ export interface CallLimit {
79
+ /** The name of the limiter. It is unique in the service: `svc.limiter()` declares the limiter with this name. */
80
+ readonly name: string;
81
+ /** The key of each call: who or what the limit counts. */
82
+ readonly key: CallLimitKey;
83
+ /**
84
+ * The text of the key in the declaration of the service (`LimiterSpec.key`), for example
85
+ * `tenant`. The default names the parts of the key, or `call` for a function.
86
+ */
87
+ readonly keyName?: string | undefined;
88
+ /** The calls that the limit covers: the fields of `CallLimitCovers`, or a function of the call. Without it, each call. */
89
+ readonly covers?: CallLimitCovers | ((call: LimitedCall) => boolean) | undefined;
90
+ /** The policy: a token bucket or a sliding window. */
91
+ readonly policy: LimitPolicy;
92
+ /**
93
+ * The store of the counts. `{ kind: "local" }`: the memory of this replica. `{ kind: "shared",
94
+ * dependency, onStoreFailure }`: the store of `limitStores` with the name of the dependency.
95
+ */
96
+ readonly store: LimiterStoreSpec;
97
+ /** The bound on the keys that the store holds. */
98
+ readonly evict: LimiterEviction;
99
+ }
100
+
101
+ /** The refusal of a call by one limit. */
102
+ export interface CallLimitRefusal {
103
+ /** The name of the limit that refused the call. */
104
+ readonly limit: string;
105
+ readonly refused: Refused;
106
+ }
107
+
108
+ /** The limits of one server, as runtime: one keyed limiter for each limit. */
109
+ export interface CallLimiter {
110
+ /** The keyed limiters, in the sequence of the limits. A host declares each one with `svc.limiter(name, spec)`. */
111
+ readonly limiters: readonly KeyedLimiter[];
112
+ /**
113
+ * This method takes one unit of each limit that covers the call, in the sequence of the limits.
114
+ * It gives the first refusal, or undefined. A limit before the refusal keeps its unit.
115
+ */
116
+ admit(call: LimitedCall): Promise<CallLimitRefusal | undefined>;
117
+ }
118
+
119
+ /** The options of `callLimiter()`. */
120
+ export interface CallLimiterOptions {
121
+ /** The clock of the local store: give the clock of the server. */
122
+ readonly clock: Clock;
123
+ /** The stores that the replicas share, by the name of their dependency. */
124
+ readonly stores?: Readonly<Record<string, LimiterStore>> | undefined;
125
+ }
126
+
127
+ /** The metadata key of the wait of a refusal, in milliseconds (the same key as `retry-after` of wire v1). */
128
+ const RETRY_AFTER = "retry-after";
129
+
130
+ /** This function tells if a key is a list of parts. */
131
+ function isPartList(key: CallLimitKey): key is readonly CallLimitKeyPart[] {
132
+ return Array.isArray(key);
133
+ }
134
+
135
+ /** The value of one part of a key, or undefined. */
136
+ function partValue(part: CallLimitKeyPart, call: LimitedCall): string | undefined {
137
+ if (part === "principal") return call.principal?.id;
138
+ if (part === "route") return call.route;
139
+ const value = call.principal?.claims?.[part.claim];
140
+ return typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
141
+ }
142
+
143
+ /** The text of one part of a key, for the declaration of the service. */
144
+ function partName(part: CallLimitKeyPart): string {
145
+ return typeof part === "string" ? part : part.claim;
146
+ }
147
+
148
+ /** This function gives the function of the key of a limit. A list of parts gives the JSON of the values. */
149
+ function keyFunction(key: CallLimitKey): (call: LimitedCall) => string | undefined {
150
+ if (typeof key === "function") return key;
151
+ const parts = isPartList(key) ? key : [key];
152
+ if (parts.length === 0) throw new Error("A key of a limit needs one part or more");
153
+ return (call) => {
154
+ const values: string[] = [];
155
+ for (const part of parts) {
156
+ const value = partValue(part, call);
157
+ if (value === undefined) return undefined;
158
+ values.push(value);
159
+ }
160
+ return values.length === 1 ? values[0] : JSON.stringify(values);
161
+ };
162
+ }
163
+
164
+ /** The text of the key of a limit, for the declaration of the service. */
165
+ function keyNameOf(limit: CallLimit): string {
166
+ if (limit.keyName !== undefined) return limit.keyName;
167
+ if (typeof limit.key === "function") return "call";
168
+ return (isPartList(limit.key) ? limit.key : [limit.key]).map(partName).join("+");
169
+ }
170
+
171
+ /** This function gives the function that tells if a limit covers a call. */
172
+ function coversFunction(covers: CallLimit["covers"]): (call: LimitedCall) => boolean {
173
+ if (covers === undefined) return () => true;
174
+ if (typeof covers === "function") return covers;
175
+ const { writes, routes } = covers;
176
+ const routeRule = routes === undefined ? undefined : exposePatterns(routes);
177
+ return (call) => (writes === undefined || call.writes === writes) && (routeRule === undefined || routeRule(call.path));
178
+ }
179
+
180
+ /** This function gives the store of a limit: the local store, or the shared store of its dependency. */
181
+ function storeOf(limit: CallLimit, local: LimiterStore, stores: Readonly<Record<string, LimiterStore>>): LimiterStore {
182
+ const spec = limit.store as LimiterStoreSpec | undefined;
183
+ if (spec === undefined || spec.kind !== "shared") return local;
184
+ const store = Object.prototype.hasOwnProperty.call(stores, spec.dependency) ? stores[spec.dependency] : undefined;
185
+ if (store === undefined) {
186
+ throw new Error(`The limit ${limit.name} keeps its counts in the shared store ${spec.dependency}, and limitStores has no store with that name`);
187
+ }
188
+ return store;
189
+ }
190
+
191
+ /**
192
+ * This function makes the runtime of the limits of a server: one keyed limiter for each limit. The
193
+ * local limits share one memory store on the clock of the options. It refuses two limits with one
194
+ * name, a limit with a problem in its spec, and a shared store that `stores` does not have.
195
+ */
196
+ export function callLimiter(limits: readonly CallLimit[], options: CallLimiterOptions): CallLimiter {
197
+ const local = memoryStore(options.clock);
198
+ const stores = options.stores ?? {};
199
+ const names = new Set<string>();
200
+ const entries = limits.map((limit) => {
201
+ if (names.has(limit.name)) throw new Error(`Two limits have the name ${limit.name}: the name of a limit is unique`);
202
+ names.add(limit.name);
203
+ const keyOf = keyFunction(limit.key);
204
+ const covers = coversFunction(limit.covers);
205
+ const limiter = keyedLimiter(limit.name, { key: keyNameOf(limit), policy: limit.policy, store: limit.store, evict: limit.evict }, storeOf(limit, local, stores));
206
+ return { limiter, keyOf, covers };
207
+ });
208
+ return {
209
+ limiters: entries.map((entry) => entry.limiter),
210
+ async admit(call) {
211
+ for (const { limiter, keyOf, covers } of entries) {
212
+ if (!covers(call)) continue;
213
+ const key = keyOf(call);
214
+ if (key === undefined) continue;
215
+ const decision = await limiter.take(key);
216
+ if (!decision.allowed) return { limit: limiter.name, refused: decision };
217
+ }
218
+ return undefined;
219
+ },
220
+ };
221
+ }
222
+
223
+ /** This function gives the facts of a call for the limits: its procedure, its effects, its principal and its request. */
224
+ export function limitedCall(procedure: { readonly path: ProcedurePath; readonly metadata?: unknown }, request: ServerRequest<unknown>): LimitedCall {
225
+ const effects = effectsOf(procedure);
226
+ return {
227
+ path: procedure.path,
228
+ route: pathToKey(procedure.path),
229
+ effects,
230
+ writes: !isReadOnly(effects),
231
+ principal: request.principal,
232
+ request,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * This function gives the response of a refused call: `RATE_LIMITED`, retryable, with the wait in
238
+ * `retry-after` (milliseconds) and the cause in `reason`. The failure of a store stays on the server.
239
+ */
240
+ export function rateLimitedResponse(id: string, refusal: CallLimitRefusal): ServerResponse<unknown> {
241
+ const { limit, refused } = refusal;
242
+ const waitMs = valueIn(refused.retryAfter, "ms");
243
+ const seconds = retryAfterSeconds(refused.retryAfter);
244
+ return {
245
+ id,
246
+ status: {
247
+ type: "error",
248
+ code: "RATE_LIMITED",
249
+ message: `The limit ${limit} of the host refuses the call (${refused.cause}): retry after ${seconds} s`,
250
+ retryable: true,
251
+ },
252
+ metadata: { [RETRY_AFTER]: waitMs, reason: refused.cause },
253
+ };
254
+ }