fix: versioning track / events request body

This commit is contained in:
John Yeo
2026-01-26 08:36:22 +00:00
parent 9b9ed180bb
commit 141d4ea5af
14 changed files with 550 additions and 57 deletions

View File

@@ -13,6 +13,7 @@ source "$(dirname "$0")/config.sh"
BUN_PARALLEL_V2 \
'integration/balances/check' \
'integration/balances/track' \
'balances/track/basic' \
'balances/track/concurrency' \
'balances/track/breakdown' \

View File

@@ -1,8 +1,6 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import type { Context, Next } from "hono";
import { redis } from "@/external/redis/initRedis.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils";
import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js";
/**
* Middleware that checks for idempotence in a request
@@ -17,19 +15,11 @@ export const idempotencyMiddleware = async (
headers["idempotency-key"] || headers["Idempotency-Key"];
if (idempotencyKey) {
const redisKey = `${ctx.org.id}:${ctx.env}:idempotency:${idempotencyKey}`;
// Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions
const wasSet = await tryRedisWrite(() => {
return redis.set(redisKey, "1", "PX", 1000 * 60 * 60 * 24, "NX"); // 24 hours, only set if not exists
await checkIdempotencyKey({
orgId: ctx.org.id,
env: ctx.env,
idempotencyKey,
});
if (!wasSet) {
throw new RecaseError({
message: `Another request with idempotency key ${idempotencyKey} has already been received`,
code: ErrCode.DuplicateIdempotencyKey,
statusCode: 409,
});
}
}
await next();

View File

@@ -1,4 +1,10 @@
import { TrackParamsSchema, TrackQuerySchema } from "@autumn/shared";
import {
AffectedResource,
ApiVersion,
TrackParamsSchema,
TrackParamsV0Schema,
TrackQuerySchema,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { runTrackV2 } from "@/internal/balances/track/runTrackV2.js";
import {
@@ -8,19 +14,15 @@ import {
export const handleTrack = createRoute({
query: TrackQuerySchema,
body: TrackParamsSchema,
versionedBody: {
latest: TrackParamsSchema,
[ApiVersion.V1_Beta]: TrackParamsV0Schema,
},
resource: AffectedResource.Track,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
// Legacy: support value in properties
if (body.properties?.value) {
const parsedValue = Number(body.properties.value);
if (!Number.isNaN(parsedValue)) {
body.value = parsedValue;
}
}
// Build feature deductions
const featureDeductions = body.feature_id
? getTrackFeatureDeductions({

View File

@@ -47,7 +47,6 @@ export const runTrackV2 = async ({
await handleEventIdempotencyKey({
ctx,
body,
fullCustomer,
});
}

View File

@@ -1,32 +1,35 @@
import type { FullCustomer, TrackParams } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { EventService } from "../../../api/events/EventService";
import { buildEventInfo, initEvent } from "../../events/initEvent";
import type { TrackParams } from "@autumn/shared";
import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
export const handleEventIdempotencyKey = async ({
ctx,
body,
fullCustomer,
}: {
ctx: AutumnContext;
body: TrackParams;
fullCustomer: FullCustomer;
}) => {
const eventInfo = buildEventInfo(body);
const newEvent = initEvent({
ctx,
eventInfo,
internalCustomerId: fullCustomer.internal_id,
internalEntityId: fullCustomer.entity?.internal_id ?? undefined,
customerId: body.customer_id,
entityId: body.entity_id,
await checkIdempotencyKey({
orgId: ctx.org.id,
env: ctx.env,
idempotencyKey: `track:${body.idempotency_key}`,
});
await EventService.insert({
db: ctx.db,
event: newEvent,
});
// const eventInfo = buildEventInfo(body);
body.skip_event = true;
// const newEvent = initEvent({
// ctx,
// eventInfo,
// internalCustomerId: fullCustomer.internal_id,
// internalEntityId: fullCustomer.entity?.internal_id ?? undefined,
// customerId: body.customer_id,
// entityId: body.entity_id,
// });
// await EventService.insert({
// db: ctx.db,
// event: newEvent,
// });
// body.skip_event = true;
};

View File

@@ -53,7 +53,7 @@ const queueEvent = ({
body: TrackParams;
fullCustomer: FullCustomer;
}): void => {
if (body.skip_event || body.idempotency_key) return;
if (body.skip_event) return;
const eventInfo = buildEventInfo(body);

View File

@@ -0,0 +1,52 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
const IDEMPOTENCY_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
/**
* Checks and sets an idempotency key in Redis using atomic SET NX operation.
* If Redis is not ready, allows the request to proceed (fail-open).
* Throws if the key already exists (duplicate request).
*/
export const checkIdempotencyKey = async ({
orgId,
env,
idempotencyKey,
}: {
orgId: string;
env: string;
idempotencyKey: string;
}): Promise<void> => {
// Fail-open: if Redis is not ready, allow the request
if (redis.status !== "ready") {
return;
}
const redisKey = `${orgId}:${env}:idempotency:${idempotencyKey}`;
try {
// Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions
const wasSet = await redis.set(
redisKey,
"1",
"PX",
IDEMPOTENCY_TTL_MS,
"NX",
);
if (!wasSet) {
throw new RecaseError({
message: `Another request with idempotency key ${idempotencyKey} has already been received`,
code: ErrCode.DuplicateIdempotencyKey,
statusCode: 409,
});
}
} catch (error) {
// Re-throw RecaseError (duplicate key)
if (error instanceof RecaseError) {
throw error;
}
// For other Redis errors, fail-open (allow request)
return;
}
};

