@spine-event-engine/server 2.0.0-snapshot.25 → 2.0.0-snapshot.26

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 (150) hide show
  1. package/README.md +49 -9
  2. package/REFERENCE.md +339 -22
  3. package/dist/agent/agent-history.d.ts +19 -3
  4. package/dist/agent/agent-history.d.ts.map +1 -1
  5. package/dist/agent/agent-history.js +41 -10
  6. package/dist/agent/agent-history.js.map +1 -1
  7. package/dist/agent/agent-live-read-runtime.d.ts +12 -1
  8. package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
  9. package/dist/agent/agent-live-read-runtime.js +5 -4
  10. package/dist/agent/agent-live-read-runtime.js.map +1 -1
  11. package/dist/browser/browser-server.d.ts +163 -0
  12. package/dist/browser/browser-server.d.ts.map +1 -1
  13. package/dist/browser/browser-server.js +579 -188
  14. package/dist/browser/browser-server.js.map +1 -1
  15. package/dist/browser/durable-subscription-bindings.d.ts +5 -0
  16. package/dist/browser/durable-subscription-bindings.d.ts.map +1 -1
  17. package/dist/browser/durable-subscription-bindings.js +226 -38
  18. package/dist/browser/durable-subscription-bindings.js.map +1 -1
  19. package/dist/browser/options.d.ts +5 -0
  20. package/dist/browser/options.d.ts.map +1 -1
  21. package/dist/bus/command-bus.d.ts +3 -0
  22. package/dist/bus/command-bus.d.ts.map +1 -1
  23. package/dist/bus/command-bus.js +69 -9
  24. package/dist/bus/command-bus.js.map +1 -1
  25. package/dist/bus/command-errors.d.ts +20 -4
  26. package/dist/bus/command-errors.d.ts.map +1 -1
  27. package/dist/bus/command-errors.js +47 -7
  28. package/dist/bus/command-errors.js.map +1 -1
  29. package/dist/bus/event-bus.d.ts +3 -0
  30. package/dist/bus/event-bus.d.ts.map +1 -1
  31. package/dist/bus/event-bus.js +130 -8
  32. package/dist/bus/event-bus.js.map +1 -1
  33. package/dist/context/bounded-context.d.ts +29 -11
  34. package/dist/context/bounded-context.d.ts.map +1 -1
  35. package/dist/context/bounded-context.js +147 -186
  36. package/dist/context/bounded-context.js.map +1 -1
  37. package/dist/entity/entity.d.ts +59 -0
  38. package/dist/entity/entity.d.ts.map +1 -1
  39. package/dist/entity/entity.js +60 -0
  40. package/dist/entity/entity.js.map +1 -1
  41. package/dist/handler/app-query-handler.d.ts +60 -0
  42. package/dist/handler/app-query-handler.d.ts.map +1 -0
  43. package/dist/handler/app-query-handler.js +23 -0
  44. package/dist/handler/app-query-handler.js.map +1 -0
  45. package/dist/handler/generated-registry-discovery.d.ts +6 -0
  46. package/dist/handler/generated-registry-discovery.d.ts.map +1 -1
  47. package/dist/handler/generated-registry-discovery.js +71 -2
  48. package/dist/handler/generated-registry-discovery.js.map +1 -1
  49. package/dist/handler/handler-decorators.d.ts +8 -29
  50. package/dist/handler/handler-decorators.d.ts.map +1 -1
  51. package/dist/handler/handler-decorators.js +33 -210
  52. package/dist/handler/handler-decorators.js.map +1 -1
  53. package/dist/handler/spine-metadata-inventory.d.ts +99 -0
  54. package/dist/handler/spine-metadata-inventory.d.ts.map +1 -0
  55. package/dist/handler/spine-metadata-inventory.js +162 -0
  56. package/dist/handler/spine-metadata-inventory.js.map +1 -0
  57. package/dist/index.d.ts +5 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/repository/repository.d.ts +44 -1
  62. package/dist/repository/repository.d.ts.map +1 -1
  63. package/dist/repository/repository.js +368 -88
  64. package/dist/repository/repository.js.map +1 -1
  65. package/dist/server/managed-server-application.d.ts +12 -11
  66. package/dist/server/managed-server-application.d.ts.map +1 -1
  67. package/dist/server/managed-server-application.js +41 -25
  68. package/dist/server/managed-server-application.js.map +1 -1
  69. package/dist/server/node-coordinator.d.ts +18 -5
  70. package/dist/server/node-coordinator.d.ts.map +1 -1
  71. package/dist/server/node-coordinator.js +554 -84
  72. package/dist/server/node-coordinator.js.map +1 -1
  73. package/dist/server/reserved-spine-rpc-paths.json +1 -0
  74. package/dist/server/server-environment.d.ts +17 -0
  75. package/dist/server/server-environment.d.ts.map +1 -1
  76. package/dist/server/server-environment.js +77 -16
  77. package/dist/server/server-environment.js.map +1 -1
  78. package/dist/server/server.d.ts +45 -0
  79. package/dist/server/server.d.ts.map +1 -1
  80. package/dist/server/server.js +111 -3
  81. package/dist/server/server.js.map +1 -1
  82. package/dist/services/app-query-execution.d.ts +53 -0
  83. package/dist/services/app-query-execution.d.ts.map +1 -0
  84. package/dist/services/app-query-execution.js +408 -0
  85. package/dist/services/app-query-execution.js.map +1 -0
  86. package/dist/services/query-reader.d.ts +32 -7
  87. package/dist/services/query-reader.d.ts.map +1 -1
  88. package/dist/services/query-reader.js +22 -2
  89. package/dist/services/query-reader.js.map +1 -1
  90. package/dist/services/read-request-status.d.ts +65 -0
  91. package/dist/services/read-request-status.d.ts.map +1 -0
  92. package/dist/services/read-request-status.js +151 -0
  93. package/dist/services/read-request-status.js.map +1 -0
  94. package/dist/services/spine-services.d.ts +36 -7
  95. package/dist/services/spine-services.d.ts.map +1 -1
  96. package/dist/services/spine-services.js +900 -168
  97. package/dist/services/spine-services.js.map +1 -1
  98. package/dist/services/trusted-reader-ingress.d.ts +92 -0
  99. package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
  100. package/dist/services/trusted-reader-ingress.js +89 -0
  101. package/dist/services/trusted-reader-ingress.js.map +1 -0
  102. package/dist/spi/handler-registry.d.ts +2 -0
  103. package/dist/spi/handler-registry.d.ts.map +1 -1
  104. package/dist/spi/handler-registry.js +1 -1
  105. package/dist/spi/handler-registry.js.map +1 -1
  106. package/dist/stand/read-side-filter-engine.d.ts +46 -0
  107. package/dist/stand/read-side-filter-engine.d.ts.map +1 -0
  108. package/dist/stand/read-side-filter-engine.js +356 -0
  109. package/dist/stand/read-side-filter-engine.js.map +1 -0
  110. package/dist/stand/read-side-filter.d.ts +247 -0
  111. package/dist/stand/read-side-filter.d.ts.map +1 -0
  112. package/dist/stand/read-side-filter.js +39 -0
  113. package/dist/stand/read-side-filter.js.map +1 -0
  114. package/dist/stand/read-side-message.d.ts +66 -0
  115. package/dist/stand/read-side-message.d.ts.map +1 -0
  116. package/dist/stand/read-side-message.js +167 -0
  117. package/dist/stand/read-side-message.js.map +1 -0
  118. package/dist/stand/stand-direct-delivery.d.ts +161 -0
  119. package/dist/stand/stand-direct-delivery.d.ts.map +1 -0
  120. package/dist/stand/stand-direct-delivery.js +210 -0
  121. package/dist/stand/stand-direct-delivery.js.map +1 -0
  122. package/dist/stand/stand.d.ts +80 -42
  123. package/dist/stand/stand.d.ts.map +1 -1
  124. package/dist/stand/stand.js +410 -54
  125. package/dist/stand/stand.js.map +1 -1
  126. package/dist/stand/subscription-observer.d.ts.map +1 -1
  127. package/dist/stand/subscription-observer.js +103 -0
  128. package/dist/stand/subscription-observer.js.map +1 -1
  129. package/dist/stand/subscription-read-filters.d.ts +40 -0
  130. package/dist/stand/subscription-read-filters.d.ts.map +1 -0
  131. package/dist/stand/subscription-read-filters.js +91 -0
  132. package/dist/stand/subscription-read-filters.js.map +1 -0
  133. package/dist/stand/subscription-reader-binding.d.ts +20 -0
  134. package/dist/stand/subscription-reader-binding.d.ts.map +1 -0
  135. package/dist/stand/subscription-reader-binding.js +56 -0
  136. package/dist/stand/subscription-reader-binding.js.map +1 -0
  137. package/dist/stand/subscription-records.d.ts +30 -3
  138. package/dist/stand/subscription-records.d.ts.map +1 -1
  139. package/dist/stand/subscription-records.js +61 -16
  140. package/dist/stand/subscription-records.js.map +1 -1
  141. package/dist/stand/subscription-registry.d.ts +49 -14
  142. package/dist/stand/subscription-registry.d.ts.map +1 -1
  143. package/dist/stand/subscription-registry.js +150 -31
  144. package/dist/stand/subscription-registry.js.map +1 -1
  145. package/dist/stand/subscription-runtime.d.ts +7 -2
  146. package/dist/stand/subscription-runtime.d.ts.map +1 -1
  147. package/dist/stand/subscription-runtime.js +131 -25
  148. package/dist/stand/subscription-runtime.js.map +1 -1
  149. package/dist/tsconfig.tsbuildinfo +1 -1
  150. package/package.json +14 -14
