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

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 (207) hide show
  1. package/README.md +104 -16
  2. package/REFERENCE.md +486 -57
  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-ai-runtime.d.ts +1 -1
  8. package/dist/agent/agent-live-ai-runtime.js +1 -1
  9. package/dist/agent/agent-live-budget.d.ts +1 -1
  10. package/dist/agent/agent-live-budget.js +1 -1
  11. package/dist/agent/agent-live-read-runtime.d.ts +13 -2
  12. package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
  13. package/dist/agent/agent-live-read-runtime.js +6 -5
  14. package/dist/agent/agent-live-read-runtime.js.map +1 -1
  15. package/dist/browser/browser-admission.d.ts +47 -0
  16. package/dist/browser/browser-admission.d.ts.map +1 -0
  17. package/dist/browser/browser-admission.js +200 -0
  18. package/dist/browser/browser-admission.js.map +1 -0
  19. package/dist/browser/browser-server.d.ts +171 -1
  20. package/dist/browser/browser-server.d.ts.map +1 -1
  21. package/dist/browser/browser-server.js +1027 -303
  22. package/dist/browser/browser-server.js.map +1 -1
  23. package/dist/browser/durable-subscription-bindings.d.ts +5 -0
  24. package/dist/browser/durable-subscription-bindings.d.ts.map +1 -1
  25. package/dist/browser/durable-subscription-bindings.js +226 -38
  26. package/dist/browser/durable-subscription-bindings.js.map +1 -1
  27. package/dist/browser/index.d.ts +1 -1
  28. package/dist/browser/index.d.ts.map +1 -1
  29. package/dist/browser/options.d.ts +53 -2
  30. package/dist/browser/options.d.ts.map +1 -1
  31. package/dist/bus/command-bus.d.ts +9 -1
  32. package/dist/bus/command-bus.d.ts.map +1 -1
  33. package/dist/bus/command-bus.js +75 -10
  34. package/dist/bus/command-bus.js.map +1 -1
  35. package/dist/bus/command-errors.d.ts +20 -4
  36. package/dist/bus/command-errors.d.ts.map +1 -1
  37. package/dist/bus/command-errors.js +47 -7
  38. package/dist/bus/command-errors.js.map +1 -1
  39. package/dist/bus/event-bus.d.ts +9 -1
  40. package/dist/bus/event-bus.d.ts.map +1 -1
  41. package/dist/bus/event-bus.js +136 -9
  42. package/dist/bus/event-bus.js.map +1 -1
  43. package/dist/context/bounded-context.d.ts +59 -39
  44. package/dist/context/bounded-context.d.ts.map +1 -1
  45. package/dist/context/bounded-context.js +277 -262
  46. package/dist/context/bounded-context.js.map +1 -1
  47. package/dist/context/entity-inbox.d.ts +12 -17
  48. package/dist/context/entity-inbox.d.ts.map +1 -1
  49. package/dist/context/entity-inbox.js +106 -119
  50. package/dist/context/entity-inbox.js.map +1 -1
  51. package/dist/context/local-inbox-handoff.d.ts +13 -13
  52. package/dist/context/local-inbox-handoff.d.ts.map +1 -1
  53. package/dist/context/local-inbox-handoff.js +60 -32
  54. package/dist/context/local-inbox-handoff.js.map +1 -1
  55. package/dist/context/persistent-tenant-index.d.ts +81 -0
  56. package/dist/context/persistent-tenant-index.d.ts.map +1 -0
  57. package/dist/context/persistent-tenant-index.js +270 -0
  58. package/dist/context/persistent-tenant-index.js.map +1 -0
  59. package/dist/context/projection-handoff.d.ts +9 -9
  60. package/dist/context/projection-handoff.d.ts.map +1 -1
  61. package/dist/context/projection-handoff.js +31 -23
  62. package/dist/context/projection-handoff.js.map +1 -1
  63. package/dist/context/tenant-index.d.ts +7 -6
  64. package/dist/context/tenant-index.d.ts.map +1 -1
  65. package/dist/context/tenant-index.js +2 -188
  66. package/dist/context/tenant-index.js.map +1 -1
  67. package/dist/context/tenant-unknown-error.d.ts +2 -2
  68. package/dist/context/tenant-unknown-error.d.ts.map +1 -1
  69. package/dist/context/tenant-unknown-error.js +4 -1
  70. package/dist/context/tenant-unknown-error.js.map +1 -1
  71. package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
  72. package/dist/delivery/delivery-supervisor.js +12 -3
  73. package/dist/delivery/delivery-supervisor.js.map +1 -1
  74. package/dist/delivery/delivery.d.ts +4 -3
  75. package/dist/delivery/delivery.d.ts.map +1 -1
  76. package/dist/delivery/delivery.js +82 -34
  77. package/dist/delivery/delivery.js.map +1 -1
  78. package/dist/delivery/reception-observation.d.ts +3 -3
  79. package/dist/delivery/reception-observation.js +2 -2
  80. package/dist/entity/entity.d.ts +59 -0
  81. package/dist/entity/entity.d.ts.map +1 -1
  82. package/dist/entity/entity.js +60 -0
  83. package/dist/entity/entity.js.map +1 -1
  84. package/dist/handler/app-query-handler.d.ts +60 -0
  85. package/dist/handler/app-query-handler.d.ts.map +1 -0
  86. package/dist/handler/app-query-handler.js +23 -0
  87. package/dist/handler/app-query-handler.js.map +1 -0
  88. package/dist/handler/generated-registry-discovery.d.ts +6 -0
  89. package/dist/handler/generated-registry-discovery.d.ts.map +1 -1
  90. package/dist/handler/generated-registry-discovery.js +71 -2
  91. package/dist/handler/generated-registry-discovery.js.map +1 -1
  92. package/dist/handler/handler-decorators.d.ts +8 -29
  93. package/dist/handler/handler-decorators.d.ts.map +1 -1
  94. package/dist/handler/handler-decorators.js +33 -210
  95. package/dist/handler/handler-decorators.js.map +1 -1
  96. package/dist/handler/spine-metadata-inventory.d.ts +99 -0
  97. package/dist/handler/spine-metadata-inventory.d.ts.map +1 -0
  98. package/dist/handler/spine-metadata-inventory.js +162 -0
  99. package/dist/handler/spine-metadata-inventory.js.map +1 -0
  100. package/dist/index.d.ts +9 -4
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/repository/command-routing.d.ts +1 -1
  105. package/dist/repository/command-routing.js +1 -1
  106. package/dist/repository/event-routing.d.ts +1 -1
  107. package/dist/repository/event-routing.js +1 -1
  108. package/dist/repository/repository.d.ts +61 -26
  109. package/dist/repository/repository.d.ts.map +1 -1
  110. package/dist/repository/repository.js +612 -306
  111. package/dist/repository/repository.js.map +1 -1
  112. package/dist/repository/state-update-routing.d.ts +1 -1
  113. package/dist/repository/state-update-routing.js +1 -1
  114. package/dist/runtime/signal-publisher.d.ts +23 -8
  115. package/dist/runtime/signal-publisher.d.ts.map +1 -1
  116. package/dist/runtime/signal-publisher.js +11 -8
  117. package/dist/runtime/signal-publisher.js.map +1 -1
  118. package/dist/server/managed-server-application.d.ts +12 -11
  119. package/dist/server/managed-server-application.d.ts.map +1 -1
  120. package/dist/server/managed-server-application.js +41 -25
  121. package/dist/server/managed-server-application.js.map +1 -1
  122. package/dist/server/node-coordinator.d.ts +18 -5
  123. package/dist/server/node-coordinator.d.ts.map +1 -1
  124. package/dist/server/node-coordinator.js +744 -108
  125. package/dist/server/node-coordinator.js.map +1 -1
  126. package/dist/server/reserved-spine-rpc-paths.json +1 -0
  127. package/dist/server/server-environment.d.ts +23 -5
  128. package/dist/server/server-environment.d.ts.map +1 -1
  129. package/dist/server/server-environment.js +89 -24
  130. package/dist/server/server-environment.js.map +1 -1
  131. package/dist/server/server.d.ts +45 -0
  132. package/dist/server/server.d.ts.map +1 -1
  133. package/dist/server/server.js +112 -3
  134. package/dist/server/server.js.map +1 -1
  135. package/dist/services/app-query-execution.d.ts +53 -0
  136. package/dist/services/app-query-execution.d.ts.map +1 -0
  137. package/dist/services/app-query-execution.js +408 -0
  138. package/dist/services/app-query-execution.js.map +1 -0
  139. package/dist/services/native-operation.d.ts +34 -0
  140. package/dist/services/native-operation.d.ts.map +1 -0
  141. package/dist/services/native-operation.js +77 -0
  142. package/dist/services/native-operation.js.map +1 -0
  143. package/dist/services/query-reader.d.ts +32 -7
  144. package/dist/services/query-reader.d.ts.map +1 -1
  145. package/dist/services/query-reader.js +22 -2
  146. package/dist/services/query-reader.js.map +1 -1
  147. package/dist/services/read-request-status.d.ts +65 -0
  148. package/dist/services/read-request-status.d.ts.map +1 -0
  149. package/dist/services/read-request-status.js +151 -0
  150. package/dist/services/read-request-status.js.map +1 -0
  151. package/dist/services/spine-services.d.ts +42 -7
  152. package/dist/services/spine-services.d.ts.map +1 -1
  153. package/dist/services/spine-services.js +1005 -175
  154. package/dist/services/spine-services.js.map +1 -1
  155. package/dist/services/trusted-reader-ingress.d.ts +110 -0
  156. package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
  157. package/dist/services/trusted-reader-ingress.js +102 -0
  158. package/dist/services/trusted-reader-ingress.js.map +1 -0
  159. package/dist/spi/handler-registry.d.ts +2 -0
  160. package/dist/spi/handler-registry.d.ts.map +1 -1
  161. package/dist/spi/handler-registry.js +1 -1
  162. package/dist/spi/handler-registry.js.map +1 -1
  163. package/dist/stand/read-side-filter-engine.d.ts +46 -0
  164. package/dist/stand/read-side-filter-engine.d.ts.map +1 -0
  165. package/dist/stand/read-side-filter-engine.js +356 -0
  166. package/dist/stand/read-side-filter-engine.js.map +1 -0
  167. package/dist/stand/read-side-filter.d.ts +247 -0
  168. package/dist/stand/read-side-filter.d.ts.map +1 -0
  169. package/dist/stand/read-side-filter.js +39 -0
  170. package/dist/stand/read-side-filter.js.map +1 -0
  171. package/dist/stand/read-side-message.d.ts +66 -0
  172. package/dist/stand/read-side-message.d.ts.map +1 -0
  173. package/dist/stand/read-side-message.js +167 -0
  174. package/dist/stand/read-side-message.js.map +1 -0
  175. package/dist/stand/stand-direct-delivery.d.ts +161 -0
  176. package/dist/stand/stand-direct-delivery.d.ts.map +1 -0
  177. package/dist/stand/stand-direct-delivery.js +210 -0
  178. package/dist/stand/stand-direct-delivery.js.map +1 -0
  179. package/dist/stand/stand.d.ts +81 -43
  180. package/dist/stand/stand.d.ts.map +1 -1
  181. package/dist/stand/stand.js +411 -55
  182. package/dist/stand/stand.js.map +1 -1
  183. package/dist/stand/subscription-observer.d.ts.map +1 -1
  184. package/dist/stand/subscription-observer.js +103 -0
  185. package/dist/stand/subscription-observer.js.map +1 -1
  186. package/dist/stand/subscription-read-filters.d.ts +40 -0
  187. package/dist/stand/subscription-read-filters.d.ts.map +1 -0
  188. package/dist/stand/subscription-read-filters.js +91 -0
  189. package/dist/stand/subscription-read-filters.js.map +1 -0
  190. package/dist/stand/subscription-reader-binding.d.ts +20 -0
  191. package/dist/stand/subscription-reader-binding.d.ts.map +1 -0
  192. package/dist/stand/subscription-reader-binding.js +56 -0
  193. package/dist/stand/subscription-reader-binding.js.map +1 -0
  194. package/dist/stand/subscription-records.d.ts +30 -3
  195. package/dist/stand/subscription-records.d.ts.map +1 -1
  196. package/dist/stand/subscription-records.js +61 -16
  197. package/dist/stand/subscription-records.js.map +1 -1
  198. package/dist/stand/subscription-registry.d.ts +49 -14
  199. package/dist/stand/subscription-registry.d.ts.map +1 -1
  200. package/dist/stand/subscription-registry.js +150 -31
  201. package/dist/stand/subscription-registry.js.map +1 -1
  202. package/dist/stand/subscription-runtime.d.ts +7 -2
  203. package/dist/stand/subscription-runtime.d.ts.map +1 -1
  204. package/dist/stand/subscription-runtime.js +131 -25
  205. package/dist/stand/subscription-runtime.js.map +1 -1
  206. package/dist/tsconfig.tsbuildinfo +1 -1
  207. package/package.json +16 -14
