1
0
Fork 0

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:
Jason Fraley 2026-09-20 02:43:40 -04:00
parent f35da021da
commit cc23742dd8
12 changed files with 31066 additions and 0 deletions

View 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" },
],
};

View 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" },
],
};

View file

@ -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}`);

View 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"
}`,
);
}
}

View 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;
}

View 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 };
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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,

View file

@ -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",

View file

@ -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];

View 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);
});
});
});