Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
const TENANTS_TABLE = `Tenants`;
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable(TENANTS_TABLE, {
id: {
type: DataTypes.INTEGER,
allowNull: false,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable(TENANTS_TABLE);
},
};

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
import { DEFAULT_TENANT_ID } from '@citrineos/base';
import { QueryInterface } from 'sequelize';
const TENANTS_TABLE = `Tenants`;
export default {
up: async (queryInterface: QueryInterface) => {
const [[existingTenant]] = await queryInterface.sequelize.query(
`SELECT 1 FROM "${TENANTS_TABLE}" WHERE id = ${DEFAULT_TENANT_ID} LIMIT 1`,
);
if (!existingTenant) {
await queryInterface.bulkInsert(TENANTS_TABLE, [
{
id: DEFAULT_TENANT_ID,
name: 'Default Tenant',
createdAt: new Date(),
updatedAt: new Date(),
},
]);
}
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.bulkDelete(TENANTS_TABLE, { id: DEFAULT_TENANT_ID });
},
};

View File

@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DEFAULT_TENANT_ID } from '@citrineos/base';
import { DataTypes, QueryInterface } from 'sequelize';
const TABLES = [
'AdditionalInfos',
'IdTokens',
'IdTokenInfos',
'Authorizations',
'Boots',
'Certificates',
'InstalledCertificates',
'ChangeConfigurations',
'Evses',
'Locations',
'ChargingStations',
'Transactions',
'ChargingNeeds',
'ChargingProfiles',
'ChargingSchedules',
'ServerNetworkProfiles',
'SetNetworkProfiles',
'ChargingStationNetworkProfiles',
'ChargingStationSecurityInfos',
'ChargingStationSequences',
'Components',
'Variables',
'ComponentVariables',
'CompositeSchedules',
'Connectors',
'EventData',
'IdTokenAdditionalInfos',
'TransactionEvents',
'StopTransactions',
'MeterValues',
'MessageInfos',
'OCPPMessages',
'Reservations',
'SalesTariffs',
'SecurityEvents',
'StartTransactions',
'StatusNotifications',
'LatestStatusNotifications',
'Subscriptions',
'Tariffs',
'VariableAttributes',
'VariableCharacteristics',
'VariableMonitorings',
'VariableMonitoringStatuses',
'VariableStatuses',
'LocalListAuthorizations',
'LocalListVersions',
'LocalListVersionAuthorizations',
'SendLocalLists',
'SendLocalListAuthorizations',
];
const TENANT_COLUMN = 'tenantId';
const TENANTS_TABLE = `Tenants`;
export default {
up: async (queryInterface: QueryInterface) => {
for (const table of TABLES) {
const tableDescription = await queryInterface.describeTable(table);
if (!tableDescription[TENANT_COLUMN]) {
await queryInterface.addColumn(table, TENANT_COLUMN, {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: DEFAULT_TENANT_ID,
references: {
model: TENANTS_TABLE,
key: 'id',
},
onUpdate: 'CASCADE', // update tenantId if the tenant primary key is updated (should never happen)
onDelete: 'RESTRICT', // ensure tenant row cannot be deleted if there are existing records using it
});
}
}
},
down: async (queryInterface: QueryInterface) => {
for (const table of TABLES) {
const tableDescription = await queryInterface.describeTable(table);
if (tableDescription[TENANT_COLUMN]) {
await queryInterface.removeColumn(table, TENANT_COLUMN);
}
}
},
};

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
import { DataType } from 'sequelize-typescript';
const TABLE_NAME = 'Authorizations';
const COLUMN_NAME = 'concurrentTransaction';
export default {
up: async (queryInterface: QueryInterface) => {
const tableDescription = await queryInterface.describeTable(TABLE_NAME);
if (!tableDescription[COLUMN_NAME]) {
await queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, {
type: DataType.BOOLEAN,
allowNull: true,
defaultValue: false,
});
}
},
down: async (queryInterface: QueryInterface) => {
const tableDescription = await queryInterface.describeTable(TABLE_NAME);
if (tableDescription[COLUMN_NAME]) {
await queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME);
}
},
};

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { AuthorizationWhitelistEnum } from '@citrineos/base';
import { QueryInterface } from 'sequelize';
import { DataType } from 'sequelize-typescript';
const TABLE_NAME = 'Authorizations';
const COLUMNS = [
{
name: 'realTimeAuth',
attributes: {
type: DataType.STRING,
allowNull: false,
defaultValue: AuthorizationWhitelistEnum.Never,
},
},
{
name: 'realTimeAuthUrl',
attributes: {
type: DataType.STRING,
allowNull: true,
},
},
];
export default {
up: async (queryInterface: QueryInterface) => {
const tableDescription = await queryInterface.describeTable(TABLE_NAME);
for (const column of COLUMNS) {
if (!tableDescription[column.name]) {
await queryInterface.addColumn(TABLE_NAME, column.name, column.attributes);
}
}
},
down: async (queryInterface: QueryInterface) => {
const tableDescription = await queryInterface.describeTable(TABLE_NAME);
for (const column of COLUMNS) {
if (tableDescription[column.name]) {
await queryInterface.removeColumn(TABLE_NAME, column.name);
}
}
},
};

View File