@@ -15,17 +15,22 @@ import * as http2 from "node:http2";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { clone, create, equals, fromBinary, toBinary } from "@bufbuild/protobuf";
17
17
  import { EmptySchema } from "@bufbuild/protobuf/wkt";
18
- import { Code, ConnectError, createClient } from "@connectrpc/connect";
18
+ import { Code, ConnectError, createClient, } from "@connectrpc/connect";
19
19
  import { connectNodeAdapter, createGrpcTransport, Http2SessionManager, } from "@connectrpc/connect-node";
20
20
  import { BackendMembershipKernel, } from "@spine-event-engine/deployment/spi/backend-membership";
21
21
  import { SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS } from "@spine-event-engine/core/spi/subscription-lifecycle";
22
+ import { Validate } from "@spine-event-engine/core";
23
+ import { ReaderForwarding, } from "@spine-event-engine/core/spi/reader-forwarding";
24
+ import { ReaderIdentities, snapshotTrustedReader, } from "@spine-event-engine/core/spi/trusted-reader";
22
25
  import { AckSchema, ResponseSchema, StatusSchema, } from "@spine-event-engine/proto";
23
- import { QueryResponseSchema, CommandService, QueryService, SubscriptionIdSchema, SubscriptionSchema, SubscriptionService, SubscriptionUpdateSchema, TopicSchema, } from "@spine-event-engine/proto/client";
26
+ import { AppQueryResponseSchema, AppQuerySchema, QueryResponseSchema, QuerySchema, CommandService, QueryService, SubscriptionIdSchema, SubscriptionSchema, SubscriptionService, SubscriptionUpdateSchema, TopicSchema, } from "@spine-event-engine/proto/client";
27
+ import { ReadRequestStatus } from "../services/read-request-status.js";
28
+ import { TrustedReaderIngress, } from "../services/trusted-reader-ingress.js";
29
+ import { ServerEnvironment } from "./server-environment.js";
24
30
  const defaultHost = "127.0.0.1";
25
31
  const defaultPort = 0;
26
32
  const defaultMessageMaxBytes = 4_194_304;
27
33
  const gracefulSessionDrainMs = 100;
28
- const subscriptionQueueLimit = 100;
29
34
  /**
30
35
  * Hosts generated unary services in front of managed complete replicas.
31
36
  *
@@ -34,6 +39,13 @@ const subscriptionQueueLimit = 100;
34
39
  export class NodeCoordinator {
35
40
  // prettier-ignore
36
41
  #members;
42
+ #readerAdmission;
43
+ #forwardingProof;
44
+ #readers = new Map();
45
+ #children = new Map();
46
+ #logicalForChild = new Map();
47
+ #readMaxBytes;
48
+ #subscriptionQueueCapacity;
37
49
  #unaryKernel;
38
50
  #subscriptionKernel;
39
51
  #server;
@@ -67,26 +79,48 @@ export class NodeCoordinator {
67
79
  * @param server The HTTP/2 server.
68
80
  * @param address The listening address.
69
81
  * @param sessions Tracked HTTP/2 sessions.
82
+ * @param subscriptionQueueCapacity Resolved bound for each managed subscriber.
70
83
  */
