@crvouga/mockingbird-service-odx 1.7.1 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog — @crvouga/mockingbird-service-odx
2
2
 
3
+ ## 1.8.0 (2026-10-02)
4
+
5
+ ### Features
6
+
7
+ - coordinate HTTP and protocol mocks ([3168653](https://github.com/crvouga/mockingbird/commit/31686534c55a9823fdaa92afc9cc456cf741b809))
8
+
9
+ ### Fixes and improvements
10
+
11
+ - decouple smoke types from built packages ([a4b31c6](https://github.com/crvouga/mockingbird/commit/a4b31c6205d92aa0c6f7c9de3518d5ae44d38829))
12
+ - protect child admin endpoints ([5410b94](https://github.com/crvouga/mockingbird/commit/5410b948a30dcedc21e9a70e44fd415c1ea25394))
13
+ - harden protocol startup and state ([6f5b557](https://github.com/crvouga/mockingbird/commit/6f5b557e87b48b31985949e04f3b5ef58f51d295))
14
+ - preserve checkpoints and lifecycle ([50e8c32](https://github.com/crvouga/mockingbird/commit/50e8c322feded2f7f45afce9d8cdef43c37ab9ab))
15
+ - clean branch state on reset ([3b714d4](https://github.com/crvouga/mockingbird/commit/3b714d4a5df8e75fee0bb31e98d7c7566e164381))
16
+ - type signal listener cleanup ([5279852](https://github.com/crvouga/mockingbird/commit/5279852e56ea60262ccf17aa6e46b3c31091a34a))
17
+
3
18
  ## 1.7.1 (2026-10-01)
4
19
 
5
20
  ### Fixes and improvements
@@ -183,29 +183,30 @@ var createControlPlane = (context) => {
183
183
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
184
  },
185
185
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
186
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
187
+ "POST /clock": ({ body, namespace }) => {
188
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
189
  if (!isRecord(body))
189
190
  return adminError(400, "expected a JSON object");
190
191
  if (body.reset === true)
191
- context.clock.reset();
192
+ clock.reset();
192
193
  if (body.freeze === true)
193
- context.clock.freeze();
194
+ clock.freeze();
194
195
  if (body.set !== void 0) {
195
196
  const instant = parseInstant(body.set);
196
197
  if (instant === void 0)
197
198
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
199
+ clock.set(instant);
199
200
  }
200
201
  if (body.advance !== void 0) {
201
202
  const delta = parseDuration(body.advance);
202
203
  if (delta === void 0)
203
204
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
205
+ clock.advance(delta);
205
206
  }
206
207
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
208
+ clock.unfreeze();
209
+ return json(200, clock.state());
209
210
  },
210
211
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
212
  "POST /faults": ({ body, namespace }) => {
@@ -467,7 +468,33 @@ var faultResponse = (rule) => {
467
468
  };
468
469
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
469
470
  const entries = [];
471
+ let isolated = false;
472
+ const scopedRngs = /* @__PURE__ */ new Map();
473
+ const random = (namespace) => {
474
+ if (!isolated || namespace === "default")
475
+ return rng;
476
+ let scoped = scopedRngs.get(namespace);
477
+ if (!scoped) {
478
+ scoped = createRng(rng.seed);
479
+ scopedRngs.set(namespace, scoped);
480
+ }
481
+ return scoped;
482
+ };
470
483
  return {
484
+ isolateNamespaces() {
485
+ isolated = true;
486
+ },
487
+ snapshot(namespace) {
488
+ return {
489
+ rules: structuredClone(entries.map((entry, position) => ({
490
+ ...entry.rule,
491
+ remaining: entry.remaining,
492
+ hits: entry.hits,
493
+ position
494
+ })).filter((entry) => entry.namespace === namespace)),
495
+ rngState: random(namespace).state()
496
+ };
497
+ },
471
498
  add(rule) {
472
499
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
473
500
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -487,6 +514,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
487
514
  },
488
515
  clear() {
489
516
  entries.length = 0;
517
+ rng.reset();
518
+ scopedRngs.clear();
519
+ },
520
+ restore(namespace, state) {
521
+ for (let i = entries.length - 1; i >= 0; i--)
522
+ if (entries[i]?.rule.namespace === namespace)
523
+ entries.splice(i, 1);
524
+ for (const { remaining, hits, position, ...rule } of state.rules)
525
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
526
+ random(namespace).setState(state.rngState);
490
527
  },
491
528
  async take(candidate) {
492
529
  const hits = [];
@@ -496,7 +533,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
496
533
  if (!matches(entry.rule, candidate))
497
534
  continue;
498
535
  const rate = entry.rule.rate ?? 1;
499
- if (rng.next() >= rate)
536
+ if (random(candidate.namespace).next() >= rate)
500
537
  continue;
501
538
  entry.hits++;
502
539
  if (entry.remaining !== null)
@@ -832,7 +869,8 @@ var annotateResponse = (response, extra) => {
832
869
  var responseNotes = (response) => notes.get(response);
833
870
 
834
871
  // ../core/dist/metrics.js
835
- var createMetrics = () => {
872
+ var createMetrics = (partition = true) => {
873
+ const namespaces = /* @__PURE__ */ new Map();
836
874
  let requests = 0;
837
875
  let faults = 0;
838
876
  let totalDurationMs = 0;
@@ -840,6 +878,14 @@ var createMetrics = () => {
840
878
  const unmatched = /* @__PURE__ */ new Map();
841
879
  return {
842
880
  record(entry) {
881
+ if (partition) {
882
+ let scoped = namespaces.get(entry.namespace);
883
+ if (!scoped) {
884
+ scoped = createMetrics(false);
885
+ namespaces.set(entry.namespace, scoped);
886
+ }
887
+ scoped.record(entry);
888
+ }
843
889
  requests++;
844
890
  totalDurationMs += entry.durationMs;
845
891
  if (entry.faultId !== void 0)
@@ -851,7 +897,7 @@ var createMetrics = () => {
851
897
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
852
898
  }
853
899
  },
854
- report: () => ({
900
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
855
901
  requests,
856
902
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
857
903
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -860,8 +906,13 @@ var createMetrics = () => {
860
906
  }),
861
907
  faults,
862
908
  totalDurationMs
863
- }),
864
- reset() {
909
+ },
910
+ reset(namespace) {
911
+ if (namespace !== void 0) {
912
+ namespaces.delete(namespace);
913
+ return;
914
+ }
915
+ namespaces.clear();
865
916
  requests = 0;
866
917
  faults = 0;
867
918
  totalDurationMs = 0;
@@ -3759,7 +3810,7 @@ var stateAdminRoutes = (routes) => {
3759
3810
  };
3760
3811
 
3761
3812
  // ../core/dist/version.js
3762
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
3813
+ var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
3763
3814
 
3764
3815
  // ../core/dist/signing.js
3765
3816
  var encoder = new TextEncoder();
@@ -3852,7 +3903,9 @@ var createWebhookHub = (options) => {
3852
3903
  const faults = /* @__PURE__ */ new Map();
3853
3904
  const held = /* @__PURE__ */ new Map();
3854
3905
  const inFlight = /* @__PURE__ */ new Set();
3906
+ const controllers = /* @__PURE__ */ new Map();
3855
3907
  let immediate = Promise.resolve();
3908
+ const snapshots = /* @__PURE__ */ new WeakMap();
3856
3909
  const track = (work) => {
3857
3910
  inFlight.add(work);
3858
3911
  void work.finally(() => inFlight.delete(work));
@@ -3873,6 +3926,7 @@ var createWebhookHub = (options) => {
3873
3926
  responseBody: null
3874
3927
  };
3875
3928
  const controller = new AbortController();
3929
+ controllers.set(delivery.id, controller);
3876
3930
  const timer = scheduleTimer(() => controller.abort(), timeoutMs);
3877
3931
  try {
3878
3932
  const signed = await options.signer({
@@ -3902,6 +3956,7 @@ var createWebhookHub = (options) => {
3902
3956
  } catch (error) {
3903
3957
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
3904
3958
  } finally {
3959
+ controllers.delete(delivery.id);
3905
3960
  cancel(timer);
3906
3961
  record.durationMs = now() - started;
3907
3962
  delivery.attempts.push(record);
@@ -3919,6 +3974,8 @@ var createWebhookHub = (options) => {
3919
3974
  if (!payloads.has(delivery.id))
3920
3975
  return;
3921
3976
  await attempt(delivery).then((ok) => {
3977
+ if (!payloads.has(delivery.id))
3978
+ return;
3922
3979
  if (ok)
3923
3980
  delivery.state = "delivered";
3924
3981
  else
@@ -3980,6 +4037,37 @@ var createWebhookHub = (options) => {
3980
4037
  fanOut(message2);
3981
4038
  };
3982
4039
  const hub = {
4040
+ pending(namespace) {
4041
+ return inFlight.size + (held.get(namespace)?.length ?? 0) + [...deliveries.values()].filter((d) => d.namespace === namespace && d.state === "pending").length;
4042
+ },
4043
+ snapshot(namespace) {
4044
+ if (hub.pending(namespace) > 0)
4045
+ throw new Error("webhooks are not quiescent");
4046
+ const handle = {};
4047
+ snapshots.set(handle, structuredClone({
4048
+ namespace,
4049
+ endpoints: own.get(namespace) ?? [],
4050
+ messages: messages.filter((m) => m.namespace === namespace),
4051
+ deliveries: [...deliveries].filter(([, d]) => d.namespace === namespace),
4052
+ payloads: [...payloads].filter(([, p]) => p.message.namespace === namespace),
4053
+ faults: faults.get(namespace) ?? []
4054
+ }));
4055
+ return handle;
4056
+ },
4057
+ restore(namespace, handle) {
4058
+ const point = typeof handle === "object" && handle !== null ? snapshots.get(handle) : void 0;
4059
+ if (!point || point.namespace !== namespace || hub.pending(namespace) > 0)
4060
+ throw new Error("invalid webhook restore");
4061
+ hub.clear(namespace);
4062
+ const copied = structuredClone(point);
4063
+ own.set(namespace, copied.endpoints);
4064
+ messages.push(...copied.messages);
4065
+ for (const [id2, delivery] of copied.deliveries)
4066
+ deliveries.set(id2, delivery);
4067
+ for (const [id2, payload] of copied.payloads)
4068
+ payloads.set(id2, payload);
4069
+ faults.set(namespace, copied.faults);
4070
+ },
3983
4071
  publish(input) {
3984
4072
  const contentType = input.contentType ?? (input.form ? "application/x-www-form-urlencoded" : "application/json");
3985
4073
  const body = typeof input.body === "string" ? input.body : input.form ? new URLSearchParams(input.form).toString() : JSON.stringify(input.body);
@@ -4070,6 +4158,7 @@ var createWebhookHub = (options) => {
4070
4158
  for (const [id2, delivery] of deliveries) {
4071
4159
  if (namespace !== void 0 && delivery.namespace !== namespace)
4072
4160
  continue;
4161
+ controllers.get(id2)?.abort();
4073
4162
  const timer = pending.get(id2);
4074
4163
  if (timer !== void 0)
4075
4164
  cancel(timer);
@@ -4257,6 +4346,20 @@ var createRuntime = (options) => {
4257
4346
  const instances = /* @__PURE__ */ new Map();
4258
4347
  const publicNamespaces = /* @__PURE__ */ new Set();
4259
4348
  const branchRngs = /* @__PURE__ */ new Map();
4349
+ const namespaceClocks = /* @__PURE__ */ new Map();
4350
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4351
+ let isolatedNamespaces = false;
4352
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4353
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4354
+ return clock;
4355
+ let found = namespaceClocks.get(name);
4356
+ if (!found) {
4357
+ found = createClock();
4358
+ namespaceClocks.set(name, found);
4359
+ }
4360
+ return found;
4361
+ };
4362
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4260
4363
  const timelines = /* @__PURE__ */ new Map();
4261
4364
  const branchStorage = /* @__PURE__ */ new Map();
4262
4365
  const captured = /* @__PURE__ */ new Map();
@@ -4270,17 +4373,21 @@ var createRuntime = (options) => {
4270
4373
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4271
4374
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4272
4375
  }
4376
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4273
4377
  const created = options.create({
4274
4378
  namespace: storageNamespace(key),
4275
4379
  publicNamespace,
4276
4380
  sqlite,
4277
- clock,
4278
- rng: isolatedRng ?? rng
4381
+ clock: namespaceClock(publicNamespace),
4382
+ rng: createdContextRng
4279
4383
  });
4280
4384
  instances.set(key, created);
4385
+ namespaceForStorage.set(key, publicNamespace);
4281
4386
  publicNamespaces.add(publicNamespace);
4282
4387
  if (isolatedRng)
4283
4388
  branchRngs.set(key, isolatedRng);
4389
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4390
+ branchRngs.set(key, createdContextRng);
4284
4391
  return created;
4285
4392
  };
4286
4393
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4298,7 +4405,7 @@ var createRuntime = (options) => {
4298
4405
  captured.set(storage, snapshot2);
4299
4406
  return Object.freeze({
4300
4407
  snapshot: snapshot2,
4301
- clock: Object.freeze(clock.state()),
4408
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4302
4409
  rngState: (branchRngs.get(storage) ?? rng).state()
4303
4410
  });
4304
4411
  };
@@ -4308,7 +4415,7 @@ var createRuntime = (options) => {
4308
4415
  return found;
4309
4416
  instance(name);
4310
4417
  found = new Timeline({
4311
- now: clock.now,
4418
+ now: namespaceClock(name).now,
4312
4419
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4313
4420
  });
4314
4421
  found.commit(capture(name));
@@ -4335,12 +4442,13 @@ var createRuntime = (options) => {
4335
4442
  const point = history2.checkout("main", at);
4336
4443
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4337
4444
  captured.set(namespace, point.value.snapshot);
4338
- rng.setState(point.value.rngState);
4339
- clock.set(point.value.clock.now);
4445
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4446
+ const selectedClock = namespaceClock(namespace);
4447
+ selectedClock.set(point.value.clock.now);
4340
4448
  if (point.value.clock.frozen)
4341
- clock.freeze();
4449
+ selectedClock.freeze();
4342
4450
  else
4343
- clock.unfreeze();
4451
+ selectedClock.unfreeze();
4344
4452
  }
4345
4453
  return namespace;
4346
4454
  }
@@ -4398,11 +4506,12 @@ var createRuntime = (options) => {
4398
4506
  const storage = ensureBranch(namespace, branchName);
4399
4507
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4400
4508
  captured.set(storage, point.value.snapshot);
4401
- clock.set(point.value.clock.now);
4509
+ const selectedClock = namespaceClock(namespace);
4510
+ selectedClock.set(point.value.clock.now);
4402
4511
  if (point.value.clock.frozen)
4403
- clock.freeze();
4512
+ selectedClock.freeze();
4404
4513
  else
4405
- clock.unfreeze();
4514
+ selectedClock.unfreeze();
4406
4515
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4407
4516
  };
4408
4517
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4410,10 +4519,21 @@ var createRuntime = (options) => {
4410
4519
  options.webhooks?.clear();
4411
4520
  for (const each of instances.values())
4412
4521
  await each.reset();
4522
+ for (const each of branchRngs.values())
4523
+ each.reset();
4413
4524
  timelines.clear();
4525
+ for (const storage of branchStorage.values()) {
4526
+ instances.delete(storage);
4527
+ branchRngs.delete(storage);
4528
+ namespaceForStorage.delete(storage);
4529
+ }
4414
4530
  branchStorage.clear();
4415
- branchRngs.clear();
4416
4531
  captured.clear();
4532
+ faults.clear();
4533
+ clock.reset();
4534
+ rng.reset();
4535
+ for (const each of namespaceClocks.values())
4536
+ each.reset();
4417
4537
  return;
4418
4538
  }
4419
4539
  options.webhooks?.clear(name);
@@ -4431,11 +4551,18 @@ var createRuntime = (options) => {
4431
4551
  else
4432
4552
  clearNamespace(sqlite, storageNamespace(storage));
4433
4553
  branchStorage.delete(mapping);
4554
+ instances.delete(storage);
4555
+ namespaceForStorage.delete(storage);
4434
4556
  branchRngs.delete(storage);
4435
4557
  captured.delete(storage);
4436
4558
  }
4437
4559
  timelines.delete(name);
4438
4560
  captured.delete(name);
4561
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4562
+ namespaceClock(name).reset();
4563
+ (branchRngs.get(name) ?? rng).reset();
4564
+ journal.clear(name);
4565
+ metrics.reset(name);
4439
4566
  };
4440
4567
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4441
4568
  return checkpoint(name, "main").value.snapshot;
@@ -4473,6 +4600,53 @@ var createRuntime = (options) => {
4473
4600
  rng,
4474
4601
  credentials,
4475
4602
  webhooks: options.webhooks,
4603
+ isolateNamespaces() {
4604
+ if (publicNamespaces.size > 1)
4605
+ throw new Error("enable namespace isolation before creating named namespaces");
4606
+ isolatedNamespaces = true;
4607
+ faults.isolateNamespaces();
4608
+ },
4609
+ namespaceClock,
4610
+ namespaceOf(request) {
4611
+ const url = new URL(request.url);
4612
+ const prefixed = PATH_PREFIX.exec(url.pathname);
4613
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4614
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4615
+ if (path === "/__admin" || path.startsWith("/__admin/"))
4616
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4617
+ if (selected !== void 0)
4618
+ return selected;
4619
+ const credential = options.credential?.(request);
4620
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4621
+ },
4622
+ fleetSnapshot(namespace) {
4623
+ instance(namespace);
4624
+ const handle = {};
4625
+ fleetPoints.set(handle, {
4626
+ namespace,
4627
+ state: capture(namespace),
4628
+ faults: faults.snapshot(namespace),
4629
+ webhooks: options.webhooks?.snapshot(namespace)
4630
+ });
4631
+ return handle;
4632
+ },
4633
+ fleetRestore(handle, namespace) {
4634
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4635
+ if (!point || point.namespace !== namespace)
4636
+ throw new Error("invalid fleet checkpoint");
4637
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4638
+ captured.set(namespace, point.state.snapshot);
4639
+ const selectedClock = namespaceClock(namespace);
4640
+ selectedClock.freeze();
4641
+ selectedClock.set(point.state.clock.now);
4642
+ if (!point.state.clock.frozen)
4643
+ selectedClock.unfreeze();
4644
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4645
+ faults.restore(namespace, point.faults);
4646
+ if (point.webhooks !== void 0)
4647
+ options.webhooks?.restore(namespace, point.webhooks);
4648
+ timelines.delete(namespace);
4649
+ },
4476
4650
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4477
4651
  const preset = options.presets?.[name];
4478
4652
  if (!preset)
@@ -4648,6 +4822,7 @@ var createRuntime = (options) => {
4648
4822
  startedAt: wallNow(),
4649
4823
  wallNow,
4650
4824
  clock,
4825
+ namespaceClock,
4651
4826
  faults,
4652
4827
  metrics,
4653
4828
  journal,
@@ -5779,6 +5954,8 @@ var OdxAPI = class {
5779
5954
  var publicPatient = ({ partnerUserId: _partner, ...patient }) => patient;
5780
5955
 
5781
5956
  export {
5957
+ createClock,
5958
+ parseDuration,
5782
5959
  LABS,
5783
5960
  ELEMENTS,
5784
5961
  document2 as document,
@@ -5795,4 +5972,4 @@ export {
5795
5972
  apiKeyCredential,
5796
5973
  OdxAPI
5797
5974
  };
5798
- //# sourceMappingURL=chunk-IS6GBONJ.js.map
5975
+ //# sourceMappingURL=chunk-BSGAYJGM.js.map