mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): classify legacy secret config migration
This commit is contained in:
@@ -45,6 +45,11 @@
|
||||
"require": "./dist/legacy-adoption/legacyCrontabInspection.js",
|
||||
"default": "./dist/legacy-adoption/legacyCrontabInspection.js"
|
||||
},
|
||||
"./reconciliation-secret-and-config-inspection": {
|
||||
"types": "./dist/legacy-adoption/secret-and-config/environmentInspection.d.ts",
|
||||
"require": "./dist/legacy-adoption/secret-and-config/environmentInspection.js",
|
||||
"default": "./dist/legacy-adoption/secret-and-config/environmentInspection.js"
|
||||
},
|
||||
"./reconciliation-automation-decision": {
|
||||
"types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts",
|
||||
"require": "./dist/legacy-adoption/reconciliationAutomationDecision.js",
|
||||
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export const MAX_EDGE_LEGACY_ENVIRONMENT_ROWS = 10_000;
|
||||
export const MAX_STANDALONE_LEGACY_ENVIRONMENT_ROWS = 100_000;
|
||||
export const MAX_LEGACY_ENVIRONMENT_BINDINGS = 256;
|
||||
export const MAX_LEGACY_ENVIRONMENT_VALUE_BYTES = 16 * 1024;
|
||||
export const MAX_LEGACY_ENVIRONMENT_EFFECTIVE_BYTES = 64 * 1024;
|
||||
export const MAX_EDGE_LEGACY_DISABLED_ENVIRONMENTS = 128;
|
||||
export const MAX_STANDALONE_LEGACY_DISABLED_ENVIRONMENTS = 512;
|
||||
|
||||
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
||||
const COLUMNS = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'value',
|
||||
'status',
|
||||
'isPinned',
|
||||
'position',
|
||||
'createdAt',
|
||||
] as const);
|
||||
|
||||
type Column = (typeof COLUMNS)[number];
|
||||
type LegacyRow = Record<Column, unknown>;
|
||||
|
||||
export type LegacyEnvironmentTableState =
|
||||
| 'absent'
|
||||
| 'supported'
|
||||
| 'unsupported_schema'
|
||||
| 'budget_exceeded';
|
||||
|
||||
export type LegacyEnvironmentRowDisposition =
|
||||
| 'active_member'
|
||||
| 'preserve_disabled'
|
||||
| 'manual_required';
|
||||
|
||||
export type LegacyEnvironmentRowReason =
|
||||
| 'identifier_invalid'
|
||||
| 'name_invalid'
|
||||
| 'value_invalid'
|
||||
| 'status_invalid'
|
||||
| 'ordering_metadata_invalid';
|
||||
|
||||
export interface LegacyEnvironmentRowInspection {
|
||||
readonly rowOrdinal: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly disposition: LegacyEnvironmentRowDisposition;
|
||||
readonly reasons: readonly LegacyEnvironmentRowReason[];
|
||||
}
|
||||
|
||||
export interface LegacyEnvironmentActiveBindingCandidate {
|
||||
readonly kind: 'active_binding';
|
||||
readonly environmentName: string;
|
||||
readonly value: string;
|
||||
readonly sourceRowCount: number;
|
||||
readonly sourceSetDigest: string;
|
||||
readonly candidateDigest: string;
|
||||
}
|
||||
|
||||
export interface LegacyEnvironmentDisabledPreservationCandidate {
|
||||
readonly kind: 'disabled_preservation';
|
||||
readonly environmentName: string;
|
||||
readonly value: string;
|
||||
readonly sourceRowOrdinal: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly candidateDigest: string;
|
||||
}
|
||||
|
||||
export type LegacyEnvironmentCandidate =
|
||||
| LegacyEnvironmentActiveBindingCandidate
|
||||
| LegacyEnvironmentDisabledPreservationCandidate;
|
||||
|
||||
export interface LegacyEnvironmentInventory {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-environment-inventory';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly tableState: LegacyEnvironmentTableState;
|
||||
readonly rowCount: number;
|
||||
readonly activeRowCount: number;
|
||||
readonly disabledRowCount: number;
|
||||
readonly manualRowCount: number;
|
||||
readonly activeGroupCount: number;
|
||||
readonly bindingReadyCount: number;
|
||||
readonly preservationReadyCount: number;
|
||||
readonly manualGroupCount: number;
|
||||
readonly mutationReady: boolean;
|
||||
readonly inventoryDigest: string;
|
||||
}
|
||||
|
||||
export interface VisitLegacyEnvironmentOptions {
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly visitRow?: (row: Readonly<LegacyEnvironmentRowInspection>) => void;
|
||||
readonly visitCandidate?: (
|
||||
candidate: Readonly<LegacyEnvironmentCandidate>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class LegacyEnvironmentInspectionError extends Error {
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Legacy environment inspection failed: ${message}`);
|
||||
this.name = 'LegacyEnvironmentInspectionError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveGroup {
|
||||
readonly name: string;
|
||||
readonly rowDigests: string[];
|
||||
readonly values: string[];
|
||||
valueBytes: number;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
function digest(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function scalarEvidence(value: unknown): readonly unknown[] {
|
||||
if (value === null) return Object.freeze(['null']);
|
||||
if (value === undefined) return Object.freeze(['missing']);
|
||||
if (typeof value === 'string') {
|
||||
return Object.freeze([
|
||||
'text',
|
||||
Buffer.byteLength(value, 'utf8'),
|
||||
createHash('sha256').update(value).digest('hex'),
|
||||
]);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Object.freeze([
|
||||
'number',
|
||||
Number.isFinite(value)
|
||||
? String(Object.is(value, -0) ? 0 : value)
|
||||
: 'invalid',
|
||||
]);
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return Object.freeze(['bigint', value.toString()]);
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return Object.freeze([
|
||||
'blob',
|
||||
value.byteLength,
|
||||
createHash('sha256').update(value).digest('hex'),
|
||||
]);
|
||||
}
|
||||
return Object.freeze(['unsupported', typeof value]);
|
||||
}
|
||||
|
||||
function tableNames(client: DatabaseSync): readonly string[] {
|
||||
return Object.freeze(
|
||||
(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "name" FROM "sqlite_schema"
|
||||
WHERE "type" = 'table' AND "name" NOT LIKE 'sqlite_%'
|
||||
ORDER BY "name"`,
|
||||
)
|
||||
.all() as { readonly name?: unknown }[]
|
||||
)
|
||||
.map(({ name }) => name)
|
||||
.filter((name): name is string => typeof name === 'string'),
|
||||
);
|
||||
}
|
||||
|
||||
function columns(client: DatabaseSync): ReadonlySet<string> {
|
||||
return new Set(
|
||||
(
|
||||
client.prepare('PRAGMA table_info("Envs")').all() as {
|
||||
readonly name?: unknown;
|
||||
}[]
|
||||
)
|
||||
.map(({ name }) => name)
|
||||
.filter((name): name is string => typeof name === 'string'),
|
||||
);
|
||||
}
|
||||
|
||||
function rowCount(client: DatabaseSync): number {
|
||||
const row = client.prepare('SELECT COUNT(*) AS "count" FROM "Envs"').get() as
|
||||
| { readonly count?: unknown }
|
||||
| undefined;
|
||||
if (!row || !Number.isSafeInteger(row.count) || (row.count as number) < 0) {
|
||||
throw new LegacyEnvironmentInspectionError('row count is invalid');
|
||||
}
|
||||
return row.count as number;
|
||||
}
|
||||
|
||||
function emptyInventory(
|
||||
profile: 'edge' | 'standalone',
|
||||
tableState: Exclude<LegacyEnvironmentTableState, 'supported'>,
|
||||
count: number,
|
||||
schemaColumns: readonly string[],
|
||||
): Readonly<LegacyEnvironmentInventory> {
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-legacy-environment-inventory' as const,
|
||||
profile,
|
||||
tableState,
|
||||
rowCount: count,
|
||||
activeRowCount: 0,
|
||||
disabledRowCount: 0,
|
||||
manualRowCount: count,
|
||||
activeGroupCount: 0,
|
||||
bindingReadyCount: 0,
|
||||
preservationReadyCount: 0,
|
||||
manualGroupCount: 0,
|
||||
mutationReady: tableState === 'absent',
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
inventoryDigest: digest(
|
||||
'qinglong3.legacy-environment-inventory.v1',
|
||||
Object.freeze({ ...payload, schemaColumns }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function selectSql(schema: ReadonlySet<string>): string {
|
||||
const projection = COLUMNS.map((column) => {
|
||||
if (schema.has(column)) return `"${column}"`;
|
||||
if (column === 'status' || column === 'isPinned') {
|
||||
return `0 AS "${column}"`;
|
||||
}
|
||||
return `NULL AS "${column}"`;
|
||||
});
|
||||
const pinned = schema.has('isPinned') ? 'COALESCE("isPinned", 0)' : '0';
|
||||
const position = schema.has('position') ? '"position"' : 'NULL';
|
||||
const createdAt = schema.has('createdAt') ? '"createdAt"' : 'NULL';
|
||||
return `SELECT ${projection.join(', ')} FROM "Envs"
|
||||
ORDER BY ${pinned} DESC, ${position} DESC, ${createdAt} ASC, "id" ASC`;
|
||||
}
|
||||
|
||||
function reasons(row: LegacyRow): readonly LegacyEnvironmentRowReason[] {
|
||||
const selected: LegacyEnvironmentRowReason[] = [];
|
||||
if (!Number.isSafeInteger(row.id) || (row.id as number) < 1) {
|
||||
selected.push('identifier_invalid');
|
||||
}
|
||||
if (
|
||||
typeof row.name !== 'string' ||
|
||||
!ENVIRONMENT_NAME.test(row.name) ||
|
||||
row.name.startsWith('QL3_')
|
||||
) {
|
||||
selected.push('name_invalid');
|
||||
}
|
||||
if (
|
||||
typeof row.value !== 'string' ||
|
||||
row.value.includes('\0') ||
|
||||
Buffer.byteLength(row.value, 'utf8') > MAX_LEGACY_ENVIRONMENT_VALUE_BYTES
|
||||
) {
|
||||
selected.push('value_invalid');
|
||||
}
|
||||
if (row.status !== 0 && row.status !== 1) selected.push('status_invalid');
|
||||
if (
|
||||
(row.isPinned !== 0 && row.isPinned !== 1) ||
|
||||
(row.position !== null &&
|
||||
(typeof row.position !== 'number' || !Number.isFinite(row.position))) ||
|
||||
(row.createdAt !== null &&
|
||||
(typeof row.createdAt !== 'string' ||
|
||||
row.createdAt.includes('\0') ||
|
||||
Buffer.byteLength(row.createdAt, 'utf8') > 128))
|
||||
) {
|
||||
selected.push('ordering_metadata_invalid');
|
||||
}
|
||||
return Object.freeze(selected);
|
||||
}
|
||||
|
||||
function sourceDigest(row: LegacyRow): string {
|
||||
return digest(
|
||||
'qinglong3.legacy-environment-row.v1',
|
||||
COLUMNS.map((column) => [column, scalarEvidence(row[column])]),
|
||||
);
|
||||
}
|
||||
|
||||
function candidateDigest(
|
||||
candidate: Omit<LegacyEnvironmentCandidate, 'value' | 'candidateDigest'>,
|
||||
value: string,
|
||||
): string {
|
||||
return digest('qinglong3.legacy-environment-candidate.v1', {
|
||||
...candidate,
|
||||
environmentNameDigest: digest(
|
||||
'qinglong3.legacy-environment-name.v1',
|
||||
candidate.environmentName,
|
||||
),
|
||||
environmentName: undefined,
|
||||
valueBytes: Buffer.byteLength(value, 'utf8'),
|
||||
valueDigest: createHash('sha256').update(value).digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
export function visitLegacyEnvironmentAdoption(
|
||||
client: DatabaseSync,
|
||||
options: Readonly<VisitLegacyEnvironmentOptions>,
|
||||
): Readonly<LegacyEnvironmentInventory> {
|
||||
if (
|
||||
!client ||
|
||||
typeof client !== 'object' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||
(options.visitRow !== undefined &&
|
||||
typeof options.visitRow !== 'function') ||
|
||||
(options.visitCandidate !== undefined &&
|
||||
typeof options.visitCandidate !== 'function')
|
||||
) {
|
||||
throw new LegacyEnvironmentInspectionError('options are invalid');
|
||||
}
|
||||
const names = tableNames(client);
|
||||
if (!names.includes('Envs')) {
|
||||
return emptyInventory(options.profile, 'absent', 0, []);
|
||||
}
|
||||
const schema = columns(client);
|
||||
const orderedSchema = [...schema].sort();
|
||||
let count: number;
|
||||
try {
|
||||
count = rowCount(client);
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyEnvironmentInspectionError) throw error;
|
||||
throw new LegacyEnvironmentInspectionError(
|
||||
'row count is unavailable',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!['id', 'name', 'value'].every((column) => schema.has(column))) {
|
||||
return emptyInventory(
|
||||
options.profile,
|
||||
'unsupported_schema',
|
||||
count,
|
||||
orderedSchema,
|
||||
);
|
||||
}
|
||||
const maximumRows =
|
||||
options.profile === 'edge'
|
||||
? MAX_EDGE_LEGACY_ENVIRONMENT_ROWS
|
||||
: MAX_STANDALONE_LEGACY_ENVIRONMENT_ROWS;
|
||||
if (count > maximumRows) {
|
||||
return emptyInventory(
|
||||
options.profile,
|
||||
'budget_exceeded',
|
||||
count,
|
||||
orderedSchema,
|
||||
);
|
||||
}
|
||||
|
||||
const inventoryHash = createHash('sha256')
|
||||
.update('qinglong3.legacy-environment-inventory.v1\0')
|
||||
.update(
|
||||
JSON.stringify({ profile: options.profile, schema: orderedSchema }),
|
||||
);
|
||||
const groups = new Map<string, ActiveGroup>();
|
||||
const invalidActiveGroupDigests = new Set<string>();
|
||||
const disabledLimit =
|
||||
options.profile === 'edge'
|
||||
? MAX_EDGE_LEGACY_DISABLED_ENVIRONMENTS
|
||||
: MAX_STANDALONE_LEGACY_DISABLED_ENVIRONMENTS;
|
||||
let rowOrdinal = 0;
|
||||
let activeRowCount = 0;
|
||||
let disabledRowCount = 0;
|
||||
let manualRowCount = 0;
|
||||
let preservationReadyCount = 0;
|
||||
let activeValueBytes = 0;
|
||||
|
||||
try {
|
||||
for (const raw of client
|
||||
.prepare(selectSql(schema))
|
||||
.iterate() as Iterable<LegacyRow>) {
|
||||
rowOrdinal += 1;
|
||||
const digestValue = sourceDigest(raw);
|
||||
const rowReasons = reasons(raw);
|
||||
let disposition: LegacyEnvironmentRowDisposition = 'manual_required';
|
||||
if (rowReasons.length === 0 && raw.status === 0) {
|
||||
disposition = 'active_member';
|
||||
activeRowCount += 1;
|
||||
} else if (rowReasons.length === 0 && raw.status === 1) {
|
||||
disposition = 'preserve_disabled';
|
||||
disabledRowCount += 1;
|
||||
preservationReadyCount += 1;
|
||||
} else {
|
||||
manualRowCount += 1;
|
||||
if (raw.status === 0) activeRowCount += 1;
|
||||
if (raw.status === 1) disabledRowCount += 1;
|
||||
}
|
||||
const inspection = Object.freeze({
|
||||
rowOrdinal,
|
||||
sourceDigest: digestValue,
|
||||
disposition,
|
||||
reasons: rowReasons,
|
||||
});
|
||||
options.visitRow?.(inspection);
|
||||
inventoryHash.update('\0row\0').update(JSON.stringify(inspection));
|
||||
|
||||
if (raw.status !== 0) continue;
|
||||
if (
|
||||
typeof raw.name !== 'string' ||
|
||||
!ENVIRONMENT_NAME.test(raw.name) ||
|
||||
raw.name.startsWith('QL3_')
|
||||
) {
|
||||
invalidActiveGroupDigests.add(
|
||||
digest(
|
||||
'qinglong3.legacy-environment-invalid-name.v1',
|
||||
scalarEvidence(raw.name),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let group = groups.get(raw.name);
|
||||
if (!group) {
|
||||
group = {
|
||||
name: raw.name,
|
||||
rowDigests: [],
|
||||
values: [],
|
||||
valueBytes: 0,
|
||||
valid: true,
|
||||
};
|
||||
groups.set(raw.name, group);
|
||||
}
|
||||
group.rowDigests.push(digestValue);
|
||||
if (rowReasons.length > 0 || typeof raw.value !== 'string') {
|
||||
group.valid = false;
|
||||
group.values.length = 0;
|
||||
continue;
|
||||
}
|
||||
const valueBytes = Buffer.byteLength(raw.value, 'utf8');
|
||||
const separatorBytes = group.values.length === 0 ? 0 : 1;
|
||||
group.valueBytes += valueBytes + separatorBytes;
|
||||
activeValueBytes += valueBytes + separatorBytes;
|
||||
if (
|
||||
group.valueBytes > MAX_LEGACY_ENVIRONMENT_VALUE_BYTES ||
|
||||
activeValueBytes > MAX_LEGACY_ENVIRONMENT_EFFECTIVE_BYTES
|
||||
) {
|
||||
group.valid = false;
|
||||
group.values.length = 0;
|
||||
} else if (group.valid) {
|
||||
group.values.push(raw.value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyEnvironmentInspectionError) throw error;
|
||||
throw new LegacyEnvironmentInspectionError(
|
||||
'rows cannot be inspected',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (rowOrdinal !== count) {
|
||||
throw new LegacyEnvironmentInspectionError('row count drifted');
|
||||
}
|
||||
|
||||
const activeGroupCount = groups.size + invalidActiveGroupDigests.size;
|
||||
const globalBudgetExceeded =
|
||||
activeGroupCount > MAX_LEGACY_ENVIRONMENT_BINDINGS ||
|
||||
activeValueBytes > MAX_LEGACY_ENVIRONMENT_EFFECTIVE_BYTES ||
|
||||
preservationReadyCount > disabledLimit;
|
||||
let bindingReadyCount = 0;
|
||||
let manualGroupCount = invalidActiveGroupDigests.size;
|
||||
if (!globalBudgetExceeded) {
|
||||
for (const group of [...groups.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
if (!group.valid || group.values.length !== group.rowDigests.length) {
|
||||
manualGroupCount += 1;
|
||||
continue;
|
||||
}
|
||||
const value = group.values.join('&');
|
||||
const base = Object.freeze({
|
||||
kind: 'active_binding' as const,
|
||||
environmentName: group.name,
|
||||
sourceRowCount: group.rowDigests.length,
|
||||
sourceSetDigest: digest(
|
||||
'qinglong3.legacy-environment-source-set.v1',
|
||||
group.rowDigests,
|
||||
),
|
||||
});
|
||||
const candidate = Object.freeze({
|
||||
...base,
|
||||
value,
|
||||
candidateDigest: candidateDigest(base, value),
|
||||
});
|
||||
bindingReadyCount += 1;
|
||||
options.visitCandidate?.(candidate);
|
||||
inventoryHash.update('\0candidate\0').update(candidate.candidateDigest);
|
||||
}
|
||||
} else {
|
||||
manualGroupCount = activeGroupCount;
|
||||
bindingReadyCount = 0;
|
||||
preservationReadyCount = 0;
|
||||
}
|
||||
|
||||
if (!globalBudgetExceeded && preservationReadyCount > 0) {
|
||||
rowOrdinal = 0;
|
||||
for (const raw of client
|
||||
.prepare(selectSql(schema))
|
||||
.iterate() as Iterable<LegacyRow>) {
|
||||
rowOrdinal += 1;
|
||||
if (raw.status !== 1 || reasons(raw).length !== 0) continue;
|
||||
const digestValue = sourceDigest(raw);
|
||||
const base = Object.freeze({
|
||||
kind: 'disabled_preservation' as const,
|
||||
environmentName: raw.name as string,
|
||||
sourceRowOrdinal: rowOrdinal,
|
||||
sourceDigest: digestValue,
|
||||
});
|
||||
const value = raw.value as string;
|
||||
const candidate = Object.freeze({
|
||||
...base,
|
||||
value,
|
||||
candidateDigest: candidateDigest(base, value),
|
||||
});
|
||||
options.visitCandidate?.(candidate);
|
||||
inventoryHash.update('\0candidate\0').update(candidate.candidateDigest);
|
||||
}
|
||||
}
|
||||
|
||||
const mutationReady =
|
||||
!globalBudgetExceeded && manualRowCount === 0 && manualGroupCount === 0;
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-legacy-environment-inventory' as const,
|
||||
profile: options.profile,
|
||||
tableState: 'supported' as const,
|
||||
rowCount: count,
|
||||
activeRowCount,
|
||||
disabledRowCount,
|
||||
manualRowCount,
|
||||
activeGroupCount,
|
||||
bindingReadyCount,
|
||||
preservationReadyCount,
|
||||
manualGroupCount,
|
||||
mutationReady,
|
||||
});
|
||||
inventoryHash.update('\0summary\0').update(JSON.stringify(payload));
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
inventoryDigest: inventoryHash.digest('hex'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
MAX_EDGE_LEGACY_ENVIRONMENT_ROWS,
|
||||
visitLegacyEnvironmentAdoption,
|
||||
} = require('@qinglong/local-admin/reconciliation-secret-and-config-inspection');
|
||||
|
||||
function memoryDatabase(sql = '') {
|
||||
const database = new DatabaseSync(':memory:');
|
||||
if (sql) database.exec(sql);
|
||||
return database;
|
||||
}
|
||||
|
||||
function inspect(database, profile = 'edge') {
|
||||
const rows = [];
|
||||
const candidates = [];
|
||||
const inventory = visitLegacyEnvironmentAdoption(database, {
|
||||
profile,
|
||||
visitRow: (row) => rows.push(row),
|
||||
visitCandidate: (candidate) => candidates.push(candidate),
|
||||
});
|
||||
return { inventory, rows, candidates };
|
||||
}
|
||||
|
||||
test('treats an absent Envs table as a stable no-effect inventory', () => {
|
||||
const database = memoryDatabase(
|
||||
'CREATE TABLE "Crontabs" (id INTEGER PRIMARY KEY)',
|
||||
);
|
||||
try {
|
||||
const first = inspect(database);
|
||||
const second = inspect(database);
|
||||
assert.equal(first.inventory.tableState, 'absent');
|
||||
assert.equal(first.inventory.mutationReady, true);
|
||||
assert.equal(first.inventory.rowCount, 0);
|
||||
assert.equal(
|
||||
first.inventory.inventoryDigest,
|
||||
second.inventory.inventoryDigest,
|
||||
);
|
||||
assert.deepEqual(first.rows, []);
|
||||
assert.deepEqual(first.candidates, []);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reproduces legacy ordering and joins active values without exposing them in diagnostics', () => {
|
||||
const database = memoryDatabase(`
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
status INTEGER,
|
||||
position REAL,
|
||||
"isPinned" INTEGER,
|
||||
"createdAt" TEXT
|
||||
);
|
||||
INSERT INTO "Envs" VALUES
|
||||
(1, 'TOKEN', 'later-value', 0, 10, 0, '2026-01-01'),
|
||||
(2, 'TOKEN', 'pinned-value', 0, 1, 1, '2026-01-02'),
|
||||
(3, 'PLAIN', 'plain-value', 0, 9, 0, '2026-01-03'),
|
||||
(4, 'DISABLED', 'disabled-value', 1, 8, 0, '2026-01-04');
|
||||
`);
|
||||
try {
|
||||
const { inventory, rows, candidates } = inspect(database);
|
||||
assert.deepEqual(
|
||||
{
|
||||
rowCount: inventory.rowCount,
|
||||
activeRowCount: inventory.activeRowCount,
|
||||
disabledRowCount: inventory.disabledRowCount,
|
||||
activeGroupCount: inventory.activeGroupCount,
|
||||
bindingReadyCount: inventory.bindingReadyCount,
|
||||
preservationReadyCount: inventory.preservationReadyCount,
|
||||
mutationReady: inventory.mutationReady,
|
||||
},
|
||||
{
|
||||
rowCount: 4,
|
||||
activeRowCount: 3,
|
||||
disabledRowCount: 1,
|
||||
activeGroupCount: 2,
|
||||
bindingReadyCount: 2,
|
||||
preservationReadyCount: 1,
|
||||
mutationReady: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
candidates.map(({ kind, environmentName, value }) => ({
|
||||
kind,
|
||||
environmentName,
|
||||
value,
|
||||
})),
|
||||
[
|
||||
{
|
||||
kind: 'active_binding',
|
||||
environmentName: 'PLAIN',
|
||||
value: 'plain-value',
|
||||
},
|
||||
{
|
||||
kind: 'active_binding',
|
||||
environmentName: 'TOKEN',
|
||||
value: 'pinned-value&later-value',
|
||||
},
|
||||
{
|
||||
kind: 'disabled_preservation',
|
||||
environmentName: 'DISABLED',
|
||||
value: 'disabled-value',
|
||||
},
|
||||
],
|
||||
);
|
||||
const publicEvidence = JSON.stringify({ inventory, rows });
|
||||
for (const secret of [
|
||||
'later-value',
|
||||
'pinned-value',
|
||||
'plain-value',
|
||||
'disabled-value',
|
||||
'TOKEN',
|
||||
'PLAIN',
|
||||
'DISABLED',
|
||||
]) {
|
||||
assert.equal(publicEvidence.includes(secret), false);
|
||||
}
|
||||
assert.equal(
|
||||
new Set(candidates.map((value) => value.candidateDigest)).size,
|
||||
3,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for malformed rows, reserved names and effective-value overflow', () => {
|
||||
const oversized = 'x'.repeat(12 * 1024);
|
||||
const database = memoryDatabase(`
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
status INTEGER,
|
||||
position REAL,
|
||||
"isPinned" INTEGER,
|
||||
"createdAt" TEXT
|
||||
);
|
||||
`);
|
||||
const insert = database.prepare(
|
||||
'INSERT INTO "Envs" VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
insert.run(1, 'QL3_FORBIDDEN', 'secret-a', 0, 3, 0, '2026-01-01');
|
||||
insert.run(2, 'TOKEN', oversized, 0, 2, 0, '2026-01-02');
|
||||
insert.run(3, 'TOKEN', oversized, 0, 1, 0, '2026-01-03');
|
||||
insert.run(4, 'BROKEN', 'secret-b', 7, 0, 0, '2026-01-04');
|
||||
try {
|
||||
const { inventory, rows, candidates } = inspect(database);
|
||||
assert.equal(inventory.mutationReady, false);
|
||||
assert.equal(inventory.manualRowCount, 2);
|
||||
assert.equal(inventory.manualGroupCount, 2);
|
||||
assert.equal(inventory.bindingReadyCount, 0);
|
||||
assert.deepEqual(candidates, []);
|
||||
assert.deepEqual(
|
||||
rows.map(({ disposition, reasons }) => ({ disposition, reasons })),
|
||||
[
|
||||
{ disposition: 'manual_required', reasons: ['name_invalid'] },
|
||||
{ disposition: 'active_member', reasons: [] },
|
||||
{ disposition: 'active_member', reasons: [] },
|
||||
{ disposition: 'manual_required', reasons: ['status_invalid'] },
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify({ inventory, rows }).includes('secret-a'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify({ inventory, rows }).includes('secret-b'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unsupported schemas and over-budget Edge tables without scanning rows', () => {
|
||||
const unsupported = memoryDatabase(
|
||||
'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)',
|
||||
);
|
||||
try {
|
||||
const value = inspect(unsupported);
|
||||
assert.equal(value.inventory.tableState, 'unsupported_schema');
|
||||
assert.equal(value.inventory.mutationReady, false);
|
||||
} finally {
|
||||
unsupported.close();
|
||||
}
|
||||
|
||||
const overBudget = memoryDatabase(`
|
||||
CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT, value TEXT);
|
||||
WITH RECURSIVE rows(id) AS (
|
||||
SELECT 1 UNION ALL SELECT id + 1 FROM rows
|
||||
WHERE id < ${MAX_EDGE_LEGACY_ENVIRONMENT_ROWS + 1}
|
||||
)
|
||||
INSERT INTO "Envs" SELECT id, 'TOKEN_' || id, 'value' FROM rows;
|
||||
`);
|
||||
try {
|
||||
const value = inspect(overBudget);
|
||||
assert.equal(value.inventory.tableState, 'budget_exceeded');
|
||||
assert.equal(
|
||||
value.inventory.rowCount,
|
||||
MAX_EDGE_LEGACY_ENVIRONMENT_ROWS + 1,
|
||||
);
|
||||
assert.equal(value.inventory.mutationReady, false);
|
||||
assert.deepEqual(value.rows, []);
|
||||
assert.deepEqual(value.candidates, []);
|
||||
} finally {
|
||||
overBudget.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user