@@ -0,0 +1,425 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// 1. Drop any existing temp tables first, then create temp tables to preserve data from related tables
await queryInterface.sequelize.query('DROP TABLE IF EXISTS "IdTokens_temp"');
await queryInterface.sequelize.query('DROP TABLE IF EXISTS "IdTokenInfos_temp"');
await queryInterface.sequelize.query('DROP TABLE IF EXISTS "AdditionalInfos_temp"');
await queryInterface.sequelize.query('DROP TABLE IF EXISTS "Authorizations_temp"');
await queryInterface.sequelize.query(`
CREATE TABLE "IdTokens_temp" AS TABLE "IdTokens";
`);
await queryInterface.sequelize.query(`
CREATE TABLE "IdTokenInfos_temp" AS TABLE "IdTokenInfos";
`);
await queryInterface.sequelize.query(`
CREATE TABLE "AdditionalInfos_temp" AS TABLE "AdditionalInfos";
`);
await queryInterface.sequelize.query(`
CREATE TABLE "Authorizations_temp" AS TABLE "Authorizations";
`);
// 2. Alter the Authorizations table: add new flat columns, but do not drop old columns yet
// Check if columns exist before adding them
const tableDescription = await queryInterface.describeTable('Authorizations');
if (!tableDescription['idToken']) {
await queryInterface.addColumn('Authorizations', 'idToken', {
type: 'VARCHAR(255)',
allowNull: true,
});
}
if (!tableDescription['idTokenType']) {
await queryInterface.addColumn('Authorizations', 'idTokenType', {
type: 'VARCHAR(255)',
allowNull: true,
});
}
if (!tableDescription['additionalInfo']) {
await queryInterface.addColumn('Authorizations', 'additionalInfo', {
type: 'JSONB',
allowNull: true,
});
}
if (!tableDescription['status']) {
await queryInterface.addColumn('Authorizations', 'status', {
type: 'VARCHAR(255)',
allowNull: true,
});
}
if (!tableDescription['cacheExpiryDateTime']) {
await queryInterface.addColumn('Authorizations', 'cacheExpiryDateTime', {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: true,
});
}
if (!tableDescription['chargingPriority']) {
await queryInterface.addColumn('Authorizations', 'chargingPriority', {
type: 'INTEGER',
allowNull: true,
});
}
if (!tableDescription['language1']) {
await queryInterface.addColumn('Authorizations', 'language1', {
type: 'VARCHAR(255)',
allowNull: true,
});
}
if (!tableDescription['language2']) {
await queryInterface.addColumn('Authorizations', 'language2', {
type: 'VARCHAR(255)',
allowNull: true,
});
}
if (!tableDescription['personalMessage']) {
await queryInterface.addColumn('Authorizations', 'personalMessage', {
type: 'JSON',
allowNull: true,
});
}
if (!tableDescription['groupIdTokenId']) {
await queryInterface.addColumn('Authorizations', 'groupIdTokenId', {
type: 'INTEGER',
allowNull: true,
references: { model: 'Authorizations', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
}
// concurrentTransaction already exists from previous migration, skip it
if (!tableDescription['customData']) {
await queryInterface.addColumn('Authorizations', 'customData', {
type: 'JSONB',
allowNull: true,
});
}
if (!tableDescription['groupAuthorizationId']) {
await queryInterface.addColumn('Authorizations', 'groupAuthorizationId', {
type: 'INTEGER',
allowNull: true,
});
}
// Ensure personalMessage is JSONB
if (
tableDescription['personalMessage'] &&
tableDescription['personalMessage'].type !== 'JSONB'
) {
await queryInterface.changeColumn('Authorizations', 'personalMessage', {
type: 'JSONB',
allowNull: true,
});
}
// Ensure concurrentTransaction is BOOLEAN
if (
tableDescription['concurrentTransaction'] &&
tableDescription['concurrentTransaction'].type !== 'BOOLEAN'
) {
await queryInterface.changeColumn('Authorizations', 'concurrentTransaction', {
type: 'BOOLEAN',
allowNull: true,
});
}
// 3. Copy/transform data from old columns/related tables into new flat columns
await queryInterface.sequelize.query(`
UPDATE "Authorizations"
SET
"idToken" = subq."idToken",
"idTokenType" = subq."idTokenType",
"additionalInfo" = subq."additionalInfo",
"status" = subq."status",
"cacheExpiryDateTime" = subq."cacheExpiryDateTime",
"chargingPriority" = subq."chargingPriority",
"language1" = subq."language1",
"language2" = subq."language2",
"personalMessage" = subq."personalMessage",
"groupIdTokenId" = subq."groupIdTokenId",
"concurrentTransaction" = subq."concurrentTransaction",
"customData" = NULL
FROM (
SELECT
auth."id" as auth_id,
t."idToken",
t."type" as "idTokenType",
COALESCE(
(
SELECT jsonb_agg(
jsonb_build_object(
'additionalIdToken', ai."additionalIdToken",
'type', ai."type"
)
)
FROM "AdditionalInfos" ai
INNER JOIN "IdTokenAdditionalInfos" itai ON ai."id" = itai."additionalInfoId"
WHERE itai."idTokenId" = t."id"
),
NULL
) as "additionalInfo",
COALESCE(info."status", 'Accepted') as "status",
info."cacheExpiryDateTime",
info."chargingPriority",
info."language1",
info."language2",
info."personalMessage",
CASE
WHEN info."groupIdTokenId" IS NOT NULL THEN (
SELECT auth2."id"
FROM "Authorizations" auth2
WHERE auth2."idTokenId" = info."groupIdTokenId"
LIMIT 1
)
ELSE NULL
END as "groupIdTokenId",
COALESCE(auth."concurrentTransaction", false) as "concurrentTransaction"
FROM "Authorizations" auth
INNER JOIN "IdTokens" t ON auth."idTokenId" = t."id"
LEFT JOIN "IdTokenInfos" info ON auth."idTokenInfoId" = info."id"
) subq
WHERE "Authorizations"."id" = subq.auth_id
`);
// 4. Set NOT NULL and default constraints on new columns as needed
await queryInterface.changeColumn('Authorizations', 'idToken', {
type: 'VARCHAR(255)',
allowNull: false,
});
await queryInterface.changeColumn('Authorizations', 'status', {
type: 'VARCHAR(255)',
allowNull: false,
defaultValue: 'Accepted',
});
// 5. Drop old columns and tables
await queryInterface
.removeConstraint('Authorizations', 'Authorizations_idTokenId_fkey')
.catch(() => {});
await queryInterface
.removeConstraint('Authorizations', 'Authorizations_idTokenInfoId_fkey')
.catch(() => {});
await queryInterface.removeColumn('Authorizations', 'idTokenId').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'idTokenInfoId').catch(() => {});
// Drop all foreign key constraints systematically
const constraintsToRemove = [
// IdTokens table constraints
['IdTokenInfos', 'IdTokenInfos_groupIdTokenId_fkey'],
['IdTokenAdditionalInfos', 'IdTokenAdditionalInfos_idTokenId_fkey'],
['TransactionEvents', 'TransactionEvents_idTokenId_fkey'],
['StopTransactions', 'StopTransactions_idTokenDatabaseId_fkey'],
['StartTransactions', 'StartTransactions_idTokenDatabaseId_fkey'],
['LocalListAuthorizations', 'LocalListAuthorizations_idTokenId_fkey'],
// IdTokenInfos table constraints
['LocalListAuthorizations', 'LocalListAuthorizations_idTokenInfoId_fkey'],
// AdditionalInfos table constraints
['IdTokenAdditionalInfos', 'IdTokenAdditionalInfos_additionalInfoId_fkey'],
];
for (const [tableName, constraintName] of constraintsToRemove) {
await queryInterface.removeConstraint(tableName, constraintName).catch(() => {});
}
// Drop junction table first
await queryInterface.sequelize.query('DROP TABLE IF EXISTS "IdTokenAdditionalInfos"');
// Drop temp tables and old tables in correct order
const tablesToDrop = [
'Authorizations_temp',
'IdTokens_temp',
'IdTokenInfos_temp',
'AdditionalInfos_temp',
'IdTokenInfos',
'AdditionalInfos',
'IdTokens',
];
for (const tableName of tablesToDrop) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS "${tableName}"`);
}
},
down: async (queryInterface: QueryInterface) => {
// 1. Add back the old columns to Authorizations table
await queryInterface.addColumn('Authorizations', 'idTokenId', {
type: 'INTEGER',
allowNull: true,
});
await queryInterface.addColumn('Authorizations', 'idTokenInfoId', {
type: 'INTEGER',
allowNull: true,
});
// 2. Recreate old tables structure
await queryInterface.sequelize.query(`
CREATE TABLE "IdTokens" (
"id" SERIAL PRIMARY KEY,
"idToken" VARCHAR(255) NOT NULL,
"type" VARCHAR(255),
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now()
);
`);
await queryInterface.sequelize.query(`
CREATE TABLE "IdTokenInfos" (
"id" SERIAL PRIMARY KEY,
"info" JSONB,
"status" VARCHAR(255),
"cacheExpiryDateTime" TIMESTAMP WITH TIME ZONE,
"chargingPriority" INTEGER,
"language1" VARCHAR(255),
"language2" VARCHAR(255),
"personalMessage" JSON,
"groupIdTokenId" INTEGER,
"concurrentTransaction" BOOLEAN,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now()
);
`);
await queryInterface.sequelize.query(`
CREATE TABLE "AdditionalInfos" (
"id" SERIAL PRIMARY KEY,
"additionalIdToken" VARCHAR(255) NOT NULL,
"type" VARCHAR(255) NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
UNIQUE("additionalIdToken", "type")
);
`);
await queryInterface.sequelize.query(`
CREATE TABLE "IdTokenAdditionalInfos" (
"id" SERIAL PRIMARY KEY,
"idTokenId" INTEGER REFERENCES "IdTokens"("id") ON DELETE CASCADE,
"additionalInfoId" INTEGER REFERENCES "AdditionalInfos"("id") ON DELETE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(),
UNIQUE("idTokenId", "additionalInfoId")
);
`);
// 3. Restore data to the recreated tables from flattened Authorization data
// Insert IdTokens from flattened data
await queryInterface.sequelize.query(`
INSERT INTO "IdTokens" ("idToken", "type", "createdAt", "updatedAt")
SELECT DISTINCT "idToken", "idTokenType", "createdAt", "updatedAt"
FROM "Authorizations"
WHERE "idToken" IS NOT NULL
ON CONFLICT ("idToken", "type") DO NOTHING
`);
// Insert IdTokenInfos from flattened data (with proper groupIdTokenId handling)
await queryInterface.sequelize.query(`
INSERT INTO "IdTokenInfos" (
"status", "cacheExpiryDateTime", "chargingPriority", "language1", "language2",
"personalMessage", "groupIdTokenId", "concurrentTransaction", "createdAt", "updatedAt"
)
SELECT DISTINCT
"status", "cacheExpiryDateTime", "chargingPriority", "language1", "language2",
"personalMessage",
CASE
WHEN "groupIdTokenId" IS NOT NULL THEN (
SELECT t."id"
FROM "IdTokens" t
INNER JOIN "Authorizations" auth ON auth."idToken" = t."idToken" AND auth."idTokenType" = t."type"
WHERE auth."id" = "Authorizations"."groupIdTokenId"
LIMIT 1
)
ELSE NULL
END as "groupIdTokenId",
"concurrentTransaction", "createdAt", "updatedAt"
FROM "Authorizations"
WHERE "status" IS NOT NULL
`);
// Insert AdditionalInfos from flattened additionalInfo JSONB array
await queryInterface.sequelize.query(`
INSERT INTO "AdditionalInfos" ("additionalIdToken", "type", "createdAt", "updatedAt")
SELECT DISTINCT
(jsonb_array_elements("additionalInfo")->>'additionalIdToken')::VARCHAR(255),
(jsonb_array_elements("additionalInfo")->>'type')::VARCHAR(255),
"createdAt",
"updatedAt"
FROM "Authorizations"
WHERE "additionalInfo" IS NOT NULL
AND jsonb_array_length("additionalInfo") > 0
ON CONFLICT ("additionalIdToken", "type") DO NOTHING
`);
// Create IdTokenAdditionalInfo junction table relationships
await queryInterface.sequelize.query(`
INSERT INTO "IdTokenAdditionalInfos" ("idTokenId", "additionalInfoId", "createdAt", "updatedAt")
SELECT DISTINCT
t."id" as "idTokenId",
ai."id" as "additionalInfoId",
a."createdAt",
a."updatedAt"
FROM "Authorizations" a
INNER JOIN "IdTokens" t ON a."idToken" = t."idToken" AND a."idTokenType" = t."type"
CROSS JOIN LATERAL jsonb_array_elements(a."additionalInfo") as elem
INNER JOIN "AdditionalInfos" ai ON
ai."additionalIdToken" = (elem->>'additionalIdToken')::VARCHAR(255) AND
ai."type" = (elem->>'type')::VARCHAR(255)
WHERE a."additionalInfo" IS NOT NULL
AND jsonb_array_length(a."additionalInfo") > 0
ON CONFLICT ("idTokenId", "additionalInfoId") DO NOTHING
`);
// 4. Update Authorizations with foreign key references
await queryInterface.sequelize.query(`
UPDATE "Authorizations"
SET
"idTokenId" = t."id",
"idTokenInfoId" = (
SELECT info."id"
FROM "IdTokenInfos" info
WHERE COALESCE("Authorizations"."status", 'Accepted') = info."status"
AND COALESCE("Authorizations"."cacheExpiryDateTime", '1970-01-01'::timestamp) = COALESCE(info."cacheExpiryDateTime", '1970-01-01'::timestamp)
AND COALESCE("Authorizations"."chargingPriority", -999) = COALESCE(info."chargingPriority", -999)
AND COALESCE("Authorizations"."language1", '') = COALESCE(info."language1", '')
AND COALESCE("Authorizations"."language2", '') = COALESCE(info."language2", '')
LIMIT 1
)
FROM "IdTokens" t
WHERE "Authorizations"."idToken" = t."idToken"
AND COALESCE("Authorizations"."idTokenType", '') = COALESCE(t."type", '')
`);
// 5. Add foreign key constraints back
await queryInterface.addConstraint('Authorizations', {
fields: ['idTokenId'],
type: 'foreign key',
name: 'Authorizations_idTokenId_fkey',
references: { table: 'IdTokens', field: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
});
await queryInterface.addConstraint('Authorizations', {
fields: ['idTokenInfoId'],
type: 'foreign key',
name: 'Authorizations_idTokenInfoId_fkey',
references: { table: 'IdTokenInfos', field: 'id' },
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
// 6. Remove flattened columns
await queryInterface.removeColumn('Authorizations', 'idToken').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'idTokenType').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'additionalInfo').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'status').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'cacheExpiryDateTime').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'chargingPriority').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'language1').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'language2').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'personalMessage').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'groupIdTokenId').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'concurrentTransaction').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'customData').catch(() => {});
await queryInterface.removeColumn('Authorizations', 'groupAuthorizationId').catch(() => {});
},
};

View File

@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DEFAULT_TENANT_ID } from '@citrineos/base';
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('TenantPartners', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
partyId: {
type: DataTypes.STRING,
allowNull: false,
},
countryCode: {
type: DataTypes.STRING,
allowNull: false,
},
tenantId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Tenants',
key: 'id',
},
defaultValue: DEFAULT_TENANT_ID,
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
partnerProfileOCPI: {
type: DataTypes.JSONB,
allowNull: true,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
},
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('TenantPartners');
},
};

View File

@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Tenants', 'partyId', {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'default',
});
await queryInterface.addColumn('Tenants', 'countryCode', {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'US',
});
await queryInterface.addColumn('Tenants', 'url', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Tenants', 'serverProfileOCPI', {
type: DataTypes.JSONB,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Tenants', 'partyId');
await queryInterface.removeColumn('Tenants', 'countryCode');
await queryInterface.removeColumn('Tenants', 'url');
await queryInterface.removeColumn('Tenants', 'serverProfileOCPI');
},
};

View File

@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
const TABLE_NAME = 'AsyncJobStatuses';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable(TABLE_NAME, {
jobId: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
},
jobName: {
type: DataTypes.STRING,
allowNull: false,
},
tenantPartnerId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'TenantPartners',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
},
finishedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stoppedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stopScheduled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
isFailed: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
paginationParams: {
type: DataTypes.JSON,
allowNull: false,
},
totalObjects: {
type: DataTypes.INTEGER,
allowNull: true,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable(TABLE_NAME);
await queryInterface.sequelize.query(`
DROP TYPE IF EXISTS "enum_AsyncJobStatuses_jobName";
`);
},
};

View File

@@ -0,0 +1,603 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Helper to check if a constraint exists (robust for schema/casing)
const constraintExists = async (
tableName: string,
constraintName: string,
): Promise<boolean> => {
const [results] = await queryInterface.sequelize.query(
`SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = 'public' AND table_name = '${tableName}' AND constraint_name = '${constraintName}';`,
);
return results.length > 0;
};
// 1. Create EvseTypes table
await queryInterface.createTable('EvseTypes', {
databaseId: {
type: DataTypes.INTEGER,
allowNull: false,
autoIncrement: true,
primaryKey: true,
},
id: {
type: DataTypes.INTEGER,
allowNull: true,
},
connectorId: {
type: DataTypes.INTEGER,
allowNull: true,
},
tenantId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Tenants',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
// 2. Add all missing columns
await queryInterface.renameColumn('ChargingNeeds', 'evseDatabaseId', 'evseId');
await queryInterface.addColumn('Evses', 'stationId', {
type: DataTypes.STRING(36),
allowNull: true,
});
await queryInterface.addColumn('TransactionEvents', 'idTokenValue', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.removeColumn('Authorizations', 'groupIdTokenId');
await queryInterface.addColumn('Authorizations', 'tenantPartnerId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'evseId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'connectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'groupAuthorizationId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.removeColumn('LocalListAuthorizations', 'idTokenId');
await queryInterface.addColumn('LocalListAuthorizations', 'idToken', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.sequelize.query(
'UPDATE "LocalListAuthorizations" SET "idToken" = \'\' WHERE "idToken" IS NULL;',
);
await queryInterface.changeColumn('LocalListAuthorizations', 'idToken', {
type: DataTypes.STRING,
allowNull: false,
});
await queryInterface.addColumn('LocalListAuthorizations', 'idTokenType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'additionalInfo', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'status', {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Accepted',
});
await queryInterface.addColumn('LocalListAuthorizations', 'cacheExpiryDateTime', {
type: DataTypes.DATE,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'chargingPriority', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'language1', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'language2', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'personalMessage', {
type: DataTypes.JSON,
allowNull: true,
});
await queryInterface.removeColumn('LocalListAuthorizations', 'idTokenInfoId');
await queryInterface.addColumn('LocalListAuthorizations', 'customData', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'evseTypeId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('TransactionEvents', 'idTokenType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.removeColumn('TransactionEvents', 'idTokenId');
await queryInterface.addColumn('Evses', 'evseId', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'physicalReference', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'removed', {
type: DataTypes.BOOLEAN,
allowNull: true,
});
await queryInterface.addColumn('StopTransactions', 'idTokenValue', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('StopTransactions', 'idTokenType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.removeColumn('StopTransactions', 'idTokenDatabaseId');
// ChargingStation: Add missing columns
await queryInterface.addColumn('ChargingStations', 'coordinates', {
type: DataTypes.GEOMETRY('POINT'),
allowNull: true,
});
await queryInterface.addColumn('ChargingStations', 'floorLevel', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('ChargingStations', 'parkingRestrictions', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('ChargingStations', 'capabilities', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Locations', 'publishUpstream', {
type: DataTypes.BOOLEAN,
defaultValue: true,
});
await queryInterface.addColumn('Locations', 'timeZone', {
type: DataTypes.STRING,
defaultValue: 'UTC',
});
await queryInterface.addColumn('Locations', 'parkingType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Locations', 'facilities', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Locations', 'openingHours', {
type: DataTypes.JSONB,
allowNull: true,
});
// Tariff: Add missing column
await queryInterface.addColumn('Tariffs', 'tariffAltText', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('MeterValues', 'customData', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('MeterValues', 'tariffId', {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'Tariffs',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
await queryInterface.addColumn('MeterValues', 'transactionId', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.removeColumn('Transactions', 'evseDatabaseId');
await queryInterface.addColumn('Transactions', 'locationId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'evseId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'connectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'authorizationId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'tariffId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'startTime', {
type: DataTypes.DATE,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'endTime', {
type: DataTypes.DATE,
allowNull: true,
});
await queryInterface.addColumn('Transactions', 'customData', {
type: DataTypes.JSONB,
allowNull: true,
});
// 3. Drop dependent foreign key constraints
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_evseDatabaseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "ChargingNeeds" DROP CONSTRAINT IF EXISTS "ChargingNeeds_evseDatabaseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "Components_evseDatabaseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "TransactionEvents" DROP CONSTRAINT IF EXISTS "TransactionEvents_evseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Reservations" DROP CONSTRAINT IF EXISTS "Reservations_evseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "VariableAttributes" DROP CONSTRAINT IF EXISTS "VariableAttributes_evseDatabaseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "Components_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Reservations" DROP CONSTRAINT IF EXISTS "Reservations_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "TransactionEvents" DROP CONSTRAINT IF EXISTS "TransactionEvents_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "VariableAttributes" DROP CONSTRAINT IF EXISTS "VariableAttributes_evseTypeId_fkey";',
);
// 4. Fix the Evses table
await queryInterface.sequelize.query(
'ALTER TABLE "Evses" DROP CONSTRAINT IF EXISTS "Evses_pkey";',
);
// Populate EvseTypes from existing data before adding foreign keys
await queryInterface.sequelize.query(`
INSERT INTO "EvseTypes" ("id", "tenantId", "connectorId", "createdAt", "updatedAt")
SELECT "id", "tenantId", "connectorId", NOW(), NOW()
FROM "Evses";
`);
// Truncate Evses table after migration
await queryInterface.sequelize.query('TRUNCATE TABLE "Evses" CASCADE;');
await queryInterface.removeColumn('Evses', 'databaseId');
await queryInterface.removeColumn('Evses', 'connectorId');
await queryInterface.removeColumn('Evses', 'id');
// Sequelize does not support adding a primary key via addColumn, so we do it in two steps
await queryInterface.addColumn('Evses', 'id', {
type: DataTypes.INTEGER,
autoIncrement: true,
});
await queryInterface.sequelize.query(
'ALTER TABLE "Evses" ADD CONSTRAINT "Evses_pkey" PRIMARY KEY (id);',
);
await queryInterface.addColumn('Connectors', 'evseTypeConnectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'type', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'format', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'powerType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'maximumAmperage', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'maximumVoltage', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'maximumPowerWatts', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Connectors', 'termsAndConditionsUrl', {
type: DataTypes.STRING,
allowNull: true,
});
// Foreign key constraints for relationships (add only if not exists, drop if exists first)
if (await constraintExists('Connectors', 'Connectors_evseId_fkey')) {
await queryInterface.sequelize.query(
'ALTER TABLE "Connectors" DROP CONSTRAINT IF EXISTS "Connectors_evseId_fkey";',
);
}
if (!(await constraintExists('Connectors', 'Connectors_evseId_fkey'))) {
await queryInterface.sequelize.query(
'ALTER TABLE "Connectors" ADD CONSTRAINT "Connectors_evseId_fkey" FOREIGN KEY ("evseId") REFERENCES "Evses" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
}
if (!(await constraintExists('Connectors', 'Connectors_stationId_fkey'))) {
await queryInterface.sequelize.query(
'ALTER TABLE "Connectors" ADD CONSTRAINT "Connectors_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ChargingStations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
}
if (!(await constraintExists('ChargingStations', 'ChargingStations_locationId_fkey'))) {
await queryInterface.sequelize.query(
'ALTER TABLE "ChargingStations" ADD CONSTRAINT "ChargingStations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Locations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
}
if (!(await constraintExists('Authorizations', 'Authorizations_groupAuthorizationId_fkey'))) {
await queryInterface.sequelize.query(
'ALTER TABLE "Authorizations" ADD CONSTRAINT "Authorizations_groupAuthorizationId_fkey" FOREIGN KEY ("groupAuthorizationId") REFERENCES "Authorizations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
}
if (!(await constraintExists('ChargingNeeds', 'ChargingNeeds_transactionDatabaseId_fkey'))) {
await queryInterface.sequelize.query(
'ALTER TABLE "ChargingNeeds" ADD CONSTRAINT "ChargingNeeds_transactionDatabaseId_fkey" FOREIGN KEY ("transactionDatabaseId") REFERENCES "Transactions" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
}
// Rename partnerProfile to partnerProfileOCPI in TenantPartners ---
const tenantPartnersTable = 'TenantPartners';
const oldColumn = 'partnerProfile';
const newColumn = 'partnerProfileOCPI';
const tenantPartnersDesc = await queryInterface.describeTable(tenantPartnersTable);
if (tenantPartnersDesc[oldColumn] && !tenantPartnersDesc[newColumn]) {
await queryInterface.renameColumn(tenantPartnersTable, oldColumn, newColumn);
}
// 5. Re-create all foreign key constraints
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" ADD CONSTRAINT "Transactions_evseId_fkey" FOREIGN KEY ("evseId") REFERENCES "Evses" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "ChargingNeeds" ADD CONSTRAINT "ChargingNeeds_evseId_fkey" FOREIGN KEY ("evseId") REFERENCES "Evses" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Authorizations" ADD CONSTRAINT "Authorizations_tenantPartnerId_fkey" FOREIGN KEY ("tenantPartnerId") REFERENCES "TenantPartners" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Evses" ADD CONSTRAINT "Evses_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ChargingStations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Tariffs" ADD CONSTRAINT "Tariffs_connectorId_fkey" FOREIGN KEY ("connectorId") REFERENCES "Connectors" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" ADD CONSTRAINT "Transactions_tariffId_fkey" FOREIGN KEY ("tariffId") REFERENCES "Tariffs" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" ADD CONSTRAINT "Transactions_authorizationId_fkey" FOREIGN KEY ("authorizationId") REFERENCES "Authorizations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" ADD CONSTRAINT "Transactions_connectorId_fkey" FOREIGN KEY ("connectorId") REFERENCES "Connectors" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" ADD CONSTRAINT "Transactions_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Locations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "LocalListAuthorizations" ADD CONSTRAINT "LocalListAuthorizations_groupAuthorizationId_fkey" FOREIGN KEY ("groupAuthorizationId") REFERENCES "Authorizations" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Components" ADD CONSTRAINT "Components_evseTypeId_fkey" FOREIGN KEY ("evseDatabaseId") REFERENCES "EvseTypes" ("databaseId") ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Reservations" ADD CONSTRAINT "Reservations_evseTypeId_fkey" FOREIGN KEY ("evseId") REFERENCES "EvseTypes" ("databaseId") ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "TransactionEvents" ADD CONSTRAINT "TransactionEvents_evseTypeId_fkey" FOREIGN KEY ("evseId") REFERENCES "EvseTypes" ("databaseId") ON UPDATE CASCADE ON DELETE SET NULL;',
);
await queryInterface.sequelize.query(
'ALTER TABLE "VariableAttributes" ADD CONSTRAINT "VariableAttributes_evseTypeId_fkey" FOREIGN KEY ("evseDatabaseId") REFERENCES "EvseTypes" ("databaseId") ON UPDATE CASCADE ON DELETE SET NULL;',
);
},
down: async (queryInterface: QueryInterface) => {
// 1. Drop all foreign key constraints added in up
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_evseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_tariffId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_authorizationId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_connectorId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "Transactions_locationId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "ChargingNeeds" DROP CONSTRAINT IF EXISTS "ChargingNeeds_evseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Authorizations" DROP CONSTRAINT IF EXISTS "Authorizations_tenantPartnerId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Authorizations" DROP CONSTRAINT IF EXISTS "Authorizations_groupAuthorizationId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Evses" DROP CONSTRAINT IF EXISTS "Evses_stationId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Connectors" DROP CONSTRAINT IF EXISTS "Connectors_evseId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Tariffs" DROP CONSTRAINT IF EXISTS "Tariffs_connectorId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "LocalListAuthorizations" DROP CONSTRAINT IF EXISTS "LocalListAuthorizations_groupAuthorizationId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "Components_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "Reservations" DROP CONSTRAINT IF EXISTS "Reservations_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "TransactionEvents" DROP CONSTRAINT IF EXISTS "TransactionEvents_evseTypeId_fkey";',
);
await queryInterface.sequelize.query(
'ALTER TABLE "VariableAttributes" DROP CONSTRAINT IF EXISTS "VariableAttributes_evseTypeId_fkey";',
);
// 2. Remove all columns added in up
await queryInterface.renameColumn('ChargingNeeds', 'evseId', 'evseDatabaseId');
await queryInterface.removeColumn('Evses', 'stationId');
await queryInterface.removeColumn('TransactionEvents', 'idTokenValue');
await queryInterface.removeColumn('Transactions', 'evseId');
await queryInterface.removeColumn('Authorizations', 'tenantPartnerId');
await queryInterface.removeColumn('Connectors', 'evseId');
await queryInterface.removeColumn('Tariffs', 'connectorId');
await queryInterface.removeColumn('Transactions', 'tariffId');
await queryInterface.removeColumn('Transactions', 'authorizationId');
await queryInterface.removeColumn('Transactions', 'connectorId');
await queryInterface.removeColumn('Transactions', 'locationId');
await queryInterface.removeColumn('LocalListAuthorizations', 'groupAuthorizationId');
await queryInterface.addColumn('LocalListAuthorizations', 'idTokenId', {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'IdTokens',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
await queryInterface.removeColumn('LocalListAuthorizations', 'idToken');
await queryInterface.addColumn('LocalListAuthorizations', 'idTokenType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'additionalInfo', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'status', {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Accepted',
});
await queryInterface.addColumn('LocalListAuthorizations', 'cacheExpiryDateTime', {
type: DataTypes.DATE,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'chargingPriority', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'language1', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'language2', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('LocalListAuthorizations', 'personalMessage', {
type: DataTypes.JSON,
allowNull: true,
});
await queryInterface.removeColumn('LocalListAuthorizations', 'idTokenInfoId');
await queryInterface.addColumn('LocalListAuthorizations', 'customData', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'evseTypeId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('TransactionEvents', 'idTokenType', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.removeColumn('TransactionEvents', 'idTokenId');
await queryInterface.addColumn('Evses', 'evseId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'physicalReference', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Evses', 'removed', {
type: DataTypes.BOOLEAN,
allowNull: true,
});
// ChargingStation: Remove columns
await queryInterface.removeColumn('ChargingStations', 'coordinates');
await queryInterface.removeColumn('ChargingStations', 'floorLevel');
await queryInterface.removeColumn('ChargingStations', 'parkingRestrictions');
await queryInterface.removeColumn('ChargingStations', 'capabilities');
// Tariff: Remove column
await queryInterface.removeColumn('Tariffs', 'tariffAltText');
// 3. Drop EvseTypes table
await queryInterface.dropTable('EvseTypes');
// 4. Restore Evses table PK/index as needed
await queryInterface.addColumn('Evses', 'databaseId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.sequelize.query(
'ALTER TABLE "Evses" DROP CONSTRAINT IF EXISTS "Evses_pkey";',
);
// Revert partnerProfileOCPI to partnerProfile in TenantPartners ---
const tenantPartnersTable = 'TenantPartners';
const oldColumn = 'partnerProfile';
const newColumn = 'partnerProfileOCPI';
const tenantPartnersDesc = await queryInterface.describeTable(tenantPartnersTable);
if (tenantPartnersDesc[newColumn] && !tenantPartnersDesc[oldColumn]) {
await queryInterface.renameColumn(tenantPartnersTable, newColumn, oldColumn);
}
},
};

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// First, let's get all the stations that have EvseTypes associated with them
// through VariableAttributes
const [stationEvseTypes] = await queryInterface.sequelize.query(`
SELECT DISTINCT
cs.id as "stationId",
cs."tenantId" as "tenantId",
et.id as "evseTypeId",
ROW_NUMBER() OVER (PARTITION BY cs.id, et.id ORDER BY va.id) as "evseSequence"
FROM "ChargingStations" cs
INNER JOIN "VariableAttributes" va ON va."stationId" = cs.id
INNER JOIN "EvseTypes" et ON va."evseDatabaseId" = et."databaseId"
WHERE cs.id IS NOT NULL
AND et.id IS NOT NULL
ORDER BY cs.id, et.id
`);
// Now create Evse records for each station-evseType combination
let id = 1;
const evseInserts = stationEvseTypes.map((row: any, index: number) => {
// Generate evseId in the format US*TST*C*01234567*8
// Using the station's stationId and a sequence number
const paddedSequence = row.evseSequence.toString().padStart(8, '0');
const evseId = `US*TST*C*${paddedSequence}*${index % 10}`;
const evse = {
id,
stationId: row.stationId,
evseTypeId: row.evseTypeId,
evseId: evseId,
tenantId: row.tenantId,
createdAt: new Date(),
updatedAt: new Date(),
};
id++;
return evse;
});
// Bulk insert the new Evse records
if (evseInserts.length > 0) {
await queryInterface.bulkInsert('Evses', evseInserts);
console.log(`Created ${evseInserts.length} Evse records from EvseType associations`);
} else {
console.log('No EvseType associations found to migrate');
}
},
down: async (queryInterface: QueryInterface) => {
// Remove all Evse records that were created by this migration
// We'll identify them by the fact that they have evseIds matching our pattern
await queryInterface.sequelize.query(`
DELETE FROM "Evses"
WHERE "evseId" LIKE 'US*TST*C*%'
`);
console.log('Rolled back Evse creation migration');
},
};

View File

@@ -0,0 +1,157 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.changeColumn('InstalledCertificates', 'certificateType', {
type: DataTypes.STRING,
allowNull: false,
});
await queryInterface.sequelize.query(`
DROP TYPE "enum_InstalledCertificates_certificateType";
`);
await queryInterface.changeColumn('Connectors', 'status', {
type: DataTypes.STRING,
});
await queryInterface.sequelize.query(`
DROP TYPE "enum_Connectors_status";
`);
// Default value references enum type, since default value is changed before column type, so we had to change the column to STRING first.
await queryInterface.changeColumn('Connectors', 'status', {
type: DataTypes.STRING,
defaultValue: 'Unknown',
});
await queryInterface.changeColumn('Connectors', 'errorCode', {
type: DataTypes.STRING,
});
await queryInterface.sequelize.query(`
DROP TYPE "enum_Connectors_errorCode";
`);
// Default value references enum type, since default value is changed before column type, so we had to change the column to STRING first.
await queryInterface.changeColumn('Connectors', 'errorCode', {
type: DataTypes.STRING,
defaultValue: 'NoError',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'enum_InstalledCertificates_certificateType'
) THEN
CREATE TYPE "enum_InstalledCertificates_certificateType" AS ENUM (
'V2GRootCertificate',
'MORootCertificate',
'CSMSRootCertificate',
'V2GCertificateChain',
'ManufacturerRootCertificate'
);
END IF;
END$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'enum_Connectors_status'
) THEN
CREATE TYPE "enum_Connectors_status" AS ENUM (
'Available',
'Preparing',
'Charging',
'SuspendedEVSE',
'SuspendedEV',
'Finishing',
'Reserved',
'Unavailable',
'Faulted'
);
END IF;
END$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'enum_Connectors_errorCode'
) THEN
CREATE TYPE "enum_Connectors_errorCode" AS ENUM (
'ConnectorLockFailure',
'EVCommunicationError',
'GroundFailure',
'HighTemperature',
'InternalError',
'LocalListConflict',
'NoError',
'OtherError',
'OverCurrentFailure',
'PowerMeterFailure',
'PowerSwitchFailure',
'ReaderFailure',
'ResetFailure',
'UnderVoltage',
'OverVoltage',
'WeakSignal'
);
END IF;
END$$;
`);
await queryInterface.changeColumn('InstalledCertificates', 'certificateType', {
type: DataTypes.ENUM(
'V2GRootCertificate',
'MORootCertificate',
'CSMSRootCertificate',
'V2GCertificateChain',
'ManufacturerRootCertificate',
),
allowNull: false,
});
await queryInterface.changeColumn('Connectors', 'status', {
type: DataTypes.ENUM(
'Available',
'Preparing',
'Charging',
'SuspendedEVSE',
'SuspendedEV',
'Finishing',
'Reserved',
'Unavailable',
'Faulted',
),
allowNull: false,
defaultValue: 'Unknown',
});
await queryInterface.changeColumn('Connectors', 'errorCode', {
type: DataTypes.ENUM(
'ConnectorLockFailure',
'EVCommunicationError',
'GroundFailure',
'HighTemperature',
'InternalError',
'LocalListConflict',
'NoError',
'OtherError',
'OverCurrentFailure',
'PowerMeterFailure',
'PowerSwitchFailure',
'ReaderFailure',
'ResetFailure',
'UnderVoltage',
'OverVoltage',
'WeakSignal',
),
allowNull: false,
defaultValue: 'NoError',
});
},
};

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Add isUserTenant column
await queryInterface.addColumn('Tenants', 'isUserTenant', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Indicates if this tenant is a user tenant',
});
// Make url, partyId, countryCode optional
await queryInterface.changeColumn('Tenants', 'url', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.changeColumn('Tenants', 'partyId', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.changeColumn('Tenants', 'countryCode', {
type: DataTypes.STRING,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Tenants', 'isUserTenant');
await queryInterface.changeColumn('Tenants', 'url', {
type: DataTypes.STRING,
allowNull: false,
});
await queryInterface.changeColumn('Tenants', 'partyId', {
type: DataTypes.STRING,
allowNull: false,
});
await queryInterface.changeColumn('Tenants', 'countryCode', {
type: DataTypes.STRING,
allowNull: false,
});
},
};

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Authorizations', 'realTimeAuthLastAttempt', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Authorizations', 'realTimeAuthTimeout', {
type: DataTypes.INTEGER,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Authorizations', 'realTimeAuthLastAttempt');
await queryInterface.removeColumn('Authorizations', 'realTimeAuthTimeout');
},
};

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
console.log('Creating citext extension if not exists...');
await queryInterface.sequelize.query('CREATE EXTENSION IF NOT EXISTS citext;');
console.log('Changing column to use CITEXT type...');
await queryInterface.changeColumn('Authorizations', 'idToken', {
type: DataTypes.CITEXT,
allowNull: false,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.changeColumn('Authorizations', 'idToken', {
type: DataTypes.STRING,
allowNull: false,
});
// Note: Not dropping the extension in case other tables use it
},
};

View File

@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// First check if the index already exists
console.log('Checking if index idToken_type already exists...');
const [indexes] = await queryInterface.sequelize.query(`
SELECT indexname
FROM pg_indexes
WHERE tablename = 'Authorizations'
AND indexname = 'idToken_type'
`);
if (indexes.length > 0) {
console.log('Index idToken_type already exists, skipping creation');
return;
}
console.log('Checking for duplicate idToken/idTokenType combinations...');
// Check for duplicates before creating the index
const [duplicates] = await queryInterface.sequelize.query(`
SELECT "idToken", "idTokenType", COUNT(*) as count
FROM "Authorizations"
WHERE "idToken" IS NOT NULL
GROUP BY "idToken", "idTokenType"
HAVING COUNT(*) > 1
ORDER BY "idToken", "idTokenType"
`);
if (duplicates.length > 0) {
console.error('Cannot create unique index due to duplicate data:');
duplicates.forEach((dup: any) => {
console.error(
` - idToken: "${dup.idToken}", idTokenType: "${dup.idTokenType}", count: ${dup.count}`,
);
});
throw new Error(
`Migration failed: Found ${duplicates.length} duplicate idToken/idTokenType combinations. ` +
'Please resolve these duplicates before running this migration. ' +
'You may need t`o` update or remove duplicate records in the Authorizations table.',
);
}
console.log('No duplicates found. Proceeding with index creation...');
await queryInterface.addIndex('Authorizations', ['idToken', 'idTokenType'], {
unique: true,
name: 'idToken_type',
});
console.log('Successfully created unique index: idToken_type');
},
down: async (queryInterface: QueryInterface) => {
console.log('Removing unique index on idToken and idTokenType columns...');
try {
await queryInterface.removeIndex('Authorizations', 'idToken_type');
console.log('Successfully removed unique index: idToken_type');
} catch (error: any) {
if (error.message?.includes('does not exist')) {
console.log('Index idToken_type does not exist, skipping removal');
return;
}
throw error;
}
},
};

