Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import type { FullResource } from "@ctrlplane/events";
import _ from "lodash";
import { isPresent } from "ts-is-present";

import { and, eq, inArray } from "@ctrlplane/db";
import { db as dbClient } from "@ctrlplane/db/client";
import * as schema from "@ctrlplane/db/schema";

import type { Selector } from "../selector.js";
import { resourceMatchesSelector } from "./resource-match.js";

type InMemoryDeploymentVariableValueResourceSelectorOptions = {
initialEntities: FullResource[];
initialSelectors: schema.DeploymentVariableValue[];
};

const entityMatchesSelector = (
entity: FullResource,
selector: schema.DeploymentVariableValue,
) => {
if (selector.resourceSelector == null) return false;
return resourceMatchesSelector(entity, selector.resourceSelector);
};

export class InMemoryDeploymentVariableValueResourceSelector
implements Selector<schema.DeploymentVariableValue, FullResource>
{
private entities: Map<string, FullResource>;
private selectors: Map<string, schema.DeploymentVariableValue>;
private matches: Map<string, Set<string>>; // resourceId -> deploymentId

constructor(opts: InMemoryDeploymentVariableValueResourceSelectorOptions) {
this.entities = new Map(
opts.initialEntities.map((entity) => [entity.id, entity]),
);
this.selectors = new Map(
opts.initialSelectors.map((selector) => [selector.id, selector]),
);
this.matches = new Map();

for (const entity of opts.initialEntities) {
this.matches.set(entity.id, new Set());

for (const selector of opts.initialSelectors) {
const match =
selector.resourceSelector != null &&
resourceMatchesSelector(entity, selector.resourceSelector);
if (match) this.matches.get(entity.id)?.add(selector.id);
}
}
}

get selectorMatches() {
return this.matches;
}

static async create(workspaceId: string, initialEntities: FullResource[]) {
const allSelectors = await dbClient
.select()
.from(schema.deploymentVariableValue)
.leftJoin(
schema.deploymentVariableValueDirect,
eq(
schema.deploymentVariableValue.id,
schema.deploymentVariableValueDirect.variableValueId,
),
)
.leftJoin(
schema.deploymentVariableValueReference,
eq(
schema.deploymentVariableValue.id,
schema.deploymentVariableValueReference.variableValueId,
),
)
.innerJoin(
schema.deploymentVariable,
eq(
schema.deploymentVariableValue.variableId,
schema.deploymentVariable.id,
),
)
.innerJoin(
schema.deployment,
eq(schema.deploymentVariable.deploymentId, schema.deployment.id),
)
.innerJoin(
schema.system,
eq(schema.deployment.systemId, schema.system.id),
)
.where(eq(schema.system.workspaceId, workspaceId))
.then((results) =>
results.map((result) => {
if (result.deployment_variable_value_direct != null)
return {
...result.deployment_variable_value_direct,
...result.deployment_variable_value,
};
if (result.deployment_variable_value_reference != null)
return {
...result.deployment_variable_value_reference,
...result.deployment_variable_value,
};
return null;
}),
)
.then((results) => results.filter(isPresent));

return new InMemoryDeploymentVariableValueResourceSelector({
initialEntities,
initialSelectors: allSelectors,
});
}

async upsertEntity(entity: FullResource): Promise<void> {
if (this.matches.get(entity.id) == null)
this.matches.set(entity.id, new Set());
this.entities.set(entity.id, entity);

const previouslyMatchingDeployments =
this.matches.get(entity.id) ?? new Set();
const currentlyMatchingDeployments = new Set<string>();

for (const selector of this.selectors.values()) {
const match = entityMatchesSelector(entity, selector);
if (match) currentlyMatchingDeployments.add(selector.id);
}

const unmatchedDeployments = Array.from(
previouslyMatchingDeployments,
).filter((deploymentId) => !currentlyMatchingDeployments.has(deploymentId));

const newlyMatchedDeployments = Array.from(
currentlyMatchingDeployments,
).filter(
(deploymentId) => !previouslyMatchingDeployments.has(deploymentId),
);

for (const deploymentId of unmatchedDeployments)
this.matches.get(entity.id)?.delete(deploymentId);

for (const deploymentId of newlyMatchedDeployments)
this.matches.get(entity.id)?.add(deploymentId);

if (unmatchedDeployments.length > 0)
await dbClient
.delete(schema.computedDeploymentResource)
.where(
and(
eq(schema.computedDeploymentResource.resourceId, entity.id),
inArray(
schema.computedDeploymentResource.deploymentId,
unmatchedDeployments,
),
),
);

if (newlyMatchedDeployments.length > 0)
await dbClient
.insert(schema.computedDeploymentResource)
.values(
newlyMatchedDeployments.map((deploymentId) => ({
resourceId: entity.id,
deploymentId,
})),
)
.onConflictDoNothing();
}
Comment on lines +157 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Persist actual deployment IDs in computedDeploymentResource.

newlyMatchedDeployments holds selector.id, which is the deployment-variable-value ID. Writing that into computedDeploymentResource.deploymentId stores the wrong key (and will break any FK / downstream lookup that expects a real deployment ID). We need to persist the owning deployment’s ID instead—derive it when loading/upserting selectors (and dedupe when multiple values belong to the same deployment) and use that value for both the insert and delete paths here and below.

🤖 Prompt for AI Agents
In apps/event-queue/src/selector/in-memory/deployment-variable-value-resource.ts
around lines 157 to 167, the code is inserting selector.id
(deployment-variable-value ID) into computedDeploymentResource.deploymentId
which is incorrect; instead derive and persist the owning deployment's ID when
selectors are loaded/upserted, map each selector.value entry to its parent
deploymentId, dedupe so a deployment appears only once even if multiple selector
values belong to it, and use that deduped deploymentId list for the insert here
and for any delete/cleanup paths elsewhere so FK lookups use real deployment
IDs.

async removeEntity(entity: FullResource): Promise<void> {
this.entities.delete(entity.id);
this.matches.delete(entity.id);
await dbClient
.delete(schema.computedDeploymentResource)
.where(eq(schema.computedDeploymentResource.resourceId, entity.id));
}

async upsertSelector(
selector: schema.DeploymentVariableValue,
): Promise<void> {
this.selectors.set(selector.id, selector);

const previouslyMatchingResources: string[] = [];
for (const [resourceId, deploymentIds] of this.matches)
if (deploymentIds.has(selector.id))
previouslyMatchingResources.push(resourceId);

const currentlyMatchingResources: string[] = [];
for (const entity of this.entities.values())
if (entityMatchesSelector(entity, selector))
currentlyMatchingResources.push(entity.id);

const unmatchedResources = previouslyMatchingResources.filter(
(resourceId) => !currentlyMatchingResources.includes(resourceId),
);
const newlyMatchedResources = currentlyMatchingResources.filter(
(resourceId) => !previouslyMatchingResources.includes(resourceId),
);

for (const resourceId of unmatchedResources)
this.matches.get(resourceId)?.delete(selector.id);

for (const resourceId of newlyMatchedResources)
this.matches.get(resourceId)?.add(selector.id);

if (unmatchedResources.length > 0)
await dbClient
.delete(schema.computedDeploymentResource)
.where(
and(
eq(schema.computedDeploymentResource.deploymentId, selector.id),
inArray(
schema.computedDeploymentResource.resourceId,
unmatchedResources,
),
),
);

if (newlyMatchedResources.length > 0)
await dbClient
.insert(schema.computedDeploymentResource)
.values(
newlyMatchedResources.map((resourceId) => ({
resourceId,
deploymentId: selector.id,
})),
)
.onConflictDoNothing();
}
async removeSelector(
selector: schema.DeploymentVariableValue,
): Promise<void> {
this.selectors.delete(selector.id);

for (const deploymentIds of this.matches.values())
if (deploymentIds.has(selector.id)) deploymentIds.delete(selector.id);

await dbClient
.delete(schema.computedDeploymentResource)
.where(eq(schema.computedDeploymentResource.deploymentId, selector.id));
}
getEntitiesForSelector(
selector: schema.DeploymentVariableValue,
): Promise<FullResource[]> {
const resourceIds: string[] = [];
for (const [resourceId, deploymentIds] of this.matches)
if (deploymentIds.has(selector.id)) resourceIds.push(resourceId);

return Promise.resolve(
resourceIds
.map((resourceId) => this.entities.get(resourceId))
.filter(isPresent),
);
}

getSelectorsForEntity(
entity: FullResource,
): Promise<schema.DeploymentVariableValue[]> {
const matchingDeploymentIds =
this.matches.get(entity.id) ?? new Set<string>();
const matchingDeployments = Array.from(matchingDeploymentIds)
.map((deploymentId) => this.selectors.get(deploymentId))
.filter(isPresent);
return Promise.resolve(matchingDeployments);
}
getAllEntities(): Promise<FullResource[]> {
return Promise.resolve(Array.from(this.entities.values()));
}
getAllSelectors(): Promise<schema.DeploymentVariableValue[]> {
return Promise.resolve(Array.from(this.selectors.values()));
}
isMatch(
entity: FullResource,
selector: schema.DeploymentVariableValue,
): Promise<boolean> {
return Promise.resolve(
this.matches.get(entity.id)?.has(selector.id) ?? false,
);
}
}
27 changes: 27 additions & 0 deletions apps/event-queue/src/selector/selector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Deployment,
DeploymentVariableValue,
DeploymentVersion,
Environment,
PolicyDeploymentVersionSelector,
Expand Down Expand Up @@ -31,6 +32,10 @@ type SelectorManagerOptions = {
PolicyDeploymentVersionSelector,
DeploymentVersion
>;
deploymentVariableValueResourceSelector: Selector<
DeploymentVariableValue,
FullResource
>;
};

