@procedures/client 0.14.0 → 0.15.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 (136) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +12 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  5. package/dist/adapters/http/client/transport.js +4 -0
  6. package/dist/adapters/http/client/transport.js.map +1 -1
  7. package/dist/adapters/http/server/body.d.ts +17 -0
  8. package/dist/adapters/http/server/body.d.ts.map +1 -0
  9. package/dist/adapters/http/server/body.js +44 -0
  10. package/dist/adapters/http/server/body.js.map +1 -0
  11. package/dist/adapters/http/server/cors.d.ts +24 -1
  12. package/dist/adapters/http/server/cors.d.ts.map +1 -1
  13. package/dist/adapters/http/server/cors.js +33 -1
  14. package/dist/adapters/http/server/cors.js.map +1 -1
  15. package/dist/adapters/http/server/handler.d.ts +3 -1
  16. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  17. package/dist/adapters/http/server/handler.js +7 -34
  18. package/dist/adapters/http/server/handler.js.map +1 -1
  19. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  20. package/dist/adapters/local/client/transport.js +5 -0
  21. package/dist/adapters/local/client/transport.js.map +1 -1
  22. package/dist/browser.d.ts +4 -3
  23. package/dist/browser.d.ts.map +1 -1
  24. package/dist/browser.js +6 -1
  25. package/dist/browser.js.map +1 -1
  26. package/dist/client/client.js +1 -1
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/errors/registry.d.ts.map +1 -1
  29. package/dist/client/errors/registry.js +1 -0
  30. package/dist/client/errors/registry.js.map +1 -1
  31. package/dist/client/types.d.ts +6 -1
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/types.js +7 -1
  34. package/dist/client/types.js.map +1 -1
  35. package/dist/events/publisher.d.ts.map +1 -1
  36. package/dist/events/publisher.js +10 -5
  37. package/dist/events/publisher.js.map +1 -1
  38. package/dist/events/rows.d.ts +12 -4
  39. package/dist/events/rows.d.ts.map +1 -1
  40. package/dist/events/rows.js +13 -16
  41. package/dist/events/rows.js.map +1 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +8 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/node.d.ts +10 -1
  47. package/dist/node.d.ts.map +1 -1
  48. package/dist/node.js +11 -0
  49. package/dist/node.js.map +1 -1
  50. package/dist/procedures/collection/admission.d.ts +77 -0
  51. package/dist/procedures/collection/admission.d.ts.map +1 -0
  52. package/dist/procedures/collection/admission.js +204 -0
  53. package/dist/procedures/collection/admission.js.map +1 -0
  54. package/dist/procedures/collection/changes.d.ts +3 -1
  55. package/dist/procedures/collection/changes.d.ts.map +1 -1
  56. package/dist/procedures/collection/changes.js +10 -6
  57. package/dist/procedures/collection/changes.js.map +1 -1
  58. package/dist/procedures/collection/index.d.ts +5 -1
  59. package/dist/procedures/collection/index.d.ts.map +1 -1
  60. package/dist/procedures/collection/index.js +4 -0
  61. package/dist/procedures/collection/index.js.map +1 -1
  62. package/dist/procedures/collection/patch-in-parts.d.ts +15 -0
  63. package/dist/procedures/collection/patch-in-parts.d.ts.map +1 -0
  64. package/dist/procedures/collection/patch-in-parts.js +47 -0
  65. package/dist/procedures/collection/patch-in-parts.js.map +1 -0
  66. package/dist/procedures/collection/precondition.d.ts +111 -0
  67. package/dist/procedures/collection/precondition.d.ts.map +1 -0
  68. package/dist/procedures/collection/precondition.js +153 -0
  69. package/dist/procedures/collection/precondition.js.map +1 -0
  70. package/dist/procedures/collection/procedures.d.ts +8 -0
  71. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  72. package/dist/procedures/collection/procedures.js +66 -76
  73. package/dist/procedures/collection/procedures.js.map +1 -1
  74. package/dist/procedures/collection/query.d.ts +2 -0
  75. package/dist/procedures/collection/query.d.ts.map +1 -1
  76. package/dist/procedures/collection/query.js +3 -0
  77. package/dist/procedures/collection/query.js.map +1 -1
  78. package/dist/procedures/collection/schemas.d.ts +16 -0
  79. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  80. package/dist/procedures/collection/schemas.js +26 -5
  81. package/dist/procedures/collection/schemas.js.map +1 -1
  82. package/dist/procedures/collection/storage.d.ts +34 -6
  83. package/dist/procedures/collection/storage.d.ts.map +1 -1
  84. package/dist/procedures/collection/storage.js +3 -0
  85. package/dist/procedures/collection/storage.js.map +1 -1
  86. package/dist/procedures/index.d.ts +2 -2
  87. package/dist/procedures/index.d.ts.map +1 -1
  88. package/dist/procedures/index.js +3 -1
  89. package/dist/procedures/index.js.map +1 -1
  90. package/dist/procedures/invoke.d.ts +5 -2
  91. package/dist/procedures/invoke.d.ts.map +1 -1
  92. package/dist/procedures/invoke.js +5 -1
  93. package/dist/procedures/invoke.js.map +1 -1
  94. package/dist/procedures/types.d.ts +7 -0
  95. package/dist/procedures/types.d.ts.map +1 -1
  96. package/dist/procedures/types.js.map +1 -1
  97. package/dist/protocol.d.ts +1 -1
  98. package/dist/protocol.d.ts.map +1 -1
  99. package/dist/protocol.js +1 -0
  100. package/dist/protocol.js.map +1 -1
  101. package/dist/server/index.d.ts +70 -5
  102. package/dist/server/index.d.ts.map +1 -1
  103. package/dist/server/index.js +26 -3
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/procedure-server.d.ts.map +1 -1
  106. package/dist/server/procedure-server.js +5 -0
  107. package/dist/server/procedure-server.js.map +1 -1
  108. package/package.json +12 -1
  109. package/src/adapters/http/client/transport.ts +3 -0
  110. package/src/adapters/http/server/body.ts +43 -0
  111. package/src/adapters/http/server/cors.ts +42 -1
  112. package/src/adapters/http/server/handler.ts +10 -32
  113. package/src/adapters/local/client/transport.ts +4 -0
  114. package/src/browser.ts +25 -1
  115. package/src/client/client.ts +1 -1
  116. package/src/client/errors/registry.ts +1 -0
  117. package/src/client/types.ts +8 -0
  118. package/src/events/publisher.ts +8 -5
  119. package/src/events/rows.ts +19 -6
  120. package/src/index.ts +9 -6
  121. package/src/node.ts +13 -1
  122. package/src/procedures/collection/admission.ts +243 -0
  123. package/src/procedures/collection/changes.ts +11 -5
  124. package/src/procedures/collection/index.ts +21 -0
  125. package/src/procedures/collection/patch-in-parts.ts +56 -0
  126. package/src/procedures/collection/precondition.ts +180 -0
  127. package/src/procedures/collection/procedures.ts +69 -78
  128. package/src/procedures/collection/query.ts +16 -0
  129. package/src/procedures/collection/schemas.ts +43 -6
  130. package/src/procedures/collection/storage.ts +36 -6
  131. package/src/procedures/index.ts +22 -0
  132. package/src/procedures/invoke.ts +7 -2
  133. package/src/procedures/types.ts +8 -0
  134. package/src/protocol.ts +3 -1
  135. package/src/server/index.ts +94 -17
  136. package/src/server/procedure-server.ts +4 -0