@@ -15,17 +15,23 @@ 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 { NativeOperation } from "../services/native-operation.js";
30
+ import { ServerEnvironment } from "./server-environment.js";
24
31
  const defaultHost = "127.0.0.1";
25
32
  const defaultPort = 0;
26
33
  const defaultMessageMaxBytes = 4_194_304;
27
34
  const gracefulSessionDrainMs = 100;
28
- const subscriptionQueueLimit = 100;
29
35
  /**
30
36
  * Hosts generated unary services in front of managed complete replicas.
31
37
  *
@@ -34,6 +40,13 @@ const subscriptionQueueLimit = 100;
34
40
  export class NodeCoordinator {
35
41
  // prettier-ignore
36
42
  #members;
43
+ #readerAdmission;
44
+ #forwardingProof;
45
+ #readers = new Map();
46
+ #children = new Map();
47
+ #logicalForChild = new Map();
48
+ #readMaxBytes;
49
+ #subscriptionQueueCapacity;
37
50
  #unaryKernel;
38
51
  #subscriptionKernel;
39
52
  #server;
@@ -59,6 +72,7 @@ export class NodeCoordinator {
59
72
  #close;
60
73
  #networkClose;
61
74
  #draining = false;
75
+ #admissionShutdown = new AbortController();
62
76
  #membershipReconciliation = Promise.resolve();
63
77
  /**
64
78
  * Creates the native node coordinator around its HTTP/2 listener and member source.
@@ -67,26 +81,48 @@ export class NodeCoordinator {
67
81
  * @param server The HTTP/2 server.
68
82
  * @param address The listening address.
69
83
  * @param sessions Tracked HTTP/2 sessions.
84
+ * @param subscriptionQueueCapacity Resolved bound for each managed subscriber.
70
85
  */
