@crvouga/mockingbird-service-plane 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-plane
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)
@@ -1105,7 +1142,8 @@ var annotateResponse = (response, extra) => {
1105
1142
  var responseNotes = (response) => notes.get(response);
1106
1143
 
1107
1144
  // ../core/dist/metrics.js
1108
- var createMetrics = () => {
1145
+ var createMetrics = (partition = true) => {
1146
+ const namespaces = /* @__PURE__ */ new Map();
1109
1147
  let requests = 0;
1110
1148
  let faults = 0;
1111
1149
  let totalDurationMs = 0;
@@ -1113,6 +1151,14 @@ var createMetrics = () => {
1113
1151
  const unmatched = /* @__PURE__ */ new Map();
1114
1152
  return {
1115
1153
  record(entry) {
1154
+ if (partition) {
1155
+ let scoped = namespaces.get(entry.namespace);
1156
+ if (!scoped) {
1157
+ scoped = createMetrics(false);
1158
+ namespaces.set(entry.namespace, scoped);
1159
+ }
1160
+ scoped.record(entry);
1161
+ }
1116
1162
  requests++;
1117
1163
  totalDurationMs += entry.durationMs;
1118
1164
  if (entry.faultId !== void 0)
@@ -1124,7 +1170,7 @@ var createMetrics = () => {
1124
1170
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1125
1171
  }
1126
1172
  },
1127
- report: () => ({
1173
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1128
1174
  requests,
1129
1175
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1130
1176
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1133,8 +1179,13 @@ var createMetrics = () => {
1133
1179
  }),
1134
1180
  faults,
1135
1181
  totalDurationMs
1136
- }),
1137
- reset() {
1182
+ },
1183
+ reset(namespace) {
1184
+ if (namespace !== void 0) {
1185
+ namespaces.delete(namespace);
1186
+ return;
1187
+ }
1188
+ namespaces.clear();
1138
1189
  requests = 0;
1139
1190
  faults = 0;
1140
1191
  totalDurationMs = 0;
@@ -4106,7 +4157,7 @@ var stateAdminRoutes = (routes) => {
4106
4157
  };
4107
4158
 
4108
4159
  // ../core/dist/version.js
4109
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4160
+ var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4110
4161
 
4111
4162
  // ../core/dist/webhooks.js
4112
4163
  var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
@@ -4272,6 +4323,20 @@ var createRuntime = (options) => {
4272
4323
  const instances = /* @__PURE__ */ new Map();
4273
4324
  const publicNamespaces = /* @__PURE__ */ new Set();
4274
4325
  const branchRngs = /* @__PURE__ */ new Map();
4326
+ const namespaceClocks = /* @__PURE__ */ new Map();
4327
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4328
+ let isolatedNamespaces = false;
4329
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4330
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4331
+ return clock;
4332
+ let found = namespaceClocks.get(name);
4333
+ if (!found) {
4334
+ found = createClock();
4335
+ namespaceClocks.set(name, found);
4336
+ }
4337
+ return found;
4338
+ };
4339
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4275
4340
  const timelines = /* @__PURE__ */ new Map();
4276
4341
  const branchStorage = /* @__PURE__ */ new Map();
4277
4342
  const captured = /* @__PURE__ */ new Map();
@@ -4285,17 +4350,21 @@ var createRuntime = (options) => {
4285
4350
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4286
4351
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4287
4352
  }
4353
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4288
4354
  const created = options.create({
4289
4355
  namespace: storageNamespace(key),
4290
4356
  publicNamespace,
4291
4357
  sqlite,
4292
- clock,
4293
- rng: isolatedRng ?? rng
4358
+ clock: namespaceClock(publicNamespace),
4359
+ rng: createdContextRng
4294
4360
  });
4295
4361
  instances.set(key, created);
4362
+ namespaceForStorage.set(key, publicNamespace);
4296
4363
  publicNamespaces.add(publicNamespace);
4297
4364
  if (isolatedRng)
4298
4365
  branchRngs.set(key, isolatedRng);
4366
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4367
+ branchRngs.set(key, createdContextRng);
4299
4368
  return created;
4300
4369
  };
4301
4370
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4313,7 +4382,7 @@ var createRuntime = (options) => {
4313
4382
  captured.set(storage, snapshot2);
4314
4383
  return Object.freeze({
4315
4384
  snapshot: snapshot2,
4316
- clock: Object.freeze(clock.state()),
4385
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4317
4386
  rngState: (branchRngs.get(storage) ?? rng).state()
4318
4387
  });
4319
4388
  };
@@ -4323,7 +4392,7 @@ var createRuntime = (options) => {
4323
4392
  return found;
4324
4393
  instance(name);
4325
4394
  found = new Timeline({
4326
- now: clock.now,
4395
+ now: namespaceClock(name).now,
4327
4396
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4328
4397
  });
4329
4398
  found.commit(capture(name));
@@ -4350,12 +4419,13 @@ var createRuntime = (options) => {
4350
4419
  const point = history2.checkout("main", at);
4351
4420
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4352
4421
  captured.set(namespace, point.value.snapshot);
4353
- rng.setState(point.value.rngState);
4354
- clock.set(point.value.clock.now);
4422
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4423
+ const selectedClock = namespaceClock(namespace);
4424
+ selectedClock.set(point.value.clock.now);
4355
4425
  if (point.value.clock.frozen)
4356
- clock.freeze();
4426
+ selectedClock.freeze();
4357
4427
  else
4358
- clock.unfreeze();
4428
+ selectedClock.unfreeze();
4359
4429
  }
4360
4430
  return namespace;
4361
4431
  }
@@ -4413,11 +4483,12 @@ var createRuntime = (options) => {
4413
4483
  const storage = ensureBranch(namespace, branchName);
4414
4484
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4415
4485
  captured.set(storage, point.value.snapshot);
4416
- clock.set(point.value.clock.now);
4486
+ const selectedClock = namespaceClock(namespace);
4487
+ selectedClock.set(point.value.clock.now);
4417
4488
  if (point.value.clock.frozen)
4418
- clock.freeze();
4489
+ selectedClock.freeze();
4419
4490
  else
4420
- clock.unfreeze();
4491
+ selectedClock.unfreeze();
4421
4492
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4422
4493
  };
4423
4494
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4425,10 +4496,21 @@ var createRuntime = (options) => {
4425
4496
  options.webhooks?.clear();
4426
4497
  for (const each of instances.values())
4427
4498
  await each.reset();
4499
+ for (const each of branchRngs.values())
4500
+ each.reset();
4428
4501
  timelines.clear();
4502
+ for (const storage of branchStorage.values()) {
4503
+ instances.delete(storage);
4504
+ branchRngs.delete(storage);
4505
+ namespaceForStorage.delete(storage);
4506
+ }
4429
4507
  branchStorage.clear();
4430
- branchRngs.clear();
4431
4508
  captured.clear();
4509
+ faults.clear();
4510
+ clock.reset();
4511
+ rng.reset();
4512
+ for (const each of namespaceClocks.values())
4513
+ each.reset();
4432
4514
  return;
4433
4515
  }
4434
4516
  options.webhooks?.clear(name);
@@ -4446,11 +4528,18 @@ var createRuntime = (options) => {
4446
4528
  else
4447
4529
  clearNamespace(sqlite, storageNamespace(storage));
4448
4530
  branchStorage.delete(mapping);
4531
+ instances.delete(storage);
4532
+ namespaceForStorage.delete(storage);
4449
4533
  branchRngs.delete(storage);
4450
4534
  captured.delete(storage);
4451
4535
  }
4452
4536
  timelines.delete(name);
4453
4537
  captured.delete(name);
4538
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4539
+ namespaceClock(name).reset();
4540
+ (branchRngs.get(name) ?? rng).reset();
4541
+ journal.clear(name);
4542
+ metrics.reset(name);
4454
4543
  };
4455
4544
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4456
4545
  return checkpoint(name, "main").value.snapshot;
@@ -4488,6 +4577,53 @@ var createRuntime = (options) => {
4488
4577
  rng,
4489
4578
  credentials,
4490
4579
  webhooks: options.webhooks,
4580
+ isolateNamespaces() {
4581
+ if (publicNamespaces.size > 1)
4582
+ throw new Error("enable namespace isolation before creating named namespaces");
4583
+ isolatedNamespaces = true;
4584
+ faults.isolateNamespaces();
4585
+ },
4586
+ namespaceClock,
4587
+ namespaceOf(request) {
4588
+ const url = new URL(request.url);
4589
+ const prefixed = PATH_PREFIX.exec(url.pathname);
4590
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4591
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4592
+ if (path === "/__admin" || path.startsWith("/__admin/"))
4593
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4594
+ if (selected !== void 0)
4595
+ return selected;
4596
+ const credential = options.credential?.(request);
4597
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4598
+ },
4599
+ fleetSnapshot(namespace) {
4600
+ instance(namespace);
4601
+ const handle = {};
4602
+ fleetPoints.set(handle, {
4603
+ namespace,
4604
+ state: capture(namespace),
4605
+ faults: faults.snapshot(namespace),
4606
+ webhooks: options.webhooks?.snapshot(namespace)
4607
+ });
4608
+ return handle;
4609
+ },
4610
+ fleetRestore(handle, namespace) {
4611
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4612
+ if (!point || point.namespace !== namespace)
4613
+ throw new Error("invalid fleet checkpoint");
4614
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4615
+ captured.set(namespace, point.state.snapshot);
4616
+ const selectedClock = namespaceClock(namespace);
4617
+ selectedClock.freeze();
4618
+ selectedClock.set(point.state.clock.now);
4619
+ if (!point.state.clock.frozen)
4620
+ selectedClock.unfreeze();
4621
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4622
+ faults.restore(namespace, point.faults);
4623
+ if (point.webhooks !== void 0)
4624
+ options.webhooks?.restore(namespace, point.webhooks);
4625
+ timelines.delete(namespace);
4626
+ },
4491
4627
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4492
4628
  const preset = options.presets?.[name];
4493
4629
  if (!preset)
@@ -4663,6 +4799,7 @@ var createRuntime = (options) => {
4663
4799
  startedAt: wallNow(),
4664
4800
  wallNow,
4665
4801
  clock,
4802
+ namespaceClock,
4666
4803
  faults,
4667
4804
  metrics,
4668
4805
  journal,
@@ -5465,6 +5602,8 @@ var PlaneAPI = class {
5465
5602
  };
5466
5603
 
5467
5604
  export {
5605
+ createClock,
5606
+ parseDuration,
5468
5607
  document2 as document,
5469
5608
  operationIds,
5470
5609
  supportedOperationIds,
@@ -5476,4 +5615,4 @@ export {
5476
5615
  apiKeyCredential,
5477
5616
  PlaneAPI
5478
5617
  };
5479
- //# sourceMappingURL=chunk-NVLTLMDI.js.map
5618
+ //# sourceMappingURL=chunk-LRGQXAHN.js.map