feat: add pinned inventory location enrichment

This commit is contained in:
jb
2026-09-20 12:13:51 -04:00
parent fe7d218195
commit 0c9197f841
32 changed files with 3116 additions and 55 deletions
+376 -9
View File
@@ -5,7 +5,8 @@ import {
} from "convex/server";
import { v } from "convex/values";
import { assetValidator } from "./schema";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
import { deterministicEnrichmentVersion, MAX_LOCATION_ENRICHMENT_CHUNK, resolveContainment, validatePersistedLocationRows, validateReferenceEnrichmentPin } from "../lib/domain/inventory-location";
import { capabilityEnabled, parseBackendCapabilityAllowlist, type ReleaseCapability } from "../lib/server/release-gate";
import {
CAPABILITY_DEFINITIONS,
SERVER_SCOPE_ALLOWLIST,
@@ -14,6 +15,17 @@ import {
import type { Capability } from "../lib/domain/model";
const MAX_ASSET_CHUNK = 250;
const MAX_LOCATION_CHUNK = MAX_LOCATION_ENRICHMENT_CHUNK;
const MAX_LOCATION_CHUNK_BYTES = 512 * 1024;
const MAX_ENRICHMENT_TOTAL_BYTES = 16 * 1024 * 1024;
// Initial T08 persistence cap is deliberately conservative: larger optional
// enrichment falls back to a truthful failed/partial result while raw assets
// remain publishable. Increase only with measured production transaction data.
const MAX_PERSISTED_ENRICHMENT_ROWS = 2_500;
// A 50k generation can retain both helper ID plans without truncation; the
// nested validator below still caps every list and scalar independently.
const MAX_ENRICHMENT_PIN_BYTES = 8 * 1024 * 1024;
const MAX_ASSET_ROWS = 50_000;
const SYNC_LEASE_MS = 5 * 60 * 1000;
// Convex transactions must validate the manifest within bounded read limits.
const MAX_CHUNKS = 4_096;
@@ -43,6 +55,24 @@ const assetPageResultValidator = v.object({
isDone: v.boolean(),
continueCursor: v.string(),
generation: v.union(v.string(), v.null()),
enrichmentStatus: v.union(v.string(), v.null()),
enrichmentVersion: v.union(v.string(), v.null()),
snapshotChanged: v.boolean(),
});
const enrichmentManifestValidator = v.object({
generation: v.string(),
status: v.union(v.string(), v.null()),
version: v.union(v.string(), v.null()),
inputPin: v.union(v.any(), v.null()),
unresolvedCount: v.union(v.number(), v.null()),
});
const enrichmentPageValidator = v.object({
chunks: v.array(v.object({ index: v.number(), rows: v.array(v.any()) })),
isDone: v.boolean(),
continueCursor: v.string(),
generation: v.union(v.string(), v.null()),
status: v.union(v.string(), v.null()),
version: v.union(v.string(), v.null()),
});
function now(): number {
@@ -66,7 +96,7 @@ function assertSyncWorker(secret: string): void {
assertDeploymentCapability('asset_sync');
}
function assertDeploymentCapability(capability: 'asset_sync'): void {
function assertDeploymentCapability(capability: ReleaseCapability): void {
let enabled = false;
try {
enabled = capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
@@ -127,6 +157,21 @@ function scopesEqual(left: readonly string[], right: readonly string[]): boolean
return left.length === right.length && [...left].sort().every((scope, index) => scope === [...right].sort()[index]);
}
function enrichmentAuthorityMatches(character: any, generation: any): boolean {
if (!character || !generation || character.authorizationStatus !== 'active'
|| generation.grantRevision !== character.grantRevision
|| generation.authorizationEpoch !== character.authorizationEpoch
|| generation.credentialVersion !== character.credentialVersion) return false;
const pin = generation.enrichmentInputPin;
if (!pin) return generation.enrichmentStatus === 'failed' || generation.enrichmentStatus === 'unavailable';
const authority = pin.authority;
return Boolean(authority && authority.userId === character.userId && authority.principal === character.ownerHash && authority.characterId === character.characterId
&& authority.grantRevision === character.grantRevision
&& authority.authorizationEpoch === character.authorizationEpoch
&& authority.credentialVersion === character.credentialVersion
&& Array.isArray(authority.scopes) && scopesEqual(authority.scopes, character.scopes));
}
function validActualScopes(scopes: readonly string[]): boolean {
return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope));
}
@@ -288,6 +333,7 @@ export const bridgeCreateOAuth = mutationGeneric({
}
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
@@ -388,6 +434,7 @@ export const bridgeCompleteLogin = mutationGeneric({
if (args.scopes.some((scope) => !args.requestedScopes.includes(scope))) throw new Error("Granted scopes exceed the bound request");
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
@@ -725,12 +772,14 @@ export const bridgeAssetsPage = queryGeneric({
&& assetStatus.authorizationEpoch === character.authorizationEpoch;
const generation = authorized ? character.activeGeneration ?? null : null;
if (!character || !generation) {
return { page: [], isDone: true, continueCursor: "", generation: null };
return { page: [], isDone: true, continueCursor: "", generation: null, enrichmentStatus: null, enrichmentVersion: null, snapshotChanged: false };
}
// The caller pins the first page's generation. Returning the new generation
// without its data prevents a read from silently mixing two snapshots.
const generationRecord = await ctx.db.query("assetGenerations").withIndex("by_user_character_generation", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId).eq("generation", generation)).first();
const enrichmentVisible = generationRecord ? enrichmentAuthorityMatches(character, generationRecord) : false;
if (args.expectedGeneration !== undefined && args.expectedGeneration !== generation) {
return { page: [], isDone: true, continueCursor: "", generation };
return { page: [], isDone: true, continueCursor: "", generation, enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null, enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null, snapshotChanged: true };
}
const page = await ctx.db
.query("assetChunks")
@@ -747,10 +796,45 @@ export const bridgeAssetsPage = queryGeneric({
isDone: page.isDone,
continueCursor: page.continueCursor,
generation,
enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null,
enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null,
snapshotChanged: false,
};
},
});
export const bridgeEnrichmentManifest = queryGeneric({
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.optional(v.string()) },
returns: v.union(enrichmentManifestValidator, v.null()),
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
const character = await findCharacter(ctx, args.userId, args.characterId);
const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === 'assets');
if (!character || character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1') || !assetStatus || (assetStatus.status !== 'ready' && assetStatus.status !== 'error') || assetStatus.grantRevision !== character.grantRevision || assetStatus.authorizationEpoch !== character.authorizationEpoch) return null;
const generation = character.activeGeneration;
if (!generation || (args.expectedGeneration !== undefined && args.expectedGeneration !== generation)) return null;
const record = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', generation)).first();
if (!record || !enrichmentAuthorityMatches(character, record)) return null;
return { generation, status: record.enrichmentStatus ?? null, version: record.enrichmentVersion ?? null, inputPin: record.enrichmentInputPin ?? null, unresolvedCount: record.enrichmentUnresolvedCount ?? null };
},
});
export const bridgeEnrichmentChunksPage = queryGeneric({
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.string(), paginationOpts: paginationOptsValidator },
returns: enrichmentPageValidator,
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
const character = await findCharacter(ctx, args.userId, args.characterId);
const authorized = character?.authorizationStatus === 'active' && character.scopes.includes('esi-assets.read_assets.v1');
const activeGeneration = authorized ? character?.activeGeneration ?? null : null;
if (!character || activeGeneration !== args.expectedGeneration) return { chunks: [], isDone: true, continueCursor: '', generation: activeGeneration, status: null, version: null };
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', activeGeneration)).first();
if (!generation || generation.status !== 'complete' || !enrichmentAuthorityMatches(character, generation)) return { chunks: [], isDone: true, continueCursor: '', generation: null, status: null, version: null };
const page = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', args.expectedGeneration)).order('asc').paginate(args.paginationOpts);
return { chunks: page.page.map((chunk: any) => ({ index: chunk.index, rows: chunk.rows })), isDone: page.isDone, continueCursor: page.continueCursor, generation: args.expectedGeneration, status: generation.enrichmentStatus ?? null, version: generation.enrichmentVersion ?? null };
},
});
export const bridgeBeginSync = mutationGeneric({
args: {
...bridgeArgs,
@@ -1335,7 +1419,7 @@ export const bridgeBeginSyncJob = mutationGeneric({
}
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > now()) throw new Error('Character synchronization is already leased');
const generation = newIdentifier('generation');
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, stagedAssetRowCount: 0, enrichmentRowCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'syncing', grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'syncing', datasetStatuses: statuses, syncGeneration: generation, syncLeaseExpiresAt: now() + 10 * 60 * 1000, error: undefined });
await ctx.db.patch(job._id, { generation, updatedAt: now() });
@@ -1364,18 +1448,235 @@ export const bridgeStageSyncAssets = mutationGeneric({
return null;
}
if (args.index !== stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
const localIds = new Set<string>();
for (const asset of args.assets) {
if (localIds.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
localIds.add(asset.itemId);
const existingFact = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', asset.itemId)).first();
if (existingFact) throw new Error('Snapshot contains duplicate asset IDs');
}
await ctx.db.insert('assetChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, assets: args.assets });
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1 });
for (const asset of args.assets) await ctx.db.insert('assetFacts', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: asset.itemId, typeId: asset.typeId, quantity: asset.quantity, locationId: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag, ...(asset.isSingleton === undefined ? {} : { isSingleton: asset.isSingleton }), ...(asset.isBlueprintCopy === undefined ? {} : { isBlueprintCopy: asset.isBlueprintCopy }) });
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1, stagedAssetRowCount: (generation.stagedAssetRowCount ?? 0) + args.assets.length });
return null;
},
});
export const bridgeResetSyncEnrichment = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, job);
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
const items = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
for (const chunk of chunks) await ctx.db.delete(chunk._id);
for (const item of items) await ctx.db.delete(item._id);
await ctx.db.patch(generation._id, { enrichmentStatus: undefined, enrichmentChunkCount: 0, enrichmentRowCount: 0, enrichmentByteCount: 0 });
return null;
},
});
export const bridgeStageSyncEnrichment = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), index: v.number(), rows: v.array(v.any()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!Number.isInteger(args.index) || args.index < 0 || args.rows.length > MAX_LOCATION_CHUNK) throw new Error('Invalid location enrichment chunk');
const chunkBytes = boundedJsonBytes(args.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, job);
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
try { validatePersistedLocationRows(args.rows, { authority: { userId: job.userId, principal: character.ownerHash, characterId: job.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion } }); } catch { throw new Error('Invalid location enrichment rows'); }
const existing = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('index', args.index)).first();
if (existing) {
if (JSON.stringify(existing.rows) !== JSON.stringify(args.rows)) throw new Error('Location enrichment chunk already exists');
return null;
}
const localIds = new Set<string>();
for (const row of args.rows) {
if (localIds.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
localIds.add(row.itemId);
const asset = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
assertLocationRowMatchesAsset(row, asset, job.characterId);
const existingItem = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
if (existingItem) throw new Error('Location enrichment contains duplicate item IDs');
}
const stagedChunkCount = generation.enrichmentChunkCount ?? 0;
if (stagedChunkCount >= MAX_CHUNKS || args.index > stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
if (args.index !== stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
await ctx.db.insert('locationEnrichmentChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, rows: args.rows });
for (const row of args.rows) await ctx.db.insert('locationEnrichmentItems', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: row.itemId });
const totalRows = (generation.enrichmentRowCount ?? 0) + args.rows.length;
if (totalRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
const totalBytes = (generation.enrichmentByteCount ?? 0) + chunkBytes;
if (totalBytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
await ctx.db.patch(generation._id, { enrichmentStatus: 'staging', enrichmentChunkCount: stagedChunkCount + 1, enrichmentRowCount: (generation.enrichmentRowCount ?? 0) + args.rows.length, enrichmentByteCount: totalBytes });
return null;
},
});
function boundedJsonBytes(value: unknown, limit: number, field: string): number {
let encoded: string;
try { encoded = JSON.stringify(value); } catch { throw new Error(`${field} is not serializable`); }
const bytes = new TextEncoder().encode(encoded).byteLength;
if (bytes > limit) throw new Error(`${field} exceeds byte bound`);
return bytes;
}
async function stagedAssetFacts(ctx: any, job: any, generation: any): Promise<Map<string, any>> {
if ((generation.stagedChunkCount ?? 0) > MAX_CHUNKS) throw new Error('Snapshot has too many asset chunks');
const expectedRows = generation.stagedAssetRowCount ?? 0;
if (!Number.isSafeInteger(expectedRows) || expectedRows < 0 || expectedRows > MAX_ASSET_ROWS) throw new Error('Snapshot exceeds asset row bound');
// Convex permits only one paginate() per function; the immutable manifest
// count is the bounded read limit, so never use an unbounded collect here.
if (expectedRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment source row bound exceeded');
const factsRows = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).take(MAX_PERSISTED_ENRICHMENT_ROWS + 1);
if (factsRows.length !== expectedRows) throw new Error('Snapshot asset facts are incomplete');
const facts = new Map<string, any>();
for (const asset of factsRows) {
if (facts.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
facts.set(asset.itemId, asset);
}
return facts;
}
function sameOptionalFact(left: unknown, right: unknown): boolean { return left === right || (left === undefined && right === undefined); }
function assertLocationRowMatchesAsset(row: any, asset: any, characterId: number): void {
if (row.characterId !== characterId || row.itemId !== asset.itemId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || !sameOptionalFact(row.isSingleton, asset.isSingleton) || !sameOptionalFact(row.isBlueprintCopy, asset.isBlueprintCopy)) throw new Error('Location enrichment row does not match staged asset facts');
}
function assertCanonicalContainment(rows: readonly any[], facts: Map<string, any>, characterId: number): void {
const assets = [...facts.values()].map((asset) => ({ ...asset, characterId }));
const canonical = resolveContainment({ assets: assets as any, characterId });
const byId = new Map(canonical.nodes.map((node) => [node.itemId, node]));
for (const row of rows) {
const expected = byId.get(row.itemId);
const sameRoot = expected && row.root.kind === expected.root.kind && row.root.id === expected.root.id && row.root.locationType === expected.root.locationType && row.root.locationFlag === expected.root.locationFlag;
if (!expected || !sameRoot || row.parentItemId !== expected.parentItemId || row.depth !== expected.depth || row.fitted.classification !== expected.fitted.classification || row.fitted.rawFlag !== expected.fitted.rawFlag || row.fitted.reason !== expected.fitted.reason || row.fittedClassification !== expected.fittedClassification || row.isFitted !== expected.isFitted || row.breadcrumbs.length !== expected.breadcrumbs.length || row.breadcrumbs.some((breadcrumb: any, index: number) => breadcrumb.itemId !== expected.breadcrumbs[index]?.itemId || breadcrumb.typeId !== expected.breadcrumbs[index]?.typeId)) throw new Error('Location enrichment containment projection is not canonical');
const rowReasons = new Set<string>(row.unresolved);
if (expected.unresolved.some((reason) => !rowReasons.has(reason))) throw new Error('Location enrichment containment reasons are incomplete');
const expectedLocationId = expected.root.id ?? expected.locationId;
if (row.location.id !== expectedLocationId) throw new Error('Location enrichment root location is inconsistent');
const expectedKinds: Record<string, readonly string[]> = { station: ['station', 'unknown'], solar_system: ['system', 'unknown'], other: ['structure', 'unknown'], item: ['unknown'] };
if (!(expectedKinds[expected.root.locationType] ?? ['unknown']).includes(row.location.kind)) throw new Error('Location enrichment root kind is inconsistent');
}
}
async function stagedLocationRows(ctx: any, job: any, generation: any, facts: Map<string, any>, authority: any): Promise<{ rows: any[]; ids: Set<string> }> {
const expectedChunks = generation.enrichmentChunkCount ?? 0;
if (!Number.isInteger(expectedChunks) || expectedChunks < 0 || expectedChunks > MAX_CHUNKS) throw new Error('Location enrichment chunks are incomplete');
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).order('asc').take(expectedChunks);
if (chunks.length !== expectedChunks) throw new Error('Location enrichment chunks are incomplete');
const rows: any[] = [];
const ids = new Set<string>();
let bytes = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (chunk.index !== index || !Array.isArray(chunk.rows) || chunk.rows.length > MAX_LOCATION_CHUNK) throw new Error('Location enrichment chunks are malformed');
if (rows.length + chunk.rows.length > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
bytes += boundedJsonBytes(chunk.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
if (bytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
validatePersistedLocationRows(chunk.rows, { authority });
for (const row of chunk.rows) {
if (ids.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
const asset = facts.get(row.itemId);
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
assertLocationRowMatchesAsset(row, asset, job.characterId);
ids.add(row.itemId); rows.push(row);
}
}
if ((generation.enrichmentByteCount ?? 0) !== bytes || (generation.enrichmentRowCount ?? 0) !== rows.length) throw new Error('Location enrichment row manifest is inconsistent');
return { rows, ids };
}
function validateLocationRowPinAgreement(rows: readonly any[], pin: any, characterId: number): number {
const unresolved = new Set<string>();
const namesRequested = new Set<string>(pin.names.requestedIds);
const namesResolved = new Set<string>(pin.names.resolvedIds);
const namesMissing = new Set<string>(pin.names.missingIds);
const namesBatchStates = new Map<string, string>((pin.names.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
const locationsRequested = new Set<string>(pin.locations.requestedIds);
const locationsResolved = new Set<string>(pin.locations.resolvedIds);
const locationsMissing = new Set<string>(pin.locations.missingIds);
const locationsBatchStates = new Map<string, string>((pin.locations.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
const structurePins = new Map<string, any>((pin.structures ?? []).map((observation: any) => [observation.structureId, observation]));
const referenceFacts = new Map<string, any>((pin.referenceFacts ?? []).map((fact: any) => [`${fact.kind}:${fact.id}`, fact]));
const visitReference = (reference: any): void => {
if (!reference || typeof reference !== 'object' || reference.referenceVersion !== pin.referenceVersion) throw new Error('Location row reference version does not match enrichment pin');
if (reference.kind === 'structure') {
const observation = structurePins.get(reference.id);
if (!observation || reference.name !== observation.name || reference.observedAt !== observation.observedAt || reference.expiresAt !== observation.expiresAt || (reference.system?.id !== undefined && reference.system.id !== observation.solarSystemId) || (reference.evidence === 'private_structure_access' && observation.status !== 'accessible') || (reference.evidence === 'unknown' && observation.status === 'accessible')) throw new Error('Structure row facts/freshness/source does not match enrichment pin');
} else if (reference.kind === 'station' || reference.kind === 'system' || reference.kind === 'region') {
const fact = referenceFacts.get(`${reference.kind}:${reference.id}`);
if (!fact || fact.name !== reference.name || fact.parentId !== reference.parentId || fact.evidence !== reference.evidence || fact.sourceVerified !== reference.sourceVerified || fact.systemId !== (reference.kind === 'station' ? reference.parentId : reference.system?.id ?? null) || fact.regionId !== (reference.region?.id ?? null)) throw new Error('Public location facts do not match enrichment pin');
}
if (reference.system !== null) visitReference(reference.system);
if (reference.region !== null) visitReference(reference.region);
};
for (const row of rows) {
if (row.characterId !== characterId) throw new Error('Location row character mismatch');
visitReference(row.location);
const nameRequested = namesRequested.has(row.itemId);
const locationRequested = locationsRequested.has(row.itemId);
const validateHelperStatus = (status: string, requested: boolean, resolved: boolean, missing: boolean, state: string, field: string): void => {
if (!requested) { if (status !== 'not_requested') throw new Error(`${field} row is not requested`); return; }
if (missing) {
if (status !== 'missing') throw new Error(`${field} row status disagrees with missing pin`);
} else if (resolved) {
if (status === 'not_requested' || status === 'forbidden' || status === 'unavailable') throw new Error(`${field} row status disagrees with resolved pin`);
} else if (state === 'forbidden' && status !== 'forbidden') throw new Error(`${field} row is not explicitly forbidden`);
else if (state === 'unavailable' && status !== 'unavailable') throw new Error(`${field} row is not explicitly unavailable`);
else if (state === 'observed') throw new Error(`${field} row is absent from observed pin`);
};
validateHelperStatus(row.nameStatus, nameRequested, namesResolved.has(row.itemId), namesMissing.has(row.itemId), namesBatchStates.get(row.itemId) ?? pin.names.state, 'name');
validateHelperStatus(row.locationStatus, locationRequested, locationsResolved.has(row.itemId), locationsMissing.has(row.itemId), locationsBatchStates.get(row.itemId) ?? pin.locations.state, 'location');
if (row.nameStatus === 'resolved' && !namesResolved.has(row.itemId)) throw new Error('Resolved name is absent from enrichment pin');
if (row.nameStatus === 'resolved' && typeof row.customName !== 'string') throw new Error('Resolved name has no custom name');
if (row.nameStatus !== 'resolved' && row.customName !== null) throw new Error('Unresolved name has a custom name');
if (row.nameStatus === 'not_requested' && nameRequested) throw new Error('Unrequested name row disagrees with enrichment pin');
if (row.nameStatus !== 'not_requested' && !nameRequested) throw new Error('Requested name row is absent from enrichment pin');
if (row.locationStatus === 'resolved' && !locationsResolved.has(row.itemId)) throw new Error('Resolved position is absent from enrichment pin');
if (row.locationStatus === 'resolved' && row.position === null) throw new Error('Resolved position is missing coordinates');
if (row.locationStatus !== 'resolved' && row.position !== null) throw new Error('Unresolved location has coordinates');
if (row.locationStatus === 'not_requested' && locationRequested) throw new Error('Unrequested location row disagrees with enrichment pin');
if (row.locationStatus !== 'not_requested' && !locationRequested) throw new Error('Requested location row is absent from enrichment pin');
const unresolvedReasons = new Set<string>(row.unresolved);
if (row.nameStatus === 'expired' && !unresolvedReasons.has('name_expired')) throw new Error('Expired name has no explanation');
if (row.nameStatus === 'forbidden' && !unresolvedReasons.has('name_forbidden')) throw new Error('Forbidden name has no explanation');
if (row.nameStatus === 'unavailable' && !unresolvedReasons.has('name_unavailable')) throw new Error('Unavailable name has no explanation');
if (row.locationStatus === 'expired' && !unresolvedReasons.has('location_expired')) throw new Error('Expired location has no explanation');
if (row.locationStatus === 'forbidden' && !unresolvedReasons.has('location_forbidden')) throw new Error('Forbidden location has no explanation');
if (row.locationStatus === 'unavailable' && !unresolvedReasons.has('location_unavailable')) throw new Error('Unavailable location has no explanation');
if (row.unresolved.length > 0) unresolved.add(row.itemId);
}
return unresolved.size;
}
function validJobSnapshotTimes(syncedAt: number, expiresAt: number, timestamp: number): boolean {
return Number.isFinite(syncedAt) && syncedAt >= 0 && syncedAt <= timestamp && Number.isFinite(expiresAt) && expiresAt > timestamp && expiresAt > syncedAt && expiresAt - syncedAt <= SNAPSHOT_MAX_LIFETIME_MS;
}
export const bridgeCommitSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), chunkCount: v.number(), syncedAt: v.number(), expiresAt: v.number() },
args: {
...workerBridgeArgs,
jobId: v.string(),
leaseToken: v.string(),
chunkCount: v.number(),
syncedAt: v.number(),
expiresAt: v.number(),
enrichmentStatus: v.optional(v.union(v.literal('complete'), v.literal('partial'), v.literal('failed'), v.literal('unavailable'))),
enrichmentVersion: v.optional(v.string()),
enrichmentInputPin: v.optional(v.any()),
enrichmentChunkCount: v.optional(v.number()),
enrichmentUnresolvedCount: v.optional(v.number()),
enrichmentError: v.optional(v.string()),
},
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await findJob(ctx, args.jobId);
@@ -1386,8 +1687,57 @@ export const bridgeCommitSyncJob = mutationGeneric({
if (!Number.isInteger(args.chunkCount) || args.chunkCount < 0 || args.chunkCount > MAX_CHUNKS || !validJobSnapshotTimes(args.syncedAt, args.expiresAt, now())) throw new Error('Invalid snapshot');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
if (!generation || generation.status !== 'staging' || (generation.stagedChunkCount ?? 0) !== args.chunkCount) throw new Error('Snapshot is incomplete');
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion) throw new Error('Synchronization authorization is stale');
await ctx.db.patch(generation._id, { status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt });
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion || character.authorizationStatus !== 'active') throw new Error('Synchronization authorization is stale');
const enrichmentStatus = args.enrichmentStatus;
const stagedEnrichmentChunks = generation.enrichmentChunkCount ?? 0;
if (enrichmentStatus === undefined) {
if (stagedEnrichmentChunks !== 0 || generation.enrichmentStatus !== undefined) throw new Error('Location enrichment manifest is missing');
} else {
const expectedEnrichmentChunks = args.enrichmentChunkCount ?? stagedEnrichmentChunks;
if (!Number.isInteger(expectedEnrichmentChunks) || expectedEnrichmentChunks < 0 || expectedEnrichmentChunks > MAX_CHUNKS || expectedEnrichmentChunks !== stagedEnrichmentChunks) throw new Error('Location enrichment is incomplete');
const authority = { userId: leased.userId, principal: character.ownerHash, characterId: leased.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion };
let pin: any;
if (enrichmentStatus !== 'failed' && enrichmentStatus !== 'unavailable') {
if (typeof args.enrichmentVersion !== 'string' || !args.enrichmentInputPin || typeof args.enrichmentInputPin !== 'object') throw new Error('Location enrichment version and pin are required');
boundedJsonBytes(args.enrichmentInputPin, MAX_ENRICHMENT_PIN_BYTES, 'Location enrichment pin');
try { validateReferenceEnrichmentPin(args.enrichmentInputPin, { assetGeneration: leased.generation, authority }); } catch { throw new Error('Location enrichment pin is invalid'); }
pin = args.enrichmentInputPin as any;
if (args.enrichmentVersion !== deterministicEnrichmentVersion(pin)) throw new Error('Location enrichment version is not deterministic');
}
if (enrichmentStatus === 'failed' || enrichmentStatus === 'unavailable') {
const stagedAssetRows = generation.stagedAssetRowCount ?? 0;
if (stagedEnrichmentChunks !== 0 || (generation.enrichmentRowCount ?? 0) !== 0 || (generation.enrichmentByteCount ?? 0) !== 0 || args.enrichmentInputPin !== undefined || args.enrichmentVersion !== undefined || args.enrichmentUnresolvedCount !== stagedAssetRows || typeof args.enrichmentError !== 'string' || args.enrichmentError.length === 0) throw new Error('Failed enrichment cannot publish rows or a complete pin');
} else {
const facts = await stagedAssetFacts(ctx, leased, generation);
const staged = await stagedLocationRows(ctx, leased, generation, facts, authority);
if (staged.rows.length !== facts.size || staged.ids.size !== facts.size || (generation.enrichmentRowCount ?? 0) !== facts.size) throw new Error('Location enrichment does not cover the staged asset generation');
assertCanonicalContainment(staged.rows, facts, leased.characterId);
if (enrichmentStatus === 'complete') {
const assetIds = new Set(facts.keys());
for (const part of [pin.names, pin.locations]) {
if (part.state === 'not_requested' || part.requestedIds.length !== assetIds.size || new Set(part.requestedIds).size !== assetIds.size || part.requestedIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin does not cover the staged asset generation');
if (new Set([...part.resolvedIds, ...part.missingIds]).size !== assetIds.size || part.resolvedIds.some((id: string) => !assetIds.has(id)) || part.missingIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin coverage is incomplete');
}
}
const unresolvedCount = validateLocationRowPinAgreement(staged.rows, pin, leased.characterId);
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || args.enrichmentUnresolvedCount !== unresolvedCount) throw new Error('Location enrichment unresolved count is not truthful');
if (enrichmentStatus === 'complete' && unresolvedCount !== 0) throw new Error('Complete enrichment contains unresolved rows');
if (enrichmentStatus === 'partial' && unresolvedCount === 0 && pin.names.state === 'observed' && pin.locations.state === 'observed') throw new Error('Partial enrichment has no unresolved coverage');
}
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || (args.enrichmentUnresolvedCount ?? 0) < 0) throw new Error('Invalid enrichment unresolved count');
if (args.enrichmentError !== undefined && (typeof args.enrichmentError !== 'string' || args.enrichmentError.length > MAX_ERROR_LENGTH)) throw new Error('Invalid enrichment error');
}
await ctx.db.patch(generation._id, {
status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt,
...(enrichmentStatus === undefined ? {} : {
enrichmentStatus,
...(args.enrichmentVersion === undefined ? {} : { enrichmentVersion: args.enrichmentVersion }),
...(args.enrichmentInputPin === undefined ? {} : { enrichmentInputPin: args.enrichmentInputPin }),
enrichmentChunkCount: args.enrichmentChunkCount ?? (generation.enrichmentChunkCount ?? 0),
enrichmentUnresolvedCount: args.enrichmentUnresolvedCount ?? 0,
...(args.enrichmentError === undefined ? {} : { enrichmentError: args.enrichmentError.slice(0, MAX_ERROR_LENGTH) }),
}),
});
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'ready', generation: leased.generation, syncedAt: args.syncedAt, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'ready', datasetStatuses: statuses, syncedAt: args.syncedAt, expiresAt: args.expiresAt, activeGeneration: leased.generation, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: undefined });
await ctx.db.patch(leased._id, { status: 'succeeded', updatedAt: now(), finishedAt: now(), leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, cancelRequested: undefined, lastError: undefined });
@@ -1456,6 +1806,23 @@ export const bridgeCooldown = queryGeneric({
},
});
export const bridgeRecordSyncCooldown = mutationGeneric({
args: { ...workerBridgeArgs, context: v.string(), until: v.number(), group: v.optional(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!args.context || args.context.length > 256 || !Number.isFinite(args.until) || args.until < now() || args.until > now() + 24 * 60 * 60 * 1000 || (args.group !== undefined && (args.group.length === 0 || args.group.length > 128))) throw new Error('Invalid synchronization cooldown');
const timestamp = now();
const contexts = [args.context, ...(args.group ? [`group:${args.group}`] : [])];
for (const context of contexts) {
const existing = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first();
const until = Math.max(existing?.until ?? 0, args.until);
if (existing) await ctx.db.patch(existing._id, { until, group: args.group, kind: 'server', updatedAt: timestamp });
else await ctx.db.insert('syncCooldowns', { context, until, group: args.group, kind: 'server', updatedAt: timestamp });
}
return true;
},
});
export const bridgeSyncWorkerCooldown = queryGeneric({
args: { ...workerBridgeArgs, context: v.string(), group: v.optional(v.string()) },
handler: async (ctx, args) => {