71
- constructor(options, server, address, sessions) {
86
+ constructor(options, server, address, sessions, subscriptionQueueCapacity) {
72
87
  this.#members = options.members;
73
- this.#unaryKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions());
74
- this.#subscriptionKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(options.members));
88
+ this.#readerAdmission = options.readerAdmission;
89
+ this.#forwardingProof = options.forwardingProof;
90
+ this.#readMaxBytes = options.readMaxBytes ?? defaultMessageMaxBytes;
91
+ this.#subscriptionQueueCapacity = subscriptionQueueCapacity;
92
+ this.#unaryKernel = new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(undefined, this.#forwardingProof, this.#readMaxBytes, (bytes) => this.#readerFor(bytes)));
93
+ this.#subscriptionKernel = this.#makeSubscriptionKernel(options.members);
75
94
  this.#server = server;
76
95
  this.#sessions = sessions;
77
96
  this.host = address.address;
78
97
  this.port = address.port;
79
98
  this.baseUrl = `http://${NodeCoordinatorValues.formatHostForUrl(this.host)}:${this.port.toString()}`;
80
99
  const reconcile = () => {
81
- this.#membershipReconciliation = this.#membershipReconciliation
82
- .then(() => this.#reconcile(), () => this.#reconcile())
83
- .catch(() => undefined);
100
+ this.#scheduleReconciliation();
84
101
  };
85
102
  this.#stopMembers = [
86
103
  options.members.onReadyMembersChange(reconcile),
87
104
  options.members.onRelayMembersChange(reconcile),
88
105
  ];
89
106
  }
107
+ /**
108
+ * Builds the subscription kernel with reader provenance for each child.
109
+ *
110
+ * @param members The available backend member source.
111
+ * @returns The native subscription kernel.
112
+ */
113
+ #makeSubscriptionKernel(members) {
114
+ return new BackendMembershipKernel(NodeCoordinatorValues.unaryKernelOptions(members, this.#forwardingProof, this.#readMaxBytes, (bytes) => this.#readerFor(bytes), (definition, child) => {
115
+ this.#rememberChild(definition, child);
116
+ }));
117
+ }
118
+ /**
119
+ * Serializes membership refresh after readiness or relay changes.
120
+ */
121
+ #scheduleReconciliation() {
122
+ this.#membershipReconciliation = this.#membershipReconciliation
123
+ .then(() => this.#reconcile(), () => this.#reconcile())
124
+ .catch(() => undefined);
125
+ }
90
126
  /**
91
127
  * Opens one generated unary-service listener.
92
128
  *
@@ -94,37 +130,44 @@ export class NodeCoordinator {
94
130
  * @returns The running private Coordinator.
95
131
  */
96
132
  static async open(options) {
133
+ const subscriptionQueueCapacity = ServerEnvironment.instance().subscriptionQueueCapacity;
97
134
  const { host, port, readMaxBytes, writeMaxBytes } = this.#listenerOptions(options);
98
135
  // eslint-disable-next-line prefer-const -- routes capture the Coordinator before the listener exists.
99
136
  let coordinator;
100
137
  const sessions = new Set();
101
138
  const server = http2.createServer(connectNodeAdapter({
102
139
  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
- });
140
+ this.#routes(router, () => coordinator);
118
141
  },
119
142
  readMaxBytes,
120
143
  writeMaxBytes,
121
144
  }));
122
145
  this.#trackSessions(server, sessions);
123
146
  const address = await NodeCoordinatorValues.listen(server, host, port);
124
- coordinator = new NodeCoordinator(options, server, address, sessions);
147
+ coordinator = new NodeCoordinator(options, server, address, sessions, subscriptionQueueCapacity);
125
148
  await coordinator.#reconcile();
126
149
  return coordinator;
127
150
  }
151
+ /**
152
+ * Registers native service routes after the listener receives its Coordinator instance.
153
+ *
154
+ * @param router Connect router for the private listener.
155
+ * @param current Returns the initialized Coordinator at request time.
156
+ */
157
+ static #routes(router, current) {
158
+ router.service(CommandService, {
159
+ post: (command, context) => current().#post(command, context),
160
+ });
161
+ router.service(QueryService, {
162
+ read: (query, context) => current().#read(query, context),
163
+ execute: (query, context) => current().#execute(query, context),
164
+ });
165
+ router.service(SubscriptionService, {
166
+ subscribe: (topic, context) => current().#subscribe(topic, context),
167
+ activate: (subscription, context) => current().#activate(subscription, context),
168
+ cancel: (subscription, context) => current().#cancel(subscription, context),
169
+ });
170
+ }
128
171
  /**
129
172
  * Validates private listener and message limits before opening the server.
130
173
  *
@@ -157,6 +200,7 @@ export class NodeCoordinator {
157
200
  * @returns Completion after bounded Coordinator cleanup.
158
201
  */
159
202
  close() {
203
+ this.#admissionShutdown.abort();
160
204
  this.#close ??= this.#closeOnce().catch((error) => {
161
205
  this.#close = undefined;
162
206
  throw error;
@@ -181,46 +225,238 @@ export class NodeCoordinator {
181
225
  await this.#membershipReconciliation;
182
226
  await this.#subscriptionKernel.close();
183
227
  }
228
+ /**
229
+ * Admits a command reader and forwards the command to a managed child.
230
+ *
231
+ * @param command Command envelope from the native client.
232
+ * @param context The trusted context for this operation.
233
+ * @returns Child acknowledgment after forwarding.
234
+ */
184
235
  async #post(command, context) {
185
- const request = {
186
- kind: "command",
187
- command,
188
- context,
189
- response: create(AckSchema),
190
- };
191
- await this.#forward(request);
192
- return request.response;
236
+ return this.#runAdmitted("spine.client.CommandService", "Post", toBinary(CommandService.method.post.input, command), context, async (reader, signal) => {
237
+ const request = {
238
+ kind: "command",
239
+ command,
240
+ context,
241
+ reader,
242
+ signal,
243
+ response: create(AckSchema),
244
+ };
245
+ await this.#forward(request);
246
+ return request.response;
247
+ });
193
248
  }
249
+ /**
250
+ * Admits a read reader and forwards the query to a managed child.
251
+ *
252
+ * @param query Query envelope from the native client.
253
+ * @param context The trusted context for this operation.
254
+ * @returns Child read response after forwarding.
255
+ */
194
256
  async #read(query, context) {
195
- const request = {
196
- kind: "query",
197
- query,
198
- context,
199
- response: create(QueryResponseSchema),
200
- };
201
- await this.#forward(request);
202
- return request.response;
257
+ return this.#runAdmitted("spine.client.QueryService", "Read", toBinary(QuerySchema, query), context, async (reader, signal) => {
258
+ ReadRequestStatus.check("query", () => Validate.check(QuerySchema, query));
259
+ const request = {
260
+ kind: "query",
261
+ query,
262
+ context,
263
+ reader,
264
+ signal,
265
+ response: create(QueryResponseSchema),
266
+ };
267
+ await this.#forward(request);
268
+ return request.response;
269
+ });
270
+ }
271
+ /**
272
+ * Admits an App Query and forwards it to a ready application replica.
273
+ *
274
+ * @param query App Query envelope.
275
+ * @param context Native RPC context.
276
+ * @returns The application replica's response.
277
+ */
278
+ async #execute(query, context) {
279
+ return this.#runAdmitted("spine.client.QueryService", "Execute", toBinary(QueryService.method.execute.input, query), context, async (reader, signal) => {
280
+ ReadRequestStatus.check("query", () => Validate.check(AppQuerySchema, query));
281
+ const request = {
282
+ kind: "app-query",
283
+ query,
284
+ context,
285
+ reader,
286
+ signal,
287
+ response: create(AppQueryResponseSchema),
288
+ };
289
+ await this.#forward(request);
290
+ return request.response;
291
+ });
203
292
  }
293
+ /**
294
+ * Verifies forwarded or direct reader facts at the managed ingress.
295
+ *
296
+ * @param service Canonical RPC service.
297
+ * @param method Canonical RPC method.
298
+ * @param request Canonical request bytes.
299
+ * @param context Native transport context.
300
+ * @param signal Host-managed operation lifetime.
301
+ * @returns Captured reader admission and invalidation.
302
+ */
303
+ #admit(service, method, request, context, signal) {
304
+ return TrustedReaderIngress.admit({
305
+ service,
306
+ method,
307
+ request,
308
+ headers: context.requestHeader,
309
+ peer: context,
310
+ signal,
311
+ maxBytes: this.#readMaxBytes,
312
+ ...this.#readerAdmission,
313
+ });
314
+ }
315
+ /**
316
+ * Holds one unary operation through admission and child work.
317
+ *
318
+ * @typeParam T Native response type.
319
+ * @param service Canonical RPC service.
320
+ * @param method Canonical RPC method.
321
+ * @param bytes Canonical request bytes.
322
+ * @param context Original peer metadata.
323
+ * @param work Verified action using the effective signal.
324
+ * @returns Result while admission remains valid.
325
+ */
326
+ async #runAdmitted(service, method, bytes, context, work) {
327
+ const operation = new NativeOperation(context.signal, this.#admissionShutdown.signal);
328
+ try {
329
+ const admission = await this.#admit(service, method, bytes, context, operation.signal);
330
+ operation.link(admission.invalidated);
331
+ operation.check();
332
+ const result = await work(admission.reader, operation.signal);
333
+ operation.check();
334
+ return result;
335
+ }
336
+ finally {
337
+ operation.close();
338
+ }
339
+ }
340
+ /**
341
+ * Validates and retains a reader-bound subscription definition.
342
+ *
343
+ * @param topic Framework subscription Topic.
344
+ * @param context The trusted context for this operation.
345
+ * @returns The new subscription definition.
346
+ */
204
347
  async #subscribe(topic, context) {