71
- constructor(options, server, address, sessions) {
84
+ constructor(options, server, address, sessions, subscriptionQueueCapacity) {
72
85
  this.#members = options.members;
73
- this.#unaryKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions());
74
- this.#subscriptionKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(options.members));
86
+ this.#readerAdmission = options.readerAdmission;
87
+ this.#forwardingProof = options.forwardingProof;
88
+ this.#readMaxBytes = options.readMaxBytes ?? defaultMessageMaxBytes;
89
+ this.#subscriptionQueueCapacity = subscriptionQueueCapacity;
90
+ this.#unaryKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(undefined, this.#forwardingProof, this.#readMaxBytes, (bytes) => this.#readerFor(bytes)));
91
+ this.#subscriptionKernel = this.#makeSubscriptionKernel(options.members);
75
92
  this.#server = server;
76
93
  this.#sessions = sessions;
77
94
  this.host = address.address;
78
95
  this.port = address.port;
79
96
  this.baseUrl = `http://${NodeCoordinatorValues.formatHostForUrl(this.host)}:${this.port.toString()}`;
80
97
  const reconcile = () => {
81
- this.#membershipReconciliation = this.#membershipReconciliation
82
- .then(() => this.#reconcile(), () => this.#reconcile())
83
- .catch(() => undefined);
98
+ this.#scheduleReconciliation();
84
99
  };
85
100
  this.#stopMembers = [
86
101
  options.members.onReadyMembersChange(reconcile),
87
102
  options.members.onRelayMembersChange(reconcile),
88
103
  ];
89
104
  }
105
+ /**
106
+ * Builds the subscription kernel with reader provenance for each child.
107
+ *
108
+ * @param members The available backend member source.
109
+ * @returns The native subscription kernel.
110
+ */
111
+ #makeSubscriptionKernel(members) {
112
+ return new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(members, this.#forwardingProof, this.#readMaxBytes, (bytes) => this.#readerFor(bytes), (definition, child) => {
113
+ this.#rememberChild(definition, child);
114
+ }));
115
+ }
116
+ /**
117
+ * Serializes membership refresh after readiness or relay changes.
118
+ */
119
+ #scheduleReconciliation() {
120
+ this.#membershipReconciliation = this.#membershipReconciliation
121
+ .then(() => this.#reconcile(), () => this.#reconcile())
122
+ .catch(() => undefined);
123
+ }
90
124
  /**
91
125
  * Opens one generated unary-service listener.
92
126
  *
@@ -94,37 +128,44 @@ export class NodeCoordinator {
94
128
  * @returns The running private Coordinator.
95
129
  */
96
130
  static async open(options) {
131
+ const subscriptionQueueCapacity = ServerEnvironment.instance().subscriptionQueueCapacity;
97
132
  const { host, port, readMaxBytes, writeMaxBytes } = this.#listenerOptions(options);
98
133
  // eslint-disable-next-line prefer-const -- routes capture the Coordinator before the listener exists.
99
134
  let coordinator;
100
135
  const sessions = new Set();
101
136
  const server = http2.createServer(connectNodeAdapter({
102
137
  routes: (router) => {
103
- router.service(CommandService, {
104
- post: async (command, context) => {
105
- return coordinator.#post(command, context);
106
- },
107
- });
108
- router.service(QueryService, {
109
- read: async (query, context) => {
110
- return coordinator.#read(query, context);
111
- },
112
- });
113
- router.service(SubscriptionService, {
114
- subscribe: (topic, context) => coordinator.#subscribe(topic, context),
115
- activate: (subscription, context) => coordinator.#activate(subscription, context),
116
- cancel: (subscription, context) => coordinator.#cancel(subscription, context),
117
- });
138
+ this.#routes(router, () => coordinator);
118
139
  },
119
140
  readMaxBytes,
120
141
  writeMaxBytes,
121
142
  }));
122
143
  this.#trackSessions(server, sessions);
123
144
  const address = await NodeCoordinatorValues.listen(server, host, port);
124
- coordinator = new NodeCoordinator(options, server, address, sessions);
145
+ coordinator = new NodeCoordinator(options, server, address, sessions, subscriptionQueueCapacity);
125
146
  await coordinator.#reconcile();
126
147
  return coordinator;
127
148
  }
149
+ /**
150
+ * Registers native service routes after the listener receives its Coordinator instance.
151
+ *
152
+ * @param router Connect router for the private listener.
153
+ * @param current Returns the initialized Coordinator at request time.
154
+ */
155
+ static #routes(router, current) {
156
+ router.service(CommandService, {
157
+ post: (command, context) => current().#post(command, context),
158
+ });
159
+ router.service(QueryService, {
160
+ read: (query, context) => current().#read(query, context),
161
+ execute: (query, context) => current().#execute(query, context),
162
+ });
163
+ router.service(SubscriptionService, {
164
+ subscribe: (topic, context) => current().#subscribe(topic, context),
165
+ activate: (subscription, context) => current().#activate(subscription, context),
166
+ cancel: (subscription, context) => current().#cancel(subscription, context),
167
+ });
168
+ }
128
169
  /**
129
170
  * Validates private listener and message limits before opening the server.
130
171
  *
@@ -181,37 +222,110 @@ export class NodeCoordinator {
181
222
  await this.#membershipReconciliation;
182
223
  await this.#subscriptionKernel.close();
183
224
  }
225
+ /**
226
+ * Admits a command reader and forwards the command to a managed child.
227
+ *
228
+ * @param command Command envelope from the native client.
229
+ * @param context The trusted context for this operation.
230
+ * @returns Child acknowledgment after forwarding.
231
+ */
184
232
  async #post(command, context) {
233
+ const reader = await this.#admit("spine.client.CommandService", "Post", toBinary(CommandService.method.post.input, command), context);
185
234
  const request = {
186
235
  kind: "command",
187
236
  command,
188
237
  context,
238
+ reader,
189
239
  response: create(AckSchema),
190
240
  };
191
241
  await this.#forward(request);
192
242
  return request.response;
193
243
  }
244
+ /**
245
+ * Admits a read reader and forwards the query to a managed child.
246
+ *
247
+ * @param query Query envelope from the native client.
248
+ * @param context The trusted context for this operation.
249
+ * @returns Child read response after forwarding.
250
+ */
194
251
  async #read(query, context) {
252
+ const reader = await this.#admit("spine.client.QueryService", "Read", toBinary(QuerySchema, query), context);
253
+ ReadRequestStatus.check("query", () => Validate.check(QuerySchema, query));
195
254
  const request = {
196
255
  kind: "query",
197
256
  query,
198
257
  context,
258
+ reader,
199
259
  response: create(QueryResponseSchema),
200
260
  };
201
261
  await this.#forward(request);
202
262
  return request.response;
203
263
  }
264
+ /**
265
+ * Admits an App Query and forwards it to a ready application replica.
266
+ *
267
+ * @param query App Query envelope.
268
+ * @param context Native RPC context.
269
+ * @returns The application replica's response.
270
+ */
271
+ async #execute(query, context) {
272
+ const reader = await this.#admit("spine.client.QueryService", "Execute", toBinary(QueryService.method.execute.input, query), context);
273
+ ReadRequestStatus.check("query", () => Validate.check(AppQuerySchema, query));
274
+ const request = {
275
+ kind: "app-query",
276
+ query,
277
+ context,
278
+ reader,
279
+ response: create(AppQueryResponseSchema),
280
+ };
281
+ await this.#forward(request);
282
+ return request.response;
283
+ }
284
+ /**
285
+ * Verifies forwarded or direct reader facts at the managed ingress.
286
+ *
287
+ * @param service Canonical RPC service.
288
+ * @param method Canonical RPC method.
289
+ * @param request Canonical request bytes.
290
+ * @param context Native transport context.
291
+ * @returns Detached trusted reader facts.
292
+ */
293
+ #admit(service, method, request, context) {
294
+ return TrustedReaderIngress.admit({
295
+ service,
296
+ method,
297
+ request,
298
+ headers: context.requestHeader,
299
+ peer: context,
300
+ maxBytes: this.#readMaxBytes,
301
+ ...this.#readerAdmission,
302
+ });
303
+ }
304
+ /**
305
+ * Validates and retains a reader-bound subscription definition.
306
+ *
307
+ * @param topic Framework subscription Topic.
308
+ * @param context The trusted context for this operation.
309
+ * @returns The new subscription definition.
310
+ */
204
311
  async #subscribe(topic, context) {