export class SelectorManager {
Expand All @@ -52,10 +57,15 @@ export class SelectorManager {
return this.opts.deploymentResourceSelector;
}

get deploymentVariableValueResourceSelector() {
return this.opts.deploymentVariableValueResourceSelector;
}

async updateResource(resource: FullResource) {
const [environmentChanges, deploymentChanges] = await Promise.all([
this.opts.environmentResourceSelector.upsertEntity(resource),
this.opts.deploymentResourceSelector.upsertEntity(resource),
this.opts.deploymentVariableValueResourceSelector.upsertEntity(resource),
]);

return { environmentChanges, deploymentChanges };
Expand All @@ -73,6 +83,7 @@ export class SelectorManager {
const [environmentChanges, deploymentChanges] = await Promise.all([
this.opts.environmentResourceSelector.removeEntity(resource),
this.opts.deploymentResourceSelector.removeEntity(resource),
this.opts.deploymentVariableValueResourceSelector.removeEntity(resource),
]);

return { environmentChanges, deploymentChanges };
Expand Down Expand Up @@ -109,4 +120,20 @@ export class SelectorManager {
policyTarget,
);
}

async upsertDeploymentVariableValue(
deploymentVariableValue: DeploymentVariableValue,
) {
return this.opts.deploymentVariableValueResourceSelector.upsertSelector(
deploymentVariableValue,
);
}

async removeDeploymentVariableValue(
deploymentVariableValue: DeploymentVariableValue,
) {
return this.opts.deploymentVariableValueResourceSelector.removeSelector(
deploymentVariableValue,
);
}
}
8 changes: 8 additions & 0 deletions apps/event-queue/src/workspace/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ export class OperationPipeline {
defaultValueId: upsertedValue.id,
});

