Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
401 lines
16 KiB
JavaScript
401 lines
16 KiB
JavaScript
/** EE-only migration: align Registry v2 schema with spec
|
|
*
|
|
* This migration aligns existing EE tables to the Registry v2 spec:
|
|
* 1) extension_registry
|
|
* 2) extension_version
|
|
* 3) extension_bundle
|
|
* 4) tenant_extension_install
|
|
* 5) extension_execution_log
|
|
* 6) extension_quota_usage
|
|
*
|
|
* Notes:
|
|
* - Uses Postgres-specific features (jsonb, check constraints).
|
|
* - Non-destructive for main registry tables where possible.
|
|
* - For execution_log and quota_usage, we preserve old tables by renaming to *_old and create new spec-compliant tables.
|
|
*/
|
|
|
|
/** @param {import('knex').Knex} knex */
|
|
exports.up = async function up(knex) {
|
|
// 1) extension_registry
|
|
const hasExtReg = await knex.schema.hasTable('extension_registry');
|
|
if (hasExtReg) {
|
|
// Ensure an index for (publisher, name)
|
|
await knex.raw(
|
|
`CREATE INDEX IF NOT EXISTS extension_registry_publisher_name_idx ON extension_registry (publisher, name)`
|
|
);
|
|
// Prefer DB default UUID if gen_random_uuid is available
|
|
await knex.raw(`
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE p.proname = 'gen_random_uuid' AND n.nspname IN ('pgcrypto','public')
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_registry ALTER COLUMN id SET DEFAULT gen_random_uuid()';
|
|
END IF;
|
|
END
|
|
$$;
|
|
`);
|
|
// Ensure timestamps have defaults if columns exist
|
|
await knex.raw(`
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'extension_registry' AND column_name = 'created_at'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_registry ALTER COLUMN created_at SET DEFAULT now()';
|
|
END IF;
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'extension_registry' AND column_name = 'updated_at'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_registry ALTER COLUMN updated_at SET DEFAULT now()';
|
|
END IF;
|
|
END
|
|
$$;
|
|
`);
|
|
}
|
|
|
|
// 2) extension_version
|
|
const hasExtVer = await knex.schema.hasTable('extension_version');
|
|
if (hasExtVer) {
|
|
const hasApiEndpoints = await knex.schema.hasColumn('extension_version', 'api_endpoints');
|
|
if (!hasApiEndpoints) {
|
|
await knex.schema.alterTable('extension_version', (t) => {
|
|
// api_endpoints is always a JSON array; default to []
|
|
t.jsonb('api_endpoints').notNullable().defaultTo(knex.raw(`'[]'::jsonb`));
|
|
});
|
|
// Backfill api_endpoints from existing "api" column if present
|
|
const hasApi = await knex.schema.hasColumn('extension_version', 'api');
|
|
if (hasApi) {
|
|
// If "api" is an array of endpoints, copy directly.
|
|
// If "api" is an object and its "endpoints" is an array, copy that; otherwise []
|
|
await knex.raw(`
|
|
UPDATE extension_version
|
|
SET api_endpoints = CASE
|
|
WHEN jsonb_typeof(api) = 'array' THEN api
|
|
WHEN jsonb_typeof(api) = 'object' AND jsonb_typeof(api->'endpoints') = 'array' THEN api->'endpoints'
|
|
ELSE '[]'::jsonb
|
|
END
|
|
`);
|
|
} else {
|
|
await knex.raw(`UPDATE extension_version SET api_endpoints = '[]'::jsonb`);
|
|
}
|
|
}
|
|
// Ensure capabilities is jsonb default '[]' if column exists
|
|
await knex.raw(`
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'extension_version' AND column_name = 'capabilities'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_version ALTER COLUMN capabilities SET DEFAULT ''[]''::jsonb';
|
|
END IF;
|
|
END
|
|
$$;
|
|
`);
|
|
// Index for (registry_id, version)
|
|
await knex.raw(
|
|
`CREATE INDEX IF NOT EXISTS extension_version_registry_version_idx ON extension_version (registry_id, version)`
|
|
);
|
|
}
|
|
|
|
// 3) extension_bundle
|
|
const hasBundle = await knex.schema.hasTable('extension_bundle');
|
|
if (hasBundle) {
|
|
// Add missing columns and indexes
|
|
const hasStorageUrl = await knex.schema.hasColumn('extension_bundle', 'storage_url');
|
|
if (!hasStorageUrl) {
|
|
await knex.schema.alterTable('extension_bundle', (t) => {
|
|
t.text('storage_url').defaultTo(null);
|
|
});
|
|
}
|
|
const hasSizeBytes = await knex.schema.hasColumn('extension_bundle', 'size_bytes');
|
|
if (!hasSizeBytes) {
|
|
await knex.schema.alterTable('extension_bundle', (t) => {
|
|
t.bigInteger('size_bytes').defaultTo(null);
|
|
});
|
|
}
|
|
await knex.raw(
|
|
`CREATE INDEX IF NOT EXISTS extension_bundle_content_hash_idx ON extension_bundle (content_hash)`
|
|
);
|
|
}
|
|
|
|
// 4) tenant_extension_install
|
|
const hasInstall = await knex.schema.hasTable('tenant_extension_install');
|
|
if (hasInstall) {
|
|
// Rename columns to align with spec if they exist
|
|
const hasEnabled = await knex.schema.hasColumn('tenant_extension_install', 'enabled');
|
|
if (hasEnabled) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.renameColumn('enabled', 'is_enabled');
|
|
});
|
|
}
|
|
const hasInstalledAt = await knex.schema.hasColumn('tenant_extension_install', 'installed_at');
|
|
if (hasInstalledAt) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.renameColumn('installed_at', 'created_at');
|
|
});
|
|
}
|
|
// Ensure columns exist per spec
|
|
const hasUpdatedAt = await knex.schema.hasColumn('tenant_extension_install', 'updated_at');
|
|
if (!hasUpdatedAt) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
|
|
});
|
|
}
|
|
const hasStatus = await knex.schema.hasColumn('tenant_extension_install', 'status');
|
|
if (!hasStatus) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.text('status').notNullable().defaultTo('enabled');
|
|
});
|
|
// Add check constraint for status (enabled|disabled|pending) only if missing
|
|
await knex.raw(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'tenant_extension_install_status_chk'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE tenant_extension_install ADD CONSTRAINT tenant_extension_install_status_chk CHECK (status IN (''enabled'',''disabled'',''pending''))';
|
|
END IF;
|
|
END
|
|
$$;
|
|
`);
|
|
}
|
|
// Ensure is_enabled exists and is boolean not null default true
|
|
const hasIsEnabled = await knex.schema.hasColumn('tenant_extension_install', 'is_enabled');
|
|
if (!hasIsEnabled) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.boolean('is_enabled').notNullable().defaultTo(true);
|
|
});
|
|
}
|
|
// Ensure index for (tenant_id, registry_id)
|
|
await knex.raw(
|
|
`CREATE INDEX IF NOT EXISTS tenant_extension_install_tenant_registry_idx ON tenant_extension_install (tenant_id, registry_id)`
|
|
);
|
|
}
|
|
|
|
// 5) extension_execution_log (recreate to match spec; preserve old as *_old)
|
|
const hasExecLog = await knex.schema.hasTable('extension_execution_log');
|
|
const hasExecLogOld = await knex.schema.hasTable('extension_execution_log_old');
|
|
if (hasExecLog && !hasExecLogOld) {
|
|
// Preserve old data by renaming current table to *_old
|
|
await knex.schema.renameTable('extension_execution_log', 'extension_execution_log_old');
|
|
} else if (hasExecLog && hasExecLogOld) {
|
|
// We already have *_old; drop current table to allow clean recreate
|
|
await knex.schema.dropTable('extension_execution_log');
|
|
}
|
|
// Ensure old PK constraint/index does not conflict with new table
|
|
const ensureExecOldPkRename = `
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'extension_execution_log_old'
|
|
) THEN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'extension_execution_log_pkey'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_execution_log_old RENAME CONSTRAINT extension_execution_log_pkey TO extension_execution_log_old_pkey';
|
|
END IF;
|
|
END IF;
|
|
END
|
|
$$;
|
|
`;
|
|
await knex.raw(ensureExecOldPkRename);
|
|
await knex.schema.createTable('extension_execution_log', (t) => {
|
|
t.uuid('id').primary();
|
|
t.string('tenant_id').notNullable();
|
|
t.uuid('registry_id').notNullable();
|
|
t.uuid('version_id').notNullable();
|
|
t.string('request_id');
|
|
t.timestamp('started_at').notNullable().defaultTo(knex.fn.now());
|
|
t.timestamp('finished_at');
|
|
t.text('status').notNullable(); // ok|error|timeout|oom|policy_denied
|
|
t.jsonb('metrics').defaultTo(null);
|
|
t.text('error').defaultTo(null);
|
|
t.index(['tenant_id', 'registry_id', 'started_at'], 'extension_execution_log_tenant_registry_started_idx');
|
|
});
|
|
|
|
// 6) extension_quota_usage (recreate to match spec; preserve old as *_old)
|
|
const hasQuota = await knex.schema.hasTable('extension_quota_usage');
|
|
const hasQuotaOld = await knex.schema.hasTable('extension_quota_usage_old');
|
|
if (hasQuota && !hasQuotaOld) {
|
|
await knex.schema.renameTable('extension_quota_usage', 'extension_quota_usage_old');
|
|
} else if (hasQuota && hasQuotaOld) {
|
|
await knex.schema.dropTable('extension_quota_usage');
|
|
}
|
|
const ensureQuotaOldPkRename = `
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'extension_quota_usage_old'
|
|
) THEN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'extension_quota_usage_pkey'
|
|
) THEN
|
|
EXECUTE 'ALTER TABLE extension_quota_usage_old RENAME CONSTRAINT extension_quota_usage_pkey TO extension_quota_usage_old_pkey';
|
|
END IF;
|
|
END IF;
|
|
END
|
|
$$;
|
|
`;
|
|
await knex.raw(ensureQuotaOldPkRename);
|
|
await knex.schema.createTable('extension_quota_usage', (t) => {
|
|
t.string('tenant_id').notNullable();
|
|
t.uuid('registry_id').notNullable();
|
|
t.timestamp('window_start').notNullable();
|
|
t.bigInteger('cpu_ms').notNullable().defaultTo(0);
|
|
t.bigInteger('mem_mb_ms').notNullable().defaultTo(0);
|
|
t.bigInteger('invocations').notNullable().defaultTo(0);
|
|
t.bigInteger('egress_bytes').notNullable().defaultTo(0);
|
|
t.primary(['tenant_id', 'registry_id', 'window_start']);
|
|
t.index(['tenant_id', 'window_start'], 'extension_quota_usage_tenant_window_idx');
|
|
});
|
|
};
|
|
|
|
/** @param {import('knex').Knex} knex */
|
|
exports.down = async function down(knex) {
|
|
// Drop new quota_usage and restore old if present
|
|
const hasQuotaNew = await knex.schema.hasTable('extension_quota_usage');
|
|
if (hasQuotaNew) {
|
|
await knex.schema.dropTable('extension_quota_usage');
|
|
}
|
|
const hasQuotaOld = await knex.schema.hasTable('extension_quota_usage_old');
|
|
if (hasQuotaOld) {
|
|
await knex.schema.renameTable('extension_quota_usage_old', 'extension_quota_usage');
|
|
}
|
|
|
|
// Drop new execution_log and restore old if present
|
|
const hasExecNew = await knex.schema.hasTable('extension_execution_log');
|
|
if (hasExecNew) {
|
|
await knex.schema.dropTable('extension_execution_log');
|
|
}
|
|
const hasExecOld = await knex.schema.hasTable('extension_execution_log_old');
|
|
if (hasExecOld) {
|
|
await knex.schema.renameTable('extension_execution_log_old', 'extension_execution_log');
|
|
}
|
|
|
|
// Reverse some additive changes (best-effort)
|
|
// tenant_extension_install
|
|
const hasInstall = await knex.schema.hasTable('tenant_extension_install');
|
|
if (hasInstall) {
|
|
// Drop check constraint
|
|
try {
|
|
await knex.raw(`ALTER TABLE tenant_extension_install DROP CONSTRAINT tenant_extension_install_status_chk`);
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
// Remove status / updated_at if they exist
|
|
const hasStatus = await knex.schema.hasColumn('tenant_extension_install', 'status');
|
|
if (hasStatus) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.dropColumn('status');
|
|
});
|
|
}
|
|
const hasUpdatedAt = await knex.schema.hasColumn('tenant_extension_install', 'updated_at');
|
|
if (hasUpdatedAt) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.dropColumn('updated_at');
|
|
});
|
|
}
|
|
// Rename columns back if they previously existed
|
|
const hasIsEnabled = await knex.schema.hasColumn('tenant_extension_install', 'is_enabled');
|
|
if (hasIsEnabled) {
|
|
// add back enabled, then copy & drop
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.boolean('enabled').defaultTo(true);
|
|
});
|
|
try {
|
|
await knex.raw(`UPDATE tenant_extension_install SET enabled = COALESCE(is_enabled, true)`);
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.dropColumn('is_enabled');
|
|
});
|
|
}
|
|
const hasCreatedAt = await knex.schema.hasColumn('tenant_extension_install', 'created_at');
|
|
const hadInstalledAt = !(await knex.schema.hasColumn('tenant_extension_install', 'installed_at')); // we want installed_at restored only if missing
|
|
if (hasCreatedAt && hadInstalledAt) {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.timestamp('installed_at').defaultTo(knex.fn.now());
|
|
});
|
|
try {
|
|
await knex.raw(`UPDATE tenant_extension_install SET installed_at = created_at`);
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.dropColumn('created_at');
|
|
});
|
|
}
|
|
// Drop added index
|
|
try {
|
|
await knex.schema.alterTable('tenant_extension_install', (t) => {
|
|
t.dropIndex(['tenant_id', 'registry_id'], 'tenant_extension_install_tenant_registry_idx');
|
|
});
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
}
|
|
|
|
// extension_bundle: drop added columns and index
|
|
const hasBundle = await knex.schema.hasTable('extension_bundle');
|
|
if (hasBundle) {
|
|
try {
|
|
await knex.schema.alterTable('extension_bundle', (t) => {
|
|
t.dropIndex(['content_hash'], 'extension_bundle_content_hash_idx');
|
|
});
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
const hasStorageUrl = await knex.schema.hasColumn('extension_bundle', 'storage_url');
|
|
if (hasStorageUrl) {
|
|
await knex.schema.alterTable('extension_bundle', (t) => {
|
|
t.dropColumn('storage_url');
|
|
});
|
|
}
|
|
const hasSizeBytes = await knex.schema.hasColumn('extension_bundle', 'size_bytes');
|
|
if (hasSizeBytes) {
|
|
await knex.schema.alterTable('extension_bundle', (t) => {
|
|
t.dropColumn('size_bytes');
|
|
});
|
|
}
|
|
}
|
|
|
|
// extension_version: drop api_endpoints index/column if added
|
|
const hasExtVer = await knex.schema.hasTable('extension_version');
|
|
if (hasExtVer) {
|
|
const hasApiEndpoints = await knex.schema.hasColumn('extension_version', 'api_endpoints');
|
|
if (hasApiEndpoints) {
|
|
await knex.schema.alterTable('extension_version', (t) => {
|
|
t.dropColumn('api_endpoints');
|
|
});
|
|
}
|
|
try {
|
|
await knex.schema.alterTable('extension_version', (t) => {
|
|
t.dropIndex(['registry_id', 'version'], 'extension_version_registry_version_idx');
|
|
});
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
}
|
|
|
|
// extension_registry: drop index; cannot reliably unset default
|
|
const hasExtReg = await knex.schema.hasTable('extension_registry');
|
|
if (hasExtReg) {
|
|
try {
|
|
await knex.schema.alterTable('extension_registry', (t) => {
|
|
t.dropIndex(['publisher', 'name'], 'extension_registry_publisher_name_idx');
|
|
});
|
|
} catch (_e) {
|
|
// Constraint may not exist in all environments, safe to ignore
|
|
}
|
|
}
|
|
};
|