View File

@@ -26,9 +26,14 @@ const freeProd = constructProduct({
const testCase = "track-basic6";
// Generate unique idempotency keys per test run to avoid Redis TTL conflicts
const testRunId = Date.now().toString(36);
describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents duplicate tracks")}`, () => {
const customerId = "track-basic6";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const idempotencyKey1 = `test-idempotency-key-1-${testRunId}`;
const idempotencyKey2 = `test-idempotency-key-2-${testRunId}`;
beforeAll(async () => {
await initCustomerV3({
@@ -58,13 +63,12 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
test("should process first track with idempotency key", async () => {
const deductValue = 25.5;
const idempotencyKey = "test-idempotency-key-1";
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
idempotency_key: idempotencyKey1,
});
const customer = await autumnV1.customers.get(customerId);
@@ -76,28 +80,28 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
await timeout(2000);
const eventsList = await getCustomerEvents({ customerId });
expect(eventsList).toHaveLength(1);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1);
expect(eventsList?.[0].value).toBe(deductValue);
});
test("should reject second track with same idempotency key", async () => {
const deductValue = 30.75; // Different value
const idempotencyKey = "test-idempotency-key-1"; // Same key
// Get balance before attempting duplicate track
const customerBefore = await autumnV1.customers.get(customerId);
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
// This should fail or be rejected due to duplicate idempotency key
await expectAutumnError({
errCode: ErrCode.DuplicateEvent,
errCode: ErrCode.DuplicateIdempotencyKey,
func: async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
idempotency_key: idempotencyKey1, // Same key as first test
});
},
});
@@ -117,12 +121,11 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
const eventsList = await getCustomerEvents({ customerId });
expect(eventsList).toHaveLength(1);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1);
});
test("should process track with different idempotency key", async () => {
const deductValue = 15.25;
const idempotencyKey = "test-idempotency-key-2"; // Different key
const customerBefore = await autumnV1.customers.get(customerId);
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
@@ -131,7 +134,7 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
idempotency_key: idempotencyKey,
idempotency_key: idempotencyKey2, // Different key
});
const customer = await autumnV1.customers.get(customerId);

View File