312
+ const reader = await this.#admit("spine.client.SubscriptionService", "Subscribe", toBinary(TopicSchema, topic), context);
313
+ ReadRequestStatus.check("topic", () => Validate.check(TopicSchema, topic));
205
314
  if (this.#draining)
206
315
  throw new ConnectError("Managed application is closing.", Code.Unavailable);
207
316
  const subscription = create(SubscriptionSchema, {
208
317
  id: create(SubscriptionIdSchema, { value: `s-${randomUUID()}` }),
209
318
  topic,
210
319
  });
320
+ const id = subscription.id?.value;
321
+ if (id === undefined)
322
+ throw new Error("Created subscription has no ID.");
323
+ this.#readers.set(id, reader);
211
324
  try {
212
325
  await this.#subscriptionKernel.subscribe(toBinary(SubscriptionSchema, subscription), context.signal);
213
326
  }
214
327
  catch (error) {
328
+ this.#forgetReader(id);
215
329
  throw this.#availabilityError(error);
216
330
  }
217
331
  if (this.#isDraining()) {
@@ -229,7 +343,15 @@ export class NodeCoordinator {
229
343
  #isDraining() {
230
344
  return this.#draining;
231
345
  }
346
+ /**
347
+ * Verifies membership and activates a retained subscription.
348
+ *
349
+ * @param subscription The subscription definition being processed.
350
+ * @param context The trusted context for this operation.
351
+ * @returns The update stream after admission.
352
+ */
232
353
  async *#activate(subscription, context) {
354
+ await this.#admitActivation(subscription, context);
233
355
  if (this.#draining)
234
356
  throw new ConnectError("Managed application is closing.", Code.Unavailable);
235
357
  const definition = toBinary(SubscriptionSchema, subscription);
@@ -241,10 +363,14 @@ export class NodeCoordinator {
241
363
  context.signal.addEventListener("abort", abort, { once: true });
242
364
  if (context.signal.aborted)
243
365
  abort();
244
- const updates = new SubscriptionUpdateQueue(subscriptionQueueLimit, abort);
245
- const activation = this.#activateChild(definition, subscription, updates, overflow.signal);
366
+ const updates = new SubscriptionUpdateQueue(this.#subscriptionQueueCapacity, abort);
367
+ const ack = Promise.withResolvers();
368
+ const activation = this.#activateChild(definition, subscription, updates, ack, overflow.signal);
246
369
  let failure;
247
370
  try {
371
+ const ready = await ack.promise;
372
+ if (ready !== undefined)
373
+ yield ready;
248
374
  for await (const update of updates)
249
375
  yield update;
250
376
  }
@@ -258,26 +384,53 @@ export class NodeCoordinator {
258
384
  await this.#finishActivation(definition, activation, failure);
259
385
  }
260
386
  }
387
+ /**
388
+ * Admits and validates a reader-bound activation before changing pending state.
389
+ *
390
+ * @param subscription The subscription definition being processed.
391
+ * @param context The trusted context for this operation.
392
+ * @returns Completion after the operation settles.
393
+ */
394
+ async #admitActivation(subscription, context) {
395
+ const reader = await this.#admit("spine.client.SubscriptionService", "Activate", toBinary(SubscriptionSchema, subscription), context);
396
+ ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
397
+ ReadRequestStatus.checkLifecycleCriterion(subscription);
398
+ this.#requireReader(subscription, reader);
399
+ }
261
400
  /**
262
401
  * Relays accepted child updates through the bounded logical stream.
263
402
  *
264
403
  * @param definition The validated child subscription bytes.
265
404
  * @param subscription The logical subscription exposed to callers.
266
405
  * @param updates The bounded caller stream queue.
406
+ * @param ack The separate activation acknowledgment gate.
267
407
  * @param signal Cancels the active child relay.
268
408
  * @returns Completion after the backend activation ends.
269
409
  */
270
- #activateChild(definition, subscription, updates, signal) {
410
+ #activateChild(definition, subscription, updates, ack, signal) {
271
411
  return this.#subscriptionKernel
272
412
  .activate(definition, async (childUpdate) => {
273
413
  const update = fromBinary(SubscriptionUpdateSchema, childUpdate);
274
414
  update.subscription = clone(SubscriptionSchema, subscription);
415
+ if (update.response?.status?.status.case === "ok" && update.update.case === undefined) {
416
+ ack.resolve(update);
417
+ return;
418
+ }
275
419
  await updates.push(update);
276
420
  }, signal)
277
421
  .finally(() => {
422
+ ack.resolve(undefined);
278
423
  updates.close();
279
424
  });
280
425
  }
426
+ /**
427
+ * Finishes one activated stream and clears pending state.
428
+ *
429
+ * @param definition The serialized subscription definition.
430
+ * @param activation Activation task and its stream state.
431
+ * @param previousFailure Earlier lifecycle failure.
432
+ * @returns Completion after the operation settles.
433
+ */
281
434
  async #finishActivation(definition, activation, previousFailure) {
282
435
  const failures = previousFailure === undefined ? [] : [previousFailure];
283
436
  try {
@@ -298,13 +451,36 @@ export class NodeCoordinator {
298
451
  if (failures.length > 1)
299
452
  throw new AggregateError(failures, "Coordinator activation cleanup failed.");
300
453
  }
454
+ /**
455
+ * Verifies reader membership and removes a retained subscription.
456
+ *
457
+ * @param subscription The subscription definition being processed.
458
+ * @param context The trusted context for this operation.
459
+ * @returns The cancellation acknowledgment.
460
+ */
301
461
  async #cancel(subscription, context) {
462
+ const reader = await this.#admit("spine.client.SubscriptionService", "Cancel", toBinary(SubscriptionSchema, subscription), context);
463
+ ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
464
+ ReadRequestStatus.checkLifecycleCriterion(subscription);
465
+ const id = subscription.id?.value;
466
+ if (id !== undefined && this.#readers.has(id))
467
+ this.#requireReader(subscription, reader);
468
+ else
469
+ return create(ResponseSchema, {
470
+ status: create(StatusSchema, { status: { case: "ok", value: create(EmptySchema) } }),
471
+ });
302
472
  this.#clearPendingActivation(subscription);
303
473
  await this.#cancelDefinition(toBinary(SubscriptionSchema, subscription), context.signal);
304
474
  return create(ResponseSchema, {
305
475
  status: create(StatusSchema, { status: { case: "ok", value: create(EmptySchema) } }),
306
476
  });
307
477
  }
478
+ /**
479
+ * Forwards a verified request to the selected managed child.
480
+ *
481
+ * @param request The request being processed.
482
+ * @returns Completion after the operation settles.
483
+ */
308
484
  async #forward(request) {
309
485
  if (this.#draining)
310
486
  throw new ConnectError("No ready application replica is available.", Code.Unavailable);
@@ -315,11 +491,22 @@ export class NodeCoordinator {
315
491
  throw this.#availabilityError(error);
316
492
  }
317
493
  }
494
+ /**
495
+ * Maps child transport failure to the public availability status.
496
+ *
497
+ * @param error The failure being classified or propagated.
498
+ * @returns The public transport error.
499
+ */
318
500
  #availabilityError(error) {
319
501
  return error instanceof Error && error.message === "backend membership is unavailable."
320
502
  ? new ConnectError("No ready application replica is available.", Code.Unavailable)
321
503
  : error;
322
504
  }
505
+ /**
506
+ * Starts the activation handshake expiry for a new subscription.
507
+ *
508
+ * @param subscription The subscription definition being processed.
509
+ */
323
510
  #schedulePendingActivation(subscription) {
324
511
  const id = NodeCoordinatorValues.requiredValue(subscription.id?.value, "Coordinator subscription definition is missing an ID.");
325
512
  this.#clearPendingActivation(subscription);
@@ -332,18 +519,98 @@ export class NodeCoordinator {
332
519
  pending.timer.unref();
333
520
  this.#pendingActivations.set(id, pending);
334
521
  }
522
+ /**
523
+ * Forwards cancellation of a native subscription definition.
524
+ *
525
+ * @param definition The serialized subscription definition.
526
+ * @param signal The cancellation signal for this operation.
527
+ * @returns Completion after the operation settles.
528
+ */
335
529
  #cancelDefinition(definition, signal) {
336
530
  const id = NodeCoordinatorValues.requiredValue(fromBinary(SubscriptionSchema, definition).id?.value, "Coordinator subscription definition is missing an ID.");
337
531
  const active = this.#cancellations.get(id);
338
532
  if (active !== undefined)
339
533
  return active;
340
- const cancellation = this.#subscriptionKernel.cancel(definition, signal).finally(() => {
534
+ const cancellation = this.#subscriptionKernel
535
+ .cancel(definition, signal)
536
+ .then(() => {
537
+ this.#forgetReader(id);
538
+ })
539
+ .finally(() => {
341
540
  if (this.#cancellations.get(id) === cancellation)
342
541
  this.#cancellations.delete(id);
343
542
  });
344
543
  this.#cancellations.set(id, cancellation);
345
544
  return cancellation;
346
545
  }
546
+ /**
547
+ * Verifies and refreshes the reader before subscription effects.
548
+ *
549
+ * @param subscription The subscription definition being processed.
550
+ * @param reader The verified reader facts for this operation.
551
+ */
552
+ #requireReader(subscription, reader) {
553
+ const id = subscription.id?.value;
554
+ const previous = id === undefined ? undefined : this.#readers.get(id);
555
+ if (id === undefined || previous === undefined)
556
+ throw ReadRequestStatus.unknownSubscription();
557
+ if (!ReaderIdentities.matches(ReaderIdentities.from(previous), ReaderIdentities.from(reader)))
558
+ throw new ConnectError("Subscription reader is not authorized.", Code.PermissionDenied);
559
+ this.#readers.set(id, reader);
560
+ for (const child of this.#children.get(id) ?? [])
561
+ this.#readers.set(child, reader);
562
+ }
563
+ /**
564
+ * Associates one native child with the admitted logical reader.
565
+ *
566
+ * @param definition The serialized subscription definition.
567
+ * @param child The native child subscription definition.
568
+ */
569
+ #rememberChild(definition, child) {
570
+ const id = fromBinary(SubscriptionSchema, definition).id?.value;
571
+ const childId = fromBinary(SubscriptionSchema, child).id?.value;
572
+ if (id === undefined || childId === undefined)
573
+ return;
574
+ const logicalId = this.#logicalForChild.get(id) ?? (this.#readers.has(id) ? id : undefined);
575
+ if (logicalId === undefined)
576
+ return;
577
+ const children = this.#children.get(logicalId) ?? new Set();
578
+ children.add(childId);
579
+ this.#children.set(logicalId, children);
580
+ this.#logicalForChild.set(childId, logicalId);
581
+ const reader = this.#readers.get(id);
582
+ if (reader !== undefined)
583
+ this.#readers.set(childId, reader);
584
+ }
585
+ /**
586
+ * Returns current detached facts for a logical or child subscription.
587
+ *
588
+ * @param bytes The serialized message bytes.
589
+ * @returns The verified reader for this subscription.
590
+ */
591
+ #readerFor(bytes) {
592
+ const id = fromBinary(SubscriptionSchema, bytes).id?.value;
593
+ const reader = id === undefined ? undefined : this.#readers.get(id);
594
+ return reader === undefined ? undefined : snapshotTrustedReader(reader);
595
+ }
596
+ /**
597
+ * Removes a finished logical definition and its current child mappings.
598
+ *
599
+ * @param id The stable identifier for this record.
600
+ */
601
+ #forgetReader(id) {
602
+ this.#readers.delete(id);
603
+ for (const child of this.#children.get(id) ?? []) {
604
+ this.#readers.delete(child);
605
+ this.#logicalForChild.delete(child);
606
+ }
607
+ this.#children.delete(id);
608
+ }
609
+ /**
610
+ * Removes a pending activation timer and its tracking entry.
611
+ *
612
+ * @param subscription The subscription definition being processed.
613
+ */
347
614
  #clearPendingActivation(subscription) {
