@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.
- package/README.md +104 -16
- package/REFERENCE.md +486 -57
- package/dist/agent/agent-history.d.ts +19 -3
- package/dist/agent/agent-history.d.ts.map +1 -1
- package/dist/agent/agent-history.js +41 -10
- package/dist/agent/agent-history.js.map +1 -1
- package/dist/agent/agent-live-ai-runtime.d.ts +1 -1
- package/dist/agent/agent-live-ai-runtime.js +1 -1
- package/dist/agent/agent-live-budget.d.ts +1 -1
- package/dist/agent/agent-live-budget.js +1 -1
- package/dist/agent/agent-live-read-runtime.d.ts +13 -2
- package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
- package/dist/agent/agent-live-read-runtime.js +6 -5
- package/dist/agent/agent-live-read-runtime.js.map +1 -1
- package/dist/browser/browser-admission.d.ts +47 -0
- package/dist/browser/browser-admission.d.ts.map +1 -0
- package/dist/browser/browser-admission.js +200 -0
- package/dist/browser/browser-admission.js.map +1 -0
- package/dist/browser/browser-server.d.ts +171 -1
- package/dist/browser/browser-server.d.ts.map +1 -1
- package/dist/browser/browser-server.js +1027 -303
- package/dist/browser/browser-server.js.map +1 -1
- package/dist/browser/durable-subscription-bindings.d.ts +5 -0
- package/dist/browser/durable-subscription-bindings.d.ts.map +1 -1
- package/dist/browser/durable-subscription-bindings.js +226 -38
- package/dist/browser/durable-subscription-bindings.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/options.d.ts +53 -2
- package/dist/browser/options.d.ts.map +1 -1
- package/dist/bus/command-bus.d.ts +9 -1
- package/dist/bus/command-bus.d.ts.map +1 -1
- package/dist/bus/command-bus.js +75 -10
- package/dist/bus/command-bus.js.map +1 -1
- package/dist/bus/command-errors.d.ts +20 -4
- package/dist/bus/command-errors.d.ts.map +1 -1
- package/dist/bus/command-errors.js +47 -7
- package/dist/bus/command-errors.js.map +1 -1
- package/dist/bus/event-bus.d.ts +9 -1
- package/dist/bus/event-bus.d.ts.map +1 -1
- package/dist/bus/event-bus.js +136 -9
- package/dist/bus/event-bus.js.map +1 -1
- package/dist/context/bounded-context.d.ts +59 -39
- package/dist/context/bounded-context.d.ts.map +1 -1
- package/dist/context/bounded-context.js +277 -262
- package/dist/context/bounded-context.js.map +1 -1
- package/dist/context/entity-inbox.d.ts +12 -17
- package/dist/context/entity-inbox.d.ts.map +1 -1
- package/dist/context/entity-inbox.js +106 -119
- package/dist/context/entity-inbox.js.map +1 -1
- package/dist/context/local-inbox-handoff.d.ts +13 -13
- package/dist/context/local-inbox-handoff.d.ts.map +1 -1
- package/dist/context/local-inbox-handoff.js +60 -32
- package/dist/context/local-inbox-handoff.js.map +1 -1
- package/dist/context/persistent-tenant-index.d.ts +81 -0
- package/dist/context/persistent-tenant-index.d.ts.map +1 -0
- package/dist/context/persistent-tenant-index.js +270 -0
- package/dist/context/persistent-tenant-index.js.map +1 -0
- package/dist/context/projection-handoff.d.ts +9 -9
- package/dist/context/projection-handoff.d.ts.map +1 -1
- package/dist/context/projection-handoff.js +31 -23
- package/dist/context/projection-handoff.js.map +1 -1
- package/dist/context/tenant-index.d.ts +7 -6
- package/dist/context/tenant-index.d.ts.map +1 -1
- package/dist/context/tenant-index.js +2 -188
- package/dist/context/tenant-index.js.map +1 -1
- package/dist/context/tenant-unknown-error.d.ts +2 -2
- package/dist/context/tenant-unknown-error.d.ts.map +1 -1
- package/dist/context/tenant-unknown-error.js +4 -1
- package/dist/context/tenant-unknown-error.js.map +1 -1
- package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
- package/dist/delivery/delivery-supervisor.js +12 -3
- package/dist/delivery/delivery-supervisor.js.map +1 -1
- package/dist/delivery/delivery.d.ts +4 -3
- package/dist/delivery/delivery.d.ts.map +1 -1
- package/dist/delivery/delivery.js +82 -34
- package/dist/delivery/delivery.js.map +1 -1
- package/dist/delivery/reception-observation.d.ts +3 -3
- package/dist/delivery/reception-observation.js +2 -2
- package/dist/entity/entity.d.ts +59 -0
- package/dist/entity/entity.d.ts.map +1 -1
- package/dist/entity/entity.js +60 -0
- package/dist/entity/entity.js.map +1 -1
- package/dist/handler/app-query-handler.d.ts +60 -0
- package/dist/handler/app-query-handler.d.ts.map +1 -0
- package/dist/handler/app-query-handler.js +23 -0
- package/dist/handler/app-query-handler.js.map +1 -0
- package/dist/handler/generated-registry-discovery.d.ts +6 -0
- package/dist/handler/generated-registry-discovery.d.ts.map +1 -1
- package/dist/handler/generated-registry-discovery.js +71 -2
- package/dist/handler/generated-registry-discovery.js.map +1 -1
- package/dist/handler/handler-decorators.d.ts +8 -29
- package/dist/handler/handler-decorators.d.ts.map +1 -1
- package/dist/handler/handler-decorators.js +33 -210
- package/dist/handler/handler-decorators.js.map +1 -1
- package/dist/handler/spine-metadata-inventory.d.ts +99 -0
- package/dist/handler/spine-metadata-inventory.d.ts.map +1 -0
- package/dist/handler/spine-metadata-inventory.js +162 -0
- package/dist/handler/spine-metadata-inventory.js.map +1 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/repository/command-routing.d.ts +1 -1
- package/dist/repository/command-routing.js +1 -1
- package/dist/repository/event-routing.d.ts +1 -1
- package/dist/repository/event-routing.js +1 -1
- package/dist/repository/repository.d.ts +61 -26
- package/dist/repository/repository.d.ts.map +1 -1
- package/dist/repository/repository.js +612 -306
- package/dist/repository/repository.js.map +1 -1
- package/dist/repository/state-update-routing.d.ts +1 -1
- package/dist/repository/state-update-routing.js +1 -1
- package/dist/runtime/signal-publisher.d.ts +23 -8
- package/dist/runtime/signal-publisher.d.ts.map +1 -1
- package/dist/runtime/signal-publisher.js +11 -8
- package/dist/runtime/signal-publisher.js.map +1 -1
- package/dist/server/managed-server-application.d.ts +12 -11
- package/dist/server/managed-server-application.d.ts.map +1 -1
- package/dist/server/managed-server-application.js +41 -25
- package/dist/server/managed-server-application.js.map +1 -1
- package/dist/server/node-coordinator.d.ts +18 -5
- package/dist/server/node-coordinator.d.ts.map +1 -1
- package/dist/server/node-coordinator.js +744 -108
- package/dist/server/node-coordinator.js.map +1 -1
- package/dist/server/reserved-spine-rpc-paths.json +1 -0
- package/dist/server/server-environment.d.ts +23 -5
- package/dist/server/server-environment.d.ts.map +1 -1
- package/dist/server/server-environment.js +89 -24
- package/dist/server/server-environment.js.map +1 -1
- package/dist/server/server.d.ts +45 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +112 -3
- package/dist/server/server.js.map +1 -1
- package/dist/services/app-query-execution.d.ts +53 -0
- package/dist/services/app-query-execution.d.ts.map +1 -0
- package/dist/services/app-query-execution.js +408 -0
- package/dist/services/app-query-execution.js.map +1 -0
- package/dist/services/native-operation.d.ts +34 -0
- package/dist/services/native-operation.d.ts.map +1 -0
- package/dist/services/native-operation.js +77 -0
- package/dist/services/native-operation.js.map +1 -0
- package/dist/services/query-reader.d.ts +32 -7
- package/dist/services/query-reader.d.ts.map +1 -1
- package/dist/services/query-reader.js +22 -2
- package/dist/services/query-reader.js.map +1 -1
- package/dist/services/read-request-status.d.ts +65 -0
- package/dist/services/read-request-status.d.ts.map +1 -0
- package/dist/services/read-request-status.js +151 -0
- package/dist/services/read-request-status.js.map +1 -0
- package/dist/services/spine-services.d.ts +42 -7
- package/dist/services/spine-services.d.ts.map +1 -1
- package/dist/services/spine-services.js +1005 -175
- package/dist/services/spine-services.js.map +1 -1
- package/dist/services/trusted-reader-ingress.d.ts +110 -0
- package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
- package/dist/services/trusted-reader-ingress.js +102 -0
- package/dist/services/trusted-reader-ingress.js.map +1 -0
- package/dist/spi/handler-registry.d.ts +2 -0
- package/dist/spi/handler-registry.d.ts.map +1 -1
- package/dist/spi/handler-registry.js +1 -1
- package/dist/spi/handler-registry.js.map +1 -1
- package/dist/stand/read-side-filter-engine.d.ts +46 -0
- package/dist/stand/read-side-filter-engine.d.ts.map +1 -0
- package/dist/stand/read-side-filter-engine.js +356 -0
- package/dist/stand/read-side-filter-engine.js.map +1 -0
- package/dist/stand/read-side-filter.d.ts +247 -0
- package/dist/stand/read-side-filter.d.ts.map +1 -0
- package/dist/stand/read-side-filter.js +39 -0
- package/dist/stand/read-side-filter.js.map +1 -0
- package/dist/stand/read-side-message.d.ts +66 -0
- package/dist/stand/read-side-message.d.ts.map +1 -0
- package/dist/stand/read-side-message.js +167 -0
- package/dist/stand/read-side-message.js.map +1 -0
- package/dist/stand/stand-direct-delivery.d.ts +161 -0
- package/dist/stand/stand-direct-delivery.d.ts.map +1 -0
- package/dist/stand/stand-direct-delivery.js +210 -0
- package/dist/stand/stand-direct-delivery.js.map +1 -0
- package/dist/stand/stand.d.ts +81 -43
- package/dist/stand/stand.d.ts.map +1 -1
- package/dist/stand/stand.js +411 -55
- package/dist/stand/stand.js.map +1 -1
- package/dist/stand/subscription-observer.d.ts.map +1 -1
- package/dist/stand/subscription-observer.js +103 -0
- package/dist/stand/subscription-observer.js.map +1 -1
- package/dist/stand/subscription-read-filters.d.ts +40 -0
- package/dist/stand/subscription-read-filters.d.ts.map +1 -0
- package/dist/stand/subscription-read-filters.js +91 -0
- package/dist/stand/subscription-read-filters.js.map +1 -0
- package/dist/stand/subscription-reader-binding.d.ts +20 -0
- package/dist/stand/subscription-reader-binding.d.ts.map +1 -0
- package/dist/stand/subscription-reader-binding.js +56 -0
- package/dist/stand/subscription-reader-binding.js.map +1 -0
- package/dist/stand/subscription-records.d.ts +30 -3
- package/dist/stand/subscription-records.d.ts.map +1 -1
- package/dist/stand/subscription-records.js +61 -16
- package/dist/stand/subscription-records.js.map +1 -1
- package/dist/stand/subscription-registry.d.ts +49 -14
- package/dist/stand/subscription-registry.d.ts.map +1 -1
- package/dist/stand/subscription-registry.js +150 -31
- package/dist/stand/subscription-registry.js.map +1 -1
- package/dist/stand/subscription-runtime.d.ts +7 -2
- package/dist/stand/subscription-runtime.d.ts.map +1 -1
- package/dist/stand/subscription-runtime.js +131 -25
- package/dist/stand/subscription-runtime.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -14
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
* or implied. See the License for the specific language governing permissions and limitations under
|
|
12
12
|
* the License.
|
|
13
13
|
*/
|
|
14
|
+
var _a;
|
|
14
15
|
import { randomUUID } from "node:crypto";
|
|
15
16
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
16
|
-
import { clone, create, ScalarType } from "@bufbuild/protobuf";
|
|
17
|
+
import { clone, create, equals, toBinary, ScalarType, } from "@bufbuild/protobuf";
|
|
18
|
+
import { ReaderIdentities, } from "@spine-event-engine/core/spi/trusted-reader";
|
|
17
19
|
import { AnySchema, BoolValueSchema, BytesValueSchema, DoubleValueSchema, EmptySchema, FloatValueSchema, Int32ValueSchema, Int64ValueSchema, StringValueSchema, UInt32ValueSchema, UInt64ValueSchema, } from "@bufbuild/protobuf/wkt";
|
|
18
|
-
import { TypeUrls, AnyPacker } from "@spine-event-engine/core";
|
|
19
|
-
import { CommandIdSchema, TenantIdSchema, ValidationErrorSchema, VersionSchema, } from "@spine-event-engine/proto";
|
|
20
|
+
import { TypeUrls, AnyPacker, Validate, QUERY_SERVICE_MAX_RESULTS } from "@spine-event-engine/core";
|
|
21
|
+
import { CommandIdSchema, CommandValidationError as CommandValidationErrorCode, TenantIdSchema, ValidationErrorSchema, VersionSchema, } from "@spine-event-engine/proto";
|
|
20
22
|
import { ErrorSchema } from "@spine-event-engine/proto";
|
|
21
23
|
import { CommandService } from "@spine-event-engine/proto/client";
|
|
22
24
|
import { CompositeFilter_CompositeOperator, Filter_Operator, } from "@spine-event-engine/proto/client";
|
|
23
|
-
import { EntityStateWithVersionSchema as EntityStateVersionSchema, OrderBy_Direction, QueryResponseSchema, } from "@spine-event-engine/proto/client";
|
|
25
|
+
import { EntityStateWithVersionSchema as EntityStateVersionSchema, OrderBy_Direction, QueryResponseSchema, QuerySchema, } from "@spine-event-engine/proto/client";
|
|
24
26
|
import { QueryService } from "@spine-event-engine/proto/client";
|
|
25
27
|
import { SubscriptionIdSchema, SubscriptionSchema, SubscriptionUpdateSchema, TopicSchema, } from "@spine-event-engine/proto/client";
|
|
26
28
|
import { SubscriptionService } from "@spine-event-engine/proto/client";
|
|
@@ -36,12 +38,22 @@ import { CommandValidationError } from "../bus/command-errors.js";
|
|
|
36
38
|
import { TransitionValidationError } from "../repository/command-errors.js";
|
|
37
39
|
import {} from "../stand/stand.js";
|
|
38
40
|
import { QueryReader } from "./query-reader.js";
|
|
41
|
+
import { AppQueryExecution } from "./app-query-execution.js";
|
|
42
|
+
import { ReadRequestStatus } from "./read-request-status.js";
|
|
43
|
+
import { TrustedReaderIngress, } from "./trusted-reader-ingress.js";
|
|
44
|
+
import { NativeOperation } from "./native-operation.js";
|
|
39
45
|
import { RegisteredTargets } from "./registered-targets.js";
|
|
40
46
|
import { emitServerError, emitServerWarning } from "../server/server-log.js";
|
|
47
|
+
import { ServerEnvironment } from "../server/server-environment.js";
|
|
41
48
|
import { managedChildSubscriptionAccess } from "../server/managed-child-subscription.js";
|
|
42
49
|
import {} from "../stand/subscription-registry.js";
|
|
50
|
+
import { ReadSideFilterEngine } from "../stand/read-side-filter-engine.js";
|
|
51
|
+
import { SubscriptionReadFilters, } from "../stand/subscription-read-filters.js";
|
|
43
52
|
const serviceLoggers = new WeakMap();
|
|
44
53
|
const serviceInstances = new WeakSet();
|
|
54
|
+
const serviceAppQueries = new WeakMap();
|
|
55
|
+
const serviceReadFilters = new WeakMap();
|
|
56
|
+
const serviceAdmissions = new WeakMap();
|
|
45
57
|
/**
|
|
46
58
|
* Small route registrar for the first public Spine gRPC service slice.
|
|
47
59
|
*
|
|
@@ -58,7 +70,8 @@ const serviceInstances = new WeakSet();
|
|
|
58
70
|
* Queries allow at most 100 IDs, 16 simple filters, 8 composites, and 8 orderings.
|
|
59
71
|
* Use proto column names such as `open_task_count`, not generated TS local names
|
|
60
72
|
* such as `openTaskCount`. Invalid columns, values, operators, and query shapes
|
|
61
|
-
*
|
|
73
|
+
* reject with structured `INVALID_ARGUMENT` and `INVALID_QUERY` details before
|
|
74
|
+
* Stand storage is read.
|
|
62
75
|
*
|
|
63
76
|
* `SubscriptionService.Subscribe` accepts known state targets with
|
|
64
77
|
* `include_all` or validated ID/field filters and known event targets exposed
|
|
@@ -74,7 +87,9 @@ const serviceInstances = new WeakSet();
|
|
|
74
87
|
* rejected-command payload forms and stack trace from their client-facing
|
|
75
88
|
* context.
|
|
76
89
|
* Application code remains on generated domain event messages through handler
|
|
77
|
-
* dispatch. Unknown or
|
|
90
|
+
* dispatch. Unknown or expired activation IDs fail with `NOT_FOUND` and
|
|
91
|
+
* `UNKNOWN_SUBSCRIPTION` details. A duplicate active activation completes
|
|
92
|
+
* without another update stream.
|
|
78
93
|
* Cancellation of unknown or already-cleaned IDs returns OK after admission to
|
|
79
94
|
* the bounded unknown-removal pool.
|
|
80
95
|
*/
|
|
@@ -87,8 +102,11 @@ export class SpineServices {
|
|
|
87
102
|
#activationTails = new Map();
|
|
88
103
|
#removals = new Map();
|
|
89
104
|
#unknownRemovals = new Set();
|
|
90
|
-
#
|
|
105
|
+
#queueCapacity;
|
|
91
106
|
#subscriptionLimit;
|
|
107
|
+
#readerAdmission;
|
|
108
|
+
#readMaxBytes;
|
|
109
|
+
#admissionShutdown = new AbortController();
|
|
92
110
|
/**
|
|
93
111
|
* Creates service adapters over built bounded contexts.
|
|
94
112
|
*
|
|
@@ -96,9 +114,12 @@ export class SpineServices {
|
|
|
96
114
|
*/
|
|
97
115
|
constructor(options) {
|
|
98
116
|
serviceInstances.add(this);
|
|
117
|
+
serviceAdmissions.set(this, this.#admissionShutdown);
|
|
99
118
|
this.#contexts = Object.freeze([...options.contexts]);
|
|
100
|
-
this.#
|
|
119
|
+
this.#queueCapacity = ServerEnvironment.instance().subscriptionQueueCapacity;
|
|
101
120
|
this.#subscriptionLimit = ServiceValues.subscriptionLimit(options.subscriptionLimit ?? ServiceValues.defaultSubscriptionLimit);
|
|
121
|
+
this.#readerAdmission = options.readerAdmission;
|
|
122
|
+
this.#readMaxBytes = options.readMaxBytes ?? 4_194_304;
|
|
102
123
|
this.#registerSignalRoutes();
|
|
103
124
|
this.#registerStateRoutes(new RegisteredTargets(this.#contexts));
|
|
104
125
|
}
|
|
@@ -151,18 +172,205 @@ export class SpineServices {
|
|
|
151
172
|
* @returns Returns the registered router.
|
|
152
173
|
*/
|
|
153
174
|
register(router) {
|
|
175
|
+
this.#registerUnary(router);
|
|
176
|
+
this.#registerSubscriptions(router);
|
|
177
|
+
return router;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Registers admitted Command and Query routes.
|
|
181
|
+
*
|
|
182
|
+
* @param router Connect service router.
|
|
183
|
+
*/
|
|
184
|
+
#registerUnary(router) {
|
|
154
185
|
router.service(CommandService, {
|
|
155
|
-
post: (command) => this.#post(command),
|
|
186
|
+
post: (command, context) => this.#runAdmitted("spine.client.CommandService", "Post", toBinary(CommandService.method.post.input, command), context, () => this.#post(command)),
|
|
156
187
|
});
|
|
157
188
|
router.service(QueryService, {
|
|
158
|
-
read: (query) =>
|
|
189
|
+
read: (query, context) => {
|
|
190
|
+
const detached = clone(QuerySchema, query);
|
|
191
|
+
return this.#runAdmitted("spine.client.QueryService", "Read", toBinary(QuerySchema, detached), context, (reader, signal) => this.#read(detached, reader, signal));
|
|
192
|
+
},
|
|
193
|
+
execute: (query, context) => {
|
|
194
|
+
const detached = clone(QueryService.method.execute.input, query);
|
|
195
|
+
return this.#runAdmitted("spine.client.QueryService", "Execute", toBinary(QueryService.method.execute.input, detached), context, (reader, signal) => this.#executeAppQuery(detached, reader, signal));
|
|
196
|
+
},
|
|
159
197
|
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Executes a detached App Query with facts established by a trusted admission port.
|
|
201
|
+
*
|
|
202
|
+
* @param query Captured application query envelope.
|
|
203
|
+
* @param reader Verified current invocation facts.
|
|
204
|
+
* @param signal Current invocation cancellation, when available.
|
|
205
|
+
* @returns One paired response from the Server-selected handler.
|
|
206
|
+
*/
|
|
207
|
+
#executeAppQuery(query, reader, signal) {
|
|
208
|
+
const queries = serviceAppQueries.get(this);
|
|
209
|
+
if (queries === undefined)
|
|
210
|
+
return Promise.reject(new ConnectError("App Query execution is unavailable.", Code.Unimplemented));
|
|
211
|
+
return queries.execute(query, reader, signal);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Registers reader-bound subscription routes.
|
|
215
|
+
*
|
|
216
|
+
* @param router Connect service router.
|
|
217
|
+
*/
|
|
218
|
+
#registerSubscriptions(router) {
|
|
160
219
|
router.service(SubscriptionService, {
|
|
161
|
-
subscribe: (topic) => this.#
|
|
162
|
-
activate: (subscription) => this.#
|
|
163
|
-
cancel: (subscription) => this.#
|
|
220
|
+
subscribe: (topic, context) => this.#subscribeAdmitted(topic, context),
|
|
221
|
+
activate: (subscription, context) => this.#activateAdmitted(subscription, context),
|
|
222
|
+
cancel: (subscription, context) => this.#cancelAdmitted(subscription, context),
|
|
164
223
|
});
|
|
165
|
-
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Keeps direct in-process Subscribe validation synchronous while admitting native peers.
|
|
227
|
+
*
|
|
228
|
+
* @param topic Requested subscription topic.
|
|
229
|
+
* @param context Native transport context, absent for direct in-process calls.
|
|
230
|
+
* @returns Subscription or admitted native operation promise.
|
|
231
|
+
*/
|
|
232
|
+
#subscribeAdmitted(topic, context) {
|
|
233
|
+
if (context === undefined)
|
|
234
|
+
return this.#subscribe(topic, { kind: "anonymous" });
|
|
235
|
+
return this.#subscribeNative(topic, context);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Compensates a native Subscribe if its reader ends after registry creation.
|
|
239
|
+
*
|
|
240
|
+
* @param topic Requested subscription topic.
|
|
241
|
+
* @param context Native transport context.
|
|
242
|
+
* @returns Created subscription only while admission remains live.
|
|
243
|
+
*/
|
|
244
|
+
async #subscribeNative(topic, context) {
|
|
245
|
+
const operation = new NativeOperation(
|
|
246
|
+
// Direct test contexts can omit the transport signal.
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
248
|
+
context.signal ?? new AbortController().signal, this.#admissionShutdown.signal);
|
|
249
|
+
try {
|
|
250
|
+
const admission = await this.#admit("spine.client.SubscriptionService", "Subscribe", toBinary(TopicSchema, topic), context, operation.signal);
|
|
251
|
+
operation.link(admission.invalidated);
|
|
252
|
+
operation.check();
|
|
253
|
+
const reader = ReaderIdentities.from(admission.reader);
|
|
254
|
+
const subscription = await this.#subscribe(topic, reader);
|
|
255
|
+
try {
|
|
256
|
+
operation.check();
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return await this.#compensateSubscribe(subscription, reader, error);
|
|
260
|
+
}
|
|
261
|
+
return subscription;
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
operation.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Removes a committed definition after a failed final admission check.
|
|
269
|
+
*
|
|
270
|
+
* @param subscription Created definition.
|
|
271
|
+
* @param reader Admitted reader identity.
|
|
272
|
+
* @param error Final admission failure.
|
|
273
|
+
* @returns Rejected completion after conditional cleanup.
|
|
274
|
+
*/
|
|
275
|
+
async #compensateSubscribe(subscription, reader, error) {
|
|
276
|
+
try {
|
|
277
|
+
await this.#cancel(subscription, reader);
|
|
278
|
+
}
|
|
279
|
+
catch (cleanup) {
|
|
280
|
+
throw new AggregateError([error, cleanup], "Native Subscribe cleanup failed.");
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Keeps direct in-process Cancel synchronous while admitting native peers.
|
|
286
|
+
*
|
|
287
|
+
* @param subscription Requested subscription definition.
|
|
288
|
+
* @param context Native transport context, absent for direct in-process calls.
|
|
289
|
+
* @returns Cancel acknowledgment or admitted native operation promise.
|
|
290
|
+
*/
|
|
291
|
+
#cancelAdmitted(subscription, context) {
|
|
292
|
+
if (context === undefined)
|
|
293
|
+
return this.#cancel(subscription, { kind: "anonymous" });
|
|
294
|
+
return this.#runAdmitted("spine.client.SubscriptionService", "Cancel", toBinary(SubscriptionSchema, subscription), context, (reader) => Promise.resolve(this.#cancel(subscription, ReaderIdentities.from(reader))));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Verifies one native reader assertion before service side effects.
|
|
298
|
+
*
|
|
299
|
+
* @param service Canonical RPC service.
|
|
300
|
+
* @param method Canonical RPC method.
|
|
301
|
+
* @param bytes Canonical request bytes.
|
|
302
|
+
* @param context Native transport context admitted through Connect.
|
|
303
|
+
* @param signal Host-managed operation lifetime.
|
|
304
|
+
* @returns Captured admission facts and invalidation.
|
|
305
|
+
*/
|
|
306
|
+
async #admit(service, method, bytes, context, signal) {
|
|
307
|
+
return TrustedReaderIngress.admit({
|
|
308
|
+
service,
|
|
309
|
+
method,
|
|
310
|
+
request: bytes,
|
|
311
|
+
headers: context.requestHeader,
|
|
312
|
+
peer: context,
|
|
313
|
+
signal,
|
|
314
|
+
maxBytes: this.#readMaxBytes,
|
|
315
|
+
...this.#readerAdmission,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Keeps one native unary operation alive through authentication and work.
|
|
320
|
+
*
|
|
321
|
+
* @typeParam T Service result.
|
|
322
|
+
* @param service Canonical RPC service.
|
|
323
|
+
* @param method Canonical RPC method.
|
|
324
|
+
* @param bytes Canonical request bytes.
|
|
325
|
+
* @param context Native transport context.
|
|
326
|
+
* @param work Admitted service action.
|
|
327
|
+
* @returns Result while the admission remains valid.
|
|
328
|
+
*/
|
|
329
|
+
async #runAdmitted(service, method, bytes, context, work) {
|
|
330
|
+
if (context === undefined)
|
|
331
|
+
return work(Object.freeze({}), new AbortController().signal);
|
|
332
|
+
const operation = new NativeOperation(
|
|
333
|
+
// Direct test contexts can omit the transport signal.
|
|
334
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
335
|
+
context.signal ?? new AbortController().signal, this.#admissionShutdown.signal);
|
|
336
|
+
try {
|
|
337
|
+
const admission = await this.#admit(service, method, bytes, context, operation.signal);
|
|
338
|
+
operation.link(admission.invalidated);
|
|
339
|
+
operation.check();
|
|
340
|
+
const result = await work(admission.reader, operation.signal);
|
|
341
|
+
operation.check();
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
operation.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Verifies activation before entering the subscription lifecycle generator.
|
|
350
|
+
*
|
|
351
|
+
* @param subscription Native activation request.
|
|
352
|
+
* @param context Native transport context.
|
|
353
|
+
* @returns Admitted subscription update stream.
|
|
354
|
+
*/
|
|
355
|
+
async *#activateAdmitted(subscription, context) {
|
|
356
|
+
const captured = clone(SubscriptionSchema, subscription);
|
|
357
|
+
if (context === undefined) {
|
|
358
|
+
yield* this.#activate(captured, Object.freeze({}));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const operation = new NativeOperation(
|
|
362
|
+
// Direct test contexts can omit the transport signal.
|
|
363
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
364
|
+
context.signal ?? new AbortController().signal, this.#admissionShutdown.signal);
|
|
365
|
+
try {
|
|
366
|
+
const admission = await this.#admit("spine.client.SubscriptionService", "Activate", toBinary(SubscriptionSchema, captured), context, operation.signal);
|
|
367
|
+
operation.link(admission.invalidated);
|
|
368
|
+
operation.check();
|
|
369
|
+
yield* this.#activate(captured, admission.reader, operation.signal);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
operation.close();
|
|
373
|
+
}
|
|
166
374
|
}
|
|
167
375
|
/**
|
|
168
376
|
* Resolves a public Command route before attempting dispatch.
|
|
@@ -172,13 +380,28 @@ export class SpineServices {
|
|
|
172
380
|
*/
|
|
173
381
|
async #post(command) {
|
|
174
382
|
const messageId = command.id && AnyPacker.pack(CommandIdSchema, command.id);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
383
|
+
try {
|
|
384
|
+
CommandValidationError.checkEnvelope(command);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const failure = ServiceValues.commandPostError(error);
|
|
388
|
+
return ServiceValues.commandErrorAck(messageId, failure.type, failure.message, failure.code, failure.details);
|
|
389
|
+
}
|
|
390
|
+
const typeUrl = command.message.typeUrl;
|
|
178
391
|
const admission = this.#commandAdmission(command, messageId, typeUrl);
|
|
179
392
|
if ("ack" in admission)
|
|
180
393
|
return admission.ack;
|
|
181
|
-
|
|
394
|
+
return this.#dispatchCommand(admission.route, command, messageId);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Dispatches an admitted Command and maps its completed or failed outcome.
|
|
398
|
+
*
|
|
399
|
+
* @param route Selected Command route.
|
|
400
|
+
* @param command Validated Command envelope.
|
|
401
|
+
* @param messageId Packed ID for the acknowledgement.
|
|
402
|
+
* @returns The completed Command acknowledgement.
|
|
403
|
+
*/
|
|
404
|
+
async #dispatchCommand(route, command, messageId) {
|
|
182
405
|
try {
|
|
183
406
|
await route.context.commandBus().post(command);
|
|
184
407
|
return create(AckSchema, {
|
|
@@ -193,7 +416,7 @@ export class SpineServices {
|
|
|
193
416
|
this.#logCommandPostFailure(route, command, error);
|
|
194
417
|
return create(AckSchema, {
|
|
195
418
|
messageId,
|
|
196
|
-
status: ServiceValues.errorStatus(postError.type, postError.message, postError.details),
|
|
419
|
+
status: ServiceValues.errorStatus(postError.type, postError.message, postError.details, postError.code),
|
|
197
420
|
});
|
|
198
421
|
}
|
|
199
422
|
}
|
|
@@ -209,7 +432,7 @@ export class SpineServices {
|
|
|
209
432
|
const route = this.#commandRoutes.get(typeUrl);
|
|
210
433
|
if (route === undefined) {
|
|
211
434
|
return {
|
|
212
|
-
ack: ServiceValues.commandErrorAck(messageId, "
|
|
435
|
+
ack: ServiceValues.commandErrorAck(messageId, "spine.core.CommandValidationError", "No bounded context accepted the command.", CommandValidationErrorCode.UNSUPPORTED_COMMAND),
|
|
213
436
|
};
|
|
214
437
|
}
|
|
215
438
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, ServiceValues.commandTenant(command), "command");
|
|
@@ -246,19 +469,23 @@ export class SpineServices {
|
|
|
246
469
|
* Validates a public state query before reading its registered Stand.
|
|
247
470
|
*
|
|
248
471
|
* @param query Public query request.
|
|
249
|
-
* @
|
|
472
|
+
* @param reader Verified facts from current admission.
|
|
473
|
+
* @param signal Current request cancellation.
|
|
474
|
+
* @returns States for an admitted Query.
|
|
475
|
+
* @throws ConnectError for invalid input or a sanitized read failure.
|
|
250
476
|
*/
|
|
251
|
-
async #read(query) {
|
|
477
|
+
async #read(query, reader, signal) {
|
|
478
|
+
ReadRequestStatus.check("query", () => Validate.check(QuerySchema, query));
|
|
252
479
|
const target = query.target;
|
|
253
480
|
const route = this.#readRoute(target);
|
|
254
481
|
if (target === undefined || route === undefined) {
|
|
255
|
-
|
|
482
|
+
throw ReadRequestStatus.invalid("query", true, "No bounded context owns query target.");
|
|
256
483
|
}
|
|
257
484
|
const queryError = ServiceValues.validateReadQuery(query, target, route);
|
|
258
485
|
if (queryError !== undefined) {
|
|
259
|
-
|
|
486
|
+
throw ReadRequestStatus.invalid("query", false, queryError.message);
|
|
260
487
|
}
|
|
261
|
-
return this.#readValidated(query, target, route);
|
|
488
|
+
return this.#readValidated(query, target, route, reader, signal);
|
|
262
489
|
}
|
|
263
490
|
/**
|
|
264
491
|
* Resolves the effective tenant and executes an already validated read.
|
|
@@ -266,49 +493,96 @@ export class SpineServices {
|
|
|
266
493
|
* @param query Validated public query.
|
|
267
494
|
* @param target Registered query target.
|
|
268
495
|
* @param route Route containing the target Stand and schema.
|
|
269
|
-
* @
|
|
496
|
+
* @param reader Verified current invocation facts.
|
|
497
|
+
* @param signal Current request cancellation.
|
|
498
|
+
* @returns States when tenant admission and storage read succeed.
|
|
270
499
|
*/
|
|
271
|
-
async #readValidated(query, target, route) {
|
|
500
|
+
async #readValidated(query, target, route, reader, signal) {
|
|
272
501
|
const tenantId = ServiceValues.tenantValue(query.context?.tenantId);
|
|
273
502
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, tenantId, "query");
|
|
274
503
|
if (tenantError !== undefined) {
|
|
275
|
-
|
|
504
|
+
throw ReadRequestStatus.tenant(tenantError.type, tenantError.message);
|
|
276
505
|
}
|
|
277
506
|
try {
|
|
278
507
|
const effective = EffectiveTenants.current(route.context.isMultitenant, tenantId);
|
|
279
508
|
const destination = EffectiveTenants.destination(effective, route.context.isMultitenant);
|
|
280
|
-
return await this.#query(route, ServiceValues.createReadPlan(target, query, route), destination);
|
|
509
|
+
return await this.#query(route, ServiceValues.createReadPlan(target, query, route), destination, query, reader, signal);
|
|
281
510
|
}
|
|
282
511
|
catch (error) {
|
|
283
512
|
if (error instanceof TenantUnknownError)
|
|
284
513
|
throw TenantStatuses.unknown(error);
|
|
285
|
-
|
|
514
|
+
throw ReadRequestStatus.technical(error, "Query read failed.");
|
|
286
515
|
}
|
|
287
516
|
}
|
|
288
|
-
|
|
517
|
+
/**
|
|
518
|
+
* Finds the registered Entity state route for a query target.
|
|
519
|
+
*
|
|
520
|
+
* @param target Declared query or Topic target.
|
|
521
|
+
* @returns The matching read route, if present.
|
|
522
|
+
*/ #readRoute(target) {
|
|
289
523
|
return target === undefined ? undefined : this.#stateRoutes.get(target.type);
|
|
290
524
|
}
|
|
291
|
-
|
|
292
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Executes a validated Entity state read through its registered route.
|
|
527
|
+
*
|
|
528
|
+
* @param route Registered target route with schema and tenancy.
|
|
529
|
+
* @param plan Validated Entity read plan.
|
|
530
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
531
|
+
* @param query Validated public query with effective actor data.
|
|
532
|
+
* @param reader Verified current invocation facts.
|
|
533
|
+
* @param signal Current request cancellation.
|
|
534
|
+
* @returns The Entity query response.
|
|
535
|
+
*/ async #query(route, plan, tenantId, query, reader, signal) {
|
|
536
|
+
const facts = {
|
|
537
|
+
operation: { kind: "query", query },
|
|
538
|
+
...(query.context === undefined ? {} : { actorContext: query.context }),
|
|
539
|
+
...(tenantId === undefined ? {} : { effectiveTenant: tenantId }),
|
|
540
|
+
producingContext: route.context,
|
|
541
|
+
...reader,
|
|
542
|
+
...(signal === undefined ? {} : { signal }),
|
|
543
|
+
};
|
|
544
|
+
const results = await QueryReader.read(route.context.stand(), route.schema, plan, tenantId, ServiceValues.queryResultLimit, query, facts);
|
|
545
|
+
const visible = await this.#visibleStates(route, results, facts);
|
|
293
546
|
return create(QueryResponseSchema, {
|
|
294
547
|
response: ServiceValues.okResponse(),
|
|
295
|
-
message:
|
|
548
|
+
message: visible.map((result) => ServiceValues.packVersionedState(route.schema, result)),
|
|
296
549
|
});
|
|
297
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Applies this Server's outward policy after BC source policy and storage work.
|
|
553
|
+
*
|
|
554
|
+
* @param route Registered Entity result schema.
|
|
555
|
+
* @param results States returned by the producing context.
|
|
556
|
+
* @param facts Verified current Query facts.
|
|
557
|
+
* @returns Surviving states without replenishing removed records.
|
|
558
|
+
*/
|
|
559
|
+
async #visibleStates(route, results, facts) {
|
|
560
|
+
const filters = serviceReadFilters.get(this);
|
|
561
|
+
if (filters === undefined)
|
|
562
|
+
return results;
|
|
563
|
+
const visible = [];
|
|
564
|
+
for (const result of results) {
|
|
565
|
+
const state = await filters.apply(route.schema, result.state, facts);
|
|
566
|
+
if (state !== null)
|
|
567
|
+
visible.push({ ...result, state });
|
|
568
|
+
}
|
|
569
|
+
return visible;
|
|
570
|
+
}
|
|
298
571
|
/**
|
|
299
572
|
* Validates a topic synchronously and checks live membership before registry creation.
|
|
300
573
|
*
|
|
301
574
|
* @param topic Requested subscription topic.
|
|
302
575
|
* @returns Created subscription after admission.
|
|
576
|
+
* @param reader The verified reader facts for this operation.
|
|
303
577
|
*/
|
|
304
|
-
#subscribe(topic) {
|
|
578
|
+
#subscribe(topic, reader) {
|
|
579
|
+
ReadRequestStatus.check("topic", () => Validate.check(TopicSchema, topic));
|
|
305
580
|
const route = this.#subscriptionRoute(topic);
|
|
306
|
-
ServiceValues.
|
|
307
|
-
ServiceValues.createSubscriptionShape(topic, route);
|
|
581
|
+
ServiceValues.checkTopicSemantics(topic, route);
|
|
308
582
|
const tenantId = ServiceValues.topicTenant(topic);
|
|
309
583
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, tenantId, "subscription");
|
|
310
584
|
if (tenantError !== undefined) {
|
|
311
|
-
throw
|
|
585
|
+
throw ReadRequestStatus.tenant(tenantError.type, tenantError.message);
|
|
312
586
|
}
|
|
313
587
|
const subscription = create(SubscriptionSchema, {
|
|
314
588
|
id: create(SubscriptionIdSchema, { value: `s-${randomUUID()}` }),
|
|
@@ -320,46 +594,115 @@ export class SpineServices {
|
|
|
320
594
|
}
|
|
321
595
|
const registry = boundedContextAccess.subscriptionRegistry(route.context);
|
|
322
596
|
return TenantStatuses.require(route.context, tenantId, async () => {
|
|
323
|
-
await registry.create(subscription);
|
|
597
|
+
await registry.create(subscription, reader);
|
|
324
598
|
}).then(() => clone(SubscriptionSchema, subscription));
|
|
325
599
|
}
|
|
326
|
-
|
|
600
|
+
/**
|
|
601
|
+
* Verifies membership and activates a retained subscription.
|
|
602
|
+
*
|
|
603
|
+
* @param subscription The subscription definition being processed.
|
|
604
|
+
* @param reader The verified reader facts for this operation.
|
|
605
|
+
* @returns The update stream after admission.
|
|
606
|
+
*/ async *#activate(subscription, reader, signal) {
|
|
607
|
+
ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
|
|
608
|
+
ReadRequestStatus.checkLifecycleCriterion(subscription);
|
|
327
609
|
const id = subscription.id?.value;
|
|
328
610
|
if (id === undefined) {
|
|
329
611
|
return;
|
|
330
612
|
}
|
|
331
|
-
const record = await this.#activationRecord(id);
|
|
613
|
+
const record = await this.#activationRecord(id, ReaderIdentities.from(reader), signal);
|
|
332
614
|
if (record === undefined)
|
|
333
615
|
return;
|
|
616
|
+
yield* this.#streamSubscription(record, reader);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Delivers one activation in source order while honoring its cancellation.
|
|
620
|
+
*
|
|
621
|
+
* @param record Attached subscription and existing admission queue.
|
|
622
|
+
* @param reader Verified facts of this activation.
|
|
623
|
+
* @returns ACK followed by surviving filtered data updates.
|
|
624
|
+
*/
|
|
625
|
+
async *#streamSubscription(record, reader) {
|
|
334
626
|
try {
|
|
627
|
+
const facts = _a.#subscriptionFacts(record, reader);
|
|
628
|
+
if (record.delivery.closed)
|
|
629
|
+
return;
|
|
335
630
|
yield create(SubscriptionUpdateSchema, {
|
|
336
631
|
subscription: clone(SubscriptionSchema, record.subscription),
|
|
337
632
|
response: ServiceValues.okResponse(),
|
|
338
633
|
});
|
|
339
634
|
for (;;) {
|
|
340
|
-
const
|
|
341
|
-
if (
|
|
635
|
+
const work = await record.delivery.next();
|
|
636
|
+
if (work === undefined)
|
|
342
637
|
return;
|
|
638
|
+
const update = await record.delivery.settle(this.#filteredSubscription(work, facts));
|
|
639
|
+
if (update === undefined)
|
|
640
|
+
return;
|
|
641
|
+
if (update !== null) {
|
|
642
|
+
if (record.delivery.signal.aborted)
|
|
643
|
+
return;
|
|
644
|
+
yield update;
|
|
343
645
|
}
|
|
344
|
-
|
|
646
|
+
record.delivery.advance();
|
|
345
647
|
}
|
|
346
648
|
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
record.delivery.close();
|
|
651
|
+
throw ReadRequestStatus.technical(error, "Subscription filtering failed.");
|
|
652
|
+
}
|
|
347
653
|
finally {
|
|
348
|
-
if (
|
|
349
|
-
|
|
654
|
+
if (this.#subscriptions.get(record.id) === record) {
|
|
655
|
+
if (record.delivery.closed)
|
|
656
|
+
this.#removeDetachedSubscription(record.id);
|
|
657
|
+
else
|
|
658
|
+
await this.#removeSubscription(record.id);
|
|
350
659
|
}
|
|
351
660
|
}
|
|
352
661
|
}
|
|
662
|
+
/**
|
|
663
|
+
* Captures the original Topic and authenticated activation facts for policy code.
|
|
664
|
+
*
|
|
665
|
+
* @param record Current source route and canonical subscription.
|
|
666
|
+
* @param reader Verified facts of this activation.
|
|
667
|
+
* @returns Subscription operation context with a live cancellation signal.
|
|
668
|
+
*/
|
|
669
|
+
static #subscriptionFacts(record, reader) {
|
|
670
|
+
const topic = record.subscription.topic;
|
|
671
|
+
if (topic === undefined)
|
|
672
|
+
throw new TypeError("Activated subscription Topic is missing.");
|
|
673
|
+
return {
|
|
674
|
+
operation: { kind: "subscription", subscription: record.subscription, topic },
|
|
675
|
+
...(topic.context === undefined ? {} : { actorContext: topic.context }),
|
|
676
|
+
...(record.tenantId === undefined ? {} : { effectiveTenant: record.tenantId }),
|
|
677
|
+
producingContext: record.route.context,
|
|
678
|
+
...reader,
|
|
679
|
+
signal: record.delivery.signal,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Applies the producing BC policy before this Server's final policy.
|
|
684
|
+
*
|
|
685
|
+
* @param work Queued matched update and lazy BC capability.
|
|
686
|
+
* @param facts Trusted activation and original Topic facts.
|
|
687
|
+
* @returns Surviving update or null for an empty domain-data batch.
|
|
688
|
+
*/
|
|
689
|
+
async #filteredSubscription(work, facts) {
|
|
690
|
+
const source = await work.filter(facts);
|
|
691
|
+
return source === null
|
|
692
|
+
? null
|
|
693
|
+
: SubscriptionReadFilters.apply(source, serviceReadFilters.get(this), facts);
|
|
694
|
+
}
|
|
353
695
|
/**
|
|
354
696
|
* Serializes one native activation and returns its attached record.
|
|
355
697
|
*
|
|
356
698
|
* @param id The logical subscription identifier.
|
|
357
699
|
* @returns The attached record, or undefined when admission fails.
|
|
700
|
+
* @param reader The verified reader facts for this operation.
|
|
358
701
|
*/
|
|
359
|
-
async #activationRecord(id) {
|
|
702
|
+
async #activationRecord(id, reader, signal) {
|
|
360
703
|
let attached;
|
|
361
704
|
await this.#serializeActivation(id, async () => {
|
|
362
|
-
attached = await this.#startActivationRecord(id);
|
|
705
|
+
attached = await this.#startActivationRecord(id, reader, signal);
|
|
363
706
|
});
|
|
364
707
|
return attached;
|
|
365
708
|
}
|
|
@@ -368,14 +711,17 @@ export class SpineServices {
|
|
|
368
711
|
*
|
|
369
712
|
* @param id The logical subscription identifier.
|
|
370
713
|
* @returns The attached record, or undefined when unavailable.
|
|
714
|
+
* @param reader The verified reader facts for this operation.
|
|
371
715
|
*/
|
|
372
|
-
async #startActivationRecord(id) {
|
|
716
|
+
async #startActivationRecord(id, reader, signal) {
|
|
373
717
|
const record = this.#subscriptions.get(id) ?? (await this.#findSubscription(id));
|
|
374
718
|
if (record === undefined)
|
|
375
|
-
|
|
719
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
720
|
+
if (!ReaderIdentities.matches(record.reader, reader))
|
|
721
|
+
throw this.#readerDenied();
|
|
376
722
|
let attached;
|
|
377
723
|
await this.#requireSubscriptionTenant(record, async () => {
|
|
378
|
-
attached = await this.#activateKnownRecord(record, id);
|
|
724
|
+
attached = await this.#activateKnownRecord(record, id, reader, signal);
|
|
379
725
|
});
|
|
380
726
|
return attached;
|
|
381
727
|
}
|
|
@@ -385,21 +731,32 @@ export class SpineServices {
|
|
|
385
731
|
* @param record Discovered subscription record.
|
|
386
732
|
* @param id Canonical subscription ID.
|
|
387
733
|
* @returns Attached record, if activation remains eligible.
|
|
734
|
+
* @param reader The verified reader facts for this operation.
|
|
388
735
|
*/
|
|
389
|
-
async #activateKnownRecord(record, id) {
|
|
736
|
+
async #activateKnownRecord(record, id, reader, signal) {
|
|
390
737
|
if (record.delivery.active)
|
|
391
738
|
return undefined;
|
|
392
739
|
const activation = await boundedContextAccess
|
|
393
740
|
.subscriptionRegistry(record.route.context)
|
|
394
|
-
.activate(create(SubscriptionIdSchema, { value: id }));
|
|
395
|
-
if (activation.kind === "
|
|
741
|
+
.activate(create(SubscriptionIdSchema, { value: id }), reader);
|
|
742
|
+
if (activation.kind === "denied")
|
|
743
|
+
throw this.#readerDenied();
|
|
744
|
+
if (!("entry" in activation))
|
|
745
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
746
|
+
if (!equals(SubscriptionSchema, record.subscription, activation.entry.subscription))
|
|
747
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
748
|
+
if (record.delivery.closed)
|
|
396
749
|
return undefined;
|
|
397
750
|
try {
|
|
398
751
|
this.#subscriptions.set(id, record);
|
|
399
|
-
|
|
752
|
+
record.delivery.track(signal);
|
|
753
|
+
await this.#activateRecord(record, activation.entry);
|
|
754
|
+
await this.#checkAttachedRecord(record, id, reader, signal);
|
|
400
755
|
return record;
|
|
401
756
|
}
|
|
402
757
|
catch (error) {
|
|
758
|
+
if (record.delivery.invalidated)
|
|
759
|
+
throw error;
|
|
403
760
|
try {
|
|
404
761
|
await this.#removeSubscription(id);
|
|
405
762
|
}
|
|
@@ -409,7 +766,30 @@ export class SpineServices {
|
|
|
409
766
|
throw error;
|
|
410
767
|
}
|
|
411
768
|
}
|
|
412
|
-
|
|
769
|
+
/**
|
|
770
|
+
* Rejects an entry removed or replaced before its first activation ACK.
|
|
771
|
+
* @param record Attached delivery that may have been invalidated.
|
|
772
|
+
* @param id Canonical subscription ID.
|
|
773
|
+
* @param reader Reader admitted by the earlier activation.
|
|
774
|
+
* @param signal Transport cancellation, which ends the stream normally.
|
|
775
|
+
*/
|
|
776
|
+
async #checkAttachedRecord(record, id, reader, signal) {
|
|
777
|
+
if (!record.delivery.invalidated || signal?.aborted === true)
|
|
778
|
+
return;
|
|
779
|
+
const current = await boundedContextAccess
|
|
780
|
+
.subscriptionRegistry(record.route.context)
|
|
781
|
+
.get(create(SubscriptionIdSchema, { value: id }));
|
|
782
|
+
if (current !== undefined && !ReaderIdentities.matches(current.reader, reader))
|
|
783
|
+
throw this.#readerDenied();
|
|
784
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Serializes activation callbacks for the same subscription ID.
|
|
788
|
+
*
|
|
789
|
+
* @param id The stable identifier for this record.
|
|
790
|
+
* @param work Accepted lifecycle operation.
|
|
791
|
+
* @returns Completion after the operation settles.
|
|
792
|
+
*/ async #serializeActivation(id, work) {
|
|
413
793
|
const previous = this.#activationTails.get(id) ?? Promise.resolve();
|
|
414
794
|
let finish = () => undefined;
|
|
415
795
|
const settled = new Promise((resolve) => {
|
|
@@ -432,79 +812,131 @@ export class SpineServices {
|
|
|
432
812
|
*
|
|
433
813
|
* @param subscription Subscription identity supplied by the caller.
|
|
434
814
|
* @returns Cancellation response after any required membership check.
|
|
815
|
+
* @param reader The verified reader facts for this operation.
|
|
435
816
|
*/
|
|
436
|
-
#cancel(subscription) {
|
|
817
|
+
#cancel(subscription, reader) {
|
|
818
|
+
ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
|
|
819
|
+
ReadRequestStatus.checkLifecycleCriterion(subscription);
|
|
437
820
|
const id = subscription.id?.value;
|
|
438
821
|
if (id === undefined) {
|
|
439
822
|
return ServiceValues.okResponse();
|
|
440
823
|
}
|
|
441
|
-
return this.#cancelKnown(id);
|
|
824
|
+
return this.#cancelKnown(id, reader);
|
|
442
825
|
}
|
|
443
826
|
/**
|
|
444
827
|
* Checks the canonical record's tenant before cancellation side effects.
|
|
445
828
|
*
|
|
446
829
|
* @param id Subscription ID with a possible shared-registry record.
|
|
447
830
|
* @returns Successful cancellation response after removal.
|
|
831
|
+
* @param reader The verified reader facts for this operation.
|
|
448
832
|
*/
|
|
449
|
-
async #cancelKnown(id) {
|
|
450
|
-
const
|
|
833
|
+
async #cancelKnown(id, reader) {
|
|
834
|
+
const live = this.#subscriptions.get(id);
|
|
835
|
+
const record = live ?? (await this.#findSubscription(id));
|
|
451
836
|
if (record === undefined)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
837
|
+
return ServiceValues.okResponse();
|
|
838
|
+
if (!ReaderIdentities.matches(record.reader, reader))
|
|
839
|
+
throw this.#readerDenied();
|
|
840
|
+
await this.#requireSubscriptionTenant(record, async () => {
|
|
841
|
+
if (live !== undefined)
|
|
842
|
+
return this.#removeSubscription(id, reader);
|
|
843
|
+
await this.#registryOperation(record.route.context, async () => {
|
|
844
|
+
const outcome = await boundedContextAccess
|
|
845
|
+
.subscriptionRegistry(record.route.context)
|
|
846
|
+
.delete(create(SubscriptionIdSchema, { value: id }), reader);
|
|
847
|
+
if (outcome === "denied")
|
|
848
|
+
throw this.#readerDenied();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
455
851
|
return ServiceValues.okResponse();
|
|
456
852
|
}
|
|
853
|
+
/**
|
|
854
|
+
* Returns a sanitized denial for a foreign or legacy-bound subscription.
|
|
855
|
+
*
|
|
856
|
+
* @returns The reader authorization error.
|
|
857
|
+
*/
|
|
858
|
+
#readerDenied() {
|
|
859
|
+
return new ConnectError("Subscription reader is not authorized.", Code.PermissionDenied);
|
|
860
|
+
}
|
|
457
861
|
/**
|
|
458
862
|
* Checks the discovered subscription tenant before tenant-specific control work.
|
|
459
863
|
*
|
|
460
864
|
* @param record Canonical shared-registry subscription record.
|
|
461
865
|
* @param onReady Complete admitted one-shot mutation, if present.
|
|
866
|
+
* @returns Completion after the operation settles.
|
|
462
867
|
*/
|
|
463
868
|
async #requireSubscriptionTenant(record, onReady) {
|
|
464
869
|
await TenantStatuses.require(record.route.context, record.tenantId, onReady);
|
|
465
870
|
}
|
|
466
|
-
|
|
871
|
+
/**
|
|
872
|
+
* Finds the registered state or event route for a Topic target.
|
|
873
|
+
*
|
|
874
|
+
* @param topic Framework subscription Topic.
|
|
875
|
+
* @returns The matching subscription route.
|
|
876
|
+
*/ #subscriptionRoute(topic) {
|
|
467
877
|
const target = topic.target;
|
|
468
878
|
const route = target === undefined
|
|
469
879
|
? undefined
|
|
470
880
|
: (this.#stateRoutes.get(target.type) ?? this.#eventRoutes.get(target.type));
|
|
471
881
|
if (target === undefined || route === undefined) {
|
|
472
|
-
throw
|
|
882
|
+
throw ReadRequestStatus.invalid("topic", true, "Unsupported subscription target.");
|
|
473
883
|
}
|
|
474
884
|
return route;
|
|
475
885
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Attaches delivery for the validated retained subscription.
|
|
888
|
+
*
|
|
889
|
+
* @param record The durable subscription record.
|
|
890
|
+
* @returns Completion after the operation settles.
|
|
891
|
+
* @param expected Exact entry activated for this reader.
|
|
892
|
+
*/ async #activateRecord(record, expected) {
|
|
893
|
+
const consumer = await boundedContextAccess.consumeSubscription(record.route.context, record.id, (work) => {
|
|
894
|
+
record.delivery.push(work);
|
|
479
895
|
if (record.delivery.closed)
|
|
480
896
|
this.#removeDetachedSubscription(record.id);
|
|
481
|
-
})
|
|
897
|
+
}, () => {
|
|
898
|
+
if (this.#subscriptions.get(record.id) === record)
|
|
899
|
+
this.#subscriptions.delete(record.id);
|
|
900
|
+
record.delivery.invalidate();
|
|
901
|
+
}, expected);
|
|
482
902
|
record.delivery.attach(consumer);
|
|
483
903
|
managedChildSubscriptionAccess.installed(record.id);
|
|
484
904
|
}
|
|
485
|
-
|
|
905
|
+
/**
|
|
906
|
+
* Serializes removal of a retained subscription and its delivery.
|
|
907
|
+
*
|
|
908
|
+
* @param id The stable identifier for this record.
|
|
909
|
+
* @param reader The verified reader facts for this operation.
|
|
910
|
+
* @returns Completion after the operation settles.
|
|
911
|
+
*/ #removeSubscription(id, reader) {
|
|
486
912
|
const existing = this.#removals.get(id);
|
|
487
913
|
if (existing !== undefined) {
|
|
488
914
|
return existing.settled;
|
|
489
915
|
}
|
|
490
916
|
const record = this.#subscriptions.get(id);
|
|
491
917
|
const unknown = record === undefined;
|
|
918
|
+
if (reader !== undefined && unknown)
|
|
919
|
+
return Promise.resolve();
|
|
492
920
|
if (unknown && this.#unknownRemovals.size >= this.#subscriptionLimit) {
|
|
493
921
|
return Promise.reject(new ConnectError("Subscription cancellation capacity is exhausted.", Code.ResourceExhausted));
|
|
494
922
|
}
|
|
495
923
|
if (unknown) {
|
|
496
924
|
this.#unknownRemovals.add(id);
|
|
497
925
|
}
|
|
498
|
-
else {
|
|
926
|
+
else if (reader === undefined) {
|
|
499
927
|
record.delivery.close();
|
|
500
928
|
this.#subscriptions.delete(id);
|
|
501
929
|
}
|
|
502
930
|
const removal = ServiceValues.createSubscriptionRemoval();
|
|
503
931
|
this.#removals.set(id, removal);
|
|
504
|
-
void this.#runRemoval(id, removal, record, unknown);
|
|
932
|
+
void this.#runRemoval(id, removal, record, unknown, reader);
|
|
505
933
|
return removal.settled;
|
|
506
934
|
}
|
|
507
|
-
|
|
935
|
+
/**
|
|
936
|
+
* Cleans up delivery after a subscription record is detached.
|
|
937
|
+
*
|
|
938
|
+
* @param id The stable identifier for this record.
|
|
939
|
+
*/ #removeDetachedSubscription(id) {
|
|
508
940
|
// spine-log-boundary: server.subscription_cleanup_failure
|
|
509
941
|
void this.#removeSubscription(id).catch(() => {
|
|
510
942
|
const logger = serviceLoggers.get(this);
|
|
@@ -517,12 +949,27 @@ export class SpineServices {
|
|
|
517
949
|
}
|
|
518
950
|
});
|
|
519
951
|
}
|
|
520
|
-
|
|
952
|
+
/**
|
|
953
|
+
* Executes serialized subscription cleanup for one ID.
|
|
954
|
+
*
|
|
955
|
+
* @param id The stable identifier for this record.
|
|
956
|
+
* @param removal Shared subscription-removal task.
|
|
957
|
+
* @param record The durable subscription record.
|
|
958
|
+
* @param unknown Unrecognized route or filter value.
|
|
959
|
+
* @param reader The verified reader facts for this operation.
|
|
960
|
+
* @returns Completion after the operation settles.
|
|
961
|
+
*/ async #runRemoval(id, removal, record, unknown, reader) {
|
|
521
962
|
try {
|
|
522
963
|
if (record !== undefined) {
|
|
523
|
-
await boundedContextAccess
|
|
964
|
+
const deleted = await boundedContextAccess
|
|
524
965
|
.subscriptionRegistry(record.route.context)
|
|
525
|
-
.delete(create(SubscriptionIdSchema, { value: id }));
|
|
966
|
+
.delete(create(SubscriptionIdSchema, { value: id }), reader);
|
|
967
|
+
if (deleted === "denied")
|
|
968
|
+
throw this.#readerDenied();
|
|
969
|
+
if (reader !== undefined) {
|
|
970
|
+
record.delivery.close();
|
|
971
|
+
this.#subscriptions.delete(id);
|
|
972
|
+
}
|
|
526
973
|
}
|
|
527
974
|
else {
|
|
528
975
|
await this.#deleteUnknownSubscription(id);
|
|
@@ -545,6 +992,7 @@ export class SpineServices {
|
|
|
545
992
|
* Deletes an unknown subscription from every configured context registry.
|
|
546
993
|
*
|
|
547
994
|
* @param id Subscription ID to remove.
|
|
995
|
+
* @returns Completion after the operation settles.
|
|
548
996
|
*/
|
|
549
997
|
async #deleteUnknownSubscription(id) {
|
|
550
998
|
await Promise.all(ServiceValues.uniqueContexts(this.#contexts).map((context) => this.#registryOperation(context, async () => {
|
|
@@ -553,7 +1001,12 @@ export class SpineServices {
|
|
|
553
1001
|
.delete(create(SubscriptionIdSchema, { value: id }));
|
|
554
1002
|
})));
|
|
555
1003
|
}
|
|
556
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Loads a retained subscription record by opaque ID.
|
|
1006
|
+
*
|
|
1007
|
+
* @param id The stable identifier for this record.
|
|
1008
|
+
* @returns The retained record, if present.
|
|
1009
|
+
*/ async #findSubscription(id) {
|
|
557
1010
|
const subscriptionId = create(SubscriptionIdSchema, { value: id });
|
|
558
1011
|
for (const context of ServiceValues.uniqueContexts(this.#contexts)) {
|
|
559
1012
|
let entry;
|
|
@@ -577,7 +1030,8 @@ export class SpineServices {
|
|
|
577
1030
|
subscription: clone(SubscriptionSchema, subscription),
|
|
578
1031
|
shape: ServiceValues.createSubscriptionShape(canonicalTopic, route),
|
|
579
1032
|
tenantId,
|
|
580
|
-
|
|
1033
|
+
queueCapacity: this.#queueCapacity,
|
|
1034
|
+
...(entry.reader === undefined ? {} : { reader: entry.reader }),
|
|
581
1035
|
});
|
|
582
1036
|
}
|
|
583
1037
|
return undefined;
|
|
@@ -593,12 +1047,46 @@ export class SpineServices {
|
|
|
593
1047
|
return boundedContextAccess.admitServiceRegistryOperation(context, onOperation);
|
|
594
1048
|
}
|
|
595
1049
|
}
|
|
1050
|
+
_a = SpineServices;
|
|
596
1051
|
/**
|
|
597
1052
|
* Provides package-private service configuration seams.
|
|
598
1053
|
*
|
|
599
1054
|
* @internal
|
|
600
1055
|
*/
|
|
601
1056
|
export const spineServicesAccess = Object.freeze({
|
|
1057
|
+
/**
|
|
1058
|
+
* Aborts pending native admission before the listener waits for requests.
|
|
1059
|
+
*
|
|
1060
|
+
* @param services Local service adapter.
|
|
1061
|
+
*/
|
|
1062
|
+
stopAdmissions(services) {
|
|
1063
|
+
const controller = serviceAdmissions.get(services);
|
|
1064
|
+
if (controller === undefined)
|
|
1065
|
+
throw new TypeError("Expected SpineServices instance.");
|
|
1066
|
+
controller.abort();
|
|
1067
|
+
},
|
|
1068
|
+
/**
|
|
1069
|
+
* Attaches Server-only outward read policies to its service adapter.
|
|
1070
|
+
*
|
|
1071
|
+
* @param services Prepared service adapter.
|
|
1072
|
+
* @param filters Registered policy engine.
|
|
1073
|
+
*/
|
|
1074
|
+
installReadFilters(services, filters) {
|
|
1075
|
+
if (!serviceInstances.has(services))
|
|
1076
|
+
throw new TypeError("Expected SpineServices instance.");
|
|
1077
|
+
serviceReadFilters.set(services, filters);
|
|
1078
|
+
},
|
|
1079
|
+
/**
|
|
1080
|
+
* Attaches this Server's prepared handler table without exposing registration options.
|
|
1081
|
+
*
|
|
1082
|
+
* @param services Adapter receiving its internal App Query capability.
|
|
1083
|
+
* @param queries Selected handler table from Server preparation.
|
|
1084
|
+
*/
|
|
1085
|
+
installAppQueries(services, queries) {
|
|
1086
|
+
if (!serviceInstances.has(services))
|
|
1087
|
+
throw new TypeError("Expected SpineServices instance.");
|
|
1088
|
+
serviceAppQueries.set(services, queries);
|
|
1089
|
+
},
|
|
602
1090
|
/**
|
|
603
1091
|
* Clears the logger installed for a closed service adapter.
|
|
604
1092
|
*
|
|
@@ -627,18 +1115,23 @@ export const spineServicesAccess = Object.freeze({
|
|
|
627
1115
|
* Buffers subscription updates and wakes active stream readers.
|
|
628
1116
|
*/
|
|
629
1117
|
class SubscriptionDelivery {
|
|
1118
|
+
#active;
|
|
630
1119
|
#queue = [];
|
|
631
1120
|
#waiters = [];
|
|
632
|
-
#
|
|
1121
|
+
#controller = new AbortController();
|
|
1122
|
+
#queueCapacity;
|
|
1123
|
+
#activationSignal;
|
|
1124
|
+
#onActivationAbort;
|
|
633
1125
|
#subscription;
|
|
634
1126
|
#closed = false;
|
|
1127
|
+
#invalidated = false;
|
|
635
1128
|
/**
|
|
636
1129
|
* Sets the bounded update queue capacity.
|
|
637
1130
|
*
|
|
638
|
-
* @param
|
|
1131
|
+
* @param queueCapacity Maximum waiting updates beyond the active update.
|
|
639
1132
|
*/
|
|
640
|
-
constructor(
|
|
641
|
-
this.#
|
|
1133
|
+
constructor(queueCapacity) {
|
|
1134
|
+
this.#queueCapacity = queueCapacity;
|
|
642
1135
|
}
|
|
643
1136
|
/**
|
|
644
1137
|
* Checks whether a source attachment is active.
|
|
@@ -656,6 +1149,75 @@ class SubscriptionDelivery {
|
|
|
656
1149
|
get closed() {
|
|
657
1150
|
return this.#closed;
|
|
658
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Returns source invalidation separately from transport cancellation before the first ACK.
|
|
1154
|
+
* @returns Whether the admitted canonical entry changed or disappeared.
|
|
1155
|
+
*/
|
|
1156
|
+
get invalidated() {
|
|
1157
|
+
return this.#invalidated;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Closes this delivery after the admitted source entry changes.
|
|
1161
|
+
*/
|
|
1162
|
+
invalidate() {
|
|
1163
|
+
if (this.#closed)
|
|
1164
|
+
return;
|
|
1165
|
+
this.#invalidated = true;
|
|
1166
|
+
this.close();
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Returns a cancellation signal for active policy when this consumer is removed.
|
|
1170
|
+
*
|
|
1171
|
+
* @returns Cancellation signal for the current activation.
|
|
1172
|
+
*/
|
|
1173
|
+
get signal() {
|
|
1174
|
+
return this.#controller.signal;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Registers transport cancellation before the source attachment awaits.
|
|
1178
|
+
*
|
|
1179
|
+
* @param signal Cancellation of the admitted activation, when available.
|
|
1180
|
+
*/
|
|
1181
|
+
track(signal) {
|
|
1182
|
+
if (signal === undefined)
|
|
1183
|
+
return;
|
|
1184
|
+
if (signal.aborted) {
|
|
1185
|
+
this.close();
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
this.#activationSignal = signal;
|
|
1189
|
+
this.#onActivationAbort = () => {
|
|
1190
|
+
this.close();
|
|
1191
|
+
};
|
|
1192
|
+
signal.addEventListener("abort", this.#onActivationAbort, { once: true });
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Stops waiting on one policy call when this activation closes.
|
|
1196
|
+
*
|
|
1197
|
+
* @param work The policy result; a late rejection remains observed by the race.
|
|
1198
|
+
* @returns The result, or undefined after cancellation.
|
|
1199
|
+
*/
|
|
1200
|
+
async settle(work) {
|
|
1201
|
+
if (this.#closed) {
|
|
1202
|
+
// spine-log-boundary: server.subscription_closed_policy_rejection
|
|
1203
|
+
void work.catch(() => undefined);
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
let onAbort;
|
|
1207
|
+
const closed = new Promise((resolve) => {
|
|
1208
|
+
onAbort = () => {
|
|
1209
|
+
resolve(undefined);
|
|
1210
|
+
};
|
|
1211
|
+
this.#controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
1212
|
+
});
|
|
1213
|
+
try {
|
|
1214
|
+
return await Promise.race([work, closed]);
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
if (onAbort !== undefined)
|
|
1218
|
+
this.#controller.signal.removeEventListener("abort", onAbort);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
659
1221
|
/**
|
|
660
1222
|
* Attaches a source subscription or immediately removes it after close.
|
|
661
1223
|
*
|
|
@@ -677,17 +1239,16 @@ class SubscriptionDelivery {
|
|
|
677
1239
|
if (this.#closed) {
|
|
678
1240
|
return;
|
|
679
1241
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
this.#queue.push(update);
|
|
1242
|
+
if (this.#active === undefined) {
|
|
1243
|
+
this.#active = update;
|
|
1244
|
+
this.#waiters.shift()?.(update);
|
|
1245
|
+
return;
|
|
687
1246
|
}
|
|
688
|
-
|
|
689
|
-
|
|
1247
|
+
if (this.#queue.length >= this.#queueCapacity) {
|
|
1248
|
+
this.close();
|
|
1249
|
+
return;
|
|
690
1250
|
}
|
|
1251
|
+
this.#queue.push(update);
|
|
691
1252
|
}
|
|
692
1253
|
/**
|
|
693
1254
|
* Reads the next queued update, waiting while the stream is open and empty.
|
|
@@ -695,12 +1256,22 @@ class SubscriptionDelivery {
|
|
|
695
1256
|
* @returns Next update, or `undefined` after closure.
|
|
696
1257
|
*/
|
|
697
1258
|
next() {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1259
|
+
if (this.#closed)
|
|
1260
|
+
return Promise.resolve(undefined);
|
|
1261
|
+
if (this.#active !== undefined)
|
|
1262
|
+
return Promise.resolve(this.#active);
|
|
702
1263
|
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
703
1264
|
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Removes the consumed active update and promotes the next waiting update.
|
|
1267
|
+
*/
|
|
1268
|
+
advance() {
|
|
1269
|
+
if (this.#closed)
|
|
1270
|
+
return;
|
|
1271
|
+
this.#active = this.#queue.shift();
|
|
1272
|
+
if (this.#active !== undefined)
|
|
1273
|
+
this.#waiters.shift()?.(this.#active);
|
|
1274
|
+
}
|
|
704
1275
|
/**
|
|
705
1276
|
* Closes the source subscription and completes all waiting readers.
|
|
706
1277
|
*/
|
|
@@ -709,7 +1280,11 @@ class SubscriptionDelivery {
|
|
|
709
1280
|
return;
|
|
710
1281
|
}
|
|
711
1282
|
this.#closed = true;
|
|
1283
|
+
this.#controller.abort();
|
|
1284
|
+
if (this.#activationSignal !== undefined && this.#onActivationAbort !== undefined)
|
|
1285
|
+
this.#activationSignal.removeEventListener("abort", this.#onActivationAbort);
|
|
712
1286
|
this.#queue.length = 0;
|
|
1287
|
+
this.#active = undefined;
|
|
713
1288
|
this.#subscription?.unsubscribe();
|
|
714
1289
|
this.#subscription = undefined;
|
|
715
1290
|
for (const waiter of this.#waiters.splice(0)) {
|
|
@@ -719,6 +1294,8 @@ class SubscriptionDelivery {
|
|
|
719
1294
|
}
|
|
720
1295
|
/**
|
|
721
1296
|
* Builds an OK status for service responses.
|
|
1297
|
+
*
|
|
1298
|
+
* @returns The successful status.
|
|
722
1299
|
*/
|
|
723
1300
|
function okStatus() {
|
|
724
1301
|
return create(StatusSchema, {
|
|
@@ -728,30 +1305,42 @@ function okStatus() {
|
|
|
728
1305
|
},
|
|
729
1306
|
});
|
|
730
1307
|
}
|
|
731
|
-
|
|
1308
|
+
/**
|
|
1309
|
+
* Builds a structured status from a public validation error.
|
|
1310
|
+
*
|
|
1311
|
+
* @param type Registered target type URL.
|
|
1312
|
+
* @param message Public validation or error message.
|
|
1313
|
+
* @param details Structured validation details.
|
|
1314
|
+
* @param code Public status code.
|
|
1315
|
+
* @returns The structured error status.
|
|
1316
|
+
*/ function errorStatus(type, message, details, code) {
|
|
732
1317
|
return create(StatusSchema, {
|
|
733
1318
|
status: {
|
|
734
1319
|
case: "error",
|
|
735
1320
|
value: create(ErrorSchema, {
|
|
736
1321
|
type,
|
|
737
1322
|
message,
|
|
1323
|
+
...(code === undefined ? {} : { code }),
|
|
738
1324
|
...(details === undefined ? {} : { details }),
|
|
739
1325
|
}),
|
|
740
1326
|
},
|
|
741
1327
|
});
|
|
742
1328
|
}
|
|
743
|
-
|
|
1329
|
+
/**
|
|
1330
|
+
* Builds a successful subscription service response.
|
|
1331
|
+
*
|
|
1332
|
+
* @returns The OK response.
|
|
1333
|
+
*/ function okResponse() {
|
|
744
1334
|
return create(ResponseSchema, { status: okStatus() });
|
|
745
1335
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
function validateReadQuery(query, target, route) {
|
|
1336
|
+
/**
|
|
1337
|
+
* Checks a read query envelope and target before routing.
|
|
1338
|
+
*
|
|
1339
|
+
* @param query Framework Entity read query.
|
|
1340
|
+
* @param target Declared query or Topic target.
|
|
1341
|
+
* @param route Registered target route with schema and tenancy.
|
|
1342
|
+
* @returns The validated read request.
|
|
1343
|
+
*/ function validateReadQuery(query, target, route) {
|
|
755
1344
|
const formatError = formatReadError(query.format, route);
|
|
756
1345
|
if (formatError !== undefined) {
|
|
757
1346
|
return formatError;
|
|
@@ -768,7 +1357,13 @@ function validateReadQuery(query, target, route) {
|
|
|
768
1357
|
return invalidCriterionError();
|
|
769
1358
|
}
|
|
770
1359
|
}
|
|
771
|
-
|
|
1360
|
+
/**
|
|
1361
|
+
* Checks target filters against the registered Entity schema.
|
|
1362
|
+
*
|
|
1363
|
+
* @param filters Target or subscription filter collection.
|
|
1364
|
+
* @param route Registered target route with schema and tenancy.
|
|
1365
|
+
* @returns The validated target filters.
|
|
1366
|
+
*/ function validateFilters(filters, route) {
|
|
772
1367
|
const idCount = filters.idFilter?.id.length ?? 0;
|
|
773
1368
|
if (idCount > QueryLimits.idCount) {
|
|
774
1369
|
return invalidQueryError("QueryService.Read id_filter may contain at most 100 IDs.");
|
|
@@ -795,7 +1390,13 @@ function validateFilters(filters, route) {
|
|
|
795
1390
|
}
|
|
796
1391
|
: undefined;
|
|
797
1392
|
}
|
|
798
|
-
|
|
1393
|
+
/**
|
|
1394
|
+
* Checks nested composite filters and their traversal bounds.
|
|
1395
|
+
*
|
|
1396
|
+
* @param filters Target or subscription filter collection.
|
|
1397
|
+
* @param route Registered target route with schema and tenancy.
|
|
1398
|
+
* @returns The validated composite filters.
|
|
1399
|
+
*/ function validateCompositeFilters(filters, route) {
|
|
799
1400
|
const pending = filters.map((filter) => ({ filter, depth: 0 }));
|
|
800
1401
|
const seen = new WeakSet();
|
|
801
1402
|
let compositeCount = 0;
|
|
@@ -819,7 +1420,13 @@ function validateCompositeFilters(filters, route) {
|
|
|
819
1420
|
}
|
|
820
1421
|
return undefined;
|
|
821
1422
|
}
|
|
822
|
-
|
|
1423
|
+
/**
|
|
1424
|
+
* Checks query ID Any payloads against the registered Entity ID.
|
|
1425
|
+
*
|
|
1426
|
+
* @param ids Opaque query or subscription IDs.
|
|
1427
|
+
* @param route Registered target route with schema and tenancy.
|
|
1428
|
+
* @returns The validated query IDs.
|
|
1429
|
+
*/ function validateQueryIds(ids, route) {
|
|
823
1430
|
const schema = route.idField.message;
|
|
824
1431
|
if (schema === undefined || ids === undefined) {
|
|
825
1432
|
return undefined;
|
|
@@ -830,7 +1437,12 @@ function validateQueryIds(ids, route) {
|
|
|
830
1437
|
}
|
|
831
1438
|
return undefined;
|
|
832
1439
|
}
|
|
833
|
-
|
|
1440
|
+
/**
|
|
1441
|
+
* Counts leaf filters in a nested subscription criterion.
|
|
1442
|
+
*
|
|
1443
|
+
* @param filters Target or subscription filter collection.
|
|
1444
|
+
* @returns The number of simple filter nodes.
|
|
1445
|
+
*/ function countSimpleFilters(filters) {
|
|
834
1446
|
const pending = [...filters];
|
|
835
1447
|
let count = 0;
|
|
836
1448
|
while (pending.length > 0) {
|
|
@@ -842,7 +1454,13 @@ function countSimpleFilters(filters) {
|
|
|
842
1454
|
}
|
|
843
1455
|
return count;
|
|
844
1456
|
}
|
|
845
|
-
|
|
1457
|
+
/**
|
|
1458
|
+
* Checks field filters and their typed Any values.
|
|
1459
|
+
*
|
|
1460
|
+
* @param filters Target or subscription filter collection.
|
|
1461
|
+
* @param route Registered target route with schema and tenancy.
|
|
1462
|
+
* @returns The validated simple filters.
|
|
1463
|
+
*/ function validateSimpleFilters(filters, route) {
|
|
846
1464
|
for (const filter of filters) {
|
|
847
1465
|
if (wireComparison(filter.operator) === undefined) {
|
|
848
1466
|
return unsupportedFilterError("QueryService.Read comparison operator is not supported.");
|
|
@@ -875,6 +1493,10 @@ function validateSimpleFilters(filters, route) {
|
|
|
875
1493
|
}
|
|
876
1494
|
/**
|
|
877
1495
|
* Checks query ordering and limits against the registered state route.
|
|
1496
|
+
*
|
|
1497
|
+
* @param format Declared filter value format.
|
|
1498
|
+
* @param route Registered target route with schema and tenancy.
|
|
1499
|
+
* @returns The structured read error.
|
|
878
1500
|
*/
|
|
879
1501
|
function formatReadError(format, route) {
|
|
880
1502
|
if (format === undefined) {
|
|
@@ -883,10 +1505,10 @@ function formatReadError(format, route) {
|
|
|
883
1505
|
if (format.orderBy.length > QueryLimits.orderCount) {
|
|
884
1506
|
return unsupportedFormatError("QueryService.Read order_by may contain at most 8 entries.");
|
|
885
1507
|
}
|
|
886
|
-
if (format.limit >
|
|
1508
|
+
if (format.limit > QUERY_SERVICE_MAX_RESULTS) {
|
|
887
1509
|
return {
|
|
888
1510
|
type: "INVALID_QUERY",
|
|
889
|
-
message:
|
|
1511
|
+
message: `QueryService.Read limit may be at most ${String(QUERY_SERVICE_MAX_RESULTS)}.`,
|
|
890
1512
|
};
|
|
891
1513
|
}
|
|
892
1514
|
if (format.limit > 0 && format.orderBy.length === 0) {
|
|
@@ -899,6 +1521,11 @@ function formatReadError(format, route) {
|
|
|
899
1521
|
}
|
|
900
1522
|
/**
|
|
901
1523
|
* Converts an accepted wire query into a storage plan for complete states.
|
|
1524
|
+
*
|
|
1525
|
+
* @param target Declared query or Topic target.
|
|
1526
|
+
* @param query Framework Entity read query.
|
|
1527
|
+
* @param route Registered target route with schema and tenancy.
|
|
1528
|
+
* @returns The validated Entity read plan.
|
|
902
1529
|
*/
|
|
903
1530
|
function createReadPlan(target, query, route) {
|
|
904
1531
|
const predicate = target.criterion.case === "filters"
|
|
@@ -920,7 +1547,13 @@ function createReadPlan(target, query, route) {
|
|
|
920
1547
|
...(format === undefined || format.limit === 0 ? {} : { limit: format.limit }),
|
|
921
1548
|
});
|
|
922
1549
|
}
|
|
923
|
-
|
|
1550
|
+
/**
|
|
1551
|
+
* Converts validated target filters to executable predicates.
|
|
1552
|
+
*
|
|
1553
|
+
* @param filters Target or subscription filter collection.
|
|
1554
|
+
* @param route Registered target route with schema and tenancy.
|
|
1555
|
+
* @returns The normalized filter predicates.
|
|
1556
|
+
*/ function normalizedFilters(filters, route) {
|
|
924
1557
|
const predicates = [];
|
|
925
1558
|
if (filters.idFilter !== undefined) {
|
|
926
1559
|
predicates.push({
|
|
@@ -934,11 +1567,23 @@ function normalizedFilters(filters, route) {
|
|
|
934
1567
|
? onlyPredicate
|
|
935
1568
|
: { kind: "all", predicates };
|
|
936
1569
|
}
|
|
937
|
-
|
|
1570
|
+
/**
|
|
1571
|
+
* Decodes an opaque query ID using the registered Entity ID schema.
|
|
1572
|
+
*
|
|
1573
|
+
* @param value Packed Entity ID Any from the query.
|
|
1574
|
+
* @param route Registered target route with schema and tenancy.
|
|
1575
|
+
* @returns The typed Entity ID.
|
|
1576
|
+
*/ function decodeQueryIdValue(value, route) {
|
|
938
1577
|
const schema = route.idField.message;
|
|
939
1578
|
return schema === undefined ? decodeAnyValue(value) : decodeAnyValue(value, schema);
|
|
940
1579
|
}
|
|
941
|
-
|
|
1580
|
+
/**
|
|
1581
|
+
* Converts a validated composite filter to its execution form.
|
|
1582
|
+
*
|
|
1583
|
+
* @param filter One target or subscription filter.
|
|
1584
|
+
* @param route Registered target route with schema and tenancy.
|
|
1585
|
+
* @returns The normalized composite criterion.
|
|
1586
|
+
*/ function normalizedComposite(filter, route) {
|
|
942
1587
|
const predicates = [
|
|
943
1588
|
...filter.filter.map((child) => ({
|
|
944
1589
|
kind: "comparison",
|
|
@@ -953,19 +1598,35 @@ function normalizedComposite(filter, route) {
|
|
|
953
1598
|
predicates,
|
|
954
1599
|
};
|
|
955
1600
|
}
|
|
956
|
-
|
|
1601
|
+
/**
|
|
1602
|
+
* Resolves a declared state column for a field filter.
|
|
1603
|
+
*
|
|
1604
|
+
* @param filter One target or subscription filter.
|
|
1605
|
+
* @returns The required state column.
|
|
1606
|
+
*/ function requiredFilterColumn(filter) {
|
|
957
1607
|
const column = filter.fieldPath?.fieldName[0];
|
|
958
1608
|
if (column !== undefined)
|
|
959
1609
|
return column;
|
|
960
1610
|
throw new TypeError("Validated query filter has no column.");
|
|
961
1611
|
}
|
|
962
|
-
|
|
1612
|
+
/**
|
|
1613
|
+
* Maps a wire filter operator to a supported comparison.
|
|
1614
|
+
*
|
|
1615
|
+
* @param filter One target or subscription filter.
|
|
1616
|
+
* @returns The required comparison operator.
|
|
1617
|
+
*/ function requiredComparison(filter) {
|
|
963
1618
|
const operator = wireComparison(filter.operator);
|
|
964
1619
|
if (operator !== undefined)
|
|
965
1620
|
return operator;
|
|
966
1621
|
throw new TypeError("Validated query filter has no supported comparison operator.");
|
|
967
1622
|
}
|
|
968
|
-
|
|
1623
|
+
/**
|
|
1624
|
+
* Decodes a filter value for the target state column.
|
|
1625
|
+
*
|
|
1626
|
+
* @param filter One target or subscription filter.
|
|
1627
|
+
* @param route Registered target route with schema and tenancy.
|
|
1628
|
+
* @returns The typed column value.
|
|
1629
|
+
*/ function decodeColumnValue(filter, route) {
|
|
969
1630
|
const column = filter.fieldPath?.fieldName[0] ?? "";
|
|
970
1631
|
if (column === "version" && filter.value !== undefined) {
|
|
971
1632
|
const value = AnyPacker.tryUnpack(filter.value, VersionSchema);
|
|
@@ -982,7 +1643,13 @@ function decodeColumnValue(filter, route) {
|
|
|
982
1643
|
const field = route.columnFields.get(column);
|
|
983
1644
|
return decodeAnyValue(filter.value, field?.fieldKind === "message" ? field.message : undefined);
|
|
984
1645
|
}
|
|
985
|
-
|
|
1646
|
+
/**
|
|
1647
|
+
* Checks an Any filter value against the registered field schema.
|
|
1648
|
+
*
|
|
1649
|
+
* @param field Registered Protobuf field descriptor.
|
|
1650
|
+
* @param value Packed field-filter value.
|
|
1651
|
+
* @returns Whether the filter value has the expected type.
|
|
1652
|
+
*/ function filterValueMatches(field, value) {
|
|
986
1653
|
const schema = filterValueSchema(field);
|
|
987
1654
|
if (schema === undefined ||
|
|
988
1655
|
(schema.typeName !== AnySchema.typeName && value.typeUrl !== TypeUrls.derive(schema)))
|
|
@@ -1010,7 +1677,12 @@ function filterValueMatches(field, value) {
|
|
|
1010
1677
|
}
|
|
1011
1678
|
return typeof decoded === "number" && Number.isFinite(decoded);
|
|
1012
1679
|
}
|
|
1013
|
-
|
|
1680
|
+
/**
|
|
1681
|
+
* Selects the Protobuf schema required for a state field filter.
|
|
1682
|
+
*
|
|
1683
|
+
* @param field Registered Protobuf field descriptor.
|
|
1684
|
+
* @returns The declared filter-value schema.
|
|
1685
|
+
*/ function filterValueSchema(field) {
|
|
1014
1686
|
if (field.fieldKind === "message")
|
|
1015
1687
|
return field.message;
|
|
1016
1688
|
if (field.fieldKind === "enum")
|
|
@@ -1040,7 +1712,13 @@ function filterValueSchema(field) {
|
|
|
1040
1712
|
return StringValueSchema;
|
|
1041
1713
|
return Int32ValueSchema;
|
|
1042
1714
|
}
|
|
1043
|
-
|
|
1715
|
+
/**
|
|
1716
|
+
* Checks special state columns against a wire filter value.
|
|
1717
|
+
*
|
|
1718
|
+
* @param column Registered Entity state column.
|
|
1719
|
+
* @param value Packed value for a system state column.
|
|
1720
|
+
* @returns Whether the special value matches.
|
|
1721
|
+
*/ function systemValueMatches(column, value) {
|
|
1044
1722
|
if (column === "version") {
|
|
1045
1723
|
return (value.typeUrl === TypeUrls.derive(VersionSchema) &&
|
|
1046
1724
|
AnyPacker.tryUnpack(value, VersionSchema) !== undefined);
|
|
@@ -1051,16 +1729,24 @@ function systemValueMatches(column, value) {
|
|
|
1051
1729
|
}
|
|
1052
1730
|
return false;
|
|
1053
1731
|
}
|
|
1054
|
-
|
|
1732
|
+
/**
|
|
1733
|
+
* Checks whether a field type supports ordered comparisons.
|
|
1734
|
+
*
|
|
1735
|
+
* @param field Registered Protobuf field descriptor.
|
|
1736
|
+
* @returns Whether range comparisons are supported.
|
|
1737
|
+
*/ function supportsRange(field) {
|
|
1055
1738
|
if (field.fieldKind === "message") {
|
|
1056
1739
|
return (field.message.typeName === "google.protobuf.Timestamp" ||
|
|
1057
1740
|
field.message.typeName === "spine.core.Version");
|
|
1058
1741
|
}
|
|
1059
|
-
return
|
|
1060
|
-
field.scalar !== ScalarType.BOOL &&
|
|
1061
|
-
field.scalar !== ScalarType.BYTES);
|
|
1742
|
+
return field.fieldKind === "scalar" && field.scalar !== ScalarType.BOOL;
|
|
1062
1743
|
}
|
|
1063
|
-
|
|
1744
|
+
/**
|
|
1745
|
+
* Maps a wire filter enum to the execution comparison.
|
|
1746
|
+
*
|
|
1747
|
+
* @param operator Wire filter comparison enum.
|
|
1748
|
+
* @returns The comparison operator.
|
|
1749
|
+
*/ function wireComparison(operator) {
|
|
1064
1750
|
if (operator === Filter_Operator.EQUAL)
|
|
1065
1751
|
return "equal";
|
|
1066
1752
|
if (operator === Filter_Operator.GREATER_THAN)
|
|
@@ -1073,25 +1759,43 @@ function wireComparison(operator) {
|
|
|
1073
1759
|
return "lessOrEqual";
|
|
1074
1760
|
return undefined;
|
|
1075
1761
|
}
|
|
1076
|
-
|
|
1762
|
+
/**
|
|
1763
|
+
* Creates a structured error for an unsupported filter.
|
|
1764
|
+
*
|
|
1765
|
+
* @param message Public validation or error message.
|
|
1766
|
+
* @returns The invalid-filter error.
|
|
1767
|
+
*/ function unsupportedFilterError(message) {
|
|
1077
1768
|
return invalidQueryError(message);
|
|
1078
1769
|
}
|
|
1079
|
-
|
|
1770
|
+
/**
|
|
1771
|
+
* Creates a structured error for an unsupported filter format.
|
|
1772
|
+
*
|
|
1773
|
+
* @param message Public validation or error message.
|
|
1774
|
+
* @returns The invalid-format error.
|
|
1775
|
+
*/ function unsupportedFormatError(message) {
|
|
1080
1776
|
return invalidQueryError(message);
|
|
1081
1777
|
}
|
|
1082
|
-
|
|
1778
|
+
/**
|
|
1779
|
+
* Creates the structured invalid-query error.
|
|
1780
|
+
*
|
|
1781
|
+
* @param message Public validation or error message.
|
|
1782
|
+
* @returns The invalid-query error.
|
|
1783
|
+
*/ function invalidQueryError(message) {
|
|
1083
1784
|
return {
|
|
1084
1785
|
type: "INVALID_QUERY",
|
|
1085
1786
|
message,
|
|
1086
1787
|
};
|
|
1087
1788
|
}
|
|
1088
|
-
|
|
1789
|
+
/**
|
|
1790
|
+
* Creates the structured invalid-subscription criterion error.
|
|
1791
|
+
*
|
|
1792
|
+
* @returns The invalid-criterion error.
|
|
1793
|
+
*/ function invalidCriterionError() {
|
|
1089
1794
|
return {
|
|
1090
1795
|
type: "INVALID_QUERY",
|
|
1091
1796
|
message: "QueryService.Read requires filters or include_all = true.",
|
|
1092
1797
|
};
|
|
1093
1798
|
}
|
|
1094
|
-
const DEFAULT_QUEUE_LIMIT = 100;
|
|
1095
1799
|
const DEFAULT_SUBSCRIPTION_LIMIT = 100;
|
|
1096
1800
|
/**
|
|
1097
1801
|
* Holds bounded request limits for QueryService.
|
|
@@ -1101,7 +1805,6 @@ const QueryLimits = Object.freeze({
|
|
|
1101
1805
|
filterCount: 16,
|
|
1102
1806
|
compositeCount: 8,
|
|
1103
1807
|
orderCount: 8,
|
|
1104
|
-
resultCount: 1_000,
|
|
1105
1808
|
});
|
|
1106
1809
|
/**
|
|
1107
1810
|
* Holds bounded request limits for SubscriptionService.
|
|
@@ -1114,25 +1817,37 @@ const SubscriptionLimits = Object.freeze({
|
|
|
1114
1817
|
pathComponentCount: 16,
|
|
1115
1818
|
pathSegmentLength: 128,
|
|
1116
1819
|
});
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1820
|
+
/**
|
|
1821
|
+
* Validates the configured maximum concurrent subscriptions.
|
|
1822
|
+
*
|
|
1823
|
+
* @param value Configured maximum concurrent subscriptions.
|
|
1824
|
+
* @returns The accepted subscription limit.
|
|
1825
|
+
*/ function subscriptionLimit(value) {
|
|
1121
1826
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1122
1827
|
throw new TypeError("SpineServices subscriptionLimit must be a positive safe integer.");
|
|
1123
1828
|
}
|
|
1124
1829
|
return value;
|
|
1125
1830
|
}
|
|
1126
|
-
|
|
1831
|
+
/**
|
|
1832
|
+
* Constructs an in-memory subscription record with its reader binding.
|
|
1833
|
+
*
|
|
1834
|
+
* @param input Fields for a new reader-bound subscription record.
|
|
1835
|
+
* @returns The new subscription record.
|
|
1836
|
+
*/ function createSubscriptionRecord(input) {
|
|
1127
1837
|
return {
|
|
1128
1838
|
id: input.id,
|
|
1129
1839
|
subscription: clone(SubscriptionSchema, input.subscription),
|
|
1840
|
+
...(input.reader === undefined ? {} : { reader: input.reader }),
|
|
1130
1841
|
tenantId: input.tenantId === undefined ? undefined : clone(TenantIdSchema, input.tenantId),
|
|
1131
|
-
delivery: new SubscriptionDelivery(input.
|
|
1842
|
+
delivery: new SubscriptionDelivery(input.queueCapacity),
|
|
1132
1843
|
...input.shape,
|
|
1133
1844
|
};
|
|
1134
1845
|
}
|
|
1135
|
-
|
|
1846
|
+
/**
|
|
1847
|
+
* Creates a shared removal task for concurrent cancellation.
|
|
1848
|
+
*
|
|
1849
|
+
* @returns The removal task and its completion promise.
|
|
1850
|
+
*/ function createSubscriptionRemoval() {
|
|
1136
1851
|
let reject;
|
|
1137
1852
|
let resolve;
|
|
1138
1853
|
const settled = new Promise((settle, fail) => {
|
|
@@ -1143,10 +1858,19 @@ function createSubscriptionRemoval() {
|
|
|
1143
1858
|
});
|
|
1144
1859
|
return { reject, resolve, settled };
|
|
1145
1860
|
}
|
|
1146
|
-
|
|
1861
|
+
/**
|
|
1862
|
+
* Deduplicates registered bounded contexts for routing.
|
|
1863
|
+
*
|
|
1864
|
+
* @param contexts Built bounded contexts available for routing.
|
|
1865
|
+
* @returns The distinct bounded contexts.
|
|
1866
|
+
*/ function uniqueContexts(contexts) {
|
|
1147
1867
|
return [...new Set(contexts)];
|
|
1148
1868
|
}
|
|
1149
|
-
|
|
1869
|
+
/**
|
|
1870
|
+
* Checks Topic identity, context, target and criterion.
|
|
1871
|
+
*
|
|
1872
|
+
* @param topic Framework subscription Topic.
|
|
1873
|
+
*/ function validateTopic(topic) {
|
|
1150
1874
|
if (topic.id?.value === undefined || topic.id.value.trim().length === 0) {
|
|
1151
1875
|
throw new ConnectError("Subscription topic ID is required.", Code.InvalidArgument);
|
|
1152
1876
|
}
|
|
@@ -1159,6 +1883,10 @@ function validateTopic(topic) {
|
|
|
1159
1883
|
}
|
|
1160
1884
|
/**
|
|
1161
1885
|
* Validates a topic and returns its state or Event subscription shape.
|
|
1886
|
+
*
|
|
1887
|
+
* @param topic Framework subscription Topic.
|
|
1888
|
+
* @param route Registered target route with schema and tenancy.
|
|
1889
|
+
* @returns The normalized subscription shape.
|
|
1162
1890
|
*/
|
|
1163
1891
|
function createSubscriptionShape(topic, route) {
|
|
1164
1892
|
const target = topic.target;
|
|
@@ -1184,6 +1912,8 @@ function createSubscriptionShape(topic, route) {
|
|
|
1184
1912
|
}
|
|
1185
1913
|
/**
|
|
1186
1914
|
* Checks the supported event topic criteria.
|
|
1915
|
+
*
|
|
1916
|
+
* @param target Declared query or Topic target.
|
|
1187
1917
|
*/
|
|
1188
1918
|
function validateEventSubscriptionTopic(target) {
|
|
1189
1919
|
switch (target.criterion.case) {
|
|
@@ -1199,7 +1929,11 @@ function validateEventSubscriptionTopic(target) {
|
|
|
1199
1929
|
throw new ConnectError("SubscriptionService.Subscribe requires filters or include_all = true.", Code.InvalidArgument);
|
|
1200
1930
|
}
|
|
1201
1931
|
}
|
|
1202
|
-
|
|
1932
|
+
/**
|
|
1933
|
+
* Checks a Topic filter tree before accepting its definition.
|
|
1934
|
+
*
|
|
1935
|
+
* @param filters Target or subscription filter collection.
|
|
1936
|
+
*/ function validateSubscriptionFilterTree(filters) {
|
|
1203
1937
|
if (filters.length > SubscriptionLimits.compositeCount) {
|
|
1204
1938
|
throw new ConnectError("SubscriptionService.Subscribe may contain at most 8 composite filters.", Code.InvalidArgument);
|
|
1205
1939
|
}
|
|
@@ -1220,7 +1954,13 @@ function validateSubscriptionFilterTree(filters) {
|
|
|
1220
1954
|
enqueueSubscriptionFilterChildren(current.filter, current.depth, stack);
|
|
1221
1955
|
}
|
|
1222
1956
|
}
|
|
1223
|
-
|
|
1957
|
+
/**
|
|
1958
|
+
* Checks one nested subscription filter node and its bounds.
|
|
1959
|
+
*
|
|
1960
|
+
* @param filter One target or subscription filter.
|
|
1961
|
+
* @param depth Current composite-filter nesting depth.
|
|
1962
|
+
* @param counts Current filter traversal counts.
|
|
1963
|
+
*/ function validateSubscriptionFilterNode(filter, depth, counts) {
|
|
1224
1964
|
if (depth > SubscriptionLimits.compositeDepth) {
|
|
1225
1965
|
throw new ConnectError("SubscriptionService.Subscribe composite filters may nest at most 8 levels.", Code.InvalidArgument);
|
|
1226
1966
|
}
|
|
@@ -1247,7 +1987,13 @@ function validateSubscriptionFilterNode(filter, depth, counts) {
|
|
|
1247
1987
|
throw new ConnectError("SubscriptionService.Subscribe may contain at most 8 composite filters.", Code.InvalidArgument);
|
|
1248
1988
|
}
|
|
1249
1989
|
}
|
|
1250
|
-
|
|
1990
|
+
/**
|
|
1991
|
+
* Queues nested filter children for bounded traversal.
|
|
1992
|
+
*
|
|
1993
|
+
* @param filter One target or subscription filter.
|
|
1994
|
+
* @param depth Current composite-filter nesting depth.
|
|
1995
|
+
* @param stack Pending filter traversal stack.
|
|
1996
|
+
*/ function enqueueSubscriptionFilterChildren(filter, depth, stack) {
|
|
1251
1997
|
for (const nested of filter.compositeFilter) {
|
|
1252
1998
|
if (depth + 1 > SubscriptionLimits.compositeDepth) {
|
|
1253
1999
|
throw new ConnectError("SubscriptionService.Subscribe composite filters may nest at most 8 levels.", Code.InvalidArgument);
|
|
@@ -1257,6 +2003,9 @@ function enqueueSubscriptionFilterChildren(filter, depth, stack) {
|
|
|
1257
2003
|
}
|
|
1258
2004
|
/**
|
|
1259
2005
|
* Checks a state filter path before resolving its fields.
|
|
2006
|
+
*
|
|
2007
|
+
* @param fieldPath Declared filter field path.
|
|
2008
|
+
* @param label Diagnostic label for the invalid input.
|
|
1260
2009
|
*/
|
|
1261
2010
|
function validateSubscriptionPath(fieldPath, label) {
|
|
1262
2011
|
if (fieldPath.length === 0 || fieldPath.some((field) => field.trim().length === 0)) {
|
|
@@ -1269,7 +2018,13 @@ function validateSubscriptionPath(fieldPath, label) {
|
|
|
1269
2018
|
throw new ConnectError(`SubscriptionService.Subscribe ${label} path components may contain at most 128 characters.`, Code.InvalidArgument);
|
|
1270
2019
|
}
|
|
1271
2020
|
}
|
|
1272
|
-
|
|
2021
|
+
/**
|
|
2022
|
+
* Decodes a subscription ID filter using the target ID schema.
|
|
2023
|
+
*
|
|
2024
|
+
* @param value Packed subscription ID Any.
|
|
2025
|
+
* @param route Registered target route with schema and tenancy.
|
|
2026
|
+
* @returns The typed subscription filter ID.
|
|
2027
|
+
*/ function decodeSubscriptionIdValue(value, route) {
|
|
1273
2028
|
if (!isAny(value)) {
|
|
1274
2029
|
throw new ConnectError("SubscriptionService.Subscribe id_filter values must be packed Any messages.", Code.InvalidArgument);
|
|
1275
2030
|
}
|
|
@@ -1284,7 +2039,13 @@ function decodeSubscriptionIdValue(value, route) {
|
|
|
1284
2039
|
}
|
|
1285
2040
|
return decoded;
|
|
1286
2041
|
}
|
|
1287
|
-
|
|
2042
|
+
/**
|
|
2043
|
+
* Decodes a field-filter Any with its declared value schema.
|
|
2044
|
+
*
|
|
2045
|
+
* @param value Packed field-filter Any, if present.
|
|
2046
|
+
* @param schema Registered Protobuf message schema.
|
|
2047
|
+
* @returns The typed field-filter value.
|
|
2048
|
+
*/ function decodeFieldFilterValue(value, schema) {
|
|
1288
2049
|
if (value === undefined || schema === undefined) {
|
|
1289
2050
|
return decodeAnyValue(value);
|
|
1290
2051
|
}
|
|
@@ -1294,7 +2055,13 @@ function decodeFieldFilterValue(value, schema) {
|
|
|
1294
2055
|
}
|
|
1295
2056
|
return decoded;
|
|
1296
2057
|
}
|
|
1297
|
-
|
|
2058
|
+
/**
|
|
2059
|
+
* Decodes a known Any payload against its declared schema.
|
|
2060
|
+
*
|
|
2061
|
+
* @param value Packed Any payload, if present.
|
|
2062
|
+
* @param schema Registered Protobuf message schema.
|
|
2063
|
+
* @returns The decoded payload value.
|
|
2064
|
+
*/ function decodeAnyValue(value, schema) {
|
|
1298
2065
|
if (value === undefined) {
|
|
1299
2066
|
return undefined;
|
|
1300
2067
|
}
|
|
@@ -1316,6 +2083,11 @@ function decodeAnyValue(value, schema) {
|
|
|
1316
2083
|
}
|
|
1317
2084
|
/**
|
|
1318
2085
|
* Resolves a state filter path to generated message fields.
|
|
2086
|
+
*
|
|
2087
|
+
* @param schema Registered Protobuf message schema.
|
|
2088
|
+
* @param fieldPath Declared filter field path.
|
|
2089
|
+
* @param label Diagnostic label for the invalid input.
|
|
2090
|
+
* @returns The registered field at the declared path.
|
|
1319
2091
|
*/
|
|
1320
2092
|
function resolveMessagePath(schema, fieldPath, label) {
|
|
1321
2093
|
let currentSchema = schema;
|
|
@@ -1335,14 +2107,30 @@ function resolveMessagePath(schema, fieldPath, label) {
|
|
|
1335
2107
|
...(leafSchema === undefined ? {} : { leafSchema }),
|
|
1336
2108
|
});
|
|
1337
2109
|
}
|
|
1338
|
-
|
|
2110
|
+
/**
|
|
2111
|
+
* Finds a declared field in a registered Protobuf message.
|
|
2112
|
+
*
|
|
2113
|
+
* @param schema Registered Protobuf message schema.
|
|
2114
|
+
* @param name Configured header or field name.
|
|
2115
|
+
* @returns The matching field descriptor, if present.
|
|
2116
|
+
*/ function findMessageField(schema, name) {
|
|
1339
2117
|
const fields = (schema?.fields ?? []);
|
|
1340
2118
|
return fields.find((field) => field.name === name || field.localName === name);
|
|
1341
2119
|
}
|
|
1342
|
-
|
|
2120
|
+
/**
|
|
2121
|
+
* Recognizes a Protobuf message value.
|
|
2122
|
+
*
|
|
2123
|
+
* @param value Candidate value to inspect as a Protobuf message.
|
|
2124
|
+
* @returns Whether the value is a message.
|
|
2125
|
+
*/ function isMessage(value) {
|
|
1343
2126
|
return typeof value === "object" && value !== null && "$typeName" in value;
|
|
1344
2127
|
}
|
|
1345
|
-
|
|
2128
|
+
/**
|
|
2129
|
+
* Recognizes a Protobuf Any message by its declared type name.
|
|
2130
|
+
*
|
|
2131
|
+
* @param value Candidate value to inspect as a Protobuf Any.
|
|
2132
|
+
* @returns Whether the value is an Any message.
|
|
2133
|
+
*/ function isAny(value) {
|
|
1346
2134
|
return isMessage(value) && value.$typeName === "google.protobuf.Any";
|
|
1347
2135
|
}
|
|
1348
2136
|
/**
|
|
@@ -1359,13 +2147,28 @@ function packVersionedState(schema, result) {
|
|
|
1359
2147
|
version: result.version ?? create(VersionSchema),
|
|
1360
2148
|
});
|
|
1361
2149
|
}
|
|
1362
|
-
|
|
2150
|
+
/**
|
|
2151
|
+
* Extracts the declared tenant from a command actor context.
|
|
2152
|
+
*
|
|
2153
|
+
* @param command Framework command envelope.
|
|
2154
|
+
* @returns The declared command tenant, if present.
|
|
2155
|
+
*/ function commandTenant(command) {
|
|
1363
2156
|
return tenantValue(command.context?.actorContext?.tenantId);
|
|
1364
2157
|
}
|
|
1365
|
-
|
|
2158
|
+
/**
|
|
2159
|
+
* Extracts the declared tenant from a Topic actor context.
|
|
2160
|
+
*
|
|
2161
|
+
* @param topic Framework subscription Topic.
|
|
2162
|
+
* @returns The Topic tenant, if present.
|
|
2163
|
+
*/ function topicTenant(topic) {
|
|
1366
2164
|
return tenantValue(topic?.context?.tenantId);
|
|
1367
2165
|
}
|
|
1368
|
-
|
|
2166
|
+
/**
|
|
2167
|
+
* Extracts the tenant value from a Proto tenant identifier.
|
|
2168
|
+
*
|
|
2169
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
2170
|
+
* @returns The tenant value, if present.
|
|
2171
|
+
*/ function tenantValue(tenantId) {
|
|
1369
2172
|
if (tenantId === undefined)
|
|
1370
2173
|
return undefined;
|
|
1371
2174
|
try {
|
|
@@ -1375,7 +2178,13 @@ function tenantValue(tenantId) {
|
|
|
1375
2178
|
return undefined;
|
|
1376
2179
|
}
|
|
1377
2180
|
}
|
|
1378
|
-
|
|
2181
|
+
/**
|
|
2182
|
+
* Finds the declared Entity ID field for a registered state route.
|
|
2183
|
+
*
|
|
2184
|
+
* @param schema Registered Protobuf message schema.
|
|
2185
|
+
* @param idField Registered Entity ID field.
|
|
2186
|
+
* @returns The Entity ID field descriptor.
|
|
2187
|
+
*/ function stateRouteIdField(schema, idField) {
|
|
1379
2188
|
const field = findMessageField(schema, idField?.name ?? "") ??
|
|
1380
2189
|
findMessageField(schema, idField?.localName ?? "") ??
|
|
1381
2190
|
schema.fields[0] ??
|
|
@@ -1420,19 +2229,17 @@ const TenantStatuses = Object.freeze({
|
|
|
1420
2229
|
* @returns Connect failure preserving its stable Spine error type.
|
|
1421
2230
|
*/
|
|
1422
2231
|
unknown(error) {
|
|
1423
|
-
return
|
|
1424
|
-
{
|
|
1425
|
-
desc: ErrorSchema,
|
|
1426
|
-
value: create(ErrorSchema, {
|
|
1427
|
-
type: error.type,
|
|
1428
|
-
code: Code.NotFound,
|
|
1429
|
-
message: error.message,
|
|
1430
|
-
}),
|
|
1431
|
-
},
|
|
1432
|
-
], error);
|
|
2232
|
+
return ReadRequestStatus.unknownTenant(error);
|
|
1433
2233
|
},
|
|
1434
2234
|
});
|
|
1435
|
-
|
|
2235
|
+
/**
|
|
2236
|
+
* Checks a request tenant against bounded-context tenancy.
|
|
2237
|
+
*
|
|
2238
|
+
* @param multitenant Whether the bounded context requires a tenant.
|
|
2239
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
2240
|
+
* @param subject Verified request subject.
|
|
2241
|
+
* @returns The tenant mismatch status, if any.
|
|
2242
|
+
*/ function tenantMismatch(multitenant, tenantId, subject) {
|
|
1436
2243
|
if (multitenant) {
|
|
1437
2244
|
return tenantId === undefined
|
|
1438
2245
|
? {
|
|
@@ -1448,7 +2255,12 @@ function tenantMismatch(multitenant, tenantId, subject) {
|
|
|
1448
2255
|
message: `Tenant is not applicable for this ${subject}.`,
|
|
1449
2256
|
};
|
|
1450
2257
|
}
|
|
1451
|
-
|
|
2258
|
+
/**
|
|
2259
|
+
* Maps a command-post failure to its public structured status.
|
|
2260
|
+
*
|
|
2261
|
+
* @param error The failure being classified or propagated.
|
|
2262
|
+
* @returns The sanitized command status.
|
|
2263
|
+
*/ function commandPostError(error) {
|
|
1452
2264
|
if (error instanceof TransitionValidationError) {
|
|
1453
2265
|
return {
|
|
1454
2266
|
type: error.type,
|
|
@@ -1458,7 +2270,8 @@ function commandPostError(error) {
|
|
|
1458
2270
|
}
|
|
1459
2271
|
if (error instanceof CommandValidationError) {
|
|
1460
2272
|
return {
|
|
1461
|
-
type: "
|
|
2273
|
+
type: "spine.core.CommandValidationError",
|
|
2274
|
+
code: CommandValidationErrorCode.INVALID_COMMAND,
|
|
1462
2275
|
message: "Command payload validation failed.",
|
|
1463
2276
|
details: AnyPacker.pack(ValidationErrorSchema, error.validationError),
|
|
1464
2277
|
};
|
|
@@ -1543,6 +2356,24 @@ const VALUE_DECODERS = Object.freeze([
|
|
|
1543
2356
|
* Groups non-public service contract, query, and subscription operations.
|
|
1544
2357
|
*/
|
|
1545
2358
|
const ServiceValues = Object.freeze({
|
|
2359
|
+
/**
|
|
2360
|
+
* Maps supported Topic shape errors to structured public failures.
|
|
2361
|
+
*
|
|
2362
|
+
* @param topic Constraint-valid Topic to inspect.
|
|
2363
|
+
* @param route Registered state or Event route.
|
|
2364
|
+
*/
|
|
2365
|
+
checkTopicSemantics(topic, route) {
|
|
2366
|
+
try {
|
|
2367
|
+
ServiceValues.validateTopic(topic);
|
|
2368
|
+
ServiceValues.createSubscriptionShape(topic, route);
|
|
2369
|
+
}
|
|
2370
|
+
catch (error) {
|
|
2371
|
+
if (error instanceof ConnectError && error.code === Code.InvalidArgument) {
|
|
2372
|
+
throw ReadRequestStatus.invalid("topic", false, error.rawMessage);
|
|
2373
|
+
}
|
|
2374
|
+
throw new ConnectError("Topic validation failed.", Code.Internal);
|
|
2375
|
+
}
|
|
2376
|
+
},
|
|
1546
2377
|
/**
|
|
1547
2378
|
* Checks one composite query filter and its current traversal bounds.
|
|
1548
2379
|
*
|
|
@@ -1660,27 +2491,26 @@ const ServiceValues = Object.freeze({
|
|
|
1660
2491
|
* Builds a stable Command rejection acknowledgement.
|
|
1661
2492
|
*
|
|
1662
2493
|
* @param messageId The packed Command ID, if supplied.
|
|
1663
|
-
* @param type The
|
|
2494
|
+
* @param type The error type.
|
|
1664
2495
|
* @param message The public error message.
|
|
2496
|
+
* @param code Numeric Proto error code when available.
|
|
2497
|
+
* @param details Packed structured validation details when available.
|
|
1665
2498
|
* @returns A sanitized Ack.
|
|
1666
2499
|
*/
|
|
1667
|
-
commandErrorAck(messageId, type, message) {
|
|
1668
|
-
return create(AckSchema, { messageId, status: errorStatus(type, message) });
|
|
2500
|
+
commandErrorAck(messageId, type, message, code, details) {
|
|
2501
|
+
return create(AckSchema, { messageId, status: errorStatus(type, message, details, code) });
|
|
1669
2502
|
},
|
|
1670
2503
|
commandTenant,
|
|
1671
2504
|
createReadPlan,
|
|
1672
2505
|
createSubscriptionRecord,
|
|
1673
2506
|
createSubscriptionRemoval,
|
|
1674
2507
|
createSubscriptionShape,
|
|
1675
|
-
defaultQueueLimit: DEFAULT_QUEUE_LIMIT,
|
|
1676
2508
|
defaultSubscriptionLimit: DEFAULT_SUBSCRIPTION_LIMIT,
|
|
1677
2509
|
errorStatus,
|
|
1678
2510
|
okResponse,
|
|
1679
2511
|
okStatus,
|
|
1680
|
-
positiveInteger,
|
|
1681
2512
|
packVersionedState,
|
|
1682
|
-
|
|
1683
|
-
queryResultLimit: QueryLimits.resultCount,
|
|
2513
|
+
queryResultLimit: QUERY_SERVICE_MAX_RESULTS,
|
|
1684
2514
|
stateRouteIdField,
|
|
1685
2515
|
subscriptionLimit,
|
|
1686
2516
|
tenantMismatch,
|