@@ -24,7 +24,7 @@ test(
items: [allocatedUsersItem, priceItem],
});
const uniqueId = `paid-alloc-lock-${Date.now()}`;
const uniqueId = `paid-alloc-lock`;
const { customerId, autumnV2 } = await initScenario({
customerId: uniqueId,
setup: [

View File

@@ -0,0 +1,330 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiEntityV0,
CusExpand,
type TrackResponseV2,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { EventService } from "@/internal/api/events/EventService.js";
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC1: Auto-create customer and entity via track
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc1: track auto-creates customer and entity")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const customerId = "track-misc1";
const { autumnV1 } = await initScenario({
setup: [
s.deleteCustomer({ customerId: "track-misc1" }),
s.products({ list: [freeProd], prefix: customerId }),
],
actions: [],
});
const entityId = `${customerId}-entity-1`;
await autumnV1.track({
customer_id: customerId,
customer_data: {
name: "Test Customer",
email: "test@test.com",
},
feature_id: TestFeature.Messages,
entity_id: entityId,
entity_data: {
name: "Test Entity",
feature_id: TestFeature.Users,
},
value: 5,
});
// Verify customer was created with provided data
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer).toMatchObject({
id: customerId,
name: "Test Customer",
email: "test@test.com",
});
// Verify entity was created with provided data
const entity = await autumnV1.entities.get<ApiEntityV0>(customerId, entityId);
expect(entity).toMatchObject({
id: entityId,
name: "Test Entity",
});
// Verify customer.entities includes the created entity
const customerWithEntities = await autumnV1.customers.get<ApiCustomerV3>(
customerId,
{ expand: [CusExpand.Entities] },
);
expect(customerWithEntities.entities).toBeDefined();
expect(customerWithEntities.entities).toHaveLength(1);
expect(customerWithEntities.entities?.[0].id).toBe(entityId);
expect(customerWithEntities.entities?.[0].name).toBe("Test Entity");
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC2: Track event stores properties
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc2: track event stores custom properties")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc2",
setup: [s.customer({ testClock: false })],
actions: [],
});
await autumnV1.track({
customer_id: customerId,
customer_data: {
name: "track-misc2",
email: "track-misc2@test.com",
},
feature_id: TestFeature.Messages,
value: 5,
properties: {
hello: "world",
foo: "bar",
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
with_autumn_id: true,
});
await timeout(2000);
const events = await EventService.getByCustomerId({
db: ctx.db,
orgId: ctx.org.id,
internalCustomerId: customer.autumn_id!,
env: ctx.env,
});
expect(events).toHaveLength(1);
expect(events?.[0].properties).toMatchObject({
hello: "world",
foo: "bar",
});
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC3: Track creates events when balance is empty
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc3: track creates events when customer has no balance")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc3",
setup: [s.customer({ testClock: false })],
actions: [],
});
const trackCount = Math.floor(Math.random() * 10) + 1;
let totalValue = 0;
await Promise.all(
Array.from({ length: trackCount }, () => {
const trackValue = Math.random() * 10;
totalValue += trackValue;
return autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: trackValue,
});
}),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
with_autumn_id: true,
});
await timeout(2000);
const events = await EventService.getByCustomerId({
db: ctx.db,
orgId: ctx.org.id,
internalCustomerId: customer.autumn_id ?? "",
env: ctx.env,
});
expect(events).toHaveLength(trackCount);
expect(events.reduce((acc, event) => acc + (event.value ?? 0), 0)).toBe(
totalValue,
);
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC4: Track v1.2 response format
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc4: track returns correct v1.2 response format")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc4",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV2 = await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 20,
});
expect(trackRes).toMatchObject({
id: "placeholder",
code: "event_received",
customer_id: customerId,
feature_id: TestFeature.Messages,
});
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC5: V1.2 properties.value maps to value field
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc5: V1.2 properties.value maps to value field")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc5",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
// Track using V1.2 legacy format with properties.value
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
properties: {
value: 42.1532,
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
with_autumn_id: true,
});
// Verify balance was deducted correctly
expect(customer.features[TestFeature.Messages].balance).toBe(
new Decimal(100).sub(42.1532).toNumber(),
);
expect(customer.features[TestFeature.Messages].usage).toBe(42.1532);
await timeout(2000);
const events = await EventService.getByCustomerId({
db: ctx.db,
orgId: ctx.org.id,
internalCustomerId: customer.autumn_id!,
env: ctx.env,
});
expect(events).toHaveLength(1);
expect(events[0].value).toBe(42.1532);
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC7: Track defaults to value: 1 when no value provided
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc7: track defaults to value 1 when no value provided")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc7",
setup: [s.customer({ testClock: false })],
actions: [],
});
// Track without providing any value
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
with_autumn_id: true,
});
await timeout(2000);
const events = await EventService.getByCustomerId({
db: ctx.db,
orgId: ctx.org.id,
internalCustomerId: customer.autumn_id!,
env: ctx.env,
});
expect(events).toHaveLength(1);
expect(events[0].value).toBe(1);
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC8: V1.2 properties.value is removed from properties after extraction
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-misc8: V1.2 properties.value is removed from stored properties")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "track-misc8",
setup: [s.customer({ testClock: false })],
actions: [],
});
// Track using V1.2 legacy format with properties.value and other properties
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
properties: {
value: 25,
hello: "world",
foo: "bar",
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
with_autumn_id: true,
});
await timeout(2000);
const events = await EventService.getByCustomerId({
db: ctx.db,
orgId: ctx.org.id,
internalCustomerId: customer.autumn_id!,
env: ctx.env,
});
expect(events).toHaveLength(1);
expect(events[0].value).toBe(25);
// Verify value was removed from properties but other props remain
expect(events[0].properties).toMatchObject({
hello: "world",
foo: "bar",
});
expect(events[0].properties).not.toHaveProperty("value");
});

View File