await this.opts.workspace.selectorManager.upsertDeploymentVariableValue(
upsertedValue,
);

await this.markDeploymentReleaseTargetsAsStale(
deploymentVariable.deploymentId,
);
Expand All @@ -307,6 +311,10 @@ export class OperationPipeline {
if (deploymentVariable == null)
throw new Error("Deployment variable not found");

await this.opts.workspace.selectorManager.removeDeploymentVariableValue(
deploymentVariableValue,
);

await this.markDeploymentReleaseTargetsAsStale(
deploymentVariable.deploymentId,
);
Expand Down
7 changes: 7 additions & 0 deletions apps/event-queue/src/workspace/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { WorkspaceRepository } from "../repository/repository.js";
import { DbVersionRuleRepository } from "../repository/rules/db-rule-repository.js";
import { DbDeploymentVersionSelector } from "../selector/db/db-deployment-version-selector.js";
import { InMemoryDeploymentResourceSelector } from "../selector/in-memory/deployment-resource.js";
import { InMemoryDeploymentVariableValueResourceSelector } from "../selector/in-memory/deployment-variable-value-resource.js";
import { InMemoryEnvironmentResourceSelector } from "../selector/in-memory/environment-resource.js";
import { InMemoryPolicyTargetReleaseTargetSelector } from "../selector/in-memory/policy-target-release-target.js";
import { SelectorManager } from "../selector/selector.js";
Expand Down Expand Up @@ -90,10 +91,15 @@ const createSelectorManager = async (
deploymentResourceSelector,
environmentResourceSelector,
policyTargetReleaseTargetSelector,
deploymentVariableValueResourceSelector,
] = await Promise.all([
InMemoryDeploymentResourceSelector.create(id, initialResources),
InMemoryEnvironmentResourceSelector.create(id, initialResources),
InMemoryPolicyTargetReleaseTargetSelector.create(id),
InMemoryDeploymentVariableValueResourceSelector.create(
id,
initialResources,
),
]);

return new SelectorManager({
Expand All @@ -103,6 +109,7 @@ const createSelectorManager = async (
deploymentVersionSelector: new DbDeploymentVersionSelector({
workspaceId: id,
}),
deploymentVariableValueResourceSelector,
});
};

Expand Down
Loading