@crvouga/mockingbird-service-junction 0.5.0 → 0.7.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.
@@ -247,20 +247,66 @@ var createControlPlane = (context) => {
247
247
  return context.faults.remove(id) ? json(200, { status: "ok" }) : adminError(404, `no fault ${id}`);
248
248
  },
249
249
  "POST /snapshots": ({ namespace }) => {
250
- const snapshot = context.snapshot(namespace);
250
+ const point = context.timeTravel.checkpoint(namespace, "main");
251
+ context.timeTravel.retain(namespace, point.id);
251
252
  snapshotCounter++;
252
253
  const id = `snap_${snapshotCounter}`;
253
- snapshots.set(id, snapshot);
254
- return json(201, { id, namespace, records: snapshot.records.length });
254
+ snapshots.set(id, { namespace, checkpoint: point.id });
255
+ return json(201, { id, namespace, records: point.records ?? 0 });
255
256
  },
256
257
  "POST /snapshots/:id/restore": ({ params, namespace }) => {
257
- const snapshot = snapshots.get(params.id);
258
- if (!snapshot)
258
+ const alias = snapshots.get(params.id);
259
+ if (!alias)
259
260
  return adminError(404, `no snapshot ${params.id}`);
260
- context.restore(namespace, snapshot);
261
+ if (alias.namespace !== namespace) {
262
+ return adminError(409, `snapshot ${params.id} belongs to namespace ${alias.namespace}`);
263
+ }
264
+ context.timeTravel.checkout(alias.checkpoint, { namespace, branch: "main" });
261
265
  return json(200, { status: "ok", id: params.id, namespace });
262
266
  },
