@danypops/vehicle-core 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vehicle-contract.d.ts +51 -0
- package/dist/vehicle-contract.js +34 -0
- package/dist/vehicle-watchers.d.ts +60 -0
- package/dist/vehicle-watchers.js +85 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/vehicle-contract.ts +82 -0
- package/src/vehicle-watchers.ts +103 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -182,8 +182,59 @@ export interface VehicleManifestOperation extends VehicleOperationDescriptor {
|
|
|
182
182
|
readonly available: boolean;
|
|
183
183
|
readonly unavailableReason?: string;
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* A named, schema'd event type a provider declares as part of its
|
|
187
|
+
* manifest -- the typed alternative to a raw PushChannel.publish(topic,
|
|
188
|
+
* payload) call with a hand-invented topic string. Confirmed independently
|
|
189
|
+
* reinvented three-plus times across Papyrus and Lector before this
|
|
190
|
+
* existed (see this task's own body). No `available` flag the way an
|
|
191
|
+
* operation has one: an event type, once declared, is always emittable --
|
|
192
|
+
* there's no credential-gated "this event is currently unavailable"
|
|
193
|
+
* concept the way a live external-service-backed operation can have.
|
|
194
|
+
*/
|
|
195
|
+
export interface VehicleEventDescriptor {
|
|
196
|
+
readonly name: string;
|
|
197
|
+
readonly version: number;
|
|
198
|
+
readonly description: string;
|
|
199
|
+
readonly payloadSchema: JsonSchema;
|
|
200
|
+
/** Same bounded-resource discipline as an operation's own maxRequestBytes/maxResponseBytes -- required, never silently defaulted. */
|
|
201
|
+
readonly maxPayloadBytes: number;
|
|
202
|
+
}
|
|
203
|
+
export interface VehicleEvent<Payload> {
|
|
204
|
+
readonly descriptor: VehicleEventDescriptor;
|
|
205
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
206
|
+
}
|
|
207
|
+
export interface DefineVehicleEventOptions<Payload> {
|
|
208
|
+
readonly name: string;
|
|
209
|
+
readonly version: number;
|
|
210
|
+
readonly description: string;
|
|
211
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
212
|
+
readonly maxPayloadBytes: number;
|
|
213
|
+
}
|
|
214
|
+
export declare function defineVehicleEvent<Payload>(options: DefineVehicleEventOptions<Payload>): VehicleEvent<Payload>;
|
|
215
|
+
export type VehicleManifestEvent = VehicleEventDescriptor;
|
|
216
|
+
export type VehicleEventHandler<Payload> = (payload: Payload) => void;
|
|
217
|
+
export interface VehicleSubscription {
|
|
218
|
+
close(): void;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
|
|
222
|
+
* vehicle-server) publishes an event under, and a subscriber
|
|
223
|
+
* (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
|
|
224
|
+
* function in vehicle-core so both sides can never drift apart on the
|
|
225
|
+
* convention, the same failure mode this primitive exists to prevent
|
|
226
|
+
* providers from reinventing per-project.
|
|
227
|
+
*/
|
|
228
|
+
export declare function vehicleEventTopic(name: string, version: number): string;
|
|
229
|
+
/**
|
|
230
|
+
* `events` is optional purely for backward compatibility with every
|
|
231
|
+
* hand-authored VehicleManifest test fixture across the ecosystem that
|
|
232
|
+
* predates this field -- a real VehicleRegistry.manifest() always
|
|
233
|
+
* populates it (as [] when no events are declared), never omits it.
|
|
234
|
+
*/
|
|
185
235
|
export interface VehicleManifest extends VehicleManifestIdentity {
|
|
186
236
|
readonly operations: readonly VehicleManifestOperation[];
|
|
237
|
+
readonly events?: readonly VehicleManifestEvent[];
|
|
187
238
|
}
|
|
188
239
|
export interface VehicleClient {
|
|
189
240
|
manifest(): Promise<VehicleManifest>;
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -69,6 +69,40 @@ export function extractVehicleContent(output) {
|
|
|
69
69
|
}
|
|
70
70
|
return blocks;
|
|
71
71
|
}
|
|
72
|
+
function validateEventMetadata(options) {
|
|
73
|
+
if (!options.name.trim())
|
|
74
|
+
throw new Error("Vehicle event name must not be empty");
|
|
75
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
76
|
+
throw new Error("Vehicle event version must be a positive integer");
|
|
77
|
+
}
|
|
78
|
+
if (!options.description.trim())
|
|
79
|
+
throw new Error("Vehicle event description must not be empty");
|
|
80
|
+
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
|
|
81
|
+
throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function defineVehicleEvent(options) {
|
|
85
|
+
validateEventMetadata(options);
|
|
86
|
+
const descriptor = Object.freeze({
|
|
87
|
+
name: options.name,
|
|
88
|
+
version: options.version,
|
|
89
|
+
description: options.description,
|
|
90
|
+
payloadSchema: cloneJson(options.payload.jsonSchema),
|
|
91
|
+
maxPayloadBytes: options.maxPayloadBytes,
|
|
92
|
+
});
|
|
93
|
+
return Object.freeze({ descriptor, payload: options.payload });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
|
|
97
|
+
* vehicle-server) publishes an event under, and a subscriber
|
|
98
|
+
* (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
|
|
99
|
+
* function in vehicle-core so both sides can never drift apart on the
|
|
100
|
+
* convention, the same failure mode this primitive exists to prevent
|
|
101
|
+
* providers from reinventing per-project.
|
|
102
|
+
*/
|
|
103
|
+
export function vehicleEventTopic(name, version) {
|
|
104
|
+
return `vehicle-event:${name}@${version}`;
|
|
105
|
+
}
|
|
72
106
|
export function defineVehicleOperation(options) {
|
|
73
107
|
validateOperationMetadata(options);
|
|
74
108
|
const descriptor = Object.freeze({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
19
|
+
export declare const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
20
|
+
export interface WatchRegistration {
|
|
21
|
+
readonly watchId: string;
|
|
22
|
+
readonly scope: string;
|
|
23
|
+
readonly resource: string;
|
|
24
|
+
readonly topic: string;
|
|
25
|
+
}
|
|
26
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
27
|
+
export declare class WatchLimitExceeded extends Error {
|
|
28
|
+
readonly scope: string;
|
|
29
|
+
readonly max: number;
|
|
30
|
+
constructor(scope: string, max: number);
|
|
31
|
+
}
|
|
32
|
+
export interface WatchRegistryOptions {
|
|
33
|
+
/** Defaults to DEFAULT_MAX_WATCHES_PER_SCOPE. */
|
|
34
|
+
readonly maxWatchesPerScope?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare class WatchRegistry {
|
|
37
|
+
private readonly byId;
|
|
38
|
+
private readonly byScope;
|
|
39
|
+
private readonly maxWatchesPerScope;
|
|
40
|
+
constructor(options?: WatchRegistryOptions);
|
|
41
|
+
add(scope: string, resource: string, watchId: string, topic: string): WatchRegistration;
|
|
42
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
43
|
+
remove(watchId: string): WatchRegistration | undefined;
|
|
44
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
45
|
+
hasAnyFor(scope: string): boolean;
|
|
46
|
+
registrationsFor(scope: string): readonly WatchRegistration[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
50
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
51
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
52
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
53
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
54
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
55
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
56
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
57
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
58
|
+
* declared-event-type layer itself.
|
|
59
|
+
*/
|
|
60
|
+
export declare function vehicleWatchTopic(watchId: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
19
|
+
export const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
20
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
21
|
+
export class WatchLimitExceeded extends Error {
|
|
22
|
+
scope;
|
|
23
|
+
max;
|
|
24
|
+
constructor(scope, max) {
|
|
25
|
+
super(`scope "${scope}" already has ${max} active watches -- unwatch one before adding another`);
|
|
26
|
+
this.scope = scope;
|
|
27
|
+
this.max = max;
|
|
28
|
+
this.name = "WatchLimitExceeded";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class WatchRegistry {
|
|
32
|
+
byId = new Map();
|
|
33
|
+
byScope = new Map();
|
|
34
|
+
maxWatchesPerScope;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.maxWatchesPerScope = options.maxWatchesPerScope ?? DEFAULT_MAX_WATCHES_PER_SCOPE;
|
|
37
|
+
}
|
|
38
|
+
add(scope, resource, watchId, topic) {
|
|
39
|
+
const existing = this.byScope.get(scope) ?? new Set();
|
|
40
|
+
if (existing.size >= this.maxWatchesPerScope)
|
|
41
|
+
throw new WatchLimitExceeded(scope, this.maxWatchesPerScope);
|
|
42
|
+
const registration = { watchId, scope, resource, topic };
|
|
43
|
+
existing.add(watchId);
|
|
44
|
+
this.byScope.set(scope, existing);
|
|
45
|
+
this.byId.set(watchId, registration);
|
|
46
|
+
return registration;
|
|
47
|
+
}
|
|
48
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
49
|
+
remove(watchId) {
|
|
50
|
+
const registration = this.byId.get(watchId);
|
|
51
|
+
if (!registration)
|
|
52
|
+
return undefined;
|
|
53
|
+
this.byId.delete(watchId);
|
|
54
|
+
const scopeWatches = this.byScope.get(registration.scope);
|
|
55
|
+
scopeWatches?.delete(watchId);
|
|
56
|
+
if (scopeWatches?.size === 0)
|
|
57
|
+
this.byScope.delete(registration.scope);
|
|
58
|
+
return registration;
|
|
59
|
+
}
|
|
60
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
61
|
+
hasAnyFor(scope) {
|
|
62
|
+
return (this.byScope.get(scope)?.size ?? 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
registrationsFor(scope) {
|
|
65
|
+
const ids = this.byScope.get(scope);
|
|
66
|
+
if (!ids)
|
|
67
|
+
return [];
|
|
68
|
+
return Array.from(ids, (id) => this.byId.get(id)).filter((registration) => registration !== undefined);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
73
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
74
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
75
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
76
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
77
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
78
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
79
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
80
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
81
|
+
* declared-event-type layer itself.
|
|
82
|
+
*/
|
|
83
|
+
export function vehicleWatchTopic(watchId) {
|
|
84
|
+
return `vehicle-watch:${watchId}`;
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
package/src/vehicle-contract.ts
CHANGED
|
@@ -246,8 +246,90 @@ export interface VehicleManifestOperation extends VehicleOperationDescriptor {
|
|
|
246
246
|
readonly unavailableReason?: string;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* A named, schema'd event type a provider declares as part of its
|
|
251
|
+
* manifest -- the typed alternative to a raw PushChannel.publish(topic,
|
|
252
|
+
* payload) call with a hand-invented topic string. Confirmed independently
|
|
253
|
+
* reinvented three-plus times across Papyrus and Lector before this
|
|
254
|
+
* existed (see this task's own body). No `available` flag the way an
|
|
255
|
+
* operation has one: an event type, once declared, is always emittable --
|
|
256
|
+
* there's no credential-gated "this event is currently unavailable"
|
|
257
|
+
* concept the way a live external-service-backed operation can have.
|
|
258
|
+
*/
|
|
259
|
+
export interface VehicleEventDescriptor {
|
|
260
|
+
readonly name: string;
|
|
261
|
+
readonly version: number;
|
|
262
|
+
readonly description: string;
|
|
263
|
+
readonly payloadSchema: JsonSchema;
|
|
264
|
+
/** Same bounded-resource discipline as an operation's own maxRequestBytes/maxResponseBytes -- required, never silently defaulted. */
|
|
265
|
+
readonly maxPayloadBytes: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface VehicleEvent<Payload> {
|
|
269
|
+
readonly descriptor: VehicleEventDescriptor;
|
|
270
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface DefineVehicleEventOptions<Payload> {
|
|
274
|
+
readonly name: string;
|
|
275
|
+
readonly version: number;
|
|
276
|
+
readonly description: string;
|
|
277
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
278
|
+
readonly maxPayloadBytes: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateEventMetadata<Payload>(options: DefineVehicleEventOptions<Payload>): void {
|
|
282
|
+
if (!options.name.trim()) throw new Error("Vehicle event name must not be empty");
|
|
283
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
284
|
+
throw new Error("Vehicle event version must be a positive integer");
|
|
285
|
+
}
|
|
286
|
+
if (!options.description.trim()) throw new Error("Vehicle event description must not be empty");
|
|
287
|
+
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
|
|
288
|
+
throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function defineVehicleEvent<Payload>(options: DefineVehicleEventOptions<Payload>): VehicleEvent<Payload> {
|
|
293
|
+
validateEventMetadata(options);
|
|
294
|
+
const descriptor: VehicleEventDescriptor = Object.freeze({
|
|
295
|
+
name: options.name,
|
|
296
|
+
version: options.version,
|
|
297
|
+
description: options.description,
|
|
298
|
+
payloadSchema: cloneJson(options.payload.jsonSchema),
|
|
299
|
+
maxPayloadBytes: options.maxPayloadBytes,
|
|
300
|
+
});
|
|
301
|
+
return Object.freeze({ descriptor, payload: options.payload });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type VehicleManifestEvent = VehicleEventDescriptor;
|
|
305
|
+
|
|
306
|
+
export type VehicleEventHandler<Payload> = (payload: Payload) => void;
|
|
307
|
+
|
|
308
|
+
export interface VehicleSubscription {
|
|
309
|
+
close(): void;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
|
|
314
|
+
* vehicle-server) publishes an event under, and a subscriber
|
|
315
|
+
* (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
|
|
316
|
+
* function in vehicle-core so both sides can never drift apart on the
|
|
317
|
+
* convention, the same failure mode this primitive exists to prevent
|
|
318
|
+
* providers from reinventing per-project.
|
|
319
|
+
*/
|
|
320
|
+
export function vehicleEventTopic(name: string, version: number): string {
|
|
321
|
+
return `vehicle-event:${name}@${version}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `events` is optional purely for backward compatibility with every
|
|
326
|
+
* hand-authored VehicleManifest test fixture across the ecosystem that
|
|
327
|
+
* predates this field -- a real VehicleRegistry.manifest() always
|
|
328
|
+
* populates it (as [] when no events are declared), never omits it.
|
|
329
|
+
*/
|
|
249
330
|
export interface VehicleManifest extends VehicleManifestIdentity {
|
|
250
331
|
readonly operations: readonly VehicleManifestOperation[];
|
|
332
|
+
readonly events?: readonly VehicleManifestEvent[];
|
|
251
333
|
}
|
|
252
334
|
|
|
253
335
|
export interface VehicleClient {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
20
|
+
export const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
21
|
+
|
|
22
|
+
export interface WatchRegistration {
|
|
23
|
+
readonly watchId: string;
|
|
24
|
+
readonly scope: string;
|
|
25
|
+
readonly resource: string;
|
|
26
|
+
readonly topic: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
30
|
+
export class WatchLimitExceeded extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly scope: string,
|
|
33
|
+
readonly max: number,
|
|
34
|
+
) {
|
|
35
|
+
super(`scope "${scope}" already has ${max} active watches -- unwatch one before adding another`);
|
|
36
|
+
this.name = "WatchLimitExceeded";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WatchRegistryOptions {
|
|
41
|
+
/** Defaults to DEFAULT_MAX_WATCHES_PER_SCOPE. */
|
|
42
|
+
readonly maxWatchesPerScope?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class WatchRegistry {
|
|
46
|
+
private readonly byId = new Map<string, WatchRegistration>();
|
|
47
|
+
private readonly byScope = new Map<string, Set<string>>();
|
|
48
|
+
private readonly maxWatchesPerScope: number;
|
|
49
|
+
|
|
50
|
+
constructor(options: WatchRegistryOptions = {}) {
|
|
51
|
+
this.maxWatchesPerScope = options.maxWatchesPerScope ?? DEFAULT_MAX_WATCHES_PER_SCOPE;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
add(scope: string, resource: string, watchId: string, topic: string): WatchRegistration {
|
|
55
|
+
const existing = this.byScope.get(scope) ?? new Set();
|
|
56
|
+
if (existing.size >= this.maxWatchesPerScope) throw new WatchLimitExceeded(scope, this.maxWatchesPerScope);
|
|
57
|
+
const registration: WatchRegistration = { watchId, scope, resource, topic };
|
|
58
|
+
existing.add(watchId);
|
|
59
|
+
this.byScope.set(scope, existing);
|
|
60
|
+
this.byId.set(watchId, registration);
|
|
61
|
+
return registration;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
65
|
+
remove(watchId: string): WatchRegistration | undefined {
|
|
66
|
+
const registration = this.byId.get(watchId);
|
|
67
|
+
if (!registration) return undefined;
|
|
68
|
+
this.byId.delete(watchId);
|
|
69
|
+
const scopeWatches = this.byScope.get(registration.scope);
|
|
70
|
+
scopeWatches?.delete(watchId);
|
|
71
|
+
if (scopeWatches?.size === 0) this.byScope.delete(registration.scope);
|
|
72
|
+
return registration;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
76
|
+
hasAnyFor(scope: string): boolean {
|
|
77
|
+
return (this.byScope.get(scope)?.size ?? 0) > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
registrationsFor(scope: string): readonly WatchRegistration[] {
|
|
81
|
+
const ids = this.byScope.get(scope);
|
|
82
|
+
if (!ids) return [];
|
|
83
|
+
return Array.from(ids, (id) => this.byId.get(id)).filter(
|
|
84
|
+
(registration): registration is WatchRegistration => registration !== undefined,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
91
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
92
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
93
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
94
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
95
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
96
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
97
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
98
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
99
|
+
* declared-event-type layer itself.
|
|
100
|
+
*/
|
|
101
|
+
export function vehicleWatchTopic(watchId: string): string {
|
|
102
|
+
return `vehicle-watch:${watchId}`;
|
|
103
|
+
}
|