348
615
  const id = subscription.id?.value;
349
616
  if (id === undefined)
@@ -354,6 +621,13 @@ export class NodeCoordinator {
354
621
  clearTimeout(pending.timer);
355
622
  this.#pendingActivations.delete(id);
356
623
  }
624
+ /**
625
+ * Expires an unactivated subscription after its handshake window.
626
+ *
627
+ * @param id The stable identifier for this record.
628
+ * @param pending Pending activation or retry state.
629
+ * @returns Completion after the operation settles.
630
+ */
357
631
  async #expirePendingActivation(id, pending) {
358
632
  if (this.#pendingActivations.get(id) !== pending)
359
633
  return;
@@ -367,6 +641,11 @@ export class NodeCoordinator {
367
641
  this.#schedulePendingActivation(fromBinary(SubscriptionSchema, pending.definition));
368
642
  }
369
643
  }
644
+ /**
645
+ * Reconciles managed child subscriptions after topology changes.
646
+ *
647
+ * @returns Completion after the operation settles.
648
+ */
370
649
  #reconcile() {
371
650
  if (this.#members.onRelaySynchronized !== undefined)
372
651
  return this.#subscriptionKernel
@@ -377,6 +656,11 @@ export class NodeCoordinator {
377
656
  this.#subscriptionKernel.reconcile(this.#members.relayMembers()),
378
657
  ]).then(() => undefined);
379
658
  }
659
+ /**
660
+ * Runs shutdown once while concurrent callers await the same completion.
661
+ *
662
+ * @returns Completion after the operation settles.
663
+ */
380
664
  async #closeOnce() {
