Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
* composition). Separate from freight.drivers, which registers road/last-mile
* truck drivers and shares none of these fields.
*
* Role and nationality are stored as varchar rather than PG enums so adding a
* crew role later is an application change, not a type migration. The partial
* unique index keys on name + role — the roster has no employee number yet, so
* that is the only identity available to block an accidental re-entry; it is
* scoped to live rows so a soft-deleted member does not hold the name hostage.
*/
export class TrainCrewMembers3850000000000 implements MigrationInterface {
name = 'TrainCrewMembers3850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
first_name varchar(100) NOT NULL,
last_name varchar(100) NOT NULL,
role varchar(32) NOT NULL,
nationality varchar(16) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_train_crew_role CHECK (role IN (
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
)),
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
'ETHIOPIAN','DJIBOUTIAN'
)),
CONSTRAINT chk_train_crew_status CHECK (status IN (
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
))
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
ON freight.train_crew_members (role)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
ON freight.train_crew_members (nationality)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
ON freight.train_crew_members (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
ON freight.train_crew_members (is_active)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
*
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
* splits four drivers across the Dire Dawa boundary, and a driver who is
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
* roster edit cannot rewrite the crew of a run that already departed.
*
* The partial unique index on (schedule, segment, duty_role) applies to drivers
* only — one segment cannot have two Primaries, while four federal police on
* the same run carry no segment or duty role and are unconstrained by it.
*/
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
name = 'TrainCrewAssignments3860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id uuid NOT NULL,
crew_member_id uuid NOT NULL
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
role varchar(32) NOT NULL,
duty_role varchar(16),
segment varchar(24),
crewing_case varchar(8),
layover_start_at timestamptz,
layover_end_at timestamptz,
duty_start_at timestamptz,
duty_end_at timestamptz,
status varchar(16) NOT NULL DEFAULT 'PLANNED',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_crew_assignment_duty_role CHECK (
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
),
CONSTRAINT chk_crew_assignment_segment CHECK (
segment IS NULL OR segment IN
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
),
CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
),
CONSTRAINT chk_crew_assignment_status CHECK (
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
ON freight.train_crew_assignments (train_schedule_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
ON freight.train_crew_assignments (crew_member_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
ON freight.train_crew_assignments (status)
`);
// Nobody holds two seats on one run.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
WHERE deleted_at IS NULL
`);
// One Primary (and one Assistant) per segment — drivers only.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
// Monthly overtime rollups scan a member's duty spans (§3.1).
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
WHERE duty_start_at IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drop `crewing_case` from train crew assignments.
*
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
* its own need instead — any number of drivers, each carrying their own segment
* and duty role — so the case has nothing left to select and the column no
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
* per-driver rule and still enforced.
*/
export class DropCrewingCase3870000000000 implements MigrationInterface {
name = 'DropCrewingCase3870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS crewing_case
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
)
`);
}
}

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Driver legs become yard-to-yard instead of three fixed corridor segments.
*
* The old `segment` enum could only express IndodeDire Dawa, Dire DawaNagad
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
* places), so a leg is now any two yards on the schedule's route.
*
* `segment` is kept, nullable, so rows written before this still read back; it
* is never populated again. The §1.1 territorial boundary is unaffected — it
* now derives from yard position rather than the segment name, so a Djibouti
* driver is still confined to Dire Dawa and eastward.
*
* The driver-slot unique index moves with it: one Primary per leg, where a leg
* is the (from, to) pair rather than a segment name.
*/
export class CrewLegYards3880000000000 implements MigrationInterface {
name = 'CrewLegYards3880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
`);
// Backfill the three legacy segments onto real yards so historic rows keep
// a usable leg. Matched by code; a deployment missing one simply leaves
// those rows with a null leg, which the validator reports as incomplete.
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'INDODE_DIRE_DAWA'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'DIRE_DAWA_NAGAD'
AND a.from_yard_id IS NULL
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'FULL_CORRIDOR'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'NAGAD'
`);
// One Primary per leg replaces one Primary per segment.
await queryRunner.query(`
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
ON freight.train_crew_assignments
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
WHERE deleted_at IS NULL
AND from_yard_id IS NOT NULL
AND to_yard_id IS NOT NULL
AND duty_role IS NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
WHERE from_yard_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS from_yard_id,
DROP COLUMN IF EXISTS to_yard_id
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empty container import is base rail freight for equipment carrying no cargo,
* so it is sold per lane exactly like laden container freight.
*
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
* Drop-and-recreate is the established shape for this constraint — see
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
*/
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
name = 'EmptyContainerRateScope3890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Whether a booking moves cargo or bare equipment.
*
* EMPTY is container freight carrying nothing — the box itself is the shipment,
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
* separate column rather than a third `freight_type`: an empty booking is still
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
* places whose else-arm means "container".
*
* Every existing row is LADEN, which the default supplies — no backfill needed.
*/
export class BookingCargoCondition3900000000000 implements MigrationInterface {
name = 'BookingCargoCondition3900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
`);
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
// freight. Enforced here so no API path can file the combination.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
// The booking queues filter empties out of (and into) the laden lists.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
ON freight.bookings (cargo_condition)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Contract paper for empty container import.
*
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
* contract can commit to moving bare equipment.
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
* resolves for those contracts. It carries no customs variant: an empty box
* has no declaration to clear, the same reason intercity is unsuffixed.
*/
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
name = 'EmptyContainerContractTemplate3910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,99 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customer-requested validity extension of an EXPIRED contract.
*
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
* add days on the backoffice detail page and the contract returns to the status
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
* read) now stash that status in `status_before_expiry`, mirroring
* `status_before_suspension`; rows expired before this column existed fall
* back to the kind's resting status on extension.
*
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
* resolves every registry key against `iam.permissions` at boot and throws on
* a missing row, so the catalog row must exist wherever the registry ships.
* The grant is copied from whoever already holds `contracts:suspend` — the
* registry places both keys on the same desk (marketing), and the position
* seeder only re-syncs presets when SEED_EDR_ORG is set.
*/
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
name = 'ContractExtensionRequest3920000000000';
private static readonly KEY = 'edr_freight_app:contracts:extend';
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
`);
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
);
// Grant wherever suspend is already granted (positions and roles alike).
await queryRunner.query(
`INSERT INTO iam.position_permissions (position_id, permission_id)
SELECT pp.position_id, np.id
FROM iam.position_permissions pp
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.position_permissions x
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
await queryRunner.query(
`INSERT INTO iam.role_permissions (role_id, permission_id)
SELECT rp.role_id, np.id
FROM iam.role_permissions rp
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.role_permissions x
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
}
/** Grants go first, or the delete trips the permission foreign keys. */
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
ContractExtensionRequest3920000000000.KEY,
]);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
);
}
}