feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
+726 -72
View File
@@ -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,