@portalshq/capability-queue-broadcast 0.1.7 → 0.1.9

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Server-only producer API for one isolated Queue Broadcast Server endpoint.
4
4
 
5
+ `ContentQueuePipeline` is the reusable producer preparation boundary. It
6
+ deduplicates channel/run/content identities, bounds concurrent preparation and
7
+ measured duration/bytes/count, accounts for shared visuals once, commits
8
+ results in enqueue order, and exposes staging/release/completion acknowledgments.
9
+ Applications provide content preparation and persistence callbacks.
10
+
5
11
  The queue is deliberately separate from playback: a trusted application backend
6
12
  uses this package to enqueue and observe jobs; its browser player gets only the
7
13
  public HLS manifest returned by `getPlayback()`. Use
@@ -0,0 +1,66 @@
1
+ export type ContentQueueState = "queued" | "preparing" | "prepared" | "staged" | "released" | "completed" | "terminal-failed" | "cancelled";
2
+ export interface ContentQueueIdentity {
3
+ channelId: string;
4
+ runEpoch: string;
5
+ contentKey: string;
6
+ ordinal: number;
7
+ }
8
+ export interface ContentCapacity {
9
+ durationMs: number;
10
+ bytes: number;
11
+ contentCount: number;
12
+ visualKey?: string;
13
+ }
14
+ export interface ContentQueueLimits {
15
+ maxInFlight: number;
16
+ maxDurationMs?: number;
17
+ maxBytes?: number;
18
+ maxContentCount?: number;
19
+ }
20
+ export interface ContentQueueObservation {
21
+ identity: ContentQueueIdentity;
22
+ state: ContentQueueState;
23
+ sequence: number;
24
+ error?: unknown;
25
+ }
26
+ export interface ContentQueuePipelineOptions<TRequest, TPrepared> {
27
+ limits: ContentQueueLimits;
28
+ identity(request: TRequest): ContentQueueIdentity;
29
+ estimate(request: TRequest): ContentCapacity;
30
+ measure(prepared: TPrepared, request: TRequest): ContentCapacity;
31
+ prepare(request: TRequest, signal: AbortSignal): Promise<TPrepared>;
32
+ /** Commits completions in enqueue order, even when preparation finishes out of order. */
33
+ commit(observation: ContentQueueObservation, prepared: TPrepared | undefined, request: TRequest): Promise<void>;
34
+ onObservation?: (observation: ContentQueueObservation) => void;
35
+ }
36
+ export interface ContentQueueTicket<TPrepared> {
37
+ identity: ContentQueueIdentity;
38
+ completion: Promise<TPrepared>;
39
+ }
40
+ /** Bounded, ordered preparation for queue-broadcast producers. */
41
+ export declare class ContentQueuePipeline<TRequest, TPrepared> {
42
+ private readonly options;
43
+ private readonly work;
44
+ private readonly pending;
45
+ private nextSequence;
46
+ private nextCommitSequence;
47
+ private active;
48
+ private committing;
49
+ private stopped;
50
+ constructor(options: ContentQueuePipelineOptions<TRequest, TPrepared>);
51
+ enqueue(request: TRequest): ContentQueueTicket<TPrepared>;
52
+ /** Update queue lifecycle after preparation without creating another state owner. */
53
+ acknowledge(identity: ContentQueueIdentity, state: Extract<ContentQueueState, "staged" | "released" | "completed">): void;
54
+ snapshot(): readonly ContentQueueObservation[];
55
+ capacity(): ContentCapacity;
56
+ private capacityExcluding;
57
+ private assertMeasuredCapacity;
58
+ private assertCapacityAgainst;
59
+ stop(reason?: unknown): Promise<void>;
60
+ private assertCapacity;
61
+ private drain;
62
+ private run;
63
+ private commitReady;
64
+ private observe;
65
+ private toObservation;
66
+ }
@@ -0,0 +1,231 @@
1
+ /** Bounded, ordered preparation for queue-broadcast producers. */
2
+ export class ContentQueuePipeline {
3
+ options;
4
+ work = new Map();
5
+ pending = [];
6
+ nextSequence = 1;
7
+ nextCommitSequence = 1;
8
+ active = 0;
9
+ committing = false;
10
+ stopped = false;
11
+ constructor(options) {
12
+ this.options = options;
13
+ const { limits } = options;
14
+ assertPositiveInteger("maxInFlight", limits.maxInFlight);
15
+ assertOptionalLimit("maxDurationMs", limits.maxDurationMs);
16
+ assertOptionalLimit("maxBytes", limits.maxBytes);
17
+ assertOptionalLimit("maxContentCount", limits.maxContentCount);
18
+ }
19
+ enqueue(request) {
20
+ if (this.stopped)
21
+ throw new Error("ContentQueuePipeline is stopped");
22
+ const identity = normalizeIdentity(this.options.identity(request));
23
+ const key = identityKey(identity);
24
+ const existing = this.work.get(key);
25
+ if (existing)
26
+ return { identity: existing.identity, completion: itemCompletion(existing) };
27
+ const estimate = normalizeCapacity(this.options.estimate(request));
28
+ this.assertCapacity(estimate);
29
+ let resolve;
30
+ let reject;
31
+ const completion = new Promise((res, rej) => { resolve = res; reject = rej; });
32
+ const item = {
33
+ sequence: this.nextSequence++, key, identity, request, estimate, state: "queued",
34
+ controller: new AbortController(), resolve, reject,
35
+ };
36
+ Object.defineProperty(item, "completion", { value: completion });
37
+ this.work.set(key, item);
38
+ this.pending.push(item);
39
+ this.observe(item);
40
+ this.drain();
41
+ return { identity, completion };
42
+ }
43
+ /** Update queue lifecycle after preparation without creating another state owner. */
44
+ acknowledge(identity, state) {
45
+ const item = this.work.get(identityKey(normalizeIdentity(identity)));
46
+ if (!item)
47
+ throw new Error("Unknown content queue identity");
48
+ const allowed = item.state === "prepared"
49
+ ? state === "staged"
50
+ : item.state === "staged"
51
+ ? state === "released"
52
+ : item.state === "released" && state === "completed";
53
+ if (!allowed)
54
+ throw new Error(`Invalid content queue transition ${item.state} -> ${state}`);
55
+ item.state = state;
56
+ this.observe(item);
57
+ }
58
+ snapshot() {
59
+ return [...this.work.values()].sort((a, b) => a.sequence - b.sequence).map((item) => this.toObservation(item));
60
+ }
61
+ capacity() {
62
+ return capacityOf([...this.work.values()].filter((item) => !isTerminal(item.state)));
63
+ }
64
+ capacityExcluding(excluded) {
65
+ return capacityOf([...this.work.values()].filter((item) => item !== excluded && !isTerminal(item.state)));
66
+ }
67
+ assertMeasuredCapacity(item, candidate) {
68
+ this.assertCapacityAgainst(this.capacityExcluding(item), candidate, item);
69
+ }
70
+ assertCapacityAgainst(current, candidate, excluded) {
71
+ const sharesVisual = candidate.visualKey && [...this.work.values()].some((item) => {
72
+ if (item === excluded || isTerminal(item.state))
73
+ return false;
74
+ return (item.actual ?? item.estimate).visualKey === candidate.visualKey;
75
+ });
76
+ const bytes = current.bytes + (sharesVisual ? 0 : candidate.bytes);
77
+ const { limits } = this.options;
78
+ if (limits.maxDurationMs !== undefined && current.durationMs + candidate.durationMs > limits.maxDurationMs)
79
+ throw capacityError("duration");
80
+ if (limits.maxBytes !== undefined && bytes > limits.maxBytes)
81
+ throw capacityError("bytes");
82
+ if (limits.maxContentCount !== undefined && current.contentCount + candidate.contentCount > limits.maxContentCount)
83
+ throw capacityError("content count");
84
+ }
85
+ async stop(reason = new Error("ContentQueuePipeline stopped")) {
86
+ if (this.stopped)
87
+ return;
88
+ this.stopped = true;
89
+ for (const item of this.work.values()) {
90
+ if (isTerminal(item.state))
91
+ continue;
92
+ item.controller.abort(reason);
93
+ if (item.state !== "preparing") {
94
+ item.state = "cancelled";
95
+ item.error = reason;
96
+ this.observe(item);
97
+ }
98
+ }
99
+ this.pending.length = 0;
100
+ await this.commitReady();
101
+ await Promise.allSettled([...this.work.values()].map((item) => itemCompletion(item)));
102
+ }
103
+ assertCapacity(candidate) {
104
+ this.assertCapacityAgainst(this.capacity(), candidate);
105
+ }
106
+ drain() {
107
+ while (!this.stopped && this.active < this.options.limits.maxInFlight) {
108
+ const item = this.pending.shift();
109
+ if (!item)
110
+ return;
111
+ this.active += 1;
112
+ item.state = "preparing";
113
+ this.observe(item);
114
+ void this.run(item);
115
+ }
116
+ }
117
+ async run(item) {
118
+ try {
119
+ const prepared = await this.options.prepare(item.request, item.controller.signal);
120
+ if (item.controller.signal.aborted || this.stopped)
121
+ throw item.controller.signal.reason ?? new Error("cancelled");
122
+ const actual = normalizeCapacity(this.options.measure(prepared, item.request));
123
+ this.assertMeasuredCapacity(item, actual);
124
+ item.prepared = prepared;
125
+ item.actual = actual;
126
+ item.state = "prepared";
127
+ }
128
+ catch (error) {
129
+ item.error = error;
130
+ item.state = item.controller.signal.aborted || this.stopped ? "cancelled" : "terminal-failed";
131
+ }
132
+ finally {
133
+ this.active -= 1;
134
+ this.observe(item);
135
+ await this.commitReady();
136
+ this.drain();
137
+ }
138
+ }
139
+ async commitReady() {
140
+ if (this.committing)
141
+ return;
142
+ this.committing = true;
143
+ try {
144
+ while (true) {
145
+ const item = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
146
+ if (!item || !["prepared", "terminal-failed", "cancelled"].includes(item.state))
147
+ return;
148
+ const observation = this.toObservation(item);
149
+ try {
150
+ await this.options.commit(observation, item.prepared, item.request);
151
+ if (item.state === "prepared")
152
+ item.resolve(item.prepared);
153
+ else
154
+ item.reject(item.error);
155
+ this.nextCommitSequence += 1;
156
+ }
157
+ catch (error) {
158
+ item.error = error;
159
+ item.state = "terminal-failed";
160
+ this.observe(item);
161
+ item.reject(error);
162
+ this.nextCommitSequence += 1;
163
+ }
164
+ }
165
+ }
166
+ finally {
167
+ this.committing = false;
168
+ const next = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
169
+ if (next && ["prepared", "terminal-failed", "cancelled"].includes(next.state))
170
+ void this.commitReady();
171
+ }
172
+ }
173
+ observe(item) {
174
+ this.options.onObservation?.(this.toObservation(item));
175
+ }
176
+ toObservation(item) {
177
+ return { identity: item.identity, state: item.state, sequence: item.sequence, ...(item.error === undefined ? {} : { error: item.error }) };
178
+ }
179
+ }
180
+ function capacityOf(current) {
181
+ const visualKeys = new Set();
182
+ return current.reduce((total, item) => {
183
+ const measure = item.actual ?? item.estimate;
184
+ const countVisual = !measure.visualKey || !visualKeys.has(measure.visualKey);
185
+ if (measure.visualKey)
186
+ visualKeys.add(measure.visualKey);
187
+ total.durationMs += measure.durationMs;
188
+ total.bytes += countVisual ? measure.bytes : 0;
189
+ total.contentCount += measure.contentCount;
190
+ return total;
191
+ }, { durationMs: 0, bytes: 0, contentCount: 0 });
192
+ }
193
+ function itemCompletion(item) {
194
+ return item.completion;
195
+ }
196
+ function normalizeIdentity(identity) {
197
+ const result = { ...identity, channelId: identity.channelId.trim(), runEpoch: identity.runEpoch.trim(), contentKey: identity.contentKey.trim() };
198
+ if (!result.channelId || !result.runEpoch || !result.contentKey)
199
+ throw new TypeError("content identity fields are required");
200
+ if (!Number.isInteger(result.ordinal) || result.ordinal < 0)
201
+ throw new TypeError("ordinal must be a non-negative integer");
202
+ return result;
203
+ }
204
+ function identityKey(identity) {
205
+ return JSON.stringify([identity.channelId, identity.runEpoch, identity.contentKey, identity.ordinal]);
206
+ }
207
+ function normalizeCapacity(value) {
208
+ for (const [name, amount] of Object.entries({ durationMs: value.durationMs, bytes: value.bytes, contentCount: value.contentCount })) {
209
+ if (!Number.isFinite(amount) || amount < 0)
210
+ throw new TypeError(`${name} must be non-negative`);
211
+ }
212
+ if (!Number.isInteger(value.contentCount))
213
+ throw new TypeError("contentCount must be an integer");
214
+ return { ...value, visualKey: value.visualKey?.trim() || undefined };
215
+ }
216
+ function isTerminal(state) {
217
+ return state === "completed" || state === "terminal-failed" || state === "cancelled";
218
+ }
219
+ function capacityError(kind) {
220
+ const error = new Error(`Content queue ${kind} capacity exceeded`);
221
+ error.name = "ContentQueueCapacityError";
222
+ return error;
223
+ }
224
+ function assertPositiveInteger(name, value) {
225
+ if (!Number.isInteger(value) || value < 1)
226
+ throw new TypeError(`${name} must be a positive integer`);
227
+ }
228
+ function assertOptionalLimit(name, value) {
229
+ if (value !== undefined && (!Number.isFinite(value) || value < 0))
230
+ throw new TypeError(`${name} must be non-negative`);
231
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./client.js";
2
+ export * from "./content-pipeline.js";
2
3
  export * from "./generated/api.js";
3
4
  export * from "./streaming/index.js";
4
5
  export * from "./monitoring/metrics.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./client.js";
2
+ export * from "./content-pipeline.js";
2
3
  // Low-level OpenAPI client. Prefer QueueBroadcastClient for application code.
3
4
  export * from "./generated/api.js";
4
5
  // Streaming infrastructure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portalshq/capability-queue-broadcast",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/portalshq/portals-cloud.git",
@@ -22,8 +22,8 @@
22
22
  "generate:check": "npm run generate && npm run typecheck"
23
23
  },
24
24
  "dependencies": {
25
- "@portalshq/capability-realtime-fanout": "^0.1.6",
26
- "@portalshq/capability-video-delivery": "^0.1.6"
25
+ "@portalshq/capability-realtime-fanout": "^0.1.8",
26
+ "@portalshq/capability-video-delivery": "^0.1.8"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.0.0",