348
+ const operation = new NativeOperation(context.signal, this.#admissionShutdown.signal);
349
+ try {
350
+ const admission = await this.#admit("spine.client.SubscriptionService", "Subscribe", toBinary(TopicSchema, topic), context, operation.signal);
351
+ operation.link(admission.invalidated);
352
+ operation.check();
353
+ const subscription = await this.#subscribeReader(topic, admission.reader, operation.signal);
354
+ try {
355
+ operation.check();
356
+ }
357
+ catch (error) {
358
+ return await this.#compensateSubscribe(subscription, error);
359
+ }
360
+ return subscription;
361
+ }
362
+ finally {
363
+ operation.close();
364
+ }
365
+ }
366
+ /**
367
+ * Removes a committed definition after a failed final admission check.
368
+ *
369
+ * @param subscription Created logical definition.
370
+ * @param error Final admission failure.
371
+ * @returns Rejected completion after conditional cleanup.
372
+ */
373
+ async #compensateSubscribe(subscription, error) {
374
+ this.#clearPendingActivation(subscription);
375
+ try {
376
+ await this.#cancelDefinition(toBinary(SubscriptionSchema, subscription), new AbortController().signal);
377
+ }
378
+ catch (cleanup) {
379
+ throw new AggregateError([error, cleanup], "Coordinator Subscribe cleanup failed.");
380
+ }
381
+ throw error;
382
+ }
383
+ /**
384
+ * Creates a reader-bound definition under its native operation lifetime.
385
+ *
386
+ * @param topic Framework subscription Topic.
387
+ * @param reader Detached authenticated facts.
388
+ * @param signal Effective cancellation.
389
+ * @returns Created logical definition.
390
+ */
391
+ async #subscribeReader(topic, reader, signal) {
392
+ ReadRequestStatus.check("topic", () => Validate.check(TopicSchema, topic));
205
393
  if (this.#draining)
206
394
  throw new ConnectError("Managed application is closing.", Code.Unavailable);
207
395
  const subscription = create(SubscriptionSchema, {
208
396
  id: create(SubscriptionIdSchema, { value: `s-${randomUUID()}` }),
209
397
  topic,
210
398
  });
399
+ const id = subscription.id?.value;
400
+ if (id === undefined)
401
+ throw new Error("Created subscription has no ID.");
402
+ const definition = toBinary(SubscriptionSchema, subscription);
403
+ this.#readers.set(id, reader);
211
404
  try {
212
- await this.#subscriptionKernel.subscribe(toBinary(SubscriptionSchema, subscription), context.signal);
405
+ await this.#createDefinition(definition, signal);
213
406
  }
214
407
  catch (error) {
408
+ if (!(error instanceof AggregateError))
409
+ this.#forgetReader(id);
410
+ if (signal.aborted && !(error instanceof AggregateError))
411
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
215
412
  throw this.#availabilityError(error);
216
413
  }
217
- if (this.#isDraining()) {
218
- await this.#cancelDefinition(toBinary(SubscriptionSchema, subscription), context.signal);
414
+ if (this.#isDraining() || signal.aborted) {
415
+ await this.#cancelDefinition(definition, new AbortController().signal);
219
416
  throw new ConnectError("Managed application is closing.", Code.Unavailable);
220
417
  }
221
418
  this.#schedulePendingActivation(subscription);
222
419
  return subscription;
223
420
  }
421
+ /**
422
+ * Cancels a pending child creation when the requesting reader loses authority.
423
+ *
424
+ * @param definition Logical subscription definition.
425
+ * @param signal Reader operation lifetime.
426
+ * @returns Completion after creation or conditional cancellation settles.
427
+ */
428
+ async #createDefinition(definition, signal) {
429
+ let cancellation;
430
+ const abort = () => {
431
+ cancellation = this.#cancelDefinition(definition, new AbortController().signal);
432
+ void cancellation.catch(() => undefined);
433
+ };
434
+ signal.addEventListener("abort", abort, { once: true });
435
+ let failed = false;
436
+ let primary;
437
+ try {
438
+ await this.#subscriptionKernel.subscribe(definition, signal);
439
+ }
440
+ catch (error) {
441
+ failed = true;
442
+ primary = error;
443
+ }
444
+ finally {
445
+ signal.removeEventListener("abort", abort);
446
+ }
447
+ if (signal.aborted && cancellation === undefined)
448
+ abort();
449
+ try {
450
+ await cancellation;
451
+ }
452
+ catch (cleanup) {
453
+ throw new AggregateError(failed ? [primary, cleanup] : [cleanup], "Subscription cancellation failed.");
454
+ }
455
+ if (failed)
456
+ throw primary;
457
+ if (signal.aborted)
458
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
459
+ }
224
460
  /**
225
461
  * Reads current admission state after an asynchronous backend Subscribe.
226
462
  *
@@ -229,7 +465,32 @@ export class NodeCoordinator {
229
465
  #isDraining() {
230
466
  return this.#draining;
231
467
  }
468
+ /**
469
+ * Verifies membership and activates a retained subscription.
470
+ *
471
+ * @param subscription The subscription definition being processed.
472
+ * @param context The trusted context for this operation.
473
+ * @returns The update stream after admission.
474
+ */
232
475
  async *#activate(subscription, context) {