@@ -29,6 +29,7 @@ import { ms, type Duration } from "../../../duration.js";
29
29
  import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
30
30
  import { applyCors, corsOrigins, corsPolicy, type CorsOptions, type CorsPolicy } from "./cors.js";
31
31
  import { urlAddressing, type Addressing } from "./addressing.js";
32
+ import { BodyError, readJsonBody } from "./body.js";
32
33
  import { NARROWING_METADATA_KEY, addNarrowing } from "../../../procedures/narrowing.js";
33
34
 
34
35
  /** The parts of a request that a URL strategy of `procedureHandler` reads. */
@@ -71,7 +72,9 @@ export interface ProcedureHandlerOptions extends BrowserGuardOptions {
71
72
  readonly addressing?: Addressing | undefined;
72
73
  /**
73
74
  * The CORS headers of the requests on the path. `true` uses the defaults: each origin, without
74
- * credentials. The permitted origins of the browser checks follow the CORS origins.
75
+ * credentials. The permitted origins of the browser checks follow the CORS origins. An origin
76
+ * that the CORS does not permit gets no `Access-Control-*` header. With a list or a function of
77
+ * origins, each response gets `Vary: Origin`.
75
78
  * @default false
76
79
  */
77
80
  readonly cors?: boolean | CorsOptions | undefined;
@@ -163,16 +166,6 @@ export interface HandlerCompat {
163
166
  readonly face?: { readonly name: string; readonly originOption: string } | undefined;
164
167
  }
165
168
 
166
- /** The error of a request body or of a header that the handler cannot read. */
167
- class BodyError extends Error {
168
- constructor(
169
- message: string,
170
- readonly httpStatus: number
171
- ) {
172
- super(message);
173
- }
174
- }
175
-
176
169
  /** True when a stream response has no end (`STREAM_METADATA_KEY` of procedure-server.ts). */
177
170
  function isUnboundedStream(response: { metadata?: Record<string, unknown> | undefined }): boolean {
178
171
  return response.metadata?.["stream"] === "unbounded";
@@ -295,8 +288,9 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
295
288
  const clock = options.clock ?? realClock;
296
289
  const corsOptions: CorsOptions | undefined = options.cors === true ? {} : options.cors === false ? undefined : options.cors;
297
290
  // A list of origins or a function: the default sends the credentials. "*" with credentials is invalid.
291
+ // An origin that is not permitted gets no CORS header, and each response still varies by the origin (CLI-56)
298
292
  const cors: CorsPolicy | undefined =
299
- corsOptions === undefined || compat.corsOutside ? undefined : corsPolicy(corsOptions, (corsOptions.origin ?? "*") !== "*");
293
+ corsOptions === undefined || compat.corsOutside ? undefined : { ...corsPolicy(corsOptions, (corsOptions.origin ?? "*") !== "*"), permittedOnly: true };
300
294
  const guard: BrowserGuardOptions = {
301
295
  allowedOrigins: options.allowedOrigins ?? corsOrigins(corsOptions),
302
296
  allowedHosts: options.allowedHosts,
@@ -353,26 +347,7 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
353
347
  * The request body. A body parser of a framework (`express.json({ strict: false })`) gives
354
348
  * `req.body`. Without one, the handler reads the JSON body itself (deep dive TRN-10).
355
349
  */
356
- const readBody = async (req: IncomingMessage): Promise<unknown> => {
357
- const parsed = (req as { body?: unknown }).body;
358
- if (parsed !== undefined) return parsed;
359
- if (req.method === "GET" || req.method === "HEAD" || req.readableEnded) return undefined;
360
- const chunks: Buffer[] = [];
361
- let size = 0;
362
- for await (const chunk of req as AsyncIterable<Buffer | string>) {
363
- const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
364
- size += buffer.length;
365
- if (size > bodyLimit) throw new BodyError(`The request body is larger than ${bodyLimit} bytes`, 413);
366
- chunks.push(buffer);
367
- }
368
- const text = Buffer.concat(chunks).toString("utf8");
369
- if (text.trim() === "") return undefined;
370
- try {
371
- return JSON.parse(text) as unknown;
372
- } catch {
373
- throw new BodyError("The request body is not valid JSON", 400);
374
- }
375
- };
350
+ const readBody = (req: IncomingMessage): Promise<unknown> => readJsonBody(req, bodyLimit);
376
351
 
377
352
  /**
378
353
  * This function converts an HTTP request to a `ServerRequest`, or gives null when the addressing
@@ -486,12 +461,15 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
486
461
  if (hasRetryAfter) res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000)));
487
462
  // The reason of a FORBIDDEN error travels on each face (roadmap R25)
488
463
  const reason = typeof metadata["reason"] === "string" ? metadata["reason"] : undefined;
464
+ // The current items of a VERSION_CONFLICT error (roadmap CLI-16)
465
+ const conflicts = Array.isArray(metadata["conflicts"]) ? metadata["conflicts"] : undefined;
489
466
  sendJson(res, httpStatusCode, {
490
467
  error: status.message,
491
468
  code: status.code,
492
469
  retryable: status.retryable,
493
470
  ...(hasRetryAfter ? { retryAfterMs } : {}),
494
471
  ...(reason !== undefined ? { reason } : {}),
472
+ ...(conflicts !== undefined ? { conflicts } : {}),
495
473
  });
496
474
  };
497
475
 
@@ -15,6 +15,7 @@ import { narrowCapability } from "../../../procedures/capability.js";
15
15
  import type { Capability, ClearanceOrder } from "../../../procedures/types.js";
16
16
  import { methodToPath } from "../../../server/method.js";
17
17
  import { errorCodeOf } from "../../../protocol.js";
18
+ import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../../../procedures/collection/precondition.js";
18
19
  import type { EventPublisher } from "../../../events/publisher.js";
19
20
 
20
21
  /** The value of `untilAborted` when the signal aborts first. */
@@ -236,6 +237,9 @@ export class LocalTransport implements Transport {
236
237
  const item = errorItem<TRes>(message.id, code, error instanceof Error ? error.message : "Unknown error");
237
238
  // The reason of a FORBIDDEN error travels on each face (roadmap R25)
238
239
  if (error instanceof InvocationError && error.reason) item.metadata = { reason: error.reason };
240
+ // The current items of a VERSION_CONFLICT error travel on each face (roadmap CLI-16)
241
+ const conflicts = versionConflictsOf(error);
242
+ if (conflicts !== undefined) item.metadata = { ...item.metadata, [VERSION_CONFLICTS_METADATA_KEY]: conflicts };
239
243
  yield item;
240
244
  }
241
245
  }
package/src/browser.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * This module exports everything of the package except the Node server transports
5
5
  * (`HttpServerTransport` and `WebSocketServerTransport`, which use `http` and `ws`).
6
6
  * A browser bundle imports "@procedures/client/browser". The root entry point
7
- * re-exports this module and adds the server transports.
7
+ * re-exports this module. It also adds the server transports as deprecated aliases: their entry
8
+ * is "@procedures/client/node", and the next MINOR removes them from the root (CLI-6).
8
9
  *
9
10
  * Collections (maps, caches, collection storage) are in @procedures/collections.
10
11
  * The program store, the storage backends that need a Client (ApiStorage, HybridStorage) and the
@@ -130,6 +131,9 @@ export {
130
131
  PARTITION_PATTERN,
131
132
  collectionTopicPartition,
132
133
  collectionTopicTarget,
134
+ // The rules of the rows of a collection (roadmap CLI-41)
135
+ admittedStorage,
136
+ admitsRow,
133
137
  // The query language of the collection storages (roadmap CLI-14, CLI-34)
134
138
  DEFAULT_FIND_LIMIT,
135
139
  DEFAULT_QUERY_OPERATORS,
@@ -145,6 +149,17 @@ export {
145
149
  queryEntries,
146
150
  queryOptionsProblems,
147
151
  selectorProblems,
152
+ // The precondition of the collection writes (roadmap CLI-16)
153
+ MAX_EXPECTED_VERSION_LENGTH,
154
+ PRECONDITION_NOT_SUPPORTED,
155
+ VERSION_CONFLICTS_METADATA_KEY,
156
+ VersionConflictError,
157
+ asVersionConflictError,
158
+ isExpectedVersion,
159
+ preconditionNotSupported,
160
+ versionConflict,
161
+ versionConflicts,
162
+ versionConflictsOf,
148
163
  // Manual registration helpers
149
164
  registerModule,
150
165
  registerProcedures,
@@ -264,6 +279,11 @@ export type {
264
279
  StorageChanges,
265
280
  StorageMetadata,
266
281
  StorageWriteOptions,
282
+ StorageItemWriteOptions,
283
+ StorageBatchWriteOptions,
284
+ CurrentItem,
285
+ VersionConflict,
286
+ VersionOf,
267
287
  StorageSetChange,
268
288
  StorageDeleteChange,
269
289
  StorageClearChange,
@@ -281,6 +301,10 @@ export type {
281
301
  StorageQueryPolicy,
282
302
  CollectionProceduresOptions,
283
303
  CollectionChangeEventsOptions,
304
+ CollectionAdmission,
305
+ CollectionRow,
306
+ CollectionWrite,
307
+ CollectionWriteGuard,
284
308
  CollectionPartitions,
285
309
  PartitionInput,
286
310
  PartitionResolver,
@@ -799,7 +799,7 @@ export class Client<TContext = {}> {
799
799
  // Yield payloads, handling errors
800
800
  for await (const item of responseStream) {
801
801
  if (item.status.type === "error" && this.throwOnError) {
802
- throw new ClientError(item.status, item.id);
802
+ throw new ClientError(item.status, item.id, item.metadata);
803
803
  }
804
804
 
805
805
  yield item.payload;
@@ -85,6 +85,7 @@ const PROTOCOL_CATEGORIES: Readonly<Record<ProtocolErrorCode, ErrorCategory>> =
85
85
  CONNECTION_NOT_FOUND: ErrorCategory.CLIENT,
86
86
  TOPIC_NOT_FOUND: ErrorCategory.CLIENT,
87
87
  TOPIC_CLEARED: ErrorCategory.CANCELLED,
88
+ VERSION_CONFLICT: ErrorCategory.CLIENT,
88
89
  };
89
90
 
90
91
  /**
@@ -326,12 +326,20 @@ export type AccumulateContext<
326
326
  * Client error thrown when response has error status.
327
327
  */
328
328
  export class ClientError extends Error {
329
+ /**
330
+ * The metadata of the error response, for example `reason` of a `FORBIDDEN` error, or
331
+ * `conflicts` of a `VERSION_CONFLICT` error (roadmap CLI-16). Empty when the response has none.
332
+ */
333
+ public readonly metadata: Metadata;
334
+
329
335
  constructor(
330
336
  public readonly status: Extract<Status, { type: "error" }>,
331
337
  public readonly responseId: string,
338
+ metadata: Metadata = {},
332
339
  ) {
333
340
  super(status.message);
334
341
  this.name = "ClientError";
342
+ this.metadata = metadata;
335
343
  }
336
344
 
337
345
  get code(): string {
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { z } from "zod";
28
28
  import { createProcedure } from "../procedures/define.js";
29
- import { InvocationError } from "../procedures/invoke.js";
29
+ import { InvocationError, type Invocation } from "../procedures/invoke.js";
30
30
  import { capabilityRefusal } from "../procedures/capability.js";
31
31
  import { collectionChangeTopic, collectionTopicReads } from "../procedures/collection/changes.js";
32
32
  import { collectionTopicTarget, partitionOfCall } from "../procedures/collection/partition.js";
@@ -177,6 +177,8 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
177
177
  private readonly onClose: (subscription: TopicSubscription) => void,
178
178
  /** The output filter of the host for this subscriber: each row of a value passes it */
179
179
  private readonly admit: ((item: unknown) => Promise<boolean>) | undefined,
180
+ /** The invocation of the subscription: the rule of the rows of a value gets it (roadmap CLI-41) */
181
+ private readonly invocation?: Invocation | undefined,
180
182
  ) {
181
183
  this.confirmation = { type: "subscribed", topics: routes.map(([topic]) => topic) };
182
184
  // Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
@@ -214,12 +216,13 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
214
216
 
215
217
  /**
216
218
  * The item of an event for the subscriber. The rows of its data pass the output filter of the
217
- * host for this subscriber, and the subscriber gets only the admitted rows.
219
+ * host for this subscriber and the rule of the rows, and the subscriber gets only the admitted rows.
218
220
  */
219
221
  private deliver(event: Extract<EventItem, { type: "event" }>): Promise<IteratorResult<EventItem>> {
220
222
  const item = this.withDropped(event) as Extract<EventItem, { type: "event" }>;
221
- if (!this.admit || !rowsOf(item.data)) return Promise.resolve({ value: item, done: false });
222
- return admittedValue(item.data, this.admit).then((data): IteratorResult<EventItem> => ({ value: { ...item, data }, done: false }));
223
+ const rows = rowsOf(item.data);
224
+ if (!rows || (!this.admit && !rows.admit)) return Promise.resolve({ value: item, done: false });
225
+ return admittedValue(item.data, this.admit, this.invocation).then((data): IteratorResult<EventItem> => ({ value: { ...item, data }, done: false }));
223
226
  }
224
227
 
225
228
  /** The event with the number of events that the publisher dropped before it. */
@@ -407,7 +410,7 @@ export class EventPublisher {
407
410
  }
408
411
  const routes: Array<readonly [string, string]> = [];
409
412
  for (const topic of unique) routes.push([topic, await this.busTopic(topic, ctx)]);
410
- const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
413
+ const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit, ctx.invocation);
411
414
  this.open.add(subscription);
412
415
  return subscription.closeIfAborted();
413
416
  }
@@ -7,7 +7,12 @@
7
7
  * reaches a subscriber only through the same filter as a read.
8
8
  *
9
9
  * The rows are on a symbol key, so they stay in this process: JSON does not take them.
10
+ *
11
+ * The rows can have a rule of their own too (`admit`): the rule of the rows of a collection
12
+ * (roadmap CLI-41). Each subscriber applies it after the filter of the host, with the invocation
13
+ * of its subscription.
10
14
  */
15
+ import type { AdmitItem, Invocation } from "../procedures/invoke.js";
11
16
 
12
17
  /** The rows of a published value, and the function that makes the value of the admitted rows. */
13
18
  export interface PublishedRows {
@@ -15,6 +20,8 @@ export interface PublishedRows {
15
20
  readonly rows: readonly unknown[];
16
21
  /** This function gives the value for a subscriber: `admitted[i]` tells if row `i` stays. */
17
22
  keep(admitted: readonly boolean[]): unknown;
23
+ /** The rule of the rows themselves, with the invocation of the subscription. A row stays only when it gives true. */
24
+ readonly admit?: AdmitItem | undefined;
18
25
  }
19
26
 
20
27
  /** The key of the rows of a published value. */
@@ -36,13 +43,19 @@ export function rowsOf(value: unknown): PublishedRows | undefined {
36
43
  }
37
44
 
38
45
  /**
39
- * The value of a published value for one subscriber: each row passes the filter, and the value
40
- * keeps the admitted rows. An error of the filter refuses the row. Without rows or a filter, the
41
- * value stays as it is.
46
+ * The value of a published value for one subscriber: each row passes the filter of the host and
47
+ * the rule of the rows, and the value keeps the admitted rows. An error of a filter refuses the
48
+ * row. A rule of the rows refuses each row of a subscription without an invocation. Without rows,
49
+ * or without a filter and a rule, the value stays as it is.
42
50
  */
43
- export async function admittedValue(value: unknown, admit: ((item: unknown) => Promise<boolean>) | undefined): Promise<unknown> {
51
+ export async function admittedValue(value: unknown, admit: ((item: unknown) => Promise<boolean>) | undefined, invocation?: Invocation): Promise<unknown> {
44
52
  const rows = rowsOf(value);
45
- if (!rows || !admit) return value;
46
- const admitted = await Promise.all(rows.rows.map((row) => admit(row).catch(() => false)));
53
+ const own = rows?.admit;
54
+ if (!rows || (!admit && !own)) return value;
55
+ const admits = async (row: unknown): Promise<boolean> => {
56
+ if (admit && !(await admit(row))) return false;
57
+ return !own || (invocation !== undefined && (await own(row, invocation)) === true);
58
+ };
59
+ const admitted = await Promise.all(rows.rows.map((row) => admits(row).catch(() => false)));
47
60
  return rows.keep(admitted);
48
61
  }
package/src/index.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * and transport adapters.
6
6
  *
7
7
  * This entry point is for Node. It exports the browser-safe entry point
8
- * ("@procedures/client/browser") and the server transports, which use `http` and `ws`.
8
+ * ("@procedures/client/browser"). It also exports the server transports, which use `http` and
9
+ * `ws`, but these exports are deprecated (CLI-6 step 1). Their entry is "@procedures/client/node".
10
+ * The next MINOR removes them from this entry, so the root imports no `http` and no `ws`.
9
11
  *
10
12
  * The `WebSocketTransport` of this entry uses the global `WebSocket`, because some applications
11
13
  * bundle this entry for a browser. The Node entry "@procedures/client/node" has a
@@ -17,11 +19,11 @@ export * from "./browser.js";
17
19
  // ============================================================================
18
20
  // Server Transports (Node only)
19
21
  // ============================================================================
20
- export {
21
- HttpServerTransport,
22
- defaultServerUrlStrategy,
23
- rpcServerUrlStrategy,
24
- } from "./server/index.js";
22
+ export { defaultServerUrlStrategy, rpcServerUrlStrategy } from "./adapters/http/server/strategies.js";
23
+ // Deprecated (CLI-6 step 1): import these from "@procedures/client/node". The next MINOR removes
24
+ // them from the root. ./server/index.ts holds the deprecated aliases, so an editor marks each old
25
+ // import, and each alias is the same class or type as the export of ./node.
26
+ export { HttpServerTransport } from "./server/index.js";
25
27
  export type {
26
28
  HttpServerTransportOptions,
27
29
  HttpUrlStrategy,
@@ -29,6 +31,7 @@ export type {
29
31
  export { WebSocketServerTransport } from "./server/index.js";
30
32
  export { checkBrowserRequest, hasAmbientCredentials, isLoopbackHost, isLoopbackAddress, hostNameOf } from "./server/browser-guard.js";
31
33
  export type { BrowserGuardOptions, GuardedRequest, GuardResult } from "./server/browser-guard.js";
34
+ // Deprecated (CLI-6 step 1): import these from "@procedures/client/node"
32
35
  export type {
33
36
  WebSocketServerTransportOptions,
34
37
  WebSocketAuthHandler,
package/src/node.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  * The option `addressing` of `procedureHandler` gives the method and the payload of a request
19
19
  * (CLI-31). `urlAddressing()` is the default, and `bodyPathAddressing()` reads the path from the
20
20
  * body of a single endpoint.
21
+ *
22
+ * The server transports are in this entry too (CLI-6): `HttpServerTransport`, the Express shim
23
+ * over `procedureHandler`, and `WebSocketServerTransport`. The root and `./server` also export
24
+ * them, but these exports are deprecated. The next MINOR removes them, so the root imports no
25
+ * `http` and no `ws`.
21
26
  */
22
27
 
23
28
  export { WebSocketTransport } from "./adapters/websocket/client/node.js";
@@ -25,13 +30,20 @@ export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/webso
25
30
  export { writeNdjsonStream } from "./adapters/http/server/ndjson.js";
26
31
  export type { WriteNdjsonStreamOptions, NdjsonError } from "./adapters/http/server/ndjson.js";
27
32
  export { procedureHandler } from "./adapters/http/server/handler.js";
33
+ // The JSON body of a request, read to a limit: the procedure handler and the MCP face read it so (CLI-35)
34
+ export { readJsonBody, BodyError } from "./adapters/http/server/body.js";
28
35
  export type { ProcedureHandlerOptions, NodeProcedureHandler, NodeUrlStrategy, UrlRequest } from "./adapters/http/server/handler.js";
29
- export type { CorsOptions } from "./adapters/http/server/cors.js";
36
+ export type { CorsOptions, CredentialedCorsOptions } from "./adapters/http/server/cors.js";
37
+ // The credentialed CORS of the trusted origins of a host, for a route of the app (CLI-49)
38
+ export { credentialedCors } from "./adapters/http/server/cors.js";
30
39
  export type { RoutedRequest } from "./adapters/http/server/strategies.js";
31
40
  export { defaultServerUrlStrategy, rpcServerUrlStrategy } from "./adapters/http/server/strategies.js";
32
41
  // The addressing of a request: the URL (the default), or the path in the body of a single endpoint (CLI-31)
33
42
  export { urlAddressing, bodyPathAddressing } from "./adapters/http/server/addressing.js";
34
43
  export type { Addressing, CallAddress, UrlAddressingOptions, BodyPathAddressingOptions } from "./adapters/http/server/addressing.js";
44
+ // The server transports (CLI-6): the root and ./server export them only until the next MINOR
45
+ export { HttpServerTransport } from "./adapters/http/server/transport.js";
46
+ export type { HttpServerTransportOptions, HttpUrlStrategy } from "./adapters/http/server/types.js";
35
47
  export { upgradeHandler } from "./adapters/websocket/server/upgrade.js";
36
48
  export type { UpgradeHandlerOptions, NodeUpgradeHandler } from "./adapters/websocket/server/upgrade.js";
37
49
  export { WebSocketServerTransport } from "./adapters/websocket/server/transport.js";
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The rules of the rows of a served collection (roadmap CLI-41). A right can depend on the
3
+ * document: a `when` condition of Graph, or `accessAdmitItem()` of `@procedures/access`. The option
4
+ * `admission` of `createCollectionProcedures()` gives the rules as data:
5
+ *
6
+ * - `admit` examines each row `{ id, value }` that a read gives. It has the shape of `AdmitItem`,
7
+ * the output filter of a host. The change events with values give it the same rows for each
8
+ * subscriber, so the reads and the events obey one rule.
9
+ * - `guard` examines each write `{ op, id, doc, prev }` before the storage changes. A refused write
10
+ * gets `FORBIDDEN` with the reason `write-not-allowed`.
11
+ *
12
+ * The procedures apply the rules in one place: the view of the storage of a call
13
+ * (`admittedStorage()`). Each read and each write of each procedure goes through this view.
14
+ *
15
+ * - A refused row is absent: `get` gives undefined, `has` gives false, and the lists, `size`,
16
+ * `find` and `changes` do not have it. A delta of `changes` puts the id of a refused row in
17
+ * `deleted`, so a copy of the caller drops it.
18
+ * - An error of `admit` fails the read. It does not hide the rows, so a sync does not delete them.
19
+ * - The guard reads the stored item before the write. CLI-16 (`expectedVersion`) closes the gap of
20
+ * a write of another writer between that read and the write.
21
+ * - The rules get the invocation of the call (`ctx.invocation`). A call without an invocation gets
22
+ * no row and makes no write.
23
+ */
24
+ import { InvocationError, type AdmitItem, type Invocation } from "../invoke.js";
25
+ import type { ProcedureContext } from "../types.js";
26
+ import { applyPatch, type StoragePatch, type StorageQueryOptions, type StorageQueryPolicy, type StorageSelector } from "./query.js";
27
+ import type { CollectionStorage, StorageChanges, StorageWriteOptions } from "./storage.js";
28
+ import { patchInParts } from "./patch-in-parts.js";
29
+
30
+ /** A row of a collection: the id and the stored item. The rule `admit` examines one row at a time. */
31
+ export interface CollectionRow {
32
+ readonly id: string;
33
+ readonly value: unknown;
34
+ }
35
+
36
+ /** A write of a collection, as the guard examines it. */
37
+ export interface CollectionWrite {
38
+ /**
39
+ * The operation. `setBatch` gives one `set` for each item. `deleteBatch` and `clear` give one
40
+ * `delete` for each id.
41
+ */
42
+ readonly op: "set" | "patch" | "delete";
43
+ /** The id of the item. */
44
+ readonly id: string;
45
+ /** The item after the write: the value of a `set`, or the merged item of a `patch`. Undefined for a `delete`. */
46
+ readonly doc: unknown;
47
+ /** The stored item before the write. Undefined when the id has no item. */
48
+ readonly prev: unknown;
49
+ }
50
+
51
+ /**
52
+ * The guard of the writes of a collection. It gets each write and the invocation of the call. It
53
+ * gives true to permit the write. An error that it throws passes through, and the storage does
54
+ * not change.
55
+ */
56
+ export type CollectionWriteGuard = (write: CollectionWrite, invocation: Invocation) => boolean | Promise<boolean>;
57
+
58
+ /** The rules of the rows of a served collection (roadmap CLI-41). Each rule is optional. */
59
+ export interface CollectionAdmission {
60
+ /**
61
+ * The rule of the rows. It gets each row `{ id, value }` of a read and the invocation of the
62
+ * call, and it gives true to admit the row. The change events with values apply it for each
63
+ * subscriber, with the invocation of the subscription.
64
+ */
65
+ admit?: AdmitItem | undefined;
66
+ /** The guard of each `set`, `patch` and `delete`, also of the batches and of `clear`. */
67
+ guard?: CollectionWriteGuard | undefined;
68
+ }
69
+
70
+ /** The size of a page of a query of the storage, when the policy gives no `maxLimit`. */
71
+ const QUERY_PAGE = 1000;
72
+
73
+ /** The refusal of a write. */
74
+ function writeRefused(ctx: ProcedureContext, write: CollectionWrite): InvocationError {
75
+ return new InvocationError("FORBIDDEN", `The rule of the collection does not permit the ${write.op} of "${write.id}"`, ctx.path, "write-not-allowed");
76
+ }
77
+
78
+ /**
79
+ * This function tells if the rule of the rows admits a row for a call. Without a rule, it gives
80
+ * true. A call without an invocation gets no row.
81
+ */
82
+ export async function admitsRow(admission: CollectionAdmission | undefined, ctx: ProcedureContext, id: string, value: unknown): Promise<boolean> {
83
+ const admit = admission?.admit;
84
+ if (!admit) return true;
85
+ const invocation = ctx.invocation;
86
+ return invocation !== undefined && (await admit({ id, value }, invocation)) === true;
87
+ }
88
+
89
+ /**
90
+ * This function gives the view of the storage of a call: the storage, with the rules of the
91
+ * collection for each read and each write. Without rules, it gives the storage itself.
92
+ *
93
+ * @param storage - The storage of the call: of the collection, or of its partition
94
+ * @param admission - The rules of the collection
95
+ * @param ctx - The context of the call. The rules get its invocation.
96
+ */
97
+ export function admittedStorage<T>(storage: CollectionStorage<T>, admission: CollectionAdmission | undefined, ctx: ProcedureContext): CollectionStorage<T> {
98
+ const admit = admission?.admit;
99
+ const guard = admission?.guard;
100
+ if (!admit && !guard) return storage;
101
+ const invocation = ctx.invocation;
102
+
103
+ const admits = (id: string, value: unknown): Promise<boolean> => admitsRow(admission, ctx, id, value);
104
+ /** The entries that the rule admits, in their order. */
105
+ const admitted = async (entries: Array<[string, T]>): Promise<Array<[string, T]>> => {
106
+ if (!admit) return entries;
107
+ const keep = await Promise.all(entries.map(([id, value]) => admits(id, value)));
108
+ return entries.filter((_, index) => keep[index]);
109
+ };
110
+ /** The entries of the storage that the rule admits. The rule needs the ids, so the storage must give them. */
111
+ const entries = async (): Promise<Array<[string, T]>> => {
112
+ if (!storage.getEntries) throw new Error("The storage cannot give the ids of its items, so the rule of the rows cannot examine them");
113
+ return admitted(await storage.getEntries());
114
+ };
115
+ /** This function throws `FORBIDDEN` unless the guard permits each write. */
116
+ const permit = async (writes: readonly CollectionWrite[]): Promise<void> => {
117
+ if (!guard) return;
118
+ for (const write of writes) {
119
+ if (invocation === undefined || (await guard(write, invocation)) !== true) throw writeRefused(ctx, write);
120
+ }
121
+ };
122
+
123
+ const view: CollectionStorage<T> = {
124
+ async get(id) {
125
+ const value = await storage.get(id);
126
+ return value === undefined || (await admits(id, value)) ? value : undefined;
127
+ },
128
+ async getAll() {
129
+ return admit ? (await entries()).map(([, value]) => value) : storage.getAll();
130
+ },
131
+ async find(predicate) {
132
+ return admit ? (await entries()).map(([, value]) => value).filter(predicate) : storage.find(predicate);
133
+ },
134
+ async has(id) {
135
+ return admit ? (await view.get(id)) !== undefined : storage.has(id);
136
+ },
137
+ async size() {
138
+ return admit ? (await entries()).length : storage.size();
139
+ },
140
+ async getBatch(ids) {
141
+ const found = await storage.getBatch(ids);
142
+ if (!admit) return found;
143
+ const kept = await admitted([...found.entries()]);
144
+ return new Map(kept);
145
+ },
146
+
147
+ async set(id, value, options) {
148
+ if (guard) await permit([{ op: "set", id, doc: value, prev: await storage.get(id) }]);
149
+ return storage.set(id, value, options);
150
+ },
151
+ async delete(id, options) {
152
+ if (guard) await permit([{ op: "delete", id, doc: undefined, prev: await storage.get(id) }]);
153
+ return storage.delete(id, options);
154
+ },
155
+ async clear(options) {
156
+ if (!guard) return storage.clear(options);
157
+ // Each item is a delete that the guard examines. The storage deletes only the items that it examined.
158
+ if (!storage.getEntries) throw new Error("The storage cannot give the ids of its items, so the guard cannot examine a clear");
159
+ const all = await storage.getEntries();
160
+ await permit(all.map(([id, prev]) => ({ op: "delete" as const, id, doc: undefined, prev })));
161
+ if (all.length > 0) await storage.deleteBatch(all.map(([id]) => id), options);
162
+ },
163
+ async setBatch(items, options) {
164
+ if (guard) {
165
+ const before = await storage.getBatch(items.map(([id]) => id));
166
+ await permit(items.map(([id, value]) => ({ op: "set" as const, id, doc: value, prev: before.get(id) })));
167
+ }
168
+ return storage.setBatch(items, options);
169
+ },
170
+ async deleteBatch(ids, options) {
171
+ if (guard) {
172
+ const before = await storage.getBatch(ids);
173
+ await permit(ids.map((id) => ({ op: "delete" as const, id, doc: undefined, prev: before.get(id) })));
174
+ }
175
+ return storage.deleteBatch(ids, options);
176
+ },
177
+ async patch(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined> {
178
+ const check = async (prev: unknown, doc: unknown): Promise<void> => permit([{ op: "patch", id, doc, prev }]);
179
+ if (!storage.patch) return patchInParts(storage as CollectionStorage<unknown>, id, fields, options, guard ? check : undefined) as Promise<T | undefined>;
180
+ if (guard) {
181
+ const prev = await storage.get(id);
182
+ // A patch of a missing id changes nothing
183
+ if (prev === undefined) return undefined;
184
+ await check(prev, applyPatch(prev, fields));
185
+ }
186
+ return storage.patch(id, fields, options);
187
+ },
188
+ close: () => storage.close(),
189
+ };
190
+
191
+ if (storage.getEntries) view.getEntries = async () => (admit ? entries() : storage.getEntries!());
192
+ if (admit ? storage.getEntries : storage.getIds) {
193
+ view.getIds = async () => (admit ? (await entries()).map(([id]) => id) : storage.getIds!());
194
+ }
195
+ if (storage.getChangesSince) {
196
+ view.getChangesSince = async (since?: string): Promise<StorageChanges<T>> => {
197
+ const changes = await storage.getChangesSince!(since);
198
+ if (!admit) return changes;
199
+ const changed = await admitted(changes.changed);
200
+ if (changes.full) return { ...changes, changed };
201
+ // A delta: the copy of the caller drops each refused row
202
+ const kept = new Set(changed.map(([id]) => id));
203
+ const refused = changes.changed.filter(([id]) => !kept.has(id)).map(([id]) => id);
204
+ return { ...changes, changed, deleted: [...changes.deleted, ...refused] };
205
+ };
206
+ }
207
+ if (storage.query) {
208
+ view.query = async (selector: StorageSelector, options?: StorageQueryOptions, policy?: StorageQueryPolicy) =>
209
+ admit ? admittedQuery(storage, admitted, selector, options ?? {}, policy) : storage.query!(selector, options, policy);
210
+ }
211
+ return view;
212
+ }
213
+
214
+ /**
215
+ * The query of a storage with the rule of the rows. The storage runs the selector and the order,
216
+ * page by page. The skip and the limit count the admitted rows only, so a page of the caller is full.
217
+ */
218
+ async function admittedQuery<T>(
219
+ storage: CollectionStorage<T>,
220
+ admitted: (entries: Array<[string, T]>) => Promise<Array<[string, T]>>,
221
+ selector: StorageSelector,
222
+ options: StorageQueryOptions,
223
+ policy: StorageQueryPolicy | undefined,
224
+ ): Promise<Array<[string, T]>> {
225
+ const page = policy?.maxLimit !== undefined && Number.isFinite(policy.maxLimit) ? policy.maxLimit : QUERY_PAGE;
226
+ const skip = options.skip ?? 0;
227
+ const limit = options.limit;
228
+ const out: Array<[string, T]> = [];
229
+ let skipped = 0;
230
+ for (let offset = 0; ; offset += page) {
231
+ const rows = await storage.query!(selector, { ...(options.sort !== undefined && { sort: options.sort }), skip: offset, limit: page }, policy);
232
+ for (const entry of await admitted(rows)) {
233
+ if (skipped < skip) {
234
+ skipped++;
235
+ continue;
236
+ }
237
+ out.push(entry);
238
+ if (limit !== undefined && out.length >= limit) return out;
239
+ }
240
+ if (rows.length < page) return out;
241
+ }
242
+ }
243
+