feat(operations): versioned operation event and effect ledger
Consume raw arma-sync-events into a validated, idempotent operation ledger: contract v1, reject/dead-letter with zero effects, effect keys with unique-constraint race handling, and a staged-activation flag hook. Registers the operations permission domains. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
f35da021da
commit
cc23742dd8
12 changed files with 31066 additions and 0 deletions
108
src/collections/operations/OperationEffects.ts
Normal file
108
src/collections/operations/OperationEffects.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
|
||||
/**
|
||||
* Derived-effect ledger for validated operation events.
|
||||
*
|
||||
* One row per (event, effect type) pair, keyed by `effectKey` =
|
||||
* `${eventId}:${effectType}` so an exact retry is a no-op. Effects are the
|
||||
* only place derived operation state may be recorded; they always carry
|
||||
* provenance back to both the operation event and the raw source event.
|
||||
*
|
||||
* Writes are internal only (create/update/delete denied; the bridge writes
|
||||
* via `overrideAccess`). Read is open to any logged-in user.
|
||||
*/
|
||||
export const OperationEffects: CollectionConfig = {
|
||||
slug: "operation-effects",
|
||||
admin: {
|
||||
group: "Operations",
|
||||
useAsTitle: "effectKey",
|
||||
defaultColumns: ["effectKey", "operationId", "effectType", "status", "appliedAt"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: () => false,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "operationEvent",
|
||||
type: "relationship",
|
||||
relationTo: "operation-events",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "The validated operation event this effect derives from.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sourceEvent",
|
||||
type: "relationship",
|
||||
relationTo: "arma-sync-events",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "The raw source event this effect derives from.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "operationId",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "effectKey",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Idempotency key: `${eventId}:${effectType}`.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "effectType",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Operation Started", value: "operation.started" },
|
||||
{ label: "Operation Ended", value: "operation.ended" },
|
||||
{ label: "Objective Updated", value: "operation.objective-updated" },
|
||||
{ label: "Extraction Recorded", value: "operation.extraction-recorded" },
|
||||
{ label: "Inventory Recorded", value: "operation.inventory-recorded" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "applied",
|
||||
options: [
|
||||
{ label: "Applied", value: "applied" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
{ label: "Reversed", value: "reversed" },
|
||||
],
|
||||
},
|
||||
{ name: "payload", type: "json" },
|
||||
{
|
||||
name: "reversalOf",
|
||||
type: "relationship",
|
||||
relationTo: "operation-effects",
|
||||
admin: {
|
||||
description: "Set when this effect reverses another effect.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reversedBy",
|
||||
type: "relationship",
|
||||
relationTo: "operation-effects",
|
||||
admin: {
|
||||
description: "Set when this effect has been reversed.",
|
||||
},
|
||||
},
|
||||
{ name: "reconciliationNotes", type: "textarea" },
|
||||
{ name: "appliedAt", type: "date" },
|
||||
],
|
||||
};
|
||||
108
src/collections/operations/OperationEvents.ts
Normal file
108
src/collections/operations/OperationEvents.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
|
||||
/**
|
||||
* Validated operation-event ledger.
|
||||
*
|
||||
* Consumes immutable raw `arma-sync-events` evidence and records the outcome
|
||||
* of validating it against the supported operation event contract. Every raw
|
||||
* event that reaches the ledger gets exactly one row here (idempotent by
|
||||
* `sourceEvent`), including malformed or unsupported events, which are kept
|
||||
* as `rejected` / `dead-letter` records with zero derived effects.
|
||||
*
|
||||
* Writes are internal only (create/update/delete are denied to everyone; the
|
||||
* bridge writes via `overrideAccess`). Read is open to any logged-in user.
|
||||
*/
|
||||
export const OperationEvents: CollectionConfig = {
|
||||
slug: "operation-events",
|
||||
admin: {
|
||||
group: "Operations",
|
||||
useAsTitle: "eventId",
|
||||
defaultColumns: ["eventId", "operationId", "type", "status", "occurredAt"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: () => false,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "sourceEvent",
|
||||
type: "relationship",
|
||||
relationTo: "arma-sync-events",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "The immutable raw sync event this record was derived from.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "eventId",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Mirror of the raw event's server-prefixed id (serverId:eventId).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "operationId",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Operation identity from the event payload, or \"unknown\".",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description:
|
||||
"Event type as received. Supported: operation.start, operation.end, operation.objective, operation.extraction, operation.inventory, player.kill. Unknown types are kept as dead-letter records.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
type: "number",
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
admin: { description: "Event contract version from the payload (default 1)." },
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "validated",
|
||||
options: [
|
||||
{ label: "Validated", value: "validated" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
{ label: "Dead Letter", value: "dead-letter" },
|
||||
],
|
||||
admin: {
|
||||
description: "Validation outcome. Only validated events derive effects.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
type: "json",
|
||||
admin: { description: "Rejection/dead-letter reason: { code, message }." },
|
||||
},
|
||||
{
|
||||
name: "payloadHash",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: { description: "SHA-256 of the stable-stringified raw payload." },
|
||||
},
|
||||
{ name: "actor", type: "relationship", relationTo: "users" },
|
||||
{ name: "server", type: "relationship", relationTo: "game-servers" },
|
||||
{ name: "mission", type: "relationship", relationTo: "missions" },
|
||||
{ name: "occurredAt", type: "date", required: true },
|
||||
{ name: "validatedAt", type: "date" },
|
||||
{ name: "reconciliationNotes", type: "textarea" },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
||||
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
||||
import { processOperationEvent } from "@/lib/operations/process";
|
||||
|
||||
export const ArmaSyncEvents: CollectionConfig = {
|
||||
slug: "arma-sync-events",
|
||||
|
|
@ -9,6 +11,12 @@ export const ArmaSyncEvents: CollectionConfig = {
|
|||
afterChange: [
|
||||
async ({ doc, req, operation }) => {
|
||||
if (operation !== "create") return;
|
||||
// Ledger first: every raw event is consumed into the operation ledger
|
||||
// before any downstream projection runs. Evidence is always stored;
|
||||
// only derived effects are gated by the kill switch.
|
||||
if (isOperationLedgerEnabled()) {
|
||||
await processOperationEvent(req.payload, doc, req);
|
||||
}
|
||||
if (doc.type === "player.kill") {
|
||||
processPlayerKill(req.payload, doc).catch((err) => {
|
||||
req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`);
|
||||
|
|
|
|||
197
src/lib/operations/process.ts
Normal file
197
src/lib/operations/process.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { Payload, PayloadRequest } from "payload";
|
||||
import { validateOperationEvent } from "./validate";
|
||||
import { applyOperationEffect } from "./settlement";
|
||||
import type { OperationEffectType, OperationEventRecord, RawSyncEvent } from "./types";
|
||||
|
||||
/** JSON.stringify with recursively sorted object keys (stable hashing input). */
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/** SHA-256 hex digest of the stable-stringified payload. */
|
||||
export function hashPayload(payload: unknown): string {
|
||||
return createHash("sha256").update(stableStringify(payload)).digest("hex");
|
||||
}
|
||||
|
||||
function pgErrorCode(error: unknown): string | undefined {
|
||||
const cause = (error as { cause?: { code?: string } })?.cause;
|
||||
return cause?.code ?? (error as { code?: string })?.code;
|
||||
}
|
||||
|
||||
interface DerivedEffect {
|
||||
effectType: OperationEffectType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-type effect derivation. Returns null when the event derives no effect
|
||||
* (player.kill is an AAR fact only; unknown types never reach this point).
|
||||
* The effect payload carries the full validated event payload through, so
|
||||
* the settlement layer sees the complete extraction/loss/end contract.
|
||||
*/
|
||||
export function deriveEffect(raw: RawSyncEvent, operationId: string): DerivedEffect | null {
|
||||
const payload = raw.payload as Record<string, unknown> | undefined;
|
||||
switch (raw.type) {
|
||||
case "operation.start":
|
||||
return {
|
||||
effectType: "operation.started",
|
||||
payload: { operationId, ...payload },
|
||||
};
|
||||
case "operation.end":
|
||||
return {
|
||||
effectType: "operation.ended",
|
||||
payload: { operationId, ...payload },
|
||||
};
|
||||
case "operation.objective":
|
||||
return {
|
||||
effectType: "operation.objective-updated",
|
||||
payload: { operationId, ...payload },
|
||||
};
|
||||
case "operation.extraction":
|
||||
return {
|
||||
effectType: "operation.extraction-recorded",
|
||||
payload: { operationId, ...payload },
|
||||
};
|
||||
case "operation.inventory":
|
||||
return {
|
||||
effectType: "operation.inventory-recorded",
|
||||
payload: { operationId, ...payload },
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one raw sync event into the operation ledger.
|
||||
*
|
||||
* Idempotent: a raw event already present in the ledger (by `sourceEvent`) is
|
||||
* skipped, and an effect whose `effectKey` already exists is skipped. The whole
|
||||
* body is defensive: the ledger must never break raw event ingestion, so any
|
||||
* failure is logged and swallowed.
|
||||
*
|
||||
* When called from a Payload hook, pass `req` so the ledger writes join the
|
||||
* caller's transaction (a separate connection cannot see uncommitted rows).
|
||||
*/
|
||||
export async function processOperationEvent(
|
||||
payload: Payload,
|
||||
raw: RawSyncEvent,
|
||||
req?: PayloadRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const validation = validateOperationEvent(raw);
|
||||
|
||||
const record: OperationEventRecord = {
|
||||
sourceEvent: raw.id,
|
||||
eventId: raw.eventId,
|
||||
operationId: validation.operationId ?? "unknown",
|
||||
type: raw.type,
|
||||
version: validation.version ?? 1,
|
||||
status: validation.status,
|
||||
error: validation.error ?? null,
|
||||
payloadHash: hashPayload(raw.payload),
|
||||
actor: null,
|
||||
server: typeof raw.server === "number" ? raw.server : null,
|
||||
mission: null,
|
||||
occurredAt: raw.occurredAt,
|
||||
validatedAt: new Date().toISOString(),
|
||||
reconciliationNotes: null,
|
||||
};
|
||||
|
||||
// Actor/mission links come from the payload when present.
|
||||
if (typeof raw.payload === "object" && raw.payload !== null && !Array.isArray(raw.payload)) {
|
||||
const p = raw.payload as Record<string, unknown>;
|
||||
if (typeof p.actorId === "number") record.actor = p.actorId;
|
||||
if (typeof p.missionId === "number") record.mission = p.missionId;
|
||||
}
|
||||
|
||||
// Idempotency: skip if this raw event is already in the ledger.
|
||||
const existing = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { sourceEvent: { equals: raw.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
});
|
||||
if (existing.docs.length > 0) {
|
||||
payload.logger.info(`[Operations] Duplicate operation event ${raw.eventId} skipped`);
|
||||
return;
|
||||
}
|
||||
|
||||
let eventDoc;
|
||||
try {
|
||||
eventDoc = await payload.create({
|
||||
collection: "operation-events",
|
||||
data: record,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
} catch (error) {
|
||||
if (pgErrorCode(error) === "23505") {
|
||||
// Unique-constraint race with a concurrent request: already stored.
|
||||
payload.logger.info(`[Operations] Duplicate operation event ${raw.eventId} skipped (race)`);
|
||||
} else {
|
||||
payload.logger.error(
|
||||
`[Operations] Failed to create operation event ${raw.eventId}: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only validated events derive effects.
|
||||
if (validation.status !== "validated") return;
|
||||
|
||||
const effect = deriveEffect(raw, record.operationId);
|
||||
if (!effect) return;
|
||||
|
||||
try {
|
||||
const effectDoc = await payload.create({
|
||||
collection: "operation-effects",
|
||||
data: {
|
||||
operationEvent: eventDoc.id,
|
||||
sourceEvent: raw.id,
|
||||
operationId: record.operationId,
|
||||
effectKey: `${raw.eventId}:${effect.effectType}`,
|
||||
effectType: effect.effectType,
|
||||
status: "pending",
|
||||
payload: effect.payload,
|
||||
appliedAt: null,
|
||||
reconciliationNotes: null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
|
||||
// Settlement transitions the effect to applied/failed and never
|
||||
// throws: the ledger path must not break raw event ingestion.
|
||||
await applyOperationEffect(payload, effectDoc.id, req);
|
||||
} catch (error) {
|
||||
if (pgErrorCode(error) === "23505") {
|
||||
// Unique-constraint race on effectKey: already applied.
|
||||
payload.logger.info(`[Operations] Duplicate effect ${raw.eventId}:${effect.effectType} skipped`);
|
||||
} else {
|
||||
payload.logger.error(
|
||||
`[Operations] Failed to create effect ${raw.eventId}:${effect.effectType}: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
payload.logger.error(
|
||||
`[Operations] Failed to process operation event ${raw.eventId}: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/lib/operations/types.ts
Normal file
82
src/lib/operations/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Operation ledger contracts.
|
||||
*
|
||||
* Shared types for the validated operation-event ledger and the derived-effect
|
||||
* ledger. Pure module: no Payload imports, safe to import anywhere.
|
||||
*/
|
||||
|
||||
/** Current version of the operation event contract. */
|
||||
export const OPERATION_EVENT_VERSION = 1;
|
||||
|
||||
/** Event types the ledger knows how to validate. */
|
||||
export const SUPPORTED_OPERATION_EVENT_TYPES = [
|
||||
"operation.start",
|
||||
"operation.end",
|
||||
"operation.objective",
|
||||
"operation.extraction",
|
||||
"operation.inventory",
|
||||
"player.kill",
|
||||
] as const;
|
||||
|
||||
export type OperationEventType = (typeof SUPPORTED_OPERATION_EVENT_TYPES)[number];
|
||||
|
||||
/** Effect types derived from validated operation events. */
|
||||
export const OPERATION_EFFECT_TYPES = [
|
||||
"operation.started",
|
||||
"operation.ended",
|
||||
"operation.objective-updated",
|
||||
"operation.extraction-recorded",
|
||||
"operation.inventory-recorded",
|
||||
] as const;
|
||||
|
||||
export type OperationEffectType = (typeof OPERATION_EFFECT_TYPES)[number];
|
||||
|
||||
export type OperationEventStatus = "validated" | "rejected" | "dead-letter";
|
||||
export type OperationEffectStatus = "applied" | "pending" | "failed" | "reversed";
|
||||
|
||||
/** Shape of a raw arma-sync-events doc as seen by the afterChange hook (depth 0). */
|
||||
export interface RawSyncEvent {
|
||||
id: number;
|
||||
eventId: string;
|
||||
server: number | null;
|
||||
type: string;
|
||||
occurredAt: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** Structured rejection/dead-letter reason stored on the event record. */
|
||||
export type OperationEventError = {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Data written to the operation-events collection. */
|
||||
export interface OperationEventRecord {
|
||||
sourceEvent: number;
|
||||
eventId: string;
|
||||
operationId: string;
|
||||
type: string;
|
||||
version: number;
|
||||
status: OperationEventStatus;
|
||||
error: OperationEventError | null;
|
||||
payloadHash: string;
|
||||
actor: number | null;
|
||||
server: number | null;
|
||||
mission: number | null;
|
||||
occurredAt: string;
|
||||
validatedAt: string | null;
|
||||
reconciliationNotes: string | null;
|
||||
}
|
||||
|
||||
/** Data written to the operation-effects collection. */
|
||||
export interface OperationEffectRecord {
|
||||
operationEvent: number;
|
||||
sourceEvent: number;
|
||||
operationId: string;
|
||||
effectKey: string;
|
||||
effectType: OperationEffectType;
|
||||
status: OperationEffectStatus;
|
||||
payload: unknown;
|
||||
appliedAt: string | null;
|
||||
reconciliationNotes: string | null;
|
||||
}
|
||||
87
src/lib/operations/validate.ts
Normal file
87
src/lib/operations/validate.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
OPERATION_EVENT_VERSION,
|
||||
SUPPORTED_OPERATION_EVENT_TYPES,
|
||||
type OperationEventError,
|
||||
type OperationEventStatus,
|
||||
type OperationEventType,
|
||||
type RawSyncEvent,
|
||||
} from "./types";
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
status: OperationEventStatus;
|
||||
error?: OperationEventError;
|
||||
operationId?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw sync event against the operation event contract.
|
||||
*
|
||||
* - Unknown type -> dead-letter (code `unsupported-type`), so the original
|
||||
* type string is preserved on the record.
|
||||
* - Supported type with a malformed payload -> rejected (code `invalid-payload`).
|
||||
* - Supported type with a version above the current contract -> rejected
|
||||
* (code `unsupported-version`); the record is retained but derives no effects.
|
||||
* - Otherwise -> validated.
|
||||
*/
|
||||
export function validateOperationEvent(raw: RawSyncEvent): ValidationResult {
|
||||
if (!SUPPORTED_OPERATION_EVENT_TYPES.includes(raw.type as OperationEventType)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "dead-letter",
|
||||
error: { code: "unsupported-type", message: `Unsupported event type "${raw.type}".` },
|
||||
};
|
||||
}
|
||||
|
||||
if (raw.type === "player.kill") {
|
||||
// Kill tuple: [victimName, victimSteamId, killerName, killerSteamId, ...].
|
||||
if (!Array.isArray(raw.payload) || raw.payload.length < 6 || !raw.payload[3]) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "rejected",
|
||||
error: {
|
||||
code: "invalid-payload",
|
||||
message: "player.kill payload must be a kill tuple with a killer Steam ID.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, status: "validated", operationId: "unknown", version: 1 };
|
||||
}
|
||||
|
||||
const payload = raw.payload;
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "rejected",
|
||||
error: {
|
||||
code: "invalid-payload",
|
||||
message: `${raw.type} payload must be a JSON object.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const p = payload as Record<string, unknown>;
|
||||
const operationId =
|
||||
typeof p.operationId === "string" && p.operationId
|
||||
? p.operationId
|
||||
: typeof p.operation === "string" && p.operation
|
||||
? p.operation
|
||||
: "unknown";
|
||||
const version = typeof p.version === "number" ? p.version : 1;
|
||||
|
||||
if (version > OPERATION_EVENT_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "rejected",
|
||||
error: {
|
||||
code: "unsupported-version",
|
||||
message: `Event version ${version} exceeds supported version ${OPERATION_EVENT_VERSION}.`,
|
||||
},
|
||||
operationId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: "validated", operationId, version };
|
||||
}
|
||||
29858
src/migrations/20260918_180851_add_operation_ledger.json
Normal file
29858
src/migrations/20260918_180851_add_operation_ledger.json
Normal file
File diff suppressed because it is too large
Load diff
125
src/migrations/20260918_180851_add_operation_ledger.ts
Normal file
125
src/migrations/20260918_180851_add_operation_ledger.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -28,6 +28,11 @@ import { GameServers } from "@/collections/server/GameServers";
|
|||
import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents";
|
||||
import { ArmaCommands } from "@/collections/server/ArmaCommands";
|
||||
import { RespawnTickets } from "@/collections/server/RespawnTickets";
|
||||
import { OperationEvents } from "@/collections/operations/OperationEvents";
|
||||
import { OperationEffects } from "@/collections/operations/OperationEffects";
|
||||
import { OperationReservations } from "@/collections/operations/OperationReservations";
|
||||
import { OperationReadiness } from "@/collections/operations/OperationReadiness";
|
||||
import { ZonePressure } from "@/collections/operations/ZonePressure";
|
||||
import { Factions } from "@/collections/intelligence/Factions";
|
||||
import { Assets } from "@/collections/logistics/Assets";
|
||||
import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
|
||||
|
|
@ -309,6 +314,13 @@ const collections = [
|
|||
ArmaCommands,
|
||||
RespawnTickets,
|
||||
|
||||
// Operations
|
||||
OperationEvents,
|
||||
OperationEffects,
|
||||
OperationReservations,
|
||||
OperationReadiness,
|
||||
ZonePressure,
|
||||
|
||||
// Minigames
|
||||
CustomMinefields,
|
||||
CustomMinefieldScores,
|
||||
|
|
|
|||
|
|
@ -556,6 +556,56 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
{ value: "admin:arma-commands:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Operation Events",
|
||||
permissions: [
|
||||
{ value: "operation-events:create", label: "Create" },
|
||||
{ value: "operation-events:read", label: "Read" },
|
||||
{ value: "operation-events:update", label: "Update" },
|
||||
{ value: "operation-events:delete", label: "Delete" },
|
||||
{ value: "admin:operation-events:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Operation Effects",
|
||||
permissions: [
|
||||
{ value: "operation-effects:create", label: "Create" },
|
||||
{ value: "operation-effects:read", label: "Read" },
|
||||
{ value: "operation-effects:update", label: "Update" },
|
||||
{ value: "operation-effects:delete", label: "Delete" },
|
||||
{ value: "admin:operation-effects:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Operation Reservations",
|
||||
permissions: [
|
||||
{ value: "operation-reservations:create", label: "Create" },
|
||||
{ value: "operation-reservations:read", label: "Read" },
|
||||
{ value: "operation-reservations:update", label: "Update" },
|
||||
{ value: "operation-reservations:delete", label: "Delete" },
|
||||
{ value: "admin:operation-reservations:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Operation Readiness",
|
||||
permissions: [
|
||||
{ value: "operation-readiness:create", label: "Create" },
|
||||
{ value: "operation-readiness:read", label: "Read" },
|
||||
{ value: "operation-readiness:update", label: "Update" },
|
||||
{ value: "operation-readiness:delete", label: "Delete" },
|
||||
{ value: "admin:operation-readiness:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Zone Pressure",
|
||||
permissions: [
|
||||
{ value: "zone-pressure:create", label: "Create" },
|
||||
{ value: "zone-pressure:read", label: "Read" },
|
||||
{ value: "zone-pressure:update", label: "Update" },
|
||||
{ value: "zone-pressure:delete", label: "Delete" },
|
||||
{ value: "admin:zone-pressure:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- Globals -------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -872,6 +922,31 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
|||
"arma-commands:update",
|
||||
"arma-commands:delete",
|
||||
"admin:arma-commands:manage",
|
||||
"operation-events:create",
|
||||
"operation-events:read",
|
||||
"operation-events:update",
|
||||
"operation-events:delete",
|
||||
"admin:operation-events:manage",
|
||||
"operation-effects:create",
|
||||
"operation-effects:read",
|
||||
"operation-effects:update",
|
||||
"operation-effects:delete",
|
||||
"admin:operation-effects:manage",
|
||||
"operation-reservations:create",
|
||||
"operation-reservations:read",
|
||||
"operation-reservations:update",
|
||||
"operation-reservations:delete",
|
||||
"admin:operation-reservations:manage",
|
||||
"operation-readiness:create",
|
||||
"operation-readiness:read",
|
||||
"operation-readiness:update",
|
||||
"operation-readiness:delete",
|
||||
"admin:operation-readiness:manage",
|
||||
"zone-pressure:create",
|
||||
"zone-pressure:read",
|
||||
"zone-pressure:update",
|
||||
"zone-pressure:delete",
|
||||
"admin:zone-pressure:manage",
|
||||
"game-rules:read",
|
||||
"game-rules:update",
|
||||
"shims:read",
|
||||
|
|
|
|||
|
|
@ -199,6 +199,12 @@ export const EventTypes = {
|
|||
|
||||
// Respawn Tickets
|
||||
RespawnTicketsSettled: "respawn:tickets-settled",
|
||||
// Production
|
||||
ProductionOutput: "production:output",
|
||||
ProductionPaused: "production:paused",
|
||||
ProductionToggled: "production:toggled",
|
||||
StructureModuleInstall: "structure:module-install",
|
||||
StructureModuleUninstall: "structure:module-uninstall",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
400
tests/int/operation-ledger.int.spec.ts
Normal file
400
tests/int/operation-ledger.int.spec.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { hashPayload, processOperationEvent, stableStringify } from "@/lib/operations/process";
|
||||
import type { RawSyncEvent } from "@/lib/operations/types";
|
||||
|
||||
type JsonValue = string | number | boolean | unknown[] | { [k: string]: unknown } | null;
|
||||
|
||||
let payload: Payload;
|
||||
const RUN = `op-${Date.now().toString(36)}`;
|
||||
|
||||
describe("Operation ledger", () => {
|
||||
let userId: number;
|
||||
let profileId: number;
|
||||
let serverId: number;
|
||||
const createdSyncEventIds: number[] = [];
|
||||
const createdEventIds: number[] = [];
|
||||
const createdEffectIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const user = await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-op`,
|
||||
discordUsername: `${RUN}-op-discord`,
|
||||
displayName: `${RUN} Operator`,
|
||||
steamId: `${RUN}-steam-001`,
|
||||
password: "Test1234",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const profiles = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: userId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
profileId = profiles.docs[0].id;
|
||||
|
||||
const server = await payload.create({
|
||||
collection: "game-servers",
|
||||
data: {
|
||||
serverId: `${RUN}-srv`,
|
||||
name: `${RUN} Server`,
|
||||
status: "online",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
serverId = server.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdEffectIds) {
|
||||
await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of createdEventIds) {
|
||||
await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of createdSyncEventIds) {
|
||||
await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
if (profileId) {
|
||||
await payload.delete({ collection: "profiles", id: profileId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
if (userId) {
|
||||
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
if (serverId) {
|
||||
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function createRawEvent(opts: {
|
||||
eventId: string;
|
||||
type: string;
|
||||
payload: JsonValue;
|
||||
}): Promise<RawSyncEvent> {
|
||||
const occurredAt = new Date().toISOString();
|
||||
const syncDoc = await payload.create({
|
||||
collection: "arma-sync-events",
|
||||
data: {
|
||||
eventId: opts.eventId,
|
||||
server: serverId,
|
||||
type: opts.type,
|
||||
occurredAt,
|
||||
payload: opts.payload,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdSyncEventIds.push(syncDoc.id);
|
||||
return {
|
||||
id: syncDoc.id,
|
||||
eventId: syncDoc.eventId,
|
||||
server: serverId,
|
||||
type: syncDoc.type,
|
||||
occurredAt,
|
||||
payload: syncDoc.payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stableStringify", () => {
|
||||
it("produces stable output regardless of key order", () => {
|
||||
expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 }));
|
||||
expect(stableStringify([{ b: 1, a: 2 }])).toBe(stableStringify([{ a: 2, b: 1 }]));
|
||||
expect(stableStringify([{ b: 1 }, { a: 2 }])).not.toBe(stableStringify([{ a: 2 }, { b: 1 }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("processOperationEvent", () => {
|
||||
it("records a validated operation.start and derives an operation.started effect", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-start`,
|
||||
type: "operation.start",
|
||||
payload: { operationId: RUN, version: 1 },
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
const event = events.docs[0];
|
||||
expect(event.status).toBe("validated");
|
||||
expect(event.operationId).toBe(RUN);
|
||||
expect(event.type).toBe("operation.start");
|
||||
expect(event.version).toBe(1);
|
||||
expect(event.sourceEvent).toBe(raw.id);
|
||||
expect(event.payloadHash).toBe(hashPayload(raw.payload));
|
||||
createdEventIds.push(event.id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: event.id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(1);
|
||||
const effect = effects.docs[0];
|
||||
expect(effect.effectType).toBe("operation.started");
|
||||
expect(effect.status).toBe("applied");
|
||||
expect(effect.sourceEvent).toBe(raw.id);
|
||||
expect(effect.operationId).toBe(RUN);
|
||||
createdEffectIds.push(effect.id);
|
||||
});
|
||||
|
||||
it("is idempotent for a duplicate raw event", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-dup`,
|
||||
type: "operation.end",
|
||||
payload: { operationId: RUN, version: 1 },
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
createdEventIds.push(events.docs[0].id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: events.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(1);
|
||||
createdEffectIds.push(effects.docs[0].id);
|
||||
});
|
||||
|
||||
it("retains an unsupported-version event with zero effects", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-v2`,
|
||||
type: "operation.start",
|
||||
payload: { operationId: RUN, version: 2 },
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
const event = events.docs[0];
|
||||
expect(event.status).toBe("rejected");
|
||||
expect(event.version).toBe(2);
|
||||
expect(event.error).toMatchObject({ code: "unsupported-version" });
|
||||
createdEventIds.push(event.id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: event.id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records player.kill as validated with zero effects", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-kill`,
|
||||
type: "player.kill",
|
||||
payload: [
|
||||
"Victim", "", `${RUN} Killer`, `${RUN}-steam-001`,
|
||||
`${RUN} Killer`, `${RUN}-steam-001`, "O_T_Soldier_AT_F", "B_Officer_Parade_Veteran_F",
|
||||
],
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
expect(events.docs[0].status).toBe("validated");
|
||||
expect(events.docs[0].operationId).toBe("unknown");
|
||||
createdEventIds.push(events.docs[0].id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: events.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a malformed payload with zero effects", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-bad`,
|
||||
type: "operation.start",
|
||||
payload: [],
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
expect(events.docs[0].status).toBe("rejected");
|
||||
expect(events.docs[0].error).toMatchObject({ code: "invalid-payload" });
|
||||
createdEventIds.push(events.docs[0].id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: events.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dead-letters an unknown type with zero effects", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-unknown`,
|
||||
type: "some.unknown.type",
|
||||
payload: { anything: true },
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
expect(events.docs[0].status).toBe("dead-letter");
|
||||
expect(events.docs[0].type).toBe("some.unknown.type");
|
||||
expect(events.docs[0].error).toMatchObject({ code: "unsupported-type" });
|
||||
createdEventIds.push(events.docs[0].id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: events.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("links actor from the payload", async () => {
|
||||
const raw = await createRawEvent({
|
||||
eventId: `${RUN}-evt-extract`,
|
||||
type: "operation.extraction",
|
||||
payload: { operationId: RUN, version: 1, actorId: userId, player: `${RUN} Operator` },
|
||||
});
|
||||
|
||||
await processOperationEvent(payload, raw);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { eventId: { equals: raw.eventId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
expect(events.docs[0].actor).toBe(userId);
|
||||
createdEventIds.push(events.docs[0].id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: events.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(1);
|
||||
expect(effects.docs[0].effectType).toBe("operation.extraction-recorded");
|
||||
createdEffectIds.push(effects.docs[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("end-to-end via arma-sync-events create", () => {
|
||||
it("consumes a real sync event through the afterChange hook", async () => {
|
||||
const syncEvent = await payload.create({
|
||||
collection: "arma-sync-events",
|
||||
data: {
|
||||
eventId: `${RUN}-real-1`,
|
||||
server: serverId,
|
||||
type: "operation.start",
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: { operationId: RUN, version: 1 },
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdSyncEventIds.push(syncEvent.id);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { sourceEvent: { equals: syncEvent.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(events.docs).toHaveLength(1);
|
||||
const event = events.docs[0];
|
||||
expect(event.status).toBe("validated");
|
||||
expect(event.operationId).toBe(RUN);
|
||||
expect(event.eventId).toBe(`${RUN}-real-1`);
|
||||
createdEventIds.push(event.id);
|
||||
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: event.id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(effects.docs).toHaveLength(1);
|
||||
const effect = effects.docs[0];
|
||||
expect(effect.effectType).toBe("operation.started");
|
||||
expect(effect.sourceEvent).toBe(syncEvent.id);
|
||||
expect(effect.operationEvent).toBe(event.id);
|
||||
createdEffectIds.push(effect.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue