borgmcp-server 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +70 -22
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/cli.js +62 -11
  4. package/dist/cli.js.map +1 -1
  5. package/dist/coordination-api.d.ts +3 -1
  6. package/dist/coordination-api.js +476 -78
  7. package/dist/coordination-api.js.map +1 -1
  8. package/dist/credentials.d.ts +13 -7
  9. package/dist/credentials.js +77 -15
  10. package/dist/credentials.js.map +1 -1
  11. package/dist/debug-log.d.ts +76 -0
  12. package/dist/debug-log.js +124 -0
  13. package/dist/debug-log.js.map +1 -0
  14. package/dist/enrollment.d.ts +3 -11
  15. package/dist/enrollment.js +21 -60
  16. package/dist/enrollment.js.map +1 -1
  17. package/dist/https-server.d.ts +5 -20
  18. package/dist/https-server.js +108 -50
  19. package/dist/https-server.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/message-taxonomy.d.ts +38 -0
  22. package/dist/message-taxonomy.js +218 -0
  23. package/dist/message-taxonomy.js.map +1 -0
  24. package/dist/migrations.d.ts +4 -0
  25. package/dist/migrations.js +99 -0
  26. package/dist/migrations.js.map +1 -1
  27. package/dist/operator-error.d.ts +2 -1
  28. package/dist/operator-error.js +23 -5
  29. package/dist/operator-error.js.map +1 -1
  30. package/dist/role-section.d.ts +14 -0
  31. package/dist/role-section.js +87 -0
  32. package/dist/role-section.js.map +1 -0
  33. package/dist/service.d.ts +14 -4
  34. package/dist/service.js +243 -25
  35. package/dist/service.js.map +1 -1
  36. package/dist/start-options.d.ts +5 -1
  37. package/dist/start-options.js +17 -3
  38. package/dist/start-options.js.map +1 -1
  39. package/dist/store.d.ts +106 -8
  40. package/dist/store.js +772 -120
  41. package/dist/store.js.map +1 -1
  42. package/npm-shrinkwrap.json +6 -7
  43. package/package.json +2 -2
  44. package/src/cli.ts +61 -11
  45. package/src/coordination-api.ts +490 -72
  46. package/src/credentials.ts +103 -19
  47. package/src/debug-log.ts +165 -0
  48. package/src/enrollment.ts +32 -78
  49. package/src/https-server.ts +113 -78
  50. package/src/index.ts +1 -1
  51. package/src/message-taxonomy.ts +284 -0
  52. package/src/migrations.ts +102 -0
  53. package/src/operator-error.ts +40 -6
  54. package/src/role-section.ts +108 -0
  55. package/src/service.ts +268 -27
  56. package/src/start-options.ts +21 -4
  57. package/src/store.ts +887 -142
  58. package/dist/protocol-draft.d.ts +0 -2
  59. package/dist/protocol-draft.js +0 -31
  60. package/dist/protocol-draft.js.map +0 -1
  61. package/src/protocol-draft.ts +0 -32
@@ -1,12 +1,20 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ATTACH_PATH, ErrorCode, PROTOCOL_VERSION, ProtocolContractError, createProtocolEnvelope, decodeAttachRequestEnvelope, decodeProtocolEnvelope, } from "borgmcp-shared/protocol";
1
3
  import { assertServerDerivedPrincipal } from "./principal.js";
2
- import { CursorExpiredError, AttachConflictError, AccessDeniedError, CreateCubeConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
4
+ import { disabledDebugLogger } from "./debug-log.js";
5
+ import { patchMessageTaxonomy, resolveMessageRouting, validateMessageTaxonomy, } from "./message-taxonomy.js";
6
+ import { CursorExpiredError, AttachSessionRejectedError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
3
7
  export class CoordinationApi {
4
8
  #runtime;
5
9
  #authority;
10
+ #debugLogger;
11
+ #streamHeartbeatMs;
6
12
  #replayBarrier;
7
- constructor(runtime, authority) {
13
+ constructor(runtime, authority, debugLogger = disabledDebugLogger, streamHeartbeatMs = 5_000) {
8
14
  this.#runtime = runtime;
9
15
  this.#authority = authority;
16
+ this.#debugLogger = debugLogger;
17
+ this.#streamHeartbeatMs = streamHeartbeatMs;
10
18
  }
11
19
  armReplayTransition() {
12
20
  let markReached;
@@ -24,35 +32,36 @@ export class CoordinationApi {
24
32
  async handle(request) {
25
33
  assertServerDerivedPrincipal(request.principal);
26
34
  const authentication = request.principal;
27
- if (request.path === "/api/client/attach" && request.method === "POST") {
35
+ if (request.path === ATTACH_PATH && request.method === "POST") {
28
36
  const requestId = safeRequestId(request.body);
29
37
  try {
30
- const envelope = decodeEnvelope(request.body);
31
- exactKeys(envelope.payload, ["cube_id", "role_id", "retry_key"], ["prior_drone_id"]);
32
- const priorDroneId = envelope.payload["prior_drone_id"] === undefined
33
- ? undefined
34
- : requiredUuid(envelope.payload, "prior_drone_id");
38
+ const envelope = decodeAttachRequestEnvelope(request.body);
39
+ const priorDroneId = envelope.payload.prior_drone_id;
35
40
  const attachment = this.#authority.attachSeat(this.#runtime.forPrincipal(authentication), {
36
- cubeId: requiredUuid(envelope.payload, "cube_id"),
37
- roleId: requiredUuid(envelope.payload, "role_id"),
38
- retryKey: requiredUuid(envelope.payload, "retry_key"),
41
+ cubeId: envelope.payload.cube_id,
42
+ roleId: envelope.payload.role_id,
43
+ sessionCredential: envelope.payload.session_credential,
39
44
  ...(priorDroneId === undefined ? {} : { priorDroneId }),
40
45
  });
41
- return success(201, envelope.requestId, {
46
+ return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
47
+ result: attachment.result,
42
48
  cube: attachment.cube,
43
49
  role: attachment.role,
44
50
  drone: attachment.drone,
45
51
  session: {
46
- token: attachment.credential,
52
+ id: attachment.sessionId,
47
53
  expires_at: attachment.expiresAt,
48
- generation: attachment.generation,
49
54
  },
50
- reattached: attachment.reattached,
51
55
  });
52
56
  }
53
57
  catch (error) {
54
- if (error instanceof AttachConflictError) {
55
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
58
+ if (error instanceof ProtocolContractError) {
59
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
60
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
61
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
62
+ }
63
+ if (error instanceof AttachSessionRejectedError) {
64
+ return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
56
65
  }
57
66
  if (error instanceof ScopedStoreError) {
58
67
  return failure(404, "NOT_FOUND", error.message, requestId);
@@ -72,6 +81,7 @@ export class CoordinationApi {
72
81
  owner_id: cube.ownerId,
73
82
  name: cube.name,
74
83
  cube_directive: cube.directive,
84
+ message_taxonomy: cube.messageTaxonomy,
75
85
  created_at: cube.createdAt,
76
86
  updated_at: cube.updatedAt,
77
87
  }));
@@ -107,21 +117,31 @@ export class CoordinationApi {
107
117
  if (error instanceof StorageCapacityError) {
108
118
  return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
109
119
  }
120
+ if (error instanceof ProtocolContractError) {
121
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
122
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
123
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
124
+ }
110
125
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
111
126
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
112
127
  }
113
128
  throw error;
114
129
  }
115
130
  }
116
- const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
131
+ const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
117
132
  .exec(request.path);
118
- if (match === null)
133
+ const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
134
+ .exec(request.path);
135
+ if (match === null && roleMatch === null) {
119
136
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
120
- const cubeId = match[1];
121
- if (!uuidPattern.test(cubeId)) {
137
+ }
138
+ const cubeId = (roleMatch?.[1] ?? match?.[1]);
139
+ const roleId = roleMatch?.[2];
140
+ if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
122
141
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
123
142
  }
124
- const resource = match[2];
143
+ const resource = roleMatch === null ? match?.[2] : "role";
144
+ const sectionPatch = roleMatch?.[3] !== undefined;
125
145
  const store = this.#runtime.forPrincipal(authentication);
126
146
  try {
127
147
  if (resource === undefined && request.method === "GET") {
@@ -134,31 +154,194 @@ export class CoordinationApi {
134
154
  owner_id: cube.ownerId,
135
155
  name: cube.name,
136
156
  cube_directive: cube.directive,
157
+ message_taxonomy: cube.messageTaxonomy,
137
158
  created_at: cube.createdAt,
138
159
  updated_at: cube.updatedAt,
139
160
  },
140
161
  });
141
162
  }
163
+ if (resource === undefined && request.method === "PATCH") {
164
+ const envelope = decodeEnvelope(request.body);
165
+ exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
166
+ if (Object.keys(envelope.payload).length === 0)
167
+ throw new InputError();
168
+ const directive = optionalText(envelope.payload, "cube_directive", 100_000);
169
+ const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
170
+ const cube = store.updateCube(cubeId, {
171
+ ...(directive === undefined ? {} : { directive }),
172
+ ...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
173
+ });
174
+ return success(200, envelope.requestId, { cube: cubePayload(cube) });
175
+ }
176
+ if (resource === "taxonomy-patch" && request.method === "POST") {
177
+ const envelope = decodeEnvelope(request.body);
178
+ const action = envelope.payload["action"];
179
+ const cube = store.getCube(cubeId);
180
+ if (cube === null)
181
+ throw new ScopedStoreError();
182
+ if (action === "remove") {
183
+ exactKeys(envelope.payload, ["action", "class"]);
184
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
185
+ action,
186
+ className: requiredString(envelope.payload, "class", 64),
187
+ });
188
+ return success(200, envelope.requestId, {
189
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
190
+ });
191
+ }
192
+ if (action !== "add" && action !== "replace")
193
+ throw new InputError();
194
+ exactKeys(envelope.payload, ["action", "class_def"]);
195
+ const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])[0];
196
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
197
+ return success(200, envelope.requestId, {
198
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
199
+ });
200
+ }
142
201
  if (resource === "roles" && request.method === "GET") {
143
202
  return success(200, "roles-read", { roles: store.listRoles(cubeId) });
144
203
  }
204
+ if (resource === "roles" && request.method === "POST") {
205
+ const envelope = decodeEnvelope(request.body);
206
+ exactKeys(envelope.payload, ["name"], [
207
+ "short_description",
208
+ "detailed_description",
209
+ "is_default",
210
+ "is_mandatory",
211
+ "is_human_seat",
212
+ "can_broadcast",
213
+ "receives_all_direct",
214
+ "role_class",
215
+ ]);
216
+ const role = store.createRole(cubeId, {
217
+ name: requiredRoleName(envelope.payload, "name"),
218
+ shortDescription: optionalText(envelope.payload, "short_description", 1_024) ?? "",
219
+ detailedDescription: optionalText(envelope.payload, "detailed_description", 51_200) ?? "",
220
+ isDefault: optionalBoolean(envelope.payload["is_default"]),
221
+ isMandatory: optionalBoolean(envelope.payload["is_mandatory"]),
222
+ isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
223
+ canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
224
+ receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
225
+ ...(envelope.payload["role_class"] === undefined
226
+ ? {}
227
+ : { roleClass: optionalRoleClass(envelope.payload["role_class"]) }),
228
+ });
229
+ return success(201, envelope.requestId, { role });
230
+ }
231
+ if (resource === "role" && !sectionPatch && request.method === "PATCH") {
232
+ const envelope = decodeEnvelope(request.body);
233
+ exactKeys(envelope.payload, [], [
234
+ "name",
235
+ "short_description",
236
+ "detailed_description",
237
+ "is_default",
238
+ "is_mandatory",
239
+ "is_human_seat",
240
+ "can_broadcast",
241
+ "receives_all_direct",
242
+ "role_class",
243
+ ]);
244
+ if (Object.keys(envelope.payload).length === 0)
245
+ throw new InputError();
246
+ const name = envelope.payload["name"] === undefined
247
+ ? undefined
248
+ : requiredRoleName(envelope.payload, "name");
249
+ const shortDescription = optionalText(envelope.payload, "short_description", 1_024);
250
+ const detailedDescription = optionalText(envelope.payload, "detailed_description", 51_200);
251
+ const isDefault = optionalBooleanValue(envelope.payload["is_default"]);
252
+ const isMandatory = optionalBooleanValue(envelope.payload["is_mandatory"]);
253
+ const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
254
+ const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
255
+ const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
256
+ const roleClass = optionalRoleClass(envelope.payload["role_class"]);
257
+ const role = store.updateRole(cubeId, roleId, {
258
+ ...(name === undefined ? {} : { name }),
259
+ ...(shortDescription === undefined ? {} : { shortDescription }),
260
+ ...(detailedDescription === undefined ? {} : { detailedDescription }),
261
+ ...(isDefault === undefined ? {} : { isDefault }),
262
+ ...(isMandatory === undefined ? {} : { isMandatory }),
263
+ ...(isHumanSeat === undefined ? {} : { isHumanSeat }),
264
+ ...(canBroadcast === undefined ? {} : { canBroadcast }),
265
+ ...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
266
+ ...(roleClass === undefined ? {} : { roleClass }),
267
+ });
268
+ return success(200, envelope.requestId, { role });
269
+ }
270
+ if (resource === "role" && sectionPatch && request.method === "POST") {
271
+ const envelope = decodeEnvelope(request.body);
272
+ const action = envelope.payload["action"];
273
+ if (action === "delete") {
274
+ exactKeys(envelope.payload, ["action", "heading"]);
275
+ const role = store.patchRoleSection(cubeId, roleId, {
276
+ action,
277
+ heading: requiredSectionHeading(envelope.payload, "heading"),
278
+ });
279
+ return success(200, envelope.requestId, { role });
280
+ }
281
+ if (action !== "replace" && action !== "insert")
282
+ throw new InputError();
283
+ exactKeys(envelope.payload, ["action", "heading", "body"], action === "insert" ? ["after"] : []);
284
+ const body = optionalText(envelope.payload, "body", 51_200);
285
+ if (body === undefined)
286
+ throw new InputError();
287
+ const heading = requiredSectionHeading(envelope.payload, "heading");
288
+ if (action === "replace") {
289
+ return success(200, envelope.requestId, {
290
+ role: store.patchRoleSection(cubeId, roleId, { action, heading, body }),
291
+ });
292
+ }
293
+ const after = optionalNullableSectionHeading(envelope.payload["after"]);
294
+ return success(200, envelope.requestId, {
295
+ role: store.patchRoleSection(cubeId, roleId, {
296
+ action,
297
+ heading,
298
+ body,
299
+ ...(after === undefined ? {} : { after }),
300
+ }),
301
+ });
302
+ }
145
303
  if (resource === "drones" && request.method === "GET") {
146
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
304
+ if (request.since === undefined) {
305
+ return success(200, "drones-read", { drones: store.listDrones(cubeId) });
306
+ }
307
+ const since = decodeSince(request.since);
308
+ return success(200, "drones-read", store.listDronesSince(cubeId, since));
147
309
  }
148
310
  if (resource === "logs" && request.method === "POST") {
149
311
  const envelope = decodeEnvelope(request.body);
150
- exactKeys(envelope.payload, ["message"], ["visibility", "recipientDroneIds"]);
312
+ exactKeys(envelope.payload, ["message"], [
313
+ "visibility", "recipientDroneIds", "class", "to",
314
+ ]);
151
315
  const message = requiredString(envelope.payload, "message", 10_240);
152
316
  const visibility = optionalVisibility(envelope.payload["visibility"]);
153
317
  const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
154
- if (((visibility ?? "broadcast") === "broadcast" && recipientDroneIds !== undefined) ||
155
- (visibility === "direct" && (recipientDroneIds?.length ?? 0) === 0)) {
156
- throw new InputError();
157
- }
158
- const entry = store.appendLog(cubeId, {
318
+ const className = optionalString(envelope.payload, "class", 64);
319
+ const to = optionalStringArray(envelope.payload["to"]);
320
+ const cube = store.getCube(cubeId);
321
+ if (cube === null)
322
+ throw new ScopedStoreError();
323
+ const resolved = resolveMessageRouting({
159
324
  message,
160
325
  ...(visibility === undefined ? {} : { visibility }),
161
326
  ...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
327
+ ...(className === undefined ? {} : { className }),
328
+ ...(to === undefined ? {} : { to }),
329
+ }, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
330
+ const entry = store.appendLog(cubeId, {
331
+ message,
332
+ visibility: resolved.visibility,
333
+ ...(resolved.visibility === "direct"
334
+ ? { recipientDroneIds: resolved.recipientDroneIds }
335
+ : {}),
336
+ });
337
+ this.#debugLogger.emit({
338
+ event: "activity_append",
339
+ cubeId,
340
+ entryId: entry.id,
341
+ principal: authentication,
342
+ droneId: entry.drone_id,
343
+ visibility: entry.visibility,
344
+ recipientDroneIds: entry.recipient_drone_ids,
162
345
  });
163
346
  return success(201, envelope.requestId, { entry });
164
347
  }
@@ -167,7 +350,17 @@ export class CoordinationApi {
167
350
  exactKeys(envelope.payload, ["cursor"], ["limit"]);
168
351
  const cursor = decodeCursor(envelope.payload["cursor"]);
169
352
  const limit = optionalLimit(envelope.payload["limit"]);
170
- return success(200, envelope.requestId, store.readLog(cubeId, cursor, limit));
353
+ const page = store.readLog(cubeId, cursor, limit);
354
+ this.#debugLogger.emit({
355
+ event: "cursor_replay",
356
+ mode: "page",
357
+ cubeId,
358
+ cursorId: cursor?.id ?? null,
359
+ returnedCount: page.entries.length,
360
+ behindBy: page.behind_by,
361
+ truncated: page.has_more,
362
+ });
363
+ return success(200, envelope.requestId, page);
171
364
  }
172
365
  if (resource === "acks" && request.method === "POST") {
173
366
  const envelope = decodeEnvelope(request.body);
@@ -177,6 +370,7 @@ export class CoordinationApi {
177
370
  throw new InputError();
178
371
  exactKeys(envelope.payload, ["entry_id", "kind"]);
179
372
  store.acknowledge(cubeId, entryId, kind);
373
+ this.#debugLogger.emit({ event: "ack_write", cubeId, entryId, kind, principal: authentication });
180
374
  return { status: 204 };
181
375
  }
182
376
  if (resource === "decisions" && request.method === "POST") {
@@ -185,13 +379,18 @@ export class CoordinationApi {
185
379
  const topic = requiredString(envelope.payload, "topic", 120);
186
380
  const decision = requiredString(envelope.payload, "decision", 100_000);
187
381
  const rationale = optionalString(envelope.payload, "rationale", 100_000);
188
- return success(201, envelope.requestId, {
189
- decision: store.recordDecision(cubeId, {
190
- topic,
191
- decision,
192
- ...(rationale === undefined ? {} : { rationale }),
193
- }),
382
+ const decisionRecord = store.recordDecision(cubeId, {
383
+ topic,
384
+ decision,
385
+ ...(rationale === undefined ? {} : { rationale }),
194
386
  });
387
+ this.#debugLogger.emit({
388
+ event: "decision_write",
389
+ cubeId,
390
+ decisionId: decisionRecord.id,
391
+ principal: authentication,
392
+ });
393
+ return success(201, envelope.requestId, { decision: decisionRecord });
195
394
  }
196
395
  if (resource === "decisions" && request.method === "PUT") {
197
396
  const envelope = decodeEnvelope(request.body);
@@ -207,12 +406,29 @@ export class CoordinationApi {
207
406
  if (error instanceof CursorExpiredError) {
208
407
  return failure(410, "CURSOR_EXPIRED", error.message);
209
408
  }
409
+ if (error instanceof AccessDeniedError) {
410
+ return failure(403, ErrorCode.ACCESS_DENIED, error.message);
411
+ }
210
412
  if (error instanceof ScopedStoreError) {
211
413
  return failure(404, "NOT_FOUND", error.message);
212
414
  }
415
+ if (error instanceof RoleConflictError) {
416
+ return failure(409, error.code, error.message, safeRequestId(request.body));
417
+ }
418
+ if (error instanceof DefaultRoleRequiredError) {
419
+ return failure(409, error.code, error.message, safeRequestId(request.body));
420
+ }
421
+ if (error instanceof RoleSectionConflictError) {
422
+ return failure(409, error.code, error.message, safeRequestId(request.body));
423
+ }
213
424
  if (error instanceof StorageCapacityError) {
214
425
  return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
215
426
  }
427
+ if (error instanceof ProtocolContractError) {
428
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
429
+ ? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
430
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
431
+ }
216
432
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
217
433
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
218
434
  }
@@ -221,14 +437,31 @@ export class CoordinationApi {
221
437
  }
222
438
  async #openStream(principal, cubeId, cursor, requestSignal) {
223
439
  const store = this.#runtime.forPrincipal(principal);
440
+ const connectionId = randomUUID();
224
441
  const session = this.#authority.registerLiveSession(principal);
225
442
  const signal = AbortSignal.any([requestSignal, session.signal]);
226
443
  let unsubscribe = () => undefined;
444
+ let subscribed = false;
445
+ let deliveryCount = 0;
446
+ let heartbeat;
227
447
  const queue = new AsyncStringQueue(() => {
448
+ if (heartbeat !== undefined)
449
+ clearInterval(heartbeat);
228
450
  unsubscribe();
229
451
  session.release();
230
- });
231
- const pending = [];
452
+ if (subscribed) {
453
+ this.#debugLogger.emit({
454
+ event: "sse_unsubscribe",
455
+ connectionId,
456
+ cubeId,
457
+ principal,
458
+ deliveryCount,
459
+ });
460
+ }
461
+ }, () => { deliveryCount += 1; });
462
+ let replayDirty = false;
463
+ const pendingNotifications = [];
464
+ let notificationIndex = 0;
232
465
  let live = false;
233
466
  try {
234
467
  unsubscribe = store.subscribeActivity(cubeId, (entry) => {
@@ -238,13 +471,15 @@ export class CoordinationApi {
238
471
  }
239
472
  if (live)
240
473
  queue.push(encodeLogEvent(entry));
241
- else if (pending.length >= 200)
242
- queue.close();
474
+ else if ("kind" in entry)
475
+ pendingNotifications.push(entry);
243
476
  else
244
- pending.push(entry);
477
+ replayDirty = true;
245
478
  });
479
+ subscribed = true;
246
480
  signal.addEventListener("abort", () => queue.close(), { once: true });
247
- const replay = store.readLog(cubeId, cursor, 200).entries;
481
+ const firstPage = store.readLog(cubeId, cursor, 200);
482
+ this.#logReplayPage(cubeId, cursor, firstPage);
248
483
  const barrier = this.#replayBarrier;
249
484
  this.#replayBarrier = undefined;
250
485
  if (barrier !== undefined) {
@@ -252,20 +487,63 @@ export class CoordinationApi {
252
487
  barrier.markReached();
253
488
  await barrier.release;
254
489
  }
255
- const seen = new Set(replay.map(cursorKey));
256
- for (const entry of replay)
257
- queue.push(encodeLogEvent(entry));
258
- for (const entry of pending) {
259
- if (!seen.has(cursorKey(entry)) && afterCursor(entry, cursor)) {
260
- seen.add(cursorKey(entry));
261
- queue.push(encodeLogEvent(entry));
490
+ void (async () => {
491
+ let page = firstPage;
492
+ let replayCursor = cursor;
493
+ let replayCount = 0;
494
+ try {
495
+ for (;;) {
496
+ for (const entry of page.entries) {
497
+ if (!await queue.write(encodeLogEvent(entry)))
498
+ return;
499
+ replayCursor = { id: entry.id, created_at: entry.created_at };
500
+ replayCount += 1;
501
+ }
502
+ if (page.has_more || replayDirty) {
503
+ replayDirty = false;
504
+ page = store.readLog(cubeId, replayCursor, 200);
505
+ this.#logReplayPage(cubeId, replayCursor, page);
506
+ continue;
507
+ }
508
+ while (notificationIndex < pendingNotifications.length) {
509
+ if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex])))
510
+ return;
511
+ notificationIndex += 1;
512
+ }
513
+ // Reserve room for the bookmark before making live callbacks visible.
514
+ // Any append while waiting marks replay dirty and is fetched first.
515
+ if (!await queue.waitForSpace())
516
+ return;
517
+ if (replayDirty || notificationIndex < pendingNotifications.length) {
518
+ replayDirty = false;
519
+ page = store.readLog(cubeId, replayCursor, 200);
520
+ this.#logReplayPage(cubeId, replayCursor, page);
521
+ continue;
522
+ }
523
+ queue.push(`event: bookmark\ndata: ${JSON.stringify({
524
+ as_of: new Date().toISOString(),
525
+ replay_complete: true,
526
+ })}\n\n`);
527
+ live = true;
528
+ heartbeat = setInterval(() => {
529
+ queue.tryPush(encodeHeartbeat());
530
+ }, this.#streamHeartbeatMs);
531
+ heartbeat.unref();
532
+ this.#debugLogger.emit({
533
+ event: "sse_subscribe",
534
+ connectionId,
535
+ cubeId,
536
+ principal,
537
+ replayCount,
538
+ truncated: false,
539
+ });
540
+ return;
541
+ }
262
542
  }
263
- }
264
- queue.push(`event: bookmark\ndata: ${JSON.stringify({
265
- as_of: new Date().toISOString(),
266
- replay_complete: true,
267
- })}\n\n`);
268
- live = true;
543
+ catch {
544
+ queue.close();
545
+ }
546
+ })();
269
547
  return { status: 200, stream: queue };
270
548
  }
271
549
  catch (error) {
@@ -277,16 +555,30 @@ export class CoordinationApi {
277
555
  throw error;
278
556
  }
279
557
  }
558
+ #logReplayPage(cubeId, cursor, page) {
559
+ this.#debugLogger.emit({
560
+ event: "cursor_replay",
561
+ mode: "sse",
562
+ cubeId,
563
+ cursorId: cursor?.id ?? null,
564
+ returnedCount: page.entries.length,
565
+ behindBy: page.behind_by,
566
+ truncated: page.has_more,
567
+ });
568
+ }
280
569
  }
281
570
  class InputError extends Error {
282
571
  }
283
572
  class AsyncStringQueue {
284
573
  #values = [];
285
574
  #waiters = [];
575
+ #spaceWaiters = [];
286
576
  #cleanup;
577
+ #onDelivered;
287
578
  #closed = false;
288
- constructor(cleanup) {
579
+ constructor(cleanup, onDelivered = () => undefined) {
289
580
  this.#cleanup = cleanup;
581
+ this.#onDelivered = onDelivered;
290
582
  }
291
583
  push(value) {
292
584
  if (this.#closed)
@@ -295,11 +587,30 @@ class AsyncStringQueue {
295
587
  this.close();
296
588
  return;
297
589
  }
298
- const waiter = this.#waiters.shift();
299
- if (waiter === undefined)
300
- this.#values.push(value);
301
- else
302
- waiter({ value, done: false });
590
+ this.#enqueue(value);
591
+ }
592
+ tryPush(value) {
593
+ if (this.#closed || this.#values.length >= 200)
594
+ return false;
595
+ this.#enqueue(value);
596
+ return true;
597
+ }
598
+ async write(value) {
599
+ while (!this.#closed && this.#values.length >= 200) {
600
+ if (!await this.waitForSpace())
601
+ return false;
602
+ }
603
+ if (this.#closed)
604
+ return false;
605
+ this.#enqueue(value);
606
+ return true;
607
+ }
608
+ waitForSpace() {
609
+ if (this.#closed)
610
+ return Promise.resolve(false);
611
+ if (this.#values.length < 200)
612
+ return Promise.resolve(true);
613
+ return new Promise((resolve) => this.#spaceWaiters.push(resolve));
303
614
  }
304
615
  close() {
305
616
  if (this.#closed)
@@ -308,13 +619,18 @@ class AsyncStringQueue {
308
619
  this.#cleanup();
309
620
  for (const waiter of this.#waiters.splice(0))
310
621
  waiter({ value: undefined, done: true });
622
+ for (const waiter of this.#spaceWaiters.splice(0))
623
+ waiter(false);
311
624
  }
312
625
  [Symbol.asyncIterator]() {
313
626
  return {
314
627
  next: async () => {
315
628
  const value = this.#values.shift();
316
- if (value !== undefined)
629
+ if (value !== undefined) {
630
+ this.#spaceWaiters.shift()?.(true);
631
+ this.#onDelivered();
317
632
  return { value, done: false };
633
+ }
318
634
  if (this.#closed)
319
635
  return { value: undefined, done: true };
320
636
  return new Promise((resolve) => this.#waiters.push(resolve));
@@ -325,17 +641,19 @@ class AsyncStringQueue {
325
641
  },
326
642
  };
327
643
  }
644
+ #enqueue(value) {
645
+ const waiter = this.#waiters.shift();
646
+ if (waiter === undefined)
647
+ this.#values.push(value);
648
+ else {
649
+ this.#onDelivered();
650
+ waiter({ value, done: false });
651
+ }
652
+ }
328
653
  }
329
654
  function decodeEnvelope(value) {
330
- const record = object(value);
331
- exactKeys(record, ["protocol_version", "request_id", "payload"]);
332
- if (record["protocol_version"] !== "1")
333
- throw new InputError();
334
- const requestId = record["request_id"];
335
- if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
336
- throw new InputError();
337
- }
338
- return { requestId, payload: object(record["payload"]) };
655
+ const envelope = decodeProtocolEnvelope(value, object);
656
+ return { requestId: envelope.request_id, payload: envelope.payload };
339
657
  }
340
658
  function object(value) {
341
659
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -361,12 +679,75 @@ function requiredPresentationName(record, key) {
361
679
  throw new InputError();
362
680
  return value;
363
681
  }
682
+ function requiredRoleName(record, key) {
683
+ const value = record[key];
684
+ if (typeof value !== "string" || value.length < 1 || value.length > 64)
685
+ throw new InputError();
686
+ return value;
687
+ }
364
688
  function optionalString(record, key, maxBytes) {
365
689
  const value = record[key];
366
690
  if (value === undefined)
367
691
  return undefined;
368
692
  return requiredString(record, key, maxBytes);
369
693
  }
694
+ function optionalText(record, key, maxCharacters) {
695
+ const value = record[key];
696
+ if (value === undefined)
697
+ return undefined;
698
+ if (typeof value !== "string" || value.length > maxCharacters)
699
+ throw new InputError();
700
+ return value;
701
+ }
702
+ function optionalBoolean(value) {
703
+ if (value === undefined)
704
+ return false;
705
+ if (typeof value !== "boolean")
706
+ throw new InputError();
707
+ return value;
708
+ }
709
+ function optionalBooleanValue(value) {
710
+ if (value === undefined)
711
+ return undefined;
712
+ if (typeof value !== "boolean")
713
+ throw new InputError();
714
+ return value;
715
+ }
716
+ function optionalRoleClass(value) {
717
+ if (value === undefined)
718
+ return undefined;
719
+ if (value !== "queen" && value !== "worker")
720
+ throw new InputError();
721
+ return value;
722
+ }
723
+ function optionalMessageTaxonomy(value) {
724
+ return value === undefined ? undefined : validateMessageTaxonomy(value);
725
+ }
726
+ function optionalStringArray(value) {
727
+ if (value === undefined)
728
+ return undefined;
729
+ if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
730
+ value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
731
+ throw new InputError();
732
+ }
733
+ const entries = value;
734
+ if (new Set(entries).size !== entries.length)
735
+ throw new InputError();
736
+ return entries;
737
+ }
738
+ function requiredSectionHeading(record, key) {
739
+ const value = requiredString(record, key, 60);
740
+ const heading = value.trim();
741
+ if (heading.length === 0 || /^\s/u.test(value) || /[:\n\r]/u.test(heading) ||
742
+ /^[*\-#>`]/u.test(heading))
743
+ throw new InputError();
744
+ return heading;
745
+ }
746
+ function optionalNullableSectionHeading(value) {
747
+ if (value === undefined || value === null)
748
+ return value;
749
+ return requiredSectionHeading({ value }, "value");
750
+ }
370
751
  function requiredUuid(record, key) {
371
752
  const value = record[key];
372
753
  if (typeof value !== "string" || !uuidPattern.test(value))
@@ -418,6 +799,13 @@ function decodeOpaqueCursor(value) {
418
799
  throw new InputError();
419
800
  }
420
801
  }
802
+ function decodeSince(value) {
803
+ if (uuidPattern.test(value))
804
+ return value.toLowerCase();
805
+ if (timestampPattern.test(value) && new Date(value).toISOString() === value)
806
+ return value;
807
+ throw new InputError();
808
+ }
421
809
  function safeRequestId(value) {
422
810
  if (typeof value !== "object" || value === null || Array.isArray(value))
423
811
  return undefined;
@@ -427,30 +815,40 @@ function safeRequestId(value) {
427
815
  : undefined;
428
816
  }
429
817
  function success(status, requestId, payload) {
430
- return { status, body: { protocol_version: "1", request_id: requestId, payload } };
818
+ return { status, body: createProtocolEnvelope(requestId, payload) };
431
819
  }
432
820
  function failure(status, code, message, requestId) {
433
821
  return {
434
822
  status,
435
823
  body: {
436
- protocol_version: "1",
824
+ protocol_version: PROTOCOL_VERSION,
437
825
  ...(requestId === undefined ? {} : { request_id: requestId }),
438
826
  error: { code, message },
439
827
  },
440
828
  };
441
829
  }
442
830
  function encodeLogEvent(entry) {
831
+ if ("kind" in entry) {
832
+ return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
833
+ }
443
834
  return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
444
835
  cursor: { id: entry.id, created_at: entry.created_at },
445
836
  entry,
446
837
  })}\n\n`;
447
838
  }
448
- function cursorKey(entry) {
449
- return `${entry.created_at}\0${entry.id}`;
839
+ function encodeHeartbeat() {
840
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
450
841
  }
451
- function afterCursor(entry, cursor) {
452
- return cursor === null || entry.created_at > cursor.created_at ||
453
- (entry.created_at === cursor.created_at && entry.id > cursor.id);
842
+ function cubePayload(cube) {
843
+ return {
844
+ id: cube.id,
845
+ owner_id: cube.ownerId,
846
+ name: cube.name,
847
+ cube_directive: cube.directive,
848
+ message_taxonomy: cube.messageTaxonomy,
849
+ created_at: cube.createdAt,
850
+ updated_at: cube.updatedAt,
851
+ };
454
852
  }
455
853
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
456
854
  const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;