381
665
  this.#stopMembers.forEach((stop) => {
382
666
  stop();
@@ -395,16 +679,16 @@ export class NodeCoordinator {
395
679
  * @internal
396
680
  */
397
681
  export class SubscriptionUpdateQueue {
682
+ #active;
398
683
  #updates = [];
399
684
  #waiters = [];
400
- #delivered = [];
401
685
  #limit;
402
686
  #onOverflow;
403
687
  #closed = false;
404
688
  /**
405
689
  * Creates one terminal bounded update queue.
406
690
  *
407
- * @param limit Limits retained updates before terminal closure.
691
+ * @param limit Maximum waiting updates beyond one active update.
408
692
  * @param onOverflow Aborts the public relay when the queue reaches its bound.
409
693
  */
410
694
  constructor(limit, onOverflow = () => undefined) {
@@ -420,18 +704,22 @@ export class SubscriptionUpdateQueue {
420
704
  push(update) {
421
705
  if (this.#closed)
422
706
  return Promise.resolve();
423
- const waiter = this.#waiters.shift();
424
- if (waiter !== undefined) {
425
- waiter({ value: update, done: false });
426
- return Promise.resolve();
427
- }
428
- if (this.#updates.length >= this.#limit) {
707
+ if (this.#active !== undefined && this.#updates.length >= this.#limit) {
429
708
  this.#onOverflow();
430
709
  this.close();
431
710
  return Promise.resolve();
432
711
  }
433
- this.#updates.push(update);
434
- return new Promise((resolve) => this.#delivered.push(resolve));
712
+ return new Promise((resolve) => {
713
+ const entry = { update, delivered: false, resolve };
714
+ if (this.#active === undefined) {
715
+ this.#active = entry;
716
+ const waiter = this.#waiters.shift();
717
+ if (waiter !== undefined)
718
+ waiter(this.#deliver(entry));
719
+ }
720
+ else
721
+ this.#updates.push(entry);
722
+ });
435
723
  }
436
724
  /**
437
725
  * Closes the queue and releases all waiting producers and consumers.
@@ -440,11 +728,20 @@ export class SubscriptionUpdateQueue {
440
728
  if (this.#closed)
441
729
  return;
442
730
  this.#closed = true;
443
- this.#updates.length = 0;
731
+ this.#active?.resolve();
732
+ this.#active = undefined;
733
+ for (const update of this.#updates.splice(0))
734
+ update.resolve();
444
735
  for (const waiter of this.#waiters.splice(0))
445
736
  waiter({ value: undefined, done: true });
446
- for (const delivered of this.#delivered.splice(0))
447
- delivered();
737
+ }
738
+ /**
739
+ * Resolves one waiting consumer with the next queued subscription update.
740
+ */
741
+ #deliver(entry) {
742
+ entry.delivered = true;
743
+ entry.resolve();
744
+ return { value: entry.update, done: false };
448
745
  }
449
746
  /**
450
747
  * Returns queued updates until the terminal close.
@@ -454,13 +751,12 @@ export class SubscriptionUpdateQueue {
454
751
  [Symbol.asyncIterator]() {
455
752
  return {
456
753
  next: () => {
457
- const update = this.#updates.shift();
458
- if (update !== undefined) {
459
- this.#delivered.shift()?.();
460
- return Promise.resolve({ value: update, done: false });
461
- }
462
754
  if (this.#closed)
463
755
  return Promise.resolve({ value: undefined, done: true });
756
+ if (this.#active?.delivered)
757
+ this.#active = this.#updates.shift();
758
+ if (this.#active !== undefined)
759
+ return Promise.resolve(this.#deliver(this.#active));
464
760
  return new Promise((resolve) => this.#waiters.push(resolve));
465
761
  },
466
762
  };
@@ -472,20 +768,35 @@ const NodeCoordinatorValues = Object.freeze({
472
768
  *
473
769
  * @param members The ready member source when available.
474
770
  * @returns The configured membership kernel options.
771
+ * @param issuer The configured forwarding proof producer.
772
+ * @param maxBytes The existing native message-size bound.
773
+ * @param readerFor Reader lookup for a retained subscription.
774
+ * @param onRememberChild Records a native child ID for its logical subscription.
475
775
  */
476
- unaryKernelOptions(members) {
477
- // The Coordinator deliberately uses only the kernel's member lifecycle and
478
- // one-shot forwarding operations. Subscription fan-out is owned by T-0208.
776
+ unaryKernelOptions(members, issuer, maxBytes = defaultMessageMaxBytes, readerFor, onRememberChild) {
479
777
  return {
480
- create: (member) => Promise.resolve(NodeCoordinatorValues.client(member, members)),
778
+ create: (member) => Promise.resolve(NodeCoordinatorValues.client(member, members, issuer, maxBytes, readerFor, onRememberChild)),
481
779
  memberKey: (member) => `${member.slot.toString()}/${member.incarnation}`,
482
780
  sameMember: (left, right) => left.endpoint === right.endpoint,
483
781
  definitionKey: (definition) => fromBinary(SubscriptionSchema, definition).id?.value,
484
- childDefinition: NodeCoordinatorValues.childDefinition,
782
+ childDefinition: (definition, member) => NodeCoordinatorValues.rememberChildDefinition(definition, member, onRememberChild),
485
783
  childSize: (child) => child.byteLength,
486
784
  ...NodeCoordinatorValues.activationAckOptions(members),
487
785
  };
488
786
  },
787
+ /**
788
+ * Builds and records a child definition before member subscription.
789
+ *
790
+ * @param definition The serialized subscription definition.
791
+ * @param member The selected backend member.
792
+ * @param onRememberChild Records a native child ID for its logical subscription.
793
+ * @returns The retained managed-child definition.
794
+ */
795
+ rememberChildDefinition(definition, member, onRememberChild) {
796
+ const child = NodeCoordinatorValues.childDefinition(definition, member);
797
+ onRememberChild?.(definition, child);
798
+ return child;
799
+ },
489
800
  /**
490
801
  * Builds strict child ACK validation for the subscription membership kernel.
491
802
  *
@@ -526,49 +837,172 @@ const NodeCoordinatorValues = Object.freeze({
526
837
  * @param member The discovered backend member.
527
838
  * @param members The ready member source when available.
528
839
  * @returns The member transport client.
840
+ * @param issuer The configured forwarding proof producer.
841
+ * @param maxBytes The existing native message-size bound.
842
+ * @param readerFor Reader lookup for a retained subscription.
843
+ * @param onRememberChild Records a native child ID for its logical subscription.
529
844
  */
530
- client(member, members) {
845
+ client(member, members, issuer, maxBytes = defaultMessageMaxBytes, readerFor, onRememberChild) {
531
846
  const manager = new Http2SessionManager(member.endpoint);
532
847
  const transport = createGrpcTransport({ baseUrl: member.endpoint, sessionManager: manager });
533
848
  return {
534
- forward: (request) => NodeCoordinatorValues.forward(transport, request),
535
- close: () => {
536
- manager.abort();
537
- return Promise.resolve();
538
- },
539
- subscribe: async (definition, signal) => {
540
- const subscription = fromBinary(SubscriptionSchema, definition);
541
- const created = await createClient(SubscriptionService, transport).subscribe(NodeCoordinatorValues.requiredValue(subscription.topic, "Coordinator subscription definition is missing a Topic."), { signal });
542
- return toBinary(SubscriptionSchema, created);
543
- },
544
- activate: (child, updates, signal) => NodeCoordinatorValues.activateChild(transport, member, members, child, updates, signal),
545
- dispose: async (child, signal) => {
546
- const subscription = fromBinary(SubscriptionSchema, child);
547
- members?.onChildSubscriptionCancelled?.(member, subscription);
548
- await createClient(SubscriptionService, transport).cancel(subscription, { signal });
549
- },
849
+ forward: (request) => NodeCoordinatorValues.forward(transport, request, issuer, maxBytes),
850
+ close: () => NodeCoordinatorValues.closeManager(manager),
851
+ subscribe: (definition, signal) => NodeCoordinatorValues.subscribeChild(transport, definition, signal, readerFor?.(definition), issuer, maxBytes, onRememberChild),
852
+ ...NodeCoordinatorValues.childOperations(transport, member, members, issuer, maxBytes, readerFor),
550
853
  };
551
854
  },
552
855
  /**
553
- * Sends one native Command or Query to a ready member.
856
+ * Closes the transport manager for a departed native member.
857
+ *
858
+ * @param manager Native session manager.
859
+ * @returns Completion after the operation settles.
860
+ */
861
+ closeManager(manager) {
862
+ manager.abort();
863
+ return Promise.resolve();
864
+ },
865
+ /**
866
+ * Returns streaming and cancellation operations for a native member.
867
+ *
868
+ * @param transport The connected native transport.
869
+ * @param member The selected backend member.
870
+ * @param members The available backend member source.
871
+ * @param issuer The configured forwarding proof producer.
872
+ * @param maxBytes The existing native message-size bound.
873
+ * @param readerFor Reader lookup for a retained subscription.
874
+ * @returns The managed-child operations.
875
+ */
876
+ childOperations(transport, member, members, issuer, maxBytes, readerFor) {
877
+ return {
878
+ activate: NodeCoordinatorValues.activation(transport, member, members, issuer, maxBytes, readerFor),
879
+ dispose: (child, signal) => NodeCoordinatorValues.cancelChild(transport, member, members, child, signal, readerFor?.(child), issuer, maxBytes),
880
+ };
881
+ },
882
+ /**
883
+ * Captures native member context for one child stream.
884
+ *
885
+ * @param transport The connected native transport.
886
+ * @param member The selected backend member.
887
+ * @param members The available backend member source.
888
+ * @param issuer The configured forwarding proof producer.
889
+ * @param maxBytes The existing native message-size bound.
890
+ * @param readerFor Reader lookup for a retained subscription.
891
+ * @returns The activation stream completion.
892
+ */
893
+ activation(transport, member, members, issuer, maxBytes, readerFor) {
894
+ return (child, updates, signal) => NodeCoordinatorValues.activateChild(transport, member, members, child, updates, signal, readerFor?.(child), issuer, maxBytes);
895
+ },
896
+ /**
897
+ * Creates one native child and records any backend-assigned ID.
898
+ *
899
+ * @param transport The connected native transport.
900
+ * @param definition The serialized subscription definition.
901
+ * @param signal The cancellation signal for this operation.
902
+ * @param reader The verified reader facts for this operation.
903
+ * @param issuer The configured forwarding proof producer.
904
+ * @param maxBytes The existing native message-size bound.
905
+ * @param onRememberChild Records a native child ID for its logical subscription.
906
+ * @returns The managed-child subscription.
907
+ */
908
+ async subscribeChild(transport, definition, signal, reader, issuer, maxBytes, onRememberChild) {
909
+ const subscription = fromBinary(SubscriptionSchema, definition);
910
+ const topic = NodeCoordinatorValues.requiredValue(subscription.topic, "Coordinator subscription definition is missing a Topic.");
911
+ const headers = await NodeCoordinatorValues.readerHeaders("Subscribe", toBinary(TopicSchema, topic), reader, issuer, maxBytes);
912
+ const created = await createClient(SubscriptionService, transport).subscribe(topic, {
913
+ signal,
914
+ ...(headers === undefined ? {} : { headers }),
915
+ });
916
+ const bytes = toBinary(SubscriptionSchema, created);
917
+ onRememberChild?.(definition, bytes);
918
+ return bytes;
919
+ },
920
+ /**
921
+ * Cancels one native child with the same verified reader as its creation.
922
+ *
923
+ * @param transport The connected native transport.
924
+ * @param member The selected backend member.
925
+ * @param members The available backend member source.
926
+ * @param child The native child subscription definition.
927
+ * @param signal The cancellation signal for this operation.
928
+ * @param reader The verified reader facts for this operation.
929
+ * @param issuer The configured forwarding proof producer.
930
+ * @param maxBytes The existing native message-size bound.
931
+ * @returns Completion after the operation settles.
932
+ */
933
+ async cancelChild(transport, member, members, child, signal, reader, issuer, maxBytes) {
934
+ const subscription = fromBinary(SubscriptionSchema, child);
935
+ members?.onChildSubscriptionCancelled?.(member, subscription);
936
+ const headers = await NodeCoordinatorValues.readerHeaders("Cancel", child, reader, issuer, maxBytes);
937
+ await createClient(SubscriptionService, transport).cancel(subscription, {
938
+ signal,
939
+ ...(headers === undefined ? {} : { headers }),
940
+ });
941
+ },
942
+ /**
943
+ * Sends one native Command, entity read, or App Query to a ready member.
554
944
  *
555
945
  * @param transport The connected native transport.
556
946
  * @param request The request to forward.
557
947
  * @returns Empty routing bytes after the response is assigned.
948
+ * @param issuer The configured forwarding proof producer.
949
+ * @param maxBytes The existing native message-size bound.
558
950
  */
559
- async forward(transport, request) {
951
+ async forward(transport, request, issuer, maxBytes = defaultMessageMaxBytes) {
952
+ const headers = await NodeCoordinatorValues.forwardHeaders(request, issuer, maxBytes);
560
953
  if (request.kind === "command") {
561
- const response = await transport.unary(CommandService.method.post, request.context.signal, request.context.timeoutMs(), NodeCoordinatorValues.applicationHeaders(request.context.requestHeader), request.command);
954
+ await NodeCoordinatorValues.forwardCommand(transport, request, headers);
955
+ }
956
+ else if (request.kind === "query") {
957
+ const response = await transport.unary(QueryService.method.read, request.context.signal, request.context.timeoutMs(), headers, request.query);
562
958
  NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
563
959
  request.response = response.message;
564
960
  }
565
961
  else {
566
- const response = await transport.unary(QueryService.method.read, request.context.signal, request.context.timeoutMs(), NodeCoordinatorValues.applicationHeaders(request.context.requestHeader), request.query);
962
+ const response = await transport.unary(QueryService.method.execute, request.context.signal, request.context.timeoutMs(), headers, request.query);
567
963
  NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
568
964
  request.response = response.message;
569
965
  }
570
966
  return new Uint8Array();
571
967
  },
968
+ /**
969
+ * Invokes the Command branch and retains its native response metadata.
970
+ *
971
+ * @param transport The selected native child transport.
972
+ * @param request The admitted Command and response slot.
973
+ * @param headers Operation-bound verified-reader metadata.
974
+ * @returns Completion after assigning the native response.
975
+ */
976
+ async forwardCommand(transport, request, headers) {
977
+ const response = await transport.unary(CommandService.method.post, request.context.signal, request.context.timeoutMs(), headers, request.command);
978
+ NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
979
+ request.response = response.message;
980
+ },
981
+ /**
982
+ * Encodes verified reader facts into one exact native unary operation.
983
+ *
984
+ * @param request The request being processed.
985
+ * @param issuer The configured forwarding proof producer.
986
+ * @param maxBytes The existing native message-size bound.
987
+ * @returns The operation-bound forwarding headers.
988
+ */
989
+ async forwardHeaders(request, issuer, maxBytes) {
990
+ const service = request.kind === "command" ? "spine.client.CommandService" : "spine.client.QueryService";
991
+ const method = request.kind === "command" ? "Post" : request.kind === "query" ? "Read" : "Execute";
992
+ const value = request.kind === "command"
993
+ ? toBinary(CommandService.method.post.input, request.command)
994
+ : request.kind === "query"
995
+ ? toBinary(QuerySchema, request.query)
996
+ : toBinary(AppQuerySchema, request.query);
997
+ const headers = NodeCoordinatorValues.applicationHeaders(request.context.requestHeader);
998
+ if (issuer === undefined &&
999
+ (request.reader.principal !== undefined || request.reader.authorityFacts !== undefined))
1000
+ throw new ConnectError("Managed reader forwarding is unavailable.", Code.Internal);
1001
+ if (issuer !== undefined)
1002
+ for (const [name, entry] of Object.entries(await ReaderForwarding.seal(service, method, value, request.reader, issuer, maxBytes)))
1003
+ headers.set(name, entry);
1004
+ return headers;
1005
+ },
572
1006
  /**
573
1007
  * Streams validated updates from one native child subscription.
574
1008
  *
@@ -579,25 +1013,59 @@ const NodeCoordinatorValues = Object.freeze({
579
1013
  * @param updates The logical update sink.
580
1014
  * @param signal Cancels child activation.
581
1015
  * @returns Completion after the child stream closes.
1016
+ * @param reader The verified reader facts for this operation.
1017
+ * @param issuer The configured forwarding proof producer.
1018
+ * @param maxBytes The existing native message-size bound.
582
1019
  */
583
- async activateChild(transport, member, members, child, updates, signal) {
1020
+ async activateChild(transport, member, members, child, updates, signal, reader, issuer, maxBytes = defaultMessageMaxBytes) {
584
1021
  const subscription = fromBinary(SubscriptionSchema, child);
585
1022
  members?.onChildSubscriptionActivated?.(member, subscription);
1023
+ const headers = await NodeCoordinatorValues.readerHeaders("Activate", child, reader, issuer, maxBytes);
586
1024
  for await (const update of createClient(SubscriptionService, transport).activate(subscription, {
587
1025
  signal,
1026
+ ...(headers === undefined ? {} : { headers }),
588
1027
  })) {
589
- const topic = subscription.topic;
590
- const updateTopic = update.subscription?.topic;
591
- if (update.subscription?.id?.value !== subscription.id?.value ||
592
- topic === undefined ||
593
- updateTopic === undefined ||
594
- !equals(TopicSchema, updateTopic, topic))
595
- throw new Error("Backend child update does not match its subscription.");
1028
+ NodeCoordinatorValues.checkChildUpdate(subscription, update);
596
1029
  await updates(toBinary(SubscriptionUpdateSchema, update));
597
1030
  }
598
1031
  },
599
1032
  /**
600
- * Copies backend response metadata into the public context.
1033
+ * Rejects a native update for a different child ID or Topic.
1034
+ *
1035
+ * @param subscription The subscription definition being processed.
1036
+ * @param update Native subscription update.
1037
+ */
1038
+ checkChildUpdate(subscription, update) {
1039
+ const topic = subscription.topic;
1040
+ const updateTopic = update.subscription?.topic;
1041
+ if (update.subscription?.id?.value !== subscription.id?.value ||
1042
+ topic === undefined ||
1043
+ updateTopic === undefined ||
1044
+ !equals(TopicSchema, updateTopic, topic))
1045
+ throw new Error("Backend child update does not match its subscription.");
1046
+ },
1047
+ /**
1048
+ * Encodes one verified reader against the exact child subscription RPC.
1049
+ *
1050
+ * @param method The canonical RPC method name.
1051
+ * @param value Canonical child subscription RPC bytes.
1052
+ * @param reader The verified reader facts for this operation.
1053
+ * @param issuer The configured forwarding proof producer.
1054
+ * @param maxBytes The existing native message-size bound.
1055
+ * @returns The canonical reader assertion headers.
1056
+ */
1057
+ async readerHeaders(method, value, reader, issuer, maxBytes) {
1058
+ if (reader === undefined)
1059
+ return undefined;
1060
+ if (issuer === undefined) {
1061
+ if (reader.principal !== undefined || reader.authorityFacts !== undefined)
1062
+ throw new ConnectError("Managed reader forwarding is unavailable.", Code.Internal);
1063
+ return undefined;
1064
+ }
1065
+ return new Headers(await ReaderForwarding.seal("spine.client.SubscriptionService", method, value, reader, issuer, maxBytes));
1066
+ },
1067
+ /**
1068
+ * Snapshots backend response metadata into the public context.
601
1069
  *
602
1070
  * @param headers Headers received from the backend.
603
1071
  * @param trailers Trailers received from the backend.
@@ -610,7 +1078,7 @@ const NodeCoordinatorValues = Object.freeze({
610
1078
  context.responseTrailer.append(name, value);
611
1079
  },
612
1080
  /**
613
- * Copies application headers for forwarding.
1081
+ * Snapshots application headers for forwarding.
614
1082
  *
615
1083
  * @param headers Headers received from the backend.
616
1084
  * @returns Forwardable application headers.
@@ -622,7 +1090,9 @@ const NodeCoordinatorValues = Object.freeze({
622
1090
  name === "te" ||
623
1091
  name === "user-agent" ||
624
1092
  name.startsWith("grpc-") ||
625
- name.startsWith("connect-"))
1093
+ name.startsWith("connect-") ||
1094
+ name === "spine-internal-reader" ||
1095
+ name === "spine-internal-reader-proof")
626
1096
  forwarded.delete(name);
627
1097
  }
628
1098
  return forwarded;