476
+ const operation = new NativeOperation(context.signal, this.#admissionShutdown.signal);
477
+ try {
478
+ await this.#admitActivation(subscription, context, operation);
479
+ operation.check();
480
+ yield* this.#relayActivation(subscription, operation);
481
+ }
482
+ finally {
483
+ operation.close();
484
+ }
485
+ }
486
+ /**
487
+ * Relays one admitted definition through ACK and complete child iteration.
488
+ *
489
+ * @param subscription Retained logical definition.
490
+ * @param operation Host-managed activation lifetime.
491
+ * @returns ACK and surviving updates.
492
+ */
493
+ async *#relayActivation(subscription, operation) {
233
494
  if (this.#draining)
234
495
  throw new ConnectError("Managed application is closing.", Code.Unavailable);
235
496
  const definition = toBinary(SubscriptionSchema, subscription);
@@ -238,46 +499,84 @@ export class NodeCoordinator {
238
499
  const abort = () => {
239
500
  overflow.abort();
240
501
  };
241
- context.signal.addEventListener("abort", abort, { once: true });
242
- if (context.signal.aborted)
502
+ operation.signal.addEventListener("abort", abort, { once: true });
503
+ if (operation.signal.aborted)
243
504
  abort();
244
- const updates = new SubscriptionUpdateQueue(subscriptionQueueLimit, abort);
245
- const activation = this.#activateChild(definition, subscription, updates, overflow.signal);
505
+ const updates = new SubscriptionUpdateQueue(this.#subscriptionQueueCapacity, abort);
506
+ const ack = Promise.withResolvers();
507
+ const activation = this.#activateChild(definition, subscription, updates, ack, overflow.signal);
246
508
  let failure;
247
509
  try {
248
- for await (const update of updates)
510
+ const ready = await ack.promise;
511
+ operation.check();
512
+ if (ready !== undefined)
513
+ yield ready;
514
+ for await (const update of updates) {
515
+ operation.check();
249
516
  yield update;
517
+ }
250
518
  }
251
519
  catch (error) {
252
520
  failure = NodeCoordinatorValues.error(error);
253
521
  }
254
522
  finally {
255
- context.signal.removeEventListener("abort", abort);
523
+ operation.signal.removeEventListener("abort", abort);
256
524
  overflow.abort();
257
525
  updates.close();
258
526
  await this.#finishActivation(definition, activation, failure);
259
527
  }
260
528
  }
529
+ /**
530
+ * Admits and validates a reader-bound activation before changing pending state.
531
+ *
532
+ * @param subscription The subscription definition being processed.
533
+ * @param context The trusted context for this operation.
534
+ * @returns Completion after the operation settles.
535
+ */
536
+ async #admitActivation(subscription, context, operation) {
537
+ const admission = await this.#admit("spine.client.SubscriptionService", "Activate", toBinary(SubscriptionSchema, subscription), context, operation.signal);
538
+ operation.link(admission.invalidated);
539
+ operation.check();
540
+ ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
541
+ ReadRequestStatus.checkLifecycleCriterion(subscription);
542
+ this.#requireReader(subscription, admission.reader);
543
+ }
261
544
  /**
262
545
  * Relays accepted child updates through the bounded logical stream.
263
546
  *
264
547
  * @param definition The validated child subscription bytes.
265
548
  * @param subscription The logical subscription exposed to callers.
266
549
  * @param updates The bounded caller stream queue.
550
+ * @param ack The separate activation acknowledgment gate.
267
551
  * @param signal Cancels the active child relay.
268
552
  * @returns Completion after the backend activation ends.
269
553
  */
270
- #activateChild(definition, subscription, updates, signal) {
554
+ #activateChild(definition, subscription, updates, ack, signal) {
271
555
  return this.#subscriptionKernel
272
556
  .activate(definition, async (childUpdate) => {
557
+ if (signal.aborted)
558
+ return;
273
559
  const update = fromBinary(SubscriptionUpdateSchema, childUpdate);
274
560
  update.subscription = clone(SubscriptionSchema, subscription);
561
+ if (update.response?.status?.status.case === "ok" && update.update.case === undefined) {
562
+ ack.resolve(update);
563
+ return;
564
+ }
275
565
  await updates.push(update);
276
566
  }, signal)
277
567
  .finally(() => {
568
+ ack.resolve(undefined);
278
569
  updates.close();
279
570
  });
280
571
  }
572
+ /**
573
+ * Finishes one activated stream and clears pending state.
574
+ *
575
+ * @param definition The serialized subscription definition.
576
+ * @param activation Activation task and its stream state.
577
+ * @param previousFailure Earlier lifecycle failure.
578
+ * @returns Completion after the operation settles.
579
+ */
281
580
  async #finishActivation(definition, activation, previousFailure) {
282
581
  const failures = previousFailure === undefined ? [] : [previousFailure];
283
582
  try {
@@ -298,16 +597,51 @@ export class NodeCoordinator {
298
597
  if (failures.length > 1)
299
598
  throw new AggregateError(failures, "Coordinator activation cleanup failed.");
300
599
  }
600
+ /**
601
+ * Verifies reader membership and removes a retained subscription.
602
+ *
603
+ * @param subscription The subscription definition being processed.
604
+ * @param context The trusted context for this operation.
605
+ * @returns The cancellation acknowledgment.
606
+ */
301
607
  async #cancel(subscription, context) {
608
+ return this.#runAdmitted("spine.client.SubscriptionService", "Cancel", toBinary(SubscriptionSchema, subscription), context, (reader, signal) => this.#cancelReader(subscription, reader, signal));
609
+ }
610
+ /**
611
+ * Checks reader binding before removing an existing definition.
612
+ *
613
+ * @param subscription Logical definition.
614
+ * @param reader Detached authenticated facts.
615
+ * @param signal Effective cancellation.
616
+ * @returns Cancellation acknowledgment.
617
+ */
618
+ async #cancelReader(subscription, reader, signal) {
619
+ ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
620
+ ReadRequestStatus.checkLifecycleCriterion(subscription);
621
+ const id = subscription.id?.value;
622
+ if (id !== undefined && this.#readers.has(id))
623
+ this.#requireReader(subscription, reader);
624
+ else
625
+ return create(ResponseSchema, {
626
+ status: create(StatusSchema, { status: { case: "ok", value: create(EmptySchema) } }),
627
+ });
302
628
  this.#clearPendingActivation(subscription);
303
- await this.#cancelDefinition(toBinary(SubscriptionSchema, subscription), context.signal);
629
+ await this.#cancelDefinition(toBinary(SubscriptionSchema, subscription), signal);
304
630
  return create(ResponseSchema, {
305
631
  status: create(StatusSchema, { status: { case: "ok", value: create(EmptySchema) } }),
306
632
  });
307
633
  }
634
+ /**
635
+ * Forwards a verified request to the selected managed child.
636
+ *
637
+ * @param request The request being processed.
638
+ * @returns Completion after the operation settles.
639
+ */
308
640
  async #forward(request) {
309
641
  if (this.#draining)
310
642
  throw new ConnectError("No ready application replica is available.", Code.Unavailable);
643
+ if (request.signal.aborted)
644
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
311
645
  try {
312
646
  await this.#unaryKernel.forward(request);
313
647
  }
@@ -315,11 +649,22 @@ export class NodeCoordinator {
315
649
  throw this.#availabilityError(error);
316
650
  }
317
651
  }
652
+ /**
653
+ * Maps child transport failure to the public availability status.
654
+ *
655
+ * @param error The failure being classified or propagated.
656
+ * @returns The public transport error.
657
+ */
318
658
  #availabilityError(error) {
319
659
  return error instanceof Error && error.message === "backend membership is unavailable."
320
660
  ? new ConnectError("No ready application replica is available.", Code.Unavailable)
321
661
  : error;
322
662
  }
663
+ /**
664
+ * Starts the activation handshake expiry for a new subscription.
665
+ *
666
+ * @param subscription The subscription definition being processed.
667
+ */
323
668
  #schedulePendingActivation(subscription) {
324
669
  const id = NodeCoordinatorValues.requiredValue(subscription.id?.value, "Coordinator subscription definition is missing an ID.");
325
670
  this.#clearPendingActivation(subscription);
@@ -332,18 +677,98 @@ export class NodeCoordinator {
332
677
  pending.timer.unref();
333
678
  this.#pendingActivations.set(id, pending);
334
679
  }
680
+ /**
681
+ * Forwards cancellation of a native subscription definition.
682
+ *
683
+ * @param definition The serialized subscription definition.
684
+ * @param signal The cancellation signal for this operation.
685
+ * @returns Completion after the operation settles.
686
+ */
335
687
  #cancelDefinition(definition, signal) {
336
688
  const id = NodeCoordinatorValues.requiredValue(fromBinary(SubscriptionSchema, definition).id?.value, "Coordinator subscription definition is missing an ID.");
337
689
  const active = this.#cancellations.get(id);
338
690
  if (active !== undefined)
339
691
  return active;
340
- const cancellation = this.#subscriptionKernel.cancel(definition, signal).finally(() => {
692
+ const cancellation = this.#subscriptionKernel
693
+ .cancel(definition, signal)
694
+ .then(() => {
695
+ this.#forgetReader(id);
696
+ })
697
+ .finally(() => {
341
698
  if (this.#cancellations.get(id) === cancellation)
342
699
  this.#cancellations.delete(id);
343
700
  });
344
701
  this.#cancellations.set(id, cancellation);
345
702
  return cancellation;
346
703
  }
