forked from wangziqi/gongxue-base
1115 lines
40 KiB
JavaScript
1115 lines
40 KiB
JavaScript
import { performance } from 'node:perf_hooks';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import pg from 'pg';
|
|
import {
|
|
DESTRUCTIVE_TEST_CONFIRMATION,
|
|
assertDestructiveTestDatabase,
|
|
describeDatabaseTarget,
|
|
resolveDestructiveTestConfirmation,
|
|
} from './lib/destructive-test-database-guard.js';
|
|
|
|
const { Client } = pg;
|
|
|
|
export const CAPACITY_NAMESPACE = 'tiku.student-capacity.v1';
|
|
export const DEFAULT_TENANT_SLUG = 'capacity-test-students-100k';
|
|
export const MAX_STUDENT_COUNT = 100_000;
|
|
export const DEFAULT_STUDENT_COUNT = 100_000;
|
|
export const DEFAULT_BATCH_SIZE = 10_000;
|
|
export const DEFAULT_ITERATIONS = 10;
|
|
export const DEFAULT_WARMUP_ITERATIONS = 3;
|
|
export const DEFAULT_PAGE_LIMIT = 100;
|
|
export const DEFAULT_OUTPUT_DIR = 'docs/refactor/performance-reports';
|
|
|
|
const MODES = new Set(['plan', 'seed', 'benchmark', 'run', 'evidence', 'cleanup', 'smoke']);
|
|
const TENANT_SLUG_PATTERN = /^capacity-test-[a-z0-9](?:[a-z0-9-]{0,47}[a-z0-9])?$/;
|
|
const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i;
|
|
|
|
function argumentValue(argv, name) {
|
|
const directIndex = argv.indexOf(name);
|
|
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
|
|
const prefix = `${name}=`;
|
|
const direct = argv.find(value => value.startsWith(prefix));
|
|
return direct ? String(direct).slice(prefix.length).trim() : '';
|
|
}
|
|
|
|
function integerOption(value, fallback, { name, min, max }) {
|
|
if (value === '') return fallback;
|
|
if (!/^\d+$/.test(value)) throw new Error(`${name} must be an integer`);
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
|
throw new Error(`${name} must be between ${min} and ${max}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function validateTenantSlug(value) {
|
|
const slug = String(value || '').toLowerCase();
|
|
if (!TENANT_SLUG_PATTERN.test(slug)) {
|
|
throw new Error('tenant slug must match capacity-test-* and contain only lowercase letters, numbers, and hyphens');
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
export function parseCapacityOptions(argv = process.argv.slice(2), env = process.env) {
|
|
const mode = argumentValue(argv, '--mode') || 'plan';
|
|
if (!MODES.has(mode)) throw new Error(`unsupported mode: ${mode}`);
|
|
|
|
const options = {
|
|
mode,
|
|
databaseUrl: String(env.DATABASE_URL || '').trim(),
|
|
tenantSlug: validateTenantSlug(
|
|
argumentValue(argv, '--tenant-slug') || env.CAPACITY_TENANT_SLUG || DEFAULT_TENANT_SLUG,
|
|
),
|
|
count: integerOption(argumentValue(argv, '--count'), DEFAULT_STUDENT_COUNT, {
|
|
name: 'count', min: 10, max: MAX_STUDENT_COUNT,
|
|
}),
|
|
batchSize: integerOption(argumentValue(argv, '--batch-size'), DEFAULT_BATCH_SIZE, {
|
|
name: 'batch-size', min: 100, max: 20_000,
|
|
}),
|
|
iterations: integerOption(argumentValue(argv, '--iterations'), DEFAULT_ITERATIONS, {
|
|
name: 'iterations', min: 1, max: 100,
|
|
}),
|
|
warmupIterations: integerOption(
|
|
argumentValue(argv, '--warmup-iterations'),
|
|
DEFAULT_WARMUP_ITERATIONS,
|
|
{ name: 'warmup-iterations', min: 0, max: 20 },
|
|
),
|
|
pageLimit: integerOption(argumentValue(argv, '--limit'), DEFAULT_PAGE_LIMIT, {
|
|
name: 'limit', min: 1, max: 500,
|
|
}),
|
|
outputDir: argumentValue(argv, '--output-dir') || env.CAPACITY_OUTPUT_DIR || DEFAULT_OUTPUT_DIR,
|
|
confirmation: resolveDestructiveTestConfirmation(env, argv),
|
|
};
|
|
|
|
if (mode !== 'plan' && !options.databaseUrl) {
|
|
throw new Error('DATABASE_URL is required outside plan mode');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function safeTarget(databaseUrl) {
|
|
if (!databaseUrl) return null;
|
|
const target = describeDatabaseTarget(databaseUrl);
|
|
const normalizedHost = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
if (
|
|
['127.0.0.1', 'localhost', '::1'].includes(normalizedHost) &&
|
|
target.port === '5432'
|
|
) {
|
|
throw new Error('Refusing tenant student capacity operation: local port 5432 is reserved for tikupro-pg');
|
|
}
|
|
for (const value of [target.host, target.database, target.user]) {
|
|
if (BLOCKED_TARGET_PATTERN.test(value)) {
|
|
throw new Error('Refusing tenant student capacity operation: tikupro-pg targets are forbidden');
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function normalizePlanText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function percentile(values, target) {
|
|
if (!values.length) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const index = Math.ceil((target / 100) * sorted.length) - 1;
|
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(Number(value || 0) * 1000) / 1000;
|
|
}
|
|
|
|
function containsSearchPattern(value) {
|
|
return `%${String(value).replace(/[\\%_]/g, match => `\\${match}`)}%`;
|
|
}
|
|
|
|
function studentLegacyId(tenantSlug, ordinal) {
|
|
return `${CAPACITY_NAMESPACE}:${tenantSlug}:${String(ordinal).padStart(6, '0')}`;
|
|
}
|
|
|
|
function capacityPlan(options) {
|
|
return {
|
|
schemaVersion: 1,
|
|
mode: options.mode,
|
|
dryRun: options.mode === 'plan',
|
|
target: safeTarget(options.databaseUrl),
|
|
fixture: {
|
|
namespace: CAPACITY_NAMESPACE,
|
|
tenantSlug: options.tenantSlug,
|
|
requestedStudents: options.count,
|
|
batchSize: options.batchSize,
|
|
tables: ['public.platform_users', 'public.tenant_memberships', 'public.student_profiles'],
|
|
},
|
|
benchmark: {
|
|
cases: ['first-page', 'deep-cursor', 'name-substring', 'phone-substring', 'email-substring'],
|
|
iterations: options.iterations,
|
|
warmupIterations: options.warmupIterations,
|
|
pageLimit: options.pageLimit,
|
|
explain: 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)',
|
|
},
|
|
applyRequirement: `--confirm=${DESTRUCTIVE_TEST_CONFIRMATION}`,
|
|
};
|
|
}
|
|
|
|
async function acquireHarnessLock(client, tenantSlug) {
|
|
const result = await client.query(
|
|
'select pg_try_advisory_lock(hashtext($1)) as acquired',
|
|
[`${CAPACITY_NAMESPACE}:${tenantSlug}`],
|
|
);
|
|
if (result.rows[0]?.acquired !== true) {
|
|
throw new Error(`capacity tenant ${tenantSlug} is already being modified or benchmarked`);
|
|
}
|
|
}
|
|
|
|
async function releaseHarnessLock(client, tenantSlug) {
|
|
await client.query('select pg_advisory_unlock(hashtext($1))', [`${CAPACITY_NAMESPACE}:${tenantSlug}`]);
|
|
}
|
|
|
|
async function loadManagedTenant(client, tenantSlug) {
|
|
const result = await client.query(
|
|
`
|
|
select id, slug::text as slug, name, owner_user_id as "ownerUserId", metadata
|
|
from public.tenants
|
|
where slug = $1::citext
|
|
limit 1
|
|
`,
|
|
[tenantSlug],
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
function assertManagedTenant(tenant, tenantSlug) {
|
|
const marker = tenant?.metadata?.capacityHarness;
|
|
if (
|
|
!tenant ||
|
|
marker?.namespace !== CAPACITY_NAMESPACE ||
|
|
marker?.managed !== true ||
|
|
marker?.tenantSlug !== tenantSlug ||
|
|
tenant.ownerUserId !== null
|
|
) {
|
|
throw new Error(`Refusing to use tenant ${tenantSlug}: capacity harness metadata marker does not match`);
|
|
}
|
|
}
|
|
|
|
async function ensureManagedTenant(client, tenantSlug) {
|
|
await client.query('begin');
|
|
try {
|
|
let tenant = await loadManagedTenant(client, tenantSlug);
|
|
if (tenant) {
|
|
assertManagedTenant(tenant, tenantSlug);
|
|
} else {
|
|
const inserted = await client.query(
|
|
`
|
|
insert into public.tenants (
|
|
slug, name, legal_name, status, mode, billing_status, metadata
|
|
)
|
|
values (
|
|
$1::citext,
|
|
'Capacity Test Students',
|
|
'Capacity Test Students - Non Production Only',
|
|
'active',
|
|
'saas',
|
|
'trial',
|
|
jsonb_build_object(
|
|
'capacityHarness',
|
|
jsonb_build_object(
|
|
'namespace', $2::text,
|
|
'managed', true,
|
|
'tenantSlug', $1::text
|
|
)
|
|
)
|
|
)
|
|
returning id, slug::text as slug, name, owner_user_id as "ownerUserId", metadata
|
|
`,
|
|
[tenantSlug, CAPACITY_NAMESPACE],
|
|
);
|
|
tenant = inserted.rows[0];
|
|
}
|
|
await client.query('commit');
|
|
return tenant;
|
|
} catch (error) {
|
|
await client.query('rollback').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function managedFixtureCounts(client, tenantId, tenantSlug) {
|
|
const result = await client.query(
|
|
`
|
|
with managed_users as (
|
|
select id
|
|
from public.platform_users
|
|
where raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
and raw_profile #>> '{capacityHarness,tenantId}' = $3::text
|
|
)
|
|
select
|
|
(select count(*)::integer from managed_users) as "platformUsers",
|
|
(
|
|
select count(*)::integer
|
|
from public.tenant_memberships tm
|
|
join managed_users u on u.id = tm.user_id
|
|
where tm.tenant_id = $3::uuid and tm.role = 'student'
|
|
) as "tenantMemberships",
|
|
(
|
|
select count(*)::integer
|
|
from public.student_profiles sp
|
|
join managed_users u on u.id = sp.user_id
|
|
where sp.tenant_id = $3::uuid
|
|
) as "studentProfiles"
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug, tenantId],
|
|
);
|
|
return result.rows[0];
|
|
}
|
|
|
|
async function assertManagedUsersAreIsolated(client, tenantId, tenantSlug) {
|
|
const result = await client.query(
|
|
`
|
|
select
|
|
count(*) filter (
|
|
where u.auth_user_id is not null
|
|
)::integer as "linkedAuthUsers",
|
|
count(*) filter (
|
|
where coalesce(u.raw_profile #>> '{capacityHarness,tenantId}', '') <> $3::text
|
|
)::integer as "wrongTenantMarkers",
|
|
count(*) filter (
|
|
where exists (
|
|
select 1
|
|
from public.tenant_memberships tm
|
|
where tm.user_id = u.id and tm.tenant_id <> $3::uuid
|
|
)
|
|
)::integer as "crossTenantMemberships"
|
|
from public.platform_users u
|
|
where u.raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and u.raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug, tenantId],
|
|
);
|
|
const isolation = result.rows[0];
|
|
if (
|
|
Number(isolation.linkedAuthUsers) > 0 ||
|
|
Number(isolation.wrongTenantMarkers) > 0 ||
|
|
Number(isolation.crossTenantMemberships) > 0
|
|
) {
|
|
throw new Error(`Refusing capacity fixture mutation: managed users are not isolated (${JSON.stringify(isolation)})`);
|
|
}
|
|
}
|
|
|
|
async function assertManagedTenantMemberships(client, tenantId, tenantSlug) {
|
|
const result = await client.query(
|
|
`
|
|
select count(*)::integer as "unmanagedMemberships"
|
|
from public.tenant_memberships tm
|
|
join public.platform_users u on u.id = tm.user_id
|
|
where tm.tenant_id = $3::uuid
|
|
and (
|
|
tm.role <> 'student'
|
|
or u.raw_profile #>> '{capacityHarness,namespace}' is distinct from $1
|
|
or u.raw_profile #>> '{capacityHarness,tenantSlug}' is distinct from $2
|
|
or u.raw_profile #>> '{capacityHarness,tenantId}' is distinct from $3::text
|
|
)
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug, tenantId],
|
|
);
|
|
if (Number(result.rows[0]?.unmanagedMemberships || 0) > 0) {
|
|
throw new Error('Refusing capacity tenant operation: dedicated tenant contains unmanaged memberships');
|
|
}
|
|
}
|
|
|
|
async function assertBatchHasNoIdentityCollisions(client, tenantId, tenantSlug, start, end) {
|
|
const result = await client.query(
|
|
`
|
|
with source as (
|
|
select
|
|
ordinal,
|
|
$1::text || ':' || $2::text || ':' || lpad(ordinal::text, 6, '0') as legacy_id
|
|
from generate_series($4::integer, $5::integer) ordinal
|
|
)
|
|
select count(*)::integer as collisions
|
|
from source
|
|
join public.platform_users u on u.legacy_id = source.legacy_id
|
|
where u.raw_profile #>> '{capacityHarness,namespace}' is distinct from $1
|
|
or u.raw_profile #>> '{capacityHarness,tenantSlug}' is distinct from $2
|
|
or u.raw_profile #>> '{capacityHarness,tenantId}' is distinct from $3::text
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug, tenantId, start, end],
|
|
);
|
|
if (Number(result.rows[0]?.collisions || 0) > 0) {
|
|
throw new Error(`Refusing capacity fixture seed: legacy identity collision in ordinals ${start}-${end}`);
|
|
}
|
|
}
|
|
|
|
async function seedBatch(client, tenantId, tenantSlug, start, end) {
|
|
await client.query('begin');
|
|
try {
|
|
await assertBatchHasNoIdentityCollisions(client, tenantId, tenantSlug, start, end);
|
|
const result = await client.query(
|
|
`
|
|
with source as materialized (
|
|
select
|
|
ordinal,
|
|
md5($1::text || ':' || $2::text || ':user:' || ordinal::text)::uuid as user_id,
|
|
$1::text || ':' || $2::text || ':' || lpad(ordinal::text, 6, '0') as legacy_id,
|
|
'capacity_' || replace($2::text, '-', '_') || '_' || lpad(ordinal::text, 6, '0') as username,
|
|
'student' || lpad(ordinal::text, 6, '0') || '@' || $2::text || '.capacity.invalid' as email,
|
|
'188' || lpad(ordinal::text, 8, '0') as phone,
|
|
'Capacity Student ' || lpad(ordinal::text, 6, '0') as name,
|
|
timestamptz '2024-01-01 00:00:00+00' + (ordinal * interval '1 second') as created_at
|
|
from generate_series($4::integer, $5::integer) ordinal
|
|
),
|
|
upsert_users as (
|
|
insert into public.platform_users (
|
|
id, legacy_id, username, email, phone, name, primary_role, status,
|
|
raw_profile, created_at, updated_at
|
|
)
|
|
select
|
|
source.user_id,
|
|
source.legacy_id,
|
|
source.username,
|
|
source.email::citext,
|
|
source.phone,
|
|
source.name,
|
|
'student',
|
|
'active',
|
|
jsonb_build_object(
|
|
'capacityHarness',
|
|
jsonb_build_object(
|
|
'namespace', $1::text,
|
|
'managed', true,
|
|
'tenantSlug', $2::text,
|
|
'tenantId', $3::text,
|
|
'ordinal', source.ordinal
|
|
)
|
|
),
|
|
source.created_at,
|
|
now()
|
|
from source
|
|
on conflict (legacy_id)
|
|
do update set
|
|
username = excluded.username,
|
|
email = excluded.email,
|
|
phone = excluded.phone,
|
|
name = excluded.name,
|
|
primary_role = 'student',
|
|
status = 'active',
|
|
raw_profile = excluded.raw_profile,
|
|
created_at = excluded.created_at,
|
|
updated_at = now()
|
|
where public.platform_users.raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and public.platform_users.raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
and public.platform_users.raw_profile #>> '{capacityHarness,tenantId}' = $3::text
|
|
returning id
|
|
),
|
|
upsert_memberships as (
|
|
insert into public.tenant_memberships (
|
|
tenant_id, user_id, role, status, permissions, created_at, updated_at
|
|
)
|
|
select $3::uuid, source.user_id, 'student', 'active', '{}'::jsonb, source.created_at, now()
|
|
from source
|
|
join upsert_users users on users.id = source.user_id
|
|
on conflict (tenant_id, user_id, role)
|
|
do update set
|
|
status = 'active',
|
|
permissions = '{}'::jsonb,
|
|
created_at = excluded.created_at,
|
|
updated_at = now()
|
|
returning id
|
|
),
|
|
upsert_profiles as (
|
|
insert into public.student_profiles (
|
|
tenant_id, user_id, questions_answered_today, mastered_words_count,
|
|
stats, progress, module_selections, recent_activities, created_at, updated_at
|
|
)
|
|
select
|
|
$3::uuid,
|
|
source.user_id,
|
|
source.ordinal % 80,
|
|
source.ordinal % 5000,
|
|
jsonb_build_object('capacityHarnessOrdinal', source.ordinal),
|
|
jsonb_build_object('completionPercent', source.ordinal % 101),
|
|
'{}'::jsonb,
|
|
'[]'::jsonb,
|
|
source.created_at,
|
|
now()
|
|
from source
|
|
join upsert_users users on users.id = source.user_id
|
|
on conflict (tenant_id, user_id)
|
|
do update set
|
|
questions_answered_today = excluded.questions_answered_today,
|
|
mastered_words_count = excluded.mastered_words_count,
|
|
stats = excluded.stats,
|
|
progress = excluded.progress,
|
|
module_selections = excluded.module_selections,
|
|
recent_activities = excluded.recent_activities,
|
|
created_at = excluded.created_at,
|
|
updated_at = now()
|
|
returning id
|
|
)
|
|
select
|
|
(select count(*)::integer from upsert_users) as users,
|
|
(select count(*)::integer from upsert_memberships) as memberships,
|
|
(select count(*)::integer from upsert_profiles) as profiles
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug, tenantId, start, end],
|
|
);
|
|
const expected = end - start + 1;
|
|
const counts = result.rows[0];
|
|
for (const key of ['users', 'memberships', 'profiles']) {
|
|
if (Number(counts?.[key] || 0) !== expected) {
|
|
throw new Error(`capacity batch ${start}-${end} wrote ${counts?.[key] || 0} ${key}; expected ${expected}`);
|
|
}
|
|
}
|
|
await client.query('commit');
|
|
return counts;
|
|
} catch (error) {
|
|
await client.query('rollback').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function seedFixture(client, options) {
|
|
const tenant = await ensureManagedTenant(client, options.tenantSlug);
|
|
assertManagedTenant(tenant, options.tenantSlug);
|
|
await assertManagedTenantMemberships(client, tenant.id, options.tenantSlug);
|
|
await assertManagedUsersAreIsolated(client, tenant.id, options.tenantSlug);
|
|
|
|
const before = await managedFixtureCounts(client, tenant.id, options.tenantSlug);
|
|
if (Number(before.platformUsers) > options.count) {
|
|
throw new Error(
|
|
`managed fixture already has ${before.platformUsers} users; cleanup is required before reducing it to ${options.count}`,
|
|
);
|
|
}
|
|
|
|
const batches = [];
|
|
for (let start = 1; start <= options.count; start += options.batchSize) {
|
|
const end = Math.min(options.count, start + options.batchSize - 1);
|
|
const counts = await seedBatch(client, tenant.id, options.tenantSlug, start, end);
|
|
batches.push({ start, end, ...counts });
|
|
console.error(`[capacity] seeded ${end}/${options.count} students`);
|
|
}
|
|
|
|
await client.query('analyze public.platform_users');
|
|
await client.query('analyze public.tenant_memberships');
|
|
await client.query('analyze public.student_profiles');
|
|
|
|
const after = await managedFixtureCounts(client, tenant.id, options.tenantSlug);
|
|
for (const key of ['platformUsers', 'tenantMemberships', 'studentProfiles']) {
|
|
if (Number(after[key]) !== options.count) {
|
|
throw new Error(`capacity fixture validation failed: ${key}=${after[key]}, expected ${options.count}`);
|
|
}
|
|
}
|
|
|
|
return { tenant, before, after, batches };
|
|
}
|
|
|
|
function buildStudentListQuery({ keyword = false, cursor = false, explain = false } = {}) {
|
|
const params = { tenantId: 1, status: 2 };
|
|
let nextParam = 3;
|
|
const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2'];
|
|
if (keyword) {
|
|
params.keyword = nextParam++;
|
|
filters.push(`(
|
|
coalesce(u.username, '') || ' ' ||
|
|
coalesce(u.name, '') || ' ' ||
|
|
coalesce(u.phone, '') || ' ' ||
|
|
coalesce(u.email::text, '')
|
|
) ilike $${params.keyword} escape '\\'`);
|
|
}
|
|
if (cursor) {
|
|
params.cursorCreatedAt = nextParam++;
|
|
params.cursorMembershipId = nextParam++;
|
|
filters.push(
|
|
`(tm.created_at, tm.id) < ($${params.cursorCreatedAt}::timestamptz, $${params.cursorMembershipId}::uuid)`,
|
|
);
|
|
}
|
|
params.limit = nextParam;
|
|
|
|
const sql = `
|
|
with student_page as materialized (
|
|
select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at
|
|
from public.tenant_memberships tm
|
|
join public.platform_users u on u.id = tm.user_id
|
|
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
|
where ${filters.join(' and ')}
|
|
order by tm.created_at desc, tm.id desc
|
|
limit $${params.limit}
|
|
),
|
|
class_agg as (
|
|
select tcm.tenant_id, tcm.user_id,
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'classId', tc.id,
|
|
'className', tc.name,
|
|
'classCode', tc.code,
|
|
'memberType', tcm.member_type,
|
|
'joinedAt', tcm.joined_at
|
|
)
|
|
order by tc.sort_order asc, tc.created_at desc
|
|
) filter (where tcm.status = 'active') as classes
|
|
from public.tenant_class_members tcm
|
|
join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id
|
|
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
|
|
where tcm.tenant_id = $1 and tcm.member_type = 'student'
|
|
group by tcm.tenant_id, tcm.user_id
|
|
)
|
|
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
|
|
tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt",
|
|
tm.updated_at as "memberUpdatedAt",
|
|
u.username, u.email::text as email, u.phone, u.name,
|
|
null::text as "avatarUrl", u.primary_role as "primaryRole",
|
|
u.last_seen_at as "lastSeenAt",
|
|
sp.id as "profileId", sp.region_id as "regionId", r.name as "regionName",
|
|
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
|
|
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
|
|
sp.questions_answered_today as "questionsAnsweredToday",
|
|
sp.mastered_words_count as "masteredWordsCount",
|
|
sp.last_check_in_date as "lastCheckInDate",
|
|
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
|
|
coalesce(ca.classes, '[]'::jsonb) as classes
|
|
from student_page tm
|
|
join public.platform_users u on u.id = tm.user_id
|
|
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
|
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
|
|
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
|
|
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
|
|
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
|
|
order by tm.created_at desc, tm.id desc
|
|
`;
|
|
return {
|
|
sql: explain ? `explain (analyze, buffers, format json) ${sql}` : sql,
|
|
params,
|
|
};
|
|
}
|
|
|
|
async function assertBenchmarkIndexes(client) {
|
|
const names = [
|
|
'idx_memberships_student_keyset_page',
|
|
'idx_platform_users_identity_search_trgm',
|
|
];
|
|
const result = await client.query(
|
|
`
|
|
select
|
|
index_name,
|
|
to_regclass('public.' || index_name)::text as relation,
|
|
pg_get_indexdef(to_regclass('public.' || index_name)) as definition
|
|
from unnest($1::text[]) index_name
|
|
order by index_name
|
|
`,
|
|
[names],
|
|
);
|
|
const missing = result.rows.filter(row => !row.relation || !row.definition);
|
|
if (missing.length) {
|
|
throw new Error(`required student capacity indexes are missing: ${missing.map(row => row.index_name).join(', ')}`);
|
|
}
|
|
return result.rows.map(row => ({ name: row.index_name, definition: normalizePlanText(row.definition) }));
|
|
}
|
|
|
|
async function loadBenchmarkFixture(client, tenantSlug) {
|
|
const tenant = await loadManagedTenant(client, tenantSlug);
|
|
assertManagedTenant(tenant, tenantSlug);
|
|
await assertManagedTenantMemberships(client, tenant.id, tenantSlug);
|
|
await assertManagedUsersAreIsolated(client, tenant.id, tenantSlug);
|
|
const counts = await managedFixtureCounts(client, tenant.id, tenantSlug);
|
|
if (
|
|
Number(counts.platformUsers) < 10 ||
|
|
counts.platformUsers !== counts.tenantMemberships ||
|
|
counts.platformUsers !== counts.studentProfiles
|
|
) {
|
|
throw new Error(`capacity fixture is incomplete: ${JSON.stringify(counts)}`);
|
|
}
|
|
return { tenant, counts, count: Number(counts.platformUsers) };
|
|
}
|
|
|
|
async function loadBenchmarkTargets(client, tenantId, tenantSlug, count) {
|
|
const searchOrdinal = Math.max(1, Math.floor(count / 2));
|
|
const deepOrdinal = Math.max(2, Math.floor(count * 0.1));
|
|
const result = await client.query(
|
|
`
|
|
select
|
|
u.legacy_id as "legacyId",
|
|
u.name,
|
|
u.phone,
|
|
u.email::text as email,
|
|
tm.id as "membershipId",
|
|
tm.created_at as "createdAt"
|
|
from public.platform_users u
|
|
join public.tenant_memberships tm
|
|
on tm.tenant_id = $1 and tm.user_id = u.id and tm.role = 'student'
|
|
where u.legacy_id = any($2::text[])
|
|
`,
|
|
[tenantId, [studentLegacyId(tenantSlug, searchOrdinal), studentLegacyId(tenantSlug, deepOrdinal)]],
|
|
);
|
|
const byLegacyId = new Map(result.rows.map(row => [row.legacyId, row]));
|
|
const search = byLegacyId.get(studentLegacyId(tenantSlug, searchOrdinal));
|
|
const deep = byLegacyId.get(studentLegacyId(tenantSlug, deepOrdinal));
|
|
if (!search || !deep) throw new Error('capacity benchmark target rows are missing');
|
|
return {
|
|
searchOrdinal,
|
|
deepOrdinal,
|
|
approximateDeepCursorOffset: count - deepOrdinal,
|
|
search,
|
|
deep,
|
|
};
|
|
}
|
|
|
|
function explainSummary(document) {
|
|
const root = Array.isArray(document) ? document[0] : document;
|
|
const plan = root?.Plan || {};
|
|
const nodeTypes = new Set();
|
|
const indexes = new Set();
|
|
function visit(node) {
|
|
if (!node || typeof node !== 'object') return;
|
|
if (node['Node Type']) nodeTypes.add(node['Node Type']);
|
|
if (node['Index Name']) indexes.add(node['Index Name']);
|
|
for (const child of node.Plans || []) visit(child);
|
|
}
|
|
visit(plan);
|
|
return {
|
|
planningTimeMs: round(root?.['Planning Time']),
|
|
executionTimeMs: round(root?.['Execution Time']),
|
|
actualRows: Number(plan['Actual Rows'] || 0),
|
|
nodeTypes: [...nodeTypes],
|
|
indexes: [...indexes],
|
|
buffers: {
|
|
sharedHit: Number(plan['Shared Hit Blocks'] || 0),
|
|
sharedRead: Number(plan['Shared Read Blocks'] || 0),
|
|
sharedDirtied: Number(plan['Shared Dirtied Blocks'] || 0),
|
|
sharedWritten: Number(plan['Shared Written Blocks'] || 0),
|
|
localHit: Number(plan['Local Hit Blocks'] || 0),
|
|
localRead: Number(plan['Local Read Blocks'] || 0),
|
|
tempRead: Number(plan['Temp Read Blocks'] || 0),
|
|
tempWritten: Number(plan['Temp Written Blocks'] || 0),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function benchmarkCase(client, definition, options) {
|
|
const query = buildStudentListQuery(definition.shape);
|
|
for (let index = 0; index < options.warmupIterations; index += 1) {
|
|
await client.query(query.sql, definition.values);
|
|
}
|
|
|
|
const latencies = [];
|
|
const rowCounts = [];
|
|
for (let index = 0; index < options.iterations; index += 1) {
|
|
const startedAt = performance.now();
|
|
const result = await client.query(query.sql, definition.values);
|
|
latencies.push(performance.now() - startedAt);
|
|
rowCounts.push(result.rowCount);
|
|
}
|
|
|
|
const explainQuery = buildStudentListQuery({ ...definition.shape, explain: true });
|
|
const explainResult = await client.query(explainQuery.sql, definition.values);
|
|
const explain = explainResult.rows[0]?.['QUERY PLAN'];
|
|
return {
|
|
id: definition.id,
|
|
label: definition.label,
|
|
searchField: definition.searchField || null,
|
|
keyword: definition.keyword || null,
|
|
cursor: definition.cursor || null,
|
|
iterations: options.iterations,
|
|
rows: {
|
|
min: Math.min(...rowCounts),
|
|
max: Math.max(...rowCounts),
|
|
},
|
|
latencyMs: {
|
|
min: round(Math.min(...latencies)),
|
|
p50: round(percentile(latencies, 50)),
|
|
p95: round(percentile(latencies, 95)),
|
|
max: round(Math.max(...latencies)),
|
|
},
|
|
explain: {
|
|
summary: explainSummary(explain),
|
|
document: explain,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function benchmarkFixture(client, options, safety) {
|
|
const fixture = await loadBenchmarkFixture(client, options.tenantSlug);
|
|
const indexes = await assertBenchmarkIndexes(client);
|
|
const targets = await loadBenchmarkTargets(
|
|
client,
|
|
fixture.tenant.id,
|
|
options.tenantSlug,
|
|
fixture.count,
|
|
);
|
|
const limitValue = options.pageLimit + 1;
|
|
const nameKeyword = String(targets.search.name || '').replace(/^Capacity /, '');
|
|
const phoneKeyword = String(targets.search.phone || '').slice(-7);
|
|
const emailKeyword = String(targets.search.email || '').match(/\d{6}@/)?.[0] || String(targets.search.email || '');
|
|
|
|
const definitions = [
|
|
{
|
|
id: 'first-page',
|
|
label: 'Tenant student first page',
|
|
shape: {},
|
|
values: [fixture.tenant.id, 'active', limitValue],
|
|
},
|
|
{
|
|
id: 'deep-cursor',
|
|
label: 'Tenant student deep cursor page',
|
|
shape: { cursor: true },
|
|
values: [fixture.tenant.id, 'active', targets.deep.createdAt, targets.deep.membershipId, limitValue],
|
|
cursor: {
|
|
approximateOffset: targets.approximateDeepCursorOffset,
|
|
anchorOrdinal: targets.deepOrdinal,
|
|
},
|
|
},
|
|
{
|
|
id: 'name-substring',
|
|
label: 'Student name substring search',
|
|
searchField: 'name',
|
|
keyword: nameKeyword,
|
|
shape: { keyword: true },
|
|
values: [fixture.tenant.id, 'active', containsSearchPattern(nameKeyword), limitValue],
|
|
},
|
|
{
|
|
id: 'phone-substring',
|
|
label: 'Student phone substring search',
|
|
searchField: 'phone',
|
|
keyword: phoneKeyword,
|
|
shape: { keyword: true },
|
|
values: [fixture.tenant.id, 'active', containsSearchPattern(phoneKeyword), limitValue],
|
|
},
|
|
{
|
|
id: 'email-substring',
|
|
label: 'Student email substring search',
|
|
searchField: 'email',
|
|
keyword: emailKeyword,
|
|
shape: { keyword: true },
|
|
values: [fixture.tenant.id, 'active', containsSearchPattern(emailKeyword), limitValue],
|
|
},
|
|
];
|
|
|
|
const cases = [];
|
|
for (const definition of definitions) {
|
|
console.error(`[capacity] benchmarking ${definition.id}`);
|
|
cases.push(await benchmarkCase(client, definition, options));
|
|
}
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: 'tenant-student-capacity',
|
|
generatedAt: new Date().toISOString(),
|
|
safety: {
|
|
databaseEnvironment: safety.environment,
|
|
databaseTarget: safety.target,
|
|
namespace: CAPACITY_NAMESPACE,
|
|
tenantSlug: options.tenantSlug,
|
|
productionAllowed: false,
|
|
},
|
|
fixture: {
|
|
platformUsers: Number(fixture.counts.platformUsers),
|
|
tenantMemberships: Number(fixture.counts.tenantMemberships),
|
|
studentProfiles: Number(fixture.counts.studentProfiles),
|
|
},
|
|
config: {
|
|
iterations: options.iterations,
|
|
warmupIterations: options.warmupIterations,
|
|
pageLimit: options.pageLimit,
|
|
rawQueryLimit: limitValue,
|
|
deepCursorApproximateOffset: targets.approximateDeepCursorOffset,
|
|
},
|
|
indexes,
|
|
cases,
|
|
limitations: [
|
|
'Synthetic non-production data only.',
|
|
'Latency samples are warm-cache observations from the current test host, not a production SLA.',
|
|
'The harness validates the unscoped tenant-admin student list shape; class/region filters require separate workload evidence.',
|
|
],
|
|
};
|
|
}
|
|
|
|
function reportMarkdown(report) {
|
|
const lines = [
|
|
'# Tenant Student Capacity Evidence',
|
|
'',
|
|
`Generated: ${report.generatedAt}`,
|
|
'',
|
|
`Database environment: \`${report.safety.databaseEnvironment}\``,
|
|
'',
|
|
`Dedicated tenant: \`${report.safety.tenantSlug}\``,
|
|
'',
|
|
`Managed rows: ${report.fixture.platformUsers} platform users / ${report.fixture.tenantMemberships} memberships / ${report.fixture.studentProfiles} profiles`,
|
|
'',
|
|
...(report.timingsMs
|
|
? [
|
|
`Harness timings: ${Object.entries(report.timingsMs).map(([key, value]) => `${key}=${value}ms`).join(', ')}`,
|
|
'',
|
|
]
|
|
: []),
|
|
'| Case | Rows | P50 ms | P95 ms | EXPLAIN ms | Shared hit/read | Plan indexes |',
|
|
'| --- | ---: | ---: | ---: | ---: | ---: | --- |',
|
|
];
|
|
for (const item of report.cases) {
|
|
const summary = item.explain.summary;
|
|
lines.push(
|
|
`| ${item.id} | ${item.rows.min}-${item.rows.max} | ${item.latencyMs.p50} | ${item.latencyMs.p95} | ${summary.executionTimeMs} | ${summary.buffers.sharedHit}/${summary.buffers.sharedRead} | ${summary.indexes.join(', ') || 'none'} |`,
|
|
);
|
|
}
|
|
lines.push('', '## Index Definitions', '');
|
|
for (const index of report.indexes) {
|
|
lines.push(`### ${index.name}`, '', '```sql', index.definition, '```', '');
|
|
}
|
|
lines.push(
|
|
'## Interpretation Limits',
|
|
'',
|
|
...report.limitations.map(item => `- ${item}`),
|
|
'',
|
|
'The full JSON report contains PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` documents for every case.',
|
|
'',
|
|
);
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function fileTimestamp(date = new Date()) {
|
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
}
|
|
|
|
async function writeReport(report, outputDir) {
|
|
const absoluteOutputDir = path.resolve(outputDir);
|
|
await fs.mkdir(absoluteOutputDir, { recursive: true });
|
|
const stamp = fileTimestamp(new Date(report.generatedAt));
|
|
const jsonPath = path.join(absoluteOutputDir, `tenant-student-capacity-${stamp}.json`);
|
|
const markdownPath = path.join(absoluteOutputDir, `tenant-student-capacity-${stamp}.md`);
|
|
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
await fs.writeFile(markdownPath, reportMarkdown(report), 'utf8');
|
|
return { jsonPath, markdownPath };
|
|
}
|
|
|
|
async function cleanupManagedFixture(client, tenantSlug) {
|
|
const tenant = await loadManagedTenant(client, tenantSlug);
|
|
let tenantId = null;
|
|
if (tenant) {
|
|
assertManagedTenant(tenant, tenantSlug);
|
|
tenantId = tenant.id;
|
|
await assertManagedTenantMemberships(client, tenant.id, tenantSlug);
|
|
await assertManagedUsersAreIsolated(client, tenant.id, tenantSlug);
|
|
} else {
|
|
const orphanSafety = await client.query(
|
|
`
|
|
select count(*)::integer as unsafe
|
|
from public.platform_users u
|
|
where u.raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and u.raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
and (
|
|
u.auth_user_id is not null
|
|
or exists (select 1 from public.tenant_memberships tm where tm.user_id = u.id)
|
|
)
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug],
|
|
);
|
|
if (Number(orphanSafety.rows[0]?.unsafe || 0) > 0) {
|
|
throw new Error('Refusing orphan capacity cleanup: managed users still have auth or tenant membership references');
|
|
}
|
|
}
|
|
|
|
let deletedTenant = 0;
|
|
if (tenantId) {
|
|
const deleted = await client.query(
|
|
`
|
|
delete from public.tenants
|
|
where id = $1
|
|
and metadata #>> '{capacityHarness,namespace}' = $2
|
|
and metadata #>> '{capacityHarness,tenantSlug}' = $3
|
|
`,
|
|
[tenantId, CAPACITY_NAMESPACE, tenantSlug],
|
|
);
|
|
deletedTenant = deleted.rowCount;
|
|
}
|
|
|
|
let deletedUsers = 0;
|
|
while (true) {
|
|
const deleted = await client.query(
|
|
`
|
|
with doomed as (
|
|
select id
|
|
from public.platform_users
|
|
where raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
and auth_user_id is null
|
|
and not exists (
|
|
select 1 from public.tenant_memberships tm where tm.user_id = public.platform_users.id
|
|
)
|
|
order by id
|
|
limit 5000
|
|
)
|
|
delete from public.platform_users users
|
|
using doomed
|
|
where users.id = doomed.id
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug],
|
|
);
|
|
deletedUsers += deleted.rowCount;
|
|
if (deleted.rowCount === 0) break;
|
|
}
|
|
|
|
const remaining = await client.query(
|
|
`
|
|
with managed_users as (
|
|
select id
|
|
from public.platform_users
|
|
where raw_profile #>> '{capacityHarness,namespace}' = $1
|
|
and raw_profile #>> '{capacityHarness,tenantSlug}' = $2
|
|
)
|
|
select
|
|
(
|
|
select count(*)::integer
|
|
from public.tenants
|
|
where slug = $2::citext
|
|
and metadata #>> '{capacityHarness,namespace}' = $1
|
|
and metadata #>> '{capacityHarness,tenantSlug}' = $2
|
|
) as tenants,
|
|
(select count(*)::integer from managed_users) as "platformUsers",
|
|
(
|
|
select count(*)::integer
|
|
from public.tenant_memberships membership
|
|
join managed_users users on users.id = membership.user_id
|
|
) as memberships,
|
|
(
|
|
select count(*)::integer
|
|
from public.student_profiles profile
|
|
join managed_users users on users.id = profile.user_id
|
|
) as profiles
|
|
`,
|
|
[CAPACITY_NAMESPACE, tenantSlug],
|
|
);
|
|
const remainingCounts = remaining.rows[0] || {};
|
|
const cleanupVerified = Object.values(remainingCounts).every(value => Number(value || 0) === 0);
|
|
if (!cleanupVerified) {
|
|
throw new Error(`capacity cleanup left managed rows behind: ${JSON.stringify(remainingCounts)}`);
|
|
}
|
|
return { deletedTenant, deletedUsers, cleanupVerified, remaining: remainingCounts };
|
|
}
|
|
|
|
async function runDatabaseMode(options) {
|
|
const totalStartedAt = performance.now();
|
|
safeTarget(options.databaseUrl);
|
|
const client = new Client({
|
|
connectionString: options.databaseUrl,
|
|
application_name: 'tiku-tenant-student-capacity',
|
|
});
|
|
await client.connect();
|
|
let locked = false;
|
|
try {
|
|
const safety = await assertDestructiveTestDatabase({
|
|
client,
|
|
databaseUrl: options.databaseUrl,
|
|
confirmation: options.confirmation,
|
|
operation: `tenant student capacity ${options.mode}`,
|
|
});
|
|
await client.query(`select set_config('lock_timeout', '5s', false)`);
|
|
await client.query(`select set_config('statement_timeout', '300s', false)`);
|
|
await acquireHarnessLock(client, options.tenantSlug);
|
|
locked = true;
|
|
|
|
if (options.mode === 'cleanup') {
|
|
const cleanupStartedAt = performance.now();
|
|
const cleanup = await cleanupManagedFixture(client, options.tenantSlug);
|
|
return {
|
|
mode: options.mode,
|
|
cleanup,
|
|
safety,
|
|
timingsMs: {
|
|
cleanup: round(performance.now() - cleanupStartedAt),
|
|
total: round(performance.now() - totalStartedAt),
|
|
},
|
|
};
|
|
}
|
|
|
|
let seed = null;
|
|
let report = null;
|
|
let reportPaths = null;
|
|
let primaryError = null;
|
|
const timingsMs = {};
|
|
try {
|
|
if (['seed', 'run', 'evidence', 'smoke'].includes(options.mode)) {
|
|
const seedStartedAt = performance.now();
|
|
seed = await seedFixture(client, options);
|
|
timingsMs.seed = round(performance.now() - seedStartedAt);
|
|
}
|
|
if (['benchmark', 'run', 'evidence', 'smoke'].includes(options.mode)) {
|
|
const benchmarkStartedAt = performance.now();
|
|
report = await benchmarkFixture(client, options, safety);
|
|
timingsMs.benchmark = round(performance.now() - benchmarkStartedAt);
|
|
report.timingsMs = { ...timingsMs };
|
|
if (options.mode !== 'evidence') reportPaths = await writeReport(report, options.outputDir);
|
|
}
|
|
} catch (error) {
|
|
primaryError = error;
|
|
}
|
|
|
|
let cleanup = null;
|
|
if (['smoke', 'evidence'].includes(options.mode)) {
|
|
try {
|
|
const cleanupStartedAt = performance.now();
|
|
cleanup = await cleanupManagedFixture(client, options.tenantSlug);
|
|
timingsMs.cleanup = round(performance.now() - cleanupStartedAt);
|
|
} catch (cleanupError) {
|
|
if (primaryError) {
|
|
primaryError.message += `; cleanup also failed: ${cleanupError.message}`;
|
|
} else {
|
|
primaryError = cleanupError;
|
|
}
|
|
}
|
|
}
|
|
if (primaryError) throw primaryError;
|
|
|
|
if (report && options.mode === 'evidence') {
|
|
report.cleanup = cleanup;
|
|
report.timingsMs = {
|
|
...timingsMs,
|
|
total: round(performance.now() - totalStartedAt),
|
|
};
|
|
reportPaths = await writeReport(report, options.outputDir);
|
|
}
|
|
|
|
return {
|
|
mode: options.mode,
|
|
safety,
|
|
seed: seed ? { tenantId: seed.tenant.id, counts: seed.after, batches: seed.batches.length } : null,
|
|
report: report ? { fixture: report.fixture, paths: reportPaths } : null,
|
|
cleanup,
|
|
timingsMs: {
|
|
...timingsMs,
|
|
total: round(performance.now() - totalStartedAt),
|
|
},
|
|
};
|
|
} finally {
|
|
if (locked) await releaseHarnessLock(client, options.tenantSlug).catch(() => undefined);
|
|
await client.end().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
const options = parseCapacityOptions(argv, env);
|
|
if (options.mode === 'plan') {
|
|
console.log(JSON.stringify(capacityPlan(options), null, 2));
|
|
return;
|
|
}
|
|
const result = await runDatabaseMode(options);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
|
|
if (import.meta.url === invokedPath) {
|
|
main().catch(error => {
|
|
console.error(`[capacity] ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
export {
|
|
buildStudentListQuery,
|
|
capacityPlan,
|
|
containsSearchPattern,
|
|
reportMarkdown,
|
|
safeTarget,
|
|
studentLegacyId,
|
|
};
|