@jeffusion/bungee-core 6.4.1 → 6.5.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.
@@ -213,7 +213,7 @@ function copyJsonValue(value, path, context) {
213
213
  context.add("non_json_value", path, "Expected a JSON-compatible value");
214
214
  return;
215
215
  }
216
- function normalizePositions(values, path, context) {
216
+ function normalizePositions(values, path, context, itemPath = (index) => `${path}[${index}]`) {
217
217
  const reserved = new Set;
218
218
  const seen = new Set;
219
219
  for (const value of values) {
@@ -224,7 +224,7 @@ function normalizePositions(values, path, context) {
224
224
  return values.map((value, index) => {
225
225
  if (isObject(value) && typeof value.position === "number" && Number.isSafeInteger(value.position) && value.position >= 0) {
226
226
  if (seen.has(value.position)) {
227
- context.add("duplicate_position", `${path}[${index}].position`, "Position must be unique within its owner");
227
+ context.add("duplicate_position", `${itemPath(index)}.position`, "Position must be unique within its owner");
228
228
  }
229
229
  seen.add(value.position);
230
230
  return value.position;
@@ -566,8 +566,6 @@ function defaultMenuGroup(id) {
566
566
  }
567
567
  // src/config-storage/gateway-target-validation.ts
568
568
  function validateGatewayTargets(config, schemas, context) {
569
- if (!schemas)
570
- return;
571
569
  const routes = new Map(config.routes.map((route) => [route.id, route]));
572
570
  const services = new Map(config.services.map((service) => [service.id, service]));
573
571
  const edges = new Map;
@@ -593,39 +591,52 @@ function validateGatewayTargets(config, schemas, context) {
593
591
  plugins.forEach((plugin, index) => {
594
592
  if (!plugin.enabled)
595
593
  return;
596
- for (const field of schemas.get(plugin.name) ?? [])
594
+ for (const field of schemas?.get(plugin.name) ?? [])
597
595
  visit(plugin.options?.[field.name], field, owner, `${path}[${index}].options.${field.name}`);
598
596
  });
599
597
  };
600
- config.routes.forEach((route, index) => {
601
- const owner = `route:${route.id}`;
602
- bindings(route.plugins, owner, `routes[${index}].plugins`);
603
- if (route.service_id) {
598
+ const references = (values, owner, path) => {
599
+ for (const [index, reference] of (values ?? []).entries()) {
600
+ const target = `service:${reference.service_id}`;
601
+ if (!services.has(reference.service_id))
602
+ context.add("unknown_service", `${path}[${index}].service_id`, "Service does not exist");
603
+ if (target === owner)
604
+ context.add("invalid_value", `${path}[${index}].service_id`, "Service cannot reference itself");
604
605
  const refs = edges.get(owner) ?? new Set;
605
- refs.add(`service:${route.service_id}`);
606
+ refs.add(target);
606
607
  edges.set(owner, refs);
607
608
  }
608
- });
609
- config.services.forEach((service, index) => bindings(service.plugins, `service:${service.id}`, `services[${index}].plugins`));
610
- const active = new Set, done = new Set;
611
- const walk = (owner) => {
612
- if (active.has(owner))
613
- return false;
614
- if (done.has(owner))
615
- return true;
616
- active.add(owner);
617
- for (const target of edges.get(owner) ?? [])
618
- if (!walk(target))
619
- return false;
620
- active.delete(owner);
621
- done.add(owner);
622
- return true;
623
609
  };
624
- for (const owner of edges.keys())
625
- if (!walk(owner)) {
626
- context.add("invalid_value", "routes", "Internal dispatch cycle");
627
- break;
610
+ config.routes.forEach((route, index) => {
611
+ const owner = `route:${route.id}`;
612
+ bindings(route.plugins, owner, `routes[${index}].plugins`);
613
+ references(route.service_references, owner, `routes[${index}].service_references`);
614
+ route.endpoints?.forEach((upstream, i) => bindings(upstream.plugins, owner, `routes[${index}].endpoints[${i}].plugins`));
615
+ });
616
+ config.services.forEach((service, index) => {
617
+ const owner = `service:${service.id}`;
618
+ bindings(service.plugins, owner, `services[${index}].plugins`);
619
+ references(service.service_references, owner, `services[${index}].service_references`);
620
+ service.endpoints.forEach((upstream, i) => bindings(upstream.plugins, owner, `services[${index}].endpoints[${i}].plugins`));
621
+ });
622
+ const incoming = new Map;
623
+ for (const [owner, targets] of edges) {
624
+ if (!incoming.has(owner))
625
+ incoming.set(owner, 0);
626
+ for (const target of targets)
627
+ incoming.set(target, (incoming.get(target) ?? 0) + 1);
628
+ }
629
+ const ready = [...incoming].filter(([, count]) => count === 0).map(([owner]) => owner);
630
+ for (let index = 0;index < ready.length; index++) {
631
+ for (const target of edges.get(ready[index]) ?? []) {
632
+ const count = incoming.get(target) - 1;
633
+ incoming.set(target, count);
634
+ if (count === 0)
635
+ ready.push(target);
628
636
  }
637
+ }
638
+ if (ready.length !== incoming.size)
639
+ context.add("invalid_value", "routes", "Internal dispatch cycle");
629
640
  }
630
641
 
631
642
  // ../../node_modules/acorn/dist/acorn.mjs
@@ -6458,6 +6469,131 @@ function validateDirectionalModificationRules(object, path, context) {
6458
6469
  }
6459
6470
  }
6460
6471
 
6472
+ // src/config-storage/service-policy-validation.ts
6473
+ var HEALTH_FIELDS = [
6474
+ "enabled",
6475
+ "interval_ms",
6476
+ "timeout_ms",
6477
+ "path",
6478
+ "method",
6479
+ "expected_status",
6480
+ "unhealthy_threshold",
6481
+ "healthy_threshold",
6482
+ "body",
6483
+ "content_type",
6484
+ "headers",
6485
+ "query",
6486
+ "auto_enable_on_active_health_check"
6487
+ ];
6488
+ var FAILOVER_FIELDS = ["enabled", "retry_on", "retry_on_response", "passive_health", "recovery", "slow_start"];
6489
+ function validateHealth(value, path, context) {
6490
+ const object = objectField(value, path, HEALTH_FIELDS, context);
6491
+ if (!object)
6492
+ return;
6493
+ booleanField(object, "enabled", path, context);
6494
+ for (const field of ["interval_ms", "timeout_ms", "unhealthy_threshold", "healthy_threshold"]) {
6495
+ numberField(object, field, path, context, { positive: true });
6496
+ }
6497
+ for (const field of ["path", "method", "body", "content_type"])
6498
+ optionalString(object, field, path, context);
6499
+ if (object.expected_status !== undefined) {
6500
+ if (!Array.isArray(object.expected_status))
6501
+ context.add("invalid_type", `${path}.expected_status`, "Expected an array");
6502
+ else
6503
+ object.expected_status.forEach((item, index) => {
6504
+ validateStatus(item, `${path}.expected_status[${index}]`, context);
6505
+ });
6506
+ }
6507
+ if (object.headers !== undefined)
6508
+ stringRecord(object.headers, `${path}.headers`, context);
6509
+ if (object.query !== undefined)
6510
+ stringRecord(object.query, `${path}.query`, context);
6511
+ optionalBoolean(object, "auto_enable_on_active_health_check", path, context);
6512
+ }
6513
+ function validateStatus(value, path, context) {
6514
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 100 || value > 599) {
6515
+ context.add("invalid_value", path, "Expected an HTTP status");
6516
+ }
6517
+ }
6518
+ function validateRetryOn(value, path, context) {
6519
+ const valid = (item) => typeof item === "string" || typeof item === "number" && Number.isFinite(item);
6520
+ if (Array.isArray(value)) {
6521
+ value.forEach((item, index) => {
6522
+ if (!valid(item))
6523
+ context.add("invalid_type", `${path}[${index}]`, "Expected a string or number");
6524
+ });
6525
+ } else if (!valid(value))
6526
+ context.add("invalid_type", path, "Expected a string, number, or array");
6527
+ }
6528
+ function validatePositiveObject(value, path, fields, context) {
6529
+ const object = objectField(value, path, fields, context);
6530
+ if (object)
6531
+ fields.forEach((field) => {
6532
+ numberField(object, field, path, context, { positive: true });
6533
+ });
6534
+ return object;
6535
+ }
6536
+ function validateFailover(value, path, context) {
6537
+ const object = objectField(value, path, FAILOVER_FIELDS, context);
6538
+ if (!object)
6539
+ return;
6540
+ booleanField(object, "enabled", path, context);
6541
+ if (object.retry_on !== undefined)
6542
+ validateRetryOn(object.retry_on, `${path}.retry_on`, context);
6543
+ if (object.retry_on_response !== undefined)
6544
+ stringArray(object.retry_on_response, `${path}.retry_on_response`, context);
6545
+ validatePositiveObject(object.passive_health, `${path}.passive_health`, ["consecutive_failures", "healthy_successes", "auto_disable_threshold"], context);
6546
+ validatePositiveObject(object.recovery, `${path}.recovery`, ["backoff_base_ms", "probe_timeout_ms"], context);
6547
+ const slow = objectField(object.slow_start, `${path}.slow_start`, ["enabled", "duration_ms", "initial_weight_factor"], context);
6548
+ if (!slow)
6549
+ return;
6550
+ booleanField(slow, "enabled", `${path}.slow_start`, context);
6551
+ numberField(slow, "duration_ms", `${path}.slow_start`, context, { positive: true });
6552
+ const factor = slow.initial_weight_factor;
6553
+ if (factor !== undefined && (typeof factor !== "number" || !Number.isFinite(factor) || factor <= 0 || factor > 1)) {
6554
+ context.add("invalid_value", `${path}.slow_start.initial_weight_factor`, "Expected a finite number in (0, 1]");
6555
+ }
6556
+ }
6557
+ function validateLoadBalancing(value, path, context) {
6558
+ const object = objectField(value, path, ["policy", "hash_policy"], context);
6559
+ if (!object)
6560
+ return;
6561
+ const policies = new Set(["weighted_random", "round_robin", "least_requests", "consistent_hash"]);
6562
+ if (typeof object.policy !== "string" || !policies.has(object.policy)) {
6563
+ context.add("invalid_load_balancing", `${path}.policy`, "Unsupported load balancing policy");
6564
+ }
6565
+ const hash = objectField(object.hash_policy, `${path}.hash_policy`, ["header", "expression"], context);
6566
+ if (hash) {
6567
+ optionalString(hash, "header", `${path}.hash_policy`, context);
6568
+ validateExpressionSyntax(hash.expression, `${path}.hash_policy.expression`, context);
6569
+ }
6570
+ if (object.policy === "consistent_hash") {
6571
+ const header = typeof hash?.header === "string" ? hash.header.trim() : "";
6572
+ const expression = typeof hash?.expression === "string" ? hash.expression.trim() : "";
6573
+ const headerShapeValid = hash?.header === undefined || typeof hash.header === "string";
6574
+ const expressionShapeValid = hash?.expression === undefined || typeof hash.expression === "string";
6575
+ if (!header && !expression && headerShapeValid && expressionShapeValid) {
6576
+ context.add("invalid_load_balancing", `${path}.hash_policy`, "Hash input is required");
6577
+ }
6578
+ }
6579
+ }
6580
+ function validateServicePolicies(object, path, context) {
6581
+ validateHealth(object.health_check, `${path}.health_check`, context);
6582
+ validateFailover(object.failover, `${path}.failover`, context);
6583
+ validateLoadBalancing(object.load_balancing, `${path}.load_balancing`, context);
6584
+ }
6585
+ function validateServiceSelectionPolicies(object, path, context) {
6586
+ validateLoadBalancing(object.load_balancing, `${path}.load_balancing`, context);
6587
+ const failover = objectField(object.failover, `${path}.failover`, ["enabled", "retry_on", "retry_on_response"], context);
6588
+ if (!failover)
6589
+ return;
6590
+ booleanField(failover, "enabled", `${path}.failover`, context);
6591
+ if (failover.retry_on !== undefined)
6592
+ validateRetryOn(failover.retry_on, `${path}.failover.retry_on`, context);
6593
+ if (failover.retry_on_response !== undefined)
6594
+ stringArray(failover.retry_on_response, `${path}.failover.retry_on_response`, context);
6595
+ }
6596
+
6461
6597
  // src/config-storage/route-policy-validation.ts
6462
6598
  function validatePathRewrite(value, path, context) {
6463
6599
  if (value === undefined)
@@ -6611,6 +6747,7 @@ function validateRetry(value, path, context) {
6611
6747
  numberField(object, "per_retry_timeout_ms", path, context, { positive: true });
6612
6748
  }
6613
6749
  function validateRoutePolicies(object, path, context) {
6750
+ validateServiceSelectionPolicies(object, path, context);
6614
6751
  validatePathRewrite(object.path_rewrite, `${path}.path_rewrite`, context);
6615
6752
  validateAuth(object.auth, `${path}.auth`, context);
6616
6753
  const websocket = objectField(object.websocket, `${path}.websocket`, ["enabled"], context);
@@ -6644,120 +6781,6 @@ function validateRoutePolicies(object, path, context) {
6644
6781
  validateRetry(object.retry, `${path}.retry`, context);
6645
6782
  }
6646
6783
 
6647
- // src/config-storage/service-policy-validation.ts
6648
- var HEALTH_FIELDS = [
6649
- "enabled",
6650
- "interval_ms",
6651
- "timeout_ms",
6652
- "path",
6653
- "method",
6654
- "expected_status",
6655
- "unhealthy_threshold",
6656
- "healthy_threshold",
6657
- "body",
6658
- "content_type",
6659
- "headers",
6660
- "query",
6661
- "auto_enable_on_active_health_check"
6662
- ];
6663
- var FAILOVER_FIELDS = ["enabled", "retry_on", "retry_on_response", "passive_health", "recovery", "slow_start"];
6664
- function validateHealth(value, path, context) {
6665
- const object = objectField(value, path, HEALTH_FIELDS, context);
6666
- if (!object)
6667
- return;
6668
- booleanField(object, "enabled", path, context);
6669
- for (const field of ["interval_ms", "timeout_ms", "unhealthy_threshold", "healthy_threshold"]) {
6670
- numberField(object, field, path, context, { positive: true });
6671
- }
6672
- for (const field of ["path", "method", "body", "content_type"])
6673
- optionalString(object, field, path, context);
6674
- if (object.expected_status !== undefined) {
6675
- if (!Array.isArray(object.expected_status))
6676
- context.add("invalid_type", `${path}.expected_status`, "Expected an array");
6677
- else
6678
- object.expected_status.forEach((item, index) => {
6679
- validateStatus(item, `${path}.expected_status[${index}]`, context);
6680
- });
6681
- }
6682
- if (object.headers !== undefined)
6683
- stringRecord(object.headers, `${path}.headers`, context);
6684
- if (object.query !== undefined)
6685
- stringRecord(object.query, `${path}.query`, context);
6686
- optionalBoolean(object, "auto_enable_on_active_health_check", path, context);
6687
- }
6688
- function validateStatus(value, path, context) {
6689
- if (typeof value !== "number" || !Number.isInteger(value) || value < 100 || value > 599) {
6690
- context.add("invalid_value", path, "Expected an HTTP status");
6691
- }
6692
- }
6693
- function validateRetryOn(value, path, context) {
6694
- const valid = (item) => typeof item === "string" || typeof item === "number" && Number.isFinite(item);
6695
- if (Array.isArray(value)) {
6696
- value.forEach((item, index) => {
6697
- if (!valid(item))
6698
- context.add("invalid_type", `${path}[${index}]`, "Expected a string or number");
6699
- });
6700
- } else if (!valid(value))
6701
- context.add("invalid_type", path, "Expected a string, number, or array");
6702
- }
6703
- function validatePositiveObject(value, path, fields, context) {
6704
- const object = objectField(value, path, fields, context);
6705
- if (object)
6706
- fields.forEach((field) => {
6707
- numberField(object, field, path, context, { positive: true });
6708
- });
6709
- return object;
6710
- }
6711
- function validateFailover(value, path, context) {
6712
- const object = objectField(value, path, FAILOVER_FIELDS, context);
6713
- if (!object)
6714
- return;
6715
- booleanField(object, "enabled", path, context);
6716
- if (object.retry_on !== undefined)
6717
- validateRetryOn(object.retry_on, `${path}.retry_on`, context);
6718
- if (object.retry_on_response !== undefined)
6719
- stringArray(object.retry_on_response, `${path}.retry_on_response`, context);
6720
- validatePositiveObject(object.passive_health, `${path}.passive_health`, ["consecutive_failures", "healthy_successes", "auto_disable_threshold"], context);
6721
- validatePositiveObject(object.recovery, `${path}.recovery`, ["backoff_base_ms", "probe_timeout_ms"], context);
6722
- const slow = objectField(object.slow_start, `${path}.slow_start`, ["enabled", "duration_ms", "initial_weight_factor"], context);
6723
- if (!slow)
6724
- return;
6725
- booleanField(slow, "enabled", `${path}.slow_start`, context);
6726
- numberField(slow, "duration_ms", `${path}.slow_start`, context, { positive: true });
6727
- const factor = slow.initial_weight_factor;
6728
- if (factor !== undefined && (typeof factor !== "number" || !Number.isFinite(factor) || factor <= 0 || factor > 1)) {
6729
- context.add("invalid_value", `${path}.slow_start.initial_weight_factor`, "Expected a finite number in (0, 1]");
6730
- }
6731
- }
6732
- function validateLoadBalancing(value, path, context) {
6733
- const object = objectField(value, path, ["policy", "hash_policy"], context);
6734
- if (!object)
6735
- return;
6736
- const policies = new Set(["weighted_random", "round_robin", "least_requests", "consistent_hash"]);
6737
- if (typeof object.policy !== "string" || !policies.has(object.policy)) {
6738
- context.add("invalid_load_balancing", `${path}.policy`, "Unsupported load balancing policy");
6739
- }
6740
- const hash = objectField(object.hash_policy, `${path}.hash_policy`, ["header", "expression"], context);
6741
- if (hash) {
6742
- optionalString(hash, "header", `${path}.hash_policy`, context);
6743
- validateExpressionSyntax(hash.expression, `${path}.hash_policy.expression`, context);
6744
- }
6745
- if (object.policy === "consistent_hash") {
6746
- const header = typeof hash?.header === "string" ? hash.header.trim() : "";
6747
- const expression = typeof hash?.expression === "string" ? hash.expression.trim() : "";
6748
- const headerShapeValid = hash?.header === undefined || typeof hash.header === "string";
6749
- const expressionShapeValid = hash?.expression === undefined || typeof hash.expression === "string";
6750
- if (!header && !expression && headerShapeValid && expressionShapeValid) {
6751
- context.add("invalid_load_balancing", `${path}.hash_policy`, "Hash input is required");
6752
- }
6753
- }
6754
- }
6755
- function validateServicePolicies(object, path, context) {
6756
- validateHealth(object.health_check, `${path}.health_check`, context);
6757
- validateFailover(object.failover, `${path}.failover`, context);
6758
- validateLoadBalancing(object.load_balancing, `${path}.load_balancing`, context);
6759
- }
6760
-
6761
6784
  // src/config-storage/domain-validation.ts
6762
6785
  var AUTH_FIELDS = ["enabled", "tokens"];
6763
6786
  function validateAuth(value, path, context) {
@@ -6811,7 +6834,7 @@ function validateUpstreamDomain(object, path, context) {
6811
6834
  }
6812
6835
  function validateServiceDomain(object, path, endpointCount, context) {
6813
6836
  if (endpointCount === 0)
6814
- context.add("empty_service", `${path}.endpoints`, "Service requires at least one upstream");
6837
+ context.add("empty_service", `${path}.endpoints`, "Service requires at least one upstream or service reference");
6815
6838
  validateServicePolicies(object, path, context);
6816
6839
  }
6817
6840
  function validateRouteDomain(object, path, hasService, endpointCount, context) {
@@ -7234,7 +7257,9 @@ var ROUTE_KEYS = [
7234
7257
  "direct_response",
7235
7258
  "redirect",
7236
7259
  "retry",
7237
- "websocket"
7260
+ "websocket",
7261
+ "load_balancing",
7262
+ "failover"
7238
7263
  ];
7239
7264
  var UPSTREAM_KEYS = ["request", "response", "description", "condition"];
7240
7265
  var ENTITY_KEYS = ["id", "position", "plugins"];
@@ -7243,8 +7268,8 @@ function allowed(...groups) {
7243
7268
  }
7244
7269
  var ROOT_FIELDS = allowed(GLOBAL_KEYS, ["services", "routes", "plugins"]);
7245
7270
  var PLUGIN_FIELDS = allowed(ENTITY_KEYS, ["name", "options", "enabled", "path"]);
7246
- var SERVICE_FIELDS = allowed(ENTITY_KEYS, SERVICE_KEYS, ["name", "endpoints"]);
7247
- var ROUTE_FIELDS = allowed(ENTITY_KEYS, ROUTE_KEYS, ["path", "service_id", "service", "endpoints"]);
7271
+ var SERVICE_FIELDS = allowed(ENTITY_KEYS, SERVICE_KEYS, ["name", "endpoints", "service_references"]);
7272
+ var ROUTE_FIELDS = allowed(ENTITY_KEYS, ROUTE_KEYS, ["path", "service_references", "endpoints"]);
7248
7273
  var UPSTREAM_FIELDS = allowed(ENTITY_KEYS, UPSTREAM_KEYS, [
7249
7274
  "target",
7250
7275
  "weight",
@@ -7339,10 +7364,10 @@ function booleanDefault(object, key, fallback, path, context) {
7339
7364
  context.add("invalid_type", `${path}.${key}`, "Expected a boolean");
7340
7365
  return fallback;
7341
7366
  }
7342
- function parseUpstreams(value, path, catalog, context, present = false) {
7367
+ function parseUpstreams(value, path, catalog, context, present = false, sharedPositions) {
7343
7368
  const upstreams = [];
7344
7369
  const candidates = context.array(value, path, present);
7345
- const positions = normalizePositions(candidates, path, context);
7370
+ const positions = sharedPositions ?? normalizePositions(candidates, path, context);
7346
7371
  candidates.forEach((candidate, index) => {
7347
7372
  const itemPath = `${path}[${index}]`;
7348
7373
  const object = context.object(candidate, itemPath);
@@ -7370,13 +7395,44 @@ function parseUpstreams(value, path, catalog, context, present = false) {
7370
7395
  });
7371
7396
  return upstreams.sort((left, right) => left.position - right.position);
7372
7397
  }
7373
- function parseServices(value, catalog, context) {
7374
- const services = [];
7375
- const names = new Set;
7376
- const candidates = context.array(value, "services", value !== undefined);
7377
- const positions = normalizePositions(candidates, "services", context);
7378
- candidates.forEach((candidate, index) => {
7379
- const path = `services[${index}]`;
7398
+ var REFERENCE_FIELDS = new Set(["id", "position", "service_id", "weight", "priority", "is_disabled"]);
7399
+ function parseReferences(value, path, context, positions, present = false) {
7400
+ const candidates = context.array(value, path, present);
7401
+ const normalized = positions ?? normalizePositions(candidates, path, context);
7402
+ const targets = new Set;
7403
+ return candidates.flatMap((candidate, index) => {
7404
+ const itemPath = `${path}[${index}]`;
7405
+ const object = context.object(candidate, itemPath);
7406
+ if (!object)
7407
+ return [];
7408
+ rejectUnknownFields(object, REFERENCE_FIELDS, itemPath, context);
7409
+ const serviceId = context.string(object, "service_id", `${itemPath}.service_id`);
7410
+ if (!isLowercaseUuid(serviceId))
7411
+ context.add("invalid_uuid", `${itemPath}.service_id`, "Expected a lowercase UUID");
7412
+ if (targets.has(serviceId))
7413
+ context.add("invalid_value", `${itemPath}.service_id`, "Service reference must be unique within its owner");
7414
+ targets.add(serviceId);
7415
+ for (const key of ["weight", "priority"])
7416
+ if (typeof object[key] === "number" && object[key] <= 0) {
7417
+ context.add("invalid_positive_number", `${itemPath}.${key}`, "Expected a positive number");
7418
+ }
7419
+ return [{
7420
+ id: context.id(object, `${itemPath}.id`),
7421
+ position: context.position(object, normalized[index], `${itemPath}.position`),
7422
+ service_id: serviceId,
7423
+ weight: numberDefault(object, "weight", 100, itemPath, context),
7424
+ priority: numberDefault(object, "priority", 1, itemPath, context),
7425
+ is_disabled: booleanDefault(object, "is_disabled", false, itemPath, context)
7426
+ }];
7427
+ }).sort((left, right) => left.position - right.position);
7428
+ }
7429
+ function parseServices(value, catalog, context) {
7430
+ const services = [];
7431
+ const names = new Set;
7432
+ const candidates = context.array(value, "services", value !== undefined);
7433
+ const positions = normalizePositions(candidates, "services", context);
7434
+ candidates.forEach((candidate, index) => {
7435
+ const path = `services[${index}]`;
7380
7436
  const object = context.object(candidate, path);
7381
7437
  if (!object)
7382
7438
  return;
@@ -7387,7 +7443,11 @@ function parseServices(value, catalog, context) {
7387
7443
  context.add("duplicate_name", `${path}.name`, "Service name must be unique");
7388
7444
  else
7389
7445
  names.add(name);
7390
- const endpointCount = Array.isArray(object.endpoints) ? object.endpoints.length : 0;
7446
+ const endpointCandidates = Array.isArray(object.endpoints) ? object.endpoints : [];
7447
+ const referenceCandidates = Array.isArray(object.service_references) ? object.service_references : [];
7448
+ const sharedPositions = normalizePositions([...endpointCandidates, ...referenceCandidates], path, context, (index) => index < endpointCandidates.length ? `${path}.endpoints[${index}]` : `${path}.service_references[${index - endpointCandidates.length}]`);
7449
+ const references = parseReferences(object.service_references, `${path}.service_references`, context, sharedPositions.slice(endpointCandidates.length), "service_references" in object);
7450
+ const endpointCount = endpointCandidates.length + references.length;
7391
7451
  const policies = copyKnown(object, SERVICE_KEYS, path, context);
7392
7452
  validateServiceDomain(object, path, endpointCount, context);
7393
7453
  services.push({
@@ -7395,13 +7455,14 @@ function parseServices(value, catalog, context) {
7395
7455
  id,
7396
7456
  position: context.position(object, positions[index], `${path}.position`),
7397
7457
  name,
7398
- endpoints: parseUpstreams(object.endpoints, `${path}.endpoints`, catalog, context, "endpoints" in object),
7458
+ endpoints: parseUpstreams(object.endpoints, `${path}.endpoints`, catalog, context, "endpoints" in object, sharedPositions),
7459
+ ...references.length ? { service_references: references } : {},
7399
7460
  plugins: parsePlugins(object.plugins, `${path}.plugins`, catalog, context, "plugins" in object)
7400
7461
  });
7401
7462
  });
7402
7463
  return services.sort((left, right) => left.position - right.position);
7403
7464
  }
7404
- function parseRoutes(value, serviceIds, catalog, context) {
7465
+ function parseRoutes(value, catalog, context) {
7405
7466
  const routes = [];
7406
7467
  const paths = new Set;
7407
7468
  const candidates = context.array(value, "routes", value !== undefined);
@@ -7418,14 +7479,13 @@ function parseRoutes(value, serviceIds, catalog, context) {
7418
7479
  context.add("duplicate_path", `${itemPath}.path`, "Route path must be unique");
7419
7480
  else
7420
7481
  paths.add(path);
7421
- const serviceId = object.service_id;
7422
- const hasService = serviceId !== undefined;
7482
+ const references = parseReferences(object.service_references, `${itemPath}.service_references`, context, undefined, "service_references" in object);
7483
+ const hasService = references.length > 0;
7484
+ if ("service_references" in object && !hasService)
7485
+ context.add("invalid_value", `${itemPath}.service_references`, "Service route requires at least one reference");
7423
7486
  const endpointCount = Array.isArray(object.endpoints) ? object.endpoints.length : 0;
7424
7487
  const policies = copyKnown(object, ROUTE_KEYS, itemPath, context);
7425
7488
  validateRouteDomain(object, itemPath, hasService, endpointCount, context);
7426
- validateServiceReference(serviceId, itemPath, serviceIds, context);
7427
- if ("service" in object)
7428
- context.add("invalid_value", `${itemPath}.service`, "Use service_id");
7429
7489
  if (hasService && object.endpoints !== undefined) {
7430
7490
  context.add("conflicting_owner", `${itemPath}.endpoints`, "A service route cannot own upstreams");
7431
7491
  }
@@ -7437,22 +7497,12 @@ function parseRoutes(value, serviceIds, catalog, context) {
7437
7497
  plugins: parsePlugins(object.plugins, `${itemPath}.plugins`, catalog, context, "plugins" in object)
7438
7498
  };
7439
7499
  if (hasService)
7440
- routes.push({ ...base, service_id: typeof serviceId === "string" ? serviceId : "" });
7500
+ routes.push({ ...base, service_references: references });
7441
7501
  else
7442
7502
  routes.push({ ...base, endpoints: parseUpstreams(object.endpoints, `${itemPath}.endpoints`, catalog, context, "endpoints" in object) });
7443
7503
  });
7444
7504
  return routes.sort((left, right) => left.position - right.position);
7445
7505
  }
7446
- function validateServiceReference(value, path, serviceIds, context) {
7447
- if (value === undefined)
7448
- return;
7449
- if (typeof value !== "string")
7450
- context.add("invalid_type", `${path}.service_id`, "Expected a UUID string");
7451
- else if (!isLowercaseUuid(value))
7452
- context.add("invalid_uuid", `${path}.service_id`, "Expected a lowercase UUID");
7453
- else if (!serviceIds.has(value))
7454
- context.add("unknown_service", `${path}.service_id`, "Service does not exist");
7455
- }
7456
7506
  function parseNormalizeCompile(input, options) {
7457
7507
  const context = new ValidationContext;
7458
7508
  const safeInput = preflightJsonGraph(input, context);
@@ -7474,11 +7524,10 @@ function parseNormalizeCompile(input, options) {
7474
7524
  const catalog = options?.availablePlugins === undefined ? options?.pluginSchemas : new Map([...options.pluginSchemas].filter(([name]) => options.availablePlugins?.has(name)));
7475
7525
  const plugins = parsePlugins(root.plugins, "plugins", catalog, context, "plugins" in root);
7476
7526
  const services = parseServices(root.services, catalog, context);
7477
- const serviceIds = new Set(services.map(({ id }) => id));
7478
7527
  const value = {
7479
7528
  ...policies,
7480
7529
  services,
7481
- routes: parseRoutes(root.routes, serviceIds, catalog, context),
7530
+ routes: parseRoutes(root.routes, catalog, context),
7482
7531
  plugins
7483
7532
  };
7484
7533
  validateGatewayTargets(value, catalog, context);
@@ -8676,16 +8725,12 @@ function writeUpstreams(db, upstreams, owner) {
8676
8725
  }
8677
8726
  }
8678
8727
  function servicePolicy(service) {
8679
- const { id, position, name, endpoints, plugins, ...policy } = service;
8728
+ const { id, position, name, endpoints, service_references, plugins, ...policy } = service;
8680
8729
  return policy;
8681
8730
  }
8682
8731
  function routePolicy(route) {
8683
8732
  const { id, position, path, plugins, ...remainder } = route;
8684
- if ("service_id" in remainder) {
8685
- const { service_id, ...policy } = remainder;
8686
- return policy;
8687
- }
8688
- const { endpoints, ...policy } = remainder;
8733
+ const { endpoints, service_references, ...policy } = remainder;
8689
8734
  return policy;
8690
8735
  }
8691
8736
  function writeServices(db, services) {
@@ -8702,12 +8747,10 @@ function writeServices(db, services) {
8702
8747
  }
8703
8748
  function writeRoutes(db, routes) {
8704
8749
  for (const route of routes) {
8705
- const serviceId = typeof route.service_id === "string" ? route.service_id : null;
8706
- db.run("INSERT INTO routes (id,position,path,service_id,policy_json) VALUES (?,?,?,?,?)", [
8750
+ db.run("INSERT INTO routes (id,position,path,policy_json) VALUES (?,?,?,?)", [
8707
8751
  route.id,
8708
8752
  route.position,
8709
8753
  route.path,
8710
- serviceId,
8711
8754
  canonicalJson(routePolicy(route))
8712
8755
  ]);
8713
8756
  if (route.endpoints !== undefined)
@@ -8716,6 +8759,8 @@ function writeRoutes(db, routes) {
8716
8759
  }
8717
8760
  }
8718
8761
  function replaceActiveMaterialization(db, aggregate) {
8762
+ if (db.query("SELECT 1 FROM sqlite_schema WHERE name='service_references'").get())
8763
+ db.run("DELETE FROM service_references");
8719
8764
  db.run("DELETE FROM plugin_bindings");
8720
8765
  db.run("DELETE FROM plugin_activations");
8721
8766
  db.run("DELETE FROM upstreams");
@@ -8736,11 +8781,30 @@ function replaceActiveMaterialization(db, aggregate) {
8736
8781
  ]);
8737
8782
  writeServices(db, logical.services);
8738
8783
  writeRoutes(db, logical.routes);
8784
+ for (const service of logical.services)
8785
+ writeReferences(db, service.service_references ?? [], "service", service.id);
8786
+ for (const route of logical.routes)
8787
+ writeReferences(db, route.service_references ?? [], "route", route.id);
8739
8788
  writeBindings(db, logical.plugins, { kind: "global", id: "" });
8740
8789
  for (const activation of aggregate.plugin_activations) {
8741
8790
  db.run("INSERT INTO plugin_activations (plugin_name) VALUES (?)", [activation.plugin_name]);
8742
8791
  }
8743
8792
  }
8793
+ function writeReferences(db, references, ownerKind, ownerId) {
8794
+ for (const ref of references)
8795
+ db.run(`INSERT INTO service_references
8796
+ (id,owner_kind,owner_service_id,route_id,position,service_id,weight,priority,is_disabled) VALUES(?,?,?,?,?,?,?,?,?)`, [
8797
+ ref.id,
8798
+ ownerKind,
8799
+ ownerKind === "service" ? ownerId : null,
8800
+ ownerKind === "route" ? ownerId : null,
8801
+ ref.position,
8802
+ ref.service_id,
8803
+ ref.weight,
8804
+ ref.priority,
8805
+ ref.is_disabled ? 1 : 0
8806
+ ]);
8807
+ }
8744
8808
 
8745
8809
  // src/config-storage/recovery-store.ts
8746
8810
  var RECOVERY_MAX_ATTEMPTS = 6;
@@ -9279,520 +9343,261 @@ function commitTransaction(db, prepared, options, snapshot, offlineManagement =
9279
9343
  } };
9280
9344
  }
9281
9345
 
9282
- // src/config-storage/schema-fingerprint.ts
9283
- import { Database } from "bun:sqlite";
9346
+ // src/config-storage/service-reference-upgrade.ts
9347
+ import { randomUUID as randomUUID2 } from "crypto";
9284
9348
 
9285
- // src/database-schema.ts
9286
- function pragmaName(name) {
9287
- return `'${name.replaceAll("'", "''")}'`;
9349
+ // src/config-storage/persisted-json.ts
9350
+ function parseCanonicalObject(text, field, reserved = new Set) {
9351
+ try {
9352
+ const value = JSON.parse(text);
9353
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
9354
+ throw new TypeError("expected a JSON object");
9355
+ }
9356
+ if (text !== canonicalJson(value))
9357
+ throw new TypeError("expected canonical JSON text");
9358
+ const entries = Object.entries(value);
9359
+ const key = entries.find(([name]) => reserved.has(name))?.[0];
9360
+ if (key !== undefined)
9361
+ throw new TypeError(`reserved key ${key}`);
9362
+ return Object.fromEntries(entries);
9363
+ } catch (error) {
9364
+ throw new ConfigRepositoryError("schema_corrupt", `${field} is invalid`, error);
9365
+ }
9288
9366
  }
9289
- function normalizedSql(sql) {
9290
- return (sql ?? "").match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`[^`]*`|\[[^\]]*\]|[^'"`\[]+/g)?.map((token) => /^['"`\[]/.test(token) ? token : token.replace(/\s+/g, " ")).join("").trim() ?? "";
9367
+
9368
+ // src/config-storage/relation-materialization.ts
9369
+ var UPSTREAM_RESERVED = new Set(["id", "position", "target", "weight", "priority", "is_disabled", "plugins"]);
9370
+ function bindingValue(row) {
9371
+ requireUuid(row.id, "plugin_bindings.id");
9372
+ const scopeKind = requireOneOf(row.scope_kind, ["global", "service", "route", "upstream"], "plugin_bindings.scope_kind");
9373
+ const compatibleOwner = scopeKind === "global" ? row.scope_owner === "" && row.service_id === null && row.route_id === null && row.upstream_id === null : scopeKind === "service" ? row.scope_owner === row.service_id && row.service_id !== null && row.route_id === null && row.upstream_id === null : scopeKind === "route" ? row.scope_owner === row.route_id && row.route_id !== null && row.service_id === null && row.upstream_id === null : row.scope_owner === row.upstream_id && row.upstream_id !== null && row.service_id === null && row.route_id === null;
9374
+ if (!compatibleOwner)
9375
+ throw new ConfigRepositoryError("schema_corrupt", "plugin binding owner is invalid");
9376
+ if (scopeKind !== "global")
9377
+ requireUuid(row.scope_owner, "plugin_bindings.scope_owner");
9378
+ return {
9379
+ id: row.id,
9380
+ position: requireSafeInteger(row.position, "plugin_bindings.position"),
9381
+ name: requirePluginName(row.plugin_name, "plugin_bindings.plugin_name"),
9382
+ ...row.options_json === null ? {} : { options: parseCanonicalObject(row.options_json, "options_json") },
9383
+ enabled: requireBooleanInteger(row.enabled, "plugin_bindings.enabled")
9384
+ };
9291
9385
  }
9292
- function indexDescriptor(db, table) {
9293
- const indexes = sqliteAll(db, 'SELECT name,"unique",origin,partial FROM pragma_index_list(?)', table);
9294
- return indexes.map((index) => ({
9295
- unique: index.unique,
9296
- origin: index.origin,
9297
- partial: index.partial,
9298
- columns: sqliteAll(db, `PRAGMA index_xinfo(${pragmaName(index.name)})`).map(({ seqno, cid, name, desc, coll, key }) => ({ seqno, cid, name, desc, coll, key }))
9299
- })).sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right)));
9386
+ function groupBindings(rows) {
9387
+ const groups = new Map;
9388
+ for (const row of rows) {
9389
+ const key = `${row.scope_kind}:${row.scope_owner}`;
9390
+ const values = groups.get(key) ?? [];
9391
+ values.push(bindingValue(row));
9392
+ groups.set(key, values);
9393
+ }
9394
+ return groups;
9300
9395
  }
9301
- function databaseSchemaDescriptor(db) {
9302
- const definitions = new Map(sqliteAll(db, `SELECT name,sql FROM sqlite_schema
9303
- WHERE type='table' AND name NOT LIKE 'sqlite_%'`).map((row) => [row.name, row.sql]));
9304
- const tables = sqliteAll(db, `SELECT name,type,ncol,wr,strict FROM pragma_table_list
9305
- WHERE schema='main' AND name NOT LIKE 'sqlite_%' ORDER BY name`);
9306
- const objects = sqliteAll(db, `SELECT type,name,tbl_name,sql
9307
- FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`).map((row) => ({
9308
- type: row.type,
9396
+ function takeGroup(groups, key) {
9397
+ const values = groups.get(key) ?? [];
9398
+ groups.delete(key);
9399
+ return values;
9400
+ }
9401
+ function groupUpstreams(rows, bindings) {
9402
+ const groups = new Map;
9403
+ for (const row of rows) {
9404
+ requireUuid(row.id, "upstreams.id");
9405
+ const ownerKind = requireOneOf(row.owner_kind, ["service", "route"], "upstreams.owner_kind");
9406
+ if (ownerKind === "service" && row.route_id !== null || ownerKind === "route" && row.service_id !== null) {
9407
+ throw new ConfigRepositoryError("schema_corrupt", "upstream has incompatible owners");
9408
+ }
9409
+ const ownerId = ownerKind === "service" ? row.service_id : row.route_id;
9410
+ if (ownerId === null)
9411
+ throw new ConfigRepositoryError("schema_corrupt", "upstream owner is missing");
9412
+ requireUuid(ownerId, "upstreams.owner_id");
9413
+ const key = `${ownerKind}:${ownerId}`;
9414
+ const values = groups.get(key) ?? [];
9415
+ values.push({
9416
+ ...parseCanonicalObject(row.policy_json, "upstream policy_json", UPSTREAM_RESERVED),
9417
+ id: row.id,
9418
+ position: requireSafeInteger(row.position, "upstreams.position"),
9419
+ target: row.target,
9420
+ weight: requirePositiveFinite(row.weight, "upstreams.weight"),
9421
+ priority: requirePositiveFinite(row.priority, "upstreams.priority"),
9422
+ is_disabled: requireBooleanInteger(row.is_disabled, "upstreams.is_disabled"),
9423
+ plugins: takeGroup(bindings, `upstream:${row.id}`)
9424
+ });
9425
+ groups.set(key, values);
9426
+ }
9427
+ return groups;
9428
+ }
9429
+
9430
+ // src/config-storage/read-materialization.ts
9431
+ var SERVICE_RESERVED = new Set(["id", "position", "name", "endpoints", "service_references", "plugins"]);
9432
+ var ROUTE_RESERVED = new Set(["id", "position", "path", "service_id", "service", "service_references", "endpoints", "plugins"]);
9433
+ function readSettings(db) {
9434
+ const hasPublication = sqliteAll(db, "PRAGMA table_info(settings)").some(({ name }) => name === "publication_json");
9435
+ const rows = sqliteAll(db, `SELECT log_level,body_parser_limit,auth_json,logging_json${hasPublication ? ",publication_json" : ",NULL AS publication_json"} FROM settings`);
9436
+ const row = rows[0];
9437
+ if (rows.length !== 1 || row === undefined)
9438
+ throw new ConfigRepositoryError("schema_corrupt", "settings singleton is broken");
9439
+ return {
9440
+ ...row.log_level === null ? {} : { log_level: requireOptionalGlobalScalar(row.log_level, "log_level") },
9441
+ ...row.body_parser_limit === null ? {} : {
9442
+ body_parser_limit: requireOptionalGlobalScalar(row.body_parser_limit, "body_parser_limit")
9443
+ },
9444
+ ...row.auth_json === null ? {} : { auth: parseCanonicalObject(row.auth_json, "auth_json") },
9445
+ ...row.logging_json === null ? {} : { logging: parseCanonicalObject(row.logging_json, "logging_json") },
9446
+ ...row.publication_json === null ? {} : { publication: parseCanonicalObject(row.publication_json, "publication_json") }
9447
+ };
9448
+ }
9449
+ function readRawActiveAggregate(db) {
9450
+ const references = new Map;
9451
+ for (const row of sqliteAll(db, "SELECT id,owner_kind,owner_service_id,route_id,position,service_id,weight,priority,is_disabled FROM service_references ORDER BY owner_kind,position")) {
9452
+ const ownerKind = requireOneOf(row.owner_kind, ["service", "route"], "service_references.owner_kind");
9453
+ const ownerId = ownerKind === "service" ? row.owner_service_id : row.route_id;
9454
+ if (ownerId === null || (ownerKind === "service" ? row.route_id !== null : row.owner_service_id !== null)) {
9455
+ throw new ConfigRepositoryError("schema_corrupt", "service reference owner is invalid");
9456
+ }
9457
+ const key = `${ownerKind}:${requireUuid(ownerId, "service_references.owner_id")}`;
9458
+ const values = references.get(key) ?? [];
9459
+ values.push({
9460
+ id: requireUuid(row.id, "service_references.id"),
9461
+ position: requireSafeInteger(row.position, "service_references.position"),
9462
+ service_id: requireUuid(row.service_id, "service_references.service_id"),
9463
+ weight: requirePositiveFinite(row.weight, "service_references.weight"),
9464
+ priority: requirePositiveFinite(row.priority, "service_references.priority"),
9465
+ is_disabled: requireBooleanInteger(row.is_disabled, "service_references.is_disabled")
9466
+ });
9467
+ references.set(key, values);
9468
+ }
9469
+ const takeReferences = (key) => {
9470
+ const values = references.get(key);
9471
+ references.delete(key);
9472
+ return values?.length ? { service_references: values } : {};
9473
+ };
9474
+ const bindings = groupBindings(sqliteAll(db, `SELECT id,scope_kind,scope_owner,service_id,route_id,
9475
+ upstream_id,position,plugin_name,options_json,enabled FROM plugin_bindings ORDER BY scope_kind,scope_owner,position`));
9476
+ const upstreams = groupUpstreams(sqliteAll(db, `SELECT id,owner_kind,service_id,route_id,
9477
+ position,target,weight,priority,is_disabled,policy_json FROM upstreams ORDER BY owner_kind,position`), bindings);
9478
+ const services = sqliteAll(db, "SELECT id,position,name,policy_json FROM services ORDER BY position").map((row) => ({
9479
+ ...parseCanonicalObject(row.policy_json, "service policy_json", SERVICE_RESERVED),
9480
+ id: requireUuid(row.id, "services.id"),
9481
+ position: requireSafeInteger(row.position, "services.position"),
9482
+ ...takeReferences(`service:${row.id}`),
9309
9483
  name: row.name,
9310
- table: row.tbl_name,
9311
- sql: normalizedSql(row.sql)
9484
+ endpoints: takeGroup(upstreams, `service:${row.id}`),
9485
+ plugins: takeGroup(bindings, `service:${row.id}`)
9312
9486
  }));
9313
- return canonicalJson({
9314
- objects,
9315
- tables: tables.map((table) => ({
9316
- name: table.name,
9317
- type: table.type,
9318
- ncol: table.ncol,
9319
- wr: table.wr,
9320
- strict: table.strict,
9321
- sql: normalizedSql(definitions.get(table.name) ?? null),
9322
- columns: sqliteAll(db, `SELECT cid,name,type,"notnull",dflt_value,pk,hidden
9323
- FROM pragma_table_xinfo(?) ORDER BY cid`, table.name),
9324
- foreignKeys: sqliteAll(db, `SELECT id,seq,"table","from","to",on_update,on_delete,"match"
9325
- FROM pragma_foreign_key_list(?) ORDER BY id,seq`, table.name),
9326
- indexes: indexDescriptor(db, table.name)
9327
- }))
9328
- });
9487
+ const routes = sqliteAll(db, "SELECT id,position,path,policy_json FROM routes ORDER BY position").map((row) => ({
9488
+ ...parseCanonicalObject(row.policy_json, "route policy_json", ROUTE_RESERVED),
9489
+ id: requireUuid(row.id, "routes.id"),
9490
+ position: requireSafeInteger(row.position, "routes.position"),
9491
+ path: row.path,
9492
+ ...references.has(`route:${row.id}`) ? takeReferences(`route:${row.id}`) : { endpoints: takeGroup(upstreams, `route:${row.id}`) },
9493
+ plugins: takeGroup(bindings, `route:${row.id}`)
9494
+ }));
9495
+ const activations = sqliteAll(db, "SELECT plugin_name FROM plugin_activations ORDER BY plugin_name");
9496
+ const input = { logical_configuration: {
9497
+ ...readSettings(db),
9498
+ services,
9499
+ routes,
9500
+ plugins: takeGroup(bindings, "global:")
9501
+ }, plugin_activations: activations.map(({ plugin_name }) => ({
9502
+ plugin_name: requirePluginName(plugin_name, "plugin_activations.plugin_name")
9503
+ })) };
9504
+ if (bindings.size !== 0 || upstreams.size !== 0 || references.size !== 0) {
9505
+ throw new ConfigRepositoryError("schema_corrupt", "normalized relation rows were not consumed");
9506
+ }
9507
+ return input;
9508
+ }
9509
+ function readActiveAggregate(db) {
9510
+ const result = parseNormalizeCompileAggregate(readRawActiveAggregate(db));
9511
+ if (!result.ok)
9512
+ throw new ConfigRepositoryError("schema_corrupt", "active configuration is invalid", result.errors);
9513
+ return result.value;
9329
9514
  }
9330
9515
 
9331
- // src/config-storage/migrations/management-protection.ts
9332
- var MANAGEMENT_PROTECTION_MIGRATION = {
9333
- version: 4,
9334
- name: "management_protection",
9335
- up(db) {
9336
- db.run(`CREATE TABLE management_protection (
9337
- id INTEGER PRIMARY KEY CHECK(id=1),
9338
- required INTEGER NOT NULL CHECK(required IN (0,1)),
9339
- selected_provider TEXT REFERENCES plugin_activations(plugin_name) DEFERRABLE INITIALLY DEFERRED,
9340
- generation INTEGER NOT NULL CHECK(generation BETWEEN 0 AND 9007199254740991),
9341
- CHECK((required=0 AND selected_provider IS NULL) OR (required=1 AND selected_provider IS NOT NULL))
9342
- ) STRICT`);
9343
- db.run("INSERT INTO management_protection(id,required,selected_provider,generation) VALUES(1,0,NULL,0)");
9344
- db.run("CREATE TRIGGER management_protection_no_delete BEFORE DELETE ON management_protection BEGIN SELECT RAISE(ABORT,'management protection cannot be deleted'); END");
9345
- db.run("CREATE TRIGGER management_protection_no_replace BEFORE INSERT ON management_protection WHEN EXISTS(SELECT 1 FROM management_protection) BEGIN SELECT RAISE(ABORT,'management protection cannot be replaced'); END");
9346
- db.run("CREATE TRIGGER management_protection_fixed_identity BEFORE UPDATE OF id ON management_protection BEGIN SELECT RAISE(ABORT,'management protection identity is immutable'); END");
9347
- db.run("ALTER TABLE configuration_operations ADD COLUMN management_provider TEXT");
9348
- db.run("ALTER TABLE configuration_operations ADD COLUMN offline_management INTEGER NOT NULL DEFAULT 0 CHECK(offline_management IN (0,1))");
9349
- }
9516
+ // src/config-storage/cross-table-audit.ts
9517
+ var EMPTY_AGGREGATE = {
9518
+ logical_configuration: { services: [], routes: [], plugins: [] },
9519
+ plugin_activations: []
9350
9520
  };
9351
-
9352
- // src/config-storage/migrations/ui-menus.ts
9353
- var UI_MENUS_MIGRATION = {
9354
- version: 3,
9355
- name: "instance_ui_menus",
9356
- up(db) {
9357
- db.run(`CREATE TABLE ui_menu_layout (
9358
- id INTEGER PRIMARY KEY CHECK(id=1),
9359
- revision INTEGER NOT NULL CHECK(revision >= 0 AND revision <= 9007199254740991),
9360
- layout_json TEXT CHECK(layout_json IS NULL OR (json_valid(layout_json) AND json_type(layout_json)='array'
9361
- AND length(CAST(layout_json AS BLOB)) <= 262144)),
9362
- CHECK((revision=0 AND layout_json IS NULL) OR (revision>0 AND layout_json IS NOT NULL))
9363
- ) STRICT`);
9364
- db.run("INSERT INTO ui_menu_layout(id,revision,layout_json) VALUES(1,0,NULL)");
9365
- db.run("CREATE TRIGGER ui_menu_layout_no_delete BEFORE DELETE ON ui_menu_layout BEGIN SELECT RAISE(ABORT,'menu singleton cannot be deleted'); END");
9366
- db.run("CREATE TRIGGER ui_menu_layout_no_replace BEFORE INSERT ON ui_menu_layout WHEN EXISTS(SELECT 1 FROM ui_menu_layout) BEGIN SELECT RAISE(ABORT,'menu singleton cannot be replaced'); END");
9367
- db.run("CREATE TRIGGER ui_menu_layout_fixed_identity BEFORE UPDATE OF id ON ui_menu_layout BEGIN SELECT RAISE(ABORT,'menu singleton identity is immutable'); END");
9521
+ var INITIAL_REVISION_HASH = hashConfigurationContent(EMPTY_AGGREGATE);
9522
+ function auditConfigurationTables(db) {
9523
+ const states = sqliteAll(db, `SELECT id,active_revision,schema_version,created_at,updated_at
9524
+ FROM configuration_state ORDER BY id`);
9525
+ const state = states[0];
9526
+ if (states.length !== 1 || state === undefined || state.id !== 1 || state.schema_version !== 4) {
9527
+ throw new ConfigRepositoryError("schema_corrupt", "configuration state singleton is broken");
9368
9528
  }
9369
- };
9370
-
9371
- // src/config-storage/migrations/baseline.ts
9372
- import { randomUUID } from "crypto";
9373
-
9374
- // src/config-storage/schema.ts
9375
- var CONFIG_SCHEMA_STATEMENTS = [
9376
- `CREATE TABLE configuration_operation_workers (
9377
- mutation_id TEXT NOT NULL,
9378
- worker_slot INTEGER NOT NULL CHECK(worker_slot >= 0 AND worker_slot <= 9007199254740991),
9379
- target_revision INTEGER NOT NULL CHECK(target_revision > 0 AND target_revision <= 9007199254740991),
9380
- drain_recovery_generation INTEGER NOT NULL DEFAULT 0
9381
- CHECK(drain_recovery_generation >= 0 AND drain_recovery_generation <= 9007199254740991),
9382
- attempt_no INTEGER NOT NULL DEFAULT 0 CHECK(attempt_no >= 0 AND attempt_no <= 9007199254740991),
9383
- last_begin_previous_attempt_no INTEGER CHECK(last_begin_previous_attempt_no IS NULL OR
9384
- (last_begin_previous_attempt_no >= 0 AND last_begin_previous_attempt_no < 9007199254740991)),
9385
- last_begin_reason TEXT CHECK(last_begin_reason IS NULL OR last_begin_reason IN ('initial','retry','master_recovery')),
9386
- state TEXT NOT NULL CHECK(state IN ('pending','converged','failed')),
9387
- applied_revision INTEGER CHECK(applied_revision IS NULL OR (applied_revision > 0 AND applied_revision <= 9007199254740991)),
9388
- last_error TEXT,
9389
- updated_at INTEGER NOT NULL CHECK(updated_at >= 0 AND updated_at <= 9007199254740991),
9390
- PRIMARY KEY(mutation_id,worker_slot),
9391
- FOREIGN KEY(mutation_id,target_revision) REFERENCES configuration_operations(mutation_id,committed_revision) ON DELETE CASCADE,
9392
- CHECK((attempt_no=0 AND last_begin_previous_attempt_no IS NULL AND last_begin_reason IS NULL) OR
9393
- (attempt_no=1 AND last_begin_previous_attempt_no=0 AND last_begin_reason IN ('initial','master_recovery')) OR
9394
- (attempt_no > 1 AND last_begin_previous_attempt_no=attempt_no-1 AND
9395
- last_begin_reason IN ('retry','master_recovery'))),
9396
- CHECK((state='pending' AND applied_revision IS NULL AND last_error IS NULL) OR
9397
- (state='converged' AND attempt_no > 0 AND applied_revision=target_revision AND last_error IS NULL) OR
9398
- (state='failed' AND attempt_no > 0 AND last_error IS NOT NULL AND
9399
- length(last_error) <= 512 AND length(trim(last_error)) > 0))
9400
- ) STRICT, WITHOUT ROWID`,
9401
- `CREATE TABLE configuration_operations (
9402
- mutation_id TEXT PRIMARY KEY CHECK(length(mutation_id) BETWEEN 1 AND 128),
9403
- request_hash TEXT NOT NULL CHECK(length(request_hash)=71 AND substr(request_hash,1,7)='sha256:' AND substr(request_hash,8) NOT GLOB '*[^0-9a-f]*'),
9404
- expected_revision INTEGER NOT NULL CHECK(expected_revision > 0 AND expected_revision <= 9007199254740991),
9405
- committed_revision INTEGER NOT NULL CHECK(committed_revision > 0 AND committed_revision <= 9007199254740991)
9406
- REFERENCES configuration_revisions(revision),
9407
- kind TEXT NOT NULL CHECK(kind IN ('config','admin_state')),
9408
- target_worker_count INTEGER NOT NULL CHECK(target_worker_count >= 0 AND target_worker_count <= 9007199254740991),
9409
- state TEXT NOT NULL CHECK(state IN ('committed','publishing','draining','converged','degraded')),
9410
- result_status INTEGER,
9411
- error_code TEXT,
9412
- error_detail TEXT,
9413
- drain_recovery_generation INTEGER NOT NULL DEFAULT 0
9414
- CHECK(drain_recovery_generation >= 0 AND drain_recovery_generation <= 9007199254740991),
9415
- last_drain_recovery_previous_generation INTEGER
9416
- CHECK(last_drain_recovery_previous_generation IS NULL OR
9417
- (last_drain_recovery_previous_generation >= 0 AND
9418
- last_drain_recovery_previous_generation < 9007199254740991)),
9419
- created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
9420
- updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991),
9421
- UNIQUE(mutation_id,committed_revision),
9422
- CHECK((drain_recovery_generation=0 AND last_drain_recovery_previous_generation IS NULL) OR
9423
- (drain_recovery_generation > 0 AND
9424
- last_drain_recovery_previous_generation=drain_recovery_generation-1)),
9425
- CHECK((state IN ('committed','publishing') AND drain_recovery_generation=0 AND
9426
- result_status IS NULL AND error_code IS NULL AND error_detail IS NULL) OR
9427
- (state='draining' AND result_status IS NULL AND error_code IS NULL AND error_detail IS NULL) OR
9428
- (state='converged' AND drain_recovery_generation=0 AND
9429
- result_status=200 AND error_code IS NULL AND error_detail IS NULL) OR
9430
- (state='degraded' AND result_status=202 AND
9431
- error_code IN ('replacement_convergence_failed','old_worker_drain_failed','control_readiness_failed') AND
9432
- (error_code <> 'replacement_convergence_failed' OR drain_recovery_generation=0) AND
9433
- error_detail IS NOT NULL AND length(error_detail) <= 512 AND length(trim(error_detail)) > 0))
9434
- ) STRICT`,
9435
- `CREATE TABLE configuration_recoveries (
9436
- recovery_sequence INTEGER PRIMARY KEY AUTOINCREMENT CHECK(recovery_sequence > 0),
9437
- recovery_id TEXT NOT NULL UNIQUE CHECK(length(recovery_id)=36 AND substr(recovery_id,9,1)='-' AND substr(recovery_id,14,1)='-' AND substr(recovery_id,19,1)='-' AND substr(recovery_id,24,1)='-' AND replace(recovery_id,'-','') NOT GLOB '*[^0-9a-f]*'),
9438
- source_mutation_id TEXT NOT NULL,
9439
- target_revision INTEGER NOT NULL CHECK(target_revision > 0 AND target_revision <= 9007199254740991),
9440
- trigger TEXT NOT NULL CHECK(trigger IN ('automatic','manual')),
9441
- state TEXT NOT NULL CHECK(state IN ('scheduled','running','succeeded','stopped')),
9442
- attempt_count INTEGER NOT NULL CHECK(attempt_count >= 0 AND attempt_count <= 6),
9443
- max_attempts INTEGER NOT NULL CHECK(max_attempts=6),
9444
- next_retry_at INTEGER CHECK(next_retry_at IS NULL OR (next_retry_at >= 0 AND next_retry_at <= 9007199254740991)),
9445
- final_reason_code TEXT CHECK(final_reason_code IS NULL OR final_reason_code IN
9446
- ('target_serving','already_serving','deterministic_worker_rejection','deterministic_protocol_failure',
9447
- 'deterministic_control_failure','retry_exhausted','revision_superseded','safety_outcome_unknown','fatal_source_failure')), final_reason_detail TEXT,
9448
- created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
9449
- updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991),
9450
- FOREIGN KEY(source_mutation_id) REFERENCES configuration_operations(mutation_id),
9451
- FOREIGN KEY(target_revision) REFERENCES configuration_revisions(revision),
9452
- CHECK((state='scheduled' AND final_reason_code IS NULL AND final_reason_detail IS NULL) OR
9453
- (state='running' AND next_retry_at IS NULL AND final_reason_code IS NULL AND final_reason_detail IS NULL) OR
9454
- (state='succeeded' AND ((final_reason_code='already_serving' AND attempt_count=0) OR
9455
- (final_reason_code='target_serving' AND attempt_count > 0)) AND next_retry_at IS NULL) OR
9456
- (state='stopped' AND final_reason_code IN ('deterministic_worker_rejection','deterministic_protocol_failure',
9457
- 'deterministic_control_failure','revision_superseded') AND next_retry_at IS NULL) OR
9458
- (state='stopped' AND final_reason_code='retry_exhausted' AND attempt_count=6 AND next_retry_at IS NULL) OR
9459
- (state='stopped' AND final_reason_code='safety_outcome_unknown' AND attempt_count > 0 AND next_retry_at IS NULL) OR
9460
- (state='stopped' AND final_reason_code='fatal_source_failure' AND attempt_count=0 AND next_retry_at IS NULL)),
9461
- CHECK(state <> 'scheduled' OR next_retry_at IS NULL OR next_retry_at > updated_at),
9462
- CHECK(state <> 'scheduled' OR attempt_count < 6), CHECK(state <> 'running' OR attempt_count > 0),
9463
- CHECK(final_reason_detail IS NULL OR (length(final_reason_detail) <= 512 AND length(trim(final_reason_detail)) > 0))
9464
- ) STRICT`,
9465
- `CREATE TABLE configuration_revisions (
9466
- revision INTEGER PRIMARY KEY CHECK(revision > 0 AND revision <= 9007199254740991),
9467
- content_hash TEXT NOT NULL CHECK(length(content_hash)=71 AND substr(content_hash,1,7)='sha256:' AND substr(content_hash,8) NOT GLOB '*[^0-9a-f]*'),
9468
- kind TEXT NOT NULL CHECK(kind IN ('config','admin_state')),
9469
- created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991)
9470
- ) STRICT`,
9471
- `CREATE TABLE configuration_serving_snapshots (
9472
- revision INTEGER NOT NULL CHECK(revision > 0 AND revision <= 9007199254740991),
9473
- content_hash TEXT NOT NULL CHECK(length(content_hash)=71 AND substr(content_hash,1,7)='sha256:' AND substr(content_hash,8) NOT GLOB '*[^0-9a-f]*'),
9474
- plugin_catalog_hash TEXT NOT NULL CHECK(length(plugin_catalog_hash)=71 AND substr(plugin_catalog_hash,1,7)='sha256:' AND substr(plugin_catalog_hash,8) NOT GLOB '*[^0-9a-f]*'),
9475
- aggregate_json TEXT NOT NULL CHECK(json_valid(aggregate_json) AND json_type(aggregate_json)='object'
9476
- AND length(CAST(aggregate_json AS BLOB)) BETWEEN 2 AND 1048576),
9477
- PRIMARY KEY(revision,content_hash,plugin_catalog_hash),
9478
- FOREIGN KEY(revision,content_hash)
9479
- REFERENCES configuration_revisions(revision,content_hash)
9480
- ) STRICT, WITHOUT ROWID`,
9481
- `CREATE TABLE configuration_state (
9482
- id INTEGER PRIMARY KEY CHECK(id=1),
9483
- schema_version INTEGER NOT NULL CHECK(schema_version=4),
9484
- active_revision INTEGER NOT NULL CHECK(active_revision > 0 AND active_revision <= 9007199254740991)
9485
- REFERENCES configuration_revisions(revision),
9486
- created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
9487
- updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991)
9488
- ) STRICT`,
9489
- `CREATE TABLE plugin_activations (
9490
- plugin_name TEXT PRIMARY KEY CHECK(length(plugin_name) > 0 AND plugin_name=lower(plugin_name)
9491
- AND plugin_name NOT GLOB '*[^a-z0-9-]*' AND substr(plugin_name,1,1) GLOB '[a-z]'
9492
- AND substr(plugin_name,-1,1)<>'-' AND plugin_name NOT LIKE '%--%')
9493
- ) STRICT, WITHOUT ROWID`,
9494
- `CREATE TABLE plugin_bindings (
9495
- id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
9496
- scope_kind TEXT NOT NULL CHECK(scope_kind IN ('global','service','route','upstream')),
9497
- scope_owner TEXT NOT NULL,
9498
- service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
9499
- route_id TEXT REFERENCES routes(id) ON DELETE CASCADE,
9500
- upstream_id TEXT REFERENCES upstreams(id) ON DELETE CASCADE,
9501
- position INTEGER NOT NULL CHECK(position >= 0 AND position <= 9007199254740991),
9502
- plugin_name TEXT NOT NULL CHECK(length(plugin_name) > 0 AND plugin_name=lower(plugin_name)
9503
- AND plugin_name NOT GLOB '*[^a-z0-9-]*' AND substr(plugin_name,1,1) GLOB '[a-z]'
9504
- AND substr(plugin_name,-1,1)<>'-' AND plugin_name NOT LIKE '%--%'),
9505
- options_json TEXT CHECK(options_json IS NULL OR (json_valid(options_json) AND json_type(options_json)='object')),
9506
- enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),
9507
- CHECK((scope_kind='global' AND scope_owner='' AND service_id IS NULL AND route_id IS NULL AND upstream_id IS NULL) OR
9508
- (scope_kind='service' AND service_id IS NOT NULL AND scope_owner=service_id AND route_id IS NULL AND upstream_id IS NULL) OR
9509
- (scope_kind='route' AND route_id IS NOT NULL AND scope_owner=route_id AND service_id IS NULL AND upstream_id IS NULL) OR
9510
- (scope_kind='upstream' AND upstream_id IS NOT NULL AND scope_owner=upstream_id AND service_id IS NULL AND route_id IS NULL)),
9511
- UNIQUE(scope_kind,scope_owner,position)
9512
- ) STRICT`,
9513
- `CREATE TABLE routes (
9514
- id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
9515
- position INTEGER NOT NULL UNIQUE CHECK(position >= 0 AND position <= 9007199254740991),
9516
- path TEXT NOT NULL UNIQUE CHECK(substr(path,1,1)='/'),
9517
- service_id TEXT REFERENCES services(id),
9518
- policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object')
9519
- ) STRICT`,
9520
- `CREATE TABLE schema_migrations (
9521
- version INTEGER PRIMARY KEY CHECK(version > 0 AND version <= 9007199254740991),
9522
- name TEXT NOT NULL UNIQUE
9523
- ) STRICT`,
9524
- `CREATE TABLE services (
9525
- id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
9526
- position INTEGER NOT NULL UNIQUE CHECK(position >= 0 AND position <= 9007199254740991),
9527
- name TEXT NOT NULL UNIQUE CHECK(length(name) > 0),
9528
- policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object')
9529
- ) STRICT`,
9530
- `CREATE TABLE settings (
9531
- id INTEGER PRIMARY KEY CHECK(id=1),
9532
- log_level TEXT CHECK(log_level IS NULL OR log_level IN ('trace','debug','info','warn','error','fatal')),
9533
- body_parser_limit TEXT CHECK(body_parser_limit IS NULL OR CASE
9534
- WHEN substr(body_parser_limit,-2) IN ('gb','mb','kb') THEN
9535
- length(substr(body_parser_limit,1,length(body_parser_limit)-2)) > 0
9536
- AND body_parser_limit=substr(body_parser_limit,1,length(body_parser_limit)-2)||substr(body_parser_limit,-2)
9537
- AND substr(body_parser_limit,1,1) GLOB '[1-9]'
9538
- AND substr(body_parser_limit,1,length(body_parser_limit)-2) NOT GLOB '*[^0-9]*'
9539
- AND (length(substr(body_parser_limit,1,length(body_parser_limit)-2)) < 16 OR
9540
- (length(substr(body_parser_limit,1,length(body_parser_limit)-2))=16 AND
9541
- substr(body_parser_limit,1,length(body_parser_limit)-2)<='9007199254740991'))
9542
- WHEN substr(body_parser_limit,-1)='b' THEN
9543
- length(substr(body_parser_limit,1,length(body_parser_limit)-1)) > 0
9544
- AND body_parser_limit=substr(body_parser_limit,1,length(body_parser_limit)-1)||'b'
9545
- AND substr(body_parser_limit,1,1) GLOB '[1-9]'
9546
- AND substr(body_parser_limit,1,length(body_parser_limit)-1) NOT GLOB '*[^0-9]*'
9547
- AND (length(substr(body_parser_limit,1,length(body_parser_limit)-1)) < 16 OR
9548
- (length(substr(body_parser_limit,1,length(body_parser_limit)-1))=16 AND
9549
- substr(body_parser_limit,1,length(body_parser_limit)-1)<='9007199254740991'))
9550
- ELSE 0 END),
9551
- auth_json TEXT CHECK(auth_json IS NULL OR json_valid(auth_json)),
9552
- logging_json TEXT CHECK(logging_json IS NULL OR json_valid(logging_json))
9553
- , publication_json TEXT) STRICT`,
9554
- `CREATE TABLE supervision_state (
9555
- id INTEGER PRIMARY KEY CHECK(id=1),
9556
- instance_id TEXT NOT NULL UNIQUE CHECK(length(instance_id)=36),
9557
- controller_epoch INTEGER NOT NULL CHECK(controller_epoch >= 0 AND controller_epoch < 9007199254740991),
9558
- current_controller_id TEXT,
9559
- updated_at INTEGER NOT NULL CHECK(updated_at >= 0 AND updated_at < 9007199254740991)
9560
- ) STRICT, WITHOUT ROWID`,
9561
- `CREATE TABLE upstreams (
9562
- id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
9563
- owner_kind TEXT NOT NULL CHECK(owner_kind IN ('service','route')),
9564
- service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
9565
- route_id TEXT REFERENCES routes(id) ON DELETE CASCADE,
9566
- position INTEGER NOT NULL CHECK(position >= 0 AND position <= 9007199254740991),
9567
- target TEXT NOT NULL CHECK(length(target) > 0),
9568
- weight REAL NOT NULL CHECK(weight > 0),
9569
- priority REAL NOT NULL CHECK(priority > 0),
9570
- is_disabled INTEGER NOT NULL CHECK(is_disabled IN (0,1)),
9571
- policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object'),
9572
- CHECK((owner_kind='service' AND service_id IS NOT NULL AND route_id IS NULL) OR
9573
- (owner_kind='route' AND route_id IS NOT NULL AND service_id IS NULL)),
9574
- UNIQUE(owner_kind,service_id,position), UNIQUE(owner_kind,route_id,position)
9575
- ) STRICT`,
9576
- `CREATE UNIQUE INDEX configuration_recoveries_active_target_revision
9577
- ON configuration_recoveries(target_revision) WHERE state IN ('scheduled','running')`,
9578
- `CREATE UNIQUE INDEX configuration_revisions_revision_content_hash
9579
- ON configuration_revisions(revision,content_hash)`,
9580
- `CREATE TRIGGER configuration_operations_terminal_immutable_delete
9581
- BEFORE DELETE ON configuration_operations
9582
- WHEN OLD.state IN ('converged','degraded')
9583
- BEGIN
9584
- SELECT RAISE(ABORT,'terminal configuration operations are immutable');
9585
- END`,
9586
- `CREATE TRIGGER configuration_operations_terminal_immutable_update
9587
- BEFORE UPDATE ON configuration_operations
9588
- WHEN OLD.state IN ('converged','degraded')
9589
- BEGIN
9590
- SELECT RAISE(ABORT,'terminal configuration operations are immutable');
9591
- END`,
9592
- `CREATE TRIGGER configuration_recoveries_no_delete BEFORE DELETE ON configuration_recoveries
9593
- BEGIN SELECT RAISE(ABORT,'configuration recovery cannot be deleted'); END`,
9594
- `CREATE TRIGGER configuration_recoveries_sequence_insert_guard BEFORE INSERT ON configuration_recoveries
9595
- WHEN NEW.recovery_sequence != -1 BEGIN SELECT RAISE(ABORT,'recovery sequence is database assigned'); END`,
9596
- `CREATE TRIGGER configuration_recoveries_sequence_update_guard BEFORE UPDATE OF recovery_sequence ON configuration_recoveries
9597
- BEGIN SELECT RAISE(ABORT,'recovery sequence is immutable'); END`,
9598
- `CREATE TRIGGER configuration_serving_snapshots_immutable_delete
9599
- BEFORE DELETE ON configuration_serving_snapshots
9600
- BEGIN
9601
- SELECT RAISE(ABORT,'configuration serving snapshots are immutable');
9602
- END`,
9603
- `CREATE TRIGGER configuration_serving_snapshots_immutable_update
9604
- BEFORE UPDATE ON configuration_serving_snapshots
9605
- BEGIN
9606
- SELECT RAISE(ABORT,'configuration serving snapshots are immutable');
9607
- END`,
9608
- `CREATE TRIGGER configuration_state_singleton_id_immutable
9609
- BEFORE UPDATE OF id ON configuration_state
9610
- BEGIN
9611
- SELECT RAISE(ABORT,'configuration state singleton identity is immutable');
9612
- END`,
9613
- `CREATE TRIGGER configuration_state_singleton_no_delete
9614
- BEFORE DELETE ON configuration_state
9615
- BEGIN
9616
- SELECT RAISE(ABORT,'configuration state singleton cannot be deleted');
9617
- END`,
9618
- `CREATE TRIGGER configuration_state_singleton_no_replace
9619
- BEFORE INSERT ON configuration_state
9620
- WHEN EXISTS(SELECT 1 FROM configuration_state)
9621
- BEGIN
9622
- SELECT RAISE(ABORT,'configuration state singleton cannot be replaced');
9623
- END`
9624
- ];
9625
-
9626
- // src/config-storage/migrations/baseline.ts
9627
- var CONFIG_BASELINE_VERSION = 1;
9628
- var CONFIG_BASELINE_NAME = "current_storage_baseline";
9629
- var CONFIG_BASELINE = {
9630
- version: CONFIG_BASELINE_VERSION,
9631
- name: CONFIG_BASELINE_NAME,
9632
- up(db) {
9633
- for (const statement of CONFIG_SCHEMA_STATEMENTS)
9634
- db.run(statement);
9635
- const aggregate = { logical_configuration: { services: [], routes: [], plugins: [] }, plugin_activations: [] };
9636
- replaceActiveMaterialization(db, aggregate);
9637
- db.run("INSERT INTO configuration_revisions(revision,content_hash,kind,created_at) VALUES(1,?,'config',0)", [hashConfigurationContent(aggregate)]);
9638
- db.run("INSERT INTO configuration_state(id,schema_version,active_revision,created_at,updated_at) VALUES(1,4,1,0,0)");
9639
- db.run("INSERT INTO supervision_state(id,instance_id,controller_epoch,current_controller_id,updated_at) VALUES(1,?,0,NULL,0)", [randomUUID()]);
9529
+ requireSafeInteger(state.active_revision, "configuration_state.active_revision", 1);
9530
+ requireSafeInteger(state.created_at, "configuration_state.created_at");
9531
+ requireSafeInteger(state.updated_at, "configuration_state.updated_at", state.created_at);
9532
+ const revisions = sqliteAll(db, `SELECT revision,content_hash,kind,created_at
9533
+ FROM configuration_revisions ORDER BY revision`);
9534
+ if (revisions.length !== state.active_revision) {
9535
+ throw new ConfigRepositoryError("schema_corrupt", "configuration revisions are not contiguous");
9640
9536
  }
9641
- };
9642
-
9643
- // src/config-storage/migrations/publication-retirement.ts
9644
- var PUBLICATION_RETIREMENT_MIGRATION = {
9645
- version: 2,
9646
- name: "publication_retirement_and_runtime_recovery",
9647
- up(db) {
9648
- db.run(`CREATE TABLE configuration_retirements (
9649
- mutation_id TEXT PRIMARY KEY REFERENCES configuration_operations(mutation_id),
9650
- generation INTEGER NOT NULL CHECK(generation >= 0),
9651
- members_json TEXT NOT NULL CHECK(json_valid(members_json) AND json_type(members_json)='array'),
9652
- state TEXT NOT NULL CHECK(state IN ('pending','stopped','released')),
9653
- reason_code TEXT CHECK(reason_code IS NULL OR reason_code IN ('exit_unknown','cleanup_unknown','release_unknown')),
9654
- updated_at INTEGER NOT NULL CHECK(updated_at >= 0)
9655
- ) STRICT`);
9656
- db.run(`CREATE TABLE configuration_retirement_evidence (
9657
- mutation_id TEXT NOT NULL REFERENCES configuration_retirements(mutation_id),
9658
- member_id TEXT NOT NULL,
9659
- exit_state TEXT NOT NULL CHECK(exit_state IN ('live','confirmed','unknown')),
9660
- cleanup_state TEXT NOT NULL CHECK(cleanup_state IN ('success','failed','unknown')),
9661
- exit_boot_id TEXT,
9662
- exit_deadline_ns TEXT,
9663
- start_boot_id TEXT,
9664
- start_deadline_ns TEXT,
9665
- updated_at INTEGER NOT NULL CHECK(updated_at >= 0),
9666
- CHECK((exit_boot_id IS NULL AND exit_deadline_ns IS NULL) OR (exit_boot_id IS NOT NULL AND exit_deadline_ns IS NOT NULL)),
9667
- CHECK((start_boot_id IS NULL AND start_deadline_ns IS NULL) OR (start_boot_id IS NOT NULL AND start_deadline_ns IS NOT NULL)),
9668
- PRIMARY KEY(mutation_id,member_id)
9669
- ) STRICT, WITHOUT ROWID`);
9670
- db.run("CREATE TRIGGER retirement_start_deadline_immutable BEFORE UPDATE OF start_boot_id,start_deadline_ns ON configuration_retirement_evidence WHEN OLD.start_deadline_ns IS NOT NULL AND (NEW.start_boot_id IS NOT OLD.start_boot_id OR NEW.start_deadline_ns IS NOT OLD.start_deadline_ns) BEGIN SELECT RAISE(ABORT,'retirement start deadline is immutable'); END");
9671
- db.run(`CREATE TABLE configuration_publication_serving_sets (
9672
- mutation_id TEXT NOT NULL REFERENCES configuration_operations(mutation_id),
9673
- generation INTEGER NOT NULL CHECK(generation >= 0),
9674
- attempt_key TEXT NOT NULL CHECK(json_valid(attempt_key) AND json_type(attempt_key)='array'),
9675
- workers_json TEXT NOT NULL CHECK(json_valid(workers_json) AND json_type(workers_json)='array'),
9676
- exited_json TEXT NOT NULL CHECK(json_valid(exited_json) AND json_type(exited_json)='array'),
9677
- PRIMARY KEY(mutation_id,generation,attempt_key)
9678
- ) STRICT, WITHOUT ROWID`);
9679
- db.run("CREATE TRIGGER publication_serving_authority_no_delete BEFORE DELETE ON configuration_publication_serving_sets BEGIN SELECT RAISE(ABORT,'publication serving authority history is immutable'); END");
9680
- db.run("CREATE TRIGGER publication_serving_authority_no_update BEFORE UPDATE OF mutation_id,generation,attempt_key,workers_json ON configuration_publication_serving_sets BEGIN SELECT RAISE(ABORT,'publication serving attempt identity is immutable'); END");
9681
- db.run("CREATE TRIGGER publication_serving_authority_no_replace BEFORE INSERT ON configuration_publication_serving_sets WHEN EXISTS(SELECT 1 FROM configuration_publication_serving_sets WHERE mutation_id=NEW.mutation_id AND generation=NEW.generation AND attempt_key=NEW.attempt_key) BEGIN SELECT RAISE(ABORT,'publication serving authority history is immutable'); END");
9682
- db.run(`CREATE TABLE runtime_recovery_budget (
9683
- id INTEGER PRIMARY KEY CHECK(id=1),
9684
- tickets INTEGER NOT NULL CHECK(tickets BETWEEN 0 AND 2),
9685
- ticket_id TEXT,
9686
- ticket_boot_id TEXT,
9687
- retry_at_ns TEXT,
9688
- healthy_owner TEXT,
9689
- healthy_boot_id TEXT,
9690
- healthy_since_ns TEXT,
9691
- healthy_last_ns TEXT,
9692
- CHECK((tickets=0 AND ticket_id IS NULL AND ticket_boot_id IS NULL AND retry_at_ns IS NULL) OR
9693
- (tickets>0 AND ticket_id IS NOT NULL AND ticket_boot_id IS NOT NULL AND retry_at_ns IS NOT NULL)),
9694
- CHECK((healthy_owner IS NULL AND healthy_boot_id IS NULL AND healthy_since_ns IS NULL AND healthy_last_ns IS NULL) OR
9695
- (healthy_owner IS NOT NULL AND healthy_boot_id IS NOT NULL AND healthy_since_ns IS NOT NULL AND healthy_last_ns IS NOT NULL))
9696
- ) STRICT`);
9697
- db.run("INSERT INTO runtime_recovery_budget(id,tickets) VALUES(1,0)");
9698
- db.run(`CREATE TABLE configuration_retirement_retry_requests (
9699
- request_id TEXT PRIMARY KEY,
9700
- mutation_id TEXT NOT NULL REFERENCES configuration_retirements(mutation_id),
9701
- expected_revision INTEGER NOT NULL CHECK(expected_revision>0),
9702
- created_at INTEGER NOT NULL CHECK(created_at>=0)
9703
- ) STRICT, WITHOUT ROWID`);
9704
- db.run("CREATE TRIGGER retirement_retry_no_delete BEFORE DELETE ON configuration_retirement_retry_requests BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
9705
- db.run("CREATE TRIGGER retirement_retry_no_update BEFORE UPDATE ON configuration_retirement_retry_requests BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
9706
- db.run("CREATE TRIGGER retirement_retry_no_replace BEFORE INSERT ON configuration_retirement_retry_requests WHEN EXISTS(SELECT 1 FROM configuration_retirement_retry_requests WHERE request_id=NEW.request_id) BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
9707
- db.run(`CREATE TABLE runtime_recovery_tickets (
9708
- ticket_id TEXT PRIMARY KEY,
9709
- owner TEXT NOT NULL,
9710
- boot_id TEXT NOT NULL,
9711
- consumed_at_ns TEXT NOT NULL,
9712
- retry_at_ns TEXT NOT NULL
9713
- ) STRICT, WITHOUT ROWID`);
9714
- db.run("CREATE TRIGGER runtime_recovery_tickets_no_delete BEFORE DELETE ON runtime_recovery_tickets BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
9715
- db.run("CREATE TRIGGER runtime_recovery_tickets_no_update BEFORE UPDATE ON runtime_recovery_tickets BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
9716
- db.run("CREATE TRIGGER runtime_recovery_tickets_no_replace BEFORE INSERT ON runtime_recovery_tickets WHEN EXISTS(SELECT 1 FROM runtime_recovery_tickets WHERE ticket_id=NEW.ticket_id) BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
9717
- db.run("CREATE TRIGGER runtime_recovery_budget_no_delete BEFORE DELETE ON runtime_recovery_budget BEGIN SELECT RAISE(ABORT,'runtime recovery budget cannot be deleted'); END");
9718
- db.run("CREATE TRIGGER runtime_recovery_budget_no_replace BEFORE INSERT ON runtime_recovery_budget WHEN EXISTS(SELECT 1 FROM runtime_recovery_budget) BEGIN SELECT RAISE(ABORT,'runtime recovery budget cannot be replaced'); END");
9719
- db.run("CREATE TRIGGER runtime_recovery_budget_fixed_identity BEFORE UPDATE OF id ON runtime_recovery_budget BEGIN SELECT RAISE(ABORT,'runtime recovery budget identity is immutable'); END");
9537
+ for (const [index, revision] of revisions.entries()) {
9538
+ requireSafeInteger(revision.revision, "configuration_revisions.revision", 1);
9539
+ requireSafeInteger(revision.created_at, "configuration_revisions.created_at");
9540
+ if (revision.revision !== index + 1 || !validateDigest(revision.content_hash) || revision.kind !== "config" && revision.kind !== "admin_state") {
9541
+ throw new ConfigRepositoryError("schema_corrupt", "configuration revision metadata is invalid");
9542
+ }
9543
+ }
9544
+ const initialRevision = revisions[0];
9545
+ if (initialRevision === undefined || initialRevision.content_hash !== INITIAL_REVISION_HASH || initialRevision.kind !== "config" || initialRevision.created_at !== 0) {
9546
+ throw new ConfigRepositoryError("schema_corrupt", "initial revision identity is invalid");
9547
+ }
9548
+ const operations = readAllOperations(db);
9549
+ if (operations.length !== revisions.length - 1) {
9550
+ throw new ConfigRepositoryError("schema_corrupt", "revision operation cardinality is invalid");
9720
9551
  }
9721
- };
9722
-
9723
- // src/config-storage/migrations/plan.ts
9724
- var CONFIG_MIGRATIONS = [CONFIG_BASELINE, PUBLICATION_RETIREMENT_MIGRATION, UI_MENUS_MIGRATION, MANAGEMENT_PROTECTION_MIGRATION];
9725
- var CONFIG_SCHEMA_VERSION = CONFIG_MIGRATIONS.at(-1).version;
9726
-
9727
- // src/config-storage/schema-fingerprint.ts
9728
- function readSchemaObjectFingerprint(db) {
9729
- return canonicalJson(sqliteAll(db, `SELECT type,name,tbl_name,sql
9730
- FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`));
9731
- }
9732
- var expectedDescriptors = new Map;
9733
- function verifySchemaFingerprint(db, version = CONFIG_SCHEMA_VERSION, plan = CONFIG_MIGRATIONS) {
9734
- if (!plan.some((migration) => migration.version === version)) {
9735
- throw new ConfigRepositoryError("schema_corrupt", "unsupported configuration schema version");
9552
+ const operationsByRevision = new Map(operations.map((operation) => [operation.committed_revision, operation]));
9553
+ for (const revision of revisions.slice(1)) {
9554
+ const operation = operationsByRevision.get(revision.revision);
9555
+ if (operation === undefined || operation.expected_revision !== revision.revision - 1 || operation.kind !== revision.kind || operation.created_at !== revision.created_at) {
9556
+ throw new ConfigRepositoryError("schema_corrupt", "revision operation metadata is incoherent");
9557
+ }
9736
9558
  }
9737
- let expected = plan === CONFIG_MIGRATIONS ? expectedDescriptors.get(version) : undefined;
9738
- if (expected === undefined) {
9739
- const reference = new Database(":memory:", { strict: true });
9740
- try {
9741
- for (const migration of plan.filter((migration) => migration.version <= version))
9742
- migration.up(reference);
9743
- expected = databaseSchemaDescriptor(reference);
9744
- } finally {
9745
- reference.close(true);
9559
+ const operationsByMutation = new Map(operations.map((operation) => [operation.mutation_id, operation]));
9560
+ const workersByMutation = new Map;
9561
+ for (const worker of readAllWorkers(db)) {
9562
+ const operation = operationsByMutation.get(worker.mutation_id);
9563
+ if (operation === undefined || worker.target_revision !== operation.committed_revision || worker.updated_at < operation.created_at || worker.updated_at > operation.updated_at) {
9564
+ throw new ConfigRepositoryError("schema_corrupt", "operation worker metadata is incoherent");
9746
9565
  }
9747
- if (plan === CONFIG_MIGRATIONS)
9748
- expectedDescriptors.set(version, expected);
9566
+ workersByMutation.set(worker.mutation_id, [...workersByMutation.get(worker.mutation_id) ?? [], worker]);
9749
9567
  }
9750
- if (databaseSchemaDescriptor(db) !== expected) {
9751
- throw new ConfigRepositoryError("schema_corrupt", "configuration schema fingerprint is invalid");
9568
+ for (const operation of operations) {
9569
+ const workers = workersByMutation.get(operation.mutation_id) ?? [];
9570
+ validateOperationRelations(operation, workers);
9571
+ if (operation.committed_revision !== state.active_revision && (operation.state === "committed" || operation.state === "publishing" || operation.state === "draining")) {
9572
+ throw new ConfigRepositoryError("schema_corrupt", "non-active operation is not terminal");
9573
+ }
9752
9574
  }
9575
+ const activeRevisionRow = revisions.at(-1);
9576
+ if (activeRevisionRow === undefined)
9577
+ throw new ConfigRepositoryError("schema_corrupt", "active revision is missing");
9578
+ const activeOperation = operationsByRevision.get(state.active_revision) ?? null;
9579
+ const activeTargetSlots = activeOperation === null ? [] : (workersByMutation.get(activeOperation.mutation_id) ?? []).map(({ worker_slot }) => worker_slot).sort((left, right) => left - right);
9580
+ return {
9581
+ activeRevision: state.active_revision,
9582
+ activeRevisionRow,
9583
+ activeOperation,
9584
+ activeTargetSlots,
9585
+ managementIdentity: activeOperation === null ? null : readManagementRequestIdentity(db, activeOperation.mutation_id)
9586
+ };
9753
9587
  }
9754
-
9755
- // src/config-storage/sqlite-errors.ts
9756
- function isSqliteBusyError(error) {
9757
- if (!(error instanceof Error))
9758
- return false;
9759
- const sqliteError = error;
9760
- const errno = sqliteError.errno;
9761
- if (typeof errno === "number" && ((errno & 255) === 5 || (errno & 255) === 6))
9762
- return true;
9763
- const code = sqliteError.code;
9764
- return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || code?.startsWith("SQLITE_BUSY_") === true || code?.startsWith("SQLITE_LOCKED_") === true;
9765
- }
9766
- function repositoryFailure(message, cause) {
9767
- return new ConfigRepositoryError("repository_failure", message, cause);
9768
- }
9769
-
9770
- // src/config-storage/migrations/index.ts
9771
- function migrateConfigurationDatabase(db, plan = CONFIG_MIGRATIONS) {
9772
- try {
9773
- db.transaction(() => {
9774
- if (!plan.length || plan[0] !== CONFIG_MIGRATIONS[0] || plan.some((m, i) => i > 0 && m.version !== plan[i - 1].version + 1) || new Set(plan.map((m) => m.name)).size !== plan.length)
9775
- throw new ConfigRepositoryError("migration_failed", "invalid configuration migration plan");
9776
- const count = db.query("SELECT count(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").get().count;
9777
- let applied = [];
9778
- if (count > 0) {
9779
- applied = db.query("SELECT version,name FROM schema_migrations ORDER BY version").all();
9780
- if (!applied.length || applied.length > plan.length || applied.some((r, i) => r.version !== plan[i]?.version || r.name !== plan[i]?.name))
9781
- throw new ConfigRepositoryError("schema_corrupt", "configuration migration history is not an exact baseline prefix");
9782
- verifySchemaFingerprint(db, applied.at(-1).version, plan);
9783
- }
9784
- for (const migration of plan.slice(applied.length)) {
9785
- migration.up(db);
9786
- db.run("INSERT INTO schema_migrations(version,name) VALUES(?,?)", [migration.version, migration.name]);
9787
- }
9788
- verifySchemaFingerprint(db, plan.at(-1).version, plan);
9789
- }).immediate();
9790
- } catch (error) {
9791
- if (error instanceof ConfigRepositoryError)
9792
- throw error;
9793
- if (isSqliteBusyError(error))
9794
- throw repositoryFailure("configuration migration was blocked by SQLite", error);
9795
- throw new ConfigRepositoryError("migration_failed", "configuration migration failed", error);
9588
+ function verifyActiveRequestIdentity(audited, aggregate) {
9589
+ const operation = audited.activeOperation;
9590
+ if (operation === null)
9591
+ return;
9592
+ const requestHash = hashConfigurationRequest({
9593
+ ...audited.managementIdentity,
9594
+ kind: operation.kind,
9595
+ expected_revision: operation.expected_revision,
9596
+ aggregate,
9597
+ target_worker_slots: audited.activeTargetSlots
9598
+ });
9599
+ if (requestHash !== operation.request_hash) {
9600
+ throw new ConfigRepositoryError("schema_corrupt", "active operation request hash is incoherent");
9796
9601
  }
9797
9602
  }
9798
9603
 
@@ -9856,241 +9661,643 @@ function prepareCommitCommand(input, compileOptions) {
9856
9661
  };
9857
9662
  }
9858
9663
 
9859
- // src/config-storage/cross-table-audit.ts
9860
- var EMPTY_AGGREGATE = {
9861
- logical_configuration: { services: [], routes: [], plugins: [] },
9862
- plugin_activations: []
9863
- };
9864
- var INITIAL_REVISION_HASH = hashConfigurationContent(EMPTY_AGGREGATE);
9865
- function auditConfigurationTables(db) {
9866
- const states = sqliteAll(db, `SELECT id,active_revision,schema_version,created_at,updated_at
9867
- FROM configuration_state ORDER BY id`);
9868
- const state = states[0];
9869
- if (states.length !== 1 || state === undefined || state.id !== 1 || state.schema_version !== 4) {
9870
- throw new ConfigRepositoryError("schema_corrupt", "configuration state singleton is broken");
9871
- }
9872
- requireSafeInteger(state.active_revision, "configuration_state.active_revision", 1);
9873
- requireSafeInteger(state.created_at, "configuration_state.created_at");
9874
- requireSafeInteger(state.updated_at, "configuration_state.updated_at", state.created_at);
9875
- const revisions = sqliteAll(db, `SELECT revision,content_hash,kind,created_at
9876
- FROM configuration_revisions ORDER BY revision`);
9877
- if (revisions.length !== state.active_revision) {
9878
- throw new ConfigRepositoryError("schema_corrupt", "configuration revisions are not contiguous");
9879
- }
9880
- for (const [index, revision] of revisions.entries()) {
9881
- requireSafeInteger(revision.revision, "configuration_revisions.revision", 1);
9882
- requireSafeInteger(revision.created_at, "configuration_revisions.created_at");
9883
- if (revision.revision !== index + 1 || !validateDigest(revision.content_hash) || revision.kind !== "config" && revision.kind !== "admin_state") {
9884
- throw new ConfigRepositoryError("schema_corrupt", "configuration revision metadata is invalid");
9885
- }
9664
+ // src/config-storage/consistent-read.ts
9665
+ function withConsistentRead(db, fn) {
9666
+ if (db.inTransaction)
9667
+ return fn();
9668
+ return db.transaction(fn).deferred();
9669
+ }
9670
+
9671
+ // src/config-storage/serving-snapshot.ts
9672
+ var MAX_AGGREGATE_BYTES = 1048576;
9673
+ function invalid4(message) {
9674
+ throw new ConfigRepositoryError("invalid_configuration", message);
9675
+ }
9676
+ function corrupt3(message) {
9677
+ throw new ConfigRepositoryError("serving_snapshot_corrupt", message);
9678
+ }
9679
+ function canonicalAggregate(aggregate) {
9680
+ const result = parseNormalizeCompileAggregate(aggregate, undefined);
9681
+ if (!result.ok)
9682
+ invalid4("configuration aggregate is invalid");
9683
+ const aggregateJson = canonicalJson(result.value);
9684
+ const bytes = new TextEncoder().encode(aggregateJson).byteLength;
9685
+ if (bytes < 2 || bytes > MAX_AGGREGATE_BYTES) {
9686
+ invalid4("configuration aggregate is too large");
9886
9687
  }
9887
- const initialRevision = revisions[0];
9888
- if (initialRevision === undefined || initialRevision.content_hash !== INITIAL_REVISION_HASH || initialRevision.kind !== "config" || initialRevision.created_at !== 0) {
9889
- throw new ConfigRepositoryError("schema_corrupt", "initial revision identity is invalid");
9688
+ return { aggregateJson, contentHash: hashConfigurationContent(result.value) };
9689
+ }
9690
+ function appendServingSnapshot(db, snapshot, pluginCatalogHash) {
9691
+ if (!Number.isSafeInteger(snapshot?.revision) || snapshot.revision <= 0 || typeof snapshot.content_hash !== "string" || !validateDigest(snapshot.content_hash) || !validateDigest(pluginCatalogHash)) {
9692
+ invalid4("serving snapshot identity is invalid");
9890
9693
  }
9891
- const operations = readAllOperations(db);
9892
- if (operations.length !== revisions.length - 1) {
9893
- throw new ConfigRepositoryError("schema_corrupt", "revision operation cardinality is invalid");
9694
+ const normalized = canonicalAggregate(snapshot.aggregate);
9695
+ if (normalized.contentHash !== snapshot.content_hash) {
9696
+ invalid4("configuration aggregate content hash does not match");
9894
9697
  }
9895
- const operationsByRevision = new Map(operations.map((operation) => [operation.committed_revision, operation]));
9896
- for (const revision of revisions.slice(1)) {
9897
- const operation = operationsByRevision.get(revision.revision);
9898
- if (operation === undefined || operation.expected_revision !== revision.revision - 1 || operation.kind !== revision.kind || operation.created_at !== revision.created_at) {
9899
- throw new ConfigRepositoryError("schema_corrupt", "revision operation metadata is incoherent");
9698
+ const transaction = db.transaction(() => {
9699
+ const revision = sqliteGet(db, "SELECT revision,content_hash FROM configuration_revisions WHERE revision=?", snapshot.revision);
9700
+ if (revision === null || revision.content_hash !== snapshot.content_hash) {
9701
+ invalid4("serving snapshot revision does not match configuration history");
9900
9702
  }
9901
- }
9902
- const operationsByMutation = new Map(operations.map((operation) => [operation.mutation_id, operation]));
9903
- const workersByMutation = new Map;
9904
- for (const worker of readAllWorkers(db)) {
9905
- const operation = operationsByMutation.get(worker.mutation_id);
9906
- if (operation === undefined || worker.target_revision !== operation.committed_revision || worker.updated_at < operation.created_at || worker.updated_at > operation.updated_at) {
9907
- throw new ConfigRepositoryError("schema_corrupt", "operation worker metadata is incoherent");
9703
+ const existing = sqliteGet(db, `SELECT revision,content_hash,plugin_catalog_hash,aggregate_json
9704
+ FROM configuration_serving_snapshots
9705
+ WHERE revision=? AND content_hash=? AND plugin_catalog_hash=?`, snapshot.revision, snapshot.content_hash, pluginCatalogHash);
9706
+ if (existing !== null) {
9707
+ if (existing.aggregate_json !== normalized.aggregateJson) {
9708
+ corrupt3("configuration serving snapshot is immutable and inconsistent");
9709
+ }
9710
+ return;
9908
9711
  }
9909
- workersByMutation.set(worker.mutation_id, [...workersByMutation.get(worker.mutation_id) ?? [], worker]);
9712
+ db.run(`INSERT INTO configuration_serving_snapshots
9713
+ (revision,content_hash,plugin_catalog_hash,aggregate_json) VALUES (?,?,?,?)`, [snapshot.revision, snapshot.content_hash, pluginCatalogHash, normalized.aggregateJson]);
9714
+ });
9715
+ transaction.immediate();
9716
+ }
9717
+ function getServingSnapshot(db, key) {
9718
+ if (!Number.isSafeInteger(key?.revision) || key.revision <= 0 || typeof key.content_hash !== "string" || !validateDigest(key.content_hash) || typeof key.plugin_catalog_hash !== "string" || !validateDigest(key.plugin_catalog_hash)) {
9719
+ invalid4("serving snapshot lookup identity is invalid");
9910
9720
  }
9911
- for (const operation of operations) {
9912
- const workers = workersByMutation.get(operation.mutation_id) ?? [];
9913
- validateOperationRelations(operation, workers);
9914
- if (operation.committed_revision !== state.active_revision && (operation.state === "committed" || operation.state === "publishing" || operation.state === "draining")) {
9915
- throw new ConfigRepositoryError("schema_corrupt", "non-active operation is not terminal");
9721
+ return withConsistentRead(db, () => {
9722
+ const row = sqliteGet(db, `SELECT revision,content_hash,plugin_catalog_hash,aggregate_json
9723
+ FROM configuration_serving_snapshots
9724
+ WHERE revision=? AND content_hash=? AND plugin_catalog_hash=?`, key.revision, key.content_hash, key.plugin_catalog_hash);
9725
+ if (row === null)
9726
+ return null;
9727
+ if (row.revision !== key.revision || row.content_hash !== key.content_hash || row.plugin_catalog_hash !== key.plugin_catalog_hash || !Number.isSafeInteger(row.revision) || row.revision <= 0 || !validateDigest(row.content_hash) || !validateDigest(row.plugin_catalog_hash)) {
9728
+ corrupt3("configuration serving snapshot identity is invalid");
9729
+ }
9730
+ const bytes = new TextEncoder().encode(row.aggregate_json).byteLength;
9731
+ if (bytes < 2 || bytes > MAX_AGGREGATE_BYTES)
9732
+ corrupt3("configuration serving snapshot size is invalid");
9733
+ let parsed;
9734
+ try {
9735
+ parsed = JSON.parse(row.aggregate_json);
9736
+ } catch {
9737
+ corrupt3("configuration serving snapshot JSON is invalid");
9738
+ }
9739
+ let canonical;
9740
+ try {
9741
+ canonical = canonicalJson(parsed);
9742
+ } catch {
9743
+ corrupt3("configuration serving snapshot JSON is invalid");
9744
+ }
9745
+ if (canonical !== row.aggregate_json)
9746
+ corrupt3("configuration serving snapshot JSON is not canonical");
9747
+ const normalized = parseNormalizeCompileAggregate(parsed, undefined);
9748
+ if (!normalized.ok) {
9749
+ if (normalized.errors.some((error) => error.code === "unknown_field" && /^logical_configuration\.routes\[\d+\]\.(service_id|service)$/.test(error.path))) {
9750
+ throw new ConfigRepositoryError("invalid_configuration", "historical serving snapshot does not use the current configuration schema and cannot be loaded");
9751
+ }
9752
+ corrupt3("configuration serving snapshot aggregate is invalid");
9753
+ }
9754
+ if (canonicalJson(normalized.value) !== row.aggregate_json) {
9755
+ corrupt3("configuration serving snapshot aggregate normalization drifted");
9756
+ }
9757
+ if (hashConfigurationContent(normalized.value) !== key.content_hash) {
9758
+ corrupt3("configuration serving snapshot content hash is invalid");
9759
+ }
9760
+ const revision = sqliteGet(db, "SELECT revision,content_hash FROM configuration_revisions WHERE revision=?", key.revision);
9761
+ if (revision === null || revision.revision !== key.revision || revision.content_hash !== key.content_hash) {
9762
+ corrupt3("configuration serving snapshot revision is invalid");
9763
+ }
9764
+ return { revision: row.revision, content_hash: row.content_hash, aggregate: normalized.value };
9765
+ });
9766
+ }
9767
+ function auditServingSnapshotHistory(db) {
9768
+ const rows = db.query(`SELECT s.revision,s.content_hash,s.plugin_catalog_hash,s.aggregate_json,
9769
+ r.content_hash AS revision_hash FROM configuration_serving_snapshots s
9770
+ LEFT JOIN configuration_revisions r ON r.revision=s.revision`).all();
9771
+ for (const row of rows) {
9772
+ if (row.revision_hash !== row.content_hash || !validateDigest(row.content_hash) || !validateDigest(row.plugin_catalog_hash)) {
9773
+ corrupt3("historical serving snapshot identity is invalid");
9774
+ }
9775
+ try {
9776
+ const parsed = JSON.parse(row.aggregate_json);
9777
+ if (new TextEncoder().encode(row.aggregate_json).byteLength > MAX_AGGREGATE_BYTES || canonicalJson(parsed) !== row.aggregate_json || hashConfigurationContent(parsed) !== row.content_hash)
9778
+ corrupt3("historical serving snapshot content is invalid");
9779
+ } catch (error) {
9780
+ if (error instanceof ConfigRepositoryError)
9781
+ throw error;
9782
+ corrupt3("historical serving snapshot JSON is invalid");
9916
9783
  }
9917
9784
  }
9918
- const activeRevisionRow = revisions.at(-1);
9919
- if (activeRevisionRow === undefined)
9920
- throw new ConfigRepositoryError("schema_corrupt", "active revision is missing");
9921
- const activeOperation = operationsByRevision.get(state.active_revision) ?? null;
9922
- const activeTargetSlots = activeOperation === null ? [] : (workersByMutation.get(activeOperation.mutation_id) ?? []).map(({ worker_slot }) => worker_slot).sort((left, right) => left - right);
9923
- return {
9924
- activeRevision: state.active_revision,
9925
- activeRevisionRow,
9926
- activeOperation,
9927
- activeTargetSlots,
9928
- managementIdentity: activeOperation === null ? null : readManagementRequestIdentity(db, activeOperation.mutation_id)
9929
- };
9930
9785
  }
9931
- function verifyActiveRequestIdentity(audited, aggregate) {
9932
- const operation = audited.activeOperation;
9933
- if (operation === null)
9934
- return;
9935
- const requestHash = hashConfigurationRequest({
9936
- ...audited.managementIdentity,
9937
- kind: operation.kind,
9938
- expected_revision: operation.expected_revision,
9939
- aggregate,
9940
- target_worker_slots: audited.activeTargetSlots
9786
+
9787
+ // src/config-storage/schema-fingerprint.ts
9788
+ import { Database } from "bun:sqlite";
9789
+
9790
+ // src/database-schema.ts
9791
+ function pragmaName(name) {
9792
+ return `'${name.replaceAll("'", "''")}'`;
9793
+ }
9794
+ function normalizedSql(sql) {
9795
+ return (sql ?? "").match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`[^`]*`|\[[^\]]*\]|[^'"`\[]+/g)?.map((token) => /^['"`\[]/.test(token) ? token : token.replace(/\s+/g, " ")).join("").trim() ?? "";
9796
+ }
9797
+ function indexDescriptor(db, table) {
9798
+ const indexes = sqliteAll(db, 'SELECT name,"unique",origin,partial FROM pragma_index_list(?)', table);
9799
+ return indexes.map((index) => ({
9800
+ unique: index.unique,
9801
+ origin: index.origin,
9802
+ partial: index.partial,
9803
+ columns: sqliteAll(db, `PRAGMA index_xinfo(${pragmaName(index.name)})`).map(({ seqno, cid, name, desc, coll, key }) => ({ seqno, cid, name, desc, coll, key }))
9804
+ })).sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right)));
9805
+ }
9806
+ function databaseSchemaDescriptor(db) {
9807
+ const definitions = new Map(sqliteAll(db, `SELECT name,sql FROM sqlite_schema
9808
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'`).map((row) => [row.name, row.sql]));
9809
+ const tables = sqliteAll(db, `SELECT name,type,ncol,wr,strict FROM pragma_table_list
9810
+ WHERE schema='main' AND name NOT LIKE 'sqlite_%' ORDER BY name`);
9811
+ const objects = sqliteAll(db, `SELECT type,name,tbl_name,sql
9812
+ FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`).map((row) => ({
9813
+ type: row.type,
9814
+ name: row.name,
9815
+ table: row.tbl_name,
9816
+ sql: normalizedSql(row.sql)
9817
+ }));
9818
+ return canonicalJson({
9819
+ objects,
9820
+ tables: tables.map((table) => ({
9821
+ name: table.name,
9822
+ type: table.type,
9823
+ ncol: table.ncol,
9824
+ wr: table.wr,
9825
+ strict: table.strict,
9826
+ sql: normalizedSql(definitions.get(table.name) ?? null),
9827
+ columns: sqliteAll(db, `SELECT cid,name,type,"notnull",dflt_value,pk,hidden
9828
+ FROM pragma_table_xinfo(?) ORDER BY cid`, table.name),
9829
+ foreignKeys: sqliteAll(db, `SELECT id,seq,"table","from","to",on_update,on_delete,"match"
9830
+ FROM pragma_foreign_key_list(?) ORDER BY id,seq`, table.name),
9831
+ indexes: indexDescriptor(db, table.name)
9832
+ }))
9941
9833
  });
9942
- if (requestHash !== operation.request_hash) {
9943
- throw new ConfigRepositoryError("schema_corrupt", "active operation request hash is incoherent");
9944
- }
9945
9834
  }
9946
9835
 
9947
- // src/config-storage/persisted-json.ts
9948
- function parseCanonicalObject(text, field, reserved = new Set) {
9949
- try {
9950
- const value = JSON.parse(text);
9951
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
9952
- throw new TypeError("expected a JSON object");
9953
- }
9954
- if (text !== canonicalJson(value))
9955
- throw new TypeError("expected canonical JSON text");
9956
- const entries = Object.entries(value);
9957
- const key = entries.find(([name]) => reserved.has(name))?.[0];
9958
- if (key !== undefined)
9959
- throw new TypeError(`reserved key ${key}`);
9960
- return Object.fromEntries(entries);
9961
- } catch (error) {
9962
- throw new ConfigRepositoryError("schema_corrupt", `${field} is invalid`, error);
9836
+ // src/config-storage/migrations/service-references.ts
9837
+ var SERVICE_REFERENCES_MIGRATION = {
9838
+ version: 5,
9839
+ name: "service_references",
9840
+ up(db) {
9841
+ db.run(`CREATE TABLE service_references (
9842
+ id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
9843
+ owner_kind TEXT NOT NULL CHECK(owner_kind IN ('service','route')),
9844
+ owner_service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
9845
+ route_id TEXT REFERENCES routes(id) ON DELETE CASCADE,
9846
+ position INTEGER NOT NULL CHECK(position BETWEEN 0 AND 9007199254740991),
9847
+ service_id TEXT NOT NULL REFERENCES services(id),
9848
+ weight REAL NOT NULL CHECK(weight>0),
9849
+ priority REAL NOT NULL CHECK(priority>0),
9850
+ is_disabled INTEGER NOT NULL CHECK(is_disabled IN (0,1)),
9851
+ CHECK((owner_kind='service' AND owner_service_id IS NOT NULL AND route_id IS NULL AND owner_service_id<>service_id)
9852
+ OR (owner_kind='route' AND route_id IS NOT NULL AND owner_service_id IS NULL)),
9853
+ UNIQUE(owner_service_id,position), UNIQUE(route_id,position),
9854
+ UNIQUE(owner_service_id,service_id), UNIQUE(route_id,service_id)
9855
+ ) STRICT`);
9856
+ db.run(`INSERT INTO service_references(id,owner_kind,owner_service_id,route_id,position,service_id,weight,priority,is_disabled)
9857
+ SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2)
9858
+ || '-8' || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
9859
+ 'route',NULL,id,0,service_id,100,1,0 FROM routes WHERE service_id IS NOT NULL`);
9860
+ db.run("ALTER TABLE routes DROP COLUMN service_id");
9861
+ }
9862
+ };
9863
+
9864
+ // src/config-storage/migrations/management-protection.ts
9865
+ var MANAGEMENT_PROTECTION_MIGRATION = {
9866
+ version: 4,
9867
+ name: "management_protection",
9868
+ up(db) {
9869
+ db.run(`CREATE TABLE management_protection (
9870
+ id INTEGER PRIMARY KEY CHECK(id=1),
9871
+ required INTEGER NOT NULL CHECK(required IN (0,1)),
9872
+ selected_provider TEXT REFERENCES plugin_activations(plugin_name) DEFERRABLE INITIALLY DEFERRED,
9873
+ generation INTEGER NOT NULL CHECK(generation BETWEEN 0 AND 9007199254740991),
9874
+ CHECK((required=0 AND selected_provider IS NULL) OR (required=1 AND selected_provider IS NOT NULL))
9875
+ ) STRICT`);
9876
+ db.run("INSERT INTO management_protection(id,required,selected_provider,generation) VALUES(1,0,NULL,0)");
9877
+ db.run("CREATE TRIGGER management_protection_no_delete BEFORE DELETE ON management_protection BEGIN SELECT RAISE(ABORT,'management protection cannot be deleted'); END");
9878
+ db.run("CREATE TRIGGER management_protection_no_replace BEFORE INSERT ON management_protection WHEN EXISTS(SELECT 1 FROM management_protection) BEGIN SELECT RAISE(ABORT,'management protection cannot be replaced'); END");
9879
+ db.run("CREATE TRIGGER management_protection_fixed_identity BEFORE UPDATE OF id ON management_protection BEGIN SELECT RAISE(ABORT,'management protection identity is immutable'); END");
9880
+ db.run("ALTER TABLE configuration_operations ADD COLUMN management_provider TEXT");
9881
+ db.run("ALTER TABLE configuration_operations ADD COLUMN offline_management INTEGER NOT NULL DEFAULT 0 CHECK(offline_management IN (0,1))");
9882
+ }
9883
+ };
9884
+
9885
+ // src/config-storage/migrations/ui-menus.ts
9886
+ var UI_MENUS_MIGRATION = {
9887
+ version: 3,
9888
+ name: "instance_ui_menus",
9889
+ up(db) {
9890
+ db.run(`CREATE TABLE ui_menu_layout (
9891
+ id INTEGER PRIMARY KEY CHECK(id=1),
9892
+ revision INTEGER NOT NULL CHECK(revision >= 0 AND revision <= 9007199254740991),
9893
+ layout_json TEXT CHECK(layout_json IS NULL OR (json_valid(layout_json) AND json_type(layout_json)='array'
9894
+ AND length(CAST(layout_json AS BLOB)) <= 262144)),
9895
+ CHECK((revision=0 AND layout_json IS NULL) OR (revision>0 AND layout_json IS NOT NULL))
9896
+ ) STRICT`);
9897
+ db.run("INSERT INTO ui_menu_layout(id,revision,layout_json) VALUES(1,0,NULL)");
9898
+ db.run("CREATE TRIGGER ui_menu_layout_no_delete BEFORE DELETE ON ui_menu_layout BEGIN SELECT RAISE(ABORT,'menu singleton cannot be deleted'); END");
9899
+ db.run("CREATE TRIGGER ui_menu_layout_no_replace BEFORE INSERT ON ui_menu_layout WHEN EXISTS(SELECT 1 FROM ui_menu_layout) BEGIN SELECT RAISE(ABORT,'menu singleton cannot be replaced'); END");
9900
+ db.run("CREATE TRIGGER ui_menu_layout_fixed_identity BEFORE UPDATE OF id ON ui_menu_layout BEGIN SELECT RAISE(ABORT,'menu singleton identity is immutable'); END");
9901
+ }
9902
+ };
9903
+
9904
+ // src/config-storage/migrations/baseline.ts
9905
+ import { randomUUID } from "crypto";
9906
+
9907
+ // src/config-storage/schema.ts
9908
+ var CONFIG_SCHEMA_STATEMENTS = [
9909
+ `CREATE TABLE configuration_operation_workers (
9910
+ mutation_id TEXT NOT NULL,
9911
+ worker_slot INTEGER NOT NULL CHECK(worker_slot >= 0 AND worker_slot <= 9007199254740991),
9912
+ target_revision INTEGER NOT NULL CHECK(target_revision > 0 AND target_revision <= 9007199254740991),
9913
+ drain_recovery_generation INTEGER NOT NULL DEFAULT 0
9914
+ CHECK(drain_recovery_generation >= 0 AND drain_recovery_generation <= 9007199254740991),
9915
+ attempt_no INTEGER NOT NULL DEFAULT 0 CHECK(attempt_no >= 0 AND attempt_no <= 9007199254740991),
9916
+ last_begin_previous_attempt_no INTEGER CHECK(last_begin_previous_attempt_no IS NULL OR
9917
+ (last_begin_previous_attempt_no >= 0 AND last_begin_previous_attempt_no < 9007199254740991)),
9918
+ last_begin_reason TEXT CHECK(last_begin_reason IS NULL OR last_begin_reason IN ('initial','retry','master_recovery')),
9919
+ state TEXT NOT NULL CHECK(state IN ('pending','converged','failed')),
9920
+ applied_revision INTEGER CHECK(applied_revision IS NULL OR (applied_revision > 0 AND applied_revision <= 9007199254740991)),
9921
+ last_error TEXT,
9922
+ updated_at INTEGER NOT NULL CHECK(updated_at >= 0 AND updated_at <= 9007199254740991),
9923
+ PRIMARY KEY(mutation_id,worker_slot),
9924
+ FOREIGN KEY(mutation_id,target_revision) REFERENCES configuration_operations(mutation_id,committed_revision) ON DELETE CASCADE,
9925
+ CHECK((attempt_no=0 AND last_begin_previous_attempt_no IS NULL AND last_begin_reason IS NULL) OR
9926
+ (attempt_no=1 AND last_begin_previous_attempt_no=0 AND last_begin_reason IN ('initial','master_recovery')) OR
9927
+ (attempt_no > 1 AND last_begin_previous_attempt_no=attempt_no-1 AND
9928
+ last_begin_reason IN ('retry','master_recovery'))),
9929
+ CHECK((state='pending' AND applied_revision IS NULL AND last_error IS NULL) OR
9930
+ (state='converged' AND attempt_no > 0 AND applied_revision=target_revision AND last_error IS NULL) OR
9931
+ (state='failed' AND attempt_no > 0 AND last_error IS NOT NULL AND
9932
+ length(last_error) <= 512 AND length(trim(last_error)) > 0))
9933
+ ) STRICT, WITHOUT ROWID`,
9934
+ `CREATE TABLE configuration_operations (
9935
+ mutation_id TEXT PRIMARY KEY CHECK(length(mutation_id) BETWEEN 1 AND 128),
9936
+ request_hash TEXT NOT NULL CHECK(length(request_hash)=71 AND substr(request_hash,1,7)='sha256:' AND substr(request_hash,8) NOT GLOB '*[^0-9a-f]*'),
9937
+ expected_revision INTEGER NOT NULL CHECK(expected_revision > 0 AND expected_revision <= 9007199254740991),
9938
+ committed_revision INTEGER NOT NULL CHECK(committed_revision > 0 AND committed_revision <= 9007199254740991)
9939
+ REFERENCES configuration_revisions(revision),
9940
+ kind TEXT NOT NULL CHECK(kind IN ('config','admin_state')),
9941
+ target_worker_count INTEGER NOT NULL CHECK(target_worker_count >= 0 AND target_worker_count <= 9007199254740991),
9942
+ state TEXT NOT NULL CHECK(state IN ('committed','publishing','draining','converged','degraded')),
9943
+ result_status INTEGER,
9944
+ error_code TEXT,
9945
+ error_detail TEXT,
9946
+ drain_recovery_generation INTEGER NOT NULL DEFAULT 0
9947
+ CHECK(drain_recovery_generation >= 0 AND drain_recovery_generation <= 9007199254740991),
9948
+ last_drain_recovery_previous_generation INTEGER
9949
+ CHECK(last_drain_recovery_previous_generation IS NULL OR
9950
+ (last_drain_recovery_previous_generation >= 0 AND
9951
+ last_drain_recovery_previous_generation < 9007199254740991)),
9952
+ created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
9953
+ updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991),
9954
+ UNIQUE(mutation_id,committed_revision),
9955
+ CHECK((drain_recovery_generation=0 AND last_drain_recovery_previous_generation IS NULL) OR
9956
+ (drain_recovery_generation > 0 AND
9957
+ last_drain_recovery_previous_generation=drain_recovery_generation-1)),
9958
+ CHECK((state IN ('committed','publishing') AND drain_recovery_generation=0 AND
9959
+ result_status IS NULL AND error_code IS NULL AND error_detail IS NULL) OR
9960
+ (state='draining' AND result_status IS NULL AND error_code IS NULL AND error_detail IS NULL) OR
9961
+ (state='converged' AND drain_recovery_generation=0 AND
9962
+ result_status=200 AND error_code IS NULL AND error_detail IS NULL) OR
9963
+ (state='degraded' AND result_status=202 AND
9964
+ error_code IN ('replacement_convergence_failed','old_worker_drain_failed','control_readiness_failed') AND
9965
+ (error_code <> 'replacement_convergence_failed' OR drain_recovery_generation=0) AND
9966
+ error_detail IS NOT NULL AND length(error_detail) <= 512 AND length(trim(error_detail)) > 0))
9967
+ ) STRICT`,
9968
+ `CREATE TABLE configuration_recoveries (
9969
+ recovery_sequence INTEGER PRIMARY KEY AUTOINCREMENT CHECK(recovery_sequence > 0),
9970
+ recovery_id TEXT NOT NULL UNIQUE CHECK(length(recovery_id)=36 AND substr(recovery_id,9,1)='-' AND substr(recovery_id,14,1)='-' AND substr(recovery_id,19,1)='-' AND substr(recovery_id,24,1)='-' AND replace(recovery_id,'-','') NOT GLOB '*[^0-9a-f]*'),
9971
+ source_mutation_id TEXT NOT NULL,
9972
+ target_revision INTEGER NOT NULL CHECK(target_revision > 0 AND target_revision <= 9007199254740991),
9973
+ trigger TEXT NOT NULL CHECK(trigger IN ('automatic','manual')),
9974
+ state TEXT NOT NULL CHECK(state IN ('scheduled','running','succeeded','stopped')),
9975
+ attempt_count INTEGER NOT NULL CHECK(attempt_count >= 0 AND attempt_count <= 6),
9976
+ max_attempts INTEGER NOT NULL CHECK(max_attempts=6),
9977
+ next_retry_at INTEGER CHECK(next_retry_at IS NULL OR (next_retry_at >= 0 AND next_retry_at <= 9007199254740991)),
9978
+ final_reason_code TEXT CHECK(final_reason_code IS NULL OR final_reason_code IN
9979
+ ('target_serving','already_serving','deterministic_worker_rejection','deterministic_protocol_failure',
9980
+ 'deterministic_control_failure','retry_exhausted','revision_superseded','safety_outcome_unknown','fatal_source_failure')), final_reason_detail TEXT,
9981
+ created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
9982
+ updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991),
9983
+ FOREIGN KEY(source_mutation_id) REFERENCES configuration_operations(mutation_id),
9984
+ FOREIGN KEY(target_revision) REFERENCES configuration_revisions(revision),
9985
+ CHECK((state='scheduled' AND final_reason_code IS NULL AND final_reason_detail IS NULL) OR
9986
+ (state='running' AND next_retry_at IS NULL AND final_reason_code IS NULL AND final_reason_detail IS NULL) OR
9987
+ (state='succeeded' AND ((final_reason_code='already_serving' AND attempt_count=0) OR
9988
+ (final_reason_code='target_serving' AND attempt_count > 0)) AND next_retry_at IS NULL) OR
9989
+ (state='stopped' AND final_reason_code IN ('deterministic_worker_rejection','deterministic_protocol_failure',
9990
+ 'deterministic_control_failure','revision_superseded') AND next_retry_at IS NULL) OR
9991
+ (state='stopped' AND final_reason_code='retry_exhausted' AND attempt_count=6 AND next_retry_at IS NULL) OR
9992
+ (state='stopped' AND final_reason_code='safety_outcome_unknown' AND attempt_count > 0 AND next_retry_at IS NULL) OR
9993
+ (state='stopped' AND final_reason_code='fatal_source_failure' AND attempt_count=0 AND next_retry_at IS NULL)),
9994
+ CHECK(state <> 'scheduled' OR next_retry_at IS NULL OR next_retry_at > updated_at),
9995
+ CHECK(state <> 'scheduled' OR attempt_count < 6), CHECK(state <> 'running' OR attempt_count > 0),
9996
+ CHECK(final_reason_detail IS NULL OR (length(final_reason_detail) <= 512 AND length(trim(final_reason_detail)) > 0))
9997
+ ) STRICT`,
9998
+ `CREATE TABLE configuration_revisions (
9999
+ revision INTEGER PRIMARY KEY CHECK(revision > 0 AND revision <= 9007199254740991),
10000
+ content_hash TEXT NOT NULL CHECK(length(content_hash)=71 AND substr(content_hash,1,7)='sha256:' AND substr(content_hash,8) NOT GLOB '*[^0-9a-f]*'),
10001
+ kind TEXT NOT NULL CHECK(kind IN ('config','admin_state')),
10002
+ created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991)
10003
+ ) STRICT`,
10004
+ `CREATE TABLE configuration_serving_snapshots (
10005
+ revision INTEGER NOT NULL CHECK(revision > 0 AND revision <= 9007199254740991),
10006
+ content_hash TEXT NOT NULL CHECK(length(content_hash)=71 AND substr(content_hash,1,7)='sha256:' AND substr(content_hash,8) NOT GLOB '*[^0-9a-f]*'),
10007
+ plugin_catalog_hash TEXT NOT NULL CHECK(length(plugin_catalog_hash)=71 AND substr(plugin_catalog_hash,1,7)='sha256:' AND substr(plugin_catalog_hash,8) NOT GLOB '*[^0-9a-f]*'),
10008
+ aggregate_json TEXT NOT NULL CHECK(json_valid(aggregate_json) AND json_type(aggregate_json)='object'
10009
+ AND length(CAST(aggregate_json AS BLOB)) BETWEEN 2 AND 1048576),
10010
+ PRIMARY KEY(revision,content_hash,plugin_catalog_hash),
10011
+ FOREIGN KEY(revision,content_hash)
10012
+ REFERENCES configuration_revisions(revision,content_hash)
10013
+ ) STRICT, WITHOUT ROWID`,
10014
+ `CREATE TABLE configuration_state (
10015
+ id INTEGER PRIMARY KEY CHECK(id=1),
10016
+ schema_version INTEGER NOT NULL CHECK(schema_version=4),
10017
+ active_revision INTEGER NOT NULL CHECK(active_revision > 0 AND active_revision <= 9007199254740991)
10018
+ REFERENCES configuration_revisions(revision),
10019
+ created_at INTEGER NOT NULL CHECK(created_at >= 0 AND created_at <= 9007199254740991),
10020
+ updated_at INTEGER NOT NULL CHECK(updated_at >= created_at AND updated_at <= 9007199254740991)
10021
+ ) STRICT`,
10022
+ `CREATE TABLE plugin_activations (
10023
+ plugin_name TEXT PRIMARY KEY CHECK(length(plugin_name) > 0 AND plugin_name=lower(plugin_name)
10024
+ AND plugin_name NOT GLOB '*[^a-z0-9-]*' AND substr(plugin_name,1,1) GLOB '[a-z]'
10025
+ AND substr(plugin_name,-1,1)<>'-' AND plugin_name NOT LIKE '%--%')
10026
+ ) STRICT, WITHOUT ROWID`,
10027
+ `CREATE TABLE plugin_bindings (
10028
+ id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
10029
+ scope_kind TEXT NOT NULL CHECK(scope_kind IN ('global','service','route','upstream')),
10030
+ scope_owner TEXT NOT NULL,
10031
+ service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
10032
+ route_id TEXT REFERENCES routes(id) ON DELETE CASCADE,
10033
+ upstream_id TEXT REFERENCES upstreams(id) ON DELETE CASCADE,
10034
+ position INTEGER NOT NULL CHECK(position >= 0 AND position <= 9007199254740991),
10035
+ plugin_name TEXT NOT NULL CHECK(length(plugin_name) > 0 AND plugin_name=lower(plugin_name)
10036
+ AND plugin_name NOT GLOB '*[^a-z0-9-]*' AND substr(plugin_name,1,1) GLOB '[a-z]'
10037
+ AND substr(plugin_name,-1,1)<>'-' AND plugin_name NOT LIKE '%--%'),
10038
+ options_json TEXT CHECK(options_json IS NULL OR (json_valid(options_json) AND json_type(options_json)='object')),
10039
+ enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),
10040
+ CHECK((scope_kind='global' AND scope_owner='' AND service_id IS NULL AND route_id IS NULL AND upstream_id IS NULL) OR
10041
+ (scope_kind='service' AND service_id IS NOT NULL AND scope_owner=service_id AND route_id IS NULL AND upstream_id IS NULL) OR
10042
+ (scope_kind='route' AND route_id IS NOT NULL AND scope_owner=route_id AND service_id IS NULL AND upstream_id IS NULL) OR
10043
+ (scope_kind='upstream' AND upstream_id IS NOT NULL AND scope_owner=upstream_id AND service_id IS NULL AND route_id IS NULL)),
10044
+ UNIQUE(scope_kind,scope_owner,position)
10045
+ ) STRICT`,
10046
+ `CREATE TABLE routes (
10047
+ id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
10048
+ position INTEGER NOT NULL UNIQUE CHECK(position >= 0 AND position <= 9007199254740991),
10049
+ path TEXT NOT NULL UNIQUE CHECK(substr(path,1,1)='/'),
10050
+ service_id TEXT REFERENCES services(id),
10051
+ policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object')
10052
+ ) STRICT`,
10053
+ `CREATE TABLE schema_migrations (
10054
+ version INTEGER PRIMARY KEY CHECK(version > 0 AND version <= 9007199254740991),
10055
+ name TEXT NOT NULL UNIQUE
10056
+ ) STRICT`,
10057
+ `CREATE TABLE services (
10058
+ id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
10059
+ position INTEGER NOT NULL UNIQUE CHECK(position >= 0 AND position <= 9007199254740991),
10060
+ name TEXT NOT NULL UNIQUE CHECK(length(name) > 0),
10061
+ policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object')
10062
+ ) STRICT`,
10063
+ `CREATE TABLE settings (
10064
+ id INTEGER PRIMARY KEY CHECK(id=1),
10065
+ log_level TEXT CHECK(log_level IS NULL OR log_level IN ('trace','debug','info','warn','error','fatal')),
10066
+ body_parser_limit TEXT CHECK(body_parser_limit IS NULL OR CASE
10067
+ WHEN substr(body_parser_limit,-2) IN ('gb','mb','kb') THEN
10068
+ length(substr(body_parser_limit,1,length(body_parser_limit)-2)) > 0
10069
+ AND body_parser_limit=substr(body_parser_limit,1,length(body_parser_limit)-2)||substr(body_parser_limit,-2)
10070
+ AND substr(body_parser_limit,1,1) GLOB '[1-9]'
10071
+ AND substr(body_parser_limit,1,length(body_parser_limit)-2) NOT GLOB '*[^0-9]*'
10072
+ AND (length(substr(body_parser_limit,1,length(body_parser_limit)-2)) < 16 OR
10073
+ (length(substr(body_parser_limit,1,length(body_parser_limit)-2))=16 AND
10074
+ substr(body_parser_limit,1,length(body_parser_limit)-2)<='9007199254740991'))
10075
+ WHEN substr(body_parser_limit,-1)='b' THEN
10076
+ length(substr(body_parser_limit,1,length(body_parser_limit)-1)) > 0
10077
+ AND body_parser_limit=substr(body_parser_limit,1,length(body_parser_limit)-1)||'b'
10078
+ AND substr(body_parser_limit,1,1) GLOB '[1-9]'
10079
+ AND substr(body_parser_limit,1,length(body_parser_limit)-1) NOT GLOB '*[^0-9]*'
10080
+ AND (length(substr(body_parser_limit,1,length(body_parser_limit)-1)) < 16 OR
10081
+ (length(substr(body_parser_limit,1,length(body_parser_limit)-1))=16 AND
10082
+ substr(body_parser_limit,1,length(body_parser_limit)-1)<='9007199254740991'))
10083
+ ELSE 0 END),
10084
+ auth_json TEXT CHECK(auth_json IS NULL OR json_valid(auth_json)),
10085
+ logging_json TEXT CHECK(logging_json IS NULL OR json_valid(logging_json))
10086
+ , publication_json TEXT) STRICT`,
10087
+ `CREATE TABLE supervision_state (
10088
+ id INTEGER PRIMARY KEY CHECK(id=1),
10089
+ instance_id TEXT NOT NULL UNIQUE CHECK(length(instance_id)=36),
10090
+ controller_epoch INTEGER NOT NULL CHECK(controller_epoch >= 0 AND controller_epoch < 9007199254740991),
10091
+ current_controller_id TEXT,
10092
+ updated_at INTEGER NOT NULL CHECK(updated_at >= 0 AND updated_at < 9007199254740991)
10093
+ ) STRICT, WITHOUT ROWID`,
10094
+ `CREATE TABLE upstreams (
10095
+ id TEXT PRIMARY KEY CHECK(length(id)=36 AND substr(id,9,1)='-' AND substr(id,14,1)='-' AND substr(id,19,1)='-' AND substr(id,24,1)='-' AND replace(id,'-','') NOT GLOB '*[^0-9a-f]*'),
10096
+ owner_kind TEXT NOT NULL CHECK(owner_kind IN ('service','route')),
10097
+ service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
10098
+ route_id TEXT REFERENCES routes(id) ON DELETE CASCADE,
10099
+ position INTEGER NOT NULL CHECK(position >= 0 AND position <= 9007199254740991),
10100
+ target TEXT NOT NULL CHECK(length(target) > 0),
10101
+ weight REAL NOT NULL CHECK(weight > 0),
10102
+ priority REAL NOT NULL CHECK(priority > 0),
10103
+ is_disabled INTEGER NOT NULL CHECK(is_disabled IN (0,1)),
10104
+ policy_json TEXT NOT NULL CHECK(json_valid(policy_json) AND json_type(policy_json)='object'),
10105
+ CHECK((owner_kind='service' AND service_id IS NOT NULL AND route_id IS NULL) OR
10106
+ (owner_kind='route' AND route_id IS NOT NULL AND service_id IS NULL)),
10107
+ UNIQUE(owner_kind,service_id,position), UNIQUE(owner_kind,route_id,position)
10108
+ ) STRICT`,
10109
+ `CREATE UNIQUE INDEX configuration_recoveries_active_target_revision
10110
+ ON configuration_recoveries(target_revision) WHERE state IN ('scheduled','running')`,
10111
+ `CREATE UNIQUE INDEX configuration_revisions_revision_content_hash
10112
+ ON configuration_revisions(revision,content_hash)`,
10113
+ `CREATE TRIGGER configuration_operations_terminal_immutable_delete
10114
+ BEFORE DELETE ON configuration_operations
10115
+ WHEN OLD.state IN ('converged','degraded')
10116
+ BEGIN
10117
+ SELECT RAISE(ABORT,'terminal configuration operations are immutable');
10118
+ END`,
10119
+ `CREATE TRIGGER configuration_operations_terminal_immutable_update
10120
+ BEFORE UPDATE ON configuration_operations
10121
+ WHEN OLD.state IN ('converged','degraded')
10122
+ BEGIN
10123
+ SELECT RAISE(ABORT,'terminal configuration operations are immutable');
10124
+ END`,
10125
+ `CREATE TRIGGER configuration_recoveries_no_delete BEFORE DELETE ON configuration_recoveries
10126
+ BEGIN SELECT RAISE(ABORT,'configuration recovery cannot be deleted'); END`,
10127
+ `CREATE TRIGGER configuration_recoveries_sequence_insert_guard BEFORE INSERT ON configuration_recoveries
10128
+ WHEN NEW.recovery_sequence != -1 BEGIN SELECT RAISE(ABORT,'recovery sequence is database assigned'); END`,
10129
+ `CREATE TRIGGER configuration_recoveries_sequence_update_guard BEFORE UPDATE OF recovery_sequence ON configuration_recoveries
10130
+ BEGIN SELECT RAISE(ABORT,'recovery sequence is immutable'); END`,
10131
+ `CREATE TRIGGER configuration_serving_snapshots_immutable_delete
10132
+ BEFORE DELETE ON configuration_serving_snapshots
10133
+ BEGIN
10134
+ SELECT RAISE(ABORT,'configuration serving snapshots are immutable');
10135
+ END`,
10136
+ `CREATE TRIGGER configuration_serving_snapshots_immutable_update
10137
+ BEFORE UPDATE ON configuration_serving_snapshots
10138
+ BEGIN
10139
+ SELECT RAISE(ABORT,'configuration serving snapshots are immutable');
10140
+ END`,
10141
+ `CREATE TRIGGER configuration_state_singleton_id_immutable
10142
+ BEFORE UPDATE OF id ON configuration_state
10143
+ BEGIN
10144
+ SELECT RAISE(ABORT,'configuration state singleton identity is immutable');
10145
+ END`,
10146
+ `CREATE TRIGGER configuration_state_singleton_no_delete
10147
+ BEFORE DELETE ON configuration_state
10148
+ BEGIN
10149
+ SELECT RAISE(ABORT,'configuration state singleton cannot be deleted');
10150
+ END`,
10151
+ `CREATE TRIGGER configuration_state_singleton_no_replace
10152
+ BEFORE INSERT ON configuration_state
10153
+ WHEN EXISTS(SELECT 1 FROM configuration_state)
10154
+ BEGIN
10155
+ SELECT RAISE(ABORT,'configuration state singleton cannot be replaced');
10156
+ END`
10157
+ ];
10158
+
10159
+ // src/config-storage/migrations/baseline.ts
10160
+ var CONFIG_BASELINE_VERSION = 1;
10161
+ var CONFIG_BASELINE_NAME = "current_storage_baseline";
10162
+ var CONFIG_BASELINE = {
10163
+ version: CONFIG_BASELINE_VERSION,
10164
+ name: CONFIG_BASELINE_NAME,
10165
+ up(db) {
10166
+ for (const statement of CONFIG_SCHEMA_STATEMENTS)
10167
+ db.run(statement);
10168
+ const aggregate = { logical_configuration: { services: [], routes: [], plugins: [] }, plugin_activations: [] };
10169
+ replaceActiveMaterialization(db, aggregate);
10170
+ db.run("INSERT INTO configuration_revisions(revision,content_hash,kind,created_at) VALUES(1,?,'config',0)", [hashConfigurationContent(aggregate)]);
10171
+ db.run("INSERT INTO configuration_state(id,schema_version,active_revision,created_at,updated_at) VALUES(1,4,1,0,0)");
10172
+ db.run("INSERT INTO supervision_state(id,instance_id,controller_epoch,current_controller_id,updated_at) VALUES(1,?,0,NULL,0)", [randomUUID()]);
9963
10173
  }
9964
- }
10174
+ };
9965
10175
 
9966
- // src/config-storage/relation-materialization.ts
9967
- var UPSTREAM_RESERVED = new Set(["id", "position", "target", "weight", "priority", "is_disabled", "plugins"]);
9968
- function bindingValue(row) {
9969
- requireUuid(row.id, "plugin_bindings.id");
9970
- const scopeKind = requireOneOf(row.scope_kind, ["global", "service", "route", "upstream"], "plugin_bindings.scope_kind");
9971
- const compatibleOwner = scopeKind === "global" ? row.scope_owner === "" && row.service_id === null && row.route_id === null && row.upstream_id === null : scopeKind === "service" ? row.scope_owner === row.service_id && row.service_id !== null && row.route_id === null && row.upstream_id === null : scopeKind === "route" ? row.scope_owner === row.route_id && row.route_id !== null && row.service_id === null && row.upstream_id === null : row.scope_owner === row.upstream_id && row.upstream_id !== null && row.service_id === null && row.route_id === null;
9972
- if (!compatibleOwner)
9973
- throw new ConfigRepositoryError("schema_corrupt", "plugin binding owner is invalid");
9974
- if (scopeKind !== "global")
9975
- requireUuid(row.scope_owner, "plugin_bindings.scope_owner");
9976
- return {
9977
- id: row.id,
9978
- position: requireSafeInteger(row.position, "plugin_bindings.position"),
9979
- name: requirePluginName(row.plugin_name, "plugin_bindings.plugin_name"),
9980
- ...row.options_json === null ? {} : { options: parseCanonicalObject(row.options_json, "options_json") },
9981
- enabled: requireBooleanInteger(row.enabled, "plugin_bindings.enabled")
9982
- };
9983
- }
9984
- function groupBindings(rows) {
9985
- const groups = new Map;
9986
- for (const row of rows) {
9987
- const key = `${row.scope_kind}:${row.scope_owner}`;
9988
- const values = groups.get(key) ?? [];
9989
- values.push(bindingValue(row));
9990
- groups.set(key, values);
10176
+ // src/config-storage/migrations/publication-retirement.ts
10177
+ var PUBLICATION_RETIREMENT_MIGRATION = {
10178
+ version: 2,
10179
+ name: "publication_retirement_and_runtime_recovery",
10180
+ up(db) {
10181
+ db.run(`CREATE TABLE configuration_retirements (
10182
+ mutation_id TEXT PRIMARY KEY REFERENCES configuration_operations(mutation_id),
10183
+ generation INTEGER NOT NULL CHECK(generation >= 0),
10184
+ members_json TEXT NOT NULL CHECK(json_valid(members_json) AND json_type(members_json)='array'),
10185
+ state TEXT NOT NULL CHECK(state IN ('pending','stopped','released')),
10186
+ reason_code TEXT CHECK(reason_code IS NULL OR reason_code IN ('exit_unknown','cleanup_unknown','release_unknown')),
10187
+ updated_at INTEGER NOT NULL CHECK(updated_at >= 0)
10188
+ ) STRICT`);
10189
+ db.run(`CREATE TABLE configuration_retirement_evidence (
10190
+ mutation_id TEXT NOT NULL REFERENCES configuration_retirements(mutation_id),
10191
+ member_id TEXT NOT NULL,
10192
+ exit_state TEXT NOT NULL CHECK(exit_state IN ('live','confirmed','unknown')),
10193
+ cleanup_state TEXT NOT NULL CHECK(cleanup_state IN ('success','failed','unknown')),
10194
+ exit_boot_id TEXT,
10195
+ exit_deadline_ns TEXT,
10196
+ start_boot_id TEXT,
10197
+ start_deadline_ns TEXT,
10198
+ updated_at INTEGER NOT NULL CHECK(updated_at >= 0),
10199
+ CHECK((exit_boot_id IS NULL AND exit_deadline_ns IS NULL) OR (exit_boot_id IS NOT NULL AND exit_deadline_ns IS NOT NULL)),
10200
+ CHECK((start_boot_id IS NULL AND start_deadline_ns IS NULL) OR (start_boot_id IS NOT NULL AND start_deadline_ns IS NOT NULL)),
10201
+ PRIMARY KEY(mutation_id,member_id)
10202
+ ) STRICT, WITHOUT ROWID`);
10203
+ db.run("CREATE TRIGGER retirement_start_deadline_immutable BEFORE UPDATE OF start_boot_id,start_deadline_ns ON configuration_retirement_evidence WHEN OLD.start_deadline_ns IS NOT NULL AND (NEW.start_boot_id IS NOT OLD.start_boot_id OR NEW.start_deadline_ns IS NOT OLD.start_deadline_ns) BEGIN SELECT RAISE(ABORT,'retirement start deadline is immutable'); END");
10204
+ db.run(`CREATE TABLE configuration_publication_serving_sets (
10205
+ mutation_id TEXT NOT NULL REFERENCES configuration_operations(mutation_id),
10206
+ generation INTEGER NOT NULL CHECK(generation >= 0),
10207
+ attempt_key TEXT NOT NULL CHECK(json_valid(attempt_key) AND json_type(attempt_key)='array'),
10208
+ workers_json TEXT NOT NULL CHECK(json_valid(workers_json) AND json_type(workers_json)='array'),
10209
+ exited_json TEXT NOT NULL CHECK(json_valid(exited_json) AND json_type(exited_json)='array'),
10210
+ PRIMARY KEY(mutation_id,generation,attempt_key)
10211
+ ) STRICT, WITHOUT ROWID`);
10212
+ db.run("CREATE TRIGGER publication_serving_authority_no_delete BEFORE DELETE ON configuration_publication_serving_sets BEGIN SELECT RAISE(ABORT,'publication serving authority history is immutable'); END");
10213
+ db.run("CREATE TRIGGER publication_serving_authority_no_update BEFORE UPDATE OF mutation_id,generation,attempt_key,workers_json ON configuration_publication_serving_sets BEGIN SELECT RAISE(ABORT,'publication serving attempt identity is immutable'); END");
10214
+ db.run("CREATE TRIGGER publication_serving_authority_no_replace BEFORE INSERT ON configuration_publication_serving_sets WHEN EXISTS(SELECT 1 FROM configuration_publication_serving_sets WHERE mutation_id=NEW.mutation_id AND generation=NEW.generation AND attempt_key=NEW.attempt_key) BEGIN SELECT RAISE(ABORT,'publication serving authority history is immutable'); END");
10215
+ db.run(`CREATE TABLE runtime_recovery_budget (
10216
+ id INTEGER PRIMARY KEY CHECK(id=1),
10217
+ tickets INTEGER NOT NULL CHECK(tickets BETWEEN 0 AND 2),
10218
+ ticket_id TEXT,
10219
+ ticket_boot_id TEXT,
10220
+ retry_at_ns TEXT,
10221
+ healthy_owner TEXT,
10222
+ healthy_boot_id TEXT,
10223
+ healthy_since_ns TEXT,
10224
+ healthy_last_ns TEXT,
10225
+ CHECK((tickets=0 AND ticket_id IS NULL AND ticket_boot_id IS NULL AND retry_at_ns IS NULL) OR
10226
+ (tickets>0 AND ticket_id IS NOT NULL AND ticket_boot_id IS NOT NULL AND retry_at_ns IS NOT NULL)),
10227
+ CHECK((healthy_owner IS NULL AND healthy_boot_id IS NULL AND healthy_since_ns IS NULL AND healthy_last_ns IS NULL) OR
10228
+ (healthy_owner IS NOT NULL AND healthy_boot_id IS NOT NULL AND healthy_since_ns IS NOT NULL AND healthy_last_ns IS NOT NULL))
10229
+ ) STRICT`);
10230
+ db.run("INSERT INTO runtime_recovery_budget(id,tickets) VALUES(1,0)");
10231
+ db.run(`CREATE TABLE configuration_retirement_retry_requests (
10232
+ request_id TEXT PRIMARY KEY,
10233
+ mutation_id TEXT NOT NULL REFERENCES configuration_retirements(mutation_id),
10234
+ expected_revision INTEGER NOT NULL CHECK(expected_revision>0),
10235
+ created_at INTEGER NOT NULL CHECK(created_at>=0)
10236
+ ) STRICT, WITHOUT ROWID`);
10237
+ db.run("CREATE TRIGGER retirement_retry_no_delete BEFORE DELETE ON configuration_retirement_retry_requests BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
10238
+ db.run("CREATE TRIGGER retirement_retry_no_update BEFORE UPDATE ON configuration_retirement_retry_requests BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
10239
+ db.run("CREATE TRIGGER retirement_retry_no_replace BEFORE INSERT ON configuration_retirement_retry_requests WHEN EXISTS(SELECT 1 FROM configuration_retirement_retry_requests WHERE request_id=NEW.request_id) BEGIN SELECT RAISE(ABORT,'retirement retry identity is immutable'); END");
10240
+ db.run(`CREATE TABLE runtime_recovery_tickets (
10241
+ ticket_id TEXT PRIMARY KEY,
10242
+ owner TEXT NOT NULL,
10243
+ boot_id TEXT NOT NULL,
10244
+ consumed_at_ns TEXT NOT NULL,
10245
+ retry_at_ns TEXT NOT NULL
10246
+ ) STRICT, WITHOUT ROWID`);
10247
+ db.run("CREATE TRIGGER runtime_recovery_tickets_no_delete BEFORE DELETE ON runtime_recovery_tickets BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
10248
+ db.run("CREATE TRIGGER runtime_recovery_tickets_no_update BEFORE UPDATE ON runtime_recovery_tickets BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
10249
+ db.run("CREATE TRIGGER runtime_recovery_tickets_no_replace BEFORE INSERT ON runtime_recovery_tickets WHEN EXISTS(SELECT 1 FROM runtime_recovery_tickets WHERE ticket_id=NEW.ticket_id) BEGIN SELECT RAISE(ABORT,'runtime recovery ticket history is immutable'); END");
10250
+ db.run("CREATE TRIGGER runtime_recovery_budget_no_delete BEFORE DELETE ON runtime_recovery_budget BEGIN SELECT RAISE(ABORT,'runtime recovery budget cannot be deleted'); END");
10251
+ db.run("CREATE TRIGGER runtime_recovery_budget_no_replace BEFORE INSERT ON runtime_recovery_budget WHEN EXISTS(SELECT 1 FROM runtime_recovery_budget) BEGIN SELECT RAISE(ABORT,'runtime recovery budget cannot be replaced'); END");
10252
+ db.run("CREATE TRIGGER runtime_recovery_budget_fixed_identity BEFORE UPDATE OF id ON runtime_recovery_budget BEGIN SELECT RAISE(ABORT,'runtime recovery budget identity is immutable'); END");
9991
10253
  }
9992
- return groups;
9993
- }
9994
- function takeGroup(groups, key) {
9995
- const values = groups.get(key) ?? [];
9996
- groups.delete(key);
9997
- return values;
10254
+ };
10255
+
10256
+ // src/config-storage/migrations/plan.ts
10257
+ var CONFIG_MIGRATIONS = [CONFIG_BASELINE, PUBLICATION_RETIREMENT_MIGRATION, UI_MENUS_MIGRATION, MANAGEMENT_PROTECTION_MIGRATION, SERVICE_REFERENCES_MIGRATION];
10258
+ var CONFIG_SCHEMA_VERSION = CONFIG_MIGRATIONS.at(-1).version;
10259
+
10260
+ // src/config-storage/schema-fingerprint.ts
10261
+ function readSchemaObjectFingerprint(db) {
10262
+ return canonicalJson(sqliteAll(db, `SELECT type,name,tbl_name,sql
10263
+ FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`));
9998
10264
  }
9999
- function groupUpstreams(rows, bindings) {
10000
- const groups = new Map;
10001
- for (const row of rows) {
10002
- requireUuid(row.id, "upstreams.id");
10003
- const ownerKind = requireOneOf(row.owner_kind, ["service", "route"], "upstreams.owner_kind");
10004
- if (ownerKind === "service" && row.route_id !== null || ownerKind === "route" && row.service_id !== null) {
10005
- throw new ConfigRepositoryError("schema_corrupt", "upstream has incompatible owners");
10265
+ var expectedDescriptors = new Map;
10266
+ function verifySchemaFingerprint(db, version = CONFIG_SCHEMA_VERSION, plan = CONFIG_MIGRATIONS) {
10267
+ if (!plan.some((migration) => migration.version === version)) {
10268
+ throw new ConfigRepositoryError("schema_corrupt", "unsupported configuration schema version");
10269
+ }
10270
+ let expected = plan === CONFIG_MIGRATIONS ? expectedDescriptors.get(version) : undefined;
10271
+ if (expected === undefined) {
10272
+ const reference = new Database(":memory:", { strict: true });
10273
+ try {
10274
+ for (const migration of plan.filter((migration) => migration.version <= version))
10275
+ migration.up(reference);
10276
+ expected = databaseSchemaDescriptor(reference);
10277
+ } finally {
10278
+ reference.close(true);
10006
10279
  }
10007
- const ownerId = ownerKind === "service" ? row.service_id : row.route_id;
10008
- if (ownerId === null)
10009
- throw new ConfigRepositoryError("schema_corrupt", "upstream owner is missing");
10010
- requireUuid(ownerId, "upstreams.owner_id");
10011
- const key = `${ownerKind}:${ownerId}`;
10012
- const values = groups.get(key) ?? [];
10013
- values.push({
10014
- ...parseCanonicalObject(row.policy_json, "upstream policy_json", UPSTREAM_RESERVED),
10015
- id: row.id,
10016
- position: requireSafeInteger(row.position, "upstreams.position"),
10017
- target: row.target,
10018
- weight: requirePositiveFinite(row.weight, "upstreams.weight"),
10019
- priority: requirePositiveFinite(row.priority, "upstreams.priority"),
10020
- is_disabled: requireBooleanInteger(row.is_disabled, "upstreams.is_disabled"),
10021
- plugins: takeGroup(bindings, `upstream:${row.id}`)
10022
- });
10023
- groups.set(key, values);
10280
+ if (plan === CONFIG_MIGRATIONS)
10281
+ expectedDescriptors.set(version, expected);
10024
10282
  }
10025
- return groups;
10026
- }
10027
-
10028
- // src/config-storage/read-materialization.ts
10029
- var SERVICE_RESERVED = new Set(["id", "position", "name", "endpoints", "plugins"]);
10030
- var ROUTE_RESERVED = new Set(["id", "position", "path", "service_id", "service", "endpoints", "plugins"]);
10031
- function readSettings(db) {
10032
- const hasPublication = sqliteAll(db, "PRAGMA table_info(settings)").some(({ name }) => name === "publication_json");
10033
- const rows = sqliteAll(db, `SELECT log_level,body_parser_limit,auth_json,logging_json${hasPublication ? ",publication_json" : ",NULL AS publication_json"} FROM settings`);
10034
- const row = rows[0];
10035
- if (rows.length !== 1 || row === undefined)
10036
- throw new ConfigRepositoryError("schema_corrupt", "settings singleton is broken");
10037
- return {
10038
- ...row.log_level === null ? {} : { log_level: requireOptionalGlobalScalar(row.log_level, "log_level") },
10039
- ...row.body_parser_limit === null ? {} : {
10040
- body_parser_limit: requireOptionalGlobalScalar(row.body_parser_limit, "body_parser_limit")
10041
- },
10042
- ...row.auth_json === null ? {} : { auth: parseCanonicalObject(row.auth_json, "auth_json") },
10043
- ...row.logging_json === null ? {} : { logging: parseCanonicalObject(row.logging_json, "logging_json") },
10044
- ...row.publication_json === null ? {} : { publication: parseCanonicalObject(row.publication_json, "publication_json") }
10045
- };
10046
- }
10047
- function readRawActiveAggregate(db) {
10048
- const bindings = groupBindings(sqliteAll(db, `SELECT id,scope_kind,scope_owner,service_id,route_id,
10049
- upstream_id,position,plugin_name,options_json,enabled FROM plugin_bindings ORDER BY scope_kind,scope_owner,position`));
10050
- const upstreams = groupUpstreams(sqliteAll(db, `SELECT id,owner_kind,service_id,route_id,
10051
- position,target,weight,priority,is_disabled,policy_json FROM upstreams ORDER BY owner_kind,position`), bindings);
10052
- const services = sqliteAll(db, "SELECT id,position,name,policy_json FROM services ORDER BY position").map((row) => ({
10053
- ...parseCanonicalObject(row.policy_json, "service policy_json", SERVICE_RESERVED),
10054
- id: requireUuid(row.id, "services.id"),
10055
- position: requireSafeInteger(row.position, "services.position"),
10056
- name: row.name,
10057
- endpoints: takeGroup(upstreams, `service:${row.id}`),
10058
- plugins: takeGroup(bindings, `service:${row.id}`)
10059
- }));
10060
- const routes = sqliteAll(db, "SELECT id,position,path,service_id,policy_json FROM routes ORDER BY position").map((row) => ({
10061
- ...parseCanonicalObject(row.policy_json, "route policy_json", ROUTE_RESERVED),
10062
- id: requireUuid(row.id, "routes.id"),
10063
- position: requireSafeInteger(row.position, "routes.position"),
10064
- path: row.path,
10065
- ...row.service_id === null ? { endpoints: takeGroup(upstreams, `route:${row.id}`) } : { service_id: requireUuid(row.service_id, "routes.service_id") },
10066
- plugins: takeGroup(bindings, `route:${row.id}`)
10067
- }));
10068
- const activations = sqliteAll(db, "SELECT plugin_name FROM plugin_activations ORDER BY plugin_name");
10069
- const input = { logical_configuration: {
10070
- ...readSettings(db),
10071
- services,
10072
- routes,
10073
- plugins: takeGroup(bindings, "global:")
10074
- }, plugin_activations: activations.map(({ plugin_name }) => ({
10075
- plugin_name: requirePluginName(plugin_name, "plugin_activations.plugin_name")
10076
- })) };
10077
- if (bindings.size !== 0 || upstreams.size !== 0) {
10078
- throw new ConfigRepositoryError("schema_corrupt", "normalized relation rows were not consumed");
10283
+ if (databaseSchemaDescriptor(db) !== expected) {
10284
+ throw new ConfigRepositoryError("schema_corrupt", "configuration schema fingerprint is invalid");
10079
10285
  }
10080
- return input;
10081
- }
10082
- function readActiveAggregate(db) {
10083
- const result = parseNormalizeCompileAggregate(readRawActiveAggregate(db));
10084
- if (!result.ok)
10085
- throw new ConfigRepositoryError("schema_corrupt", "active configuration is invalid", result.errors);
10086
- return result.value;
10087
10286
  }
10088
10287
 
10089
- // src/config-storage/consistent-read.ts
10090
- function withConsistentRead(db, fn) {
10091
- if (db.inTransaction)
10092
- return fn();
10093
- return db.transaction(fn).deferred();
10288
+ // src/config-storage/sqlite-errors.ts
10289
+ function isSqliteBusyError(error) {
10290
+ if (!(error instanceof Error))
10291
+ return false;
10292
+ const sqliteError = error;
10293
+ const errno = sqliteError.errno;
10294
+ if (typeof errno === "number" && ((errno & 255) === 5 || (errno & 255) === 6))
10295
+ return true;
10296
+ const code = sqliteError.code;
10297
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || code?.startsWith("SQLITE_BUSY_") === true || code?.startsWith("SQLITE_LOCKED_") === true;
10298
+ }
10299
+ function repositoryFailure(message, cause) {
10300
+ return new ConfigRepositoryError("repository_failure", message, cause);
10094
10301
  }
10095
10302
 
10096
10303
  // src/config-storage/repository-snapshot.ts
@@ -10098,6 +10305,8 @@ function verifyIntegrity(db, allowActiveRecoveryDrift = false, checkDatabasePage
10098
10305
  verifySchemaFingerprint(db);
10099
10306
  readManagementProtection(db);
10100
10307
  verifyRecoveryIntegrity(db, allowActiveRecoveryDrift);
10308
+ if (checkDatabasePages)
10309
+ auditServingSnapshotHistory(db);
10101
10310
  const integrity = checkDatabasePages ? sqliteGet(db, "PRAGMA integrity_check")?.integrity_check : "ok";
10102
10311
  const foreignKeyFailures = sqliteAll(db, "PRAGMA foreign_key_check");
10103
10312
  if (integrity !== "ok" || foreignKeyFailures.length > 0) {
@@ -10144,101 +10353,109 @@ function verifyRepositoryIntegrity(db) {
10144
10353
  }
10145
10354
  }
10146
10355
 
10147
- // src/config-storage/serving-snapshot.ts
10148
- var MAX_AGGREGATE_BYTES = 1048576;
10149
- function invalid4(message) {
10150
- throw new ConfigRepositoryError("invalid_configuration", message);
10151
- }
10152
- function corrupt3(message) {
10153
- throw new ConfigRepositoryError("serving_snapshot_corrupt", message);
10154
- }
10155
- function canonicalAggregate(aggregate) {
10156
- const result = parseNormalizeCompileAggregate(aggregate, undefined);
10157
- if (!result.ok)
10158
- invalid4("configuration aggregate is invalid");
10159
- const aggregateJson = canonicalJson(result.value);
10160
- const bytes = new TextEncoder().encode(aggregateJson).byteLength;
10161
- if (bytes < 2 || bytes > MAX_AGGREGATE_BYTES) {
10162
- invalid4("configuration aggregate is too large");
10163
- }
10164
- return { aggregateJson, contentHash: hashConfigurationContent(result.value) };
10165
- }
10166
- function appendServingSnapshot(db, snapshot, pluginCatalogHash) {
10167
- if (!Number.isSafeInteger(snapshot?.revision) || snapshot.revision <= 0 || typeof snapshot.content_hash !== "string" || !validateDigest(snapshot.content_hash) || !validateDigest(pluginCatalogHash)) {
10168
- invalid4("serving snapshot identity is invalid");
10169
- }
10170
- const normalized = canonicalAggregate(snapshot.aggregate);
10171
- if (normalized.contentHash !== snapshot.content_hash) {
10172
- invalid4("configuration aggregate content hash does not match");
10173
- }
10174
- const transaction = db.transaction(() => {
10175
- const revision = sqliteGet(db, "SELECT revision,content_hash FROM configuration_revisions WHERE revision=?", snapshot.revision);
10176
- if (revision === null || revision.content_hash !== snapshot.content_hash) {
10177
- invalid4("serving snapshot revision does not match configuration history");
10178
- }
10179
- const existing = sqliteGet(db, `SELECT revision,content_hash,plugin_catalog_hash,aggregate_json
10180
- FROM configuration_serving_snapshots
10181
- WHERE revision=? AND content_hash=? AND plugin_catalog_hash=?`, snapshot.revision, snapshot.content_hash, pluginCatalogHash);
10182
- if (existing !== null) {
10183
- if (existing.aggregate_json !== normalized.aggregateJson) {
10184
- corrupt3("configuration serving snapshot is immutable and inconsistent");
10185
- }
10186
- return;
10356
+ // src/config-storage/service-reference-upgrade.ts
10357
+ function convertServiceReferenceRevision(db, options) {
10358
+ const unfinished = db.query(`
10359
+ SELECT 'publication' AS kind,mutation_id AS identity,state FROM configuration_operations
10360
+ WHERE state IN ('committed','publishing','draining')
10361
+ UNION ALL SELECT 'retirement',mutation_id,state FROM configuration_retirements WHERE state<>'released'
10362
+ UNION ALL SELECT 'recovery',recovery_id,state FROM configuration_recoveries
10363
+ WHERE state IN ('scheduled','running') OR (state='stopped'
10364
+ AND target_revision=(SELECT active_revision FROM configuration_state WHERE id=1)
10365
+ AND recovery_sequence=(SELECT max(recovery_sequence) FROM configuration_recoveries
10366
+ WHERE target_revision=(SELECT active_revision FROM configuration_state WHERE id=1)))
10367
+ LIMIT 1`).get();
10368
+ if (unfinished)
10369
+ throw new ConfigRepositoryError("migration_failed", `service reference upgrade cannot resume ${unfinished.kind} ${unfinished.identity} (${unfinished.state}); finish it with the previous executable before upgrading`);
10370
+ verifyRepositoryIntegrity(db);
10371
+ const audited = auditConfigurationTables(db);
10372
+ const aggregate = readActiveAggregate(db);
10373
+ const previous = {
10374
+ ...aggregate,
10375
+ logical_configuration: {
10376
+ ...aggregate.logical_configuration,
10377
+ routes: aggregate.logical_configuration.routes.map((route) => {
10378
+ if (!route.service_references)
10379
+ return route;
10380
+ const { service_references, ...rest } = route;
10381
+ if (service_references.length !== 1)
10382
+ throw new ConfigRepositoryError("migration_failed", "unexpected route references during upgrade");
10383
+ return { ...rest, service_id: service_references[0].service_id };
10384
+ })
10187
10385
  }
10188
- db.run(`INSERT INTO configuration_serving_snapshots
10189
- (revision,content_hash,plugin_catalog_hash,aggregate_json) VALUES (?,?,?,?)`, [snapshot.revision, snapshot.content_hash, pluginCatalogHash, normalized.aggregateJson]);
10386
+ };
10387
+ const previousHash = hashConfigurationContent(previous);
10388
+ if (previousHash !== audited.activeRevisionRow.content_hash) {
10389
+ throw new ConfigRepositoryError("schema_corrupt", "pre-upgrade configuration content hash does not match");
10390
+ }
10391
+ verifyActiveRequestIdentity(audited, previous);
10392
+ if (hashConfigurationContent(aggregate) === previousHash)
10393
+ return;
10394
+ const snapshot = { revision: audited.activeRevision, content_hash: previousHash, aggregate: previous };
10395
+ const operationId = randomUUID2(), now = Math.max(Date.now(), audited.activeOperation?.updated_at ?? 0);
10396
+ const prepared = prepareCommitCommand({
10397
+ mutation_id: operationId,
10398
+ expected_revision: snapshot.revision,
10399
+ aggregate,
10400
+ kind: "config",
10401
+ created_at: now,
10402
+ target_worker_slots: []
10190
10403
  });
10191
- transaction.immediate();
10404
+ const provider = readManagementProtection(db).selectedProvider;
10405
+ const command = {
10406
+ ...prepared,
10407
+ managementProvider: provider,
10408
+ requestHash: hashConfigurationRequest({
10409
+ kind: prepared.kind,
10410
+ expected_revision: prepared.expectedRevision,
10411
+ aggregate: prepared.aggregate,
10412
+ target_worker_slots: [],
10413
+ management_provider: provider
10414
+ })
10415
+ };
10416
+ const result = commitTransaction(db, command, options, snapshot);
10417
+ if (result.kind !== "committed")
10418
+ throw new ConfigRepositoryError("migration_failed", `service reference revision commit failed (${result.kind})`);
10419
+ beginPublication(db, operationId, now);
10420
+ markDraining(db, operationId, now);
10421
+ finalizePublication(db, operationId, { outcome: "converged", old_workers_exited: true }, now);
10422
+ readRepositorySnapshot(db);
10192
10423
  }
10193
- function getServingSnapshot(db, key) {
10194
- if (!Number.isSafeInteger(key?.revision) || key.revision <= 0 || typeof key.content_hash !== "string" || !validateDigest(key.content_hash) || typeof key.plugin_catalog_hash !== "string" || !validateDigest(key.plugin_catalog_hash)) {
10195
- invalid4("serving snapshot lookup identity is invalid");
10424
+
10425
+ // src/config-storage/migrations/index.ts
10426
+ function migrateConfigurationDatabase(db, plan = CONFIG_MIGRATIONS, options = {}) {
10427
+ try {
10428
+ db.transaction(() => {
10429
+ if (!plan.length || plan[0] !== CONFIG_MIGRATIONS[0] || plan.some((m, i) => i > 0 && m.version !== plan[i - 1].version + 1) || new Set(plan.map((m) => m.name)).size !== plan.length)
10430
+ throw new ConfigRepositoryError("migration_failed", "invalid configuration migration plan");
10431
+ const count = db.query("SELECT count(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").get().count;
10432
+ let applied = [];
10433
+ if (count > 0) {
10434
+ applied = db.query("SELECT version,name FROM schema_migrations ORDER BY version").all();
10435
+ if (!applied.length || applied.length > plan.length || applied.some((r, i) => r.version !== plan[i]?.version || r.name !== plan[i]?.name))
10436
+ throw new ConfigRepositoryError("schema_corrupt", "configuration migration history is not an exact baseline prefix");
10437
+ verifySchemaFingerprint(db, applied.at(-1).version, plan);
10438
+ }
10439
+ for (const migration of plan.slice(applied.length)) {
10440
+ migration.up(db);
10441
+ db.run("INSERT INTO schema_migrations(version,name) VALUES(?,?)", [migration.version, migration.name]);
10442
+ }
10443
+ verifySchemaFingerprint(db, plan.at(-1).version, plan);
10444
+ if (plan === CONFIG_MIGRATIONS && applied.length > 0 && applied.at(-1).version < 5) {
10445
+ convertServiceReferenceRevision(db, options);
10446
+ }
10447
+ }).immediate();
10448
+ } catch (error) {
10449
+ if (error instanceof ConfigRepositoryError)
10450
+ throw error;
10451
+ if (isSqliteBusyError(error))
10452
+ throw repositoryFailure("configuration migration was blocked by SQLite", error);
10453
+ throw new ConfigRepositoryError("migration_failed", "configuration migration failed", error);
10196
10454
  }
10197
- return withConsistentRead(db, () => {
10198
- const row = sqliteGet(db, `SELECT revision,content_hash,plugin_catalog_hash,aggregate_json
10199
- FROM configuration_serving_snapshots
10200
- WHERE revision=? AND content_hash=? AND plugin_catalog_hash=?`, key.revision, key.content_hash, key.plugin_catalog_hash);
10201
- if (row === null)
10202
- return null;
10203
- if (row.revision !== key.revision || row.content_hash !== key.content_hash || row.plugin_catalog_hash !== key.plugin_catalog_hash || !Number.isSafeInteger(row.revision) || row.revision <= 0 || !validateDigest(row.content_hash) || !validateDigest(row.plugin_catalog_hash)) {
10204
- corrupt3("configuration serving snapshot identity is invalid");
10205
- }
10206
- const bytes = new TextEncoder().encode(row.aggregate_json).byteLength;
10207
- if (bytes < 2 || bytes > MAX_AGGREGATE_BYTES)
10208
- corrupt3("configuration serving snapshot size is invalid");
10209
- let parsed;
10210
- try {
10211
- parsed = JSON.parse(row.aggregate_json);
10212
- } catch {
10213
- corrupt3("configuration serving snapshot JSON is invalid");
10214
- }
10215
- let canonical;
10216
- try {
10217
- canonical = canonicalJson(parsed);
10218
- } catch {
10219
- corrupt3("configuration serving snapshot JSON is invalid");
10220
- }
10221
- if (canonical !== row.aggregate_json)
10222
- corrupt3("configuration serving snapshot JSON is not canonical");
10223
- const normalized = parseNormalizeCompileAggregate(parsed, undefined);
10224
- if (!normalized.ok)
10225
- corrupt3("configuration serving snapshot aggregate is invalid");
10226
- if (canonicalJson(normalized.value) !== row.aggregate_json) {
10227
- corrupt3("configuration serving snapshot aggregate normalization drifted");
10228
- }
10229
- if (hashConfigurationContent(normalized.value) !== key.content_hash) {
10230
- corrupt3("configuration serving snapshot content hash is invalid");
10231
- }
10232
- const revision = sqliteGet(db, "SELECT revision,content_hash FROM configuration_revisions WHERE revision=?", key.revision);
10233
- if (revision === null || revision.revision !== key.revision || revision.content_hash !== key.content_hash) {
10234
- corrupt3("configuration serving snapshot revision is invalid");
10235
- }
10236
- return { revision: row.revision, content_hash: row.content_hash, aggregate: normalized.value };
10237
- });
10238
10455
  }
10239
10456
 
10240
10457
  // src/config-storage/config-repository.ts
10241
- import { randomUUID as randomUUID2 } from "crypto";
10458
+ import { randomUUID as randomUUID3 } from "crypto";
10242
10459
 
10243
10460
  // src/config-storage/sqlite-version.ts
10244
10461
  class SqliteVersionError extends Error {
@@ -10446,7 +10663,7 @@ class ConfigRepository {
10446
10663
  mkdirSync(dirname2(dbPath), { recursive: true });
10447
10664
  db = new Database3(dbPath, { create: true, readwrite: true, strict: true });
10448
10665
  configureConnection(db);
10449
- migrateConfigurationDatabase(db);
10666
+ migrateConfigurationDatabase(db, undefined, options);
10450
10667
  verifyRepositoryIntegrity(db);
10451
10668
  const repository = new ConfigRepository(db, options, freezeSnapshot(readRepositorySnapshot(db)));
10452
10669
  repository.removeUnavailablePlugins();
@@ -10525,7 +10742,7 @@ class ConfigRepository {
10525
10742
  return;
10526
10743
  const protection = readManagementProtection(this.db);
10527
10744
  const provider = protection.selectedProvider !== null && available.has(protection.selectedProvider) ? protection.selectedProvider : null;
10528
- const now = Date.now(), operationId = randomUUID2();
10745
+ const now = Date.now(), operationId = randomUUID3();
10529
10746
  const prepared = prepareCommitCommand({
10530
10747
  mutation_id: operationId,
10531
10748
  expected_revision: snapshot.revision,
@@ -10780,14 +10997,14 @@ class ConfigRepository {
10780
10997
  if (before.state !== "converged" && before.state !== "degraded" && outcome.outcome === "degraded" && (outcome.error_code === "replacement_convergence_failed" || outcome.error_code === "control_readiness_failed")) {
10781
10998
  switch (outcome.recovery_disposition) {
10782
10999
  case "retryable":
10783
- createAutomaticRecovery(this.db, randomUUID2(), mutationId, operation.committed_revision, updatedAt);
11000
+ createAutomaticRecovery(this.db, randomUUID3(), mutationId, operation.committed_revision, updatedAt);
10784
11001
  this.options.faultInjection?.("after_automatic_recovery");
10785
11002
  break;
10786
11003
  case "deterministic_worker_rejection":
10787
11004
  case "deterministic_protocol_failure":
10788
11005
  case "deterministic_control_failure":
10789
11006
  case "fatal":
10790
- createStoppedRecovery(this.db, randomUUID2(), mutationId, operation.committed_revision, outcome.recovery_disposition === "fatal" ? "fatal_source_failure" : outcome.recovery_disposition, outcome.error_detail, updatedAt);
11007
+ createStoppedRecovery(this.db, randomUUID3(), mutationId, operation.committed_revision, outcome.recovery_disposition === "fatal" ? "fatal_source_failure" : outcome.recovery_disposition, outcome.error_detail, updatedAt);
10791
11008
  break;
10792
11009
  default: {
10793
11010
  const unhandled = outcome.recovery_disposition;