feat: persist authored mechanic revisions
This commit is contained in:
@@ -5,6 +5,37 @@ import type { AuthUser } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { GameDataIconValidationError } from '$lib/server/game-data-icons';
|
||||
import { ManagedImageValidationError } from '$lib/server/managed-images';
|
||||
import { MediaMutationJournal } from '$lib/server/media-journal';
|
||||
|
||||
import {
|
||||
readTalentFormMedia,
|
||||
saveTalentFromFormInTransaction,
|
||||
TalentNotFoundError,
|
||||
type SaveTalentInTransactionResult,
|
||||
} from '../../routes/isac/talents/talent-save';
|
||||
import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources';
|
||||
import {
|
||||
parseProtocolForm,
|
||||
ProtocolFormValidationError,
|
||||
ProtocolNotFoundError,
|
||||
saveProtocolInTransaction,
|
||||
} from '../../routes/isac/protocols/protocol-save';
|
||||
import {
|
||||
_saveSkillChipInTransaction,
|
||||
parseSkillChipForm,
|
||||
SkillChipFormValidationError,
|
||||
SkillChipNotFoundError,
|
||||
} from '../../routes/isac/skillchips/skillchip-save';
|
||||
import {
|
||||
parseSpecializationCombatEffectForm,
|
||||
parseSpecializationNodeForm,
|
||||
saveSpecializationCombatEffectInTransaction,
|
||||
saveSpecializationNodeInTransaction,
|
||||
SpecializationFormValidationError,
|
||||
} from '../../routes/isac/specializations/specialization-save';
|
||||
|
||||
export const DATA_MODULES = [
|
||||
'configuration',
|
||||
@@ -93,7 +124,22 @@ export function proposalActionResult(
|
||||
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
||||
}
|
||||
|
||||
type SerializedEntry =
|
||||
export const MAX_PROPOSAL_ENTRIES = 300;
|
||||
export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000;
|
||||
export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024;
|
||||
const MAX_PROPOSAL_FILE_BASE64_CHARS =
|
||||
4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3);
|
||||
export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200;
|
||||
export const MAX_PROPOSAL_FILE_NAME_CHARS = 255;
|
||||
export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255;
|
||||
|
||||
const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
const PROPOSAL_BASE64_PATTERN =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
||||
|
||||
export type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| {
|
||||
name: string;
|
||||
@@ -103,58 +149,647 @@ type SerializedEntry =
|
||||
type: string;
|
||||
};
|
||||
|
||||
type PendingDefinition = {
|
||||
export class ProposalPayloadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProposalPayloadValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PendingDefinition = {
|
||||
module: DataModule;
|
||||
action: string;
|
||||
kind: string;
|
||||
fields?: string[];
|
||||
};
|
||||
|
||||
async function serialize(form: FormData): Promise<SerializedEntry[]> {
|
||||
function validateProposalFieldName(name: string) {
|
||||
if (
|
||||
Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS ||
|
||||
PROPOSAL_CONTROL_CHARACTERS.test(name)
|
||||
)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateProposalFileMetadata(fileName: string, type: string) {
|
||||
if (
|
||||
Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS ||
|
||||
PROPOSAL_CONTROL_CHARACTERS.test(fileName)
|
||||
)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`,
|
||||
);
|
||||
if (
|
||||
Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS ||
|
||||
PROPOSAL_CONTROL_CHARACTERS.test(type)
|
||||
)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
function decodeSerializedProposalFile(value: string) {
|
||||
if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS)
|
||||
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
|
||||
if (!PROPOSAL_BASE64_PATTERN.test(value))
|
||||
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
if (!bytes.length || bytes.toString('base64') !== value)
|
||||
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function validateSerializedProposalEntries(entries: SerializedEntry[]) {
|
||||
if (!Array.isArray(entries))
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
if (entries.length > MAX_PROPOSAL_ENTRIES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
|
||||
);
|
||||
|
||||
let textBytes = 0;
|
||||
let fileBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string')
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
validateProposalFieldName(entry.name);
|
||||
if (entry.kind === 'text') {
|
||||
if (typeof entry.value !== 'string')
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal entries are invalid.',
|
||||
);
|
||||
if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
|
||||
);
|
||||
textBytes += Buffer.byteLength(entry.value, 'utf8');
|
||||
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal text exceeds the safe size limit.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (entry.kind !== 'file')
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
if (
|
||||
typeof entry.value !== 'string' ||
|
||||
typeof entry.fileName !== 'string' ||
|
||||
typeof entry.type !== 'string'
|
||||
)
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
validateProposalFileMetadata(entry.fileName, entry.type);
|
||||
const bytes = decodeSerializedProposalFile(entry.value);
|
||||
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal files exceed the safe per-file size limit.',
|
||||
);
|
||||
fileBytes += bytes.length;
|
||||
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal files exceed the safe size limit.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function serializeProposal(
|
||||
form: FormData,
|
||||
): Promise<SerializedEntry[]> {
|
||||
const entries: SerializedEntry[] = [];
|
||||
let entryCount = 0;
|
||||
let textBytes = 0;
|
||||
let fileBytes = 0;
|
||||
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
|
||||
else if (value.size > 0)
|
||||
entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type,
|
||||
});
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_PROPOSAL_ENTRIES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
|
||||
);
|
||||
validateProposalFieldName(name);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
|
||||
);
|
||||
textBytes += Buffer.byteLength(value, 'utf8');
|
||||
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal text exceeds the safe size limit.',
|
||||
);
|
||||
entries.push({ name, kind: 'text', value });
|
||||
continue;
|
||||
}
|
||||
|
||||
validateProposalFileMetadata(value.name, value.type);
|
||||
if (value.size === 0) continue;
|
||||
if (value.size > MAX_PROPOSAL_FILE_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal files exceed the safe per-file size limit.',
|
||||
);
|
||||
if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal files exceed the safe size limit.',
|
||||
);
|
||||
const bytes = Buffer.from(await value.arrayBuffer());
|
||||
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal files exceed the safe per-file size limit.',
|
||||
);
|
||||
fileBytes += bytes.length;
|
||||
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal files exceed the safe size limit.',
|
||||
);
|
||||
entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: bytes.toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function parseSerializedProposalEntries(
|
||||
payload: string,
|
||||
): SerializedEntry[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
validateSerializedProposalEntries(parsed as SerializedEntry[]);
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalPayloadValidationError) throw error;
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
}
|
||||
return parsed as SerializedEntry[];
|
||||
}
|
||||
|
||||
export function deserializeProposal(payload: string): FormData {
|
||||
const entries = parseSerializedProposalEntries(payload);
|
||||
const form = new FormData();
|
||||
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||
else
|
||||
form.append(
|
||||
entry.name,
|
||||
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
|
||||
new Blob([decodeSerializedProposalFile(entry.value)], {
|
||||
type: entry.type,
|
||||
}),
|
||||
entry.fileName,
|
||||
);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export class ProposalNotPendingError extends Error {
|
||||
constructor() {
|
||||
super('That pending proposal no longer exists.');
|
||||
this.name = 'ProposalNotPendingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalDependenciesBlockedError extends Error {
|
||||
readonly dependencies: string;
|
||||
|
||||
constructor(dependencies: string) {
|
||||
super(`Approve this proposal's dependencies first: ${dependencies}.`);
|
||||
this.name = 'ProposalDependenciesBlockedError';
|
||||
this.dependencies = dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalDependencyCycleError extends Error {
|
||||
readonly proposalId: string;
|
||||
readonly dependencyId: string;
|
||||
|
||||
constructor(proposalId: string, dependencyId: string) {
|
||||
super(
|
||||
`Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`,
|
||||
);
|
||||
this.name = 'ProposalDependencyCycleError';
|
||||
this.proposalId = proposalId;
|
||||
this.dependencyId = dependencyId;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalCanonicalValidationError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ProposalCanonicalValidationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const safeValidationMessage = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message && message.length <= 500
|
||||
? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
|
||||
: 'The proposal did not pass current canonical validation.';
|
||||
};
|
||||
|
||||
function canonicalFailure(
|
||||
error: unknown,
|
||||
): ProposalCanonicalValidationError | null {
|
||||
if (error instanceof ProposalCanonicalValidationError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
error.status,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (error instanceof ProposalPayloadValidationError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
400,
|
||||
'The stored proposal payload is invalid.',
|
||||
);
|
||||
if (error instanceof MechanicSourceFingerprintConflictError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof GameDataIconValidationError ||
|
||||
error instanceof ManagedImageValidationError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
400,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof ProtocolFormValidationError ||
|
||||
error instanceof SkillChipFormValidationError ||
|
||||
error instanceof SpecializationFormValidationError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
error.status,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof ProtocolNotFoundError ||
|
||||
error instanceof TalentNotFoundError ||
|
||||
error instanceof SkillChipNotFoundError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
404,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
/no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test(
|
||||
error.message,
|
||||
)
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([
|
||||
'The specialization tree node no longer exists.',
|
||||
'The specialization combat effect no longer exists.',
|
||||
]);
|
||||
|
||||
const SPECIALIZATION_CONFLICT_ERRORS = new Set([
|
||||
'The node identity does not match the supplied record.',
|
||||
'A saved tree node cannot be moved to another focus.',
|
||||
'A combat-effect attachment was submitted more than once.',
|
||||
'A submitted combat-effect attachment does not belong to this node.',
|
||||
'A new combat-effect attachment reused an existing ID.',
|
||||
'The combat-effect identity does not match the supplied record.',
|
||||
'A saved combat effect cannot be moved to another specialization.',
|
||||
]);
|
||||
|
||||
function classifySpecializationTransactionError(error: unknown) {
|
||||
if (error instanceof SpecializationFormValidationError) return error;
|
||||
if (error instanceof MechanicSourceFingerprintConflictError) return error;
|
||||
if (!(error instanceof Error)) return null;
|
||||
const message = error.message.trim();
|
||||
if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message))
|
||||
return new SpecializationFormValidationError(
|
||||
404,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (SPECIALIZATION_CONFLICT_ERRORS.has(message))
|
||||
return new SpecializationFormValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
const isKnownValidationMessage =
|
||||
message === 'Every conditional effect requires an authored description.' ||
|
||||
message ===
|
||||
'The authored mechanic owner does not match its attachment.' ||
|
||||
message === 'One or more conditional effects are unavailable for this specialization.' ||
|
||||
message === 'One or more authored description Attributes are unavailable.' ||
|
||||
message.startsWith('The authored template ') ||
|
||||
message.startsWith('Clause ') ||
|
||||
message.startsWith('Conditional mechanic ');
|
||||
return isKnownValidationMessage
|
||||
? new SpecializationFormValidationError(400, safeValidationMessage(error))
|
||||
: null;
|
||||
}
|
||||
|
||||
function enforcePhase2ProposalIdentity(
|
||||
proposal: Record<string, unknown>,
|
||||
form: FormData,
|
||||
) {
|
||||
const entityId = String(proposal.entity_id ?? '').trim();
|
||||
if (entityId) {
|
||||
form.set('id', entityId);
|
||||
form.delete('_proposedEntityId');
|
||||
return;
|
||||
}
|
||||
const draftEntityId = String(proposal.draft_entity_id ?? '').trim();
|
||||
if (!draftEntityId)
|
||||
throw new ProposalCanonicalValidationError(
|
||||
400,
|
||||
'The proposal does not have a stored draft identity.',
|
||||
);
|
||||
form.delete('id');
|
||||
form.set('_proposedEntityId', draftEntityId);
|
||||
}
|
||||
|
||||
async function replayPhase2Owner(
|
||||
database: Database,
|
||||
proposal: Record<string, unknown>,
|
||||
reviewerId: string,
|
||||
form: FormData,
|
||||
proposalId: string,
|
||||
journal: MediaMutationJournal,
|
||||
) {
|
||||
const mutationContext = { origin: 'proposal' as const, proposalId };
|
||||
switch (`${String(proposal.module)}:${String(proposal.action)}`) {
|
||||
case 'talents:saveTalent': {
|
||||
const media = await readTalentFormMedia(form);
|
||||
const result: SaveTalentInTransactionResult =
|
||||
await saveTalentFromFormInTransaction(database, {
|
||||
form,
|
||||
operatorId: reviewerId,
|
||||
proposedEntityId:
|
||||
String(proposal.draft_entity_id ?? '').trim() || undefined,
|
||||
...media,
|
||||
mutationContext,
|
||||
journal,
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new ProposalCanonicalValidationError(
|
||||
result.status,
|
||||
result.errors[0]?.message ?? 'The talent could not be saved.',
|
||||
);
|
||||
return { entityId: result.talentId, mode: result.talentMode };
|
||||
}
|
||||
case 'protocols:save': {
|
||||
const input = await parseProtocolForm(
|
||||
database,
|
||||
form,
|
||||
reviewerId,
|
||||
mutationContext,
|
||||
);
|
||||
const result = await saveProtocolInTransaction(database, {
|
||||
...input,
|
||||
journal,
|
||||
});
|
||||
return { entityId: result.protocolId, mode: result.mode };
|
||||
}
|
||||
case 'skillchips:save': {
|
||||
const input = await parseSkillChipForm(
|
||||
database,
|
||||
form,
|
||||
reviewerId,
|
||||
mutationContext,
|
||||
);
|
||||
const result = await _saveSkillChipInTransaction(database, {
|
||||
...input,
|
||||
journal,
|
||||
});
|
||||
return { entityId: result.setId, mode: result.mode };
|
||||
}
|
||||
case 'specializations:saveNode': {
|
||||
const input = await parseSpecializationNodeForm(
|
||||
database,
|
||||
form,
|
||||
reviewerId,
|
||||
mutationContext,
|
||||
);
|
||||
try {
|
||||
const result = await saveSpecializationNodeInTransaction(database, {
|
||||
...input,
|
||||
journal,
|
||||
});
|
||||
return {
|
||||
entityId: input.nodeId,
|
||||
mode: input.id ? 'edit' : 'add',
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
throw classifySpecializationTransactionError(error) ?? error;
|
||||
}
|
||||
}
|
||||
case 'specializations:saveCombatEffect': {
|
||||
const input = await parseSpecializationCombatEffectForm(
|
||||
database,
|
||||
form,
|
||||
reviewerId,
|
||||
mutationContext,
|
||||
);
|
||||
try {
|
||||
const result = await saveSpecializationCombatEffectInTransaction(
|
||||
database,
|
||||
input,
|
||||
);
|
||||
return {
|
||||
entityId: input.effectId,
|
||||
mode: input.id ? 'edit' : 'add',
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
throw classifySpecializationTransactionError(error) ?? error;
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error('Unsupported Phase 2 proposal action.');
|
||||
}
|
||||
}
|
||||
|
||||
export type Phase2ApprovalResult = {
|
||||
proposal: Record<string, unknown>;
|
||||
entityId: string;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
/** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */
|
||||
export async function approvePhase2Proposal(
|
||||
database: Database,
|
||||
reviewerId: string,
|
||||
proposalId: string,
|
||||
note: string | null,
|
||||
): Promise<Phase2ApprovalResult> {
|
||||
const journal = new MediaMutationJournal();
|
||||
try {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const proposal = await (
|
||||
await tx.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(proposalId);
|
||||
if (!proposal) throw new ProposalNotPendingError();
|
||||
const claimed = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(proposalId);
|
||||
if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
const unresolved = await (
|
||||
await tx.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
||||
).all(proposalId);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved
|
||||
.map((item) => `${item.summary} (${item.status})`)
|
||||
.join(', ');
|
||||
throw new ProposalDependenciesBlockedError(names);
|
||||
}
|
||||
let result: { entityId: string; mode: string };
|
||||
try {
|
||||
const form = deserializeProposal(String(proposal.payload_json));
|
||||
enforcePhase2ProposalIdentity(proposal as Record<string, unknown>, form);
|
||||
result = await replayPhase2Owner(
|
||||
tx,
|
||||
proposal,
|
||||
reviewerId,
|
||||
form,
|
||||
proposalId,
|
||||
journal,
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = canonicalFailure(error);
|
||||
if (failure) throw failure;
|
||||
throw error;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const updated = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'",
|
||||
)
|
||||
).run(result.entityId, reviewerId, note || null, now, now, proposalId);
|
||||
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
await recordIsacAudit({
|
||||
actorId: reviewerId,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.approved',
|
||||
entityId: proposalId,
|
||||
before: { status: 'pending' },
|
||||
after: {
|
||||
status: 'approved',
|
||||
module: proposal.module,
|
||||
action: proposal.action,
|
||||
proposedBy: proposal.proposed_by,
|
||||
appliedEntityId: result.entityId,
|
||||
},
|
||||
database: tx,
|
||||
});
|
||||
return { proposal, entityId: result.entityId, mode: result.mode };
|
||||
});
|
||||
const result = await operation.immediate();
|
||||
journal.clear();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await journal.rollback(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function failPhase2Proposal(
|
||||
database: Database,
|
||||
reviewerId: string,
|
||||
proposalId: string,
|
||||
message: string,
|
||||
) {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const proposal = await (
|
||||
await tx.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(proposalId);
|
||||
if (!proposal) throw new ProposalNotPendingError();
|
||||
const now = new Date().toISOString();
|
||||
const updated = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(reviewerId, message, now, proposalId);
|
||||
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
await recordIsacAudit({
|
||||
actorId: reviewerId,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.failed',
|
||||
entityId: proposalId,
|
||||
before: { status: 'pending' },
|
||||
after: { status: 'failed', messagePresent: Boolean(message) },
|
||||
database: tx,
|
||||
});
|
||||
return proposal as Record<string, unknown>;
|
||||
});
|
||||
return operation.immediate();
|
||||
}
|
||||
|
||||
export async function refreshProposalDependencies(
|
||||
proposalId: string,
|
||||
payload: string,
|
||||
suppliedDatabase?: Database,
|
||||
) {
|
||||
const database = await getDatabase();
|
||||
const text = serializedTextValues(payload)
|
||||
.map((entry) => entry.value)
|
||||
.join('\n');
|
||||
const textValues = serializedTextValues(payload);
|
||||
// A malformed persisted payload cannot provide reliable dependency evidence.
|
||||
// Leave existing links intact rather than mutating state during recovery.
|
||||
if (!textValues) return [];
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
const text = textValues.map((entry) => entry.value).join('\n');
|
||||
const candidates = await (
|
||||
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE id<>? AND draft_entity_id IS NOT NULL`)
|
||||
WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`)
|
||||
).all(proposalId);
|
||||
const dependencies = candidates.filter((candidate) =>
|
||||
text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
|
||||
const existingLinks = await (
|
||||
await database.prepare(
|
||||
'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies',
|
||||
)
|
||||
).all();
|
||||
const links = new Map<string, string[]>();
|
||||
for (const link of existingLinks) {
|
||||
const proposalLinks = links.get(String(link.proposal_id)) ?? [];
|
||||
proposalLinks.push(String(link.depends_on_proposal_id));
|
||||
links.set(String(link.proposal_id), proposalLinks);
|
||||
}
|
||||
const reachesProposal = (start: string) => {
|
||||
const visited = new Set<string>();
|
||||
const toVisit = [start];
|
||||
while (toVisit.length) {
|
||||
const current = toVisit.pop()!;
|
||||
if (current === proposalId) return true;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
toVisit.push(...(links.get(current) ?? []));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const dependency of dependencies) {
|
||||
const dependencyId = String(dependency.id);
|
||||
if (reachesProposal(dependencyId))
|
||||
throw new ProposalDependencyCycleError(proposalId, dependencyId);
|
||||
}
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
|
||||
@@ -170,11 +805,19 @@ export async function refreshProposalDependencies(
|
||||
return dependencies.map((dependency) => String(dependency.id));
|
||||
}
|
||||
|
||||
const serializedTextValues = (payload: string) =>
|
||||
(JSON.parse(payload) as SerializedEntry[]).filter(
|
||||
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
|
||||
entry.kind === 'text',
|
||||
);
|
||||
const serializedTextValues = (
|
||||
payload: string,
|
||||
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
|
||||
try {
|
||||
return parseSerializedProposalEntries(payload).filter(
|
||||
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
|
||||
entry.kind === 'text',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalPayloadValidationError) return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export async function getPendingEntities(
|
||||
_userId: string,
|
||||
@@ -211,24 +854,30 @@ export async function getPendingEntities(
|
||||
(item) => item.module === row.module && item.action === row.action,
|
||||
)!;
|
||||
const values = serializedTextValues(String(row.payload_json));
|
||||
const unusable = values === null;
|
||||
const safeValues = values ?? [];
|
||||
const field = (name: string) =>
|
||||
values.find((entry) => entry.name === name)?.value ?? '';
|
||||
safeValues.find((entry) => entry.name === name)?.value ?? '';
|
||||
const fieldValue = (name: string) => {
|
||||
const matches = values
|
||||
const matches = safeValues
|
||||
.filter((entry) => entry.name === name)
|
||||
.map((entry) => entry.value);
|
||||
return matches.length > 1 ? matches : (matches[0] ?? '');
|
||||
};
|
||||
return {
|
||||
id: String(row.draft_entity_id),
|
||||
id: String(row.draft_entity_id ?? row.id),
|
||||
proposalId: String(row.id),
|
||||
pending: true,
|
||||
active: 1,
|
||||
...(unusable ? { unusable: true } : {}),
|
||||
kind: definition.kind,
|
||||
name: field('name'),
|
||||
name: unusable ? 'UNUSABLE PROPOSAL' : field('name'),
|
||||
proposerName: String(row.proposer_name),
|
||||
...Object.fromEntries(
|
||||
(definition.fields ?? []).map((name) => [name, fieldValue(name)]),
|
||||
(definition.fields ?? []).map((name) => [
|
||||
name,
|
||||
unusable ? '' : fieldValue(name),
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -258,58 +907,63 @@ export async function queueDataProposal(
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
|
||||
const now = new Date().toISOString();
|
||||
const payload = JSON.stringify(await serialize(form));
|
||||
const payload = JSON.stringify(await serializeProposal(form));
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO data_change_proposals
|
||||
const createProposal = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
await (
|
||||
await tx.prepare(`INSERT INTO data_change_proposals
|
||||
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
|
||||
).run(
|
||||
id,
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
summary,
|
||||
contributorNote || null,
|
||||
payload,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
const text = serializedTextValues(payload)
|
||||
.map((entry) => entry.value)
|
||||
.join('\n');
|
||||
const candidates = await (
|
||||
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
|
||||
).all();
|
||||
const dependencies = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
for (const dependency of dependencies) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
|
||||
)
|
||||
).run(id, dependency.id);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after: {
|
||||
).run(
|
||||
id,
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
dependencies: dependencies.map((item) => item.id),
|
||||
summary,
|
||||
contributorNote: contributorNote || null,
|
||||
},
|
||||
contributorNote || null,
|
||||
payload,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
const text = (serializedTextValues(payload) ?? [])
|
||||
.map((entry) => entry.value)
|
||||
.join('\n');
|
||||
const candidates = await (
|
||||
await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
|
||||
).all();
|
||||
const dependencies = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
for (const dependency of dependencies) {
|
||||
await (
|
||||
await tx.prepare(
|
||||
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
|
||||
)
|
||||
).run(id, dependency.id);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after: {
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
dependencies: dependencies.map((item) => item.id),
|
||||
summary,
|
||||
contributorNotePresent: Boolean(contributorNote),
|
||||
},
|
||||
database: tx,
|
||||
});
|
||||
});
|
||||
await createProposal.immediate();
|
||||
await notifyOversightReview({
|
||||
category: 'data_changes',
|
||||
itemId: id,
|
||||
|
||||
Reference in New Issue
Block a user