704
+ /**
705
+ * Verifies and refreshes the reader before subscription effects.
706
+ *
707
+ * @param subscription The subscription definition being processed.
708
+ * @param reader The verified reader facts for this operation.
709
+ */
710
+ #requireReader(subscription, reader) {
711
+ const id = subscription.id?.value;
712
+ const previous = id === undefined ? undefined : this.#readers.get(id);
713
+ if (id === undefined || previous === undefined)
714
+ throw ReadRequestStatus.unknownSubscription();
715
+ if (!ReaderIdentities.matches(ReaderIdentities.from(previous), ReaderIdentities.from(reader)))
716
+ throw new ConnectError("Subscription reader is not authorized.", Code.PermissionDenied);
717
+ this.#readers.set(id, reader);
718
+ for (const child of this.#children.get(id) ?? [])
719
+ this.#readers.set(child, reader);
720
+ }
721
+ /**
722
+ * Associates one native child with the admitted logical reader.
723
+ *
724
+ * @param definition The serialized subscription definition.
725
+ * @param child The native child subscription definition.
726
+ */
727
+ #rememberChild(definition, child) {
728
+ const id = fromBinary(SubscriptionSchema, definition).id?.value;
729
+ const childId = fromBinary(SubscriptionSchema, child).id?.value;
730
+ if (id === undefined || childId === undefined)
731
+ return;
732
+ const logicalId = this.#logicalForChild.get(id) ?? (this.#readers.has(id) ? id : undefined);
733
+ if (logicalId === undefined)
734
+ return;
735
+ const children = this.#children.get(logicalId) ?? new Set();
736
+ children.add(childId);
737
+ this.#children.set(logicalId, children);
738
+ this.#logicalForChild.set(childId, logicalId);
739
+ const reader = this.#readers.get(id);
740
+ if (reader !== undefined)
741
+ this.#readers.set(childId, reader);
742
+ }
743
+ /**
744
+ * Returns current detached facts for a logical or child subscription.
745
+ *
746
+ * @param bytes The serialized message bytes.
747
+ * @returns The verified reader for this subscription.
748
+ */
749
+ #readerFor(bytes) {
750
+ const id = fromBinary(SubscriptionSchema, bytes).id?.value;
751
+ const reader = id === undefined ? undefined : this.#readers.get(id);
752
+ return reader === undefined ? undefined : snapshotTrustedReader(reader);
753
+ }
754
+ /**
755
+ * Removes a finished logical definition and its current child mappings.
756
+ *
757
+ * @param id The stable identifier for this record.
758
+ */
759
+ #forgetReader(id) {
760
+ this.#readers.delete(id);
761
+ for (const child of this.#children.get(id) ?? []) {
762
+ this.#readers.delete(child);
763
+ this.#logicalForChild.delete(child);
764
+ }
765
+ this.#children.delete(id);
766
+ }
767
+ /**
768
+ * Removes a pending activation timer and its tracking entry.
769
+ *
770
+ * @param subscription The subscription definition being processed.
771
+ */
347
772
  #clearPendingActivation(subscription) {
348
773
  const id = subscription.id?.value;
349
774
  if (id === undefined)
@@ -354,6 +779,13 @@ export class NodeCoordinator {
354
779
  clearTimeout(pending.timer);
355
780
  this.#pendingActivations.delete(id);
356
781
  }
782
+ /**
783
+ * Expires an unactivated subscription after its handshake window.
784
+ *
785
+ * @param id The stable identifier for this record.
786
+ * @param pending Pending activation or retry state.
787
+ * @returns Completion after the operation settles.
788
+ */
357
789
  async #expirePendingActivation(id, pending) {
358
790
  if (this.#pendingActivations.get(id) !== pending)
359
791
  return;
@@ -367,6 +799,11 @@ export class NodeCoordinator {
367
799
  this.#schedulePendingActivation(fromBinary(SubscriptionSchema, pending.definition));
368
800
  }
369
801
  }
802
+ /**
803
+ * Reconciles managed child subscriptions after topology changes.
804
+ *
805
+ * @returns Completion after the operation settles.
806
+ */
370
807
  #reconcile() {
371
808
  if (this.#members.onRelaySynchronized !== undefined)
372
809
  return this.#subscriptionKernel
@@ -377,6 +814,11 @@ export class NodeCoordinator {
377
814
  this.#subscriptionKernel.reconcile(this.#members.relayMembers()),
378
815
  ]).then(() => undefined);
379
816
  }
817
+ /**
818
+ * Runs shutdown once while concurrent callers await the same completion.
819
+ *
820
+ * @returns Completion after the operation settles.
821
+ */
380
822
  async #closeOnce() {
381
823
  this.#stopMembers.forEach((stop) => {
382
824
  stop();