View File

@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('ChargingStations', 'use16StatusNotification0', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('ChargingStations', 'use16StatusNotification0');
},
};

View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Transactions', 'meterStart', {
type: DataTypes.DECIMAL,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Transactions', 'meterStart');
},
};

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Migration to add latestOcppMessageTimestamp.
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add new latestOcppMessageTimestamp column
await queryInterface.addColumn('ChargingStations', 'latestOcppMessageTimestamp', {
type: DataTypes.DATE,
allowNull: true,
});
// Add index on latestOcppMessageTimestamp for efficient staleness queries
await queryInterface.addIndex('ChargingStations', ['latestOcppMessageTimestamp'], {
name: 'idx_charging_stations_latest_ocpp_message_timestamp',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Remove index
await queryInterface.removeIndex(
'ChargingStations',
'idx_charging_stations_latest_ocpp_message_timestamp',
);
// Remove latestOcppMessageTimestamp column
await queryInterface.removeColumn('ChargingStations', 'latestOcppMessageTimestamp');
}

View File

@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
/**
* Migration to add state and requestMessageId self-referencing foreign key to OCPPMessages table.
* This enables linking response messages to their corresponding request messages.
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add new state column
await queryInterface.addColumn('OCPPMessages', 'state', {
type: DataTypes.STRING,
allowNull: true,
});
// Add new requestMessageId column
await queryInterface.addColumn('OCPPMessages', 'requestMessageId', {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'OCPPMessages',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
// Add index on requestMessageId for efficient queries
await queryInterface.addIndex('OCPPMessages', ['requestMessageId'], {
name: 'idx_ocpp_messages_request_message_id',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Remove index
await queryInterface.removeIndex('OCPPMessages', 'idx_ocpp_messages_request_message_id');
// Remove requestMessageId column
await queryInterface.removeColumn('OCPPMessages', 'requestMessageId');
// Remove state column
await queryInterface.removeColumn('OCPPMessages', 'state');
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Check if columns already exist
const table = await queryInterface.describeTable('ServerNetworkProfiles');
// Add dynamicTenantResolution column if it doesn't exist
if (!table.dynamicTenantResolution) {
await queryInterface.addColumn('ServerNetworkProfiles', 'dynamicTenantResolution', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Enable dynamic tenant resolution at WebSocket upgrade time',
});
}
// Add maxConnectionsPerTenant column if it doesn't exist
if (!table.maxConnectionsPerTenant) {
await queryInterface.addColumn('ServerNetworkProfiles', 'maxConnectionsPerTenant', {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Maximum number of concurrent connections allowed per tenant',
});
}
},
down: async (queryInterface: QueryInterface) => {
// Rollback: remove the columns
await queryInterface.removeColumn('ServerNetworkProfiles', 'dynamicTenantResolution');
await queryInterface.removeColumn('ServerNetworkProfiles', 'maxConnectionsPerTenant');
},
};

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('Certificates', 'certificateFileHash', {
type: DataTypes.STRING,
unique: true,
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('Certificates', 'certificateFileHash');
}

View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('InstalledCertificates', 'certificateId', {
type: DataTypes.INTEGER,
references: {
model: 'Certificates',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('InstalledCertificates', 'certificateId');
}

View File

@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('InstallCertificateAttempts', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
stationId: {
type: DataTypes.STRING(36),
allowNull: false,
references: {
model: 'ChargingStations',
key: 'id',
},
},
certificateType: {
type: DataTypes.STRING,
allowNull: false,
},
certificateId: {
type: DataTypes.INTEGER,
references: {
model: 'Certificates',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
status: {
type: DataTypes.STRING,
},
tenantId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Tenants',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
},
});
await queryInterface.createTable('DeleteCertificateAttempts', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
stationId: {
type: DataTypes.STRING(36),
allowNull: false,
references: {
model: 'ChargingStations',
key: 'id',
},
},
hashAlgorithm: {
type: DataTypes.STRING,
allowNull: false,
},
issuerNameHash: {
type: DataTypes.STRING,
},
issuerKeyHash: {
type: DataTypes.STRING,
},
serialNumber: {
type: DataTypes.STRING,
},
status: {
type: DataTypes.STRING,
},
tenantId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Tenants',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
},
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('DeleteCertificateAttempts');
await queryInterface.dropTable('InstallCertificateAttempts');
}

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Check if columns already exist
const table = await queryInterface.describeTable('ServerNetworkProfiles');
// Add tenantPathMapping column if it doesn't exist
if (!table.tenantPathMapping) {
await queryInterface.addColumn('ServerNetworkProfiles', 'tenantPathMapping', {
type: DataTypes.JSONB,
allowNull: true,
comment: 'Mapping of URL path segments to tenant IDs',
});
}
},
down: async (queryInterface: QueryInterface) => {
// Rollback: remove the column
await queryInterface.removeColumn('ServerNetworkProfiles', 'tenantPathMapping');
},
};

View File

@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Change evseId to allow null
await queryInterface.changeColumn('Connectors', 'evseId', {
type: DataTypes.INTEGER,
allowNull: true,
});
// Change connectorId to allow null
await queryInterface.changeColumn('Connectors', 'connectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
// Change evseTypeConnectorId to allow null
await queryInterface.changeColumn('Connectors', 'evseTypeConnectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
// Rollback: change columns back to not allow null
await queryInterface.changeColumn('Connectors', 'evseId', {
type: DataTypes.INTEGER,
allowNull: false,
});
await queryInterface.changeColumn('Connectors', 'connectorId', {
type: DataTypes.INTEGER,
allowNull: false,
});
await queryInterface.changeColumn('Connectors', 'evseTypeConnectorId', {
type: DataTypes.INTEGER,
allowNull: false,
});
},
};

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Add tariffId FK to Connectors so each connector can reference at most one reusable tariff
await queryInterface.addColumn('Connectors', 'tariffId', {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'Tariffs', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
// Migrate existing data: for Tariffs that had a connectorId set, populate
// Connectors.tariffId with that tariff's id before dropping the old columns.
await queryInterface.sequelize.query(`
UPDATE "Connectors" c
SET "tariffId" = t.id
FROM "Tariffs" t
WHERE t."connectorId" = c.id;
`);
// Drop the old FK constraint and station/connector columns from Tariffs
await queryInterface.sequelize.query(
'ALTER TABLE "Tariffs" DROP CONSTRAINT IF EXISTS "Tariffs_connectorId_fkey";',
);
await queryInterface.removeColumn('Tariffs', 'connectorId');
await queryInterface.removeColumn('Tariffs', 'stationId');
},
down: async (queryInterface: QueryInterface) => {
// Restore stationId and connectorId columns on Tariffs
await queryInterface.addColumn('Tariffs', 'stationId', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'connectorId', {
type: DataTypes.INTEGER,
allowNull: true,
});
await queryInterface.sequelize.query(
'ALTER TABLE "Tariffs" ADD CONSTRAINT "Tariffs_connectorId_fkey" FOREIGN KEY ("connectorId") REFERENCES "Connectors" (id) ON UPDATE CASCADE ON DELETE SET NULL;',
);
// Remove tariffId from Connectors
await queryInterface.removeColumn('Connectors', 'tariffId');
},
};

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Check if columns already exist
const table = await queryInterface.describeTable('Tenants');
// Add maxChargingStations column if it doesn't exist
if (!table.maxChargingStations) {
await queryInterface.addColumn('Tenants', 'maxChargingStations', {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Maximum number of charging stations allowed for this tenant',
});
}
},
down: async (queryInterface: QueryInterface) => {
// Rollback: remove the column
await queryInterface.removeColumn('Tenants', 'maxChargingStations');
},
};

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
const table = await queryInterface.describeTable('ServerNetworkProfiles');
if (!table.protocols) {
await queryInterface.addColumn('ServerNetworkProfiles', 'protocols', {
type: DataTypes.ARRAY(DataTypes.STRING),
allowNull: true,
});
}
// Migrate existing data: wrap the string value of 'protocol' into a single-element array
if (table.protocol) {
await queryInterface.sequelize.query(`
UPDATE "ServerNetworkProfiles"
SET "protocols" = ARRAY["protocol"]
WHERE "protocol" IS NOT NULL;
`);
await queryInterface.removeColumn('ServerNetworkProfiles', 'protocol');
}
},
down: async (queryInterface: QueryInterface) => {
const table = await queryInterface.describeTable('ServerNetworkProfiles');
if (!table.protocol) {
await queryInterface.addColumn('ServerNetworkProfiles', 'protocol', {
type: DataTypes.STRING,
allowNull: true,
});
}
// Migrate back: take the first element of the 'protocols' array
if (table.protocols) {
await queryInterface.sequelize.query(`
UPDATE "ServerNetworkProfiles"
SET "protocol" = "protocols"[1]
WHERE "protocols" IS NOT NULL
AND array_length("protocols", 1) > 0;
`);
await queryInterface.removeColumn('ServerNetworkProfiles', 'protocols');
}
},
};

View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
const table = await queryInterface.describeTable('ServerNetworkProfiles');
if (table.maxConnectionsPerTenant) {
await queryInterface.removeColumn('ServerNetworkProfiles', 'maxConnectionsPerTenant');
}
},
down: async (_queryInterface: QueryInterface) => {
// Column is intentionally not restored; per-tenant limits are now driven by Tenant.maxChargingStations
},
};

View File

@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Change tenantId to allow null in ServerNetworkProfiles
await queryInterface.changeColumn('ServerNetworkProfiles', 'tenantId', {
type: DataTypes.INTEGER,
allowNull: true,
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
});
},
down: async (queryInterface: QueryInterface) => {
// Rollback: change tenantId back to not allow null
await queryInterface.changeColumn('ServerNetworkProfiles', 'tenantId', {
type: DataTypes.INTEGER,
allowNull: false,
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
});
},
};

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('VariableMonitorings', 'eventNotificationType', {
type: DataTypes.STRING,
allowNull: true,
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('VariableMonitorings', 'eventNotificationType');
}

View File

@@ -0,0 +1,953 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
import { DataTypes, type ModelAttributeColumnOptions, QueryInterface, QueryTypes } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// ── Everything below runs inside a single PostgreSQL transaction. ───────────
// PostgreSQL treats DDL as transactional, so any failure causes a full
// rollback — no partial state, no data loss.
await queryInterface.sequelize.transaction(async (transaction) => {
// ── Helpers ──────────────────────────────────────────────────────────────
/** Fire-and-forget raw SQL inside the transaction. */
const q = (sql: string, replacements?: Record<string, any>) =>
queryInterface.sequelize.query(sql, {
transaction,
replacements,
type: QueryTypes.RAW,
});
/** SELECT inside the transaction; returns the row array directly. */
const qSelect = <T = any>(sql: string, replacements?: Record<string, any>): Promise<T[]> =>
queryInterface.sequelize.query(sql, {
transaction,
replacements,
type: QueryTypes.SELECT,
}) as Promise<T[]>;
const addCol = (table: string, col: string, def: ModelAttributeColumnOptions) =>
queryInterface.addColumn(table, col, def, { transaction } as any);
/** Check which columns exist — uses the transaction connection to avoid
* deadlocking on a single-connection pool. */
const describeTable = async (table: string): Promise<Record<string, any>> => {
const rows = await qSelect<{ Field: string }>(
`SELECT column_name AS "Field"
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = :table`,
{ table },
);
const desc: Record<string, any> = {};
for (const row of rows) {
desc[(row as any).Field] = true;
}
return desc;
};
const dropConstraintIfExists = (table: string, name: string) =>
q(`ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${name}"`);
const dropIndexIfExists = (name: string) => q(`DROP INDEX IF EXISTS "${name}"`);
/**
* Drop every FK on `table` that uses `column`, by inspecting
* information_schema so that we handle any constraint name.
*/
const dropFkByColumn = async (table: string, column: string) => {
const rows = await qSelect<{ constraint_name: string }>(
`SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.table_schema = 'public'
AND tc.table_name = :table
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = :column`,
{ table, column },
);
for (const row of rows) {
await q(
`ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${(row as any).constraint_name}"`,
);
}
};
/**
* After UPDATE … SET stationPkId = cs.pkId, verify that every row with a
* non-NULL stationId was matched. A mismatch means the database already
* had a referential-integrity problem; we refuse to proceed rather than
* silently lose the relationship.
*/
const validateNoOrphans = async (table: string) => {
const [row] = await qSelect<{ cnt: string }>(
`SELECT COUNT(*) AS cnt
FROM "${table}"
WHERE "stationId" IS NOT NULL
AND "stationPkId" IS NULL`,
);
const cnt = parseInt((row as any).cnt, 10);
if (cnt > 0) {
throw new Error(
`Data integrity error: ${cnt} row(s) in "${table}" have a non-NULL ` +
`stationId that could not be matched to any ChargingStation. ` +
`Resolve these orphaned rows before running this migration.`,
);
}
};
// ── Step 1: Drop ALL FK constraints on child tables ───────────────────────
// Covers both the original stationId_fkey constraints AND any
// stationPkId_fkey constraints left behind by a previous partial run.
const allChildFks: [string, string][] = [
// ── original stationId_fkey constraints ──────────────────────────────
['Transactions', 'Transactions_stationId_fkey'],
['ChargingStationNetworkProfiles', 'ChargingStationNetworkProfiles_stationId_fkey'],
['ChargingStationSequences', 'ChargingStationSequences_stationId_fkey'],
['Connectors', 'Connectors_stationId_fkey'],
['Evses', 'Evses_stationId_fkey'],
['StatusNotifications', 'StatusNotifications_stationId_fkey'],
['LatestStatusNotifications', 'LatestStatusNotifications_stationId_fkey'],
['VariableAttributes', 'VariableAttributes_stationId_fkey'],
['SetNetworkProfiles', 'SetNetworkProfiles_stationId_fkey'],
['OCPPMessages', 'OCPPMessages_stationId_fkey'],
['InstalledCertificates', 'InstalledCertificates_stationId_fkey'],
['EventData', 'EventData_stationId_fkey'],
['VariableMonitorings', 'VariableMonitorings_stationId_fkey'],
['InstallCertificateAttempts', 'InstallCertificateAttempts_stationId_fkey'],
['DeleteCertificateAttempts', 'DeleteCertificateAttempts_stationId_fkey'],
['ChargingStationSecurityInfos', 'ChargingStationSecurityInfos_stationId_fkey'],
// ── stationPkId_fkey constraints (left from a previous partial run) ──
['Evses', 'Evses_stationPkId_fkey'],
['Connectors', 'Connectors_stationPkId_fkey'],
['Transactions', 'Transactions_stationPkId_fkey'],
['ChargingStationNetworkProfiles', 'ChargingStationNetworkProfiles_stationPkId_fkey'],
['ChargingStationSequences', 'ChargingStationSequences_stationPkId_fkey'],
['StatusNotifications', 'StatusNotifications_stationPkId_fkey'],
['LatestStatusNotifications', 'LatestStatusNotifications_stationPkId_fkey'],
['VariableAttributes', 'VariableAttributes_stationPkId_fkey'],
['SetNetworkProfiles', 'SetNetworkProfiles_stationPkId_fkey'],
['OCPPMessages', 'OCPPMessages_stationPkId_fkey'],
['InstalledCertificates', 'InstalledCertificates_stationPkId_fkey'],
['EventData', 'EventData_stationPkId_fkey'],
['VariableMonitorings', 'VariableMonitorings_stationPkId_fkey'],
['InstallCertificateAttempts', 'InstallCertificateAttempts_stationPkId_fkey'],
['DeleteCertificateAttempts', 'DeleteCertificateAttempts_stationPkId_fkey'],
['ChargingStationSecurityInfos', 'ChargingStationSecurityInfos_stationPkId_fkey'],
];
for (const [table, fkName] of allChildFks) {
await dropConstraintIfExists(table, fkName);
}
// ── Step 2: Add pkId to ChargingStations (safe for tables with data) ──────
//
// Adding a NOT NULL column without a DEFAULT to a non-empty table fails in
// PostgreSQL. Safe order:
// a) add as nullable
// b) create sequence & attach as default
// c) fill NULL pkIds (all rows on a clean run, leftover rows on re-run)
// d) advance sequence past the current max
// e) enforce NOT NULL
const csDesc = await describeTable('ChargingStations');
if (!csDesc['pkId']) {
await addCol('ChargingStations', 'pkId', {
type: DataTypes.INTEGER,
allowNull: true, // populated below before setting NOT NULL
});
}
await q(`CREATE SEQUENCE IF NOT EXISTS "ChargingStations_pkId_seq"`);
await q(
`ALTER TABLE "ChargingStations"
ALTER COLUMN "pkId" SET DEFAULT nextval('"ChargingStations_pkId_seq"')`,
);
// Assign a unique integer to every row that doesn't have one yet.
await q(
`UPDATE "ChargingStations"
SET "pkId" = nextval('"ChargingStations_pkId_seq"')
WHERE "pkId" IS NULL`,
);
// Advance the sequence past the current maximum so future inserts are safe.
await q(
`SELECT setval(
'"ChargingStations_pkId_seq"',
COALESCE((SELECT MAX("pkId") FROM "ChargingStations"), 1)
)`,
);
// Now every row has a value — enforce NOT NULL.
await q(`ALTER TABLE "ChargingStations" ALTER COLUMN "pkId" SET NOT NULL`);
// ── Step 3: Swap the primary key ─────────────────────────────────────────
await q(`ALTER TABLE "ChargingStations" DROP CONSTRAINT IF EXISTS "ChargingStations_pkey"`);
await q(`ALTER TABLE "ChargingStations" ADD PRIMARY KEY ("pkId")`);
await dropConstraintIfExists('ChargingStations', 'ChargingStations_id_tenantId_key');
await q(
`ALTER TABLE "ChargingStations"
ADD CONSTRAINT "ChargingStations_id_tenantId_key" UNIQUE (id, "tenantId")`,
);
// ── Step 4: Migrate each child table ─────────────────────────────────────
//
// For every child table:
// 1. Add stationPkId (nullable) if not present
// 2. Populate it by joining to ChargingStations
// 3. Validate — fail if any row with stationId couldn't be matched
// 4. Drop old stationId FK
// 5. Add new stationPkId FK
const migrateChildTable = async (table: string, onDeleteRule = 'SET NULL') => {
const desc = await describeTable(table);
if (!desc['stationPkId']) {
await addCol(table, 'stationPkId', { type: DataTypes.INTEGER, allowNull: true });
}
await q(
`UPDATE "${table}" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId"
AND cs."tenantId" = t."tenantId"`,
);
// Fail fast before we drop the old FK if any relationship can't be resolved.
await validateNoOrphans(table);
await dropFkByColumn(table, 'stationId');
const fkName = `${table}_stationPkId_fkey`;
await dropConstraintIfExists(table, fkName);
await q(
`ALTER TABLE "${table}"
ADD CONSTRAINT "${fkName}"
FOREIGN KEY ("stationPkId")
REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE ${onDeleteRule}`,
);
};
// Evses
await migrateChildTable('Evses', 'CASCADE');
await dropConstraintIfExists('Evses', 'stationId_evseTypeId');
await dropConstraintIfExists('Evses', 'stationPkId_evseTypeId');
await q(
`ALTER TABLE "Evses" ADD CONSTRAINT "stationPkId_evseTypeId" UNIQUE ("stationPkId", "evseTypeId")`,
);
// Connectors
await migrateChildTable('Connectors', 'CASCADE');
await dropConstraintIfExists('Connectors', 'stationId_connectorId');
await dropConstraintIfExists('Connectors', 'Connectors_stationId_connectorId_key');
await dropConstraintIfExists('Connectors', 'stationPkId_connectorId');
await q(
`ALTER TABLE "Connectors" ADD CONSTRAINT "stationPkId_connectorId" UNIQUE ("stationPkId", "connectorId")`,
);
// Transactions
await migrateChildTable('Transactions', 'SET NULL');
await dropConstraintIfExists('Transactions', 'stationId_transactionId');
await dropConstraintIfExists('Transactions', 'Transactions_stationId_transactionId_key');
await dropConstraintIfExists('Transactions', 'stationPkId_transactionId');
await q(
`ALTER TABLE "Transactions" ADD CONSTRAINT "stationPkId_transactionId" UNIQUE ("stationPkId", "transactionId")`,
);
// ChargingStationNetworkProfiles — inline because it also needs its own
// PK and extra unique constraints cleaned up.
{
const desc = await describeTable('ChargingStationNetworkProfiles');
if (!desc['stationPkId']) {
await addCol('ChargingStationNetworkProfiles', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "ChargingStationNetworkProfiles" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId"
AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('ChargingStationNetworkProfiles');
await dropFkByColumn('ChargingStationNetworkProfiles', 'stationId');
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfiles_pkey',
);
// The stationId variant is exactly 63 chars — no truncation.
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfile_stationId_websocketServerConf_key',
);
// The stationPkId variant is 65 chars; PostgreSQL truncates stored names
// to 63 chars, so we must drop both spellings to be safe.
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfile_stationPkId_websocketServerConf_key',
);
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfile_stationPkId_websocketServerConf_k',
);
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'stationId_configurationSlot',
);
await dropConstraintIfExists(
'ChargingStationNetworkProfiles',
'stationPkId_configurationSlot',
);
await q(
`ALTER TABLE "ChargingStationNetworkProfiles"
ADD CONSTRAINT "ChargingStationNetworkProfiles_stationPkId_fkey"
FOREIGN KEY ("stationPkId")
REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE CASCADE`,
);
await q(
`ALTER TABLE "ChargingStationNetworkProfiles"
ADD CONSTRAINT "stationPkId_configurationSlot"
UNIQUE ("stationPkId", "configurationSlot")`,
);
// Use a name that stays within PostgreSQL's 63-char identifier limit.
await q(
`ALTER TABLE "ChargingStationNetworkProfiles"
ADD CONSTRAINT "CSNP_stationPkId_websocketServerConfigId_key"
UNIQUE ("stationPkId", "websocketServerConfigId")`,
);
}
// ChargingStationSequences
await migrateChildTable('ChargingStationSequences', 'CASCADE');
await dropConstraintIfExists('ChargingStationSequences', 'stationId_type');
await dropConstraintIfExists(
'ChargingStationSequences',
'ChargingStationSequences_stationId_type_key',
);
await dropConstraintIfExists('ChargingStationSequences', 'stationPkId_type');
await q(
`ALTER TABLE "ChargingStationSequences" ADD CONSTRAINT "stationPkId_type" UNIQUE ("stationPkId", type)`,
);
await migrateChildTable('StatusNotifications', 'SET NULL');
await migrateChildTable('LatestStatusNotifications', 'SET NULL');
// VariableAttributes
await migrateChildTable('VariableAttributes', 'CASCADE');
await dropIndexIfExists('variable_attributes_station_id');
await dropConstraintIfExists('VariableAttributes', 'stationId_type_variableId_componentId');
await dropConstraintIfExists(
'VariableAttributes',
'VariableAttributes_stationId_type_variableId_componentId_key',
);
await dropConstraintIfExists('VariableAttributes', 'stationPkId_type_variableId_componentId');
await q(
`ALTER TABLE "VariableAttributes"
ADD CONSTRAINT "stationPkId_type_variableId_componentId"
UNIQUE ("stationPkId", type, "variableId", "componentId")`,
);
await q(
`CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId"
ON "VariableAttributes" ("stationPkId")
WHERE (type IS NULL AND "variableId" IS NULL AND "componentId" IS NULL)`,
);
// SetNetworkProfiles
{
const desc = await describeTable('SetNetworkProfiles');
if (!desc['stationPkId']) {
await addCol('SetNetworkProfiles', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "SetNetworkProfiles" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('SetNetworkProfiles');
await dropFkByColumn('SetNetworkProfiles', 'stationId');
await dropConstraintIfExists('SetNetworkProfiles', 'SetNetworkProfiles_stationPkId_fkey');
await q(
`ALTER TABLE "SetNetworkProfiles"
ADD CONSTRAINT "SetNetworkProfiles_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// OCPPMessages
{
const desc = await describeTable('OCPPMessages');
if (!desc['stationPkId']) {
await addCol('OCPPMessages', 'stationPkId', { type: DataTypes.INTEGER, allowNull: true });
}
await q(
`UPDATE "OCPPMessages" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('OCPPMessages');
await dropFkByColumn('OCPPMessages', 'stationId');
await dropConstraintIfExists('OCPPMessages', 'OCPPMessages_stationPkId_fkey');
await q(
`ALTER TABLE "OCPPMessages"
ADD CONSTRAINT "OCPPMessages_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// InstalledCertificates
{
const desc = await describeTable('InstalledCertificates');
if (!desc['stationPkId']) {
await addCol('InstalledCertificates', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "InstalledCertificates" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('InstalledCertificates');
await dropFkByColumn('InstalledCertificates', 'stationId');
await dropConstraintIfExists(
'InstalledCertificates',
'InstalledCertificates_stationPkId_fkey',
);
await q(
`ALTER TABLE "InstalledCertificates"
ADD CONSTRAINT "InstalledCertificates_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE CASCADE`,
);
}
// EventData
{
const desc = await describeTable('EventData');
if (!desc['stationPkId']) {
await addCol('EventData', 'stationPkId', { type: DataTypes.INTEGER, allowNull: true });
}
await q(
`UPDATE "EventData" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('EventData');
await dropFkByColumn('EventData', 'stationId');
await dropConstraintIfExists('EventData', 'EventData_stationPkId_fkey');
await q(
`ALTER TABLE "EventData"
ADD CONSTRAINT "EventData_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// VariableMonitorings
{
const desc = await describeTable('VariableMonitorings');
if (!desc['stationPkId']) {
await addCol('VariableMonitorings', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "VariableMonitorings" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('VariableMonitorings');
await dropFkByColumn('VariableMonitorings', 'stationId');
await dropConstraintIfExists('VariableMonitorings', 'VariableMonitorings_stationPkId_fkey');
await q(
`ALTER TABLE "VariableMonitorings"
ADD CONSTRAINT "VariableMonitorings_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// InstallCertificateAttempts
{
const desc = await describeTable('InstallCertificateAttempts');
if (!desc['stationPkId']) {
await addCol('InstallCertificateAttempts', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "InstallCertificateAttempts" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('InstallCertificateAttempts');
await dropFkByColumn('InstallCertificateAttempts', 'stationId');
await dropConstraintIfExists(
'InstallCertificateAttempts',
'InstallCertificateAttempts_stationPkId_fkey',
);
await q(
`ALTER TABLE "InstallCertificateAttempts"
ADD CONSTRAINT "InstallCertificateAttempts_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// DeleteCertificateAttempts
{
const desc = await describeTable('DeleteCertificateAttempts');
if (!desc['stationPkId']) {
await addCol('DeleteCertificateAttempts', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "DeleteCertificateAttempts" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('DeleteCertificateAttempts');
await dropFkByColumn('DeleteCertificateAttempts', 'stationId');
await dropConstraintIfExists(
'DeleteCertificateAttempts',
'DeleteCertificateAttempts_stationPkId_fkey',
);
await q(
`ALTER TABLE "DeleteCertificateAttempts"
ADD CONSTRAINT "DeleteCertificateAttempts_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// ChargingStationSecurityInfos
{
const desc = await describeTable('ChargingStationSecurityInfos');
if (!desc['stationPkId']) {
await addCol('ChargingStationSecurityInfos', 'stationPkId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
await q(
`UPDATE "ChargingStationSecurityInfos" t
SET "stationPkId" = cs."pkId"
FROM "ChargingStations" cs
WHERE cs.id = t."stationId" AND cs."tenantId" = t."tenantId"`,
);
await validateNoOrphans('ChargingStationSecurityInfos');
await dropFkByColumn('ChargingStationSecurityInfos', 'stationId');
await dropConstraintIfExists(
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationPkId_fkey',
);
await q(
`ALTER TABLE "ChargingStationSecurityInfos"
ADD CONSTRAINT "ChargingStationSecurityInfos_stationPkId_fkey"
FOREIGN KEY ("stationPkId") REFERENCES "ChargingStations"("pkId")
ON UPDATE CASCADE ON DELETE SET NULL`,
);
}
// ── Step 5: Widen unique constraints to include tenantId ──────────────────
const dropOldUnique = async (table: string, ...names: string[]) => {
for (const name of names) await dropConstraintIfExists(table, name);
};
// Constraint names in PostgreSQL are globally unique per schema (they are
// backed by indexes). Prefix every name with the table to avoid collisions.
await dropOldUnique(
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationId_key',
'stationId_tenantId',
'ChargingStationSecurityInfos_stationId_tenantId',
);
await q(
`ALTER TABLE "ChargingStationSecurityInfos" ADD CONSTRAINT "ChargingStationSecurityInfos_stationId_tenantId" UNIQUE ("stationId", "tenantId")`,
);
await dropOldUnique(
'Reservations',
'stationId_id',
'Reservations_id_stationId_key',
'stationId_tenantId_id',
'Reservations_stationId_tenantId_id',
);
await q(
`ALTER TABLE "Reservations" ADD CONSTRAINT "Reservations_stationId_tenantId_id" UNIQUE ("stationId", "tenantId", id)`,
);
await dropOldUnique(
'VariableMonitorings',
'stationId_Id',
'VariableMonitorings_stationId_id_key',
'stationId_tenantId_Id',
'VariableMonitorings_stationId_tenantId_id',
);
await q(
`ALTER TABLE "VariableMonitorings" ADD CONSTRAINT "VariableMonitorings_stationId_tenantId_id" UNIQUE ("stationId", "tenantId", id)`,
);
await dropOldUnique(
'MessageInfos',
'stationId_id',
'MessageInfos_stationId_id_key',
'stationId_tenantId_id',
'MessageInfos_stationId_tenantId_id',
);
await q(
`ALTER TABLE "MessageInfos" ADD CONSTRAINT "MessageInfos_stationId_tenantId_id" UNIQUE ("stationId", "tenantId", id)`,
);
await dropOldUnique(
'ChangeConfigurations',
'stationId_key',
'ChangeConfigurations_stationId_key_key',
'stationId_tenantId_key',
'ChangeConfigurations_stationId_tenantId_key',
);
await q(
`ALTER TABLE "ChangeConfigurations" ADD CONSTRAINT "ChangeConfigurations_stationId_tenantId_key" UNIQUE ("stationId", "tenantId", key)`,
);
await dropOldUnique(
'ChargingProfiles',
'stationId_id',
'ChargingProfiles_stationId_id_key',
'stationId_tenantId_id',
'ChargingProfiles_stationId_tenantId_id',
);
await q(
`ALTER TABLE "ChargingProfiles" ADD CONSTRAINT "ChargingProfiles_stationId_tenantId_id" UNIQUE ("stationId", "tenantId", id)`,
);
await dropOldUnique(
'ChargingSchedules',
'stationId_id',
'ChargingSchedules_stationId_id_key',
'stationId_tenantId_id',
'ChargingSchedules_stationId_tenantId_id',
);
await q(
`ALTER TABLE "ChargingSchedules" ADD CONSTRAINT "ChargingSchedules_stationId_tenantId_id" UNIQUE ("stationId", "tenantId", id)`,
);
await dropOldUnique(
'LocalListVersions',
'LocalListVersions_stationId_key',
'stationId_tenantId',
'LocalListVersions_stationId_tenantId',
);
await q(
`ALTER TABLE "LocalListVersions" ADD CONSTRAINT "LocalListVersions_stationId_tenantId" UNIQUE ("stationId", "tenantId")`,
);
await dropOldUnique(
'EventData',
'stationId_eventId',
'EventData_stationId_eventId_key',
'stationId_tenantId_eventId',
'EventData_stationId_tenantId_eventId',
);
await q(
`ALTER TABLE "EventData" ADD CONSTRAINT "EventData_stationId_tenantId_eventId" UNIQUE ("stationId", "tenantId", "eventId")`,
);
// ── Step 6: Create DB triggers to auto-populate stationPkId ─────────────
//
// Now that every child table has its stationPkId column, we create a
// BEFORE INSERT OR UPDATE trigger on each one. The trigger looks up the
// ChargingStation.pkId from (stationId, tenantId) whenever stationPkId is
// not already supplied, giving a DB-level safety net on top of the ORM
// @BeforeCreate hooks defined on each model.
//
// Note: this used to live in a separate migration file
// (20260330000000-charging-station-pk-id-triggers.ts) that ran *before*
// the columns were added, so all trigger creations were silently skipped.
// The trigger setup is now consolidated here, at the end of `up`, where
// all stationPkId columns are guaranteed to exist.
const allTablesWithStationPkId = [
'Evses',
'Connectors',
'Transactions',
'ChargingStationNetworkProfiles',
'ChargingStationSequences',
'StatusNotifications',
'LatestStatusNotifications',
'VariableAttributes',
'SetNetworkProfiles',
'OCPPMessages',
'InstalledCertificates',
'ChargingStationSecurityInfos',
'VariableMonitorings',
'EventData',
'InstallCertificateAttempts',
'DeleteCertificateAttempts',
];
await q(`
CREATE OR REPLACE FUNCTION populate_station_pk_id()
RETURNS TRIGGER AS $$
BEGIN
SELECT "pkId" INTO NEW."stationPkId"
FROM "ChargingStations"
WHERE "id" = NEW."stationId" AND "tenantId" = NEW."tenantId";
IF NEW."stationPkId" IS NULL THEN
RAISE EXCEPTION 'No ChargingStation found with stationId=% and tenantId=%',
NEW."stationId", NEW."tenantId";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
for (const tableName of allTablesWithStationPkId) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_pk_id`;
await q(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`);
await q(`
CREATE TRIGGER "${triggerName}"
BEFORE INSERT OR UPDATE ON "${tableName}"
FOR EACH ROW
WHEN (NEW."stationPkId" IS NULL)
EXECUTE FUNCTION populate_station_pk_id()
`);
}
console.log('Migration 20260330100000-add-charging-station-pk-id completed successfully.');
}); // ── end transaction ──────────────────────────────────────────────────────
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.transaction(async (transaction) => {
const q = (sql: string) =>
queryInterface.sequelize.query(sql, { transaction, type: QueryTypes.RAW });
// ── Drop triggers and function first ──────────────────────────────────────
// Must happen before we remove the stationPkId columns they reference.
const allTablesWithStationPkId = [
'Evses',
'Connectors',
'Transactions',
'ChargingStationNetworkProfiles',
'ChargingStationSequences',
'StatusNotifications',
'LatestStatusNotifications',
'VariableAttributes',
'SetNetworkProfiles',
'OCPPMessages',
'InstalledCertificates',
'ChargingStationSecurityInfos',
'VariableMonitorings',
'EventData',
'InstallCertificateAttempts',
'DeleteCertificateAttempts',
];
for (const tableName of allTablesWithStationPkId) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_pk_id`;
await q(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`);
}
await q(`DROP FUNCTION IF EXISTS populate_station_pk_id()`);
// ─────────────────────────────────────────────────────────────────────────
const qSelect = <T = any>(sql: string, replacements?: Record<string, any>): Promise<T[]> =>
queryInterface.sequelize.query(sql, {
transaction,
replacements,
type: QueryTypes.SELECT,
}) as Promise<T[]>;
const dropFkIfExists = (table: string, name: string) =>
q(`ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${name}"`);
/** Check which columns exist — uses the transaction connection. */
const describeTable = async (table: string): Promise<Record<string, any>> => {
const rows = await qSelect<{ Field: string }>(
`SELECT column_name AS "Field"
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = :table`,
{ table },
);
const desc: Record<string, any> = {};
for (const row of rows) {
desc[(row as any).Field] = true;
}
return desc;
};
const childTables: [string, string][] = [
['Evses', 'Evses_stationPkId_fkey'],
['Connectors', 'Connectors_stationPkId_fkey'],
['Transactions', 'Transactions_stationPkId_fkey'],
['ChargingStationNetworkProfiles', 'ChargingStationNetworkProfiles_stationPkId_fkey'],
['ChargingStationSequences', 'ChargingStationSequences_stationPkId_fkey'],
['StatusNotifications', 'StatusNotifications_stationPkId_fkey'],
['LatestStatusNotifications', 'LatestStatusNotifications_stationPkId_fkey'],
['VariableAttributes', 'VariableAttributes_stationPkId_fkey'],
['SetNetworkProfiles', 'SetNetworkProfiles_stationPkId_fkey'],
['OCPPMessages', 'OCPPMessages_stationPkId_fkey'],
['InstalledCertificates', 'InstalledCertificates_stationPkId_fkey'],
['EventData', 'EventData_stationPkId_fkey'],
['VariableMonitorings', 'VariableMonitorings_stationPkId_fkey'],
['InstallCertificateAttempts', 'InstallCertificateAttempts_stationPkId_fkey'],
['DeleteCertificateAttempts', 'DeleteCertificateAttempts_stationPkId_fkey'],
['ChargingStationSecurityInfos', 'ChargingStationSecurityInfos_stationPkId_fkey'],
];
// Drop unique constraints / indexes that include stationPkId BEFORE
// removing the column — PostgreSQL's DROP COLUMN (without CASCADE) will
// fail if the column is still referenced by a multi-column constraint.
await q(
`ALTER TABLE "Evses" DROP CONSTRAINT IF EXISTS "stationPkId_evseTypeId"`,
);
await q(
`ALTER TABLE "Connectors" DROP CONSTRAINT IF EXISTS "stationPkId_connectorId"`,
);
await q(
`ALTER TABLE "Transactions" DROP CONSTRAINT IF EXISTS "stationPkId_transactionId"`,
);
await q(
`ALTER TABLE "ChargingStationSequences" DROP CONSTRAINT IF EXISTS "stationPkId_type"`,
);
await q(
`ALTER TABLE "ChargingStationNetworkProfiles" DROP CONSTRAINT IF EXISTS "stationPkId_configurationSlot"`,
);
await q(
`ALTER TABLE "ChargingStationNetworkProfiles" DROP CONSTRAINT IF EXISTS "CSNP_stationPkId_websocketServerConfigId_key"`,
);
// Also drop the truncated 63-char variant in case a previous partial run stored it.
await q(
`ALTER TABLE "ChargingStationNetworkProfiles" DROP CONSTRAINT IF EXISTS "ChargingStationNetworkProfile_stationPkId_websocketServerConf_k"`,
);
await q(
`ALTER TABLE "VariableAttributes" DROP CONSTRAINT IF EXISTS "stationPkId_type_variableId_componentId"`,
);
await q(`DROP INDEX IF EXISTS "variable_attributes_stationPkId"`);
// Now drop FK constraints and remove the stationPkId column from each child table.
for (const [table, fkName] of childTables) {
await dropFkIfExists(table, fkName);
const desc = await describeTable(table);
if (desc['stationPkId']) {
await queryInterface.removeColumn(table, 'stationPkId', { transaction } as any);
}
}
// Restore ChargingStations primary key to id
await q(
`ALTER TABLE "ChargingStations" DROP CONSTRAINT IF EXISTS "ChargingStations_id_tenantId_key"`,
);
await q(`ALTER TABLE "ChargingStations" DROP CONSTRAINT IF EXISTS "ChargingStations_pkey"`);
await q(`ALTER TABLE "ChargingStations" ADD PRIMARY KEY (id)`);
const csDesc = await describeTable('ChargingStations');
if (csDesc['pkId']) {
await queryInterface.removeColumn('ChargingStations', 'pkId', { transaction } as any);
}
await q(`DROP SEQUENCE IF EXISTS "ChargingStations_pkId_seq"`);
// Restore narrow unique constraints
const restoreNarrowUnique = async (
table: string,
wideName: string,
narrowName: string,
columns: string[],
) => {
await q(`ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${wideName}"`);
const cols = columns.map((c) => `"${c}"`).join(', ');
await q(`ALTER TABLE "${table}" ADD CONSTRAINT "${narrowName}" UNIQUE (${cols})`);
};
await restoreNarrowUnique(
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationId_tenantId',
'ChargingStationSecurityInfos_stationId_key',
['stationId'],
);
await restoreNarrowUnique(
'Reservations',
'Reservations_stationId_tenantId_id',
'Reservations_stationId_id',
['stationId', 'id'],
);
await restoreNarrowUnique(
'VariableMonitorings',
'VariableMonitorings_stationId_tenantId_id',
'VariableMonitorings_stationId_id',
['stationId', 'id'],
);
await restoreNarrowUnique(
'MessageInfos',
'MessageInfos_stationId_tenantId_id',
'MessageInfos_stationId_id',
['stationId', 'id'],
);
await restoreNarrowUnique(
'ChangeConfigurations',
'ChangeConfigurations_stationId_tenantId_key',
'ChangeConfigurations_stationId_key',
['stationId', 'key'],
);
await restoreNarrowUnique(
'ChargingProfiles',
'ChargingProfiles_stationId_tenantId_id',
'ChargingProfiles_stationId_id',
['stationId', 'id'],
);
await restoreNarrowUnique(
'ChargingSchedules',
'ChargingSchedules_stationId_tenantId_id',
'ChargingSchedules_stationId_id',
['stationId', 'id'],
);
await restoreNarrowUnique(
'LocalListVersions',
'LocalListVersions_stationId_tenantId',
'LocalListVersions_stationId_key',
['stationId'],
);
await restoreNarrowUnique(
'EventData',
'EventData_stationId_tenantId_eventId',
'EventData_stationId_eventId',
['stationId', 'eventId'],
);
}); // ── end transaction ──────────────────────────────────────────────────────
},
};

View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
// Drop the existing index that does not include tenantId, which prevents
// different tenants from using the same idToken+idTokenType combination.
console.log('Dropping index idToken_type (missing tenantId)...');
try {
await queryInterface.removeIndex('Authorizations', 'idToken_type');
console.log('Successfully dropped index: idToken_type');
} catch (error: any) {
if (error.message?.includes('does not exist')) {
console.log('Index idToken_type does not exist, skipping removal');
} else {
throw error;
}
}
console.log('Checking for duplicate tenantId/idToken/idTokenType combinations...');
const [duplicates] = await queryInterface.sequelize.query(`
SELECT "tenantId", "idToken", "idTokenType", COUNT(*) as count
FROM "Authorizations"
WHERE "idToken" IS NOT NULL
GROUP BY "tenantId", "idToken", "idTokenType"
HAVING COUNT(*) > 1
ORDER BY "tenantId", "idToken", "idTokenType"
`);
if (duplicates.length > 0) {
console.error('Cannot create unique index due to duplicate data:');
duplicates.forEach((dup: any) => {
console.error(
` - tenantId: "${dup.tenantId}", idToken: "${dup.idToken}", idTokenType: "${dup.idTokenType}", count: ${dup.count}`,
);
});
throw new Error(
`Migration failed: Found ${duplicates.length} duplicate tenantId/idToken/idTokenType combinations. ` +
'Please resolve these duplicates before running this migration. ' +
'You may need to update or remove duplicate records in the Authorizations table.',
);
}
console.log('No duplicates found. Proceeding with index creation...');
await queryInterface.addIndex('Authorizations', ['tenantId', 'idToken', 'idTokenType'], {
unique: true,
name: 'idToken_type',
});
console.log('Successfully created unique index: idToken_type');
},
down: async (queryInterface: QueryInterface) => {
console.log('Reverting to index without tenantId...');
try {
await queryInterface.removeIndex('Authorizations', 'idToken_type');
console.log('Successfully dropped index: idToken_type');
} catch (error: any) {
if (error.message?.includes('does not exist')) {
console.log('Index idToken_type does not exist, skipping removal');
} else {
throw error;
}
}
await queryInterface.addIndex('Authorizations', ['idToken', 'idTokenType'], {
unique: true,
name: 'idToken_type',
});
console.log('Successfully recreated original index: idToken_type');
},
};

View File

@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
// The initial migration created 7 partial unique indexes on VariableAttributes using
// stationId without tenantId, preventing different tenants from using the same
// variable attribute combinations. The 20260330000000-add-charging-station-pk-id
// migration replaced one of them (the all-null case) and the unconditional constraint
// with stationPkId-based equivalents, but left the remaining 6 partial indexes
// using stationId. This migration drops those and recreates them using stationPkId,
// which is a FK to ChargingStations(pkId) — globally unique per station-tenant pair —
// making the constraints effectively per-tenant.
export default {
up: async (queryInterface: QueryInterface) => {
const dropIndex = async (name: string) => {
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "${name}"`);
};
const oldIndexes = [
'variable_attributes_station_id_type',
'variable_attributes_station_id_variable_id',
'variable_attributes_station_id_component_id',
'variable_attributes_station_id_type_variable_id',
'variable_attributes_station_id_type_component_id',
'variable_attributes_station_id_variable_id_component_id',
];
for (const name of oldIndexes) {
console.log(`Dropping index ${name}...`);
await dropIndex(name);
}
console.log('Creating stationPkId-based partial unique indexes...');
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_type"
ON "VariableAttributes" ("stationPkId", "type")
WHERE "variableId" IS NULL AND "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_variableId"
ON "VariableAttributes" ("stationPkId", "variableId")
WHERE "type" IS NULL AND "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_componentId"
ON "VariableAttributes" ("stationPkId", "componentId")
WHERE "type" IS NULL AND "variableId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_type_variableId"
ON "VariableAttributes" ("stationPkId", "type", "variableId")
WHERE "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_type_componentId"
ON "VariableAttributes" ("stationPkId", "type", "componentId")
WHERE "variableId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_stationPkId_variableId_componentId"
ON "VariableAttributes" ("stationPkId", "variableId", "componentId")
WHERE "type" IS NULL
`);
console.log('Successfully recreated VariableAttributes partial indexes with stationPkId.');
},
down: async (queryInterface: QueryInterface) => {
const dropIndex = async (name: string) => {
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "${name}"`);
};
const newIndexes = [
'variable_attributes_stationPkId_type',
'variable_attributes_stationPkId_variableId',
'variable_attributes_stationPkId_componentId',
'variable_attributes_stationPkId_type_variableId',
'variable_attributes_stationPkId_type_componentId',
'variable_attributes_stationPkId_variableId_componentId',
];
for (const name of newIndexes) {
console.log(`Dropping index ${name}...`);
await dropIndex(name);
}
console.log('Recreating original stationId-based partial unique indexes...');
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_type"
ON "VariableAttributes" ("stationId", "type")
WHERE "variableId" IS NULL AND "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_variable_id"
ON "VariableAttributes" ("stationId", "variableId")
WHERE "type" IS NULL AND "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_component_id"
ON "VariableAttributes" ("stationId", "componentId")
WHERE "type" IS NULL AND "variableId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_type_variable_id"
ON "VariableAttributes" ("stationId", "type", "variableId")
WHERE "componentId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_type_component_id"
ON "VariableAttributes" ("stationId", "type", "componentId")
WHERE "variableId" IS NULL
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variable_attributes_station_id_variable_id_component_id"
ON "VariableAttributes" ("stationId", "variableId", "componentId")
WHERE "type" IS NULL
`);
console.log('Successfully restored original stationId-based partial indexes.');
},
};

View File

@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
// The initial migration created a globally unique constraint on correlationId in
// SetNetworkProfiles, preventing different tenants from reusing the same correlation
// ID value. stationPkId already encodes tenant (it is a FK to ChargingStations(pkId),
// which is globally unique per station-tenant pair), so a composite unique on
// (stationPkId, correlationId) is sufficient to enforce per-station uniqueness.
export default {
up: async (queryInterface: QueryInterface) => {
console.log('Dropping global unique constraint on SetNetworkProfiles.correlationId...');
// Drop both the inline constraint name and the explicitly-named index to cover
// whichever form exists in this environment.
await queryInterface.sequelize.query(
`ALTER TABLE "SetNetworkProfiles" DROP CONSTRAINT IF EXISTS "SetNetworkProfiles_correlationId_key"`,
);
await queryInterface.sequelize.query(
`DROP INDEX IF EXISTS "set_network_profiles_correlation_id"`,
);
console.log('Creating per-station unique index on (stationPkId, correlationId)...');
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "set_network_profiles_stationPkId_correlationId"
ON "SetNetworkProfiles" ("stationPkId", "correlationId")
`);
console.log('Successfully updated SetNetworkProfiles correlationId uniqueness constraint.');
},
down: async (queryInterface: QueryInterface) => {
console.log('Dropping per-station correlationId index from SetNetworkProfiles...');
await queryInterface.sequelize.query(
`DROP INDEX IF EXISTS "set_network_profiles_stationPkId_correlationId"`,
);
console.log('Restoring global unique constraint on correlationId...');
await queryInterface.sequelize.query(
`ALTER TABLE "SetNetworkProfiles" ADD CONSTRAINT "SetNetworkProfiles_correlationId_key" UNIQUE ("correlationId")`,
);
console.log('Successfully restored original SetNetworkProfiles correlationId constraint.');
},
};

View File

@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
console.log('Updating Certificates unique constraints to include tenantId...');
await queryInterface.sequelize.query(
`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "serialNumber_issuerName"`,
);
await queryInterface.sequelize.query(`
ALTER TABLE "Certificates"
ADD CONSTRAINT "tenantId_serialNumber_issuerName"
UNIQUE ("tenantId", "serialNumber", "issuerName")
`);
// The certificateFileHash constraint was created by addColumn with unique: true,
// which auto-names the constraint as Certificates_certificateFileHash_key.
await queryInterface.sequelize.query(
`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "Certificates_certificateFileHash_key"`,
);
await queryInterface.sequelize.query(`
ALTER TABLE "Certificates"
ADD CONSTRAINT "tenantId_certificateFileHash"
UNIQUE ("tenantId", "certificateFileHash")
`);
console.log('Successfully updated Certificates unique constraints.');
},
down: async (queryInterface: QueryInterface) => {
console.log('Reverting Certificates unique constraints...');
await queryInterface.sequelize.query(
`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "tenantId_serialNumber_issuerName"`,
);
await queryInterface.sequelize.query(`
ALTER TABLE "Certificates"
ADD CONSTRAINT "serialNumber_issuerName"
UNIQUE ("serialNumber", "issuerName")
`);
await queryInterface.sequelize.query(
`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "tenantId_certificateFileHash"`,
);
await queryInterface.sequelize.query(`
ALTER TABLE "Certificates"
ADD CONSTRAINT "Certificates_certificateFileHash_key"
UNIQUE ("certificateFileHash")
`);
console.log('Successfully reverted Certificates unique constraints.');
},
};

View File

@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
// EvseTypes: the schema-fix migration created this table without any unique
// constraints. The model defines (id, connectorId) uniqueness, but it was never
// applied via migration and lacks tenantId.
//
// Components / Variables: the initial migration created inline UNIQUE (name, instance)
// constraints and partial indexes on (name) WHERE instance IS NULL, both without
// tenantId, preventing different tenants from sharing the same component or variable names.
//
// This migration adds tenantId to all three tables' unique constraints.
export default {
up: async (queryInterface: QueryInterface) => {
// ── EvseTypes ──────────────────────────────────────────────────────────────
console.log('Adding tenantId-inclusive unique constraints to EvseTypes...');
await queryInterface.sequelize.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'evse_types_tenantId_id_connectorId' AND conrelid = '"EvseTypes"'::regclass) THEN
ALTER TABLE "EvseTypes" ADD CONSTRAINT "evse_types_tenantId_id_connectorId" UNIQUE ("tenantId", "id", "connectorId");
END IF;
END $$
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "evse_types_tenantId_id"
ON "EvseTypes" ("tenantId", "id")
WHERE "connectorId" IS NULL
`);
// ── Components ─────────────────────────────────────────────────────────────
console.log('Replacing Components unique constraints with tenantId-inclusive versions...');
// The inline UNIQUE (name, instance) is auto-named by PostgreSQL.
await queryInterface.sequelize.query(
`ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "Components_name_instance_key"`,
);
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "components_name"`);
// Drop the misnamed constraint left behind by a failed prior run.
await queryInterface.sequelize.query(
`ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "tenantId_name_instance"`,
);
await queryInterface.sequelize.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_tenantId_name_instance' AND conrelid = '"Components"'::regclass) THEN
ALTER TABLE "Components" ADD CONSTRAINT "components_tenantId_name_instance" UNIQUE ("tenantId", "name", "instance");
END IF;
END $$
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "components_tenantId_name"
ON "Components" ("tenantId", "name")
WHERE "instance" IS NULL
`);
// ── Variables ──────────────────────────────────────────────────────────────
console.log('Replacing Variables unique constraints with tenantId-inclusive versions...');
await queryInterface.sequelize.query(
`ALTER TABLE "Variables" DROP CONSTRAINT IF EXISTS "Variables_name_instance_key"`,
);
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "variables_name"`);
await queryInterface.sequelize.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'variables_tenantId_name_instance' AND conrelid = '"Variables"'::regclass) THEN
ALTER TABLE "Variables" ADD CONSTRAINT "variables_tenantId_name_instance" UNIQUE ("tenantId", "name", "instance");
END IF;
END $$
`);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variables_tenantId_name"
ON "Variables" ("tenantId", "name")
WHERE "instance" IS NULL
`);
console.log('Successfully updated EvseTypes, Components, and Variables unique constraints.');
},
down: async (queryInterface: QueryInterface) => {
// ── EvseTypes ──────────────────────────────────────────────────────────────
await queryInterface.sequelize.query(
`ALTER TABLE "EvseTypes" DROP CONSTRAINT IF EXISTS "evse_types_tenantId_id_connectorId"`,
);
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "evse_types_tenantId_id"`);
// ── Components ─────────────────────────────────────────────────────────────
await queryInterface.sequelize.query(
`ALTER TABLE "Components" DROP CONSTRAINT IF EXISTS "components_tenantId_name_instance"`,
);
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "components_tenantId_name"`);
await queryInterface.sequelize.query(
`ALTER TABLE "Components" ADD CONSTRAINT "Components_name_instance_key" UNIQUE ("name", "instance")`,
);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "components_name"
ON "Components" ("name")
WHERE "instance" IS NULL
`);
// ── Variables ──────────────────────────────────────────────────────────────
await queryInterface.sequelize.query(
`ALTER TABLE "Variables" DROP CONSTRAINT IF EXISTS "variables_tenantId_name_instance"`,
);
await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "variables_tenantId_name"`);
await queryInterface.sequelize.query(
`ALTER TABLE "Variables" ADD CONSTRAINT "Variables_name_instance_key" UNIQUE ("name", "instance")`,
);
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "variables_name"
ON "Variables" ("name")
WHERE "instance" IS NULL
`);
console.log('Successfully reverted EvseTypes, Components, and Variables unique constraints.');
},
};

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Tariffs', 'tariffId', {
type: DataTypes.STRING,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'validFrom', {
type: DataTypes.DATE,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'description', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'energy', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'chargingTime', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'idleTime', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'fixedFee', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'reservationTime', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'reservationFixed', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'minCost', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.addColumn('Tariffs', 'maxCost', {
type: DataTypes.JSONB,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Tariffs', 'tariffId');
await queryInterface.removeColumn('Tariffs', 'validFrom');
await queryInterface.removeColumn('Tariffs', 'description');
await queryInterface.removeColumn('Tariffs', 'energy');
await queryInterface.removeColumn('Tariffs', 'chargingTime');
await queryInterface.removeColumn('Tariffs', 'idleTime');
await queryInterface.removeColumn('Tariffs', 'fixedFee');
await queryInterface.removeColumn('Tariffs', 'reservationTime');
await queryInterface.removeColumn('Tariffs', 'reservationFixed');
await queryInterface.removeColumn('Tariffs', 'minCost');
await queryInterface.removeColumn('Tariffs', 'maxCost');
},
};

View File

@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Authorizations', 'tariffId', {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'Tariffs',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Authorizations', 'tariffId');
},
};

