@superblocksteam/shared 0.9603.2 → 0.9604.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.
Files changed (136) hide show
  1. package/dist/database-lifecycle/capacityPolicy.d.ts +51 -0
  2. package/dist/database-lifecycle/capacityPolicy.d.ts.map +1 -0
  3. package/dist/database-lifecycle/capacityPolicy.js +159 -0
  4. package/dist/database-lifecycle/capacityPolicy.js.map +1 -0
  5. package/dist/database-lifecycle/capacityPolicy.test.d.ts +2 -0
  6. package/dist/database-lifecycle/capacityPolicy.test.d.ts.map +1 -0
  7. package/dist/database-lifecycle/capacityPolicy.test.js +159 -0
  8. package/dist/database-lifecycle/capacityPolicy.test.js.map +1 -0
  9. package/dist/database-lifecycle/edit-lifecycle-capability.d.ts +18 -0
  10. package/dist/database-lifecycle/edit-lifecycle-capability.d.ts.map +1 -0
  11. package/dist/database-lifecycle/edit-lifecycle-capability.js +32 -0
  12. package/dist/database-lifecycle/edit-lifecycle-capability.js.map +1 -0
  13. package/dist/database-lifecycle/edit-lifecycle-capability.test.d.ts +2 -0
  14. package/dist/database-lifecycle/edit-lifecycle-capability.test.d.ts.map +1 -0
  15. package/dist/database-lifecycle/edit-lifecycle-capability.test.js +91 -0
  16. package/dist/database-lifecycle/edit-lifecycle-capability.test.js.map +1 -0
  17. package/dist/database-lifecycle/index.d.ts +50 -27
  18. package/dist/database-lifecycle/index.d.ts.map +1 -1
  19. package/dist/database-lifecycle/index.js +78 -54
  20. package/dist/database-lifecycle/index.js.map +1 -1
  21. package/dist/database-lifecycle/index.test.js +120 -77
  22. package/dist/database-lifecycle/index.test.js.map +1 -1
  23. package/dist/database-lifecycle/physicalPoolIdentity.d.ts +29 -0
  24. package/dist/database-lifecycle/physicalPoolIdentity.d.ts.map +1 -0
  25. package/dist/database-lifecycle/physicalPoolIdentity.js +64 -0
  26. package/dist/database-lifecycle/physicalPoolIdentity.js.map +1 -0
  27. package/dist/database-lifecycle/physicalPoolIdentity.test.d.ts +2 -0
  28. package/dist/database-lifecycle/physicalPoolIdentity.test.d.ts.map +1 -0
  29. package/dist/database-lifecycle/physicalPoolIdentity.test.js +51 -0
  30. package/dist/database-lifecycle/physicalPoolIdentity.test.js.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/types/audit/ocsf.d.ts +60 -6
  36. package/dist/types/audit/ocsf.d.ts.map +1 -1
  37. package/dist/types/audit/ocsf.js +12 -1
  38. package/dist/types/audit/ocsf.js.map +1 -1
  39. package/dist/types/deployment/deployQueueStatus.d.ts +13 -0
  40. package/dist/types/deployment/deployQueueStatus.d.ts.map +1 -1
  41. package/dist/types/deployment/index.d.ts +1 -1
  42. package/dist/types/deployment/index.d.ts.map +1 -1
  43. package/dist/types/deployment/index.js.map +1 -1
  44. package/dist/types/policyGate/index.d.ts +5 -0
  45. package/dist/types/policyGate/index.d.ts.map +1 -1
  46. package/dist/types/policyGate/index.js +5 -1
  47. package/dist/types/policyGate/index.js.map +1 -1
  48. package/dist/types/publishPipeline/plan.js +1 -1
  49. package/dist/types/publishPipeline/plan.js.map +1 -1
  50. package/dist/types/publishPipeline/plan.test.js +3 -3
  51. package/dist/types/publishPipeline/plan.test.js.map +1 -1
  52. package/dist/types/rbac/index.d.ts +2 -0
  53. package/dist/types/rbac/index.d.ts.map +1 -1
  54. package/dist/types/rbac/index.js +3 -1
  55. package/dist/types/rbac/index.js.map +1 -1
  56. package/dist/types/reviewPolicy/index.d.ts +30 -4
  57. package/dist/types/reviewPolicy/index.d.ts.map +1 -1
  58. package/dist/types/reviewPolicy/index.js +8 -1
  59. package/dist/types/reviewPolicy/index.js.map +1 -1
  60. package/dist-esm/database-lifecycle/capacityPolicy.d.ts +51 -0
  61. package/dist-esm/database-lifecycle/capacityPolicy.d.ts.map +1 -0
  62. package/dist-esm/database-lifecycle/capacityPolicy.js +150 -0
  63. package/dist-esm/database-lifecycle/capacityPolicy.js.map +1 -0
  64. package/dist-esm/database-lifecycle/capacityPolicy.test.d.ts +2 -0
  65. package/dist-esm/database-lifecycle/capacityPolicy.test.d.ts.map +1 -0
  66. package/dist-esm/database-lifecycle/capacityPolicy.test.js +157 -0
  67. package/dist-esm/database-lifecycle/capacityPolicy.test.js.map +1 -0
  68. package/dist-esm/database-lifecycle/edit-lifecycle-capability.d.ts +18 -0
  69. package/dist-esm/database-lifecycle/edit-lifecycle-capability.d.ts.map +1 -0
  70. package/dist-esm/database-lifecycle/edit-lifecycle-capability.js +29 -0
  71. package/dist-esm/database-lifecycle/edit-lifecycle-capability.js.map +1 -0
  72. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.d.ts +2 -0
  73. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.d.ts.map +1 -0
  74. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.js +89 -0
  75. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.js.map +1 -0
  76. package/dist-esm/database-lifecycle/index.d.ts +50 -27
  77. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  78. package/dist-esm/database-lifecycle/index.js +62 -51
  79. package/dist-esm/database-lifecycle/index.js.map +1 -1
  80. package/dist-esm/database-lifecycle/index.test.js +90 -47
  81. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  82. package/dist-esm/database-lifecycle/physicalPoolIdentity.d.ts +29 -0
  83. package/dist-esm/database-lifecycle/physicalPoolIdentity.d.ts.map +1 -0
  84. package/dist-esm/database-lifecycle/physicalPoolIdentity.js +59 -0
  85. package/dist-esm/database-lifecycle/physicalPoolIdentity.js.map +1 -0
  86. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.d.ts +2 -0
  87. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.d.ts.map +1 -0
  88. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.js +49 -0
  89. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.js.map +1 -0
  90. package/dist-esm/index.d.ts +1 -0
  91. package/dist-esm/index.d.ts.map +1 -1
  92. package/dist-esm/index.js +1 -0
  93. package/dist-esm/index.js.map +1 -1
  94. package/dist-esm/types/audit/ocsf.d.ts +60 -6
  95. package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
  96. package/dist-esm/types/audit/ocsf.js +12 -1
  97. package/dist-esm/types/audit/ocsf.js.map +1 -1
  98. package/dist-esm/types/deployment/deployQueueStatus.d.ts +13 -0
  99. package/dist-esm/types/deployment/deployQueueStatus.d.ts.map +1 -1
  100. package/dist-esm/types/deployment/index.d.ts +1 -1
  101. package/dist-esm/types/deployment/index.d.ts.map +1 -1
  102. package/dist-esm/types/deployment/index.js.map +1 -1
  103. package/dist-esm/types/policyGate/index.d.ts +5 -0
  104. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  105. package/dist-esm/types/policyGate/index.js +5 -1
  106. package/dist-esm/types/policyGate/index.js.map +1 -1
  107. package/dist-esm/types/publishPipeline/plan.js +1 -1
  108. package/dist-esm/types/publishPipeline/plan.js.map +1 -1
  109. package/dist-esm/types/publishPipeline/plan.test.js +3 -3
  110. package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
  111. package/dist-esm/types/rbac/index.d.ts +2 -0
  112. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  113. package/dist-esm/types/rbac/index.js +3 -1
  114. package/dist-esm/types/rbac/index.js.map +1 -1
  115. package/dist-esm/types/reviewPolicy/index.d.ts +30 -4
  116. package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
  117. package/dist-esm/types/reviewPolicy/index.js +7 -0
  118. package/dist-esm/types/reviewPolicy/index.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/database-lifecycle/capacityPolicy.test.ts +209 -0
  121. package/src/database-lifecycle/capacityPolicy.ts +197 -0
  122. package/src/database-lifecycle/edit-lifecycle-capability.test.ts +116 -0
  123. package/src/database-lifecycle/edit-lifecycle-capability.ts +34 -0
  124. package/src/database-lifecycle/index.test.ts +105 -48
  125. package/src/database-lifecycle/index.ts +157 -90
  126. package/src/database-lifecycle/physicalPoolIdentity.test.ts +64 -0
  127. package/src/database-lifecycle/physicalPoolIdentity.ts +75 -0
  128. package/src/index.ts +1 -0
  129. package/src/types/audit/ocsf.ts +73 -7
  130. package/src/types/deployment/deployQueueStatus.ts +14 -0
  131. package/src/types/deployment/index.ts +2 -1
  132. package/src/types/policyGate/index.ts +5 -1
  133. package/src/types/publishPipeline/plan.test.ts +3 -3
  134. package/src/types/publishPipeline/plan.ts +1 -1
  135. package/src/types/rbac/index.ts +3 -1
  136. package/src/types/reviewPolicy/index.ts +44 -5
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import {
4
+ CapacityPolicyValidationError,
5
+ computePhysicalDatabasePoolKey,
6
+ encodeCapacityPolicyTag,
7
+ parseCapacityPolicyTag,
8
+ parseCapacityPolicyTags,
9
+ type PhysicalDatabaseCapacityPolicy,
10
+ validateCapacityPolicyCompatibility
11
+ } from './capacityPolicy.js';
12
+
13
+ describe('capacity policy tags', () => {
14
+ test('round-trips percentage policies with trimmed case-sensitive agent names', () => {
15
+ const shared: PhysicalDatabaseCapacityPolicy = {
16
+ agentName: 'Customer OPA',
17
+ engine: 'postgres',
18
+ thresholdType: 'minAvailableCapacityPercent',
19
+ thresholdValue: 20
20
+ };
21
+
22
+ expect(
23
+ parseCapacityPolicyTag(
24
+ encodeCapacityPolicyTag({
25
+ ...shared,
26
+ agentName: ' Customer OPA '
27
+ })
28
+ )
29
+ ).toEqual(shared);
30
+ expect(encodeCapacityPolicyTag(shared)).toBe('v2|Customer%20OPA|postgres|minAvailableCapacityPercent|20');
31
+ expect(encodeCapacityPolicyTag({ ...shared, agentName: 'customer opa' })).not.toBe(encodeCapacityPolicyTag(shared));
32
+ });
33
+
34
+ test('parseCapacityPolicyTags returns empty when the tag is omitted', () => {
35
+ expect(parseCapacityPolicyTags(undefined)).toEqual([]);
36
+ });
37
+
38
+ test('rejects unsupported versions, empty agent names, blank values, and out-of-range thresholds', () => {
39
+ const agentName = encodeURIComponent('customer-opa');
40
+
41
+ expect(() => parseCapacityPolicyTag(`v1|${agentName}|postgres|minAvailableCapacityPercent|20`)).toThrow(/unsupported version/);
42
+ expect(() => parseCapacityPolicyTag(`v2|%20%20|postgres|minAvailableCapacityPercent|20`)).toThrow(/agentName must be non-empty/);
43
+ expect(() => parseCapacityPolicyTag(`v2|${agentName}|postgres|minAvailableCapacityPercent|`)).toThrow(
44
+ /thresholdValue must be a non-empty integer/
45
+ );
46
+ expect(() => parseCapacityPolicyTag(`v2|${agentName}|postgres|minAvailableCapacityPercent|101`)).toThrow(/<= 100/);
47
+ expect(() => parseCapacityPolicyTag(`v2|${agentName}|postgres|minAvailableCapacityPercent|-1`)).toThrow(/>= 0/);
48
+ expect(() => parseCapacityPolicyTag(`v2|${agentName}|postgres|minEmptyPhysicalInstances|1`)).toThrow(/unknown thresholdType/);
49
+ });
50
+
51
+ test('parseCapacityPolicyTags fails closed on any invalid value', () => {
52
+ const valid = encodeCapacityPolicyTag({
53
+ agentName: 'customer-opa',
54
+ engine: 'postgres',
55
+ thresholdType: 'minAvailableCapacityPercent',
56
+ thresholdValue: 20
57
+ });
58
+ expect(() => parseCapacityPolicyTags([valid, 'not-a-policy'])).toThrow(CapacityPolicyValidationError);
59
+ });
60
+
61
+ test('rejects duplicate policies for the same agent name and engine', () => {
62
+ const first = encodeCapacityPolicyTag({
63
+ agentName: 'Customer OPA',
64
+ engine: 'postgres',
65
+ thresholdType: 'minAvailableCapacityPercent',
66
+ thresholdValue: 20
67
+ });
68
+ const duplicate = encodeCapacityPolicyTag({
69
+ agentName: ' Customer OPA ',
70
+ engine: 'postgres',
71
+ thresholdType: 'minAvailableCapacityPercent',
72
+ thresholdValue: 10
73
+ });
74
+
75
+ try {
76
+ parseCapacityPolicyTags([first, duplicate]);
77
+ expect.fail('Expected duplicate capacity policy validation to fail');
78
+ } catch (error) {
79
+ expect(error).toBeInstanceOf(CapacityPolicyValidationError);
80
+ if (!(error instanceof CapacityPolicyValidationError)) {
81
+ throw error;
82
+ }
83
+ expect(error.details).toEqual([expect.stringContaining('duplicate capacity policy')]);
84
+ }
85
+ });
86
+
87
+ test('allows distinct agent names for the same engine', () => {
88
+ const first = encodeCapacityPolicyTag({
89
+ agentName: 'customer-opa-a',
90
+ engine: 'postgres',
91
+ thresholdType: 'minAvailableCapacityPercent',
92
+ thresholdValue: 20
93
+ });
94
+ const second = encodeCapacityPolicyTag({
95
+ agentName: 'customer-opa-b',
96
+ engine: 'postgres',
97
+ thresholdType: 'minAvailableCapacityPercent',
98
+ thresholdValue: 10
99
+ });
100
+
101
+ expect(parseCapacityPolicyTags([first, second])).toHaveLength(2);
102
+ });
103
+
104
+ test('computePhysicalDatabasePoolKey is trimmed, case-sensitive, and delimiter-safe', () => {
105
+ expect(
106
+ computePhysicalDatabasePoolKey({
107
+ organizationId: 'org:1',
108
+ agentName: ' customer:opa ',
109
+ engine: 'postgres'
110
+ })
111
+ ).toBe(
112
+ computePhysicalDatabasePoolKey({
113
+ organizationId: 'org:1',
114
+ agentName: 'customer:opa',
115
+ engine: 'postgres'
116
+ })
117
+ );
118
+ expect(
119
+ computePhysicalDatabasePoolKey({
120
+ organizationId: 'org:1',
121
+ agentName: 'customer:opa',
122
+ engine: 'postgres'
123
+ })
124
+ ).not.toBe(
125
+ computePhysicalDatabasePoolKey({
126
+ organizationId: 'org',
127
+ agentName: '1:customer:opa',
128
+ engine: 'postgres'
129
+ })
130
+ );
131
+ expect(
132
+ computePhysicalDatabasePoolKey({
133
+ organizationId: 'org:1',
134
+ agentName: 'Customer:OPA',
135
+ engine: 'postgres'
136
+ })
137
+ ).not.toBe(
138
+ computePhysicalDatabasePoolKey({
139
+ organizationId: 'org:1',
140
+ agentName: 'customer:opa',
141
+ engine: 'postgres'
142
+ })
143
+ );
144
+ expect(() =>
145
+ computePhysicalDatabasePoolKey({
146
+ organizationId: 'org:1',
147
+ agentName: ' ',
148
+ engine: 'postgres'
149
+ })
150
+ ).toThrow(/agentName must be non-empty/);
151
+ });
152
+
153
+ test('allows replicas to publish the same pool and threshold', () => {
154
+ const policy: PhysicalDatabaseCapacityPolicy = {
155
+ agentName: 'customer-opa',
156
+ engine: 'postgres',
157
+ thresholdType: 'minAvailableCapacityPercent',
158
+ thresholdValue: 20
159
+ };
160
+
161
+ expect(() => validateCapacityPolicyCompatibility([policy], [{ ...policy }])).not.toThrow();
162
+ });
163
+
164
+ test('rejects different thresholds for the same pool', () => {
165
+ expect(() =>
166
+ validateCapacityPolicyCompatibility(
167
+ [
168
+ {
169
+ agentName: 'customer-opa',
170
+ engine: 'postgres',
171
+ thresholdType: 'minAvailableCapacityPercent',
172
+ thresholdValue: 20
173
+ }
174
+ ],
175
+ [
176
+ {
177
+ agentName: 'customer-opa',
178
+ engine: 'postgres',
179
+ thresholdType: 'minAvailableCapacityPercent',
180
+ thresholdValue: 10
181
+ }
182
+ ]
183
+ )
184
+ ).toThrow(/different thresholds/);
185
+ });
186
+
187
+ test('allows different agent names and case-distinct agent names for the same engine', () => {
188
+ expect(() =>
189
+ validateCapacityPolicyCompatibility(
190
+ [
191
+ {
192
+ agentName: 'Customer OPA',
193
+ engine: 'postgres',
194
+ thresholdType: 'minAvailableCapacityPercent',
195
+ thresholdValue: 20
196
+ }
197
+ ],
198
+ [
199
+ {
200
+ agentName: 'customer opa',
201
+ engine: 'postgres',
202
+ thresholdType: 'minAvailableCapacityPercent',
203
+ thresholdValue: 10
204
+ }
205
+ ]
206
+ )
207
+ ).not.toThrow();
208
+ });
209
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Agent tag key for proactive physical-instance capacity thresholds.
3
+ * Each value encodes one pool policy (see {@link encodeCapacityPolicyTag} /
4
+ * {@link parseCapacityPolicyTags}).
5
+ *
6
+ * Versioned wire format (pipe-delimited, one tag value per pool):
7
+ * `v2|{encodedAgentName}|{engine}|{thresholdType}|{thresholdValue}`
8
+ *
9
+ * Example:
10
+ * `v2|customer-opa|postgres|minAvailableCapacityPercent|20`
11
+ */
12
+
13
+ export const DATABASE_LIFECYCLE_TAG_CAPACITY_POLICIES = 'databaseLifecycle:capacityPolicies';
14
+
15
+ export const DATABASE_ENGINES = ['postgres', 'snowflake', 'snowflake_postgres', 'lakebase'] as const;
16
+ export type DatabaseEngine = (typeof DATABASE_ENGINES)[number];
17
+
18
+ export const PHYSICAL_DATABASE_CAPACITY_THRESHOLD_TYPES = ['minAvailableCapacityPercent'] as const;
19
+ export type PhysicalDatabaseCapacityThresholdType = (typeof PHYSICAL_DATABASE_CAPACITY_THRESHOLD_TYPES)[number];
20
+
21
+ export type PhysicalDatabaseCapacityPolicy = {
22
+ agentName: string;
23
+ engine: DatabaseEngine;
24
+ thresholdType: 'minAvailableCapacityPercent';
25
+ thresholdValue: number;
26
+ };
27
+
28
+ export type PhysicalDatabasePoolKey = {
29
+ organizationId: string;
30
+ agentName: string;
31
+ engine: DatabaseEngine;
32
+ };
33
+
34
+ export class CapacityPolicyValidationError extends Error {
35
+ readonly code = 'capacity_policy_invalid';
36
+
37
+ constructor(
38
+ message: string,
39
+ readonly details: string[] = []
40
+ ) {
41
+ super(message);
42
+ this.name = 'CapacityPolicyValidationError';
43
+ }
44
+ }
45
+
46
+ /** Stable idempotency and identity key for an organization physical pool. */
47
+ export function computePhysicalDatabasePoolKey(pool: PhysicalDatabasePoolKey): string {
48
+ const agentName = normalizeCapacityPolicyAgentName(pool.agentName);
49
+ return ['provision_physical_database', encodeURIComponent(pool.organizationId), encodeURIComponent(agentName), pool.engine].join(':');
50
+ }
51
+
52
+ export function encodeCapacityPolicyTag(policy: PhysicalDatabaseCapacityPolicy): string {
53
+ const agentName = normalizeCapacityPolicyAgentName(policy.agentName);
54
+ return ['v2', encodeURIComponent(agentName), policy.engine, policy.thresholdType, String(policy.thresholdValue)].join('|');
55
+ }
56
+
57
+ /**
58
+ * Parse and validate capacity-policy tags. Fail closed: any malformed value
59
+ * throws {@link CapacityPolicyValidationError}. Missing tag key ⇒ empty list
60
+ * (no proactive provisioning for that agent).
61
+ */
62
+ export function parseCapacityPolicyTags(published: string[] | undefined): PhysicalDatabaseCapacityPolicy[] {
63
+ if (published === undefined) {
64
+ return [];
65
+ }
66
+ if (!Array.isArray(published)) {
67
+ throw new CapacityPolicyValidationError('databaseLifecycle:capacityPolicies must be an array of policy strings.');
68
+ }
69
+
70
+ const policies: PhysicalDatabaseCapacityPolicy[] = [];
71
+ const errors: string[] = [];
72
+ const seen = new Set<string>();
73
+
74
+ for (const raw of published) {
75
+ if (typeof raw !== 'string' || raw.trim() === '') {
76
+ errors.push('capacity policy value must be a non-empty string');
77
+ continue;
78
+ }
79
+ try {
80
+ const policy = parseCapacityPolicyTag(raw);
81
+ const identity = JSON.stringify([policy.agentName, policy.engine]);
82
+ if (seen.has(identity)) {
83
+ errors.push(`duplicate capacity policy for pool ${identity}`);
84
+ continue;
85
+ }
86
+ seen.add(identity);
87
+ policies.push(policy);
88
+ } catch (error) {
89
+ errors.push(error instanceof Error ? error.message : String(error));
90
+ }
91
+ }
92
+
93
+ if (errors.length > 0) {
94
+ throw new CapacityPolicyValidationError(
95
+ `Invalid databaseLifecycle:capacityPolicies (${errors.length} error${errors.length === 1 ? '' : 's'}).`,
96
+ errors
97
+ );
98
+ }
99
+
100
+ return policies;
101
+ }
102
+
103
+ /**
104
+ * Detect threshold disagreements for the same physical pool.
105
+ * Profile tags are routing metadata and do not participate in identity.
106
+ *
107
+ * Registration callers soft-skip peer conflicts (rolling threshold updates);
108
+ * the registering agent's own invalid tags still fail closed.
109
+ */
110
+ export function validateCapacityPolicyCompatibility(
111
+ candidatePolicies: readonly PhysicalDatabaseCapacityPolicy[],
112
+ existingPolicies: readonly PhysicalDatabaseCapacityPolicy[]
113
+ ): void {
114
+ const errors: string[] = [];
115
+ for (const candidate of candidatePolicies) {
116
+ const candidateAgentName = normalizeCapacityPolicyAgentName(candidate.agentName);
117
+ for (const existing of existingPolicies) {
118
+ const existingAgentName = normalizeCapacityPolicyAgentName(existing.agentName);
119
+ if (candidate.engine !== existing.engine || candidateAgentName !== existingAgentName) {
120
+ continue;
121
+ }
122
+ if (candidate.thresholdValue !== existing.thresholdValue) {
123
+ errors.push(
124
+ `different thresholds for pool ${JSON.stringify([candidateAgentName, candidate.engine])}: ${candidate.thresholdValue} and ${existing.thresholdValue}`
125
+ );
126
+ }
127
+ }
128
+ }
129
+ if (errors.length > 0) {
130
+ throw new CapacityPolicyValidationError(
131
+ `Conflicting databaseLifecycle:capacityPolicies (${errors.length} conflict${errors.length === 1 ? '' : 's'}): ${errors.join('; ')}`,
132
+ errors
133
+ );
134
+ }
135
+ }
136
+
137
+ export function parseCapacityPolicyTag(raw: string): PhysicalDatabaseCapacityPolicy {
138
+ const parts = raw.split('|');
139
+ if (parts.length !== 5) {
140
+ throw new Error(
141
+ `expected 5 pipe-delimited fields (version|agentName|engine|thresholdType|value), got ${parts.length} in ${JSON.stringify(raw)}`
142
+ );
143
+ }
144
+
145
+ const [version, agentNameEncoded, engineRaw, thresholdTypeRaw, thresholdValueRaw] = parts;
146
+
147
+ if (version !== 'v2') {
148
+ throw new Error(`unsupported version ${JSON.stringify(version)}`);
149
+ }
150
+ if (!isKnownValue(engineRaw, DATABASE_ENGINES)) {
151
+ throw new Error(`unknown engine ${JSON.stringify(engineRaw)}`);
152
+ }
153
+ if (!isKnownValue(thresholdTypeRaw, PHYSICAL_DATABASE_CAPACITY_THRESHOLD_TYPES)) {
154
+ throw new Error(`unknown thresholdType ${JSON.stringify(thresholdTypeRaw)}`);
155
+ }
156
+
157
+ let decodedAgentName: string;
158
+ try {
159
+ decodedAgentName = decodeURIComponent(agentNameEncoded);
160
+ } catch {
161
+ throw new Error(`agentName is not valid URI encoding: ${JSON.stringify(agentNameEncoded)}`);
162
+ }
163
+ const agentName = normalizeCapacityPolicyAgentName(decodedAgentName);
164
+
165
+ if (thresholdValueRaw.trim() === '' || !/^-?\d+$/.test(thresholdValueRaw)) {
166
+ throw new Error('thresholdValue must be a non-empty integer');
167
+ }
168
+ const thresholdValue = Number(thresholdValueRaw);
169
+ if (!Number.isSafeInteger(thresholdValue)) {
170
+ throw new Error(`thresholdValue must be an integer, got ${JSON.stringify(thresholdValueRaw)}`);
171
+ }
172
+ if (thresholdValue < 0) {
173
+ throw new Error(`thresholdValue must be >= 0, got ${thresholdValue}`);
174
+ }
175
+
176
+ if (thresholdValue > 100) {
177
+ throw new Error(`minAvailableCapacityPercent must be <= 100, got ${thresholdValue}`);
178
+ }
179
+ return {
180
+ agentName,
181
+ engine: engineRaw,
182
+ thresholdType: 'minAvailableCapacityPercent',
183
+ thresholdValue
184
+ };
185
+ }
186
+
187
+ function isKnownValue<T extends string>(value: string, known: readonly T[]): value is T {
188
+ return known.some((candidate) => candidate === value);
189
+ }
190
+
191
+ function normalizeCapacityPolicyAgentName(agentName: string): string {
192
+ const normalized = agentName.trim();
193
+ if (normalized.length === 0) {
194
+ throw new CapacityPolicyValidationError('agentName must be non-empty after trimming.');
195
+ }
196
+ return normalized;
197
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AgentStatus } from '../types/organization/agent.js';
4
+ import { hasEditLifecycleCapability } from './edit-lifecycle-capability.js';
5
+ import { DATABASE_LIFECYCLE_TAG_OPERATIONS } from './index.js';
6
+
7
+ describe('hasEditLifecycleCapability', () => {
8
+ it('is false when no agents are present', () => {
9
+ expect(hasEditLifecycleCapability(undefined)).toBe(false);
10
+ expect(hasEditLifecycleCapability([])).toBe(false);
11
+ });
12
+
13
+ it('is false when agents lack databaseLifecycle operations', () => {
14
+ expect(
15
+ hasEditLifecycleCapability([
16
+ {
17
+ status: AgentStatus.ACTIVE,
18
+ tags: { profile: ['dev'] }
19
+ }
20
+ ])
21
+ ).toBe(false);
22
+ });
23
+
24
+ it('is false when the agent registers other operations but not ensure_database', () => {
25
+ expect(
26
+ hasEditLifecycleCapability([
27
+ {
28
+ status: AgentStatus.ACTIVE,
29
+ tags: {
30
+ [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['migrate_schema', 'retire_database'],
31
+ profile: ['dev']
32
+ }
33
+ }
34
+ ])
35
+ ).toBe(false);
36
+ });
37
+
38
+ it('is false when ensure_database is registered but the agent serves no profile', () => {
39
+ expect(
40
+ hasEditLifecycleCapability([
41
+ {
42
+ status: AgentStatus.ACTIVE,
43
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database', 'migrate_schema'] }
44
+ }
45
+ ])
46
+ ).toBe(false);
47
+ expect(
48
+ hasEditLifecycleCapability([
49
+ {
50
+ status: AgentStatus.ACTIVE,
51
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database'], profile: [] }
52
+ }
53
+ ])
54
+ ).toBe(false);
55
+ });
56
+
57
+ it('is false when a disconnected agent has lifecycle tags', () => {
58
+ expect(
59
+ hasEditLifecycleCapability([
60
+ {
61
+ status: AgentStatus.DISCONNECTED,
62
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database'], profile: ['dev'] }
63
+ }
64
+ ])
65
+ ).toBe(false);
66
+ });
67
+
68
+ it('is false when agent status is missing even if tags would otherwise qualify', () => {
69
+ expect(
70
+ hasEditLifecycleCapability([
71
+ {
72
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database'], profile: ['dev'] }
73
+ }
74
+ ])
75
+ ).toBe(false);
76
+ });
77
+
78
+ it('is true when an active agent registered ensure_database for any profile', () => {
79
+ expect(
80
+ hasEditLifecycleCapability([
81
+ {
82
+ status: AgentStatus.ACTIVE,
83
+ tags: {
84
+ [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database', 'migrate_schema'],
85
+ profile: ['staging', 'production']
86
+ }
87
+ }
88
+ ])
89
+ ).toBe(true);
90
+ });
91
+
92
+ // A production-only lifecycle agent still advertises Native DB. Editing is no
93
+ // longer a separate lifecycle axis, so there is no `edit:` pair to require —
94
+ // profile-key resolution stays on the server at ensure/dispatch time.
95
+ it('is true for an agent serving only production', () => {
96
+ expect(
97
+ hasEditLifecycleCapability([
98
+ {
99
+ status: AgentStatus.ACTIVE,
100
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database'], profile: ['production'] }
101
+ }
102
+ ])
103
+ ).toBe(true);
104
+ });
105
+
106
+ it('is true when status is the string Active (CLI wire form)', () => {
107
+ expect(
108
+ hasEditLifecycleCapability([
109
+ {
110
+ status: 'Active',
111
+ tags: { [DATABASE_LIFECYCLE_TAG_OPERATIONS]: ['ensure_database'], profile: ['dev'] }
112
+ }
113
+ ])
114
+ ).toBe(true);
115
+ });
116
+ });
@@ -0,0 +1,34 @@
1
+ import { AgentStatus, type AgentTags } from '../types/organization/agent.js';
2
+ import { DATABASE_LIFECYCLE_TAG_OPERATIONS } from './index.js';
3
+
4
+ export type AgentLifecycleTagSource = {
5
+ status?: AgentStatus | string;
6
+ tags?: AgentTags;
7
+ };
8
+
9
+ /**
10
+ * Whether an agent list shows Native DB opted in for provisioning.
11
+ *
12
+ * Coarse registration check for product advertising (Clark tools/prompts,
13
+ * UI Databases entry): an active agent must publish `ensure_database` and
14
+ * serve at least one profile. Profile-key resolution, engine support, and
15
+ * managed-IAM admission stay on the server at ensure/dispatch time — this
16
+ * helper does not re-encode that surface.
17
+ *
18
+ * Agents without a confirmed active status are excluded (fail closed).
19
+ */
20
+ export function hasEditLifecycleCapability(agents: readonly AgentLifecycleTagSource[] | undefined): boolean {
21
+ if (!agents || agents.length === 0) {
22
+ return false;
23
+ }
24
+ return agents.some((agent) => {
25
+ if (agent.status === undefined || (agent.status !== AgentStatus.ACTIVE && agent.status !== 'Active')) {
26
+ return false;
27
+ }
28
+ const operations = agent.tags?.[DATABASE_LIFECYCLE_TAG_OPERATIONS] ?? [];
29
+ if (!operations.includes('ensure_database')) {
30
+ return false;
31
+ }
32
+ return (agent.tags?.profile ?? []).length > 0;
33
+ });
34
+ }