@@ -0,0 +1,38 @@
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../../common/customerData.js";
import { EntityDataSchema } from "../../../common/entityData.js";
/**
* TrackParamsV0Schema - V1.2 and earlier format
*
* In V1.2, the `value` field could be passed either as a top-level field OR
* inside `properties.value`. This schema supports both, with the transformation
* extracting `properties.value` only if top-level `value` is not provided.
*/
export const TrackParamsV0Schema = z
.object({
customer_id: z.string().nonempty(),
feature_id: z.string().optional(),
event_name: z.string().nonempty().optional(),
value: z.number().optional(),
properties: z.record(z.string(), z.any()).optional(),
timestamp: z.number().optional(),
idempotency_key: z.string().optional(),
customer_data: CustomerDataSchema.optional(),
entity_id: z.string().optional(),
entity_data: EntityDataSchema.optional(),
overage_behavior: z.enum(["cap", "reject"]).optional(),
skip_event: z.boolean().optional(),
})
.refine(
(data) => {
if (data.feature_id && data.event_name) return false;
if (!data.feature_id && !data.event_name) return false;
return true;
},
{
message: "Either feature_id or event_name must be provided",
},
);
export type TrackParamsV0 = z.infer<typeof TrackParamsV0Schema>;

View File

@@ -0,0 +1,72 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import { TrackParamsV0Schema } from "../prevVersions/trackParamsV0.js";
import { TrackParamsSchema } from "../trackParams.js";
/**
* V1_2_TrackParamsChange: Transforms track request body TO latest format
*
* Applied when: sourceVersion <= V1.2
*
* Breaking changes introduced in V2.0 (that we transform here):
*
* 1. Value field location:
* - V1.2: `properties.value` (value passed inside properties object)
* - V2.0+: `value` (top-level field)
*
* This transformation extracts `properties.value` and maps it to the
* top-level `value` field for V1.2 clients, ensuring they can continue
* using the legacy format.
*
* Input: TrackParamsV0 (V1.2 format with properties.value)
* Output: TrackParamsV1 (V2.0+ format with top-level value)
*/
export const V1_2_TrackParamsChange = defineVersionChange({
name: "V1_2 Track Params Change",
newVersion: ApiVersion.V2_0,
oldVersion: ApiVersion.V1_Beta,
description: [
"Maps properties.value to top-level value field for V1.2 clients",
],
affectedResources: [AffectedResource.Track],
newSchema: TrackParamsSchema,
oldSchema: TrackParamsV0Schema,
affectsRequest: true,
affectsResponse: false,
// Request: V1.2 → V2.0 (extract properties.value to value if not already set)
transformRequest: ({
input,
}: {
input: z.infer<typeof TrackParamsV0Schema>;
}): z.infer<typeof TrackParamsSchema> => {
// Keep original value if provided, otherwise extract from properties.value
let value = input.value;
let properties = input.properties;
if (input.properties?.value !== undefined) {
// Only use properties.value if top-level value is not set
if (value === undefined) {
const parsedValue = Number(input.properties.value);
if (!Number.isNaN(parsedValue)) {
value = parsedValue;
}
}
// Always remove value from properties after processing
const { value: _, ...restProperties } = input.properties;
properties = Object.keys(restProperties).length > 0 ? restProperties : {};
}
return {
...input,
properties,
value,
};
},
});

View File

@@ -66,6 +66,7 @@ export * from "./balances/check/prevVersions/CheckResponseV0.js";
export * from "./balances/check/prevVersions/CheckResponseV1.js";
export * from "./balances/create/createBalanceParams.js";
export * from "./balances/prevVersions/legacyUpdateBalanceModels.js";
export * from "./balances/track/prevVersions/trackParamsV0.js";
export * from "./balances/track/prevVersions/trackResponseV1.js";
export * from "./balances/track/trackParams.js";
export * from "./balances/track/trackResponseV2.js";

View File

@@ -25,6 +25,7 @@ import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.
import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js";
import { V1_2_CheckQueryChange } from "../../balances/check/changes/V1.2_CheckQueryChange.js";
import { V1_2_TrackChange } from "../../balances/track/changes/V1.2_TrackChange.js";
import { V1_2_TrackParamsChange } from "../../balances/track/requestChanges/V1.2_TrackParamsChange.js";
// Import attach changes
import { V0_2_AttachChange } from "../../billing/attach/changes/V0.2_AttachChange.js";
import { ApiVersion } from "../ApiVersion.js";
@@ -42,6 +43,7 @@ export const V2_CHANGES: VersionChangeConstructor[] = [
V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format
V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options)
V1_2_TrackChange, // Transforms Track TO V1.2 format from V0.2 format
V1_2_TrackParamsChange, // Transforms Track params TO V2.0 (maps properties.value → value)
V1_2_FeatureChange, // Transforms Feature TO V1_Beta format (V0) from V2 format (V1)
V1_2_CreateFeatureChange, // Transforms Create Feature params TO V1_Beta