View File

@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addConstraint('Tariffs', {
fields: ['tariffId', 'tenantId'],
type: 'unique',
name: 'tariffId_tenantId',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeConstraint('Tariffs', 'tariffId_tenantId');
},
};

View File

@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Authorizations', 'isPrepaid', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
});
await queryInterface.addColumn('Authorizations', 'prepaidBalance', {
type: DataTypes.DECIMAL,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Authorizations', 'isPrepaid');
await queryInterface.removeColumn('Authorizations', 'prepaidBalance');
},
};

View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { QueryInterface, QueryTypes } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
console.log('Fixing Certificates.signedBy column type from VARCHAR to INTEGER...');
await queryInterface.sequelize.transaction(async (transaction) => {
const q = (sql: string) =>
queryInterface.sequelize.query(sql, { transaction, type: QueryTypes.RAW });
// Drop the self-referencing FK before altering the column type.
// PostgreSQL cannot change the type of a column that is part of a FK constraint.
await q(`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "Certificates_signedBy_fkey"`);
// Cast the existing values to INTEGER. Any existing rows with a non-numeric
// signedBy value will raise an error here, which is the correct behaviour
// (it would indicate pre-existing data corruption).
await q(`
ALTER TABLE "Certificates"
ALTER COLUMN "signedBy" TYPE INTEGER
USING "signedBy"::INTEGER
`);
// Re-add the self-referencing FK now that both sides are INTEGER.
await q(`
ALTER TABLE "Certificates"
ADD CONSTRAINT "Certificates_signedBy_fkey"
FOREIGN KEY ("signedBy")
REFERENCES "Certificates" ("id")
ON UPDATE CASCADE
ON DELETE NO ACTION
`);
});
console.log('Successfully fixed Certificates.signedBy column type.');
},
down: async (queryInterface: QueryInterface) => {
console.log('Reverting Certificates.signedBy column type from INTEGER to VARCHAR...');
await queryInterface.sequelize.transaction(async (transaction) => {
const q = (sql: string) =>
queryInterface.sequelize.query(sql, { transaction, type: QueryTypes.RAW });
await q(`ALTER TABLE "Certificates" DROP CONSTRAINT IF EXISTS "Certificates_signedBy_fkey"`);
await q(`
ALTER TABLE "Certificates"
ALTER COLUMN "signedBy" TYPE VARCHAR
USING "signedBy"::VARCHAR
`);
// Note: the original broken schema had a VARCHAR FK referencing an INTEGER id,
// which is why this constraint could never be created. The down migration
// reverts the column type only; the broken FK is intentionally not restored.
});
console.log('Successfully reverted Certificates.signedBy column type.');
},
};

