Skip to content

Commit eab77fb

Browse files
feat(catalog-v2): keep license links version-anchored and follow per child sibling
Parent plan_licenses stay on the child version they point at. Child all_versions follow applies each parent's overlay from that sibling's diff, and the dashboard can select those versions (including by slug). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5705464 commit eab77fb

68 files changed

Lines changed: 2971 additions & 500 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

server/src/internal/catalogV2/actions/buildPlanChange/buildPlanLicenseChanges/buildPlanLicensePreviousAttributes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ export const buildPlanLicensePreviousAttributes = ({
1818
if (from.product.version !== to.product.version) {
1919
previous.version = from.product.version;
2020
}
21+
if (from.product.version_slug !== to.product.version_slug) {
22+
previous.version_slug = from.product.version_slug ?? undefined;
23+
}
2124
return Object.keys(previous).length > 0 ? previous : null;
2225
};

server/src/internal/catalogV2/actions/updateCatalog/compute/computeMigrationDraftPlans/resolveLicenseMigrationTarget/propagateLicenseDraftUpserts.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
licenseUpsertFromPlanLicense,
1010
sortLicenseDraftUpserts,
1111
} from "@/internal/catalogV2/actions/updateCatalog/compute/computeMigrationDraftPlans/resolveLicenseMigrationTarget/licenseUpsertFromPlanLicense";
12-
import { shouldPropagate } from "@/internal/catalogV2/actions/updateCatalog/compute/computeUpsertProductsPlan/computePlanLicensesPlan/licensePlanUtils";
12+
import {
13+
parentLicenseLinkForChild,
14+
propagateReachesLink,
15+
shouldPropagate,
16+
} from "@/internal/catalogV2/actions/updateCatalog/compute/computeUpsertProductsPlan/computePlanLicensesPlan/licensePlanUtils";
1317
import type { ProductStatesContext } from "@/internal/catalogV2/actions/updateCatalog/types/updateCatalogContext";
1418
import type { UpsertProductPlan } from "@/internal/catalogV2/actions/updateCatalog/types/upsertProductPlan";
1519

