@spine-event-engine/server 2.0.0-snapshot.25 → 2.0.0-snapshot.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -9
- package/REFERENCE.md +339 -22
- 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-read-runtime.d.ts +12 -1
- package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
- package/dist/agent/agent-live-read-runtime.js +5 -4
- package/dist/agent/agent-live-read-runtime.js.map +1 -1
- package/dist/browser/browser-server.d.ts +163 -0
- package/dist/browser/browser-server.d.ts.map +1 -1
- package/dist/browser/browser-server.js +579 -188
- 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/options.d.ts +5 -0
- package/dist/browser/options.d.ts.map +1 -1
- package/dist/bus/command-bus.d.ts +3 -0
- package/dist/bus/command-bus.d.ts.map +1 -1
- package/dist/bus/command-bus.js +69 -9
- 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 +3 -0
- package/dist/bus/event-bus.d.ts.map +1 -1
- package/dist/bus/event-bus.js +130 -8
- package/dist/bus/event-bus.js.map +1 -1
- package/dist/context/bounded-context.d.ts +29 -11
- package/dist/context/bounded-context.d.ts.map +1 -1
- package/dist/context/bounded-context.js +147 -186
- package/dist/context/bounded-context.js.map +1 -1
- 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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/repository/repository.d.ts +44 -1
- package/dist/repository/repository.d.ts.map +1 -1
- package/dist/repository/repository.js +368 -88
- package/dist/repository/repository.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 +554 -84
- 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 +17 -0
- package/dist/server/server-environment.d.ts.map +1 -1
- package/dist/server/server-environment.js +77 -16
- 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 +111 -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/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 +36 -7
- package/dist/services/spine-services.d.ts.map +1 -1
- package/dist/services/spine-services.js +900 -168
- package/dist/services/spine-services.js.map +1 -1
- package/dist/services/trusted-reader-ingress.d.ts +92 -0
- package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
- package/dist/services/trusted-reader-ingress.js +89 -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 +80 -42
- package/dist/stand/stand.d.ts.map +1 -1
- package/dist/stand/stand.js +410 -54
- 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 +14 -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, snapshotTrustedReader, } 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 } 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,20 @@ 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";
|
|
39
44
|
import { RegisteredTargets } from "./registered-targets.js";
|
|
40
45
|
import { emitServerError, emitServerWarning } from "../server/server-log.js";
|
|
46
|
+
import { ServerEnvironment } from "../server/server-environment.js";
|
|
41
47
|
import { managedChildSubscriptionAccess } from "../server/managed-child-subscription.js";
|
|
42
48
|
import {} from "../stand/subscription-registry.js";
|
|
49
|
+
import { ReadSideFilterEngine } from "../stand/read-side-filter-engine.js";
|
|
50
|
+
import { SubscriptionReadFilters, } from "../stand/subscription-read-filters.js";
|
|
43
51
|
const serviceLoggers = new WeakMap();
|
|
44
52
|
const serviceInstances = new WeakSet();
|
|
53
|
+
const serviceAppQueries = new WeakMap();
|
|
54
|
+
const serviceReadFilters = new WeakMap();
|
|
45
55
|
/**
|
|
46
56
|
* Small route registrar for the first public Spine gRPC service slice.
|
|
47
57
|
*
|
|
@@ -58,7 +68,8 @@ const serviceInstances = new WeakSet();
|
|
|
58
68
|
* Queries allow at most 100 IDs, 16 simple filters, 8 composites, and 8 orderings.
|
|
59
69
|
* Use proto column names such as `open_task_count`, not generated TS local names
|
|
60
70
|
* such as `openTaskCount`. Invalid columns, values, operators, and query shapes
|
|
61
|
-
*
|
|
71
|
+
* reject with structured `INVALID_ARGUMENT` and `INVALID_QUERY` details before
|
|
72
|
+
* Stand storage is read.
|
|
62
73
|
*
|
|
63
74
|
* `SubscriptionService.Subscribe` accepts known state targets with
|
|
64
75
|
* `include_all` or validated ID/field filters and known event targets exposed
|
|
@@ -74,7 +85,9 @@ const serviceInstances = new WeakSet();
|
|
|
74
85
|
* rejected-command payload forms and stack trace from their client-facing
|
|
75
86
|
* context.
|
|
76
87
|
* Application code remains on generated domain event messages through handler
|
|
77
|
-
* dispatch. Unknown or
|
|
88
|
+
* dispatch. Unknown or expired activation IDs fail with `NOT_FOUND` and
|
|
89
|
+
* `UNKNOWN_SUBSCRIPTION` details. A duplicate active activation completes
|
|
90
|
+
* without another update stream.
|
|
78
91
|
* Cancellation of unknown or already-cleaned IDs returns OK after admission to
|
|
79
92
|
* the bounded unknown-removal pool.
|
|
80
93
|
*/
|
|
@@ -87,8 +100,10 @@ export class SpineServices {
|
|
|
87
100
|
#activationTails = new Map();
|
|
88
101
|
#removals = new Map();
|
|
89
102
|
#unknownRemovals = new Set();
|
|
90
|
-
#
|
|
103
|
+
#queueCapacity;
|
|
91
104
|
#subscriptionLimit;
|
|
105
|
+
#readerAdmission;
|
|
106
|
+
#readMaxBytes;
|
|
92
107
|
/**
|
|
93
108
|
* Creates service adapters over built bounded contexts.
|
|
94
109
|
*
|
|
@@ -97,8 +112,10 @@ export class SpineServices {
|
|
|
97
112
|
constructor(options) {
|
|
98
113
|
serviceInstances.add(this);
|
|
99
114
|
this.#contexts = Object.freeze([...options.contexts]);
|
|
100
|
-
this.#
|
|
115
|
+
this.#queueCapacity = ServerEnvironment.instance().subscriptionQueueCapacity;
|
|
101
116
|
this.#subscriptionLimit = ServiceValues.subscriptionLimit(options.subscriptionLimit ?? ServiceValues.defaultSubscriptionLimit);
|
|
117
|
+
this.#readerAdmission = options.readerAdmission;
|
|
118
|
+
this.#readMaxBytes = options.readMaxBytes ?? 4_194_304;
|
|
102
119
|
this.#registerSignalRoutes();
|
|
103
120
|
this.#registerStateRoutes(new RegisteredTargets(this.#contexts));
|
|
104
121
|
}
|
|
@@ -151,18 +168,119 @@ export class SpineServices {
|
|
|
151
168
|
* @returns Returns the registered router.
|
|
152
169
|
*/
|
|
153
170
|
register(router) {
|
|
171
|
+
this.#registerUnary(router);
|
|
172
|
+
this.#registerSubscriptions(router);
|
|
173
|
+
return router;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Registers admitted Command and Query routes.
|
|
177
|
+
*
|
|
178
|
+
* @param router Connect service router.
|
|
179
|
+
*/
|
|
180
|
+
#registerUnary(router) {
|
|
154
181
|
router.service(CommandService, {
|
|
155
|
-
post: (command) =>
|
|
182
|
+
post: async (command, context) => {
|
|
183
|
+
await this.#admit("spine.client.CommandService", "Post", toBinary(CommandService.method.post.input, command), context);
|
|
184
|
+
return this.#post(command);
|
|
185
|
+
},
|
|
156
186
|
});
|
|
157
187
|
router.service(QueryService, {
|
|
158
|
-
read: (query) =>
|
|
188
|
+
read: async (query, context) => {
|
|
189
|
+
const detached = clone(QuerySchema, query);
|
|
190
|
+
const reader = await this.#admit("spine.client.QueryService", "Read", toBinary(QuerySchema, detached), context);
|
|
191
|
+
return this.#read(detached, reader, context?.signal);
|
|
192
|
+
},
|
|
193
|
+
execute: async (query, context) => {
|
|
194
|
+
const detached = clone(QueryService.method.execute.input, query);
|
|
195
|
+
const reader = await this.#admit("spine.client.QueryService", "Execute", toBinary(QueryService.method.execute.input, detached), context);
|
|
196
|
+
return this.#executeAppQuery(detached, reader, context.signal);
|
|
197
|
+
},
|
|
159
198
|
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Executes a detached App Query with facts established by a trusted admission port.
|
|
202
|
+
*
|
|
203
|
+
* @param query Captured application query envelope.
|
|
204
|
+
* @param reader Verified current invocation facts.
|
|
205
|
+
* @param signal Current invocation cancellation, when available.
|
|
206
|
+
* @returns One paired response from the Server-selected handler.
|
|
207
|
+
*/
|
|
208
|
+
#executeAppQuery(query, reader, signal) {
|
|
209
|
+
const queries = serviceAppQueries.get(this);
|
|
210
|
+
if (queries === undefined)
|
|
211
|
+
return Promise.reject(new ConnectError("App Query execution is unavailable.", Code.Unimplemented));
|
|
212
|
+
return queries.execute(query, reader, signal);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Registers reader-bound subscription routes.
|
|
216
|
+
*
|
|
217
|
+
* @param router Connect service router.
|
|
218
|
+
*/
|
|
219
|
+
#registerSubscriptions(router) {
|
|
160
220
|
router.service(SubscriptionService, {
|
|
161
|
-
subscribe: (topic) => this.#
|
|
162
|
-
activate: (subscription) => this.#
|
|
163
|
-
cancel: (subscription) => this.#
|
|
221
|
+
subscribe: (topic, context) => this.#subscribeAdmitted(topic, context),
|
|
222
|
+
activate: (subscription, context) => this.#activateAdmitted(subscription, context),
|
|
223
|
+
cancel: (subscription, context) => this.#cancelAdmitted(subscription, context),
|
|
164
224
|
});
|
|
165
|
-
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Keeps direct in-process Subscribe validation synchronous while admitting native peers.
|
|
228
|
+
*
|
|
229
|
+
* @param topic Requested subscription topic.
|
|
230
|
+
* @param context Native transport context, absent for direct in-process calls.
|
|
231
|
+
* @returns Subscription or admitted native operation promise.
|
|
232
|
+
*/
|
|
233
|
+
#subscribeAdmitted(topic, context) {
|
|
234
|
+
if (context === undefined)
|
|
235
|
+
return this.#subscribe(topic, { kind: "anonymous" });
|
|
236
|
+
return this.#admit("spine.client.SubscriptionService", "Subscribe", toBinary(TopicSchema, topic), context).then((reader) => this.#subscribe(topic, ReaderIdentities.from(reader)));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Keeps direct in-process Cancel synchronous while admitting native peers.
|
|
240
|
+
*
|
|
241
|
+
* @param subscription Requested subscription definition.
|
|
242
|
+
* @param context Native transport context, absent for direct in-process calls.
|
|
243
|
+
* @returns Cancel acknowledgment or admitted native operation promise.
|
|
244
|
+
*/
|
|
245
|
+
#cancelAdmitted(subscription, context) {
|
|
246
|
+
if (context === undefined)
|
|
247
|
+
return this.#cancel(subscription, { kind: "anonymous" });
|
|
248
|
+
return this.#admit("spine.client.SubscriptionService", "Cancel", toBinary(SubscriptionSchema, subscription), context).then((reader) => this.#cancel(subscription, ReaderIdentities.from(reader)));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Verifies one native reader assertion before service side effects.
|
|
252
|
+
*
|
|
253
|
+
* @param service Canonical RPC service.
|
|
254
|
+
* @param method Canonical RPC method.
|
|
255
|
+
* @param bytes Canonical request bytes.
|
|
256
|
+
* @param context Native transport context when called through Connect.
|
|
257
|
+
* @returns Detached trusted reader facts.
|
|
258
|
+
*/
|
|
259
|
+
async #admit(service, method, bytes, context) {
|
|
260
|
+
if (context === undefined)
|
|
261
|
+
return Object.freeze({});
|
|
262
|
+
return TrustedReaderIngress.admit({
|
|
263
|
+
service,
|
|
264
|
+
method,
|
|
265
|
+
request: bytes,
|
|
266
|
+
headers: context.requestHeader,
|
|
267
|
+
peer: context,
|
|
268
|
+
maxBytes: this.#readMaxBytes,
|
|
269
|
+
...this.#readerAdmission,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Verifies activation before entering the subscription lifecycle generator.
|
|
274
|
+
*
|
|
275
|
+
* @param subscription Native activation request.
|
|
276
|
+
* @param context Native transport context.
|
|
277
|
+
* @returns Admitted subscription update stream.
|
|
278
|
+
*/
|
|
279
|
+
async *#activateAdmitted(subscription, context) {
|
|
280
|
+
const captured = clone(SubscriptionSchema, subscription);
|
|
281
|
+
const signal = context?.signal;
|
|
282
|
+
const reader = await this.#admit("spine.client.SubscriptionService", "Activate", toBinary(SubscriptionSchema, captured), context);
|
|
283
|
+
yield* this.#activate(captured, snapshotTrustedReader(reader), signal);
|
|
166
284
|
}
|
|
167
285
|
/**
|
|
168
286
|
* Resolves a public Command route before attempting dispatch.
|
|
@@ -172,13 +290,28 @@ export class SpineServices {
|
|
|
172
290
|
*/
|
|
173
291
|
async #post(command) {
|
|
174
292
|
const messageId = command.id && AnyPacker.pack(CommandIdSchema, command.id);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
293
|
+
try {
|
|
294
|
+
CommandValidationError.checkEnvelope(command);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const failure = ServiceValues.commandPostError(error);
|
|
298
|
+
return ServiceValues.commandErrorAck(messageId, failure.type, failure.message, failure.code, failure.details);
|
|
299
|
+
}
|
|
300
|
+
const typeUrl = command.message.typeUrl;
|
|
178
301
|
const admission = this.#commandAdmission(command, messageId, typeUrl);
|
|
179
302
|
if ("ack" in admission)
|
|
180
303
|
return admission.ack;
|
|
181
|
-
|
|
304
|
+
return this.#dispatchCommand(admission.route, command, messageId);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Dispatches an admitted Command and maps its completed or failed outcome.
|
|
308
|
+
*
|
|
309
|
+
* @param route Selected Command route.
|
|
310
|
+
* @param command Validated Command envelope.
|
|
311
|
+
* @param messageId Packed ID for the acknowledgement.
|
|
312
|
+
* @returns The completed Command acknowledgement.
|
|
313
|
+
*/
|
|
314
|
+
async #dispatchCommand(route, command, messageId) {
|
|
182
315
|
try {
|
|
183
316
|
await route.context.commandBus().post(command);
|
|
184
317
|
return create(AckSchema, {
|
|
@@ -193,7 +326,7 @@ export class SpineServices {
|
|
|
193
326
|
this.#logCommandPostFailure(route, command, error);
|
|
194
327
|
return create(AckSchema, {
|
|
195
328
|
messageId,
|
|
196
|
-
status: ServiceValues.errorStatus(postError.type, postError.message, postError.details),
|
|
329
|
+
status: ServiceValues.errorStatus(postError.type, postError.message, postError.details, postError.code),
|
|
197
330
|
});
|
|
198
331
|
}
|
|
199
332
|
}
|
|
@@ -209,7 +342,7 @@ export class SpineServices {
|
|
|
209
342
|
const route = this.#commandRoutes.get(typeUrl);
|
|
210
343
|
if (route === undefined) {
|
|
211
344
|
return {
|
|
212
|
-
ack: ServiceValues.commandErrorAck(messageId, "
|
|
345
|
+
ack: ServiceValues.commandErrorAck(messageId, "spine.core.CommandValidationError", "No bounded context accepted the command.", CommandValidationErrorCode.UNSUPPORTED_COMMAND),
|
|
213
346
|
};
|
|
214
347
|
}
|
|
215
348
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, ServiceValues.commandTenant(command), "command");
|
|
@@ -246,19 +379,23 @@ export class SpineServices {
|
|
|
246
379
|
* Validates a public state query before reading its registered Stand.
|
|
247
380
|
*
|
|
248
381
|
* @param query Public query request.
|
|
249
|
-
* @
|
|
382
|
+
* @param reader Verified facts from current admission.
|
|
383
|
+
* @param signal Current request cancellation.
|
|
384
|
+
* @returns States for an admitted Query.
|
|
385
|
+
* @throws ConnectError for invalid input or a sanitized read failure.
|
|
250
386
|
*/
|
|
251
|
-
async #read(query) {
|
|
387
|
+
async #read(query, reader, signal) {
|
|
388
|
+
ReadRequestStatus.check("query", () => Validate.check(QuerySchema, query));
|
|
252
389
|
const target = query.target;
|
|
253
390
|
const route = this.#readRoute(target);
|
|
254
391
|
if (target === undefined || route === undefined) {
|
|
255
|
-
|
|
392
|
+
throw ReadRequestStatus.invalid("query", true, "No bounded context owns query target.");
|
|
256
393
|
}
|
|
257
394
|
const queryError = ServiceValues.validateReadQuery(query, target, route);
|
|
258
395
|
if (queryError !== undefined) {
|
|
259
|
-
|
|
396
|
+
throw ReadRequestStatus.invalid("query", false, queryError.message);
|
|
260
397
|
}
|
|
261
|
-
return this.#readValidated(query, target, route);
|
|
398
|
+
return this.#readValidated(query, target, route, reader, signal);
|
|
262
399
|
}
|
|
263
400
|
/**
|
|
264
401
|
* Resolves the effective tenant and executes an already validated read.
|
|
@@ -266,49 +403,96 @@ export class SpineServices {
|
|
|
266
403
|
* @param query Validated public query.
|
|
267
404
|
* @param target Registered query target.
|
|
268
405
|
* @param route Route containing the target Stand and schema.
|
|
269
|
-
* @
|
|
406
|
+
* @param reader Verified current invocation facts.
|
|
407
|
+
* @param signal Current request cancellation.
|
|
408
|
+
* @returns States when tenant admission and storage read succeed.
|
|
270
409
|
*/
|
|
271
|
-
async #readValidated(query, target, route) {
|
|
410
|
+
async #readValidated(query, target, route, reader, signal) {
|
|
272
411
|
const tenantId = ServiceValues.tenantValue(query.context?.tenantId);
|
|
273
412
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, tenantId, "query");
|
|
274
413
|
if (tenantError !== undefined) {
|
|
275
|
-
|
|
414
|
+
throw ReadRequestStatus.tenant(tenantError.type, tenantError.message);
|
|
276
415
|
}
|
|
277
416
|
try {
|
|
278
417
|
const effective = EffectiveTenants.current(route.context.isMultitenant, tenantId);
|
|
279
418
|
const destination = EffectiveTenants.destination(effective, route.context.isMultitenant);
|
|
280
|
-
return await this.#query(route, ServiceValues.createReadPlan(target, query, route), destination);
|
|
419
|
+
return await this.#query(route, ServiceValues.createReadPlan(target, query, route), destination, query, reader, signal);
|
|
281
420
|
}
|
|
282
421
|
catch (error) {
|
|
283
422
|
if (error instanceof TenantUnknownError)
|
|
284
423
|
throw TenantStatuses.unknown(error);
|
|
285
|
-
|
|
424
|
+
throw ReadRequestStatus.technical(error, "Query read failed.");
|
|
286
425
|
}
|
|
287
426
|
}
|
|
288
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Finds the registered Entity state route for a query target.
|
|
429
|
+
*
|
|
430
|
+
* @param target Declared query or Topic target.
|
|
431
|
+
* @returns The matching read route, if present.
|
|
432
|
+
*/ #readRoute(target) {
|
|
289
433
|
return target === undefined ? undefined : this.#stateRoutes.get(target.type);
|
|
290
434
|
}
|
|
291
|
-
|
|
292
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Executes a validated Entity state read through its registered route.
|
|
437
|
+
*
|
|
438
|
+
* @param route Registered target route with schema and tenancy.
|
|
439
|
+
* @param plan Validated Entity read plan.
|
|
440
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
441
|
+
* @param query Validated public query with effective actor data.
|
|
442
|
+
* @param reader Verified current invocation facts.
|
|
443
|
+
* @param signal Current request cancellation.
|
|
444
|
+
* @returns The Entity query response.
|
|
445
|
+
*/ async #query(route, plan, tenantId, query, reader, signal) {
|
|
446
|
+
const facts = {
|
|
447
|
+
operation: { kind: "query", query },
|
|
448
|
+
...(query.context === undefined ? {} : { actorContext: query.context }),
|
|
449
|
+
...(tenantId === undefined ? {} : { effectiveTenant: tenantId }),
|
|
450
|
+
producingContext: route.context,
|
|
451
|
+
...reader,
|
|
452
|
+
...(signal === undefined ? {} : { signal }),
|
|
453
|
+
};
|
|
454
|
+
const results = await QueryReader.read(route.context.stand(), route.schema, plan, tenantId, ServiceValues.queryResultLimit, query, facts);
|
|
455
|
+
const visible = await this.#visibleStates(route, results, facts);
|
|
293
456
|
return create(QueryResponseSchema, {
|
|
294
457
|
response: ServiceValues.okResponse(),
|
|
295
|
-
message:
|
|
458
|
+
message: visible.map((result) => ServiceValues.packVersionedState(route.schema, result)),
|
|
296
459
|
});
|
|
297
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Applies this Server's outward policy after BC source policy and storage work.
|
|
463
|
+
*
|
|
464
|
+
* @param route Registered Entity result schema.
|
|
465
|
+
* @param results States returned by the producing context.
|
|
466
|
+
* @param facts Verified current Query facts.
|
|
467
|
+
* @returns Surviving states without replenishing removed records.
|
|
468
|
+
*/
|
|
469
|
+
async #visibleStates(route, results, facts) {
|
|
470
|
+
const filters = serviceReadFilters.get(this);
|
|
471
|
+
if (filters === undefined)
|
|
472
|
+
return results;
|
|
473
|
+
const visible = [];
|
|
474
|
+
for (const result of results) {
|
|
475
|
+
const state = await filters.apply(route.schema, result.state, facts);
|
|
476
|
+
if (state !== null)
|
|
477
|
+
visible.push({ ...result, state });
|
|
478
|
+
}
|
|
479
|
+
return visible;
|
|
480
|
+
}
|
|
298
481
|
/**
|
|
299
482
|
* Validates a topic synchronously and checks live membership before registry creation.
|
|
300
483
|
*
|
|
301
484
|
* @param topic Requested subscription topic.
|
|
302
485
|
* @returns Created subscription after admission.
|
|
486
|
+
* @param reader The verified reader facts for this operation.
|
|
303
487
|
*/
|
|
304
|
-
#subscribe(topic) {
|
|
488
|
+
#subscribe(topic, reader) {
|
|
489
|
+
ReadRequestStatus.check("topic", () => Validate.check(TopicSchema, topic));
|
|
305
490
|
const route = this.#subscriptionRoute(topic);
|
|
306
|
-
ServiceValues.
|
|
307
|
-
ServiceValues.createSubscriptionShape(topic, route);
|
|
491
|
+
ServiceValues.checkTopicSemantics(topic, route);
|
|
308
492
|
const tenantId = ServiceValues.topicTenant(topic);
|
|
309
493
|
const tenantError = ServiceValues.tenantMismatch(route.context.isMultitenant, tenantId, "subscription");
|
|
310
494
|
if (tenantError !== undefined) {
|
|
311
|
-
throw
|
|
495
|
+
throw ReadRequestStatus.tenant(tenantError.type, tenantError.message);
|
|
312
496
|
}
|
|
313
497
|
const subscription = create(SubscriptionSchema, {
|
|
314
498
|
id: create(SubscriptionIdSchema, { value: `s-${randomUUID()}` }),
|
|
@@ -320,46 +504,115 @@ export class SpineServices {
|
|
|
320
504
|
}
|
|
321
505
|
const registry = boundedContextAccess.subscriptionRegistry(route.context);
|
|
322
506
|
return TenantStatuses.require(route.context, tenantId, async () => {
|
|
323
|
-
await registry.create(subscription);
|
|
507
|
+
await registry.create(subscription, reader);
|
|
324
508
|
}).then(() => clone(SubscriptionSchema, subscription));
|
|
325
509
|
}
|
|
326
|
-
|
|
510
|
+
/**
|
|
511
|
+
* Verifies membership and activates a retained subscription.
|
|
512
|
+
*
|
|
513
|
+
* @param subscription The subscription definition being processed.
|
|
514
|
+
* @param reader The verified reader facts for this operation.
|
|
515
|
+
* @returns The update stream after admission.
|
|
516
|
+
*/ async *#activate(subscription, reader, signal) {
|
|
517
|
+
ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
|
|
518
|
+
ReadRequestStatus.checkLifecycleCriterion(subscription);
|
|
327
519
|
const id = subscription.id?.value;
|
|
328
520
|
if (id === undefined) {
|
|
329
521
|
return;
|
|
330
522
|
}
|
|
331
|
-
const record = await this.#activationRecord(id);
|
|
523
|
+
const record = await this.#activationRecord(id, ReaderIdentities.from(reader), signal);
|
|
332
524
|
if (record === undefined)
|
|
333
525
|
return;
|
|
526
|
+
yield* this.#streamSubscription(record, reader);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Delivers one activation in source order while honoring its cancellation.
|
|
530
|
+
*
|
|
531
|
+
* @param record Attached subscription and existing admission queue.
|
|
532
|
+
* @param reader Verified facts of this activation.
|
|
533
|
+
* @returns ACK followed by surviving filtered data updates.
|
|
534
|
+
*/
|
|
535
|
+
async *#streamSubscription(record, reader) {
|
|
334
536
|
try {
|
|
537
|
+
const facts = _a.#subscriptionFacts(record, reader);
|
|
538
|
+
if (record.delivery.closed)
|
|
539
|
+
return;
|
|
335
540
|
yield create(SubscriptionUpdateSchema, {
|
|
336
541
|
subscription: clone(SubscriptionSchema, record.subscription),
|
|
337
542
|
response: ServiceValues.okResponse(),
|
|
338
543
|
});
|
|
339
544
|
for (;;) {
|
|
340
|
-
const
|
|
341
|
-
if (
|
|
545
|
+
const work = await record.delivery.next();
|
|
546
|
+
if (work === undefined)
|
|
342
547
|
return;
|
|
548
|
+
const update = await record.delivery.settle(this.#filteredSubscription(work, facts));
|
|
549
|
+
if (update === undefined)
|
|
550
|
+
return;
|
|
551
|
+
if (update !== null) {
|
|
552
|
+
if (record.delivery.signal.aborted)
|
|
553
|
+
return;
|
|
554
|
+
yield update;
|
|
343
555
|
}
|
|
344
|
-
|
|
556
|
+
record.delivery.advance();
|
|
345
557
|
}
|
|
346
558
|
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
record.delivery.close();
|
|
561
|
+
throw ReadRequestStatus.technical(error, "Subscription filtering failed.");
|
|
562
|
+
}
|
|
347
563
|
finally {
|
|
348
|
-
if (
|
|
349
|
-
|
|
564
|
+
if (this.#subscriptions.get(record.id) === record) {
|
|
565
|
+
if (record.delivery.closed)
|
|
566
|
+
this.#removeDetachedSubscription(record.id);
|
|
567
|
+
else
|
|
568
|
+
await this.#removeSubscription(record.id);
|
|
350
569
|
}
|
|
351
570
|
}
|
|
352
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Captures the original Topic and authenticated activation facts for policy code.
|
|
574
|
+
*
|
|
575
|
+
* @param record Current source route and canonical subscription.
|
|
576
|
+
* @param reader Verified facts of this activation.
|
|
577
|
+
* @returns Subscription operation context with a live cancellation signal.
|
|
578
|
+
*/
|
|
579
|
+
static #subscriptionFacts(record, reader) {
|
|
580
|
+
const topic = record.subscription.topic;
|
|
581
|
+
if (topic === undefined)
|
|
582
|
+
throw new TypeError("Activated subscription Topic is missing.");
|
|
583
|
+
return {
|
|
584
|
+
operation: { kind: "subscription", subscription: record.subscription, topic },
|
|
585
|
+
...(topic.context === undefined ? {} : { actorContext: topic.context }),
|
|
586
|
+
...(record.tenantId === undefined ? {} : { effectiveTenant: record.tenantId }),
|
|
587
|
+
producingContext: record.route.context,
|
|
588
|
+
...reader,
|
|
589
|
+
signal: record.delivery.signal,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Applies the producing BC policy before this Server's final policy.
|
|
594
|
+
*
|
|
595
|
+
* @param work Queued matched update and lazy BC capability.
|
|
596
|
+
* @param facts Trusted activation and original Topic facts.
|
|
597
|
+
* @returns Surviving update or null for an empty domain-data batch.
|
|
598
|
+
*/
|
|
599
|
+
async #filteredSubscription(work, facts) {
|
|
600
|
+
const source = await work.filter(facts);
|
|
601
|
+
return source === null
|
|
602
|
+
? null
|
|
603
|
+
: SubscriptionReadFilters.apply(source, serviceReadFilters.get(this), facts);
|
|
604
|
+
}
|
|
353
605
|
/**
|
|
354
606
|
* Serializes one native activation and returns its attached record.
|
|
355
607
|
*
|
|
356
608
|
* @param id The logical subscription identifier.
|
|
357
609
|
* @returns The attached record, or undefined when admission fails.
|
|
610
|
+
* @param reader The verified reader facts for this operation.
|
|
358
611
|
*/
|
|
359
|
-
async #activationRecord(id) {
|
|
612
|
+
async #activationRecord(id, reader, signal) {
|
|
360
613
|
let attached;
|
|
361
614
|
await this.#serializeActivation(id, async () => {
|
|
362
|
-
attached = await this.#startActivationRecord(id);
|
|
615
|
+
attached = await this.#startActivationRecord(id, reader, signal);
|
|
363
616
|
});
|
|
364
617
|
return attached;
|
|
365
618
|
}
|
|
@@ -368,14 +621,17 @@ export class SpineServices {
|
|
|
368
621
|
*
|
|
369
622
|
* @param id The logical subscription identifier.
|
|
370
623
|
* @returns The attached record, or undefined when unavailable.
|
|
624
|
+
* @param reader The verified reader facts for this operation.
|
|
371
625
|
*/
|
|
372
|
-
async #startActivationRecord(id) {
|
|
626
|
+
async #startActivationRecord(id, reader, signal) {
|
|
373
627
|
const record = this.#subscriptions.get(id) ?? (await this.#findSubscription(id));
|
|
374
628
|
if (record === undefined)
|
|
375
|
-
|
|
629
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
630
|
+
if (!ReaderIdentities.matches(record.reader, reader))
|
|
631
|
+
throw this.#readerDenied();
|
|
376
632
|
let attached;
|
|
377
633
|
await this.#requireSubscriptionTenant(record, async () => {
|
|
378
|
-
attached = await this.#activateKnownRecord(record, id);
|
|
634
|
+
attached = await this.#activateKnownRecord(record, id, reader, signal);
|
|
379
635
|
});
|
|
380
636
|
return attached;
|
|
381
637
|
}
|
|
@@ -385,21 +641,32 @@ export class SpineServices {
|
|
|
385
641
|
* @param record Discovered subscription record.
|
|
386
642
|
* @param id Canonical subscription ID.
|
|
387
643
|
* @returns Attached record, if activation remains eligible.
|
|
644
|
+
* @param reader The verified reader facts for this operation.
|
|
388
645
|
*/
|
|
389
|
-
async #activateKnownRecord(record, id) {
|
|
646
|
+
async #activateKnownRecord(record, id, reader, signal) {
|
|
390
647
|
if (record.delivery.active)
|
|
391
648
|
return undefined;
|
|
392
649
|
const activation = await boundedContextAccess
|
|
393
650
|
.subscriptionRegistry(record.route.context)
|
|
394
|
-
.activate(create(SubscriptionIdSchema, { value: id }));
|
|
395
|
-
if (activation.kind === "
|
|
651
|
+
.activate(create(SubscriptionIdSchema, { value: id }), reader);
|
|
652
|
+
if (activation.kind === "denied")
|
|
653
|
+
throw this.#readerDenied();
|
|
654
|
+
if (!("entry" in activation))
|
|
655
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
656
|
+
if (!equals(SubscriptionSchema, record.subscription, activation.entry.subscription))
|
|
657
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
658
|
+
if (record.delivery.closed)
|
|
396
659
|
return undefined;
|
|
397
660
|
try {
|
|
398
661
|
this.#subscriptions.set(id, record);
|
|
399
|
-
|
|
662
|
+
record.delivery.track(signal);
|
|
663
|
+
await this.#activateRecord(record, activation.entry);
|
|
664
|
+
await this.#checkAttachedRecord(record, id, reader, signal);
|
|
400
665
|
return record;
|
|
401
666
|
}
|
|
402
667
|
catch (error) {
|
|
668
|
+
if (record.delivery.invalidated)
|
|
669
|
+
throw error;
|
|
403
670
|
try {
|
|
404
671
|
await this.#removeSubscription(id);
|
|
405
672
|
}
|
|
@@ -409,7 +676,30 @@ export class SpineServices {
|
|
|
409
676
|
throw error;
|
|
410
677
|
}
|
|
411
678
|
}
|
|
412
|
-
|
|
679
|
+
/**
|
|
680
|
+
* Rejects an entry removed or replaced before its first activation ACK.
|
|
681
|
+
* @param record Attached delivery that may have been invalidated.
|
|
682
|
+
* @param id Canonical subscription ID.
|
|
683
|
+
* @param reader Reader admitted by the earlier activation.
|
|
684
|
+
* @param signal Transport cancellation, which ends the stream normally.
|
|
685
|
+
*/
|
|
686
|
+
async #checkAttachedRecord(record, id, reader, signal) {
|
|
687
|
+
if (!record.delivery.invalidated || signal?.aborted === true)
|
|
688
|
+
return;
|
|
689
|
+
const current = await boundedContextAccess
|
|
690
|
+
.subscriptionRegistry(record.route.context)
|
|
691
|
+
.get(create(SubscriptionIdSchema, { value: id }));
|
|
692
|
+
if (current !== undefined && !ReaderIdentities.matches(current.reader, reader))
|
|
693
|
+
throw this.#readerDenied();
|
|
694
|
+
throw ReadRequestStatus.unknownSubscription();
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Serializes activation callbacks for the same subscription ID.
|
|
698
|
+
*
|
|
699
|
+
* @param id The stable identifier for this record.
|
|
700
|
+
* @param work Accepted lifecycle operation.
|
|
701
|
+
* @returns Completion after the operation settles.
|
|
702
|
+
*/ async #serializeActivation(id, work) {
|
|
413
703
|
const previous = this.#activationTails.get(id) ?? Promise.resolve();
|
|
414
704
|
let finish = () => undefined;
|
|
415
705
|
const settled = new Promise((resolve) => {
|
|
@@ -432,79 +722,131 @@ export class SpineServices {
|
|
|
432
722
|
*
|
|
433
723
|
* @param subscription Subscription identity supplied by the caller.
|
|
434
724
|
* @returns Cancellation response after any required membership check.
|
|
725
|
+
* @param reader The verified reader facts for this operation.
|
|
435
726
|
*/
|
|
436
|
-
#cancel(subscription) {
|
|
727
|
+
#cancel(subscription, reader) {
|
|
728
|
+
ReadRequestStatus.check("subscription", () => Validate.check(SubscriptionSchema, subscription));
|
|
729
|
+
ReadRequestStatus.checkLifecycleCriterion(subscription);
|
|
437
730
|
const id = subscription.id?.value;
|
|
438
731
|
if (id === undefined) {
|
|
439
732
|
return ServiceValues.okResponse();
|
|
440
733
|
}
|
|
441
|
-
return this.#cancelKnown(id);
|
|
734
|
+
return this.#cancelKnown(id, reader);
|
|
442
735
|
}
|
|
443
736
|
/**
|
|
444
737
|
* Checks the canonical record's tenant before cancellation side effects.
|
|
445
738
|
*
|
|
446
739
|
* @param id Subscription ID with a possible shared-registry record.
|
|
447
740
|
* @returns Successful cancellation response after removal.
|
|
741
|
+
* @param reader The verified reader facts for this operation.
|
|
448
742
|
*/
|
|
449
|
-
async #cancelKnown(id) {
|
|
450
|
-
const
|
|
743
|
+
async #cancelKnown(id, reader) {
|
|
744
|
+
const live = this.#subscriptions.get(id);
|
|
745
|
+
const record = live ?? (await this.#findSubscription(id));
|
|
451
746
|
if (record === undefined)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
747
|
+
return ServiceValues.okResponse();
|
|
748
|
+
if (!ReaderIdentities.matches(record.reader, reader))
|
|
749
|
+
throw this.#readerDenied();
|
|
750
|
+
await this.#requireSubscriptionTenant(record, async () => {
|
|
751
|
+
if (live !== undefined)
|
|
752
|
+
return this.#removeSubscription(id, reader);
|
|
753
|
+
await this.#registryOperation(record.route.context, async () => {
|
|
754
|
+
const outcome = await boundedContextAccess
|
|
755
|
+
.subscriptionRegistry(record.route.context)
|
|
756
|
+
.delete(create(SubscriptionIdSchema, { value: id }), reader);
|
|
757
|
+
if (outcome === "denied")
|
|
758
|
+
throw this.#readerDenied();
|
|
759
|
+
});
|
|
760
|
+
});
|
|
455
761
|
return ServiceValues.okResponse();
|
|
456
762
|
}
|
|
763
|
+
/**
|
|
764
|
+
* Returns a sanitized denial for a foreign or legacy-bound subscription.
|
|
765
|
+
*
|
|
766
|
+
* @returns The reader authorization error.
|
|
767
|
+
*/
|
|
768
|
+
#readerDenied() {
|
|
769
|
+
return new ConnectError("Subscription reader is not authorized.", Code.PermissionDenied);
|
|
770
|
+
}
|
|
457
771
|
/**
|
|
458
772
|
* Checks the discovered subscription tenant before tenant-specific control work.
|
|
459
773
|
*
|
|
460
774
|
* @param record Canonical shared-registry subscription record.
|
|
461
775
|
* @param onReady Complete admitted one-shot mutation, if present.
|
|
776
|
+
* @returns Completion after the operation settles.
|
|
462
777
|
*/
|
|
463
778
|
async #requireSubscriptionTenant(record, onReady) {
|
|
464
779
|
await TenantStatuses.require(record.route.context, record.tenantId, onReady);
|
|
465
780
|
}
|
|
466
|
-
|
|
781
|
+
/**
|
|
782
|
+
* Finds the registered state or event route for a Topic target.
|
|
783
|
+
*
|
|
784
|
+
* @param topic Framework subscription Topic.
|
|
785
|
+
* @returns The matching subscription route.
|
|
786
|
+
*/ #subscriptionRoute(topic) {
|
|
467
787
|
const target = topic.target;
|
|
468
788
|
const route = target === undefined
|
|
469
789
|
? undefined
|
|
470
790
|
: (this.#stateRoutes.get(target.type) ?? this.#eventRoutes.get(target.type));
|
|
471
791
|
if (target === undefined || route === undefined) {
|
|
472
|
-
throw
|
|
792
|
+
throw ReadRequestStatus.invalid("topic", true, "Unsupported subscription target.");
|
|
473
793
|
}
|
|
474
794
|
return route;
|
|
475
795
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Attaches delivery for the validated retained subscription.
|
|
798
|
+
*
|
|
799
|
+
* @param record The durable subscription record.
|
|
800
|
+
* @returns Completion after the operation settles.
|
|
801
|
+
* @param expected Exact entry activated for this reader.
|
|
802
|
+
*/ async #activateRecord(record, expected) {
|
|
803
|
+
const consumer = await boundedContextAccess.consumeSubscription(record.route.context, record.id, (work) => {
|
|
804
|
+
record.delivery.push(work);
|
|
479
805
|
if (record.delivery.closed)
|
|
480
806
|
this.#removeDetachedSubscription(record.id);
|
|
481
|
-
})
|
|
807
|
+
}, () => {
|
|
808
|
+
if (this.#subscriptions.get(record.id) === record)
|
|
809
|
+
this.#subscriptions.delete(record.id);
|
|
810
|
+
record.delivery.invalidate();
|
|
811
|
+
}, expected);
|
|
482
812
|
record.delivery.attach(consumer);
|
|
483
813
|
managedChildSubscriptionAccess.installed(record.id);
|
|
484
814
|
}
|
|
485
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Serializes removal of a retained subscription and its delivery.
|
|
817
|
+
*
|
|
818
|
+
* @param id The stable identifier for this record.
|
|
819
|
+
* @param reader The verified reader facts for this operation.
|
|
820
|
+
* @returns Completion after the operation settles.
|
|
821
|
+
*/ #removeSubscription(id, reader) {
|
|
486
822
|
const existing = this.#removals.get(id);
|
|
487
823
|
if (existing !== undefined) {
|
|
488
824
|
return existing.settled;
|
|
489
825
|
}
|
|
490
826
|
const record = this.#subscriptions.get(id);
|
|
491
827
|
const unknown = record === undefined;
|
|
828
|
+
if (reader !== undefined && unknown)
|
|
829
|
+
return Promise.resolve();
|
|
492
830
|
if (unknown && this.#unknownRemovals.size >= this.#subscriptionLimit) {
|
|
493
831
|
return Promise.reject(new ConnectError("Subscription cancellation capacity is exhausted.", Code.ResourceExhausted));
|
|
494
832
|
}
|
|
495
833
|
if (unknown) {
|
|
496
834
|
this.#unknownRemovals.add(id);
|
|
497
835
|
}
|
|
498
|
-
else {
|
|
836
|
+
else if (reader === undefined) {
|
|
499
837
|
record.delivery.close();
|
|
500
838
|
this.#subscriptions.delete(id);
|
|
501
839
|
}
|
|
502
840
|
const removal = ServiceValues.createSubscriptionRemoval();
|
|
503
841
|
this.#removals.set(id, removal);
|
|
504
|
-
void this.#runRemoval(id, removal, record, unknown);
|
|
842
|
+
void this.#runRemoval(id, removal, record, unknown, reader);
|
|
505
843
|
return removal.settled;
|
|
506
844
|
}
|
|
507
|
-
|
|
845
|
+
/**
|
|
846
|
+
* Cleans up delivery after a subscription record is detached.
|
|
847
|
+
*
|
|
848
|
+
* @param id The stable identifier for this record.
|
|
849
|
+
*/ #removeDetachedSubscription(id) {
|
|
508
850
|
// spine-log-boundary: server.subscription_cleanup_failure
|
|
509
851
|
void this.#removeSubscription(id).catch(() => {
|
|
510
852
|
const logger = serviceLoggers.get(this);
|
|
@@ -517,12 +859,27 @@ export class SpineServices {
|
|
|
517
859
|
}
|
|
518
860
|
});
|
|
519
861
|
}
|
|
520
|
-
|
|
862
|
+
/**
|
|
863
|
+
* Executes serialized subscription cleanup for one ID.
|
|
864
|
+
*
|
|
865
|
+
* @param id The stable identifier for this record.
|
|
866
|
+
* @param removal Shared subscription-removal task.
|
|
867
|
+
* @param record The durable subscription record.
|
|
868
|
+
* @param unknown Unrecognized route or filter value.
|
|
869
|
+
* @param reader The verified reader facts for this operation.
|
|
870
|
+
* @returns Completion after the operation settles.
|
|
871
|
+
*/ async #runRemoval(id, removal, record, unknown, reader) {
|
|
521
872
|
try {
|
|
522
873
|
if (record !== undefined) {
|
|
523
|
-
await boundedContextAccess
|
|
874
|
+
const deleted = await boundedContextAccess
|
|
524
875
|
.subscriptionRegistry(record.route.context)
|
|
525
|
-
.delete(create(SubscriptionIdSchema, { value: id }));
|
|
876
|
+
.delete(create(SubscriptionIdSchema, { value: id }), reader);
|
|
877
|
+
if (deleted === "denied")
|
|
878
|
+
throw this.#readerDenied();
|
|
879
|
+
if (reader !== undefined) {
|
|
880
|
+
record.delivery.close();
|
|
881
|
+
this.#subscriptions.delete(id);
|
|
882
|
+
}
|
|
526
883
|
}
|
|
527
884
|
else {
|
|
528
885
|
await this.#deleteUnknownSubscription(id);
|
|
@@ -545,6 +902,7 @@ export class SpineServices {
|
|
|
545
902
|
* Deletes an unknown subscription from every configured context registry.
|
|
546
903
|
*
|
|
547
904
|
* @param id Subscription ID to remove.
|
|
905
|
+
* @returns Completion after the operation settles.
|
|
548
906
|
*/
|
|
549
907
|
async #deleteUnknownSubscription(id) {
|
|
550
908
|
await Promise.all(ServiceValues.uniqueContexts(this.#contexts).map((context) => this.#registryOperation(context, async () => {
|
|
@@ -553,7 +911,12 @@ export class SpineServices {
|
|
|
553
911
|
.delete(create(SubscriptionIdSchema, { value: id }));
|
|
554
912
|
})));
|
|
555
913
|
}
|
|
556
|
-
|
|
914
|
+
/**
|
|
915
|
+
* Loads a retained subscription record by opaque ID.
|
|
916
|
+
*
|
|
917
|
+
* @param id The stable identifier for this record.
|
|
918
|
+
* @returns The retained record, if present.
|
|
919
|
+
*/ async #findSubscription(id) {
|
|
557
920
|
const subscriptionId = create(SubscriptionIdSchema, { value: id });
|
|
558
921
|
for (const context of ServiceValues.uniqueContexts(this.#contexts)) {
|
|
559
922
|
let entry;
|
|
@@ -577,7 +940,8 @@ export class SpineServices {
|
|
|
577
940
|
subscription: clone(SubscriptionSchema, subscription),
|
|
578
941
|
shape: ServiceValues.createSubscriptionShape(canonicalTopic, route),
|
|
579
942
|
tenantId,
|
|
580
|
-
|
|
943
|
+
queueCapacity: this.#queueCapacity,
|
|
944
|
+
...(entry.reader === undefined ? {} : { reader: entry.reader }),
|
|
581
945
|
});
|
|
582
946
|
}
|
|
583
947
|
return undefined;
|
|
@@ -593,12 +957,35 @@ export class SpineServices {
|
|
|
593
957
|
return boundedContextAccess.admitServiceRegistryOperation(context, onOperation);
|
|
594
958
|
}
|
|
595
959
|
}
|
|
960
|
+
_a = SpineServices;
|
|
596
961
|
/**
|
|
597
962
|
* Provides package-private service configuration seams.
|
|
598
963
|
*
|
|
599
964
|
* @internal
|
|
600
965
|
*/
|
|
601
966
|
export const spineServicesAccess = Object.freeze({
|
|
967
|
+
/**
|
|
968
|
+
* Attaches Server-only outward read policies to its service adapter.
|
|
969
|
+
*
|
|
970
|
+
* @param services Prepared service adapter.
|
|
971
|
+
* @param filters Registered policy engine.
|
|
972
|
+
*/
|
|
973
|
+
installReadFilters(services, filters) {
|
|
974
|
+
if (!serviceInstances.has(services))
|
|
975
|
+
throw new TypeError("Expected SpineServices instance.");
|
|
976
|
+
serviceReadFilters.set(services, filters);
|
|
977
|
+
},
|
|
978
|
+
/**
|
|
979
|
+
* Attaches this Server's prepared handler table without exposing registration options.
|
|
980
|
+
*
|
|
981
|
+
* @param services Adapter receiving its internal App Query capability.
|
|
982
|
+
* @param queries Selected handler table from Server preparation.
|
|
983
|
+
*/
|
|
984
|
+
installAppQueries(services, queries) {
|
|
985
|
+
if (!serviceInstances.has(services))
|
|
986
|
+
throw new TypeError("Expected SpineServices instance.");
|
|
987
|
+
serviceAppQueries.set(services, queries);
|
|
988
|
+
},
|
|
602
989
|
/**
|
|
603
990
|
* Clears the logger installed for a closed service adapter.
|
|
604
991
|
*
|
|
@@ -627,18 +1014,23 @@ export const spineServicesAccess = Object.freeze({
|
|
|
627
1014
|
* Buffers subscription updates and wakes active stream readers.
|
|
628
1015
|
*/
|
|
629
1016
|
class SubscriptionDelivery {
|
|
1017
|
+
#active;
|
|
630
1018
|
#queue = [];
|
|
631
1019
|
#waiters = [];
|
|
632
|
-
#
|
|
1020
|
+
#controller = new AbortController();
|
|
1021
|
+
#queueCapacity;
|
|
1022
|
+
#activationSignal;
|
|
1023
|
+
#onActivationAbort;
|
|
633
1024
|
#subscription;
|
|
634
1025
|
#closed = false;
|
|
1026
|
+
#invalidated = false;
|
|
635
1027
|
/**
|
|
636
1028
|
* Sets the bounded update queue capacity.
|
|
637
1029
|
*
|
|
638
|
-
* @param
|
|
1030
|
+
* @param queueCapacity Maximum waiting updates beyond the active update.
|
|
639
1031
|
*/
|
|
640
|
-
constructor(
|
|
641
|
-
this.#
|
|
1032
|
+
constructor(queueCapacity) {
|
|
1033
|
+
this.#queueCapacity = queueCapacity;
|
|
642
1034
|
}
|
|
643
1035
|
/**
|
|
644
1036
|
* Checks whether a source attachment is active.
|
|
@@ -656,6 +1048,75 @@ class SubscriptionDelivery {
|
|
|
656
1048
|
get closed() {
|
|
657
1049
|
return this.#closed;
|
|
658
1050
|
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Returns source invalidation separately from transport cancellation before the first ACK.
|
|
1053
|
+
* @returns Whether the admitted canonical entry changed or disappeared.
|
|
1054
|
+
*/
|
|
1055
|
+
get invalidated() {
|
|
1056
|
+
return this.#invalidated;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Closes this delivery after the admitted source entry changes.
|
|
1060
|
+
*/
|
|
1061
|
+
invalidate() {
|
|
1062
|
+
if (this.#closed)
|
|
1063
|
+
return;
|
|
1064
|
+
this.#invalidated = true;
|
|
1065
|
+
this.close();
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Returns a cancellation signal for active policy when this consumer is removed.
|
|
1069
|
+
*
|
|
1070
|
+
* @returns Cancellation signal for the current activation.
|
|
1071
|
+
*/
|
|
1072
|
+
get signal() {
|
|
1073
|
+
return this.#controller.signal;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Registers transport cancellation before the source attachment awaits.
|
|
1077
|
+
*
|
|
1078
|
+
* @param signal Cancellation of the admitted activation, when available.
|
|
1079
|
+
*/
|
|
1080
|
+
track(signal) {
|
|
1081
|
+
if (signal === undefined)
|
|
1082
|
+
return;
|
|
1083
|
+
if (signal.aborted) {
|
|
1084
|
+
this.close();
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
this.#activationSignal = signal;
|
|
1088
|
+
this.#onActivationAbort = () => {
|
|
1089
|
+
this.close();
|
|
1090
|
+
};
|
|
1091
|
+
signal.addEventListener("abort", this.#onActivationAbort, { once: true });
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Stops waiting on one policy call when this activation closes.
|
|
1095
|
+
*
|
|
1096
|
+
* @param work The policy result; a late rejection remains observed by the race.
|
|
1097
|
+
* @returns The result, or undefined after cancellation.
|
|
1098
|
+
*/
|
|
1099
|
+
async settle(work) {
|
|
1100
|
+
if (this.#closed) {
|
|
1101
|
+
// spine-log-boundary: server.subscription_closed_policy_rejection
|
|
1102
|
+
void work.catch(() => undefined);
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
let onAbort;
|
|
1106
|
+
const closed = new Promise((resolve) => {
|
|
1107
|
+
onAbort = () => {
|
|
1108
|
+
resolve(undefined);
|
|
1109
|
+
};
|
|
1110
|
+
this.#controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
1111
|
+
});
|
|
1112
|
+
try {
|
|
1113
|
+
return await Promise.race([work, closed]);
|
|
1114
|
+
}
|
|
1115
|
+
finally {
|
|
1116
|
+
if (onAbort !== undefined)
|
|
1117
|
+
this.#controller.signal.removeEventListener("abort", onAbort);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
659
1120
|
/**
|
|
660
1121
|
* Attaches a source subscription or immediately removes it after close.
|
|
661
1122
|
*
|
|
@@ -677,17 +1138,16 @@ class SubscriptionDelivery {
|
|
|
677
1138
|
if (this.#closed) {
|
|
678
1139
|
return;
|
|
679
1140
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
this.#queue.push(update);
|
|
1141
|
+
if (this.#active === undefined) {
|
|
1142
|
+
this.#active = update;
|
|
1143
|
+
this.#waiters.shift()?.(update);
|
|
1144
|
+
return;
|
|
687
1145
|
}
|
|
688
|
-
|
|
689
|
-
|
|
1146
|
+
if (this.#queue.length >= this.#queueCapacity) {
|
|
1147
|
+
this.close();
|
|
1148
|
+
return;
|
|
690
1149
|
}
|
|
1150
|
+
this.#queue.push(update);
|
|
691
1151
|
}
|
|
692
1152
|
/**
|
|
693
1153
|
* Reads the next queued update, waiting while the stream is open and empty.
|
|
@@ -695,12 +1155,22 @@ class SubscriptionDelivery {
|
|
|
695
1155
|
* @returns Next update, or `undefined` after closure.
|
|
696
1156
|
*/
|
|
697
1157
|
next() {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1158
|
+
if (this.#closed)
|
|
1159
|
+
return Promise.resolve(undefined);
|
|
1160
|
+
if (this.#active !== undefined)
|
|
1161
|
+
return Promise.resolve(this.#active);
|
|
702
1162
|
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
703
1163
|
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Removes the consumed active update and promotes the next waiting update.
|
|
1166
|
+
*/
|
|
1167
|
+
advance() {
|
|
1168
|
+
if (this.#closed)
|
|
1169
|
+
return;
|
|
1170
|
+
this.#active = this.#queue.shift();
|
|
1171
|
+
if (this.#active !== undefined)
|
|
1172
|
+
this.#waiters.shift()?.(this.#active);
|
|
1173
|
+
}
|
|
704
1174
|
/**
|
|
705
1175
|
* Closes the source subscription and completes all waiting readers.
|
|
706
1176
|
*/
|
|
@@ -709,7 +1179,11 @@ class SubscriptionDelivery {
|
|
|
709
1179
|
return;
|
|
710
1180
|
}
|
|
711
1181
|
this.#closed = true;
|
|
1182
|
+
this.#controller.abort();
|
|
1183
|
+
if (this.#activationSignal !== undefined && this.#onActivationAbort !== undefined)
|
|
1184
|
+
this.#activationSignal.removeEventListener("abort", this.#onActivationAbort);
|
|
712
1185
|
this.#queue.length = 0;
|
|
1186
|
+
this.#active = undefined;
|
|
713
1187
|
this.#subscription?.unsubscribe();
|
|
714
1188
|
this.#subscription = undefined;
|
|
715
1189
|
for (const waiter of this.#waiters.splice(0)) {
|
|
@@ -719,6 +1193,8 @@ class SubscriptionDelivery {
|
|
|
719
1193
|
}
|
|
720
1194
|
/**
|
|
721
1195
|
* Builds an OK status for service responses.
|
|
1196
|
+
*
|
|
1197
|
+
* @returns The successful status.
|
|
722
1198
|
*/
|
|
723
1199
|
function okStatus() {
|
|
724
1200
|
return create(StatusSchema, {
|
|
@@ -728,30 +1204,42 @@ function okStatus() {
|
|
|
728
1204
|
},
|
|
729
1205
|
});
|
|
730
1206
|
}
|
|
731
|
-
|
|
1207
|
+
/**
|
|
1208
|
+
* Builds a structured status from a public validation error.
|
|
1209
|
+
*
|
|
1210
|
+
* @param type Registered target type URL.
|
|
1211
|
+
* @param message Public validation or error message.
|
|
1212
|
+
* @param details Structured validation details.
|
|
1213
|
+
* @param code Public status code.
|
|
1214
|
+
* @returns The structured error status.
|
|
1215
|
+
*/ function errorStatus(type, message, details, code) {
|
|
732
1216
|
return create(StatusSchema, {
|
|
733
1217
|
status: {
|
|
734
1218
|
case: "error",
|
|
735
1219
|
value: create(ErrorSchema, {
|
|
736
1220
|
type,
|
|
737
1221
|
message,
|
|
1222
|
+
...(code === undefined ? {} : { code }),
|
|
738
1223
|
...(details === undefined ? {} : { details }),
|
|
739
1224
|
}),
|
|
740
1225
|
},
|
|
741
1226
|
});
|
|
742
1227
|
}
|
|
743
|
-
|
|
1228
|
+
/**
|
|
1229
|
+
* Builds a successful subscription service response.
|
|
1230
|
+
*
|
|
1231
|
+
* @returns The OK response.
|
|
1232
|
+
*/ function okResponse() {
|
|
744
1233
|
return create(ResponseSchema, { status: okStatus() });
|
|
745
1234
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
function validateReadQuery(query, target, route) {
|
|
1235
|
+
/**
|
|
1236
|
+
* Checks a read query envelope and target before routing.
|
|
1237
|
+
*
|
|
1238
|
+
* @param query Framework Entity read query.
|
|
1239
|
+
* @param target Declared query or Topic target.
|
|
1240
|
+
* @param route Registered target route with schema and tenancy.
|
|
1241
|
+
* @returns The validated read request.
|
|
1242
|
+
*/ function validateReadQuery(query, target, route) {
|
|
755
1243
|
const formatError = formatReadError(query.format, route);
|
|
756
1244
|
if (formatError !== undefined) {
|
|
757
1245
|
return formatError;
|
|
@@ -768,7 +1256,13 @@ function validateReadQuery(query, target, route) {
|
|
|
768
1256
|
return invalidCriterionError();
|
|
769
1257
|
}
|
|
770
1258
|
}
|
|
771
|
-
|
|
1259
|
+
/**
|
|
1260
|
+
* Checks target filters against the registered Entity schema.
|
|
1261
|
+
*
|
|
1262
|
+
* @param filters Target or subscription filter collection.
|
|
1263
|
+
* @param route Registered target route with schema and tenancy.
|
|
1264
|
+
* @returns The validated target filters.
|
|
1265
|
+
*/ function validateFilters(filters, route) {
|
|
772
1266
|
const idCount = filters.idFilter?.id.length ?? 0;
|
|
773
1267
|
if (idCount > QueryLimits.idCount) {
|
|
774
1268
|
return invalidQueryError("QueryService.Read id_filter may contain at most 100 IDs.");
|
|
@@ -795,7 +1289,13 @@ function validateFilters(filters, route) {
|
|
|
795
1289
|
}
|
|
796
1290
|
: undefined;
|
|
797
1291
|
}
|
|
798
|
-
|
|
1292
|
+
/**
|
|
1293
|
+
* Checks nested composite filters and their traversal bounds.
|
|
1294
|
+
*
|
|
1295
|
+
* @param filters Target or subscription filter collection.
|
|
1296
|
+
* @param route Registered target route with schema and tenancy.
|
|
1297
|
+
* @returns The validated composite filters.
|
|
1298
|
+
*/ function validateCompositeFilters(filters, route) {
|
|
799
1299
|
const pending = filters.map((filter) => ({ filter, depth: 0 }));
|
|
800
1300
|
const seen = new WeakSet();
|
|
801
1301
|
let compositeCount = 0;
|
|
@@ -819,7 +1319,13 @@ function validateCompositeFilters(filters, route) {
|
|
|
819
1319
|
}
|
|
820
1320
|
return undefined;
|
|
821
1321
|
}
|
|
822
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* Checks query ID Any payloads against the registered Entity ID.
|
|
1324
|
+
*
|
|
1325
|
+
* @param ids Opaque query or subscription IDs.
|
|
1326
|
+
* @param route Registered target route with schema and tenancy.
|
|
1327
|
+
* @returns The validated query IDs.
|
|
1328
|
+
*/ function validateQueryIds(ids, route) {
|
|
823
1329
|
const schema = route.idField.message;
|
|
824
1330
|
if (schema === undefined || ids === undefined) {
|
|
825
1331
|
return undefined;
|
|
@@ -830,7 +1336,12 @@ function validateQueryIds(ids, route) {
|
|
|
830
1336
|
}
|
|
831
1337
|
return undefined;
|
|
832
1338
|
}
|
|
833
|
-
|
|
1339
|
+
/**
|
|
1340
|
+
* Counts leaf filters in a nested subscription criterion.
|
|
1341
|
+
*
|
|
1342
|
+
* @param filters Target or subscription filter collection.
|
|
1343
|
+
* @returns The number of simple filter nodes.
|
|
1344
|
+
*/ function countSimpleFilters(filters) {
|
|
834
1345
|
const pending = [...filters];
|
|
835
1346
|
let count = 0;
|
|
836
1347
|
while (pending.length > 0) {
|
|
@@ -842,7 +1353,13 @@ function countSimpleFilters(filters) {
|
|
|
842
1353
|
}
|
|
843
1354
|
return count;
|
|
844
1355
|
}
|
|
845
|
-
|
|
1356
|
+
/**
|
|
1357
|
+
* Checks field filters and their typed Any values.
|
|
1358
|
+
*
|
|
1359
|
+
* @param filters Target or subscription filter collection.
|
|
1360
|
+
* @param route Registered target route with schema and tenancy.
|
|
1361
|
+
* @returns The validated simple filters.
|
|
1362
|
+
*/ function validateSimpleFilters(filters, route) {
|
|
846
1363
|
for (const filter of filters) {
|
|
847
1364
|
if (wireComparison(filter.operator) === undefined) {
|
|
848
1365
|
return unsupportedFilterError("QueryService.Read comparison operator is not supported.");
|
|
@@ -875,6 +1392,10 @@ function validateSimpleFilters(filters, route) {
|
|
|
875
1392
|
}
|
|
876
1393
|
/**
|
|
877
1394
|
* Checks query ordering and limits against the registered state route.
|
|
1395
|
+
*
|
|
1396
|
+
* @param format Declared filter value format.
|
|
1397
|
+
* @param route Registered target route with schema and tenancy.
|
|
1398
|
+
* @returns The structured read error.
|
|
878
1399
|
*/
|
|
879
1400
|
function formatReadError(format, route) {
|
|
880
1401
|
if (format === undefined) {
|
|
@@ -899,6 +1420,11 @@ function formatReadError(format, route) {
|
|
|
899
1420
|
}
|
|
900
1421
|
/**
|
|
901
1422
|
* Converts an accepted wire query into a storage plan for complete states.
|
|
1423
|
+
*
|
|
1424
|
+
* @param target Declared query or Topic target.
|
|
1425
|
+
* @param query Framework Entity read query.
|
|
1426
|
+
* @param route Registered target route with schema and tenancy.
|
|
1427
|
+
* @returns The validated Entity read plan.
|
|
902
1428
|
*/
|
|
903
1429
|
function createReadPlan(target, query, route) {
|
|
904
1430
|
const predicate = target.criterion.case === "filters"
|
|
@@ -920,7 +1446,13 @@ function createReadPlan(target, query, route) {
|
|
|
920
1446
|
...(format === undefined || format.limit === 0 ? {} : { limit: format.limit }),
|
|
921
1447
|
});
|
|
922
1448
|
}
|
|
923
|
-
|
|
1449
|
+
/**
|
|
1450
|
+
* Converts validated target filters to executable predicates.
|
|
1451
|
+
*
|
|
1452
|
+
* @param filters Target or subscription filter collection.
|
|
1453
|
+
* @param route Registered target route with schema and tenancy.
|
|
1454
|
+
* @returns The normalized filter predicates.
|
|
1455
|
+
*/ function normalizedFilters(filters, route) {
|
|
924
1456
|
const predicates = [];
|
|
925
1457
|
if (filters.idFilter !== undefined) {
|
|
926
1458
|
predicates.push({
|
|
@@ -934,11 +1466,23 @@ function normalizedFilters(filters, route) {
|
|
|
934
1466
|
? onlyPredicate
|
|
935
1467
|
: { kind: "all", predicates };
|
|
936
1468
|
}
|
|
937
|
-
|
|
1469
|
+
/**
|
|
1470
|
+
* Decodes an opaque query ID using the registered Entity ID schema.
|
|
1471
|
+
*
|
|
1472
|
+
* @param value Packed Entity ID Any from the query.
|
|
1473
|
+
* @param route Registered target route with schema and tenancy.
|
|
1474
|
+
* @returns The typed Entity ID.
|
|
1475
|
+
*/ function decodeQueryIdValue(value, route) {
|
|
938
1476
|
const schema = route.idField.message;
|
|
939
1477
|
return schema === undefined ? decodeAnyValue(value) : decodeAnyValue(value, schema);
|
|
940
1478
|
}
|
|
941
|
-
|
|
1479
|
+
/**
|
|
1480
|
+
* Converts a validated composite filter to its execution form.
|
|
1481
|
+
*
|
|
1482
|
+
* @param filter One target or subscription filter.
|
|
1483
|
+
* @param route Registered target route with schema and tenancy.
|
|
1484
|
+
* @returns The normalized composite criterion.
|
|
1485
|
+
*/ function normalizedComposite(filter, route) {
|
|
942
1486
|
const predicates = [
|
|
943
1487
|
...filter.filter.map((child) => ({
|
|
944
1488
|
kind: "comparison",
|
|
@@ -953,19 +1497,35 @@ function normalizedComposite(filter, route) {
|
|
|
953
1497
|
predicates,
|
|
954
1498
|
};
|
|
955
1499
|
}
|
|
956
|
-
|
|
1500
|
+
/**
|
|
1501
|
+
* Resolves a declared state column for a field filter.
|
|
1502
|
+
*
|
|
1503
|
+
* @param filter One target or subscription filter.
|
|
1504
|
+
* @returns The required state column.
|
|
1505
|
+
*/ function requiredFilterColumn(filter) {
|
|
957
1506
|
const column = filter.fieldPath?.fieldName[0];
|
|
958
1507
|
if (column !== undefined)
|
|
959
1508
|
return column;
|
|
960
1509
|
throw new TypeError("Validated query filter has no column.");
|
|
961
1510
|
}
|
|
962
|
-
|
|
1511
|
+
/**
|
|
1512
|
+
* Maps a wire filter operator to a supported comparison.
|
|
1513
|
+
*
|
|
1514
|
+
* @param filter One target or subscription filter.
|
|
1515
|
+
* @returns The required comparison operator.
|
|
1516
|
+
*/ function requiredComparison(filter) {
|
|
963
1517
|
const operator = wireComparison(filter.operator);
|
|
964
1518
|
if (operator !== undefined)
|
|
965
1519
|
return operator;
|
|
966
1520
|
throw new TypeError("Validated query filter has no supported comparison operator.");
|
|
967
1521
|
}
|
|
968
|
-
|
|
1522
|
+
/**
|
|
1523
|
+
* Decodes a filter value for the target state column.
|
|
1524
|
+
*
|
|
1525
|
+
* @param filter One target or subscription filter.
|
|
1526
|
+
* @param route Registered target route with schema and tenancy.
|
|
1527
|
+
* @returns The typed column value.
|
|
1528
|
+
*/ function decodeColumnValue(filter, route) {
|
|
969
1529
|
const column = filter.fieldPath?.fieldName[0] ?? "";
|
|
970
1530
|
if (column === "version" && filter.value !== undefined) {
|
|
971
1531
|
const value = AnyPacker.tryUnpack(filter.value, VersionSchema);
|
|
@@ -982,7 +1542,13 @@ function decodeColumnValue(filter, route) {
|
|
|
982
1542
|
const field = route.columnFields.get(column);
|
|
983
1543
|
return decodeAnyValue(filter.value, field?.fieldKind === "message" ? field.message : undefined);
|
|
984
1544
|
}
|
|
985
|
-
|
|
1545
|
+
/**
|
|
1546
|
+
* Checks an Any filter value against the registered field schema.
|
|
1547
|
+
*
|
|
1548
|
+
* @param field Registered Protobuf field descriptor.
|
|
1549
|
+
* @param value Packed field-filter value.
|
|
1550
|
+
* @returns Whether the filter value has the expected type.
|
|
1551
|
+
*/ function filterValueMatches(field, value) {
|
|
986
1552
|
const schema = filterValueSchema(field);
|
|
987
1553
|
if (schema === undefined ||
|
|
988
1554
|
(schema.typeName !== AnySchema.typeName && value.typeUrl !== TypeUrls.derive(schema)))
|
|
@@ -1010,7 +1576,12 @@ function filterValueMatches(field, value) {
|
|
|
1010
1576
|
}
|
|
1011
1577
|
return typeof decoded === "number" && Number.isFinite(decoded);
|
|
1012
1578
|
}
|
|
1013
|
-
|
|
1579
|
+
/**
|
|
1580
|
+
* Selects the Protobuf schema required for a state field filter.
|
|
1581
|
+
*
|
|
1582
|
+
* @param field Registered Protobuf field descriptor.
|
|
1583
|
+
* @returns The declared filter-value schema.
|
|
1584
|
+
*/ function filterValueSchema(field) {
|
|
1014
1585
|
if (field.fieldKind === "message")
|
|
1015
1586
|
return field.message;
|
|
1016
1587
|
if (field.fieldKind === "enum")
|
|
@@ -1040,7 +1611,13 @@ function filterValueSchema(field) {
|
|
|
1040
1611
|
return StringValueSchema;
|
|
1041
1612
|
return Int32ValueSchema;
|
|
1042
1613
|
}
|
|
1043
|
-
|
|
1614
|
+
/**
|
|
1615
|
+
* Checks special state columns against a wire filter value.
|
|
1616
|
+
*
|
|
1617
|
+
* @param column Registered Entity state column.
|
|
1618
|
+
* @param value Packed value for a system state column.
|
|
1619
|
+
* @returns Whether the special value matches.
|
|
1620
|
+
*/ function systemValueMatches(column, value) {
|
|
1044
1621
|
if (column === "version") {
|
|
1045
1622
|
return (value.typeUrl === TypeUrls.derive(VersionSchema) &&
|
|
1046
1623
|
AnyPacker.tryUnpack(value, VersionSchema) !== undefined);
|
|
@@ -1051,7 +1628,12 @@ function systemValueMatches(column, value) {
|
|
|
1051
1628
|
}
|
|
1052
1629
|
return false;
|
|
1053
1630
|
}
|
|
1054
|
-
|
|
1631
|
+
/**
|
|
1632
|
+
* Checks whether a field type supports ordered comparisons.
|
|
1633
|
+
*
|
|
1634
|
+
* @param field Registered Protobuf field descriptor.
|
|
1635
|
+
* @returns Whether range comparisons are supported.
|
|
1636
|
+
*/ function supportsRange(field) {
|
|
1055
1637
|
if (field.fieldKind === "message") {
|
|
1056
1638
|
return (field.message.typeName === "google.protobuf.Timestamp" ||
|
|
1057
1639
|
field.message.typeName === "spine.core.Version");
|
|
@@ -1060,7 +1642,12 @@ function supportsRange(field) {
|
|
|
1060
1642
|
field.scalar !== ScalarType.BOOL &&
|
|
1061
1643
|
field.scalar !== ScalarType.BYTES);
|
|
1062
1644
|
}
|
|
1063
|
-
|
|
1645
|
+
/**
|
|
1646
|
+
* Maps a wire filter enum to the execution comparison.
|
|
1647
|
+
*
|
|
1648
|
+
* @param operator Wire filter comparison enum.
|
|
1649
|
+
* @returns The comparison operator.
|
|
1650
|
+
*/ function wireComparison(operator) {
|
|
1064
1651
|
if (operator === Filter_Operator.EQUAL)
|
|
1065
1652
|
return "equal";
|
|
1066
1653
|
if (operator === Filter_Operator.GREATER_THAN)
|
|
@@ -1073,25 +1660,43 @@ function wireComparison(operator) {
|
|
|
1073
1660
|
return "lessOrEqual";
|
|
1074
1661
|
return undefined;
|
|
1075
1662
|
}
|
|
1076
|
-
|
|
1663
|
+
/**
|
|
1664
|
+
* Creates a structured error for an unsupported filter.
|
|
1665
|
+
*
|
|
1666
|
+
* @param message Public validation or error message.
|
|
1667
|
+
* @returns The invalid-filter error.
|
|
1668
|
+
*/ function unsupportedFilterError(message) {
|
|
1077
1669
|
return invalidQueryError(message);
|
|
1078
1670
|
}
|
|
1079
|
-
|
|
1671
|
+
/**
|
|
1672
|
+
* Creates a structured error for an unsupported filter format.
|
|
1673
|
+
*
|
|
1674
|
+
* @param message Public validation or error message.
|
|
1675
|
+
* @returns The invalid-format error.
|
|
1676
|
+
*/ function unsupportedFormatError(message) {
|
|
1080
1677
|
return invalidQueryError(message);
|
|
1081
1678
|
}
|
|
1082
|
-
|
|
1679
|
+
/**
|
|
1680
|
+
* Creates the structured invalid-query error.
|
|
1681
|
+
*
|
|
1682
|
+
* @param message Public validation or error message.
|
|
1683
|
+
* @returns The invalid-query error.
|
|
1684
|
+
*/ function invalidQueryError(message) {
|
|
1083
1685
|
return {
|
|
1084
1686
|
type: "INVALID_QUERY",
|
|
1085
1687
|
message,
|
|
1086
1688
|
};
|
|
1087
1689
|
}
|
|
1088
|
-
|
|
1690
|
+
/**
|
|
1691
|
+
* Creates the structured invalid-subscription criterion error.
|
|
1692
|
+
*
|
|
1693
|
+
* @returns The invalid-criterion error.
|
|
1694
|
+
*/ function invalidCriterionError() {
|
|
1089
1695
|
return {
|
|
1090
1696
|
type: "INVALID_QUERY",
|
|
1091
1697
|
message: "QueryService.Read requires filters or include_all = true.",
|
|
1092
1698
|
};
|
|
1093
1699
|
}
|
|
1094
|
-
const DEFAULT_QUEUE_LIMIT = 100;
|
|
1095
1700
|
const DEFAULT_SUBSCRIPTION_LIMIT = 100;
|
|
1096
1701
|
/**
|
|
1097
1702
|
* Holds bounded request limits for QueryService.
|
|
@@ -1114,25 +1719,37 @@ const SubscriptionLimits = Object.freeze({
|
|
|
1114
1719
|
pathComponentCount: 16,
|
|
1115
1720
|
pathSegmentLength: 128,
|
|
1116
1721
|
});
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1722
|
+
/**
|
|
1723
|
+
* Validates the configured maximum concurrent subscriptions.
|
|
1724
|
+
*
|
|
1725
|
+
* @param value Configured maximum concurrent subscriptions.
|
|
1726
|
+
* @returns The accepted subscription limit.
|
|
1727
|
+
*/ function subscriptionLimit(value) {
|
|
1121
1728
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1122
1729
|
throw new TypeError("SpineServices subscriptionLimit must be a positive safe integer.");
|
|
1123
1730
|
}
|
|
1124
1731
|
return value;
|
|
1125
1732
|
}
|
|
1126
|
-
|
|
1733
|
+
/**
|
|
1734
|
+
* Constructs an in-memory subscription record with its reader binding.
|
|
1735
|
+
*
|
|
1736
|
+
* @param input Fields for a new reader-bound subscription record.
|
|
1737
|
+
* @returns The new subscription record.
|
|
1738
|
+
*/ function createSubscriptionRecord(input) {
|
|
1127
1739
|
return {
|
|
1128
1740
|
id: input.id,
|
|
1129
1741
|
subscription: clone(SubscriptionSchema, input.subscription),
|
|
1742
|
+
...(input.reader === undefined ? {} : { reader: input.reader }),
|
|
1130
1743
|
tenantId: input.tenantId === undefined ? undefined : clone(TenantIdSchema, input.tenantId),
|
|
1131
|
-
delivery: new SubscriptionDelivery(input.
|
|
1744
|
+
delivery: new SubscriptionDelivery(input.queueCapacity),
|
|
1132
1745
|
...input.shape,
|
|
1133
1746
|
};
|
|
1134
1747
|
}
|
|
1135
|
-
|
|
1748
|
+
/**
|
|
1749
|
+
* Creates a shared removal task for concurrent cancellation.
|
|
1750
|
+
*
|
|
1751
|
+
* @returns The removal task and its completion promise.
|
|
1752
|
+
*/ function createSubscriptionRemoval() {
|
|
1136
1753
|
let reject;
|
|
1137
1754
|
let resolve;
|
|
1138
1755
|
const settled = new Promise((settle, fail) => {
|
|
@@ -1143,10 +1760,19 @@ function createSubscriptionRemoval() {
|
|
|
1143
1760
|
});
|
|
1144
1761
|
return { reject, resolve, settled };
|
|
1145
1762
|
}
|
|
1146
|
-
|
|
1763
|
+
/**
|
|
1764
|
+
* Deduplicates registered bounded contexts for routing.
|
|
1765
|
+
*
|
|
1766
|
+
* @param contexts Built bounded contexts available for routing.
|
|
1767
|
+
* @returns The distinct bounded contexts.
|
|
1768
|
+
*/ function uniqueContexts(contexts) {
|
|
1147
1769
|
return [...new Set(contexts)];
|
|
1148
1770
|
}
|
|
1149
|
-
|
|
1771
|
+
/**
|
|
1772
|
+
* Checks Topic identity, context, target and criterion.
|
|
1773
|
+
*
|
|
1774
|
+
* @param topic Framework subscription Topic.
|
|
1775
|
+
*/ function validateTopic(topic) {
|
|
1150
1776
|
if (topic.id?.value === undefined || topic.id.value.trim().length === 0) {
|
|
1151
1777
|
throw new ConnectError("Subscription topic ID is required.", Code.InvalidArgument);
|
|
1152
1778
|
}
|
|
@@ -1159,6 +1785,10 @@ function validateTopic(topic) {
|
|
|
1159
1785
|
}
|
|
1160
1786
|
/**
|
|
1161
1787
|
* Validates a topic and returns its state or Event subscription shape.
|
|
1788
|
+
*
|
|
1789
|
+
* @param topic Framework subscription Topic.
|
|
1790
|
+
* @param route Registered target route with schema and tenancy.
|
|
1791
|
+
* @returns The normalized subscription shape.
|
|
1162
1792
|
*/
|
|
1163
1793
|
function createSubscriptionShape(topic, route) {
|
|
1164
1794
|
const target = topic.target;
|
|
@@ -1184,6 +1814,8 @@ function createSubscriptionShape(topic, route) {
|
|
|
1184
1814
|
}
|
|
1185
1815
|
/**
|
|
1186
1816
|
* Checks the supported event topic criteria.
|
|
1817
|
+
*
|
|
1818
|
+
* @param target Declared query or Topic target.
|
|
1187
1819
|
*/
|
|
1188
1820
|
function validateEventSubscriptionTopic(target) {
|
|
1189
1821
|
switch (target.criterion.case) {
|
|
@@ -1199,7 +1831,11 @@ function validateEventSubscriptionTopic(target) {
|
|
|
1199
1831
|
throw new ConnectError("SubscriptionService.Subscribe requires filters or include_all = true.", Code.InvalidArgument);
|
|
1200
1832
|
}
|
|
1201
1833
|
}
|
|
1202
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* Checks a Topic filter tree before accepting its definition.
|
|
1836
|
+
*
|
|
1837
|
+
* @param filters Target or subscription filter collection.
|
|
1838
|
+
*/ function validateSubscriptionFilterTree(filters) {
|
|
1203
1839
|
if (filters.length > SubscriptionLimits.compositeCount) {
|
|
1204
1840
|
throw new ConnectError("SubscriptionService.Subscribe may contain at most 8 composite filters.", Code.InvalidArgument);
|
|
1205
1841
|
}
|
|
@@ -1220,7 +1856,13 @@ function validateSubscriptionFilterTree(filters) {
|
|
|
1220
1856
|
enqueueSubscriptionFilterChildren(current.filter, current.depth, stack);
|
|
1221
1857
|
}
|
|
1222
1858
|
}
|
|
1223
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* Checks one nested subscription filter node and its bounds.
|
|
1861
|
+
*
|
|
1862
|
+
* @param filter One target or subscription filter.
|
|
1863
|
+
* @param depth Current composite-filter nesting depth.
|
|
1864
|
+
* @param counts Current filter traversal counts.
|
|
1865
|
+
*/ function validateSubscriptionFilterNode(filter, depth, counts) {
|
|
1224
1866
|
if (depth > SubscriptionLimits.compositeDepth) {
|
|
1225
1867
|
throw new ConnectError("SubscriptionService.Subscribe composite filters may nest at most 8 levels.", Code.InvalidArgument);
|
|
1226
1868
|
}
|
|
@@ -1247,7 +1889,13 @@ function validateSubscriptionFilterNode(filter, depth, counts) {
|
|
|
1247
1889
|
throw new ConnectError("SubscriptionService.Subscribe may contain at most 8 composite filters.", Code.InvalidArgument);
|
|
1248
1890
|
}
|
|
1249
1891
|
}
|
|
1250
|
-
|
|
1892
|
+
/**
|
|
1893
|
+
* Queues nested filter children for bounded traversal.
|
|
1894
|
+
*
|
|
1895
|
+
* @param filter One target or subscription filter.
|
|
1896
|
+
* @param depth Current composite-filter nesting depth.
|
|
1897
|
+
* @param stack Pending filter traversal stack.
|
|
1898
|
+
*/ function enqueueSubscriptionFilterChildren(filter, depth, stack) {
|
|
1251
1899
|
for (const nested of filter.compositeFilter) {
|
|
1252
1900
|
if (depth + 1 > SubscriptionLimits.compositeDepth) {
|
|
1253
1901
|
throw new ConnectError("SubscriptionService.Subscribe composite filters may nest at most 8 levels.", Code.InvalidArgument);
|
|
@@ -1257,6 +1905,9 @@ function enqueueSubscriptionFilterChildren(filter, depth, stack) {
|
|
|
1257
1905
|
}
|
|
1258
1906
|
/**
|
|
1259
1907
|
* Checks a state filter path before resolving its fields.
|
|
1908
|
+
*
|
|
1909
|
+
* @param fieldPath Declared filter field path.
|
|
1910
|
+
* @param label Diagnostic label for the invalid input.
|
|
1260
1911
|
*/
|
|
1261
1912
|
function validateSubscriptionPath(fieldPath, label) {
|
|
1262
1913
|
if (fieldPath.length === 0 || fieldPath.some((field) => field.trim().length === 0)) {
|
|
@@ -1269,7 +1920,13 @@ function validateSubscriptionPath(fieldPath, label) {
|
|
|
1269
1920
|
throw new ConnectError(`SubscriptionService.Subscribe ${label} path components may contain at most 128 characters.`, Code.InvalidArgument);
|
|
1270
1921
|
}
|
|
1271
1922
|
}
|
|
1272
|
-
|
|
1923
|
+
/**
|
|
1924
|
+
* Decodes a subscription ID filter using the target ID schema.
|
|
1925
|
+
*
|
|
1926
|
+
* @param value Packed subscription ID Any.
|
|
1927
|
+
* @param route Registered target route with schema and tenancy.
|
|
1928
|
+
* @returns The typed subscription filter ID.
|
|
1929
|
+
*/ function decodeSubscriptionIdValue(value, route) {
|
|
1273
1930
|
if (!isAny(value)) {
|
|
1274
1931
|
throw new ConnectError("SubscriptionService.Subscribe id_filter values must be packed Any messages.", Code.InvalidArgument);
|
|
1275
1932
|
}
|
|
@@ -1284,7 +1941,13 @@ function decodeSubscriptionIdValue(value, route) {
|
|
|
1284
1941
|
}
|
|
1285
1942
|
return decoded;
|
|
1286
1943
|
}
|
|
1287
|
-
|
|
1944
|
+
/**
|
|
1945
|
+
* Decodes a field-filter Any with its declared value schema.
|
|
1946
|
+
*
|
|
1947
|
+
* @param value Packed field-filter Any, if present.
|
|
1948
|
+
* @param schema Registered Protobuf message schema.
|
|
1949
|
+
* @returns The typed field-filter value.
|
|
1950
|
+
*/ function decodeFieldFilterValue(value, schema) {
|
|
1288
1951
|
if (value === undefined || schema === undefined) {
|
|
1289
1952
|
return decodeAnyValue(value);
|
|
1290
1953
|
}
|
|
@@ -1294,7 +1957,13 @@ function decodeFieldFilterValue(value, schema) {
|
|
|
1294
1957
|
}
|
|
1295
1958
|
return decoded;
|
|
1296
1959
|
}
|
|
1297
|
-
|
|
1960
|
+
/**
|
|
1961
|
+
* Decodes a known Any payload against its declared schema.
|
|
1962
|
+
*
|
|
1963
|
+
* @param value Packed Any payload, if present.
|
|
1964
|
+
* @param schema Registered Protobuf message schema.
|
|
1965
|
+
* @returns The decoded payload value.
|
|
1966
|
+
*/ function decodeAnyValue(value, schema) {
|
|
1298
1967
|
if (value === undefined) {
|
|
1299
1968
|
return undefined;
|
|
1300
1969
|
}
|
|
@@ -1316,6 +1985,11 @@ function decodeAnyValue(value, schema) {
|
|
|
1316
1985
|
}
|
|
1317
1986
|
/**
|
|
1318
1987
|
* Resolves a state filter path to generated message fields.
|
|
1988
|
+
*
|
|
1989
|
+
* @param schema Registered Protobuf message schema.
|
|
1990
|
+
* @param fieldPath Declared filter field path.
|
|
1991
|
+
* @param label Diagnostic label for the invalid input.
|
|
1992
|
+
* @returns The registered field at the declared path.
|
|
1319
1993
|
*/
|
|
1320
1994
|
function resolveMessagePath(schema, fieldPath, label) {
|
|
1321
1995
|
let currentSchema = schema;
|
|
@@ -1335,14 +2009,30 @@ function resolveMessagePath(schema, fieldPath, label) {
|
|
|
1335
2009
|
...(leafSchema === undefined ? {} : { leafSchema }),
|
|
1336
2010
|
});
|
|
1337
2011
|
}
|
|
1338
|
-
|
|
2012
|
+
/**
|
|
2013
|
+
* Finds a declared field in a registered Protobuf message.
|
|
2014
|
+
*
|
|
2015
|
+
* @param schema Registered Protobuf message schema.
|
|
2016
|
+
* @param name Configured header or field name.
|
|
2017
|
+
* @returns The matching field descriptor, if present.
|
|
2018
|
+
*/ function findMessageField(schema, name) {
|
|
1339
2019
|
const fields = (schema?.fields ?? []);
|
|
1340
2020
|
return fields.find((field) => field.name === name || field.localName === name);
|
|
1341
2021
|
}
|
|
1342
|
-
|
|
2022
|
+
/**
|
|
2023
|
+
* Recognizes a Protobuf message value.
|
|
2024
|
+
*
|
|
2025
|
+
* @param value Candidate value to inspect as a Protobuf message.
|
|
2026
|
+
* @returns Whether the value is a message.
|
|
2027
|
+
*/ function isMessage(value) {
|
|
1343
2028
|
return typeof value === "object" && value !== null && "$typeName" in value;
|
|
1344
2029
|
}
|
|
1345
|
-
|
|
2030
|
+
/**
|
|
2031
|
+
* Recognizes a Protobuf Any message by its declared type name.
|
|
2032
|
+
*
|
|
2033
|
+
* @param value Candidate value to inspect as a Protobuf Any.
|
|
2034
|
+
* @returns Whether the value is an Any message.
|
|
2035
|
+
*/ function isAny(value) {
|
|
1346
2036
|
return isMessage(value) && value.$typeName === "google.protobuf.Any";
|
|
1347
2037
|
}
|
|
1348
2038
|
/**
|
|
@@ -1359,13 +2049,28 @@ function packVersionedState(schema, result) {
|
|
|
1359
2049
|
version: result.version ?? create(VersionSchema),
|
|
1360
2050
|
});
|
|
1361
2051
|
}
|
|
1362
|
-
|
|
2052
|
+
/**
|
|
2053
|
+
* Extracts the declared tenant from a command actor context.
|
|
2054
|
+
*
|
|
2055
|
+
* @param command Framework command envelope.
|
|
2056
|
+
* @returns The declared command tenant, if present.
|
|
2057
|
+
*/ function commandTenant(command) {
|
|
1363
2058
|
return tenantValue(command.context?.actorContext?.tenantId);
|
|
1364
2059
|
}
|
|
1365
|
-
|
|
2060
|
+
/**
|
|
2061
|
+
* Extracts the declared tenant from a Topic actor context.
|
|
2062
|
+
*
|
|
2063
|
+
* @param topic Framework subscription Topic.
|
|
2064
|
+
* @returns The Topic tenant, if present.
|
|
2065
|
+
*/ function topicTenant(topic) {
|
|
1366
2066
|
return tenantValue(topic?.context?.tenantId);
|
|
1367
2067
|
}
|
|
1368
|
-
|
|
2068
|
+
/**
|
|
2069
|
+
* Extracts the tenant value from a Proto tenant identifier.
|
|
2070
|
+
*
|
|
2071
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
2072
|
+
* @returns The tenant value, if present.
|
|
2073
|
+
*/ function tenantValue(tenantId) {
|
|
1369
2074
|
if (tenantId === undefined)
|
|
1370
2075
|
return undefined;
|
|
1371
2076
|
try {
|
|
@@ -1375,7 +2080,13 @@ function tenantValue(tenantId) {
|
|
|
1375
2080
|
return undefined;
|
|
1376
2081
|
}
|
|
1377
2082
|
}
|
|
1378
|
-
|
|
2083
|
+
/**
|
|
2084
|
+
* Finds the declared Entity ID field for a registered state route.
|
|
2085
|
+
*
|
|
2086
|
+
* @param schema Registered Protobuf message schema.
|
|
2087
|
+
* @param idField Registered Entity ID field.
|
|
2088
|
+
* @returns The Entity ID field descriptor.
|
|
2089
|
+
*/ function stateRouteIdField(schema, idField) {
|
|
1379
2090
|
const field = findMessageField(schema, idField?.name ?? "") ??
|
|
1380
2091
|
findMessageField(schema, idField?.localName ?? "") ??
|
|
1381
2092
|
schema.fields[0] ??
|
|
@@ -1420,19 +2131,17 @@ const TenantStatuses = Object.freeze({
|
|
|
1420
2131
|
* @returns Connect failure preserving its stable Spine error type.
|
|
1421
2132
|
*/
|
|
1422
2133
|
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);
|
|
2134
|
+
return ReadRequestStatus.unknownTenant(error);
|
|
1433
2135
|
},
|
|
1434
2136
|
});
|
|
1435
|
-
|
|
2137
|
+
/**
|
|
2138
|
+
* Checks a request tenant against bounded-context tenancy.
|
|
2139
|
+
*
|
|
2140
|
+
* @param multitenant Whether the bounded context requires a tenant.
|
|
2141
|
+
* @param tenantId Declared Proto tenant identifier.
|
|
2142
|
+
* @param subject Verified request subject.
|
|
2143
|
+
* @returns The tenant mismatch status, if any.
|
|
2144
|
+
*/ function tenantMismatch(multitenant, tenantId, subject) {
|
|
1436
2145
|
if (multitenant) {
|
|
1437
2146
|
return tenantId === undefined
|
|
1438
2147
|
? {
|
|
@@ -1448,7 +2157,12 @@ function tenantMismatch(multitenant, tenantId, subject) {
|
|
|
1448
2157
|
message: `Tenant is not applicable for this ${subject}.`,
|
|
1449
2158
|
};
|
|
1450
2159
|
}
|
|
1451
|
-
|
|
2160
|
+
/**
|
|
2161
|
+
* Maps a command-post failure to its public structured status.
|
|
2162
|
+
*
|
|
2163
|
+
* @param error The failure being classified or propagated.
|
|
2164
|
+
* @returns The sanitized command status.
|
|
2165
|
+
*/ function commandPostError(error) {
|
|
1452
2166
|
if (error instanceof TransitionValidationError) {
|
|
1453
2167
|
return {
|
|
1454
2168
|
type: error.type,
|
|
@@ -1458,7 +2172,8 @@ function commandPostError(error) {
|
|
|
1458
2172
|
}
|
|
1459
2173
|
if (error instanceof CommandValidationError) {
|
|
1460
2174
|
return {
|
|
1461
|
-
type: "
|
|
2175
|
+
type: "spine.core.CommandValidationError",
|
|
2176
|
+
code: CommandValidationErrorCode.INVALID_COMMAND,
|
|
1462
2177
|
message: "Command payload validation failed.",
|
|
1463
2178
|
details: AnyPacker.pack(ValidationErrorSchema, error.validationError),
|
|
1464
2179
|
};
|
|
@@ -1543,6 +2258,24 @@ const VALUE_DECODERS = Object.freeze([
|
|
|
1543
2258
|
* Groups non-public service contract, query, and subscription operations.
|
|
1544
2259
|
*/
|
|
1545
2260
|
const ServiceValues = Object.freeze({
|
|
2261
|
+
/**
|
|
2262
|
+
* Maps supported Topic shape errors to structured public failures.
|
|
2263
|
+
*
|
|
2264
|
+
* @param topic Constraint-valid Topic to inspect.
|
|
2265
|
+
* @param route Registered state or Event route.
|
|
2266
|
+
*/
|
|
2267
|
+
checkTopicSemantics(topic, route) {
|
|
2268
|
+
try {
|
|
2269
|
+
ServiceValues.validateTopic(topic);
|
|
2270
|
+
ServiceValues.createSubscriptionShape(topic, route);
|
|
2271
|
+
}
|
|
2272
|
+
catch (error) {
|
|
2273
|
+
if (error instanceof ConnectError && error.code === Code.InvalidArgument) {
|
|
2274
|
+
throw ReadRequestStatus.invalid("topic", false, error.rawMessage);
|
|
2275
|
+
}
|
|
2276
|
+
throw new ConnectError("Topic validation failed.", Code.Internal);
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
1546
2279
|
/**
|
|
1547
2280
|
* Checks one composite query filter and its current traversal bounds.
|
|
1548
2281
|
*
|
|
@@ -1660,26 +2393,25 @@ const ServiceValues = Object.freeze({
|
|
|
1660
2393
|
* Builds a stable Command rejection acknowledgement.
|
|
1661
2394
|
*
|
|
1662
2395
|
* @param messageId The packed Command ID, if supplied.
|
|
1663
|
-
* @param type The
|
|
2396
|
+
* @param type The error type.
|
|
1664
2397
|
* @param message The public error message.
|
|
2398
|
+
* @param code Numeric Proto error code when available.
|
|
2399
|
+
* @param details Packed structured validation details when available.
|
|
1665
2400
|
* @returns A sanitized Ack.
|
|
1666
2401
|
*/
|
|
1667
|
-
commandErrorAck(messageId, type, message) {
|
|
1668
|
-
return create(AckSchema, { messageId, status: errorStatus(type, message) });
|
|
2402
|
+
commandErrorAck(messageId, type, message, code, details) {
|
|
2403
|
+
return create(AckSchema, { messageId, status: errorStatus(type, message, details, code) });
|
|
1669
2404
|
},
|
|
1670
2405
|
commandTenant,
|
|
1671
2406
|
createReadPlan,
|
|
1672
2407
|
createSubscriptionRecord,
|
|
1673
2408
|
createSubscriptionRemoval,
|
|
1674
2409
|
createSubscriptionShape,
|
|
1675
|
-
defaultQueueLimit: DEFAULT_QUEUE_LIMIT,
|
|
1676
2410
|
defaultSubscriptionLimit: DEFAULT_SUBSCRIPTION_LIMIT,
|
|
1677
2411
|
errorStatus,
|
|
1678
2412
|
okResponse,
|
|
1679
2413
|
okStatus,
|
|
1680
|
-
positiveInteger,
|
|
1681
2414
|
packVersionedState,
|
|
1682
|
-
queryErrorResponse,
|
|
1683
2415
|
queryResultLimit: QueryLimits.resultCount,
|
|
1684
2416
|
stateRouteIdField,
|
|
1685
2417
|
subscriptionLimit,
|