View File

@@ -0,0 +1,593 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
import { QueryInterface, QueryTypes } from 'sequelize';
const primaryKeyTables = [
'Evses',
'Connectors',
'Transactions',
'ChargingStationNetworkProfiles',
'ChargingStationSequences',
'StatusNotifications',
'LatestStatusNotifications',
'VariableAttributes',
'SetNetworkProfiles',
'OCPPMessages',
'InstalledCertificates',
'ChargingStationSecurityInfos',
'VariableMonitorings',
'EventData',
'InstallCertificateAttempts',
'DeleteCertificateAttempts',
];
const ocppConnectionTables = [
'ChangeConfigurations',
'ChargingProfiles',
'ChargingSchedules',
'CompositeSchedules',
'LocalListVersions',
'SendLocalLists',
'MessageInfos',
'Reservations',
'SecurityEvents',
'Subscriptions',
'StartTransactions',
'StopTransactions',
'TransactionEvents',
];
/** @type {import('sequelize-cli').Migration} */
export default {
up: async (queryInterface: QueryInterface) => {
const renameConstraint = async (table: string, oldName: string, newName: string) => {
const rows = await queryInterface.sequelize.query(
`SELECT COUNT(*) AS count FROM information_schema.table_constraints
WHERE table_schema = 'public' AND table_name = :table AND constraint_name = :name`,
{ replacements: { table, name: oldName }, type: QueryTypes.SELECT },
);
if ((rows as any)[0].count > 0) {
await queryInterface.sequelize.query(
`ALTER TABLE "${table}" RENAME CONSTRAINT "${oldName}" TO "${newName}"`,
{ type: QueryTypes.RAW },
);
}
};
const renameIndex = async (oldName: string, newName: string) => {
const rows = await queryInterface.sequelize.query(
`SELECT COUNT(*) AS count FROM pg_indexes
WHERE schemaname = 'public' AND indexname = :name`,
{ replacements: { name: oldName }, type: QueryTypes.SELECT },
);
if ((rows as any)[0].count > 0) {
await queryInterface.sequelize.query(`ALTER INDEX "${oldName}" RENAME TO "${newName}"`, {
type: QueryTypes.RAW,
});
}
};
const renameColumn = async (table: string, oldName: string, newName: string) => {
await queryInterface.sequelize.query(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${newName}') THEN
ALTER TABLE "${table}" RENAME COLUMN "${oldName}" TO "${newName}";
END IF;
END $$;`,
{ type: QueryTypes.RAW },
);
};
// Drop all triggers that reference old column names
for (const tableName of primaryKeyTables) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_pk_id`;
await queryInterface.sequelize.query(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`,
{ type: QueryTypes.RAW },
);
}
await queryInterface.sequelize.query(`DROP FUNCTION IF EXISTS populate_station_pk_id()`);
// Rename columns
await renameColumn('ChargingStations', 'id', 'ocppConnectionName');
await renameColumn('ChargingStations', 'pkId', 'id');
await queryInterface.sequelize.query(
`ALTER SEQUENCE IF EXISTS "ChargingStations_pkId_seq" RENAME TO "ChargingStations_id_seq"`,
{ type: QueryTypes.RAW },
);
await queryInterface.sequelize.query(
`ALTER TABLE "ChargingStations" ALTER COLUMN "id" SET DEFAULT nextval('"ChargingStations_id_seq"')`,
{ type: QueryTypes.RAW },
);
// Child tables: "stationId" (string) → "ocppConnectionName", then "stationPkId" (int FK and PK for ChargingStation) → "stationId"
for (const tableName of primaryKeyTables) {
await renameColumn(tableName, 'stationId', 'ocppConnectionName');
await renameColumn(tableName, 'stationPkId', 'stationId');
}
// Tables with only the string station identifier (no int FK): "stationId" → "ocppConnectionName"
for (const tableName of ocppConnectionTables) {
await renameColumn(tableName, 'stationId', 'ocppConnectionName');
}
// Rename constraints and indexes
await renameConstraint(
'ChargingStations',
'ChargingStations_id_tenantId_key',
'ChargingStations_stationName_tenantId_key',
);
const fkRenames: [string, string, string][] = [
['Evses', 'Evses_stationPkId_fkey', 'Evses_stationId_fkey'],
['Connectors', 'Connectors_stationPkId_fkey', 'Connectors_stationId_fkey'],
['Transactions', 'Transactions_stationPkId_fkey', 'Transactions_stationId_fkey'],
[
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfiles_stationPkId_fkey',
'ChargingStationNetworkProfiles_stationId_fkey',
],
[
'ChargingStationSequences',
'ChargingStationSequences_stationPkId_fkey',
'ChargingStationSequences_stationId_fkey',
],
[
'StatusNotifications',
'StatusNotifications_stationPkId_fkey',
'StatusNotifications_stationId_fkey',
],
[
'LatestStatusNotifications',
'LatestStatusNotifications_stationPkId_fkey',
'LatestStatusNotifications_stationId_fkey',
],
[
'VariableAttributes',
'VariableAttributes_stationPkId_fkey',
'VariableAttributes_stationId_fkey',
],
[
'SetNetworkProfiles',
'SetNetworkProfiles_stationPkId_fkey',
'SetNetworkProfiles_stationId_fkey',
],
['OCPPMessages', 'OCPPMessages_stationPkId_fkey', 'OCPPMessages_stationId_fkey'],
[
'InstalledCertificates',
'InstalledCertificates_stationPkId_fkey',
'InstalledCertificates_stationId_fkey',
],
['EventData', 'EventData_stationPkId_fkey', 'EventData_stationId_fkey'],
[
'VariableMonitorings',
'VariableMonitorings_stationPkId_fkey',
'VariableMonitorings_stationId_fkey',
],
[
'InstallCertificateAttempts',
'InstallCertificateAttempts_stationPkId_fkey',
'InstallCertificateAttempts_stationId_fkey',
],
[
'DeleteCertificateAttempts',
'DeleteCertificateAttempts_stationPkId_fkey',
'DeleteCertificateAttempts_stationId_fkey',
],
[
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationPkId_fkey',
'ChargingStationSecurityInfos_stationId_fkey',
],
];
for (const [table, oldName, newName] of fkRenames) {
await renameConstraint(table, oldName, newName);
}
// Unique constraints where stationPkId was the int FK column
await renameConstraint('Evses', 'stationPkId_evseTypeId', 'stationId_evseTypeId');
await renameConstraint('Connectors', 'stationPkId_connectorId', 'stationId_connectorId');
await renameConstraint('Transactions', 'stationPkId_transactionId', 'stationId_transactionId');
await renameConstraint(
'ChargingStationNetworkProfiles',
'stationPkId_configurationSlot',
'stationId_configurationSlot',
);
await renameConstraint(
'ChargingStationNetworkProfiles',
'CSNP_stationPkId_websocketServerConfigId_key',
'CSNP_stationId_websocketServerConfigId_key',
);
await renameConstraint('ChargingStationSequences', 'stationPkId_type', 'stationId_type');
await renameConstraint(
'VariableAttributes',
'stationPkId_type_variableId_componentId',
'stationId_type_variableId_componentId',
);
await renameConstraint(
'SetNetworkProfiles',
'stationPkId_correlationId',
'stationId_correlationId',
);
await renameConstraint(
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationId_tenantId',
'ChargingStationSecurityInfos_stationName_tenantId',
);
await renameConstraint(
'EventData',
'EventData_stationId_tenantId_eventId',
'EventData_stationName_tenantId_eventId',
);
await renameConstraint(
'EventData',
'EventData_stationId_eventId',
'EventData_stationName_eventId',
);
await renameConstraint(
'VariableMonitorings',
'VariableMonitorings_stationId_tenantId_id',
'VariableMonitorings_stationName_tenantId_id',
);
await renameIndex('variable_attributes_stationPkId', 'variable_attributes_stationId');
await renameIndex('variable_attributes_stationPkId_type', 'variable_attributes_stationId_type');
await renameIndex(
'variable_attributes_stationPkId_variableId',
'variable_attributes_stationId_variableId',
);
await renameIndex(
'variable_attributes_stationPkId_componentId',
'variable_attributes_stationId_componentId',
);
await renameIndex(
'variable_attributes_stationPkId_type_variableId',
'variable_attributes_stationId_type_variableId',
);
await renameIndex(
'variable_attributes_stationPkId_type_componentId',
'variable_attributes_stationId_type_componentId',
);
await renameIndex(
'variable_attributes_stationPkId_variableId_componentId',
'variable_attributes_stationId_variableId_componentId',
);
// Recreate trigger function with new column names
await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION populate_station_id()
RETURNS TRIGGER AS $$
BEGIN
SELECT "id" INTO NEW."stationId"
FROM "ChargingStations"
WHERE "ocppConnectionName" = NEW."ocppConnectionName" AND "tenantId" = NEW."tenantId";
IF NEW."stationId" IS NULL THEN
RAISE EXCEPTION 'No ChargingStation found with ocppConnectionName=% and tenantId=%',
NEW."ocppConnectionName", NEW."tenantId";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
for (const tableName of primaryKeyTables) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_id`;
await queryInterface.sequelize.query(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`,
);
await queryInterface.sequelize.query(`
CREATE TRIGGER "${triggerName}"
BEFORE INSERT OR UPDATE ON "${tableName}"
FOR EACH ROW
WHEN (NEW."stationId" IS NULL)
EXECUTE FUNCTION populate_station_id()
`);
}
console.log('Migration 20260427000000-rename-charging-station-columns completed successfully.');
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.transaction(async (transaction) => {
const qSelect = <T = any>(sql: string, replacements?: Record<string, any>): Promise<T[]> =>
queryInterface.sequelize.query(sql, {
transaction,
replacements,
type: QueryTypes.SELECT,
}) as Promise<T[]>;
const renameConstraint = async (table: string, oldName: string, newName: string) => {
const [row] = await qSelect<{ count: string }>(
`SELECT COUNT(*) AS count FROM information_schema.table_constraints
WHERE table_schema = 'public' AND table_name = :table AND constraint_name = :name`,
{ table, name: oldName },
);
if (parseInt((row as any).count, 10) > 0) {
await queryInterface.sequelize.query(
`ALTER TABLE "${table}" RENAME CONSTRAINT "${oldName}" TO "${newName}"`,
);
}
};
const renameIndex = async (oldName: string, newName: string) => {
const [row] = await qSelect<{ count: string }>(
`SELECT COUNT(*) AS count FROM pg_indexes
WHERE schemaname = 'public' AND indexname = :name`,
{ name: oldName },
);
if (parseInt((row as any).count, 10) > 0) {
await queryInterface.sequelize.query(`ALTER INDEX "${oldName}" RENAME TO "${newName}"`);
}
};
const renameColumn = async (table: string, oldName: string, newName: string) => {
await queryInterface.sequelize.query(
`DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${oldName}') THEN
ALTER TABLE "${table}" RENAME COLUMN "${oldName}" TO "${newName}";
END IF;
END $$;`,
{ transaction, type: QueryTypes.RAW },
);
};
const primaryKeyTables = [
'Evses',
'Connectors',
'Transactions',
'ChargingStationNetworkProfiles',
'ChargingStationSequences',
'StatusNotifications',
'LatestStatusNotifications',
'VariableAttributes',
'SetNetworkProfiles',
'OCPPMessages',
'InstalledCertificates',
'ChargingStationSecurityInfos',
'VariableMonitorings',
'EventData',
'InstallCertificateAttempts',
'DeleteCertificateAttempts',
];
// Drop triggers with new names
for (const tableName of primaryKeyTables) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_id`;
await queryInterface.sequelize.query(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`,
);
}
await queryInterface.sequelize.query(`DROP FUNCTION IF EXISTS populate_station_id()`);
// Rename columns back
await renameColumn('ChargingStations', 'id', 'pkId');
await renameColumn('ChargingStations', 'ocppConnectionName', 'id');
await queryInterface.sequelize.query(
`ALTER SEQUENCE IF EXISTS "ChargingStations_id_seq" RENAME TO "ChargingStations_pkId_seq"`,
);
await queryInterface.sequelize.query(
`ALTER TABLE "ChargingStations" ALTER COLUMN "pkId" SET DEFAULT nextval('"ChargingStations_pkId_seq"')`,
);
for (const tableName of primaryKeyTables) {
await renameColumn(tableName, 'stationId', 'stationPkId');
await renameColumn(tableName, 'ocppConnectionName', 'stationId');
}
// Tables with only the string station identifier: "ocppConnectionName" → "stationId"
const ocppConnectionTables = [
'ChangeConfigurations',
'ChargingProfiles',
'ChargingSchedules',
'CompositeSchedules',
'LocalListVersions',
'SendLocalLists',
'MessageInfos',
'Reservations',
'SecurityEvents',
'Subscriptions',
'StartTransactions',
'StopTransactions',
'TransactionEvents',
];
for (const tableName of ocppConnectionTables) {
await renameColumn(tableName, 'ocppConnectionName', 'stationId');
}
// Rename constraints and indexes back
await renameConstraint(
'ChargingStations',
'ChargingStations_stationName_tenantId_key',
'ChargingStations_id_tenantId_key',
);
const fkRenames: [string, string, string][] = [
['Evses', 'Evses_stationId_fkey', 'Evses_stationPkId_fkey'],
['Connectors', 'Connectors_stationId_fkey', 'Connectors_stationPkId_fkey'],
['Transactions', 'Transactions_stationId_fkey', 'Transactions_stationPkId_fkey'],
[
'ChargingStationNetworkProfiles',
'ChargingStationNetworkProfiles_stationId_fkey',
'ChargingStationNetworkProfiles_stationPkId_fkey',
],
[
'ChargingStationSequences',
'ChargingStationSequences_stationId_fkey',
'ChargingStationSequences_stationPkId_fkey',
],
[
'StatusNotifications',
'StatusNotifications_stationId_fkey',
'StatusNotifications_stationPkId_fkey',
],
[
'LatestStatusNotifications',
'LatestStatusNotifications_stationId_fkey',
'LatestStatusNotifications_stationPkId_fkey',
],
[
'VariableAttributes',
'VariableAttributes_stationId_fkey',
'VariableAttributes_stationPkId_fkey',
],
[
'SetNetworkProfiles',
'SetNetworkProfiles_stationId_fkey',
'SetNetworkProfiles_stationPkId_fkey',
],
['OCPPMessages', 'OCPPMessages_stationId_fkey', 'OCPPMessages_stationPkId_fkey'],
[
'InstalledCertificates',
'InstalledCertificates_stationId_fkey',
'InstalledCertificates_stationPkId_fkey',
],
['EventData', 'EventData_stationId_fkey', 'EventData_stationPkId_fkey'],
[
'VariableMonitorings',
'VariableMonitorings_stationId_fkey',
'VariableMonitorings_stationPkId_fkey',
],
[
'InstallCertificateAttempts',
'InstallCertificateAttempts_stationId_fkey',
'InstallCertificateAttempts_stationPkId_fkey',
],
[
'DeleteCertificateAttempts',
'DeleteCertificateAttempts_stationId_fkey',
'DeleteCertificateAttempts_stationPkId_fkey',
],
[
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationId_fkey',
'ChargingStationSecurityInfos_stationPkId_fkey',
],
];
for (const [table, oldName, newName] of fkRenames) {
await renameConstraint(table, oldName, newName);
}
await renameConstraint('Evses', 'stationId_evseTypeId', 'stationPkId_evseTypeId');
await renameConstraint('Connectors', 'stationId_connectorId', 'stationPkId_connectorId');
await renameConstraint(
'Transactions',
'stationId_transactionId',
'stationPkId_transactionId',
);
await renameConstraint(
'ChargingStationNetworkProfiles',
'stationId_configurationSlot',
'stationPkId_configurationSlot',
);
await renameConstraint(
'ChargingStationNetworkProfiles',
'CSNP_stationId_websocketServerConfigId_key',
'CSNP_stationPkId_websocketServerConfigId_key',
);
await renameConstraint('ChargingStationSequences', 'stationId_type', 'stationPkId_type');
await renameConstraint(
'VariableAttributes',
'stationId_type_variableId_componentId',
'stationPkId_type_variableId_componentId',
);
await renameConstraint(
'SetNetworkProfiles',
'stationId_correlationId',
'stationPkId_correlationId',
);
await renameConstraint(
'ChargingStationSecurityInfos',
'ChargingStationSecurityInfos_stationName_tenantId',
'ChargingStationSecurityInfos_stationId_tenantId',
);
await renameConstraint(
'EventData',
'EventData_stationName_tenantId_eventId',
'EventData_stationId_tenantId_eventId',
);
await renameConstraint(
'EventData',
'EventData_stationName_eventId',
'EventData_stationId_eventId',
);
await renameConstraint(
'VariableMonitorings',
'VariableMonitorings_stationName_tenantId_id',
'VariableMonitorings_stationId_tenantId_id',
);
await renameIndex('variable_attributes_stationId', 'variable_attributes_stationPkId');
await renameIndex(
'variable_attributes_stationId_type',
'variable_attributes_stationPkId_type',
);
await renameIndex(
'variable_attributes_stationId_variableId',
'variable_attributes_stationPkId_variableId',
);
await renameIndex(
'variable_attributes_stationId_componentId',
'variable_attributes_stationPkId_componentId',
);
await renameIndex(
'variable_attributes_stationId_type_variableId',
'variable_attributes_stationPkId_type_variableId',
);
await renameIndex(
'variable_attributes_stationId_type_componentId',
'variable_attributes_stationPkId_type_componentId',
);
await renameIndex(
'variable_attributes_stationId_variableId_componentId',
'variable_attributes_stationPkId_variableId_componentId',
);
// Restore original trigger function
await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION populate_station_pk_id()
RETURNS TRIGGER AS $$
BEGIN
SELECT "pkId" INTO NEW."stationPkId"
FROM "ChargingStations"
WHERE "id" = NEW."stationId" AND "tenantId" = NEW."tenantId";
IF NEW."stationPkId" IS NULL THEN
RAISE EXCEPTION 'No ChargingStation found with stationId=% and tenantId=%',
NEW."stationId", NEW."tenantId";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
for (const tableName of primaryKeyTables) {
const triggerName = `trigger_populate_${tableName.toLowerCase()}_station_pk_id`;
await queryInterface.sequelize.query(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${tableName}"`,
);
await queryInterface.sequelize.query(`
CREATE TRIGGER "${triggerName}"
BEFORE INSERT OR UPDATE ON "${tableName}"
FOR EACH ROW
WHEN (NEW."stationPkId" IS NULL)
EXECUTE FUNCTION populate_station_pk_id()
`);
}
console.log(
'Migration 20260427000000-rename-charging-station-columns rolled back successfully.',
);
});
},
};

View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
'use strict';
/** @type {import('sequelize-cli').Migration} */
import { DataTypes, QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('Transactions', 'transactionLimit', {
type: DataTypes.JSONB,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('Transactions', 'transactionLimit');
},
};

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { DataTypes, QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('InstallCertificateAttempts', 'requestId', {
type: DataTypes.INTEGER,
allowNull: true,
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('InstallCertificateAttempts', 'requestId');
}