@@ -395,16 +837,16 @@ export class NodeCoordinator {
395
837
  * @internal
396
838
  */
397
839
  export class SubscriptionUpdateQueue {
840
+ #active;
398
841
  #updates = [];
399
842
  #waiters = [];
400
- #delivered = [];
401
843
  #limit;
402
844
  #onOverflow;
403
845
  #closed = false;
404
846
  /**
405
847
  * Creates one terminal bounded update queue.
406
848
  *
407
- * @param limit Limits retained updates before terminal closure.
849
+ * @param limit Maximum waiting updates beyond one active update.
408
850
  * @param onOverflow Aborts the public relay when the queue reaches its bound.
409
851
  */
410
852
  constructor(limit, onOverflow = () => undefined) {
@@ -420,18 +862,22 @@ export class SubscriptionUpdateQueue {
420
862
  push(update) {
421
863
  if (this.#closed)
422
864
  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) {
865
+ if (this.#active !== undefined && this.#updates.length >= this.#limit) {
429
866
  this.#onOverflow();
430
867
  this.close();
431
868
  return Promise.resolve();
432
869
  }
433
- this.#updates.push(update);
434
- return new Promise((resolve) => this.#delivered.push(resolve));
870
+ return new Promise((resolve) => {
871
+ const entry = { update, delivered: false, resolve };
872
+ if (this.#active === undefined) {
873
+ this.#active = entry;
874
+ const waiter = this.#waiters.shift();
875
+ if (waiter !== undefined)
876
+ waiter(this.#deliver(entry));
877
+ }
878
+ else
879
+ this.#updates.push(entry);
880
+ });
435
881
  }
436
882
  /**
437
883
  * Closes the queue and releases all waiting producers and consumers.
@@ -440,11 +886,20 @@ export class SubscriptionUpdateQueue {
440
886
  if (this.#closed)
441
887
  return;
442
888
  this.#closed = true;
443
- this.#updates.length = 0;
889
+ this.#active?.resolve();
890
+ this.#active = undefined;
891
+ for (const update of this.#updates.splice(0))
892
+ update.resolve();
444
893
  for (const waiter of this.#waiters.splice(0))
445
894
  waiter({ value: undefined, done: true });
446
- for (const delivered of this.#delivered.splice(0))
447
- delivered();
895
+ }
896
+ /**
897
+ * Resolves one waiting consumer with the next queued subscription update.
898
+ */
899
+ #deliver(entry) {
900
+ entry.delivered = true;
901
+ entry.resolve();
902
+ return { value: entry.update, done: false };
448
903
  }
449
904
  /**
450
905
  * Returns queued updates until the terminal close.
@@ -454,13 +909,12 @@ export class SubscriptionUpdateQueue {
454
909
  [Symbol.asyncIterator]() {
455
910
  return {
456
911
  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
912
  if (this.#closed)
463
913
  return Promise.resolve({ value: undefined, done: true });
914
+ if (this.#active?.delivered)
915
+ this.#active = this.#updates.shift();
916
+ if (this.#active !== undefined)
917
+ return Promise.resolve(this.#deliver(this.#active));
464
918
  return new Promise((resolve) => this.#waiters.push(resolve));
465
919
  },
466
920
  };
@@ -472,20 +926,35 @@ const NodeCoordinatorValues = Object.freeze({
472
926
  *
473
927
  * @param members The ready member source when available.
474
928
  * @returns The configured membership kernel options.
929
+ * @param issuer The configured forwarding proof producer.
930
+ * @param maxBytes The existing native message-size bound.
931
+ * @param readerFor Reader lookup for a retained subscription.
932
+ * @param onRememberChild Records a native child ID for its logical subscription.
475
933
  */
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.
934
+ unaryKernelOptions(members, issuer, maxBytes = defaultMessageMaxBytes, readerFor, onRememberChild) {
479
935
  return {
480
- create: (member) => Promise.resolve(NodeCoordinatorValues.client(member, members)),
936
+ create: (member) => Promise.resolve(NodeCoordinatorValues.client(member, members, issuer, maxBytes, readerFor, onRememberChild)),
481
937
  memberKey: (member) => `${member.slot.toString()}/${member.incarnation}`,
482
938
  sameMember: (left, right) => left.endpoint === right.endpoint,
483
939
  definitionKey: (definition) => fromBinary(SubscriptionSchema, definition).id?.value,
484
- childDefinition: NodeCoordinatorValues.childDefinition,
940
+ childDefinition: (definition, member) => NodeCoordinatorValues.rememberChildDefinition(definition, member, onRememberChild),
485
941
  childSize: (child) => child.byteLength,
486
942
  ...NodeCoordinatorValues.activationAckOptions(members),
487
943
  };
488
944
  },
945
+ /**
946
+ * Builds and records a child definition before member subscription.
947
+ *
948
+ * @param definition The serialized subscription definition.
949
+ * @param member The selected backend member.
950
+ * @param onRememberChild Records a native child ID for its logical subscription.
951
+ * @returns The retained managed-child definition.
952
+ */
953
+ rememberChildDefinition(definition, member, onRememberChild) {
954
+ const child = NodeCoordinatorValues.childDefinition(definition, member);
955
+ onRememberChild?.(definition, child);
956
+ return child;
957
+ },
489
958
  /**
490
959
  * Builds strict child ACK validation for the subscription membership kernel.
491
960
  *
@@ -526,49 +995,178 @@ const NodeCoordinatorValues = Object.freeze({
526
995
  * @param member The discovered backend member.
527
996
  * @param members The ready member source when available.
528
997
  * @returns The member transport client.
998
+ * @param issuer The configured forwarding proof producer.
999
+ * @param maxBytes The existing native message-size bound.
1000
+ * @param readerFor Reader lookup for a retained subscription.
1001
+ * @param onRememberChild Records a native child ID for its logical subscription.
529
1002
  */
530
- client(member, members) {
1003
+ client(member, members, issuer, maxBytes = defaultMessageMaxBytes, readerFor, onRememberChild) {
531
1004
  const manager = new Http2SessionManager(member.endpoint);
532
1005
  const transport = createGrpcTransport({ baseUrl: member.endpoint, sessionManager: manager });
533
1006
  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
- },
1007
+ forward: (request) => NodeCoordinatorValues.forward(transport, request, issuer, maxBytes),
1008
+ close: () => NodeCoordinatorValues.closeManager(manager),
1009
+ subscribe: (definition, signal) => NodeCoordinatorValues.subscribeChild(transport, definition, signal, readerFor?.(definition), issuer, maxBytes, onRememberChild),
1010
+ ...NodeCoordinatorValues.childOperations(transport, member, members, issuer, maxBytes, readerFor),
550
1011
  };
551
1012
  },
552
1013
  /**
553
- * Sends one native Command or Query to a ready member.
1014
+ * Closes the transport manager for a departed native member.
1015
+ *
1016
+ * @param manager Native session manager.
1017
+ * @returns Completion after the operation settles.
1018
+ */
1019
+ closeManager(manager) {
1020
+ manager.abort();
1021
+ return Promise.resolve();
1022
+ },
1023
+ /**
1024
+ * Returns streaming and cancellation operations for a native member.
1025
+ *
1026
+ * @param transport The connected native transport.
1027
+ * @param member The selected backend member.
1028
+ * @param members The available backend member source.
1029
+ * @param issuer The configured forwarding proof producer.
1030
+ * @param maxBytes The existing native message-size bound.
1031
+ * @param readerFor Reader lookup for a retained subscription.
1032
+ * @returns The managed-child operations.
1033
+ */
1034
+ childOperations(transport, member, members, issuer, maxBytes, readerFor) {
1035
+ return {
1036
+ activate: NodeCoordinatorValues.activation(transport, member, members, issuer, maxBytes, readerFor),
1037
+ dispose: (child, signal) => NodeCoordinatorValues.cancelChild(transport, member, members, child, signal, readerFor?.(child), issuer, maxBytes),
1038
+ };
1039
+ },
1040
+ /**
1041
+ * Captures native member context for one child stream.
1042
+ *
1043
+ * @param transport The connected native transport.
1044
+ * @param member The selected backend member.
1045
+ * @param members The available backend member source.
1046
+ * @param issuer The configured forwarding proof producer.
1047
+ * @param maxBytes The existing native message-size bound.
1048
+ * @param readerFor Reader lookup for a retained subscription.
1049
+ * @returns The activation stream completion.
1050
+ */
1051
+ activation(transport, member, members, issuer, maxBytes, readerFor) {
1052
+ return (child, updates, signal) => NodeCoordinatorValues.activateChild(transport, member, members, child, updates, signal, readerFor?.(child), issuer, maxBytes);
1053
+ },
1054
+ /**
1055
+ * Creates one native child and records any backend-assigned ID.
1056
+ *
1057
+ * @param transport The connected native transport.
1058
+ * @param definition The serialized subscription definition.
1059
+ * @param signal The cancellation signal for this operation.
1060
+ * @param reader The verified reader facts for this operation.
1061
+ * @param issuer The configured forwarding proof producer.
1062
+ * @param maxBytes The existing native message-size bound.
1063
+ * @param onRememberChild Records a native child ID for its logical subscription.
1064
+ * @returns The managed-child subscription.
1065
+ */
1066
+ async subscribeChild(transport, definition, signal, reader, issuer, maxBytes, onRememberChild) {
1067
+ const subscription = fromBinary(SubscriptionSchema, definition);
1068
+ const topic = NodeCoordinatorValues.requiredValue(subscription.topic, "Coordinator subscription definition is missing a Topic.");
1069
+ const headers = await NodeCoordinatorValues.readerHeaders("Subscribe", toBinary(TopicSchema, topic), reader, issuer, maxBytes);
1070
+ if (signal.aborted)
1071
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
1072
+ const created = await createClient(SubscriptionService, transport).subscribe(topic, {
1073
+ signal,
1074
+ ...(headers === undefined ? {} : { headers }),
1075
+ });
1076
+ const bytes = toBinary(SubscriptionSchema, created);
1077
+ onRememberChild?.(definition, bytes);
1078
+ return bytes;
1079
+ },
1080
+ /**
1081
+ * Cancels one native child with the same verified reader as its creation.
1082
+ *
1083
+ * @param transport The connected native transport.
1084
+ * @param member The selected backend member.
1085
+ * @param members The available backend member source.
1086
+ * @param child The native child subscription definition.
1087
+ * @param signal The cancellation signal for this operation.
1088
+ * @param reader The verified reader facts for this operation.
1089
+ * @param issuer The configured forwarding proof producer.
1090
+ * @param maxBytes The existing native message-size bound.
1091
+ * @returns Completion after the operation settles.
1092
+ */
1093
+ async cancelChild(transport, member, members, child, signal, reader, issuer, maxBytes) {
1094
+ const subscription = fromBinary(SubscriptionSchema, child);
1095
+ const headers = await NodeCoordinatorValues.readerHeaders("Cancel", child, reader, issuer, maxBytes);
1096
+ if (signal.aborted)
1097
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
1098
+ members?.onChildSubscriptionCancelled?.(member, subscription);
1099
+ await createClient(SubscriptionService, transport).cancel(subscription, {
1100
+ signal,
1101
+ ...(headers === undefined ? {} : { headers }),
1102
+ });
1103
+ },
1104
+ /**
1105
+ * Sends one native Command, entity read, or App Query to a ready member.
554
1106
  *
555
1107
  * @param transport The connected native transport.
556
1108
  * @param request The request to forward.
557
1109
  * @returns Empty routing bytes after the response is assigned.
1110
+ * @param issuer The configured forwarding proof producer.
1111
+ * @param maxBytes The existing native message-size bound.
558
1112
  */
559
- async forward(transport, request) {
1113
+ async forward(transport, request, issuer, maxBytes = defaultMessageMaxBytes) {
1114
+ const headers = await NodeCoordinatorValues.forwardHeaders(request, issuer, maxBytes);
1115
+ if (request.signal.aborted)
1116
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
560
1117
  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);
1118
+ await NodeCoordinatorValues.forwardCommand(transport, request, headers);
1119
+ }
1120
+ else if (request.kind === "query") {
1121
+ const response = await transport.unary(QueryService.method.read, request.signal, request.context.timeoutMs(), headers, request.query);
562
1122
  NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
563
1123
  request.response = response.message;
564
1124
  }
565
1125
  else {
566
- const response = await transport.unary(QueryService.method.read, request.context.signal, request.context.timeoutMs(), NodeCoordinatorValues.applicationHeaders(request.context.requestHeader), request.query);
1126
+ const response = await transport.unary(QueryService.method.execute, request.signal, request.context.timeoutMs(), headers, request.query);
567
1127
  NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
568
1128
  request.response = response.message;
569
1129
  }
570
1130
  return new Uint8Array();
571
1131
  },
1132
+ /**
1133
+ * Invokes the Command branch and retains its native response metadata.
1134
+ *
1135
+ * @param transport The selected native child transport.
1136
+ * @param request The admitted Command and response slot.
1137
+ * @param headers Operation-bound verified-reader metadata.
1138
+ * @returns Completion after assigning the native response.
1139
+ */
1140
+ async forwardCommand(transport, request, headers) {
1141
+ const response = await transport.unary(CommandService.method.post, request.signal, request.context.timeoutMs(), headers, request.command);
1142
+ NodeCoordinatorValues.responseMetadata(response.header, response.trailer, request.context);
1143
+ request.response = response.message;
1144
+ },
1145
+ /**
1146
+ * Encodes verified reader facts into one exact native unary operation.
1147
+ *
1148
+ * @param request The request being processed.
1149
+ * @param issuer The configured forwarding proof producer.
1150
+ * @param maxBytes The existing native message-size bound.
1151
+ * @returns The operation-bound forwarding headers.
1152
+ */
1153
+ async forwardHeaders(request, issuer, maxBytes) {
1154
+ const service = request.kind === "command" ? "spine.client.CommandService" : "spine.client.QueryService";
1155
+ const method = request.kind === "command" ? "Post" : request.kind === "query" ? "Read" : "Execute";
1156
+ const value = request.kind === "command"
1157
+ ? toBinary(CommandService.method.post.input, request.command)
1158
+ : request.kind === "query"
1159
+ ? toBinary(QuerySchema, request.query)
1160
+ : toBinary(AppQuerySchema, request.query);
1161
+ const headers = NodeCoordinatorValues.applicationHeaders(request.context.requestHeader);
1162
+ if (issuer === undefined &&
1163
+ (request.reader.principal !== undefined || request.reader.authorityFacts !== undefined))
1164
+ throw new ConnectError("Managed reader forwarding is unavailable.", Code.Internal);
1165
+ if (issuer !== undefined)
1166
+ for (const [name, entry] of Object.entries(await ReaderForwarding.seal(service, method, value, request.reader, issuer, maxBytes)))
1167
+ headers.set(name, entry);
1168
+ return headers;
1169
+ },
572
1170
  /**
573
1171
  * Streams validated updates from one native child subscription.
574
1172
  *
@@ -579,25 +1177,61 @@ const NodeCoordinatorValues = Object.freeze({
579
1177
  * @param updates The logical update sink.
580
1178
  * @param signal Cancels child activation.
581
1179
  * @returns Completion after the child stream closes.
1180
+ * @param reader The verified reader facts for this operation.
1181
+ * @param issuer The configured forwarding proof producer.
1182
+ * @param maxBytes The existing native message-size bound.
582
1183
  */
583
- async activateChild(transport, member, members, child, updates, signal) {
1184
+ async activateChild(transport, member, members, child, updates, signal, reader, issuer, maxBytes = defaultMessageMaxBytes) {
584
1185
  const subscription = fromBinary(SubscriptionSchema, child);
1186
+ const headers = await NodeCoordinatorValues.readerHeaders("Activate", child, reader, issuer, maxBytes);
1187
+ if (signal.aborted)
1188
+ throw new ConnectError("Reader operation cancelled.", Code.Canceled);
585
1189
  members?.onChildSubscriptionActivated?.(member, subscription);
586
1190
  for await (const update of createClient(SubscriptionService, transport).activate(subscription, {
587
1191
  signal,
1192
+ ...(headers === undefined ? {} : { headers }),
588
1193
  })) {
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.");
1194
+ NodeCoordinatorValues.checkChildUpdate(subscription, update);
596
1195
  await updates(toBinary(SubscriptionUpdateSchema, update));
597
1196
  }
598
1197
  },
599
1198
  /**
600
- * Copies backend response metadata into the public context.
1199
+ * Rejects a native update for a different child ID or Topic.
1200
+ *
1201
+ * @param subscription The subscription definition being processed.
1202
+ * @param update Native subscription update.
1203
+ */
1204
+ checkChildUpdate(subscription, update) {
1205
+ const topic = subscription.topic;
1206
+ const updateTopic = update.subscription?.topic;
1207
+ if (update.subscription?.id?.value !== subscription.id?.value ||
1208
+ topic === undefined ||
1209
+ updateTopic === undefined ||
1210
+ !equals(TopicSchema, updateTopic, topic))
1211
+ throw new Error("Backend child update does not match its subscription.");
1212
+ },
1213
+ /**
1214
+ * Encodes one verified reader against the exact child subscription RPC.
1215
+ *
1216
+ * @param method The canonical RPC method name.
1217
+ * @param value Canonical child subscription RPC bytes.
1218
+ * @param reader The verified reader facts for this operation.
1219
+ * @param issuer The configured forwarding proof producer.
1220
+ * @param maxBytes The existing native message-size bound.
1221
+ * @returns The canonical reader assertion headers.
1222
+ */
1223
+ async readerHeaders(method, value, reader, issuer, maxBytes) {
1224
+ if (reader === undefined)
1225
+ return undefined;
1226
+ if (issuer === undefined) {
1227
+ if (reader.principal !== undefined || reader.authorityFacts !== undefined)
1228
+ throw new ConnectError("Managed reader forwarding is unavailable.", Code.Internal);
1229
+ return undefined;
1230
+ }
1231
+ return new Headers(await ReaderForwarding.seal("spine.client.SubscriptionService", method, value, reader, issuer, maxBytes));
1232
+ },
1233
+ /**
1234
+ * Snapshots backend response metadata into the public context.
601
1235
  *
602
1236
  * @param headers Headers received from the backend.
603
1237
  * @param trailers Trailers received from the backend.
@@ -610,7 +1244,7 @@ const NodeCoordinatorValues = Object.freeze({
610
1244
  context.responseTrailer.append(name, value);
611
1245
  },
612
1246
  /**
613
- * Copies application headers for forwarding.
1247
+ * Snapshots application headers for forwarding.
614
1248
  *
615
1249
  * @param headers Headers received from the backend.
616
1250
  * @returns Forwardable application headers.
@@ -622,7 +1256,9 @@ const NodeCoordinatorValues = Object.freeze({
622
1256
  name === "te" ||
623
1257
  name === "user-agent" ||
624
1258
  name.startsWith("grpc-") ||
625
- name.startsWith("connect-"))
1259
+ name.startsWith("connect-") ||
1260
+ name === "spine-internal-reader" ||
1261
+ name === "spine-internal-reader-proof")
626
1262
  forwarded.delete(name);
627
1263
  }
628
1264
  return forwarded;