@@ -45,6 +49,10 @@ export const propagateLicenseDraftUpserts = ({
4549
continue;
4650
}
4751

52+
const currentPlanLicense = parentLicenseLinkForChild({ parent, child });
53+
if (!currentPlanLicense) continue;
54+
if (!propagateReachesLink({ currentPlanLicense, child })) continue;
55+
4856
const planLicense = parent.planLicenses?.find(
4957
(link) => link.licensePlanId === child.row.planId,
5058
);

server/src/internal/catalogV2/actions/updateCatalog/compute/computeUpsertProductsPlan/assembleNextFullProduct.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ export const assembleNextFullProduct = ({
3232
entitlements,
3333
free_trial: freeTrial,
3434
licenses: currentFullProduct?.licenses,
35+
parent_plan_licenses: currentFullProduct?.parent_plan_licenses,
3536
} as FullProduct;
3637
};

server/src/internal/catalogV2/actions/updateCatalog/compute/computeUpsertProductsPlan/computePlanLicensesPlan/declared/resolveDeclaredPlanLicenses.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
import type { ProductStatesContext } from "@/internal/catalogV2/actions/updateCatalog/types/updateCatalogContext";
88
import type { PlanLicensePlan } from "@/internal/catalogV2/actions/updateCatalog/types/upsertProductPlan";
99
import { activeFullProductForPlan } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/activeFullProductForPlan";
10+
import { findFullProductByInternalId } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/findFullProductByInternalId";
11+
import { fullProductForSlug } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/fullProductForSlug";
1012

1113
const declaredLinkChanged = ({
1214
currentPlanLicense,
@@ -38,6 +40,39 @@ const declaredLinkChanged = ({
3840
return false;
3941
};
4042

43+
/** Stated slug = that row. Omitted keeps the existing child id; new links use active. */
44+
const anchorFullProductForDeclared = ({
45+
params,
46+
currentPlanLicense,
47+
productStatesContext,
48+
}: {
49+
params: PlanLicenseParams;
50+
currentPlanLicense: FullPlanLicense | null;
51+
productStatesContext: ProductStatesContext;
52+
}): FullProduct | null => {
53+
if (params.version_slug !== undefined) {
54+
return fullProductForSlug({
55+
planId: params.license_plan_id,
56+
versionSlug: params.version_slug,
57+
productStatesContext,
58+
});
59+
}
60+
61+
if (currentPlanLicense) {
62+
return (
63+
findFullProductByInternalId({
64+
internalId: currentPlanLicense.license_internal_product_id,
65+
productStatesContext,
66+
}) ?? currentPlanLicense.product
67+
);
68+
}
69+
70+
return activeFullProductForPlan({
71+
planId: params.license_plan_id,
72+
productStatesContext,
73+
});
74+
};
75+
4176
/** Declared licenses[] vs current links → per-link write ops. */
4277
export const resolveDeclaredPlanLicenses = ({
4378
declared,
@@ -58,12 +93,13 @@ export const resolveDeclaredPlanLicenses = ({
5893
const declaredIds = new Set(declared.map((entry) => entry.license_plan_id));
5994

6095
const planned: PlanLicensePlan[] = declared.map((params) => {
61-
const licenseProduct = activeFullProductForPlan({
62-
planId: params.license_plan_id,
63-
productStatesContext,
64-
});
6596
const currentPlanLicense =
6697
currentPlanLicenseByPlanId.get(params.license_plan_id) ?? null;
98+
const licenseProduct = anchorFullProductForDeclared({
99+
params,
100+
currentPlanLicense,
101+
productStatesContext,
102+
});
67103

68104
const op = !currentPlanLicense
69105
? "create"
@@ -87,6 +123,7 @@ export const resolveDeclaredPlanLicenses = ({
87123
prepaidOnly: params.prepaid_only ?? true,
88124
metadata: params.metadata,
89125
customize: params.customize,
126+
declaredVersionSlug: params.version_slug,
90127
};
91128
});
92129

server/src/internal/catalogV2/actions/updateCatalog/compute/computeUpsertProductsPlan/computePlanLicensesPlan/licensePlanUtils.ts

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
type CatalogPropagateTargetParams,
33
type FullPlanLicense,
4+
type FullProduct,
45
type LicenseCustomize,
56
productKeyToString,
67
productToProductKey,
@@ -9,6 +10,7 @@ import type { ProductStatesContext } from "@/internal/catalogV2/actions/updateCa
910
import type { UpsertProductPlan } from "@/internal/catalogV2/actions/updateCatalog/types/upsertProductPlan";
1011
import { activeVersionForPlan } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/activeVersionForPlan";
1112
import { findFullProductByInternalId } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/findFullProductByInternalId";
13+
import { versionForSlug } from "@/internal/catalogV2/actions/updateCatalog/utils/productStateUtils/versionForSlug";
1214

1315
/** Current plan_license links on this row. Minted versions fall back to the clone source. */
1416
export const upsertProductPlanToLicenses = ({
@@ -35,35 +37,81 @@ const childSourceInternalIds = ({
3537
),
3638
];
3739

38-
/** Incoming links on the upserted child plus the demoted pointer (promote). */
40+
const uniqueReverseLinks = ({
41+
products,
42+
}: {
43+
products: Array<FullProduct | null | undefined>;
44+
}) => {
45+
const seen = new Set<string>();
46+
return products.flatMap((product) => {
47+
if (!product) return [];
48+
return (product.parent_plan_licenses ?? []).filter((link) => {
49+
const key = productKeyToString({
50+
productKey: productToProductKey({ product: link.product }),
51+
});
52+
if (seen.has(key)) return false;
53+
seen.add(key);
54+
return true;
55+
});
56+
});
57+
};
58+
59+
/** Incoming links on every live version row of this child plan. */
60+
export const reverseLinksOnChildPlan = ({
61+
planId,
62+
productStatesContext,
63+
}: {
64+
planId: string;
65+
productStatesContext: ProductStatesContext;
66+
}) =>
67+
uniqueReverseLinks({
68+
products: productStatesContext.versionsByPlanId[planId] ?? [],
69+
});
70+
71+
/** Incoming links on one child version row. */
72+
export const reverseLinksOnChildProduct = ({
73+
planId,
74+
childInternalId,
75+
productStatesContext,
76+
}: {
77+
planId: string;
78+
childInternalId: string;
79+
productStatesContext: ProductStatesContext;
80+
}) => {
81+
const childRow = (productStatesContext.versionsByPlanId[planId] ?? []).find(
82+
(row) => row.internal_id === childInternalId,
83+
);
84+
return uniqueReverseLinks({ products: [childRow] });
85+
};
86+
87+
/** Incoming links on the planned child row, plus the demoted pointer (promote). */
3988
export const reverseLinksForChild = ({
4089
upsert,
4190
productStatesContext,
4291
}: {
4392
upsert: UpsertProductPlan;
4493
productStatesContext: ProductStatesContext;
4594
}) => {
95+
const plannedInternalId =
96+
upsert.row.currentFullProduct?.internal_id ??
97+
upsert.row.baseFullProduct?.internal_id ??
98+
upsert.row.nextFullProduct.internal_id;
99+
const hydrated = (
100+
productStatesContext.versionsByPlanId[upsert.row.planId] ?? []
101+
).find((row) => row.internal_id === plannedInternalId);
46102
const previousActive = upsert.previousActiveInternalId
47103
? findFullProductByInternalId({
48104
internalId: upsert.previousActiveInternalId,
49105
productStatesContext,
50106
})
51107
: null;
52-
const seen = new Set<string>();
53-
return [
54-
upsert.row.currentFullProduct,
55-
upsert.row.baseFullProduct,
56-
previousActive,
57-
].flatMap((product) => {
58-
if (!product) return [];
59-
return (product.parent_plan_licenses ?? []).filter((link) => {
60-
const key = productKeyToString({
61-
productKey: productToProductKey({ product: link.product }),
62-
});
63-
if (seen.has(key)) return false;
64-
seen.add(key);
65-
return true;
66-
});
108+
return uniqueReverseLinks({
109+
products: [
110+
hydrated,
111+
upsert.row.currentFullProduct,
112+
upsert.row.baseFullProduct,
113+
previousActive,
114+
],
67115
});
68116
};
69117

@@ -74,8 +122,14 @@ export const parentLicenseLinkForChild = ({
74122
parent: UpsertProductPlan;
75123
child: UpsertProductPlan;
76124
}): FullPlanLicense | undefined => {
125+
const licenses = upsertProductPlanToLicenses({ upsert: parent });
126+
// Adopt mint clones the old row's link; pair by child plan so follow can
127+
// re-anchor onto the child's active row in this batch.
128+
if (parent.row.source === "license_adopt") {
129+
return licenses.find((link) => link.product.id === child.row.planId);
130+
}
77131
const sourceInternalIds = childSourceInternalIds({ child });
78-
return upsertProductPlanToLicenses({ upsert: parent }).find(
132+
return licenses.find(
79133
(link) =>
80134
link.product.id === child.row.planId &&
81135
(sourceInternalIds.length === 0 ||
@@ -110,17 +164,25 @@ const propagateTargetMatchesParent = ({
110164
target,
111165
parent,
112166
activeVersion,
167+
productStatesContext,
113168
}: {
114169
target: CatalogPropagateTargetParams;
115170
parent: UpsertProductPlan;
116171
activeVersion: number | undefined;
172+
productStatesContext: ProductStatesContext;
117173
}): boolean => {
118174
if (target.plan_id !== parent.row.planId) return false;
119175
if (target.version !== undefined) return target.version === parent.row.version;
176+
if (target.version_slug !== undefined) {
177+
const version = versionForSlug({
178+
planId: target.plan_id,
179+
versionSlug: target.version_slug,
180+
productStatesContext,
181+
});
182+
return version !== undefined && version === parent.row.version;
183+
}
120184
if (target.versioning === "all_versions") return true;
121-
return (
122-
activeVersion !== undefined && parent.row.version === activeVersion
123-
);
185+
return activeVersion !== undefined && parent.row.version === activeVersion;
124186
};
125187

126188
export const childPropagatesToParent = ({
@@ -137,7 +199,12 @@ export const childPropagatesToParent = ({
137199
productStatesContext,
138200
});
139201
return (child.propagate?.license_parents ?? []).some((target) =>
140-
propagateTargetMatchesParent({ target, parent, activeVersion }),
202+
propagateTargetMatchesParent({
203+
target,
204+
parent,
205+
activeVersion,
206+
productStatesContext,
207+
}),
141208
);
142209
};
143210

@@ -153,18 +220,36 @@ export const movesActivePointer = ({
153220
return nextIsActive && (mintedNewRow || promotedExisting);
154221
};
155222

156-
/** In-place item writes, or the child taking the pointer. Draft-mint clones do not. */
157-
export const childTriggersLicenseRewrite = ({
223+
/** Pin-lane trigger: in-place item writes only. Mints/promotes leave anchored links alone. */
224+
export const childEditsItemsInPlace = ({
158225
child,
159226
}: {
160227
child: UpsertProductPlan;
161228
}): boolean => {
162229
const mintedNewRow = child.row.versioning === "new_version";
163230
const childHasItemWrites = child.entitlementPricesPlan != null;
164-
const inPlaceItemWrites = childHasItemWrites && !mintedNewRow;
165-
return inPlaceItemWrites || movesActivePointer({ upsert: child });
231+
return childHasItemWrites && !mintedNewRow;
166232
};
167233

234+
/** Propagate-lane trigger: in-place item writes, or the child taking the pointer. */
235+
export const childTriggersLicenseRewrite = ({
236+
child,
237+
}: {
238+
child: UpsertProductPlan;
239+
}): boolean =>
240+
childEditsItemsInPlace({ child }) || movesActivePointer({ upsert: child });
241+
242+
/** In-place follow only reaches the row this link already points at. Mint/promote still move it. */
243+
export const propagateReachesLink = ({
244+
currentPlanLicense,
245+
child,
246+
}: {
247+
currentPlanLicense: FullPlanLicense;
248+
child: UpsertProductPlan;
249+
}): boolean =>
250+
movesActivePointer({ upsert: child }) ||
251+
!needsRepoint({ currentPlanLicense, child });
252+
168253
export const shouldPropagate = ({
169254
parent,
170255
child,

0 commit comments

Comments
 (0)