263
- "DELETE /snapshots/:id": ({ params }) => snapshots.delete(params.id) ? json(200, { status: "ok" }) : adminError(404, `no snapshot ${params.id}`),
267
+ "DELETE /snapshots/:id": ({ params }) => {
268
+ const id = params.id;
269
+ const alias = snapshots.get(id);
270
+ if (!alias)
271
+ return adminError(404, `no snapshot ${id}`);
272
+ snapshots.delete(id);
273
+ context.timeTravel.release(alias.namespace, alias.checkpoint);
274
+ return json(200, { status: "ok" });
275
+ },
276
+ "GET /timeline": ({ namespace }) => json(200, context.timeTravel.inspect(namespace)),
277
+ "POST /checkpoints": ({ body, namespace }) => {
278
+ const branch = isRecord(body) && typeof body.branch === "string" ? body.branch : "main";
279
+ try {
280
+ return json(201, context.timeTravel.checkpoint(namespace, branch));
281
+ } catch (error) {
282
+ return adminError(409, error instanceof Error ? error.message : String(error));
283
+ }
284
+ },
285
+ "POST /branches/:name": ({ params, body, namespace }) => {
286
+ const at = isRecord(body) && typeof body.at === "string" ? body.at : void 0;
287
+ try {
288
+ return json(201, context.timeTravel.branch(params.name, {
289
+ namespace,
290
+ ...at !== void 0 ? { at } : {}
291
+ }));
292
+ } catch (error) {
293
+ return adminError(409, error instanceof Error ? error.message : String(error));
294
+ }
295
+ },
296
+ "POST /branches/:name/checkout": ({ params, body, namespace }) => {
297
+ if (!isRecord(body) || typeof body.checkpoint !== "string") {
298
+ return adminError(400, 'expected {"checkpoint":"cp_..."}');
299
+ }
300
+ try {
301
+ context.timeTravel.checkout(body.checkpoint, {
302
+ namespace,
303
+ branch: params.name
304
+ });
305
+ return json(200, { status: "ok", branch: params.name, checkpoint: body.checkpoint });
306
+ } catch (error) {
307
+ return adminError(409, error instanceof Error ? error.message : String(error));
308
+ }
309
+ },
264
310
  "GET /requests": ({ url, namespace }) => {
265
311
  const status = url.searchParams.get("status");
266
312
  const since = url.searchParams.get("since");
@@ -308,7 +354,7 @@ var createControlPlane = (context) => {
308
354
  return json(200, {
309
355
  status: "ok",
310
356
  service: context.name,
311
- uptimeMs: Date.now() - context.startedAt,
357
+ uptimeMs: context.wallNow() - context.startedAt,
312
358
  clock: context.clock.state(),
313
359
  namespaces: context.namespaces().length,
314
360
  ...context.describe()
@@ -386,6 +432,13 @@ var createRng = (seed = 0) => {
386
432
  reset: () => {
387
433
  state = numeric;
388
434
  },
435
+ state: () => state,
436
+ setState: (next2) => {
437
+ if (!Number.isSafeInteger(next2) || next2 < 0 || next2 > 4294967295) {
438
+ throw new RangeError("rng state must be an unsigned 32-bit integer");
439
+ }
440
+ state = next2 >>> 0;
441
+ },
389
442
  seed: numeric
390
443
  };
391
444
  };
@@ -414,7 +467,7 @@ var faultResponse = (rule) => {
414
467
  const body = rule.body === void 0 ? { detail: "Injected by Mockingbird" } : rule.body;
415
468
  return new Response(JSON.stringify(body), { status, headers });
416
469
  };
417
- var createFaultRegistry = (rng = createRng(0)) => {
470
+ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
418
471
  const entries = [];
419
472
  return {
420
473
  add(rule) {
@@ -452,7 +505,7 @@ var createFaultRegistry = (rng = createRng(0)) => {
452
505
  entry.remaining--;
453
506
  const delay = entry.rule.delayMs ?? entry.rule.latencyMs;
454
507
  if (delay !== void 0 && delay > 0) {
455
- await new Promise((resolve) => setTimeout(resolve, delay));
508
+ await sleep(delay);
456
509
  }
457
510
  const hit = { id: entry.rule.id };
458
511
  if (entry.rule.effect !== void 0) {
@@ -861,6 +914,167 @@ var createMetrics = () => {
861
914
  };
862
915
  };
863
916
 
917
+ // ../../core/dist/timeline.js
918
+ var BRANCH_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
919
+ var Timeline = class {
920
+ maxCheckpoints;
921
+ now;
922
+ makeId;
923
+ nodes = /* @__PURE__ */ new Map();
924
+ heads = /* @__PURE__ */ new Map();
925
+ /** Unreferenced nodes in the exact order they became collectible. */
926
+ evictable = /* @__PURE__ */ new Set();
927
+ /** Branch heads plus explicit retainers. Absent means zero. */
928
+ references = /* @__PURE__ */ new Map();
929
+ explicitPins = /* @__PURE__ */ new Map();
930
+ sequence = 0;
931
+ constructor(options = {}) {
932
+ const max = options.maxCheckpoints ?? 1e3;
933
+ if (!Number.isSafeInteger(max) || max < 1)
934
+ throw new RangeError("maxCheckpoints must be a positive integer");
935
+ this.maxCheckpoints = max;
936
+ this.now = options.now ?? (() => this.sequence);
937
+ this.makeId = options.id ?? ((sequence) => `cp_${sequence.toString(36).padStart(8, "0")}`);
938
+ }
939
+ /** Capture a new immutable value and move `branch` to it. */
940
+ commit(value, options = {}) {
941
+ const branch = options.branch ?? "main";
942
+ this.assertBranch(branch);
943
+ const parent = options.parent === void 0 ? this.heads.get(branch) ?? null : options.parent;
944
+ if (parent !== null && !this.nodes.has(parent))
945
+ throw new RangeError(`no checkpoint ${parent}`);
946
+ const id = this.makeId(++this.sequence);
947
+ if (this.nodes.has(id))
948
+ throw new RangeError(`duplicate checkpoint id ${id}`);
949
+ const checkpoint = Object.freeze({ id, branch, parent, at: this.now(), value });
950
+ this.nodes.set(id, checkpoint);
951
+ this.moveHead(branch, id);
952
+ this.collect(this.maxCheckpoints);
953
+ return checkpoint;
954
+ }
955
+ /** Create a branch pointer without copying its checkpoint value. */
956
+ fork(branch, options = {}) {
957
+ this.assertBranch(branch);
958
+ if (this.heads.has(branch))
959
+ throw new RangeError(`branch already exists: ${branch}`);
960
+ const from = options.from ?? this.heads.get("main");
961
+ if (from === void 0)
962
+ return void 0;
963
+ const checkpoint = this.get(from);
964
+ this.moveHead(branch, checkpoint.id);
965
+ return checkpoint;
966
+ }
967
+ /** Move a branch pointer to an existing checkpoint. */
968
+ checkout(branch, id) {
969
+ this.assertBranch(branch);
970
+ const checkpoint = this.get(id);
971
+ this.moveHead(branch, checkpoint.id);
972
+ return checkpoint;
973
+ }
974
+ get(id) {
975
+ const checkpoint = this.nodes.get(id);
976
+ if (!checkpoint)
977
+ throw new RangeError(`no checkpoint ${id}`);
978
+ return checkpoint;
979
+ }
980
+ head(branch = "main") {
981
+ const id = this.heads.get(branch);
982
+ return id === void 0 ? void 0 : this.get(id);
983
+ }
984
+ hasBranch(branch) {
985
+ return this.heads.has(branch);
986
+ }
987
+ branches() {
988
+ return Object.freeze(Object.fromEntries([...this.heads].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
989
+ }
990
+ checkpoints() {
991
+ return [...this.nodes.values()];
992
+ }
993
+ /** Number of retained checkpoints without allocating an array. */
994
+ get size() {
995
+ return this.nodes.size;
996
+ }
997
+ /** Pin a checkpoint independently of branch heads (used by compatibility snapshot handles). */
998
+ retain(id) {
999
+ const checkpoint = this.get(id);
1000
+ this.explicitPins.set(id, (this.explicitPins.get(id) ?? 0) + 1);
1001
+ this.addReference(id);
1002
+ return checkpoint;
1003
+ }
1004
+ /** Release one explicit pin. Branch heads remain pinned until moved or deleted. */
1005
+ release(id) {
1006
+ if (!this.nodes.has(id))
1007
+ return false;
1008
+ const pins = this.explicitPins.get(id) ?? 0;
1009
+ if (pins === 0)
1010
+ return false;
1011
+ if (pins === 1)
1012
+ this.explicitPins.delete(id);
1013
+ else
1014
+ this.explicitPins.set(id, pins - 1);
1015
+ this.removeReference(id);
1016
+ this.collect(this.maxCheckpoints);
1017
+ return true;
1018
+ }
1019
+ deleteBranch(branch) {
1020
+ if (branch === "main")
1021
+ throw new RangeError("cannot delete main branch");
1022
+ const previous = this.heads.get(branch);
1023
+ const deleted = this.heads.delete(branch);
1024
+ if (previous !== void 0)
1025
+ this.removeReference(previous);
1026
+ this.collect(this.maxCheckpoints);
1027
+ return deleted;
1028
+ }
1029
+ /**
1030
+ * Deterministically discard oldest unpinned checkpoints. Collection is O(number removed):
1031
+ * commits never scan pinned nodes or the retained history. Parents are metadata rather than a
1032
+ * storage dependency, so a retained node remains usable after pruning.
1033
+ */
1034
+ gc(max = this.maxCheckpoints) {
1035
+ if (!Number.isSafeInteger(max) || max < 1)
1036
+ throw new RangeError("max must be a positive integer");
1037
+ const removed = [];
1038
+ this.collect(max, removed);
1039
+ return removed;
1040
+ }
1041
+ collect(max, removed) {
1042
+ while (this.nodes.size > max && this.evictable.size > 0) {
1043
+ const id = this.evictable.values().next().value;
1044
+ this.evictable.delete(id);
1045
+ this.nodes.delete(id);
1046
+ removed?.push(id);
1047
+ }
1048
+ }
1049
+ moveHead(branch, id) {
1050
+ const previous = this.heads.get(branch);
1051
+ if (previous === id)
1052
+ return;
1053
+ if (previous !== void 0)
1054
+ this.removeReference(previous);
1055
+ this.heads.set(branch, id);
1056
+ this.addReference(id);
1057
+ }
1058
+ addReference(id) {
1059
+ this.references.set(id, (this.references.get(id) ?? 0) + 1);
1060
+ this.evictable.delete(id);
1061
+ }
1062
+ removeReference(id) {
1063
+ const next = (this.references.get(id) ?? 0) - 1;
1064
+ if (next > 0)
1065
+ this.references.set(id, next);
1066
+ else {
1067
+ this.references.delete(id);
1068
+ if (this.nodes.has(id))
1069
+ this.evictable.add(id);
1070
+ }
1071
+ }
1072
+ assertBranch(branch) {
1073
+ if (!BRANCH_PATTERN.test(branch))
1074
+ throw new RangeError(`branch must match ${BRANCH_PATTERN}`);
1075
+ }
1076
+ };
1077
+
864
1078
  // ../../sqlite/dist/default.js
865
1079
  import { Database } from "@crvouga/mockingbird-service-sqlite";
866
1080
  var createDefaultSqlite = () => new Database();
@@ -1082,7 +1296,7 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1082
1296
  };
1083
1297
 
1084
1298
  // ../core/dist/version.js
1085
- var PACKAGE_VERSION = true ? "0.5.0" : UNRELEASED_VERSION;
1299
+ var PACKAGE_VERSION = true ? "0.7.0" : UNRELEASED_VERSION;
1086
1300
 
1087
1301
  // ../core/dist/webhooks.js
1088
1302
  var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
@@ -1188,10 +1402,33 @@ var parsePayload = (message) => {
1188
1402
 
1189
1403
  // ../core/dist/runtime.js
1190
1404
  var MOCKINGBIRD_HEADER = "x-mockingbird";
1405
+ var BRANCH_HEADER = "x-mockingbird-branch";
1406
+ var AT_HEADER = "x-mockingbird-at";
1407
+ var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
1191
1408
  var DEFAULT_NAMESPACE = "default";
1192
1409
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
1193
1410
  var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
1411
+ var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
1412
+ var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
1194
1413
  var effects = /* @__PURE__ */ new WeakMap();
1414
+ var reuseSorted = (fresh, previous, compare, equal) => {
1415
+ if (!previous || previous.length === 0)
1416
+ return fresh.map((row) => Object.freeze(row));
1417
+ const result = new Array(fresh.length);
1418
+ let unchanged = fresh.length === previous.length;
1419
+ let oldIndex = 0;
1420
+ for (let index = 0; index < fresh.length; index++) {
1421
+ const row = fresh[index];
1422
+ while (oldIndex < previous.length && compare(previous[oldIndex], row) < 0) {
1423
+ oldIndex++;
1424
+ }
1425
+ const old = previous[oldIndex];
1426
+ result[index] = old !== void 0 && compare(old, row) === 0 && equal(old, row) ? old : Object.freeze(row);
1427
+ if (result[index] !== previous[index])
1428
+ unchanged = false;
1429
+ }
1430
+ return unchanged ? previous : result;
1431
+ };
1195
1432
  var DroppedConnectionError = class extends TypeError {
1196
1433
  code = "MOCKINGBIRD_DROP";
1197
1434
  constructor() {
@@ -1212,36 +1449,173 @@ var createRuntime = (options) => {
1212
1449
  const sqlite = bootSqlite(options.sqlite);
1213
1450
  const clock = options.clock ?? createClock();
1214
1451
  const rng = createRng(options.seed ?? 0);
1215
- const faults = createFaultRegistry(createRng(options.seed ?? 0));
1452
+ const wallNow = options.io?.wallNow ?? Date.now;
1453
+ const monotonicNow = options.io?.monotonicNow ?? (() => performance.now());
1454
+ const sleep = options.io?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1455
+ const faults = createFaultRegistry(createRng(options.seed ?? 0), sleep);
1216
1456
  const metrics = createMetrics();
1217
1457
  const journal = createJournal(options.journalSize ?? DEFAULT_JOURNAL_SIZE);
1218
1458
  const version = options.version ?? PACKAGE_VERSION;
1219
1459
  const instances = /* @__PURE__ */ new Map();
1460
+ const publicNamespaces = /* @__PURE__ */ new Set();
1461
+ const branchRngs = /* @__PURE__ */ new Map();
1462
+ const timelines = /* @__PURE__ */ new Map();
1463
+ const branchStorage = /* @__PURE__ */ new Map();
1464
+ const captured = /* @__PURE__ */ new Map();
1220
1465
  const credentials = createCredentialRegistry();
1221
1466
  const operationIdFor = options.document ? operationMatcher(options.document) : () => void 0;
1222
1467
  const storageNamespace = (name) => name === DEFAULT_NAMESPACE ? options.name : `${options.name}:${name}`;
1223
- const instance = (name = DEFAULT_NAMESPACE) => {
1224
- const existing = instances.get(name);
1468
+ const instanceFor = (key, publicNamespace = key, isolatedRng) => {
1469
+ const existing = instances.get(key);
1225
1470
  if (existing)
1226
1471
  return existing;
1227
- if (!NAMESPACE_PATTERN.test(name)) {
1228
- throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(name)}`);
1472
+ if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
1473
+ throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
1229
1474
  }
1230
1475
  const created = options.create({
1231
- namespace: storageNamespace(name),
1232
- publicNamespace: name,
1476
+ namespace: storageNamespace(key),
1477
+ publicNamespace,
1233
1478
  sqlite,
1234
1479
  clock,
1235
- rng
1480
+ rng: isolatedRng ?? rng
1236
1481
  });
1237
- instances.set(name, created);
1482
+ instances.set(key, created);
1483
+ publicNamespaces.add(publicNamespace);
1484
+ if (isolatedRng)
1485
+ branchRngs.set(key, isolatedRng);
1238
1486
  return created;
1239
1487
  };
1488
+ const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
1489
+ const capture = (storage) => {
1490
+ const fresh = snapshotNamespace(sqlite, storageNamespace(storage));
1491
+ const previous = captured.get(storage);
1492
+ const snapshot2 = {
1493
+ namespace: fresh.namespace,
1494
+ records: reuseSorted(fresh.records, previous?.records, (left, right) => left.collection < right.collection ? -1 : left.collection > right.collection ? 1 : left.seq - right.seq, (left, right) => left.id === right.id && left.value === right.value),
1495
+ sequences: reuseSorted(fresh.sequences, previous?.sequences, (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : left.kind < right.kind ? -1 : left.kind > right.kind ? 1 : 0, (left, right) => left.value === right.value)
1496
+ };
1497
+ Object.freeze(snapshot2.records);
1498
+ Object.freeze(snapshot2.sequences);
1499
+ Object.freeze(snapshot2);
1500
+ captured.set(storage, snapshot2);
1501
+ return Object.freeze({
1502
+ snapshot: snapshot2,
1503
+ clock: Object.freeze(clock.state()),
1504
+ rngState: (branchRngs.get(storage) ?? rng).state()
1505
+ });
1506
+ };
1507
+ const timeline = (name = DEFAULT_NAMESPACE) => {
1508
+ let found = timelines.get(name);
1509
+ if (found)
1510
+ return found;
1511
+ instance(name);
1512
+ found = new Timeline({
1513
+ now: clock.now,
1514
+ ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
1515
+ });
1516
+ found.commit(capture(name));
1517
+ timelines.set(name, found);
1518
+ return found;
1519
+ };
1520
+ const physicalBranch = (namespace, branch2) => {
1521
+ if (branch2 === "main")
1522
+ return namespace;
1523
+ const mapKey = `${namespace}\0${branch2}`;
1524
+ const existing = branchStorage.get(mapKey);
1525
+ if (existing)
1526
+ return existing;
1527
+ const key = `branch_${seedFrom(`${options.name}\0${namespace}\0${branch2}`).toString(36)}`;
1528
+ branchStorage.set(mapKey, key);
1529
+ return key;
1530
+ };
1531
+ const ensureBranch = (namespace, branch2, at) => {
1532
+ if (!BRANCH_PATTERN2.test(branch2))
1533
+ throw new RangeError(`branch must match ${BRANCH_PATTERN2}`);
1534
+ const history = timeline(namespace);
1535
+ if (branch2 === "main") {
1536
+ if (at !== void 0) {
1537
+ const point = history.checkout("main", at);
1538
+ restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
1539
+ captured.set(namespace, point.value.snapshot);
1540
+ rng.setState(point.value.rngState);
1541
+ clock.set(point.value.clock.now);
1542
+ if (point.value.clock.frozen)
1543
+ clock.freeze();
1544
+ else
1545
+ clock.unfreeze();
1546
+ }
1547
+ return namespace;
1548
+ }
1549
+ const storage = physicalBranch(namespace, branch2);
1550
+ if (!history.hasBranch(branch2)) {
1551
+ if (at === void 0)
1552
+ history.commit(capture(namespace));
1553
+ const point = history.fork(branch2, at === void 0 ? {} : { from: at });
1554
+ const branchRng = createRng(options.seed ?? 0);
1555
+ if (point)
1556
+ branchRng.setState(point.value.rngState);
1557
+ instanceFor(storage, namespace, branchRng);
1558
+ if (point)
1559
+ restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
1560
+ if (point)
1561
+ captured.set(storage, point.value.snapshot);
1562
+ } else if (at !== void 0 && history.head(branch2)?.id !== at) {
1563
+ const point = history.checkout(branch2, at);
1564
+ if (!instances.has(storage)) {
1565
+ const branchRng = createRng(options.seed ?? 0);
1566
+ branchRng.setState(point.value.rngState);
1567
+ instanceFor(storage, namespace, branchRng);
1568
+ }
1569
+ branchRngs.get(storage)?.setState(point.value.rngState);
1570
+ restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
1571
+ captured.set(storage, point.value.snapshot);
1572
+ } else {
1573
+ if (!instances.has(storage)) {
1574
+ const point = history.head(branch2);
1575
+ const branchRng = createRng(options.seed ?? 0);
1576
+ if (point)
1577
+ branchRng.setState(point.value.rngState);
1578
+ instanceFor(storage, namespace, branchRng);
1579
+ }
1580
+ }
1581
+ return storage;
1582
+ };
1583
+ const checkpoint = (namespace = DEFAULT_NAMESPACE, branch2 = "main") => {
1584
+ const storage = ensureBranch(namespace, branch2);
1585
+ return timeline(namespace).commit(capture(storage), { branch: branch2 });
1586
+ };
1587
+ const branch = (name, branchOptions = {}) => {
1588
+ const namespace = branchOptions.namespace ?? DEFAULT_NAMESPACE;
1589
+ ensureBranch(namespace, name, branchOptions.at);
1590
+ const head = timeline(namespace).head(name);
1591
+ if (!head)
1592
+ throw new RangeError(`branch ${name} has no checkpoint`);
1593
+ return head;
1594
+ };
1595
+ const checkout = (checkpointId, checkoutOptions = {}) => {
1596
+ const namespace = checkoutOptions.namespace ?? DEFAULT_NAMESPACE;
1597
+ const branchName = checkoutOptions.branch ?? "main";
1598
+ const history = timeline(namespace);
1599
+ const point = history.checkout(branchName, checkpointId);
1600
+ const storage = ensureBranch(namespace, branchName);
1601
+ restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
1602
+ captured.set(storage, point.value.snapshot);
1603
+ clock.set(point.value.clock.now);
1604
+ if (point.value.clock.frozen)
1605
+ clock.freeze();
1606
+ else
1607
+ clock.unfreeze();
1608
+ (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
1609
+ };
1240
1610
  const reset = async (name = DEFAULT_NAMESPACE) => {
1241
1611
  if (name === "*") {
1242
1612
  options.webhooks?.clear();
1243
1613
  for (const each of instances.values())
1244
1614
  await each.reset();
1615
+ timelines.clear();
1616
+ branchStorage.clear();
1617
+ branchRngs.clear();
1618
+ captured.clear();
1245
1619
  return;
1246
1620
  }
1247
1621
  options.webhooks?.clear(name);
@@ -1250,14 +1624,33 @@ var createRuntime = (options) => {
1250
1624
  await target.reset();
1251
1625
  else
1252
1626
  clearNamespace(sqlite, storageNamespace(name));
1627
+ for (const [mapping, storage] of branchStorage) {
1628
+ if (!mapping.startsWith(`${name}\0`))
1629
+ continue;
1630
+ const branchInstance = instances.get(storage);
1631
+ if (branchInstance)
1632
+ await branchInstance.reset();
1633
+ else
1634
+ clearNamespace(sqlite, storageNamespace(storage));
1635
+ branchStorage.delete(mapping);
1636
+ branchRngs.delete(storage);
1637
+ captured.delete(storage);
1638
+ }
1639
+ timelines.delete(name);
1640
+ captured.delete(name);
1253
1641
  };
1254
1642
  const snapshot = (name = DEFAULT_NAMESPACE) => {
1255
- instance(name);
1256
- return snapshotNamespace(sqlite, storageNamespace(name));
1643
+ return checkpoint(name, "main").value.snapshot;
1257
1644
  };
1258
1645
  const restore = (from, name = DEFAULT_NAMESPACE) => {
1259
1646
  instance(name);
1260
1647
  restoreNamespace(sqlite, storageNamespace(name), from);
1648
+ captured.set(name, from);
1649
+ const history = timelines.get(name);
1650
+ if (history)
1651
+ history.commit(capture(name), { branch: "main" });
1652
+ else
1653
+ timeline(name);
1261
1654
  };
1262
1655
  const runtime = {
1263
1656
  name: options.name,
@@ -1289,10 +1682,14 @@ var createRuntime = (options) => {
1289
1682
  return added;
1290
1683
  },
1291
1684
  instance,
1292
- namespaces: () => [...instances.keys()].sort(),
1685
+ namespaces: () => [...publicNamespaces].sort(),
1293
1686
  reset,
1294
1687
  snapshot,
1295
1688
  restore,
1689
+ checkpoint,
1690
+ branch,
1691
+ checkout,
1692
+ timeline,
1296
1693
  fetch: async (incoming) => {
1297
1694
  let request2 = incoming;
1298
1695
  const prefixed = PATH_PREFIX.exec(new URL(request2.url).pathname);
@@ -1318,6 +1715,8 @@ var createRuntime = (options) => {
1318
1715
  if (mapped !== void 0)
1319
1716
  namespace = mapped;
1320
1717
  }
1718
+ const selectedBranch = request2.headers.get(BRANCH_HEADER) ?? "main";
1719
+ const at = request2.headers.get(AT_HEADER) ?? void 0;
1321
1720
  const stamp = (response2) => {
1322
1721
  const value = NAMESPACE_PATTERN.test(namespace) ? `${options.name}@${version}; ns=${namespace}` : `${options.name}@${version}`;
1323
1722
  try {
@@ -1332,7 +1731,7 @@ var createRuntime = (options) => {
1332
1731
  const handled = await control.handle(request2);
1333
1732
  if (handled)
1334
1733
  return stamp(handled);
1335
- const started = performance.now();
1734
+ const started = monotonicNow();
1336
1735
  const url = new URL(request2.url);
1337
1736
  const operationId = operationIdFor(request2, url.pathname);
1338
1737
  const log = (status, faultId, response2) => {
@@ -1344,7 +1743,7 @@ var createRuntime = (options) => {
1344
1743
  method: request2.method,
1345
1744
  path: url.pathname,
1346
1745
  status,
1347
- durationMs: Math.round((performance.now() - started) * 100) / 100,
1746
+ durationMs: Math.round((monotonicNow() - started) * 100) / 100,
1348
1747
  unmatched: options.document !== void 0 && operationId === void 0,
1349
1748
  ...faultId !== void 0 ? { faultId } : {},
1350
1749
  ...noted?.ids && Object.keys(noted.ids).length > 0 ? { ids: noted.ids } : {},
@@ -1363,6 +1762,30 @@ var createRuntime = (options) => {
1363
1762
  }
1364
1763
  }), { status: 400, headers: { "content-type": "application/json" } }));
1365
1764
  }
1765
+ if (!BRANCH_PATTERN2.test(selectedBranch)) {
1766
+ log(400);
1767
+ return stamp(adminFail(400, `${BRANCH_HEADER} must match ${BRANCH_PATTERN2}`));
1768
+ }
1769
+ let storage;
1770
+ try {
1771
+ if (at !== void 0 && selectedBranch === "main" && !MUTATING_METHODS.has(request2.method)) {
1772
+ const point = timeline(namespace).get(at);
1773
+ storage = physicalBranch(namespace, `at_${at}`);
1774
+ let viewRng = branchRngs.get(storage);
1775
+ if (!viewRng) {
1776
+ viewRng = createRng(options.seed ?? 0);
1777
+ instanceFor(storage, namespace, viewRng);
1778
+ }
1779
+ viewRng.setState(point.value.rngState);
1780
+ restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
1781
+ captured.set(storage, point.value.snapshot);
1782
+ } else {
1783
+ storage = ensureBranch(namespace, selectedBranch, at);
1784
+ }
1785
+ } catch (error) {
1786
+ log(409);
1787
+ return stamp(adminFail(409, error instanceof Error ? error.message : String(error)));
1788
+ }
1366
1789
  const hits = await faults.take({
1367
1790
  operationId,
1368
1791
  method: request2.method,
@@ -1381,14 +1804,28 @@ var createRuntime = (options) => {
1381
1804
  const fired = hits.filter((hit) => hit.effect !== void 0);
1382
1805
  if (fired.length > 0)
1383
1806
  effects.set(request2, fired.map((hit) => hit.effect));
1384
- const response = await instance(namespace).fetch(request2);
1807
+ let response = await instanceFor(storage, namespace).fetch(request2);
1808
+ if (MUTATING_METHODS.has(request2.method) && response.status >= 200 && response.status < 400) {
1809
+ const point = timeline(namespace).commit(capture(storage), { branch: selectedBranch });
1810
+ response = mutableResponse(response);
1811
+ response.headers.set(CHECKPOINT_HEADER, point.id);
1812
+ }
1813
+ if (selectedBranch !== "main") {
1814
+ response = mutableResponse(response);
1815
+ response.headers.set(BRANCH_HEADER, selectedBranch);
1816
+ }
1817
+ if (at !== void 0) {
1818
+ response = mutableResponse(response);
1819
+ response.headers.set(AT_HEADER, at);
1820
+ }
1385
1821
  log(response.status, fired[0]?.id, response);
1386
1822
  return stamp(response);
1387
1823
  }
1388
1824
  };
1389
1825
  const control = createControlPlane({
1390
1826
  name: options.name,
1391
- startedAt: Date.now(),
1827
+ startedAt: wallNow(),
1828
+ wallNow,
1392
1829
  clock,
1393
1830
  faults,
1394
1831
  metrics,
@@ -1396,8 +1833,39 @@ var createRuntime = (options) => {
1396
1833
  defaultNamespace: DEFAULT_NAMESPACE,
1397
1834
  namespaces: runtime.namespaces,
1398
1835
  reset,
1399
- snapshot: (name) => snapshot(name),
1400
- restore: (name, from) => restore(from, name),
1836
+ timeTravel: {
1837
+ checkpoint: (name, branchName) => {
1838
+ const point = checkpoint(name, branchName);
1839
+ return {
1840
+ id: point.id,
1841
+ branch: point.branch,
1842
+ parent: point.parent,
1843
+ at: point.at,
1844
+ records: point.value.snapshot.records.length
1845
+ };
1846
+ },
1847
+ branch: (branchName, branchOptions) => {
1848
+ const point = branch(branchName, branchOptions);
1849
+ return { id: point.id, branch: point.branch, parent: point.parent, at: point.at };
1850
+ },
1851
+ checkout: (checkpointId, checkoutOptions) => checkout(checkpointId, checkoutOptions),
1852
+ retain: (name, checkpointId) => {
1853
+ timeline(name).retain(checkpointId);
1854
+ },
1855
+ release: (name, checkpointId) => timeline(name).release(checkpointId),
1856
+ inspect: (name) => {
1857
+ const history = timeline(name);
1858
+ return {
1859
+ branches: history.branches(),
1860
+ checkpoints: history.checkpoints().map(({ id, branch: branchName, parent, at }) => ({
1861
+ id,
1862
+ branch: branchName,
1863
+ parent,
1864
+ at
1865
+ }))
1866
+ };
1867
+ }
1868
+ },
1401
1869
  describe: options.describe ?? (() => ({})),
1402
1870
  ...options.presets ? {
1403
1871
  applyPreset: (name, namespace, overrides) => runtime.applyPreset(name, namespace, overrides)
@@ -1412,6 +1880,15 @@ var createRuntime = (options) => {
1412
1880
  });
1413
1881
  return runtime;
1414
1882
  };
1883
+ var mutableResponse = (response) => {
1884
+ try {
1885
+ response.headers.set("x-mockingbird-mutable-probe", "1");
1886
+ response.headers.delete("x-mockingbird-mutable-probe");
1887
+ return response;
1888
+ } catch {
1889
+ return new Response(response.body, response);
1890
+ }
1891
+ };
1415
1892
  var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
1416
1893
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
1417
1894
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -4346,7 +4823,7 @@ var linkAppointmentToOrder = (state, order, appointment, status, nowIso) => {
4346
4823
  transactionOrder.updated_at = nowIso;
4347
4824
  }
4348
4825
  state.orders.update(order.id, order);
4349
- state.publishOrderWebhook(order, "labtest.order.updated");
4826
+ state.publishOrderWebhook(order, "labtest.order.updated", new Date(nowIso).getTime());
4350
4827
  };
4351
4828
  var appendAppointmentEvent = (appointment, status, at) => {
4352
4829
  const event = { created_at: at, status, data: null };
@@ -5192,7 +5669,7 @@ var pyIso = (value) => {
5192
5669
  const ms = (match?.[1] ?? "000").padEnd(6, "0");
5193
5670
  return `${date}T${time.slice(0, 8)}.${ms}+00:00`;
5194
5671
  };
5195
- var orderValidation = (body) => {
5672
+ var orderValidation = (body, now = Date.now) => {
5196
5673
  const errors = [];
5197
5674
  const userId = body.user_id;
5198
5675
  if (userId === void 0) errors.push(missingError(["body", "user_id"], body));
@@ -5290,7 +5767,7 @@ var orderValidation = (body) => {
5290
5767
  const emailError = userEmailError(p[field]);
5291
5768
  if (emailError) errors.push(emailError);
5292
5769
  } else if (field === "dob") {
5293
- const dobError = orderDobError(p[field]);
5770
+ const dobError = orderDobError(p[field], now());
5294
5771
  if (dobError) errors.push(dobError);
5295
5772
  } else if (field === "phone_number" && !isValidPhone(p[field])) {
5296
5773
  errors.push(phoneError(["body", "patient_details", "phone_number"], p[field]));
@@ -5338,11 +5815,11 @@ var userEmailError = (value) => {
5338
5815
  }
5339
5816
  return void 0;
5340
5817
  };
5341
- var orderDobError = (dob) => {
5818
+ var orderDobError = (dob, nowMs) => {
5342
5819
  if (/^\d{4}-\d{2}-\d{2}T/.test(dob)) {
5343
5820
  if (/^\d{4}-\d{2}-\d{2}T00:00:00/.test(dob)) {
5344
5821
  const dateOnly = dob.slice(0, 10);
5345
- if ((/* @__PURE__ */ new Date(`${dateOnly}T00:00:00Z`)).getTime() > Date.now()) {
5822
+ if ((/* @__PURE__ */ new Date(`${dateOnly}T00:00:00Z`)).getTime() > nowMs) {
5346
5823
  return {
5347
5824
  type: "value_error",
5348
5825
  loc: ["body", "patient_details", "dob"],
@@ -5383,7 +5860,7 @@ var orderDobError = (dob) => {
5383
5860
  ctx: { error: {} }
5384
5861
  };
5385
5862
  }
5386
- if ((/* @__PURE__ */ new Date(`${dob}T00:00:00Z`)).getTime() > Date.now()) {
5863
+ if ((/* @__PURE__ */ new Date(`${dob}T00:00:00Z`)).getTime() > nowMs) {
5387
5864
  return {
5388
5865
  type: "value_error",
5389
5866
  loc: ["body", "patient_details", "dob"],
@@ -5615,7 +6092,7 @@ var orderHandlers = (state) => ({
5615
6092
  });
5616
6093
  }
5617
6094
  }
5618
- const errors = orderValidation(body);
6095
+ const errors = orderValidation(body, context.now);
5619
6096
  if (errors.length > 0) throw new HttpError(422, { detail: errors });
5620
6097
  const rawAddress = body.patient_address;
5621
6098
  if (typeof rawAddress === "object" && rawAddress !== null && !Array.isArray(rawAddress) && typeof rawAddress.country === "string" && /[^A-Za-z ]/.test(rawAddress.country ?? "")) {
@@ -8145,14 +8622,17 @@ var createWebhookDispatcher = (endpoint) => {
8145
8622
  const delays = endpoint.retryDelaysMs ?? WEBHOOK_RETRY_DELAYS_MS;
8146
8623
  const timeoutMs = endpoint.timeoutMs ?? 15e3;
8147
8624
  const send = endpoint.fetch ?? ((request2) => fetch(request2));
8625
+ const now = endpoint.now ?? Date.now;
8626
+ const scheduleTimer = endpoint.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
8627
+ const cancel = endpoint.cancel ?? ((handle) => clearTimeout(handle));
8148
8628
  const deliveries = /* @__PURE__ */ new Map();
8149
8629
  const pending = /* @__PURE__ */ new Map();
8150
8630
  const inFlight = /* @__PURE__ */ new Set();
8151
8631
  let sequence = 0;
8152
8632
  const attempt = async (delivery) => {
8153
8633
  const body = JSON.stringify(delivery.event);
8154
- const timestamp = Math.floor(Date.now() / 1e3);
8155
- const started = Date.now();
8634
+ const timestamp = Math.floor(now() / 1e3);
8635
+ const started = now();
8156
8636
  const record = {
8157
8637
  attempt: delivery.attempts.length + 1,
8158
8638
  at: new Date(started).toISOString(),
@@ -8161,7 +8641,7 @@ var createWebhookDispatcher = (endpoint) => {
8161
8641
  durationMs: 0
8162
8642
  };
8163
8643
  const controller = new AbortController();
8164
- const timer = setTimeout(() => controller.abort(), timeoutMs);
8644
+ const timer = scheduleTimer(() => controller.abort(), timeoutMs);
8165
8645
  try {
8166
8646
  const response = await send(
8167
8647
  new Request(endpoint.url, {
@@ -8182,8 +8662,8 @@ var createWebhookDispatcher = (endpoint) => {
8182
8662
  } catch (error) {
8183
8663
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
8184
8664
  } finally {
8185
- clearTimeout(timer);
8186
- record.durationMs = Date.now() - started;
8665
+ cancel(timer);
8666
+ record.durationMs = now() - started;
8187
8667
  delivery.attempts.push(record);
8188
8668
  }
8189
8669
  return record.status !== null && record.status >= 200 && record.status < 300;
@@ -8213,7 +8693,7 @@ var createWebhookDispatcher = (endpoint) => {
8213
8693
  run();
8214
8694
  return;
8215
8695
  }
8216
- const timer = setTimeout(run, delay);
8696
+ const timer = scheduleTimer(run, delay);
8217
8697
  unref(timer);
8218
8698
  pending.set(delivery.messageId, { delivery, timer });
8219
8699
  };
@@ -8236,7 +8716,7 @@ var createWebhookDispatcher = (endpoint) => {
8236
8716
  const delivery = deliveries.get(messageId);
8237
8717
  if (!delivery) return void 0;
8238
8718
  const queued = pending.get(messageId);
8239
- if (queued?.timer !== void 0) clearTimeout(queued.timer);
8719
+ if (queued?.timer !== void 0) cancel(queued.timer);
8240
8720
  pending.delete(messageId);
8241
8721
  const ok = await attempt(delivery);
8242
8722
  delivery.state = ok ? "delivered" : "failed";
@@ -8244,7 +8724,7 @@ var createWebhookDispatcher = (endpoint) => {
8244
8724
  },
8245
8725
  async flush() {
8246
8726
  for (const [id, queued] of [...pending]) {
8247
- if (queued.timer !== void 0) clearTimeout(queued.timer);
8727
+ if (queued.timer !== void 0) cancel(queued.timer);
8248
8728
  pending.delete(id);
8249
8729
  track(
8250
8730
  attempt(queued.delivery).then((ok) => {
@@ -8262,7 +8742,7 @@ var createWebhookDispatcher = (endpoint) => {
8262
8742
  for (const [id, delivery] of [...deliveries]) {
8263
8743
  if (namespace !== void 0 && delivery.namespace !== namespace) continue;
8264
8744
  const queued = pending.get(id);
8265
- if (queued?.timer !== void 0) clearTimeout(queued.timer);
8745
+ if (queued?.timer !== void 0) cancel(queued.timer);
8266
8746
  pending.delete(id);
8267
8747
  deliveries.delete(id);
8268
8748
  }
@@ -9128,4 +9608,4 @@ export {
9128
9608
  JunctionAPI,
9129
9609
  LabAccountConflict
9130
9610
  };
9131
- //# sourceMappingURL=chunk-ICINEQ7D.js.map
9611
+ //# sourceMappingURL=chunk-MS774AJA.js.map