feat: add pinned inventory location enrichment
This commit is contained in:
+376
-9
@@ -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) => {
|
||||
|
||||
@@ -42,6 +42,14 @@ export const datasetStatusValidator = v.object({
|
||||
error: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const enrichmentStatusValidator = v.union(
|
||||
v.literal("staging"),
|
||||
v.literal("complete"),
|
||||
v.literal("partial"),
|
||||
v.literal("failed"),
|
||||
v.literal("unavailable"),
|
||||
);
|
||||
|
||||
export const assetValidator = v.object({
|
||||
itemId: v.string(),
|
||||
characterId: v.number(),
|
||||
@@ -136,6 +144,7 @@ export default defineSchema({
|
||||
// Optional keeps already-deployed completed generations readable; new
|
||||
// staging generations always initialize and maintain this durable counter.
|
||||
stagedChunkCount: v.optional(v.number()),
|
||||
stagedAssetRowCount: v.optional(v.number()),
|
||||
chunkCount: v.optional(v.number()),
|
||||
syncedAt: v.optional(v.number()),
|
||||
expiresAt: v.optional(v.number()),
|
||||
@@ -143,6 +152,15 @@ export default defineSchema({
|
||||
grantRevision: v.number(),
|
||||
authorizationEpoch: v.number(),
|
||||
credentialVersion: v.number(),
|
||||
/** Optional enrichment is tied to this exact immutable asset generation. */
|
||||
enrichmentStatus: v.optional(enrichmentStatusValidator),
|
||||
enrichmentVersion: v.optional(v.string()),
|
||||
enrichmentInputPin: v.optional(v.any()),
|
||||
enrichmentChunkCount: v.optional(v.number()),
|
||||
enrichmentRowCount: v.optional(v.number()),
|
||||
enrichmentByteCount: v.optional(v.number()),
|
||||
enrichmentUnresolvedCount: v.optional(v.number()),
|
||||
enrichmentError: v.optional(v.string()),
|
||||
})
|
||||
.index("by_user_character_generation", ["userId", "characterId", "generation"])
|
||||
.index("by_generation", ["generation"]),
|
||||
@@ -155,6 +173,29 @@ export default defineSchema({
|
||||
assets: v.array(assetValidator),
|
||||
}).index("by_snapshot_index", ["userId", "characterId", "generation", "index"]),
|
||||
|
||||
/** Compact per-item facts make enrichment staging independent of a full
|
||||
* generation collect. They are scoped to the immutable generation. */
|
||||
assetFacts: defineTable({
|
||||
userId: v.string(), characterId: v.number(), generation: v.string(), itemId: v.string(),
|
||||
typeId: v.number(), quantity: v.number(), locationId: v.string(), locationType: v.string(), locationFlag: v.string(),
|
||||
isSingleton: v.optional(v.boolean()), isBlueprintCopy: v.optional(v.boolean()),
|
||||
}).index("by_snapshot_item", ["userId", "characterId", "generation", "itemId"]),
|
||||
|
||||
/** One indexed identity per persisted enrichment row enforces global
|
||||
* uniqueness without rereading every prior location chunk. */
|
||||
locationEnrichmentItems: defineTable({
|
||||
userId: v.string(), characterId: v.number(), generation: v.string(), itemId: v.string(),
|
||||
}).index("by_snapshot_item", ["userId", "characterId", "generation", "itemId"]),
|
||||
|
||||
/** Private location rows are never stored in the public/reference catalog. */
|
||||
locationEnrichmentChunks: defineTable({
|
||||
userId: v.string(),
|
||||
characterId: v.number(),
|
||||
generation: v.string(),
|
||||
index: v.number(),
|
||||
rows: v.array(v.any()),
|
||||
}).index("by_snapshot_index", ["userId", "characterId", "generation", "index"]),
|
||||
|
||||
interactions: defineTable({
|
||||
userId: v.string(),
|
||||
requestId: v.string(),
|
||||
|
||||
@@ -26,11 +26,14 @@ export const CAPABILITY_REQUIREMENTS: Readonly<Record<Capability, readonly Requi
|
||||
asset_valuation: ['assets', 'market_data'],
|
||||
manufacturing_opportunities: ['assets', 'blueprints', 'skills', 'market_data'],
|
||||
inventory_query: ['assets', 'reference_data'],
|
||||
/** Explicit opt-in for character-scoped private structure evidence. */
|
||||
inventory_locations: ['assets', 'reference_data'],
|
||||
wallet_balance: ['wallet_balance'],
|
||||
};
|
||||
|
||||
/** Assets scope verified against the 2026-08-18 ESI OpenAPI contract. */
|
||||
export const ESI_ASSETS_SCOPE = 'esi-assets.read_assets.v1';
|
||||
export const ESI_STRUCTURES_SCOPE = 'esi-universe.read_structures.v1';
|
||||
export const ESI_WALLET_SCOPE = 'esi-wallet.read_character_wallet.v1';
|
||||
export const ESI_BLUEPRINTS_SCOPE = 'esi-characters.read_blueprints.v1';
|
||||
export const ESI_SKILLS_SCOPE = 'esi-skills.read_skills.v1';
|
||||
@@ -68,13 +71,16 @@ export const CAPABILITY_DEFINITIONS: Readonly<Record<Capability, CapabilityDefin
|
||||
inventory_query: {
|
||||
capability: 'inventory_query', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
|
||||
},
|
||||
inventory_locations: {
|
||||
capability: 'inventory_locations', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
|
||||
},
|
||||
wallet_balance: {
|
||||
capability: 'wallet_balance', datasets: ['wallet_balance'], requiredScopes: [ESI_WALLET_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
|
||||
},
|
||||
};
|
||||
|
||||
export const SERVER_SCOPE_ALLOWLIST: readonly string[] = [
|
||||
ESI_ASSETS_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE,
|
||||
ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE,
|
||||
];
|
||||
const SYSTEM_JOB_DATASETS: Readonly<Record<'reference' | 'public_feed' | 'maintenance', readonly DatasetKind[]>> = {
|
||||
reference: ['reference_data'],
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
import type { Asset, DecimalId, ReferenceVersion } from './model';
|
||||
export type { DecimalId } from './model';
|
||||
|
||||
/** The signed-int64 spelling used by ESI for character-scoped item IDs. */
|
||||
export const MAX_DECIMAL_ID = 9_223_372_036_854_775_807n;
|
||||
export const DEFAULT_CONTAINMENT_DEPTH = 64;
|
||||
export const MAX_CONTAINMENT_ITEMS = 50_000;
|
||||
/** Matches the reviewed T06 helper request bound. */
|
||||
export const MAX_ENRICHMENT_IDS = 1_000;
|
||||
/** Location rows include breadcrumbs/reference facts; keep persistence chunks smaller than asset chunks. */
|
||||
export const MAX_LOCATION_ENRICHMENT_CHUNK = 100;
|
||||
export const MAX_ENRICHMENT_NAME_LENGTH = 512;
|
||||
|
||||
export type InventoryLocationAuthority = {
|
||||
userId: string;
|
||||
principal: string;
|
||||
characterId: number;
|
||||
scopes: readonly string[];
|
||||
grantRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number;
|
||||
};
|
||||
|
||||
export type EnrichmentObservationState = 'observed' | 'not_requested' | 'forbidden' | 'unavailable';
|
||||
export type EnrichmentNameStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
|
||||
export type EnrichmentLocationStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
|
||||
|
||||
export type NamesObservation = {
|
||||
requestedIds: readonly DecimalId[];
|
||||
names: readonly { itemId: DecimalId; name: string }[];
|
||||
missing: readonly DecimalId[];
|
||||
retrievedAt: number;
|
||||
expiresAt: number;
|
||||
complete: boolean;
|
||||
/** Set by an adapter when the request itself was denied or could not run. */
|
||||
state?: EnrichmentObservationState;
|
||||
characterId?: number;
|
||||
};
|
||||
|
||||
export type LocationsObservation = {
|
||||
requestedIds: readonly DecimalId[];
|
||||
locations: readonly { itemId: DecimalId; position: { x: number; y: number; z: number } }[];
|
||||
missing: readonly DecimalId[];
|
||||
retrievedAt: number;
|
||||
expiresAt: number;
|
||||
complete: boolean;
|
||||
state?: EnrichmentObservationState;
|
||||
characterId?: number;
|
||||
};
|
||||
|
||||
/** Each ESI helper call remains capped at MAX_ENRICHMENT_IDS; a generation may pin bounded batches. */
|
||||
export type NamesObservationBatch = NamesObservation;
|
||||
export type LocationsObservationBatch = LocationsObservation;
|
||||
|
||||
export type NameUnavailableReason = 'name_missing' | 'name_expired' | 'name_forbidden' | 'name_unavailable';
|
||||
export type LocationUnavailableReason = 'location_missing' | 'location_expired' | 'location_forbidden' | 'location_unavailable';
|
||||
export type ContainmentUnresolvedReason =
|
||||
| 'invalid_item_id'
|
||||
| 'invalid_location_id'
|
||||
| 'duplicate_item_id'
|
||||
| 'missing_parent'
|
||||
| 'parent_unresolved'
|
||||
| 'cycle'
|
||||
| 'depth_cap'
|
||||
| 'invalid_asset'
|
||||
| 'cap_exceeded';
|
||||
export type ReferenceUnresolvedReason =
|
||||
| 'reference_unknown'
|
||||
| 'reference_unavailable'
|
||||
| 'unsafe_reference_id'
|
||||
| 'forbidden_structure'
|
||||
| 'unavailable_structure';
|
||||
export type UnresolvedReason = ContainmentUnresolvedReason | NameUnavailableReason | LocationUnavailableReason | ReferenceUnresolvedReason;
|
||||
|
||||
export type FittedClassification = 'fitted' | 'not_fitted' | 'unknown';
|
||||
export type FittedClassificationDetail = {
|
||||
classification: FittedClassification;
|
||||
/** The source flag is retained even when the classification is intentionally unknown. */
|
||||
rawFlag: string;
|
||||
reason: 'slot_flag' | 'ordinary_location_flag' | 'ambiguous_autofit' | 'ambiguous_special_bay';
|
||||
};
|
||||
|
||||
/**
|
||||
* Flags with an unambiguous fitting meaning. AutoFit and all specialized bays
|
||||
* deliberately remain unknown: ESI does not say whether they contain a fit.
|
||||
*/
|
||||
const FITTED_SLOT_FLAG = /^(?:Hi|Med|Lo|Rig|SubSystem)Slot[0-7]$/;
|
||||
const AMBIGUOUS_FIT_FLAG = new Set([
|
||||
'AutoFit', 'BoosterBay', 'Cargo', 'DroneBay', 'FighterBay', 'FighterTube0', 'FighterTube1', 'FighterTube2', 'FighterTube3', 'FighterTube4',
|
||||
'ExpeditionHold', 'FleetHangar', 'FrigateEscapeBay', 'Hangar', 'HangarAll', 'MoonMaterialBay', 'QuafeBay', 'ShipHangar', 'SubSystemBay',
|
||||
'SpecializedAmmoHold', 'SpecializedAsteroidHold', 'SpecializedCommandCenterHold', 'SpecializedFuelBay', 'SpecializedGasHold',
|
||||
'SpecializedIceHold', 'SpecializedIndustrialShipHold', 'SpecializedLargeShipHold', 'SpecializedMaterialBay', 'SpecializedMediumShipHold',
|
||||
'SpecializedMineralHold', 'SpecializedOreHold', 'SpecializedPlanetaryCommoditiesHold', 'SpecializedSalvageHold', 'SpecializedShipHold',
|
||||
'SpecializedSmallShipHold', 'StructureDeedBay', 'Wardrobe',
|
||||
]);
|
||||
|
||||
export function classifyFittedFlag(rawFlag: unknown): FittedClassificationDetail {
|
||||
const flag = typeof rawFlag === 'string' ? rawFlag : '';
|
||||
if (FITTED_SLOT_FLAG.test(flag)) return { classification: 'fitted', rawFlag: flag, reason: 'slot_flag' };
|
||||
if (flag === 'AutoFit') return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_autofit' };
|
||||
if (AMBIGUOUS_FIT_FLAG.has(flag)) return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_special_bay' };
|
||||
return { classification: 'not_fitted', rawFlag: flag, reason: 'ordinary_location_flag' };
|
||||
}
|
||||
|
||||
export type ContainmentRoot =
|
||||
| { kind: 'external'; id: DecimalId; locationType: string; locationFlag: string }
|
||||
| { kind: 'item'; id: DecimalId; locationType: 'item'; locationFlag: string }
|
||||
| { kind: 'unresolved'; id: DecimalId | null; locationType: string; locationFlag: string };
|
||||
|
||||
export type Breadcrumb = {
|
||||
itemId: DecimalId;
|
||||
typeId: number;
|
||||
/** Null is intentional; no synthetic "Item <id>" names are generated. */
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type ContainmentNode = {
|
||||
itemId: DecimalId;
|
||||
characterId: number;
|
||||
typeId: number;
|
||||
quantity: number;
|
||||
locationId: DecimalId;
|
||||
locationType: string;
|
||||
/** Exact source value, including AutoFit/special-bay values. */
|
||||
locationFlag: string;
|
||||
isSingleton?: boolean;
|
||||
isBlueprintCopy?: boolean;
|
||||
parentItemId: DecimalId | null;
|
||||
root: ContainmentRoot;
|
||||
depth: number | null;
|
||||
breadcrumbs: readonly Breadcrumb[];
|
||||
fitted: FittedClassificationDetail;
|
||||
/** Compatibility projections: ambiguous flags are null/unknown, never false by guess. */
|
||||
fittedClassification: FittedClassification;
|
||||
isFitted: boolean | null;
|
||||
unresolved: readonly ContainmentUnresolvedReason[];
|
||||
};
|
||||
|
||||
export type ContainmentInput = {
|
||||
assets: readonly Asset[];
|
||||
characterId?: number;
|
||||
names?: ReadonlyMap<DecimalId, string> | Readonly<Record<string, string>>;
|
||||
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
export type ContainmentResult = {
|
||||
nodes: readonly ContainmentNode[];
|
||||
/** Root IDs are sorted decimal strings and have no fabricated labels. */
|
||||
roots: readonly ContainmentRoot[];
|
||||
unresolved: readonly { itemId: DecimalId | null; reasons: readonly ContainmentUnresolvedReason[] }[];
|
||||
complete: boolean;
|
||||
maxDepth: number;
|
||||
};
|
||||
|
||||
export type ReferenceEnrichmentBatchPin = {
|
||||
state: EnrichmentObservationState;
|
||||
requestedIds: readonly DecimalId[];
|
||||
resolvedIds: readonly DecimalId[];
|
||||
missingIds: readonly DecimalId[];
|
||||
retrievedAt: number | null;
|
||||
expiresAt: number | null;
|
||||
contentDigest: string;
|
||||
};
|
||||
|
||||
export type ReferenceLocationFactPin = {
|
||||
kind: 'station' | 'system' | 'region';
|
||||
id: DecimalId;
|
||||
name: string | null;
|
||||
parentId: DecimalId | null;
|
||||
evidence: 'verified' | 'system_listed';
|
||||
sourceVerified: boolean;
|
||||
systemId: DecimalId | null;
|
||||
regionId: DecimalId | null;
|
||||
referenceVersion: ReferenceVersion;
|
||||
};
|
||||
|
||||
export type StructureObservationPin = {
|
||||
structureId: DecimalId;
|
||||
characterId: number;
|
||||
status: 'accessible' | 'forbidden' | 'unavailable';
|
||||
evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable';
|
||||
name: string | null;
|
||||
solarSystemId: DecimalId | null;
|
||||
ownerId: DecimalId | null;
|
||||
typeId: DecimalId | null;
|
||||
observedAt: number;
|
||||
expiresAt: number;
|
||||
referenceVersion: ReferenceVersion;
|
||||
};
|
||||
|
||||
export type ReferenceEnrichmentPin = {
|
||||
version: 1;
|
||||
assetGeneration: string;
|
||||
authority: InventoryLocationAuthority;
|
||||
referenceVersion: ReferenceVersion;
|
||||
containmentDepth: number;
|
||||
names: {
|
||||
state: EnrichmentObservationState;
|
||||
requestedIds: readonly DecimalId[];
|
||||
resolvedIds: readonly DecimalId[];
|
||||
missingIds: readonly DecimalId[];
|
||||
retrievedAt: number | null;
|
||||
expiresAt: number | null;
|
||||
contentDigest: string;
|
||||
batches?: readonly ReferenceEnrichmentBatchPin[];
|
||||
};
|
||||
locations: {
|
||||
state: EnrichmentObservationState;
|
||||
requestedIds: readonly DecimalId[];
|
||||
resolvedIds: readonly DecimalId[];
|
||||
missingIds: readonly DecimalId[];
|
||||
retrievedAt: number | null;
|
||||
expiresAt: number | null;
|
||||
contentDigest: string;
|
||||
batches?: readonly ReferenceEnrichmentBatchPin[];
|
||||
};
|
||||
structures?: readonly StructureObservationPin[];
|
||||
/** Digest is an internal consistency check; pinned referenceVersion supplies provenance. */
|
||||
referenceFacts?: readonly ReferenceLocationFactPin[];
|
||||
referenceFactsDigest?: string;
|
||||
structureRevision: string;
|
||||
};
|
||||
|
||||
export type LocationUnresolved = {
|
||||
itemId: DecimalId | null;
|
||||
reasons: readonly UnresolvedReason[];
|
||||
};
|
||||
|
||||
export type LocationReference = {
|
||||
kind: 'station' | 'system' | 'region' | 'structure' | 'unknown';
|
||||
id: DecimalId;
|
||||
name: string | null;
|
||||
parentId: DecimalId | null;
|
||||
/** `verified` and `system_listed` are intentionally not collapsed. */
|
||||
evidence: 'verified' | 'system_listed' | 'private_structure_access' | 'unknown';
|
||||
sourceVerified: boolean | null;
|
||||
referenceVersion: ReferenceVersion;
|
||||
/** Private structure access does not establish docking permission. */
|
||||
dockingPermission: 'unknown';
|
||||
/** Present for structure observations; public catalog references are null. */
|
||||
observedAt?: number | null;
|
||||
expiresAt?: number | null;
|
||||
system: LocationReference | null;
|
||||
region: LocationReference | null;
|
||||
};
|
||||
|
||||
export type EnrichedLocationNode = Omit<ContainmentNode, 'unresolved'> & {
|
||||
/** Persisted rows carry the complete merged reason set, not only graph reasons. */
|
||||
unresolved: readonly UnresolvedReason[];
|
||||
customName: string | null;
|
||||
nameStatus: EnrichmentNameStatus;
|
||||
position: { x: number; y: number; z: number } | null;
|
||||
locationStatus: EnrichmentLocationStatus;
|
||||
location: LocationReference;
|
||||
};
|
||||
|
||||
export type LocationEnrichmentResult = {
|
||||
status: 'complete' | 'partial';
|
||||
complete: boolean;
|
||||
items: readonly EnrichedLocationNode[];
|
||||
/** Aliases make the seam convenient for sync and later projection code. */
|
||||
rows: readonly EnrichedLocationNode[];
|
||||
roots: readonly LocationReference[];
|
||||
unresolved: readonly LocationUnresolved[];
|
||||
inputPin: ReferenceEnrichmentPin;
|
||||
enrichmentVersion: string;
|
||||
};
|
||||
|
||||
export type PersistedLocationValidationOptions = {
|
||||
authority?: InventoryLocationAuthority;
|
||||
};
|
||||
|
||||
/** Runtime guard used at the Convex worker boundary before private rows are persisted. */
|
||||
export function validatePersistedLocationRows(value: unknown, options?: PersistedLocationValidationOptions): asserts value is readonly EnrichedLocationNode[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_LOCATION_ENRICHMENT_CHUNK) throw new TypeError('location enrichment chunk is oversized');
|
||||
const allowed = new Set(['itemId', 'characterId', 'typeId', 'quantity', 'locationId', 'locationType', 'locationFlag', 'isSingleton', 'isBlueprintCopy', 'parentItemId', 'root', 'depth', 'breadcrumbs', 'fitted', 'fittedClassification', 'isFitted', 'unresolved', 'customName', 'nameStatus', 'position', 'locationStatus', 'location']);
|
||||
const statuses = new Set(['resolved', 'missing', 'expired', 'forbidden', 'unavailable', 'not_requested']);
|
||||
const reasons = new Set(['invalid_item_id', 'invalid_location_id', 'duplicate_item_id', 'missing_parent', 'parent_unresolved', 'cycle', 'depth_cap', 'invalid_asset', 'cap_exceeded', 'name_missing', 'name_expired', 'name_forbidden', 'name_unavailable', 'location_missing', 'location_expired', 'location_forbidden', 'location_unavailable', 'reference_unknown', 'reference_unavailable', 'unsafe_reference_id', 'forbidden_structure', 'unavailable_structure']);
|
||||
const allowedLocation = new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'referenceVersion', 'dockingPermission', 'observedAt', 'expiresAt', 'system', 'region']);
|
||||
const allowedRoot = new Set(['kind', 'id', 'locationType', 'locationFlag']);
|
||||
const allowedBreadcrumb = new Set(['itemId', 'typeId', 'name']);
|
||||
const allowedFitted = new Set(['classification', 'rawFlag', 'reason']);
|
||||
const text = (input: unknown, field: string, max: number): string => {
|
||||
if (typeof input !== 'string' || input.length === 0 || input.length > max) throw new TypeError(`${field} is invalid`);
|
||||
return input;
|
||||
};
|
||||
const id = (input: unknown, field: string): DecimalId => { assertDecimalId(input, field); return input; };
|
||||
const location = (input: unknown, depth: number): void => {
|
||||
if (depth > 3 || !input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('location evidence is invalid');
|
||||
const candidate = input as Partial<LocationReference>;
|
||||
if (Object.keys(candidate).some((key) => !allowedLocation.has(key))) throw new TypeError('location evidence has unknown fields');
|
||||
if (!['station', 'system', 'region', 'structure', 'unknown'].includes(candidate.kind ?? '')) throw new TypeError('location evidence kind is invalid');
|
||||
id(candidate.id, 'location evidence ID');
|
||||
if (candidate.name !== null && candidate.name !== undefined) text(candidate.name, 'location evidence name', MAX_ENRICHMENT_NAME_LENGTH);
|
||||
if (candidate.parentId !== null && candidate.parentId !== undefined) id(candidate.parentId, 'location evidence parent ID');
|
||||
if (!['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(candidate.evidence ?? '')) throw new TypeError('location evidence provenance is invalid');
|
||||
const privateEvidence = candidate.evidence === 'private_structure_access';
|
||||
if (privateEvidence) {
|
||||
if (!options?.authority?.scopes.includes('esi-universe.read_structures.v1')) throw new TypeError('private structure evidence requires pinned structure scope');
|
||||
if (candidate.kind !== 'structure' || candidate.sourceVerified !== true) throw new TypeError('private structure evidence source is invalid');
|
||||
} else if (candidate.kind === 'structure' && candidate.evidence !== 'unknown') throw new TypeError('structure evidence source is invalid');
|
||||
if (candidate.sourceVerified !== null && candidate.sourceVerified !== undefined && typeof candidate.sourceVerified !== 'boolean') throw new TypeError('location evidence verification is invalid');
|
||||
if (candidate.kind === 'station' && ((candidate.evidence === 'verified' && candidate.sourceVerified !== true) || (candidate.evidence === 'system_listed' && candidate.sourceVerified !== false))) throw new TypeError('station evidence source is inconsistent');
|
||||
if ((candidate.kind === 'system' || candidate.kind === 'region') && (candidate.evidence !== 'verified' || candidate.sourceVerified !== true)) throw new TypeError('public reference evidence source is inconsistent');
|
||||
if (candidate.kind === 'unknown' && (candidate.evidence !== 'unknown' || candidate.sourceVerified !== null)) throw new TypeError('unknown location evidence source is inconsistent');
|
||||
if (candidate.kind === 'structure') {
|
||||
if (candidate.name === undefined || candidate.observedAt === undefined || candidate.expiresAt === undefined || typeof candidate.observedAt !== 'number' || !Number.isSafeInteger(candidate.observedAt) || typeof candidate.expiresAt !== 'number' || !Number.isSafeInteger(candidate.expiresAt) || candidate.observedAt < 0 || candidate.expiresAt < candidate.observedAt || candidate.expiresAt > 8_640_000_000_000_000) throw new TypeError('structure evidence freshness is invalid');
|
||||
if (candidate.evidence === 'unknown' && (candidate.name !== null || candidate.sourceVerified !== null)) throw new TypeError('denied structure evidence contains private facts');
|
||||
} else if (candidate.observedAt !== undefined || candidate.expiresAt !== undefined) throw new TypeError('public location evidence contains structure freshness');
|
||||
if (candidate.system === undefined || candidate.region === undefined) throw new TypeError('location evidence hierarchy is incomplete');
|
||||
text(candidate.referenceVersion, 'location evidence reference version', 256);
|
||||
if (candidate.dockingPermission !== 'unknown') throw new TypeError('location evidence docking permission is invalid');
|
||||
if (candidate.system !== null && candidate.system !== undefined) location(candidate.system, depth + 1);
|
||||
if (candidate.region !== null && candidate.region !== undefined) location(candidate.region, depth + 1);
|
||||
};
|
||||
for (const row of value) {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new TypeError('location enrichment row is invalid');
|
||||
const candidate = row as Partial<EnrichedLocationNode>;
|
||||
if (Object.keys(candidate).some((key) => !allowed.has(key))) throw new TypeError('location enrichment row has unknown fields');
|
||||
const itemId = id(candidate.itemId, 'location row item ID');
|
||||
if (typeof candidate.characterId !== 'number' || !Number.isSafeInteger(candidate.characterId) || candidate.characterId <= 0 || typeof candidate.typeId !== 'number' || !Number.isSafeInteger(candidate.typeId) || candidate.typeId <= 0 || typeof candidate.quantity !== 'number' || !Number.isSafeInteger(candidate.quantity) || candidate.quantity < 0) throw new TypeError('location enrichment source facts are invalid');
|
||||
id(candidate.locationId, 'location row location ID');
|
||||
if (!['station', 'solar_system', 'item', 'other'].includes(text(candidate.locationType, 'location row type', 32)) || text(candidate.locationFlag, 'location row flag', 128).length > 128) throw new TypeError('location enrichment location facts are invalid');
|
||||
if (candidate.isSingleton !== undefined && typeof candidate.isSingleton !== 'boolean') throw new TypeError('location row singleton fact is invalid');
|
||||
if (candidate.isBlueprintCopy !== undefined && typeof candidate.isBlueprintCopy !== 'boolean') throw new TypeError('location row blueprint fact is invalid');
|
||||
if (candidate.parentItemId !== null && candidate.parentItemId !== undefined) id(candidate.parentItemId, 'location row parent ID');
|
||||
if (!candidate.root || typeof candidate.root !== 'object' || Array.isArray(candidate.root)) throw new TypeError('location enrichment root is invalid');
|
||||
const root = candidate.root as Partial<ContainmentRoot>;
|
||||
if (Object.keys(root).some((key) => !allowedRoot.has(key))) throw new TypeError('location root has unknown fields');
|
||||
if (!['external', 'item', 'unresolved'].includes(root.kind ?? '')) throw new TypeError('location enrichment root kind is invalid');
|
||||
if (root.id !== null && root.id !== undefined) id(root.id, 'location root ID');
|
||||
if (root.kind !== 'unresolved' && (root.id === null || root.id === undefined)) throw new TypeError('location root ID is required');
|
||||
text(root.locationType, 'location root type', 32); text(root.locationFlag, 'location root flag', 128);
|
||||
if (candidate.depth !== null && (typeof candidate.depth !== 'number' || !Number.isSafeInteger(candidate.depth) || candidate.depth < 0 || candidate.depth > DEFAULT_CONTAINMENT_DEPTH)) throw new TypeError('location row depth is invalid');
|
||||
if (!Array.isArray(candidate.breadcrumbs) || candidate.breadcrumbs.length > DEFAULT_CONTAINMENT_DEPTH + 1) throw new TypeError('location breadcrumbs are oversized');
|
||||
for (const breadcrumb of candidate.breadcrumbs) {
|
||||
if (!breadcrumb || typeof breadcrumb !== 'object') throw new TypeError('location breadcrumb is invalid');
|
||||
const value = breadcrumb as Partial<Breadcrumb>;
|
||||
if (Object.keys(value).some((key) => !allowedBreadcrumb.has(key))) throw new TypeError('location breadcrumb has unknown fields');
|
||||
id(value.itemId, 'breadcrumb item ID');
|
||||
if (typeof value.typeId !== 'number' || !Number.isSafeInteger(value.typeId) || value.typeId <= 0) throw new TypeError('breadcrumb type ID is invalid');
|
||||
if (value.name !== null && value.name !== undefined) text(value.name, 'breadcrumb name', MAX_ENRICHMENT_NAME_LENGTH);
|
||||
}
|
||||
if (!candidate.fitted || typeof candidate.fitted !== 'object' || Object.keys(candidate.fitted).some((key) => !allowedFitted.has(key)) || !['fitted', 'not_fitted', 'unknown'].includes(candidate.fitted.classification ?? '') || !['slot_flag', 'ordinary_location_flag', 'ambiguous_autofit', 'ambiguous_special_bay'].includes(candidate.fitted.reason ?? '')) throw new TypeError('fitted evidence is invalid');
|
||||
text(candidate.fitted.rawFlag, 'fitted raw flag', 128);
|
||||
if (!['fitted', 'not_fitted', 'unknown'].includes(candidate.fittedClassification ?? '') || (candidate.isFitted !== null && typeof candidate.isFitted !== 'boolean')) throw new TypeError('fitted projection is invalid');
|
||||
if (!Array.isArray(candidate.unresolved) || candidate.unresolved.length > 24) throw new TypeError('location unresolved reasons are oversized');
|
||||
if (candidate.unresolved.some((reason: unknown) => typeof reason !== 'string' || !reasons.has(reason))) throw new TypeError('location unresolved reason is invalid');
|
||||
if (candidate.customName !== null && candidate.customName !== undefined) text(candidate.customName, 'location custom name', MAX_ENRICHMENT_NAME_LENGTH);
|
||||
if (!statuses.has(candidate.nameStatus ?? '') || !statuses.has(candidate.locationStatus ?? '')) throw new TypeError('location enrichment status is invalid');
|
||||
if (candidate.position !== null && candidate.position !== undefined && (!candidate.position || ![candidate.position.x, candidate.position.y, candidate.position.z].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate) && Math.abs(coordinate) <= 1e30))) throw new TypeError('location enrichment position is invalid');
|
||||
location(candidate.location, 0);
|
||||
void itemId;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDecimalId(value: unknown): value is DecimalId {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return false;
|
||||
try { return BigInt(value) <= MAX_DECIMAL_ID; } catch { return false; }
|
||||
}
|
||||
|
||||
export function assertDecimalId(value: unknown, field = 'ID'): asserts value is DecimalId {
|
||||
if (!isDecimalId(value)) throw new TypeError(`${field} must be a positive signed-int64 decimal string`);
|
||||
}
|
||||
|
||||
export function canonicalDecimalIds(values: readonly unknown[], field = 'IDs'): DecimalId[] {
|
||||
if (!Array.isArray(values)) throw new TypeError(`${field} must be an array`);
|
||||
const output = values.map((value, index) => {
|
||||
assertDecimalId(value, `${field}[${index}]`);
|
||||
return value;
|
||||
});
|
||||
return [...new Set(output)].sort(compareDecimalIds);
|
||||
}
|
||||
|
||||
export function compareDecimalIds(left: string, right: string): number {
|
||||
const leftTrimmed = left.replace(/^0+(?=\d)/, '');
|
||||
const rightTrimmed = right.replace(/^0+(?=\d)/, '');
|
||||
return leftTrimmed.length - rightTrimmed.length || (leftTrimmed < rightTrimmed ? -1 : leftTrimmed > rightTrimmed ? 1 : 0);
|
||||
}
|
||||
|
||||
export function decimalIdToSafeNumber(id: DecimalId): number | null {
|
||||
assertDecimalId(id);
|
||||
const value = BigInt(id);
|
||||
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
|
||||
}
|
||||
|
||||
function getMapValue<T>(map: ReadonlyMap<string, T> | Readonly<Record<string, T>> | undefined, key: string): T | undefined {
|
||||
if (!map) return undefined;
|
||||
if (map instanceof Map) return map.get(key);
|
||||
return (map as Readonly<Record<string, T>>)[key];
|
||||
}
|
||||
function getTypeName(map: ContainmentInput['typeNames'], key: number): string | null {
|
||||
if (!map) return null;
|
||||
const value = map instanceof Map ? map.get(key) : (map as Readonly<Record<string, string>>)[String(key)];
|
||||
return typeof value === 'string' && value.trim() ? value : null;
|
||||
}
|
||||
function safeCharacterId(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function validateAuthorityContext(authority: unknown): InventoryLocationAuthority {
|
||||
if (!authority || typeof authority !== 'object') throw new TypeError('authority context is required');
|
||||
const candidate = authority as Partial<InventoryLocationAuthority>;
|
||||
if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.principal !== 'string' || !candidate.principal || candidate.principal.length > 256) throw new TypeError('authority identity is invalid');
|
||||
if (!safeCharacterId(candidate.characterId)) throw new TypeError('authority character ID is invalid');
|
||||
const characterId = candidate.characterId;
|
||||
if (!safeCharacterId(characterId)) throw new TypeError('authority character ID is invalid');
|
||||
const scopes = candidate.scopes;
|
||||
if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope) => typeof scope !== 'string' || !scope || scope.length > 128)) throw new TypeError('authority scopes are invalid');
|
||||
const grantRevision = candidate.grantRevision;
|
||||
const authorizationEpoch = candidate.authorizationEpoch;
|
||||
const credentialVersion = candidate.credentialVersion;
|
||||
if (!safeCharacterId(grantRevision) || !safeCharacterId(authorizationEpoch) || !safeCharacterId(credentialVersion)) throw new TypeError('authority counters are invalid');
|
||||
return {
|
||||
userId: candidate.userId,
|
||||
principal: candidate.principal,
|
||||
characterId,
|
||||
scopes: [...new Set(scopes)].sort(),
|
||||
grantRevision,
|
||||
authorizationEpoch,
|
||||
credentialVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAsset(asset: unknown, characterId: number): asset is Asset {
|
||||
if (!asset || typeof asset !== 'object') return false;
|
||||
const candidate = asset as Partial<Asset>;
|
||||
return isDecimalId(candidate.itemId) && isDecimalId(candidate.locationId) && candidate.characterId === characterId && safeCharacterId(candidate.characterId) &&
|
||||
typeof candidate.typeId === 'number' && Number.isSafeInteger(candidate.typeId) && candidate.typeId > 0 &&
|
||||
typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0 &&
|
||||
typeof candidate.locationType === 'string' && typeof candidate.locationFlag === 'string' &&
|
||||
(candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean') &&
|
||||
(candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean');
|
||||
}
|
||||
|
||||
/** Resolve only the local parent graph. It never infers a parent from a numeric ID unless ESI says location_type=item. */
|
||||
export function resolveContainment(input: ContainmentInput): ContainmentResult {
|
||||
if (!input || !Array.isArray(input.assets)) throw new TypeError('assets must be an array');
|
||||
const characterId = input.characterId ?? (input.assets[0] && typeof input.assets[0] === 'object' ? (input.assets[0] as Partial<Asset>).characterId : undefined);
|
||||
if (!safeCharacterId(characterId)) throw new TypeError('containment character ID is invalid');
|
||||
const maxDepth = input.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
|
||||
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
|
||||
if (input.assets.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`containment item cap exceeded (${MAX_CONTAINMENT_ITEMS})`);
|
||||
|
||||
const duplicateIds = new Set<DecimalId>();
|
||||
const byId = new Map<DecimalId, Asset>();
|
||||
for (const asset of input.assets) {
|
||||
if (!validateAsset(asset, characterId)) continue;
|
||||
if (byId.has(asset.itemId)) duplicateIds.add(asset.itemId);
|
||||
else byId.set(asset.itemId, asset);
|
||||
}
|
||||
|
||||
type Computed = { parentItemId: DecimalId | null; root: ContainmentRoot; depth: number | null; breadcrumbs: Breadcrumb[]; reasons: Set<ContainmentUnresolvedReason> };
|
||||
type Frame = { id: DecimalId; asset: Asset };
|
||||
const computed = new Map<DecimalId, Computed>();
|
||||
const names = input.names;
|
||||
// A walk is at most maxDepth + 2 frames. The explicit budget makes the
|
||||
// bounded-work guarantee visible even for a maximum-sized snapshot.
|
||||
const maxWork = MAX_CONTAINMENT_ITEMS * (DEFAULT_CONTAINMENT_DEPTH + 2);
|
||||
let work = 0;
|
||||
|
||||
const unresolvedRoot = (asset: Asset): ContainmentRoot => ({ kind: 'unresolved', id: asset.locationType === 'item' ? asset.locationId : null, locationType: asset.locationType, locationFlag: asset.locationFlag });
|
||||
const setTerminal = (frame: Frame, reason: ContainmentUnresolvedReason): void => {
|
||||
computed.set(frame.id, {
|
||||
parentItemId: frame.asset.locationType === 'item' ? frame.asset.locationId : null,
|
||||
root: unresolvedRoot(frame.asset), depth: null, breadcrumbs: [], reasons: new Set([reason]),
|
||||
});
|
||||
};
|
||||
const resolveFrom = (start: DecimalId): void => {
|
||||
if (computed.has(start)) return;
|
||||
const path: Frame[] = [];
|
||||
const pathIndex = new Map<DecimalId, number>();
|
||||
let current = start;
|
||||
while (!computed.has(current)) {
|
||||
if (++work > maxWork) {
|
||||
const frame = path[path.length - 1] ?? { id: start, asset: byId.get(start)! };
|
||||
setTerminal(frame, 'cap_exceeded');
|
||||
break;
|
||||
}
|
||||
const cycleAt = pathIndex.get(current);
|
||||
if (cycleAt !== undefined) {
|
||||
for (const frame of path.slice(cycleAt)) setTerminal(frame, 'cycle');
|
||||
break;
|
||||
}
|
||||
const asset = byId.get(current);
|
||||
if (!asset) break;
|
||||
pathIndex.set(current, path.length);
|
||||
const frame = { id: current, asset };
|
||||
path.push(frame);
|
||||
if (asset.locationType !== 'item') {
|
||||
computed.set(current, { parentItemId: null, root: { kind: 'external', id: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag }, depth: 0, breadcrumbs: [], reasons: new Set() });
|
||||
break;
|
||||
}
|
||||
const parent = byId.get(asset.locationId);
|
||||
if (!parent) { setTerminal(frame, 'missing_parent'); break; }
|
||||
if (duplicateIds.has(asset.locationId)) { setTerminal(frame, 'parent_unresolved'); break; }
|
||||
// One extra parent frame lets a node exactly at maxDepth resolve; a
|
||||
// longer chain is deterministically unresolved without walking to its root.
|
||||
if (path.length > maxDepth + 1) { setTerminal(frame, 'depth_cap'); break; }
|
||||
current = asset.locationId;
|
||||
}
|
||||
|
||||
for (let index = path.length - 1; index >= 0; index -= 1) {
|
||||
const frame = path[index]!;
|
||||
if (computed.has(frame.id)) continue;
|
||||
const asset = frame.asset;
|
||||
if (asset.locationType !== 'item') continue;
|
||||
const parent = byId.get(asset.locationId);
|
||||
const parentResult = parent ? computed.get(parent.itemId) : undefined;
|
||||
const reasons = new Set<ContainmentUnresolvedReason>();
|
||||
if (!parent || !parentResult) {
|
||||
setTerminal(frame, 'parent_unresolved');
|
||||
continue;
|
||||
}
|
||||
const depth = parentResult.depth === null ? null : parentResult.depth + 1;
|
||||
const breadcrumbs = [...parentResult.breadcrumbs, { itemId: parent.itemId, typeId: parent.typeId, name: getMapValue(names, parent.itemId) ?? getTypeName(input.typeNames, parent.typeId) }];
|
||||
for (const reason of parentResult.reasons) reasons.add(reason === 'missing_parent' ? 'parent_unresolved' : reason);
|
||||
let root: ContainmentRoot = parentResult.root.kind === 'item' || parentResult.root.kind === 'external' ? parentResult.root : unresolvedRoot(asset);
|
||||
let finalDepth = depth;
|
||||
if (depth === null || depth > maxDepth) { reasons.add(depth === null ? 'parent_unresolved' : 'depth_cap'); finalDepth = null; root = unresolvedRoot(asset); }
|
||||
computed.set(frame.id, { parentItemId: asset.locationId, root, depth: finalDepth, breadcrumbs, reasons });
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of [...byId.keys()].sort(compareDecimalIds)) if (!duplicateIds.has(id)) resolveFrom(id);
|
||||
|
||||
const nodes: ContainmentNode[] = input.assets.map((source) => {
|
||||
const record = source !== null && typeof source === 'object' ? source as Partial<Asset> : {};
|
||||
const valid = validateAsset(source, characterId);
|
||||
const rawItemId = typeof record.itemId === 'string' ? record.itemId : '';
|
||||
const rawLocationId = typeof record.locationId === 'string' ? record.locationId : '';
|
||||
const rawLocationType = typeof record.locationType === 'string' ? record.locationType : 'unknown';
|
||||
const rawLocationFlag = typeof record.locationFlag === 'string' ? record.locationFlag : '';
|
||||
const detail = valid ? computed.get(record.itemId!) : undefined;
|
||||
const reasons = new Set<ContainmentUnresolvedReason>(detail?.reasons ?? []);
|
||||
if (!isDecimalId(rawItemId)) reasons.add('invalid_item_id');
|
||||
if (!isDecimalId(rawLocationId)) reasons.add('invalid_location_id');
|
||||
if (!valid) reasons.add('invalid_asset');
|
||||
if (valid && duplicateIds.has(record.itemId!)) reasons.add('duplicate_item_id');
|
||||
const root = detail?.root ?? { kind: 'unresolved' as const, id: isDecimalId(rawLocationId) ? rawLocationId : null, locationType: rawLocationType, locationFlag: rawLocationFlag };
|
||||
const fitted = classifyFittedFlag(rawLocationFlag);
|
||||
const base = valid ? source : {
|
||||
characterId: safeCharacterId(record.characterId) ? record.characterId : characterId,
|
||||
typeId: typeof record.typeId === 'number' && Number.isSafeInteger(record.typeId) && record.typeId > 0 ? record.typeId : 0,
|
||||
quantity: typeof record.quantity === 'number' && Number.isSafeInteger(record.quantity) && record.quantity >= 0 ? record.quantity : 0,
|
||||
locationType: rawLocationType,
|
||||
locationFlag: rawLocationFlag,
|
||||
...(typeof record.isSingleton === 'boolean' ? { isSingleton: record.isSingleton } : {}),
|
||||
...(typeof record.isBlueprintCopy === 'boolean' ? { isBlueprintCopy: record.isBlueprintCopy } : {}),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
itemId: isDecimalId(rawItemId) ? rawItemId : '', locationId: isDecimalId(rawLocationId) ? rawLocationId : '',
|
||||
parentItemId: detail?.parentItemId ?? null, root, depth: detail?.depth ?? null, breadcrumbs: detail?.breadcrumbs ?? [], fitted,
|
||||
fittedClassification: fitted.classification, isFitted: fitted.classification === 'fitted' ? true : fitted.classification === 'not_fitted' ? false : null,
|
||||
unresolved: [...reasons].sort(),
|
||||
} as ContainmentNode;
|
||||
}).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
|
||||
const roots = [...new Map(nodes.filter((node) => node.root.kind === 'external' || node.root.kind === 'item').map((node) => [`${node.root.kind}:${node.root.id}`, node.root])).values()]
|
||||
.sort((left, right) => compareDecimalIds(left.id ?? '1', right.id ?? '1'));
|
||||
const unresolved = nodes.filter((node) => node.unresolved.length > 0).map((node) => ({ itemId: node.itemId || null, reasons: node.unresolved }));
|
||||
return { nodes, roots, unresolved, complete: unresolved.length === 0, maxDepth };
|
||||
}
|
||||
|
||||
function finiteTimestamp(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new TypeError(`${field} is invalid`);
|
||||
return value;
|
||||
}
|
||||
function validateObservationIds(ids: unknown, field: string): DecimalId[] {
|
||||
if (!Array.isArray(ids)) throw new TypeError(`${field} must be an array`);
|
||||
// Check the raw container before touching any element. This is important for
|
||||
// duplicate-heavy/proxy inputs and keeps malformed work below the helper cap.
|
||||
if (ids.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} IDs`);
|
||||
return ids.map((value, index) => { assertDecimalId(value, `${field}[${index}]`); return value; });
|
||||
}
|
||||
function validateBoundedEntries(entries: unknown, field: string): asserts entries is readonly unknown[] {
|
||||
if (!Array.isArray(entries)) throw new TypeError(`${field} must be an array`);
|
||||
if (entries.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} entries`);
|
||||
}
|
||||
function observationState(value: unknown, fallback: EnrichmentObservationState = 'observed'): EnrichmentObservationState {
|
||||
const state = value === undefined ? fallback : value;
|
||||
if (state !== 'observed' && state !== 'not_requested' && state !== 'forbidden' && state !== 'unavailable') throw new TypeError('invalid enrichment observation state');
|
||||
return state;
|
||||
}
|
||||
|
||||
export type ObservationPlan = {
|
||||
state: EnrichmentObservationState;
|
||||
requestedIds: readonly DecimalId[];
|
||||
};
|
||||
|
||||
export type ObservationBatchPlan = ObservationPlan & {
|
||||
batches: readonly (NamesObservation | LocationsObservation)[];
|
||||
};
|
||||
|
||||
function mergeObservationState(states: readonly EnrichmentObservationState[]): EnrichmentObservationState {
|
||||
if (states.length === 0) return 'not_requested';
|
||||
if (states.every((state) => state === 'observed')) return 'observed';
|
||||
if (states.includes('forbidden')) return 'forbidden';
|
||||
if (states.includes('unavailable')) return 'unavailable';
|
||||
return 'not_requested';
|
||||
}
|
||||
|
||||
/** Validate each bounded helper response while retaining all batches in the plan. */
|
||||
export function normalizeObservationBatches(
|
||||
observations: readonly (NamesObservation | LocationsObservation)[] | undefined,
|
||||
kind: 'names' | 'locations',
|
||||
explicitState?: EnrichmentObservationState,
|
||||
explicitRequestedIds?: readonly DecimalId[],
|
||||
): ObservationBatchPlan {
|
||||
if (!observations || observations.length === 0) {
|
||||
const plan = normalizeObservationPlan(undefined, kind, explicitState, explicitRequestedIds);
|
||||
return { ...plan, batches: [] };
|
||||
}
|
||||
if (!Number.isSafeInteger(observations.length) || observations.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new RangeError(`${kind} observation batches exceed the generation bound`);
|
||||
const plans = observations.map((observation) => normalizeObservationPlan(observation, kind));
|
||||
const requested = plans.flatMap((plan) => plan.requestedIds);
|
||||
if (new Set(requested).size !== requested.length) throw new TypeError(`${kind} observation batches contain duplicate requested IDs`);
|
||||
const canonicalRequested = canonicalDecimalIds(requested, `${kind}RequestedIds`);
|
||||
if (explicitRequestedIds !== undefined) {
|
||||
if (!Array.isArray(explicitRequestedIds) || explicitRequestedIds.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`${kind}RequestedIds exceeds ${MAX_CONTAINMENT_ITEMS} IDs`);
|
||||
const explicit = canonicalDecimalIds(explicitRequestedIds.map((value, index) => { assertDecimalId(value, `${kind}RequestedIds[${index}]`); return value; }), `${kind}RequestedIds`);
|
||||
if (stableSerialize(explicit) !== stableSerialize(canonicalRequested)) throw new TypeError(`${kind} requested IDs conflict with observation batches`);
|
||||
}
|
||||
const states = plans.map((plan) => plan.state);
|
||||
const state = mergeObservationState(states);
|
||||
if (explicitState !== undefined && explicitState !== state) throw new TypeError(`${kind} state conflicts with observation batches`);
|
||||
return { state, requestedIds: canonicalRequested, batches: observations };
|
||||
}
|
||||
|
||||
export function normalizeObservationPlan(
|
||||
observation: NamesObservation | LocationsObservation | undefined,
|
||||
kind: 'names' | 'locations',
|
||||
explicitState?: EnrichmentObservationState,
|
||||
explicitRequestedIds?: readonly DecimalId[],
|
||||
): ObservationPlan {
|
||||
const rawExplicitRequested = explicitRequestedIds === undefined ? undefined : validateObservationIds(explicitRequestedIds, `${kind}RequestedIds`);
|
||||
if (rawExplicitRequested && new Set(rawExplicitRequested).size !== rawExplicitRequested.length) throw new TypeError(`${kind} requested IDs are duplicated`);
|
||||
const requested = rawExplicitRequested === undefined ? undefined : canonicalDecimalIds(rawExplicitRequested, `${kind}RequestedIds`);
|
||||
if (observation) {
|
||||
const embedded = observationState(observation.state);
|
||||
observationPin(observation, kind);
|
||||
if (explicitState !== undefined && explicitState !== embedded) throw new TypeError(`${kind} state conflicts with observation state`);
|
||||
const observedIds = canonicalDecimalIds(observation.requestedIds, `${kind}.requestedIds`);
|
||||
if (requested && stableSerialize(requested) !== stableSerialize(observedIds)) throw new TypeError(`${kind} requested IDs conflict with observation`);
|
||||
return { state: embedded, requestedIds: observedIds };
|
||||
}
|
||||
const state = observationState(explicitState, requested && requested.length > 0 ? 'unavailable' : 'not_requested');
|
||||
if (state === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
|
||||
return { state, requestedIds: requested ?? [] };
|
||||
}
|
||||
|
||||
export function observationPin(observation: NamesObservation | LocationsObservation | undefined, kind: 'names' | 'locations', state: EnrichmentObservationState = 'not_requested'): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
|
||||
if (!observation) {
|
||||
const effectiveState = observationState(state, 'not_requested');
|
||||
if (effectiveState === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
|
||||
return { state: effectiveState, requestedIds: [], resolvedIds: [], missingIds: [], retrievedAt: null, expiresAt: null, contentDigest: digest(stableSerialize({ state: effectiveState, kind })) };
|
||||
}
|
||||
const rawRequestedIds = validateObservationIds(observation.requestedIds, `${kind}.requestedIds`);
|
||||
if (new Set(rawRequestedIds).size !== rawRequestedIds.length) throw new TypeError(`${kind} observation requested IDs are duplicated`);
|
||||
const requestedIds = canonicalDecimalIds(rawRequestedIds);
|
||||
const rawMissingIds = validateObservationIds(observation.missing, `${kind}.missing`);
|
||||
if (new Set(rawMissingIds).size !== rawMissingIds.length) throw new TypeError(`${kind} observation missing IDs are duplicated`);
|
||||
const rawNames = (observation as NamesObservation).names;
|
||||
const rawLocations = (observation as LocationsObservation).locations;
|
||||
if (kind === 'names') validateBoundedEntries(rawNames, `${kind}.names`);
|
||||
else validateBoundedEntries(rawLocations, `${kind}.locations`);
|
||||
const returned = kind === 'names'
|
||||
? rawNames.map((entry: { itemId: DecimalId }) => entry.itemId)
|
||||
: rawLocations.map((entry: { itemId: DecimalId }) => entry.itemId);
|
||||
const resolvedIds = canonicalDecimalIds(returned, `${kind}.resolvedIds`);
|
||||
const missingIds = canonicalDecimalIds(rawMissingIds);
|
||||
const requestedSet = new Set(requestedIds);
|
||||
if (new Set([...resolvedIds, ...missingIds]).size !== resolvedIds.length + missingIds.length) throw new TypeError(`${kind} observation overlaps resolved and missing IDs`);
|
||||
if ([...resolvedIds, ...missingIds].some((id) => !requestedSet.has(id))) throw new TypeError(`${kind} observation contains an unrequested ID`);
|
||||
const expectedComplete = missingIds.length === 0 && resolvedIds.length === requestedIds.length;
|
||||
if (observation.complete !== expectedComplete) throw new TypeError(`${kind} observation completeness is inconsistent`);
|
||||
if (kind === 'names') {
|
||||
const names = (observation as NamesObservation).names;
|
||||
if (new Set(names.map((entry) => entry.itemId)).size !== names.length || names.some((entry) => !isDecimalId(entry.itemId) || typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_ENRICHMENT_NAME_LENGTH)) throw new TypeError('names observation contains invalid or duplicate names');
|
||||
} else {
|
||||
const locations = (observation as LocationsObservation).locations;
|
||||
if (new Set(locations.map((entry) => entry.itemId)).size !== locations.length || locations.some((entry) => !isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z))) throw new TypeError('locations observation contains invalid or duplicate positions');
|
||||
}
|
||||
const retrievedAt = finiteTimestamp(observation.retrievedAt, `${kind}.retrievedAt`);
|
||||
const expiresAt = finiteTimestamp(observation.expiresAt, `${kind}.expiresAt`);
|
||||
if (expiresAt < retrievedAt) throw new TypeError(`${kind} observation expiry precedes retrieval`);
|
||||
const effectiveState = observationState(observation.state);
|
||||
const content = kind === 'names'
|
||||
? (observation as NamesObservation).names.map((entry: { itemId: DecimalId; name: string }) => ({ itemId: entry.itemId, name: entry.name })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId))
|
||||
: (observation as LocationsObservation).locations.map((entry: { itemId: DecimalId; position: { x: number; y: number; z: number } }) => ({ itemId: entry.itemId, position: entry.position })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
|
||||
const eligibleIds = effectiveState === 'observed' ? resolvedIds : [];
|
||||
const eligibleMissing = effectiveState === 'observed' ? missingIds : [];
|
||||
const contentDigest = digest(stableSerialize(effectiveState === 'observed' ? content : { state: effectiveState, requestedIds }));
|
||||
return { state: effectiveState, requestedIds, resolvedIds: eligibleIds, missingIds: eligibleMissing, retrievedAt, expiresAt, contentDigest };
|
||||
}
|
||||
|
||||
function observationBatchPin(
|
||||
observations: readonly (NamesObservation | LocationsObservation)[],
|
||||
kind: 'names' | 'locations',
|
||||
state: EnrichmentObservationState,
|
||||
requestedIds: readonly DecimalId[],
|
||||
): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
|
||||
// Preserve each batch's own state/content digest. An unavailable batch must
|
||||
// not erase a successful neighboring batch from the generation pin.
|
||||
const pins = observations.map((observation) => observationPin(observation, kind));
|
||||
const resolvedIds = canonicalDecimalIds(pins.flatMap((pin) => pin.resolvedIds), `${kind}.resolvedIds`);
|
||||
const missingIds = canonicalDecimalIds(pins.flatMap((pin) => pin.missingIds), `${kind}.missingIds`);
|
||||
const times = pins.flatMap((pin) => [pin.retrievedAt, pin.expiresAt]).filter((value): value is number => value !== null);
|
||||
return {
|
||||
state,
|
||||
requestedIds: [...requestedIds],
|
||||
resolvedIds,
|
||||
missingIds,
|
||||
retrievedAt: times.length ? Math.min(...pins.flatMap((pin) => pin.retrievedAt).filter((value): value is number => value !== null)) : null,
|
||||
expiresAt: times.length ? Math.min(...pins.flatMap((pin) => pin.expiresAt).filter((value): value is number => value !== null)) : null,
|
||||
contentDigest: digest(stableSerialize({ kind, state, requestedIds, batches: pins.map((pin) => ({ ...pin, requestedIds: undefined })) })),
|
||||
batches: pins,
|
||||
} as ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'];
|
||||
}
|
||||
|
||||
export function createReferenceEnrichmentPin(args: {
|
||||
assetGeneration: string;
|
||||
authority: InventoryLocationAuthority;
|
||||
referenceVersion: string;
|
||||
names?: NamesObservation;
|
||||
namesBatches?: readonly NamesObservation[];
|
||||
locations?: LocationsObservation;
|
||||
locationsBatches?: readonly LocationsObservation[];
|
||||
namesState?: EnrichmentObservationState;
|
||||
locationsState?: EnrichmentObservationState;
|
||||
namesRequestedIds?: readonly DecimalId[];
|
||||
locationsRequestedIds?: readonly DecimalId[];
|
||||
structureRevision?: string;
|
||||
structureObservations?: readonly StructureObservationPin[];
|
||||
referenceFacts?: readonly ReferenceLocationFactPin[];
|
||||
containmentDepth?: number;
|
||||
}): ReferenceEnrichmentPin {
|
||||
if (typeof args.assetGeneration !== 'string' || !args.assetGeneration) throw new TypeError('asset generation is required');
|
||||
if (typeof args.referenceVersion !== 'string' || !args.referenceVersion) throw new TypeError('reference version is required');
|
||||
const containmentDepth = args.containmentDepth ?? DEFAULT_CONTAINMENT_DEPTH;
|
||||
if (!Number.isSafeInteger(containmentDepth) || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError('containment depth is invalid');
|
||||
const authority = validateAuthorityContext(args.authority);
|
||||
const namesBatches = args.namesBatches ?? (args.names ? [args.names] : []);
|
||||
const locationsBatches = args.locationsBatches ?? (args.locations ? [args.locations] : []);
|
||||
const namesPlan = normalizeObservationBatches(namesBatches, 'names', args.namesState, args.namesRequestedIds);
|
||||
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', args.locationsState, args.locationsRequestedIds);
|
||||
const namesObservation = args.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined);
|
||||
const locationsObservation = args.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined);
|
||||
const names = namesBatches.length > 1
|
||||
? observationBatchPin(namesBatches, 'names', namesPlan.state, namesPlan.requestedIds) as ReferenceEnrichmentPin['names']
|
||||
: observationPin(namesObservation, 'names', namesPlan.state) as ReferenceEnrichmentPin['names'];
|
||||
const locations = locationsBatches.length > 1
|
||||
? observationBatchPin(locationsBatches, 'locations', locationsPlan.state, locationsPlan.requestedIds) as ReferenceEnrichmentPin['locations']
|
||||
: observationPin(locationsObservation, 'locations', locationsPlan.state) as ReferenceEnrichmentPin['locations'];
|
||||
if (!namesObservation && namesBatches.length <= 1) {
|
||||
names.requestedIds = namesPlan.requestedIds;
|
||||
names.contentDigest = digest(stableSerialize({ state: names.state, requestedIds: names.requestedIds }));
|
||||
}
|
||||
if (!locationsObservation && locationsBatches.length <= 1) {
|
||||
locations.requestedIds = locationsPlan.requestedIds;
|
||||
locations.contentDigest = digest(stableSerialize({ state: locations.state, requestedIds: locations.requestedIds }));
|
||||
}
|
||||
const structures = args.structureObservations?.map((observation) => ({ ...observation })) ?? undefined;
|
||||
if (structures && structures.length > 1_000) throw new RangeError('structure observations exceed the generation bound');
|
||||
const referenceFacts = args.referenceFacts?.map((fact) => ({ ...fact })).sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)) ?? undefined;
|
||||
if (referenceFacts && referenceFacts.length > 3_000) throw new RangeError('reference location facts exceed the generation bound');
|
||||
return { version: 1, assetGeneration: args.assetGeneration, authority, referenceVersion: args.referenceVersion, containmentDepth, names, locations, ...(structures === undefined ? {} : { structures }), ...(referenceFacts === undefined ? {} : { referenceFacts, referenceFactsDigest: digest(stableSerialize(referenceFacts)) }), structureRevision: args.structureRevision ?? 'none' };
|
||||
}
|
||||
|
||||
/** Validate an immutable generation pin at the persistence boundary. */
|
||||
export function validateReferenceEnrichmentPin(value: unknown, expected?: { assetGeneration?: string; referenceVersion?: string; authority?: InventoryLocationAuthority }): asserts value is ReferenceEnrichmentPin {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('enrichment pin is invalid');
|
||||
const candidate = value as Partial<ReferenceEnrichmentPin>;
|
||||
const allowedPin = new Set(['version', 'assetGeneration', 'authority', 'referenceVersion', 'containmentDepth', 'names', 'locations', 'structures', 'referenceFacts', 'referenceFactsDigest', 'structureRevision']);
|
||||
if (Object.keys(candidate).some((key) => !allowedPin.has(key))) throw new TypeError('enrichment pin has unknown fields');
|
||||
if (candidate.version !== 1 || typeof candidate.assetGeneration !== 'string' || candidate.assetGeneration.length === 0 || candidate.assetGeneration.length > 256) throw new TypeError('enrichment pin generation is invalid');
|
||||
if (expected?.assetGeneration !== undefined && candidate.assetGeneration !== expected.assetGeneration) throw new TypeError('enrichment pin generation does not match');
|
||||
if (typeof candidate.referenceVersion !== 'string' || candidate.referenceVersion.length === 0 || candidate.referenceVersion.length > 256) throw new TypeError('enrichment pin reference version is invalid');
|
||||
if (expected?.referenceVersion !== undefined && candidate.referenceVersion !== expected.referenceVersion) throw new TypeError('enrichment pin reference version does not match');
|
||||
if (!candidate.authority || typeof candidate.authority !== 'object' || Array.isArray(candidate.authority) || Object.keys(candidate.authority as object).some((key) => !new Set(['userId', 'principal', 'characterId', 'scopes', 'grantRevision', 'authorizationEpoch', 'credentialVersion']).has(key))) throw new TypeError('enrichment pin authority has unknown fields');
|
||||
const rawAuthority = candidate.authority as Partial<InventoryLocationAuthority>;
|
||||
const rawScopes = rawAuthority.scopes;
|
||||
if (!Array.isArray(rawScopes) || stableSerialize(rawScopes) !== stableSerialize([...rawScopes].sort())) throw new TypeError('enrichment pin authority scopes are not canonical');
|
||||
const authority = validateAuthorityContext(candidate.authority);
|
||||
if (authority.userId.length > 256 || authority.principal.length > 256 || authority.scopes.length > 64 || authority.scopes.some((scope) => scope.length > 128)) throw new TypeError('enrichment pin authority bounds are invalid');
|
||||
if (expected?.authority) {
|
||||
const required = validateAuthorityContext(expected.authority);
|
||||
if (authority.userId !== required.userId || authority.principal !== required.principal || authority.characterId !== required.characterId || authority.grantRevision !== required.grantRevision || authority.authorizationEpoch !== required.authorizationEpoch || authority.credentialVersion !== required.credentialVersion || stableSerialize(authority.scopes) !== stableSerialize(required.scopes)) throw new TypeError('enrichment pin authority does not match');
|
||||
}
|
||||
const containmentDepth = candidate.containmentDepth;
|
||||
if (!Number.isSafeInteger(containmentDepth) || typeof containmentDepth !== 'number' || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH || typeof candidate.structureRevision !== 'string' || candidate.structureRevision.length === 0 || candidate.structureRevision.length > 256) throw new TypeError('enrichment pin bounds are invalid');
|
||||
const validatePart = (part: unknown, field: 'names' | 'locations'): void => {
|
||||
if (!part || typeof part !== 'object' || Array.isArray(part)) throw new TypeError(`enrichment pin ${field} is invalid`);
|
||||
const item = part as ReferenceEnrichmentPin['names'];
|
||||
const allowedPart = new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest', 'batches']);
|
||||
if (Object.keys(item).some((key) => !allowedPart.has(key))) throw new TypeError(`enrichment pin ${field} has unknown fields`);
|
||||
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(item.state)) throw new TypeError(`enrichment pin ${field} state is invalid`);
|
||||
const lists = [item.requestedIds, item.resolvedIds, item.missingIds];
|
||||
if (lists.some((list) => !Array.isArray(list) || list.length > MAX_CONTAINMENT_ITEMS)) throw new TypeError(`enrichment pin ${field} IDs are oversized`);
|
||||
const requested = canonicalDecimalIds(item.requestedIds, `${field}.requestedIds`);
|
||||
const resolved = canonicalDecimalIds(item.resolvedIds, `${field}.resolvedIds`);
|
||||
const missing = canonicalDecimalIds(item.missingIds, `${field}.missingIds`);
|
||||
if (stableSerialize(requested) !== stableSerialize(item.requestedIds) || stableSerialize(resolved) !== stableSerialize(item.resolvedIds) || stableSerialize(missing) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} IDs are not canonical`);
|
||||
const requestedSet = new Set(requested);
|
||||
if ([...resolved, ...missing].some((id) => !requestedSet.has(id)) || new Set([...resolved, ...missing]).size !== resolved.length + missing.length) throw new TypeError(`enrichment pin ${field} IDs are inconsistent`);
|
||||
if (typeof item.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(item.contentDigest)) throw new TypeError(`enrichment pin ${field} digest is invalid`);
|
||||
const times = [item.retrievedAt, item.expiresAt];
|
||||
if (times.some((time) => time !== null && (typeof time !== 'number' || !Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (item.retrievedAt === null) !== (item.expiresAt === null) || (item.retrievedAt !== null && item.expiresAt !== null && item.expiresAt < item.retrievedAt)) throw new TypeError(`enrichment pin ${field} freshness is invalid`);
|
||||
if (item.state === 'not_requested' && (item.requestedIds.length !== 0 || item.resolvedIds.length !== 0 || item.missingIds.length !== 0 || item.retrievedAt !== null || item.expiresAt !== null)) throw new TypeError(`enrichment pin ${field} not-requested state is inconsistent`);
|
||||
if (item.state === 'observed' && (item.retrievedAt === null || item.expiresAt === null)) throw new TypeError(`enrichment pin ${field} observed state is missing freshness`);
|
||||
if (item.batches !== undefined) {
|
||||
if (!Array.isArray(item.batches) || item.batches.length === 0 || item.batches.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new TypeError(`enrichment pin ${field} batches are oversized`);
|
||||
const requestedUnion = new Set<string>(); const resolvedUnion = new Set<string>(); const missingUnion = new Set<string>();
|
||||
const states: EnrichmentObservationState[] = [];
|
||||
for (const batch of item.batches) {
|
||||
if (!batch || typeof batch !== 'object' || Array.isArray(batch) || Object.keys(batch).some((key) => !new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest']).has(key))) throw new TypeError(`enrichment pin ${field} batch is invalid`);
|
||||
const candidateBatch = batch as ReferenceEnrichmentBatchPin;
|
||||
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(candidateBatch.state) || !Array.isArray(candidateBatch.requestedIds) || candidateBatch.requestedIds.length > MAX_ENRICHMENT_IDS || !Array.isArray(candidateBatch.resolvedIds) || !Array.isArray(candidateBatch.missingIds)) throw new TypeError(`enrichment pin ${field} batch shape is invalid`);
|
||||
const batchRequested = canonicalDecimalIds(candidateBatch.requestedIds, `${field}.batch.requestedIds`);
|
||||
const batchResolved = canonicalDecimalIds(candidateBatch.resolvedIds, `${field}.batch.resolvedIds`);
|
||||
const batchMissing = canonicalDecimalIds(candidateBatch.missingIds, `${field}.batch.missingIds`);
|
||||
if (stableSerialize(batchRequested) !== stableSerialize(candidateBatch.requestedIds) || stableSerialize(batchResolved) !== stableSerialize(candidateBatch.resolvedIds) || stableSerialize(batchMissing) !== stableSerialize(candidateBatch.missingIds) || batchResolved.some((id) => !batchRequested.includes(id)) || batchMissing.some((id) => !batchRequested.includes(id)) || new Set([...batchResolved, ...batchMissing]).size !== batchResolved.length + batchMissing.length) throw new TypeError(`enrichment pin ${field} batch IDs are inconsistent`);
|
||||
if (candidateBatch.state !== 'observed' && (batchResolved.length > 0 || batchMissing.length > 0)) throw new TypeError(`enrichment pin ${field} batch failure retains body IDs`);
|
||||
if (typeof candidateBatch.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(candidateBatch.contentDigest) || [candidateBatch.retrievedAt, candidateBatch.expiresAt].some((time) => time !== null && (!Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (candidateBatch.retrievedAt === null) !== (candidateBatch.expiresAt === null) || (candidateBatch.retrievedAt !== null && candidateBatch.expiresAt !== null && candidateBatch.expiresAt < candidateBatch.retrievedAt)) throw new TypeError(`enrichment pin ${field} batch freshness is invalid`);
|
||||
states.push(candidateBatch.state); batchRequested.forEach((id) => requestedUnion.add(id)); batchResolved.forEach((id) => resolvedUnion.add(id)); batchMissing.forEach((id) => missingUnion.add(id));
|
||||
}
|
||||
const mergedState = states.every((state) => state === 'observed') ? 'observed' : states.includes('forbidden') ? 'forbidden' : states.includes('unavailable') ? 'unavailable' : 'not_requested';
|
||||
if (mergedState !== item.state || stableSerialize(canonicalDecimalIds([...requestedUnion])) !== stableSerialize(item.requestedIds) || stableSerialize(canonicalDecimalIds([...resolvedUnion])) !== stableSerialize(item.resolvedIds) || stableSerialize(canonicalDecimalIds([...missingUnion])) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} aggregate batches are inconsistent`);
|
||||
} else if (item.state !== 'observed' && (item.resolvedIds.length !== 0 || item.missingIds.length !== 0)) throw new TypeError(`enrichment pin ${field} unresolved state has resolved IDs`);
|
||||
};
|
||||
validatePart(candidate.names, 'names');
|
||||
validatePart(candidate.locations, 'locations');
|
||||
if (candidate.referenceFacts !== undefined) {
|
||||
if (!Array.isArray(candidate.referenceFacts) || candidate.referenceFacts.length > 3_000 || typeof candidate.referenceFactsDigest !== 'string' || candidate.referenceFactsDigest !== digest(stableSerialize(candidate.referenceFacts))) throw new TypeError('enrichment pin reference facts are invalid');
|
||||
let previousFact = '';
|
||||
for (const fact of candidate.referenceFacts) {
|
||||
if (!fact || typeof fact !== 'object' || Array.isArray(fact) || Object.keys(fact).some((key) => !new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'systemId', 'regionId', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin reference fact is invalid');
|
||||
const referenceFact = fact as ReferenceLocationFactPin;
|
||||
assertDecimalId(referenceFact.id, 'enrichment pin reference fact ID');
|
||||
if (!['station', 'system', 'region'].includes(referenceFact.kind) || (referenceFact.kind === 'station' && !['verified', 'system_listed'].includes(referenceFact.evidence)) || (referenceFact.kind !== 'station' && referenceFact.evidence !== 'verified') || referenceFact.sourceVerified !== (referenceFact.evidence === 'verified') || (referenceFact.name !== null && (typeof referenceFact.name !== 'string' || !referenceFact.name.trim() || referenceFact.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (referenceFact.parentId !== null && !isDecimalId(referenceFact.parentId)) || (referenceFact.systemId !== null && !isDecimalId(referenceFact.systemId)) || (referenceFact.regionId !== null && !isDecimalId(referenceFact.regionId)) || (referenceFact.kind === 'station' && referenceFact.parentId !== referenceFact.systemId) || (referenceFact.kind === 'system' && referenceFact.parentId !== referenceFact.regionId) || (referenceFact.kind === 'region' && (referenceFact.parentId !== null || referenceFact.systemId !== null || referenceFact.regionId !== null)) || referenceFact.referenceVersion !== candidate.referenceVersion) throw new TypeError('enrichment pin reference fact is inconsistent');
|
||||
const key = `${referenceFact.kind}:${referenceFact.id}`;
|
||||
if (key <= previousFact) throw new TypeError('enrichment pin reference facts are not canonical');
|
||||
previousFact = key;
|
||||
}
|
||||
} else if (candidate.referenceFactsDigest !== undefined) throw new TypeError('enrichment pin reference facts digest is orphaned');
|
||||
if (candidate.structures !== undefined) {
|
||||
if (!Array.isArray(candidate.structures) || candidate.structures.length > 1_000) throw new TypeError('enrichment pin structure observations are oversized');
|
||||
const seenStructures = new Set<string>();
|
||||
for (const structure of candidate.structures) {
|
||||
if (!structure || typeof structure !== 'object' || Array.isArray(structure) || Object.keys(structure).some((key) => !new Set(['structureId', 'characterId', 'status', 'evidence', 'name', 'solarSystemId', 'ownerId', 'typeId', 'observedAt', 'expiresAt', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin structure observation is invalid');
|
||||
const observation = structure as StructureObservationPin;
|
||||
assertDecimalId(observation.structureId, 'enrichment pin structure ID');
|
||||
if (seenStructures.has(observation.structureId) || !Number.isSafeInteger(observation.characterId) || observation.characterId !== authority.characterId || !['accessible', 'forbidden', 'unavailable'].includes(observation.status) || !['esi_structure_access', 'esi_forbidden', 'unavailable'].includes(observation.evidence) || (observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') || (observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') || (observation.status === 'unavailable' && observation.evidence !== 'unavailable') || (observation.status !== 'accessible' && (observation.name !== null || observation.solarSystemId !== null || observation.ownerId !== null || observation.typeId !== null)) || (observation.name !== null && (typeof observation.name !== 'string' || !observation.name.trim() || observation.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (observation.solarSystemId !== null && !isDecimalId(observation.solarSystemId)) || (observation.ownerId !== null && !isDecimalId(observation.ownerId)) || (observation.typeId !== null && !isDecimalId(observation.typeId)) || typeof observation.referenceVersion !== 'string' || observation.referenceVersion !== candidate.referenceVersion || !Number.isSafeInteger(observation.observedAt) || !Number.isSafeInteger(observation.expiresAt) || observation.observedAt < 0 || observation.expiresAt < observation.observedAt || observation.expiresAt > 8_640_000_000_000_000) throw new TypeError('enrichment pin structure observation is inconsistent');
|
||||
seenStructures.add(observation.structureId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** Stable JSON for pin hashes; object insertion order and caller ID order never affect it. */
|
||||
export function stableSerialize(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`;
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
function digest(value: string): string {
|
||||
// Two independent 32-bit FNV passes avoid a Node-only dependency in this domain module.
|
||||
let first = 2166136261;
|
||||
let second = 2166136261 ^ 0x9e3779b9;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
first = Math.imul(first ^ code, 16777619);
|
||||
second = Math.imul(second ^ (code + index), 2246822519);
|
||||
}
|
||||
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
export function deterministicEnrichmentVersion(pin: ReferenceEnrichmentPin, algorithmVersion = 'containment-v1'): string {
|
||||
return `${algorithmVersion}-${digest(stableSerialize(pin))}`;
|
||||
}
|
||||
|
||||
export function nameStatusFor(id: DecimalId, observation: NamesObservation | undefined, now: number, requested = true): EnrichmentNameStatus {
|
||||
if (!requested || !observation) return 'not_requested';
|
||||
const state = observationState(observation.state);
|
||||
if (state === 'not_requested') return 'not_requested';
|
||||
if (state === 'forbidden') return 'forbidden';
|
||||
if (state === 'unavailable') return 'unavailable';
|
||||
if (now < observation.retrievedAt) return 'unavailable';
|
||||
if (now >= observation.expiresAt) return 'expired';
|
||||
if (observation.names.some((entry) => entry.itemId === id)) return 'resolved';
|
||||
if (observation.missing.includes(id)) return 'missing';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
export function locationStatusFor(id: DecimalId, observation: LocationsObservation | undefined, now: number, requested = true): EnrichmentLocationStatus {
|
||||
if (!requested || !observation) return 'not_requested';
|
||||
const state = observationState(observation.state);
|
||||
if (state === 'not_requested') return 'not_requested';
|
||||
if (state === 'forbidden') return 'forbidden';
|
||||
if (state === 'unavailable') return 'unavailable';
|
||||
if (now < observation.retrievedAt) return 'unavailable';
|
||||
if (now >= observation.expiresAt) return 'expired';
|
||||
if (observation.locations.some((entry) => entry.itemId === id)) return 'resolved';
|
||||
if (observation.missing.includes(id)) return 'missing';
|
||||
return 'unavailable';
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export interface MarketEstimates {
|
||||
estimates(typeIds: number[]): Promise<PriceSnapshot>;
|
||||
}
|
||||
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data' | 'wallet_balance' | 'reference_data';
|
||||
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities' | 'inventory_query' | 'wallet_balance';
|
||||
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities' | 'inventory_query' | 'inventory_locations' | 'wallet_balance';
|
||||
export type Action = { kind: 'connect_character' | 'synchronize'; label: string; href: string };
|
||||
export type MissingRequirement = { requirement: Requirement; characterId?: number; reason: 'authorization' | 'synchronization' | 'stale' | 'not_implemented' };
|
||||
export type CapabilityGate =
|
||||
|
||||
@@ -10,6 +10,8 @@ import { decimalIskToCents } from "../../domain/money";
|
||||
export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const;
|
||||
export const ESI_BASE_URL = "https://esi.evetech.net" as const;
|
||||
export const ESI_ASSETS_SCOPE = "esi-assets.read_assets.v1" as const;
|
||||
/** Character-scoped private structure endpoint scope. */
|
||||
export const ESI_STRUCTURES_SCOPE = "esi-universe.read_structures.v1" as const;
|
||||
export const ESI_WALLET_SCOPE = "esi-wallet.read_character_wallet.v1" as const;
|
||||
/** Endpoint-specific bounds are deliberately well below the old unbounded page cap. */
|
||||
export const ESI_ASSET_MAX_PAGES = 1_000;
|
||||
@@ -19,6 +21,7 @@ export const ESI_ASSET_HELPER_MAX_IDS = 1_000;
|
||||
export const ESI_ASSET_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
export const ESI_ASSET_NAME_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
||||
export const ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
export const ESI_STRUCTURE_MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
export const ESI_WALLET_MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
export const ESI_MARKET_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
export const ESI_MARKET_MAX_ROWS = 20_000;
|
||||
@@ -28,6 +31,7 @@ export const ESI_READ_OPERATIONS = {
|
||||
characterAssets: { method: "GET", path: "characters/{character_id}/assets", pagination: "x-pages", requiredScope: ESI_ASSETS_SCOPE, maxPages: ESI_ASSET_MAX_PAGES, maxRows: ESI_ASSET_MAX_ROWS_PER_PAGE, maxResponseBytes: ESI_ASSET_MAX_RESPONSE_BYTES },
|
||||
characterAssetNames: { method: "POST", path: "characters/{character_id}/assets/names", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_NAME_MAX_RESPONSE_BYTES },
|
||||
characterAssetLocations: { method: "POST", path: "characters/{character_id}/assets/locations", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES },
|
||||
characterStructure: { method: "GET", path: "universe/structures/{structure_id}", pagination: "none", requiredScope: ESI_STRUCTURES_SCOPE, maxRows: 1, maxResponseBytes: ESI_STRUCTURE_MAX_RESPONSE_BYTES },
|
||||
characterWalletBalance: { method: "GET", path: "characters/{character_id}/wallet", pagination: "none", requiredScope: ESI_WALLET_SCOPE, maxRows: 1, maxResponseBytes: ESI_WALLET_MAX_RESPONSE_BYTES },
|
||||
marketPrices: { method: "GET", path: "markets/prices", pagination: "none", requiredScope: undefined, maxRows: ESI_MARKET_MAX_ROWS, maxResponseBytes: ESI_MARKET_MAX_RESPONSE_BYTES },
|
||||
} as const;
|
||||
@@ -117,6 +121,18 @@ export type EsiAssetLocation = {
|
||||
position: { x: number; y: number; z: number };
|
||||
};
|
||||
|
||||
/** A private structure response is access evidence only; docking remains unknown. */
|
||||
export type EsiStructureObservation = {
|
||||
structureId: string;
|
||||
name: string | null;
|
||||
solarSystemId: string | null;
|
||||
ownerId: string | null;
|
||||
typeId: string | null;
|
||||
position: { x: number; y: number; z: number } | null;
|
||||
retrievedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
/** A helper response is intentionally explicit about IDs ESI did not return. */
|
||||
export type EsiAssetNamesObservation = {
|
||||
requestedIds: readonly string[];
|
||||
@@ -435,8 +451,8 @@ function parseAssetNames(
|
||||
if (!wanted.has(itemId)) throw new EsiSchemaError(endpoint, `name ${index}.item_id was not requested`);
|
||||
if (seen.has(itemId)) throw new EsiSchemaError(endpoint, `duplicate item_id ${itemId}`);
|
||||
seen.add(itemId);
|
||||
if (typeof raw.name !== "string" || raw.name.length === 0) {
|
||||
throw new EsiSchemaError(endpoint, `name ${index}.name must be a non-empty string`);
|
||||
if (typeof raw.name !== "string" || raw.name.length === 0 || raw.name.length > 512) {
|
||||
throw new EsiSchemaError(endpoint, `name ${index}.name must be a bounded non-empty string`);
|
||||
}
|
||||
return { itemId, name: raw.name };
|
||||
});
|
||||
@@ -471,6 +487,34 @@ function parseAssetLocations(
|
||||
return { locations, missing: requestedIds.filter((itemId) => !seen.has(itemId)) };
|
||||
}
|
||||
|
||||
/** Parse the fixed, character-scoped private structure response without coercing IDs through Number. */
|
||||
function parseStructure(body: string, endpoint: string, requestedStructureId: string): Omit<EsiStructureObservation, 'retrievedAt' | 'expiresAt'> {
|
||||
const value = parseJson(body, endpoint);
|
||||
if (!isRecord(value)) throw new EsiSchemaError(endpoint, "body must be an object");
|
||||
const structureId = requestedStructureId;
|
||||
const solarSystemId = value.solar_system_id === undefined || value.solar_system_id === null
|
||||
? null : positiveDecimalId(value.solar_system_id, "solar_system_id", endpoint);
|
||||
const ownerId = value.owner_id === undefined || value.owner_id === null
|
||||
? null : positiveDecimalId(value.owner_id, "owner_id", endpoint);
|
||||
const typeId = value.type_id === undefined || value.type_id === null
|
||||
? null : positiveDecimalId(value.type_id, "type_id", endpoint);
|
||||
const name = value.name;
|
||||
if (typeof name !== "string" || name.length === 0 || name.length > 512) {
|
||||
throw new EsiSchemaError(endpoint, "name must be a bounded non-empty string");
|
||||
}
|
||||
if (solarSystemId === null || ownerId === null) throw new EsiSchemaError(endpoint, "solar_system_id and owner_id are required");
|
||||
let position: { x: number; y: number; z: number } | null = null;
|
||||
if (value.position !== undefined && value.position !== null) {
|
||||
if (!isRecord(value.position)) throw new EsiSchemaError(endpoint, "position must be an object");
|
||||
const x = Number(numberText(value.position.x, "position.x", endpoint));
|
||||
const y = Number(numberText(value.position.y, "position.y", endpoint));
|
||||
const z = Number(numberText(value.position.z, "position.z", endpoint));
|
||||
if (![x, y, z].every(Number.isFinite)) throw new EsiSchemaError(endpoint, "position must contain finite coordinates");
|
||||
position = { x, y, z };
|
||||
}
|
||||
return { structureId, name, solarSystemId, ownerId, typeId, position };
|
||||
}
|
||||
|
||||
function parseWalletBalance(body: string, endpoint: string): string {
|
||||
const value = parseJson(body, endpoint);
|
||||
const text = numberText(value, "wallet balance", endpoint);
|
||||
@@ -1379,6 +1423,34 @@ export class EsiClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one private structure through the fixed typed endpoint. The cache key
|
||||
* includes the complete character authority context, so this cannot leak a
|
||||
* structure observation across users, grants, or credentials.
|
||||
*/
|
||||
public async getStructure(
|
||||
characterId: number,
|
||||
structureId: string | number | bigint,
|
||||
accessToken: string,
|
||||
authorizationKey: AuthorizationKey,
|
||||
): Promise<EsiStructureObservation> {
|
||||
validateCharacterId(characterId);
|
||||
validateAccessToken(accessToken);
|
||||
const canonicalId = canonicalAssetIds([structureId], "/universe/structures")[0]!;
|
||||
const endpoint = `/universe/structures/${canonicalId}`;
|
||||
const context = authorizationContext(characterId, authorizationKey, ESI_STRUCTURES_SCOPE);
|
||||
const result = await this.read(endpoint, `structure:${context}:${canonicalId}`, {
|
||||
method: "GET",
|
||||
operation: "characterStructure",
|
||||
accessToken,
|
||||
fallbackTtlMs: 60 * 60 * 1000,
|
||||
rateLimitContext: context,
|
||||
validateBody: (responseBody) => { parseStructure(responseBody, endpoint, canonicalId); },
|
||||
});
|
||||
const parsed = parseStructure(result.body, endpoint, canonicalId);
|
||||
return { ...parsed, retrievedAt: result.entry.receivedAt, expiresAt: result.entry.expiresAt };
|
||||
}
|
||||
|
||||
/** Read a single signed-ISK wallet balance; T04 normalizes sub-cent values. */
|
||||
public async getWalletBalance(
|
||||
characterId: number,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Asset } from '../../domain/model';
|
||||
import {
|
||||
DEFAULT_CONTAINMENT_DEPTH,
|
||||
MAX_CONTAINMENT_ITEMS,
|
||||
resolveContainment,
|
||||
type ContainmentInput,
|
||||
type ContainmentResult,
|
||||
} from '../../domain/inventory-location';
|
||||
|
||||
export { classifyFittedFlag, compareDecimalIds, isDecimalId, resolveContainment } from '../../domain/inventory-location';
|
||||
export type {
|
||||
Breadcrumb,
|
||||
ContainmentInput,
|
||||
ContainmentNode,
|
||||
ContainmentResult,
|
||||
ContainmentRoot,
|
||||
FittedClassification,
|
||||
FittedClassificationDetail,
|
||||
} from '../../domain/inventory-location';
|
||||
|
||||
export const CONTAINMENT_MAX_ITEMS = MAX_CONTAINMENT_ITEMS;
|
||||
export const CONTAINMENT_MAX_DEPTH = DEFAULT_CONTAINMENT_DEPTH;
|
||||
|
||||
/**
|
||||
* Feature-local façade used by the sync worker. The resolver is pure: no
|
||||
* reference lookups, private names, or valuation are performed here, so a
|
||||
* staged result can be retried without changing its parent graph.
|
||||
*/
|
||||
export class ContainmentResolver {
|
||||
readonly maxDepth: number;
|
||||
constructor(maxDepth = DEFAULT_CONTAINMENT_DEPTH) {
|
||||
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) {
|
||||
throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
|
||||
}
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
|
||||
resolve(input: Omit<ContainmentInput, 'maxDepth'>): ContainmentResult {
|
||||
return resolveContainment({ ...input, maxDepth: this.maxDepth });
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveContainmentGraph(input: ContainmentInput): ContainmentResult {
|
||||
return resolveContainment(input);
|
||||
}
|
||||
|
||||
/** Compatibility name for callers that describe this operation as a graph walk. */
|
||||
export const resolveContainmentTree = resolveContainmentGraph;
|
||||
export const buildContainmentGraph = resolveContainmentGraph;
|
||||
export const resolveAssetContainment = resolveContainmentGraph;
|
||||
|
||||
/** A helper for sync adapters that only need to stage the source assets. */
|
||||
export function validateContainmentInput(assets: readonly Asset[], characterId: number): void {
|
||||
if (!Array.isArray(assets) || assets.length > CONTAINMENT_MAX_ITEMS) throw new RangeError(`containment item cap exceeded (${CONTAINMENT_MAX_ITEMS})`);
|
||||
if (!Number.isSafeInteger(characterId) || characterId < 0) throw new TypeError('containment character ID is invalid');
|
||||
// resolveContainment performs the full runtime validation and fails closed;
|
||||
// calling it here intentionally avoids a second, potentially divergent validator.
|
||||
resolveContainment({ assets, characterId, maxDepth: 0 });
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import type {
|
||||
ReferenceReader,
|
||||
ReferenceReaderHandle,
|
||||
ReferenceReaderPort,
|
||||
ReferenceStationRecord,
|
||||
ReferenceSystemRecord,
|
||||
} from '../../domain/reference';
|
||||
import type { Asset } from '../../domain/model';
|
||||
import {
|
||||
|
||||
createReferenceEnrichmentPin,
|
||||
decimalIdToSafeNumber,
|
||||
compareDecimalIds,
|
||||
DEFAULT_CONTAINMENT_DEPTH,
|
||||
deterministicEnrichmentVersion,
|
||||
isDecimalId,
|
||||
locationStatusFor,
|
||||
nameStatusFor,
|
||||
resolveContainment,
|
||||
validateAuthorityContext,
|
||||
type DecimalId,
|
||||
type EnrichedLocationNode,
|
||||
type EnrichmentLocationStatus,
|
||||
type EnrichmentNameStatus,
|
||||
type EnrichmentObservationState,
|
||||
type LocationEnrichmentResult,
|
||||
type LocationReference,
|
||||
type LocationsObservation,
|
||||
type NamesObservation,
|
||||
normalizeObservationBatches,
|
||||
type ReferenceEnrichmentPin,
|
||||
type ReferenceLocationFactPin,
|
||||
type InventoryLocationAuthority,
|
||||
type LocationUnresolved,
|
||||
type UnresolvedReason,
|
||||
} from '../../domain/inventory-location';
|
||||
import { EsiError, EsiHttpError } from '../esi';
|
||||
import {
|
||||
validatePrivateStructureContext,
|
||||
type PrivateStructureAccessObservation,
|
||||
type PrivateStructureContext,
|
||||
type PrivateStructurePort,
|
||||
} from './private-structure-cache';
|
||||
|
||||
export const CHARACTER_ASSETS_SCOPE = 'esi-assets.read_assets.v1' as const;
|
||||
export const MAX_REFERENCE_ROOTS = 1_000;
|
||||
|
||||
export type LocationEnrichmentRequest = {
|
||||
assets: readonly Asset[];
|
||||
authority: InventoryLocationAuthority;
|
||||
/** T07's staged snapshot generation; it is never inferred from asset content. */
|
||||
assetGeneration: string;
|
||||
names?: NamesObservation;
|
||||
namesBatches?: readonly NamesObservation[];
|
||||
locations?: LocationsObservation;
|
||||
locationsBatches?: readonly LocationsObservation[];
|
||||
namesState?: 'not_requested' | 'forbidden' | 'unavailable';
|
||||
locationsState?: 'not_requested' | 'forbidden' | 'unavailable';
|
||||
namesRequestedIds?: readonly DecimalId[];
|
||||
locationsRequestedIds?: readonly DecimalId[];
|
||||
structureRevision?: string;
|
||||
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
export type LocationServiceOptions = {
|
||||
reference: ReferenceReaderPort | ReferenceReaderHandle | ReferenceReader;
|
||||
structures?: PrivateStructurePort;
|
||||
now?: () => number;
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
type PinnedReference = ReferenceReader & { pinnedVersion: string; close?: () => void };
|
||||
|
||||
type RootOutcome = {
|
||||
location: LocationReference;
|
||||
reasons: readonly UnresolvedReason[];
|
||||
structureObservation?: PrivateStructureAccessObservation;
|
||||
};
|
||||
|
||||
function finiteNow(clock: () => number): number {
|
||||
const value = clock();
|
||||
if (!Number.isFinite(value)) throw new RangeError('location service clock must return a finite timestamp');
|
||||
return value;
|
||||
}
|
||||
function decimalIdForReference(id: DecimalId): number | null { return decimalIdToSafeNumber(id); }
|
||||
function referenceId(value: number): DecimalId { return String(value); }
|
||||
function uniqueReasons(reasons: readonly UnresolvedReason[]): UnresolvedReason[] { return [...new Set(reasons)].sort(); }
|
||||
function nameMap(observation: NamesObservation | undefined, batches: readonly NamesObservation[] = []): Map<DecimalId, string> {
|
||||
const result = new Map<DecimalId, string>();
|
||||
for (const source of batches.length ? batches : observation ? [observation] : []) {
|
||||
for (const entry of source.names) if (isDecimalId(entry.itemId) && typeof entry.name === 'string' && entry.name.trim()) result.set(entry.itemId, entry.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function positionMap(observation: LocationsObservation | undefined, batches: readonly LocationsObservation[] = []): Map<DecimalId, { x: number; y: number; z: number }> {
|
||||
const result = new Map<DecimalId, { x: number; y: number; z: number }>();
|
||||
for (const source of batches.length ? batches : observation ? [observation] : []) {
|
||||
for (const entry of source.locations) {
|
||||
if (!isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z)) continue;
|
||||
result.set(entry.itemId, { ...entry.position });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function observationMap<T extends NamesObservation | LocationsObservation>(observations: readonly T[]): Map<DecimalId, T> {
|
||||
const result = new Map<DecimalId, T>();
|
||||
for (const observation of observations) for (const id of observation.requestedIds) result.set(id, observation);
|
||||
return result;
|
||||
}
|
||||
function privateContext(authority: InventoryLocationAuthority, referenceVersion: string): PrivateStructureContext {
|
||||
return validatePrivateStructureContext({ ...validateAuthorityContext(authority), referenceVersion });
|
||||
}
|
||||
function helperStatus<T extends EnrichmentNameStatus | EnrichmentLocationStatus>(
|
||||
observation: NamesObservation | LocationsObservation | undefined,
|
||||
requested: boolean,
|
||||
state: EnrichmentObservationState | undefined,
|
||||
resolver: (id: DecimalId, observation: any, now: number, requested: boolean) => T,
|
||||
id: DecimalId,
|
||||
now: number,
|
||||
): T {
|
||||
if (observation) return resolver(id, observation, now, requested);
|
||||
if (!requested || state === 'not_requested') return 'not_requested' as T;
|
||||
if (state === 'observed') throw new TypeError('observed helper state requires an observation');
|
||||
return (state ?? 'unavailable') as T;
|
||||
}
|
||||
function emptyUnknown(id: DecimalId, version: string): LocationReference {
|
||||
return { kind: 'unknown', id, name: null, parentId: null, evidence: 'unknown', sourceVerified: null, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
|
||||
}
|
||||
function stationReference(station: ReferenceStationRecord, version: string, system: LocationReference | null, region: LocationReference | null): LocationReference {
|
||||
return {
|
||||
kind: 'station', id: referenceId(station.id), name: typeof station.name === 'string' ? station.name : null, parentId: referenceId(station.systemId), evidence: station.evidence, sourceVerified: station.sourceVerified,
|
||||
referenceVersion: version, dockingPermission: 'unknown', system, region,
|
||||
};
|
||||
}
|
||||
function systemReference(system: ReferenceSystemRecord, version: string, region: LocationReference | null): LocationReference {
|
||||
return {
|
||||
kind: 'system', id: referenceId(system.id), name: typeof system.name === 'string' ? system.name : null, parentId: system.regionId === null ? null : referenceId(system.regionId), evidence: 'verified', sourceVerified: true,
|
||||
referenceVersion: version, dockingPermission: 'unknown', system: null, region,
|
||||
};
|
||||
}
|
||||
function regionReference(id: number, name: string | null, version: string): LocationReference {
|
||||
return { kind: 'region', id: referenceId(id), name, parentId: null, evidence: 'verified', sourceVerified: true, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one immutable reference handle for an entire enrichment. Callers
|
||||
* cannot observe a station from one reference artifact and its region from
|
||||
* another. The returned handle is closed after all reads complete.
|
||||
*/
|
||||
function pinReference(reference: LocationServiceOptions['reference']): PinnedReference {
|
||||
const candidate = reference as Partial<ReferenceReaderPort> & Partial<ReferenceReaderHandle>;
|
||||
if (typeof candidate.pin === 'function') {
|
||||
const pinned = candidate.pin() as PinnedReference;
|
||||
if (!pinned || typeof pinned.pinnedVersion !== 'string' || !pinned.pinnedVersion) {
|
||||
try { pinned?.close?.(); } catch { /* preserve the pin validation failure */ }
|
||||
throw new Error('reference pin did not provide a version');
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
const version = candidate.pinnedVersion;
|
||||
if (typeof version !== 'string' || !version) throw new Error('reference reader must provide a pinned version');
|
||||
return reference as PinnedReference;
|
||||
}
|
||||
|
||||
async function readRegion(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
|
||||
const rows = await reader.regions({ ids: [id] });
|
||||
const row = rows.find((candidate) => candidate.id === id);
|
||||
return row ? regionReference(row.id, row.name, version) : null;
|
||||
}
|
||||
|
||||
async function readSystem(reader: ReferenceReader, id: number, version: string): Promise<{ reference: LocationReference | null; row: ReferenceSystemRecord | null }> {
|
||||
const rows = await reader.systems({ ids: [id] });
|
||||
const row = rows.find((candidate) => candidate.id === id);
|
||||
if (!row) return { reference: null, row: null };
|
||||
const region = row.regionId === null ? null : await readRegion(reader, row.regionId, version);
|
||||
return { reference: systemReference(row, version, region), row };
|
||||
}
|
||||
|
||||
async function readStation(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
|
||||
const rows = await reader.stations({ ids: [id] });
|
||||
const station = rows.find((candidate) => candidate.id === id);
|
||||
if (!station) return null;
|
||||
const systemResult = await readSystem(reader, station.systemId, version);
|
||||
const region = systemResult.reference?.region ?? (station.regionId === null ? null : await readRegion(reader, station.regionId, version));
|
||||
return stationReference(station, version, systemResult.reference, region);
|
||||
}
|
||||
|
||||
async function structureLocation(value: PrivateStructureAccessObservation, version: string, reader: ReferenceReader): Promise<{ location: LocationReference; reasons: UnresolvedReason[] }> {
|
||||
let system: LocationReference | null = null;
|
||||
const reasons: UnresolvedReason[] = [];
|
||||
if (value.status === 'accessible' && value.solarSystemId !== undefined && value.solarSystemId !== null) {
|
||||
const numericSystemId = decimalIdForReference(value.solarSystemId);
|
||||
if (numericSystemId === null) reasons.push('unsafe_reference_id');
|
||||
else {
|
||||
const result = await readSystem(reader, numericSystemId, version);
|
||||
system = result.reference;
|
||||
if (!system || (result.row?.regionId !== null && !system.region)) reasons.push('reference_unknown');
|
||||
}
|
||||
}
|
||||
return {
|
||||
location: {
|
||||
kind: 'structure', id: value.structureId, name: typeof value.name === 'string' ? value.name : null, parentId: null,
|
||||
evidence: value.status === 'accessible' ? 'private_structure_access' : 'unknown',
|
||||
sourceVerified: value.status === 'accessible' ? true : null,
|
||||
observedAt: value.observedAt, expiresAt: value.expiresAt,
|
||||
referenceVersion: version, dockingPermission: 'unknown', system, region: system?.region ?? null,
|
||||
},
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded location/containment enrichment. It returns a result even when
|
||||
* portions are unavailable so T07 can stage and publish only when its own
|
||||
* completeness policy permits. No result includes valuation or quantity
|
||||
* multiplication; quantity remains the source asset quantity on each row.
|
||||
*/
|
||||
export class InventoryLocationService {
|
||||
private readonly reference: LocationServiceOptions['reference'];
|
||||
private readonly structures?: PrivateStructurePort;
|
||||
private readonly now: () => number;
|
||||
private readonly maxDepth: number | undefined;
|
||||
|
||||
constructor(options: LocationServiceOptions) {
|
||||
this.reference = options.reference;
|
||||
this.structures = options.structures;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.maxDepth = options.maxDepth;
|
||||
}
|
||||
|
||||
async enrich(request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
|
||||
const authority = validateAuthorityContext(request.authority);
|
||||
if (!authority.scopes.includes(CHARACTER_ASSETS_SCOPE)) throw new TypeError('character asset scope is required for location enrichment');
|
||||
if (typeof request.assetGeneration !== 'string' || !request.assetGeneration) throw new TypeError('asset generation is required');
|
||||
if (!Array.isArray(request.assets)) throw new TypeError('assets must be an array');
|
||||
for (const asset of request.assets) {
|
||||
if (!asset || typeof asset !== 'object' || asset.characterId !== authority.characterId) throw new TypeError('asset character or shape is invalid');
|
||||
}
|
||||
if (request.names?.characterId !== undefined && request.names.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
|
||||
if (request.locations?.characterId !== undefined && request.locations.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
|
||||
for (const observation of request.namesBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
|
||||
for (const observation of request.locationsBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
|
||||
const now = finiteNow(this.now);
|
||||
const namesBatches = request.namesBatches ?? (request.names ? [request.names] : []);
|
||||
const locationsBatches = request.locationsBatches ?? (request.locations ? [request.locations] : []);
|
||||
const namesPlan = normalizeObservationBatches(namesBatches, 'names', request.namesState, request.namesRequestedIds);
|
||||
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', request.locationsState, request.locationsRequestedIds);
|
||||
const containmentDepth = request.maxDepth ?? this.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
|
||||
let pinned: PinnedReference | undefined;
|
||||
try {
|
||||
pinned = pinReference(this.reference);
|
||||
const version = pinned.pinnedVersion;
|
||||
const freshNameBatches = namesBatches.filter((observation) => (observation.state === undefined || observation.state === 'observed') && now >= observation.retrievedAt && now < observation.expiresAt);
|
||||
const containment = resolveContainment({ assets: request.assets, characterId: authority.characterId, names: freshNameBatches.length > 0 ? nameMap(request.names, freshNameBatches) : undefined, typeNames: request.typeNames, maxDepth: containmentDepth });
|
||||
const roots = new Map<string, RootOutcome>();
|
||||
let referenceRootCount = 0;
|
||||
for (const root of containment.roots) {
|
||||
if (root.kind !== 'external') continue;
|
||||
const key = `${root.locationType}:${root.id}`;
|
||||
if (roots.has(key)) continue;
|
||||
if (referenceRootCount >= MAX_REFERENCE_ROOTS) {
|
||||
roots.set(key, { location: emptyUnknown(root.id, version), reasons: ['reference_unavailable'] });
|
||||
continue;
|
||||
}
|
||||
referenceRootCount += 1;
|
||||
roots.set(key, await this.resolveRoot(root.locationType, root.id, authority, version, pinned, now));
|
||||
}
|
||||
const referenceFacts: ReferenceLocationFactPin[] = [];
|
||||
const seenReferenceFacts = new Set<string>();
|
||||
const collectReferenceFacts = (location: LocationReference | null): void => {
|
||||
if (!location || (location.kind !== 'station' && location.kind !== 'system' && location.kind !== 'region')) return;
|
||||
const key = `${location.kind}:${location.id}`;
|
||||
if (!seenReferenceFacts.has(key)) {
|
||||
seenReferenceFacts.add(key);
|
||||
referenceFacts.push({ kind: location.kind, id: location.id, name: location.name, parentId: location.parentId, evidence: location.evidence as 'verified' | 'system_listed', sourceVerified: location.sourceVerified === true, systemId: location.kind === 'station' ? location.parentId : location.system?.id ?? null, regionId: location.region?.id ?? null, referenceVersion: version });
|
||||
}
|
||||
collectReferenceFacts(location.system); collectReferenceFacts(location.region);
|
||||
};
|
||||
for (const outcome of roots.values()) collectReferenceFacts(outcome.location);
|
||||
const structureObservations = [...roots.values()].flatMap((outcome) => outcome.structureObservation ? [{ structureId: outcome.structureObservation.structureId, characterId: outcome.structureObservation.characterId, status: outcome.structureObservation.status, evidence: outcome.structureObservation.evidence, name: outcome.structureObservation.name, solarSystemId: outcome.structureObservation.solarSystemId ?? null, ownerId: outcome.structureObservation.ownerId ?? null, typeId: outcome.structureObservation.typeId ?? null, observedAt: outcome.structureObservation.observedAt, expiresAt: outcome.structureObservation.expiresAt, referenceVersion: version }] : []);
|
||||
const pin = createReferenceEnrichmentPin({
|
||||
assetGeneration: request.assetGeneration, authority, referenceVersion: version, names: request.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined), namesBatches: namesBatches.length > 1 ? namesBatches : undefined, locations: request.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined), locationsBatches: locationsBatches.length > 1 ? locationsBatches : undefined,
|
||||
namesState: namesPlan.state, locationsState: locationsPlan.state, namesRequestedIds: namesPlan.requestedIds, locationsRequestedIds: locationsPlan.requestedIds,
|
||||
structureRevision: request.structureRevision, structureObservations, referenceFacts, containmentDepth,
|
||||
});
|
||||
|
||||
const names = nameMap(request.names, namesBatches);
|
||||
const positions = positionMap(request.locations, locationsBatches);
|
||||
const requestedNames = new Set(namesPlan.requestedIds);
|
||||
const requestedLocations = new Set(locationsPlan.requestedIds);
|
||||
const nameObservations = observationMap(namesBatches);
|
||||
const locationObservations = observationMap(locationsBatches);
|
||||
const unresolved: LocationUnresolved[] = containment.unresolved.map((entry) => ({ itemId: entry.itemId, reasons: [...entry.reasons] }));
|
||||
const items: EnrichedLocationNode[] = containment.nodes.map((node) => {
|
||||
const requestedName = requestedNames.has(node.itemId);
|
||||
const requestedLocation = requestedLocations.has(node.itemId);
|
||||
const nameObservation = nameObservations.get(node.itemId) ?? request.names;
|
||||
const locationObservation = locationObservations.get(node.itemId) ?? request.locations;
|
||||
const nameStatus = helperStatus(nameObservation, requestedName, namesPlan.state, nameStatusFor, node.itemId, now);
|
||||
const locationStatus = helperStatus(locationObservation, requestedLocation, locationsPlan.state, locationStatusFor, node.itemId, now);
|
||||
const customName = nameStatus === 'resolved' ? (names.get(node.itemId) ?? null) : null;
|
||||
const rootOutcome = node.root.kind === 'external' ? roots.get(`${node.root.locationType}:${node.root.id}`) : undefined;
|
||||
const location = rootOutcome?.location ?? emptyUnknown(node.root.id ?? node.locationId, version);
|
||||
const reasons = new Set<UnresolvedReason>([...node.unresolved]);
|
||||
if (nameStatus === 'expired') reasons.add('name_expired');
|
||||
if (nameStatus === 'forbidden') reasons.add('name_forbidden');
|
||||
if (nameStatus === 'unavailable') reasons.add('name_unavailable');
|
||||
if (locationStatus === 'expired') reasons.add('location_expired');
|
||||
if (locationStatus === 'forbidden') reasons.add('location_forbidden');
|
||||
if (locationStatus === 'unavailable') reasons.add('location_unavailable');
|
||||
if (rootOutcome) for (const reason of rootOutcome.reasons) reasons.add(reason);
|
||||
const mergedReasons = uniqueReasons([...reasons]);
|
||||
if (mergedReasons.length > 0) unresolved.push({ itemId: node.itemId, reasons: mergedReasons });
|
||||
return { ...node, unresolved: mergedReasons, customName, nameStatus, position: locationStatus === 'resolved' ? (positions.get(node.itemId) ?? null) : null, locationStatus, location };
|
||||
});
|
||||
// A root with no asset cannot make a row, but it is still part of the
|
||||
// result's pinned location set for deterministic T09 filtering.
|
||||
const resolvedRoots = [...roots.values()].map((value) => value.location).sort((left, right) => compareDecimalIds(left.id, right.id));
|
||||
const uniqueUnresolved = new Map<string, { itemId: DecimalId | null; reasons: Set<UnresolvedReason> }>();
|
||||
for (const entry of unresolved) {
|
||||
const key = entry.itemId ?? '';
|
||||
const current = uniqueUnresolved.get(key) ?? { itemId: entry.itemId, reasons: new Set<UnresolvedReason>() };
|
||||
for (const reason of entry.reasons) current.reasons.add(reason);
|
||||
uniqueUnresolved.set(key, current);
|
||||
}
|
||||
const finalUnresolved = [...uniqueUnresolved.values()].map((entry) => ({ itemId: entry.itemId, reasons: uniqueReasons([...entry.reasons]) })).sort((left, right) => (left.itemId && right.itemId ? compareDecimalIds(left.itemId, right.itemId) : (left.itemId ?? '').localeCompare(right.itemId ?? '')) || left.reasons.join(',').localeCompare(right.reasons.join(',')));
|
||||
const complete = finalUnresolved.length === 0;
|
||||
return { status: complete ? 'complete' : 'partial', complete, items, rows: items, roots: resolvedRoots, unresolved: finalUnresolved, inputPin: pin, enrichmentVersion: deterministicEnrichmentVersion(pin) };
|
||||
} finally {
|
||||
if (pinned && typeof pinned.close === 'function' && pinned !== this.reference) pinned.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRoot(locationType: string, id: DecimalId, authority: InventoryLocationAuthority, version: string, reader: ReferenceReader, now: number): Promise<RootOutcome> {
|
||||
const numericId = decimalIdForReference(id);
|
||||
if (locationType === 'other') {
|
||||
if (!this.structures) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
|
||||
let observation: PrivateStructureAccessObservation;
|
||||
try {
|
||||
observation = await this.structures.access(privateContext(authority, version), id);
|
||||
} catch (error) {
|
||||
// Authentication/lease fences remain fatal; malformed optional helper
|
||||
// responses become an explicit unavailable structure instead.
|
||||
if (error instanceof EsiError && error instanceof EsiHttpError && error.status === 401) throw error;
|
||||
if (error instanceof EsiError) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
|
||||
throw error;
|
||||
}
|
||||
if ((observation.status !== 'accessible' && observation.status !== 'forbidden' && observation.status !== 'unavailable') || observation.structureId !== id || observation.characterId !== authority.characterId || observation.dockingPermission !== 'unknown' ||
|
||||
(observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') ||
|
||||
(observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') ||
|
||||
(observation.status === 'unavailable' && observation.evidence !== 'unavailable') ||
|
||||
(observation.status !== 'accessible' && observation.name !== null) ||
|
||||
!Number.isFinite(observation.observedAt) || !Number.isFinite(observation.expiresAt) || observation.expiresAt <= now || observation.expiresAt < observation.observedAt) {
|
||||
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
|
||||
}
|
||||
try {
|
||||
if (observation.status === 'accessible') {
|
||||
const result = await structureLocation(observation, version, reader);
|
||||
return { ...result, structureObservation: observation };
|
||||
}
|
||||
const denied = await structureLocation(observation, version, reader);
|
||||
return { location: denied.location, reasons: [observation.status === 'forbidden' ? 'forbidden_structure' : 'unavailable_structure'], structureObservation: observation };
|
||||
} catch (error) {
|
||||
if (error instanceof EsiError) throw error;
|
||||
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
|
||||
}
|
||||
}
|
||||
if (numericId === null) return { location: emptyUnknown(id, version), reasons: ['unsafe_reference_id'] };
|
||||
try {
|
||||
if (locationType === 'station') {
|
||||
const location = await readStation(reader, numericId, version);
|
||||
if (!location) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
|
||||
const reasons: UnresolvedReason[] = [];
|
||||
if (!location.system) reasons.push('reference_unknown');
|
||||
else if (location.system.parentId !== null && !location.system.region) reasons.push('reference_unknown');
|
||||
return { location, reasons };
|
||||
}
|
||||
if (locationType === 'solar_system') {
|
||||
const result = await readSystem(reader, numericId, version);
|
||||
if (!result.reference) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
|
||||
const reasons: UnresolvedReason[] = [];
|
||||
if (result.row?.regionId !== null && !result.reference.region) reasons.push('reference_unknown');
|
||||
return { location: result.reference, reasons };
|
||||
}
|
||||
return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
|
||||
} catch {
|
||||
return { location: emptyUnknown(id, version), reasons: ['reference_unavailable'] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichInventoryLocations(options: LocationServiceOptions, request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
|
||||
return new InventoryLocationService(options).enrich(request);
|
||||
}
|
||||
|
||||
export const LocationService = InventoryLocationService;
|
||||
export const enrichLocations = enrichInventoryLocations;
|
||||
export const enrichInventoryLocation = enrichInventoryLocations;
|
||||
export type { ReferenceEnrichmentPin };
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
assertDecimalId,
|
||||
stableSerialize,
|
||||
validateAuthorityContext,
|
||||
type DecimalId,
|
||||
type InventoryLocationAuthority,
|
||||
} from '../../domain/inventory-location';
|
||||
import { ESI_STRUCTURES_SCOPE, EsiClient, EsiError, EsiHttpError, EsiRateLimitError, type AuthorizationKey } from '../esi';
|
||||
|
||||
export type PrivateStructureContext = InventoryLocationAuthority & { referenceVersion: string };
|
||||
|
||||
/**
|
||||
* This is deliberately an access observation, not an ACL or docking result.
|
||||
* A successful ESI structure read proves that ESI returned data for this
|
||||
* character-scoped request; it does not prove docking, tethering, or docking
|
||||
* permission. The literal `unknown` is therefore part of every variant.
|
||||
*/
|
||||
export type PrivateStructureAccessObservation = {
|
||||
structureId: DecimalId;
|
||||
characterId: number;
|
||||
status: 'accessible' | 'forbidden' | 'unavailable';
|
||||
evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable';
|
||||
name: string | null;
|
||||
observedAt: number;
|
||||
expiresAt: number;
|
||||
revision?: string;
|
||||
dockingPermission: 'unknown';
|
||||
reason?: string;
|
||||
/** Optional facts returned by the trusted structure endpoint. */
|
||||
solarSystemId?: DecimalId | null;
|
||||
ownerId?: DecimalId | null;
|
||||
typeId?: DecimalId | null;
|
||||
position?: { x: number; y: number; z: number } | null;
|
||||
};
|
||||
|
||||
export type PrivateStructureReader = (context: PrivateStructureContext, structureId: DecimalId) => Promise<PrivateStructureAccessObservation>;
|
||||
export interface PrivateStructurePort {
|
||||
access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation>;
|
||||
}
|
||||
export type PrivateStructureCacheOptions = {
|
||||
now?: () => number;
|
||||
maxEntries?: number;
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 128;
|
||||
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_NAME_LENGTH = 512;
|
||||
const MAX_REASON_LENGTH = 256;
|
||||
|
||||
function nowOf(clock: () => number): number {
|
||||
const value = clock();
|
||||
if (!Number.isFinite(value)) throw new RangeError('cache clock must return a finite timestamp');
|
||||
return value;
|
||||
}
|
||||
function validCounter(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
|
||||
|
||||
export function validatePrivateStructureContext(value: unknown): PrivateStructureContext {
|
||||
if (!value || typeof value !== 'object') throw new TypeError('private structure context is required');
|
||||
const candidate = value as Partial<PrivateStructureContext>;
|
||||
const authority = validateAuthorityContext(candidate);
|
||||
if (typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion) throw new TypeError('reference version is required for private structure access');
|
||||
return { ...authority, referenceVersion: candidate.referenceVersion };
|
||||
}
|
||||
|
||||
export function privateStructureCacheKey(context: PrivateStructureContext, structureId: DecimalId): string {
|
||||
const valid = validatePrivateStructureContext(context);
|
||||
assertDecimalId(structureId, 'structure ID');
|
||||
return stableSerialize({
|
||||
userId: valid.userId,
|
||||
principal: valid.principal,
|
||||
characterId: valid.characterId,
|
||||
scopes: [...valid.scopes].sort(),
|
||||
grantRevision: valid.grantRevision,
|
||||
authorizationEpoch: valid.authorizationEpoch,
|
||||
credentialVersion: valid.credentialVersion,
|
||||
referenceVersion: valid.referenceVersion,
|
||||
structureId,
|
||||
});
|
||||
}
|
||||
|
||||
function validateObservation(value: unknown, context: PrivateStructureContext, structureId: DecimalId, now: number): PrivateStructureAccessObservation {
|
||||
if (!value || typeof value !== 'object') throw new TypeError('private structure reader returned invalid data');
|
||||
const candidate = value as Partial<PrivateStructureAccessObservation>;
|
||||
assertDecimalId(candidate.structureId, 'structure observation ID');
|
||||
if (candidate.structureId !== structureId) throw new TypeError('private structure observation ID does not match request');
|
||||
if (candidate.characterId !== context.characterId) throw new TypeError('private structure observation character mismatch');
|
||||
if (candidate.status !== 'accessible' && candidate.status !== 'forbidden' && candidate.status !== 'unavailable') throw new TypeError('private structure observation status is invalid');
|
||||
if (candidate.evidence !== 'esi_structure_access' && candidate.evidence !== 'esi_forbidden' && candidate.evidence !== 'unavailable') throw new TypeError('private structure observation evidence is invalid');
|
||||
if (candidate.status === 'accessible' && candidate.evidence !== 'esi_structure_access') throw new TypeError('accessible structure observation has invalid evidence');
|
||||
if (candidate.status === 'forbidden' && candidate.evidence !== 'esi_forbidden') throw new TypeError('forbidden structure observation has invalid evidence');
|
||||
if (candidate.status === 'unavailable' && candidate.evidence !== 'unavailable') throw new TypeError('unavailable structure observation has invalid evidence');
|
||||
if (candidate.dockingPermission !== 'unknown') throw new TypeError('structure access cannot assert docking permission');
|
||||
if (typeof candidate.name !== 'string' && candidate.name !== null) throw new TypeError('private structure name is invalid');
|
||||
if (typeof candidate.name === 'string' && (candidate.name.length > MAX_NAME_LENGTH || !candidate.name.trim())) throw new TypeError('private structure name is invalid');
|
||||
if (candidate.status !== 'accessible' && candidate.name !== null) throw new TypeError('private structure denial cannot carry a private name');
|
||||
const observedAt = candidate.observedAt;
|
||||
const expiresAt = candidate.expiresAt;
|
||||
if (typeof observedAt !== 'number' || typeof expiresAt !== 'number' || !Number.isFinite(observedAt) || !Number.isFinite(expiresAt) || expiresAt < observedAt) throw new TypeError('private structure freshness is invalid');
|
||||
if (expiresAt <= now) throw new TypeError('expired private structure observations are not cacheable');
|
||||
if (candidate.reason !== undefined && (typeof candidate.reason !== 'string' || candidate.reason.length > MAX_REASON_LENGTH)) throw new TypeError('private structure reason is invalid');
|
||||
if (candidate.revision !== undefined && (typeof candidate.revision !== 'string' || !candidate.revision)) throw new TypeError('private structure revision is invalid');
|
||||
for (const [field, value] of [['solarSystemId', candidate.solarSystemId], ['ownerId', candidate.ownerId], ['typeId', candidate.typeId] ] as const) {
|
||||
if (value !== undefined && value !== null) assertDecimalId(value, field);
|
||||
}
|
||||
if (candidate.position !== undefined && candidate.position !== null) {
|
||||
if (typeof candidate.position !== 'object' || !Number.isFinite(candidate.position.x) || !Number.isFinite(candidate.position.y) || !Number.isFinite(candidate.position.z)) throw new TypeError('private structure position is invalid');
|
||||
}
|
||||
if (candidate.status !== 'accessible' && (candidate.solarSystemId !== undefined || candidate.ownerId !== undefined || candidate.typeId !== undefined || candidate.position !== undefined)) throw new TypeError('private structure denial cannot carry private facts');
|
||||
return {
|
||||
structureId,
|
||||
characterId: context.characterId,
|
||||
status: candidate.status,
|
||||
evidence: candidate.evidence,
|
||||
name: candidate.name ?? null,
|
||||
observedAt,
|
||||
expiresAt,
|
||||
...(candidate.revision === undefined ? {} : { revision: candidate.revision }),
|
||||
dockingPermission: 'unknown',
|
||||
...(candidate.reason === undefined ? {} : { reason: candidate.reason }),
|
||||
...(candidate.solarSystemId === undefined ? {} : { solarSystemId: candidate.solarSystemId }),
|
||||
...(candidate.ownerId === undefined ? {} : { ownerId: candidate.ownerId }),
|
||||
...(candidate.typeId === undefined ? {} : { typeId: candidate.typeId }),
|
||||
...(candidate.position === undefined ? {} : { position: candidate.position === null ? null : { ...candidate.position } }),
|
||||
};
|
||||
}
|
||||
|
||||
type Entry = { key: string; value: PrivateStructureAccessObservation; bytes: number; touchedAt: number };
|
||||
|
||||
/** Bounded, authority-isolated process cache for private structure observations. */
|
||||
export class PrivateStructureCache {
|
||||
private readonly entries = new Map<string, Entry>();
|
||||
private bytes = 0;
|
||||
private readonly now: () => number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
constructor(options: PrivateStructureCacheOptions = {}) {
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1 || this.maxEntries > DEFAULT_MAX_ENTRIES) throw new RangeError('private structure cache entry cap is invalid');
|
||||
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1024 || this.maxBytes > DEFAULT_MAX_BYTES) throw new RangeError('private structure cache byte cap is invalid');
|
||||
}
|
||||
|
||||
get size(): number { return this.entries.size; }
|
||||
get byteSize(): number { return this.bytes; }
|
||||
stats(): { entries: number; bytes: number; maxEntries: number; maxBytes: number } { return { entries: this.entries.size, bytes: this.bytes, maxEntries: this.maxEntries, maxBytes: this.maxBytes }; }
|
||||
|
||||
get(context: PrivateStructureContext, structureId: DecimalId): PrivateStructureAccessObservation | null {
|
||||
const key = privateStructureCacheKey(context, structureId);
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return null;
|
||||
const now = nowOf(this.now);
|
||||
if (entry.value.expiresAt <= now) { this.delete(key); return null; }
|
||||
// Touching an entry makes eviction LRU while retaining deterministic order.
|
||||
this.entries.delete(key);
|
||||
entry.touchedAt = now;
|
||||
this.entries.set(key, entry);
|
||||
return { ...entry.value };
|
||||
}
|
||||
|
||||
set(context: PrivateStructureContext, structureId: DecimalId, value: PrivateStructureAccessObservation): boolean {
|
||||
const key = privateStructureCacheKey(context, structureId);
|
||||
const now = nowOf(this.now);
|
||||
const checked = validateObservation(value, validatePrivateStructureContext(context), structureId, now);
|
||||
const bytes = new TextEncoder().encode(JSON.stringify({ key, value: checked })).byteLength;
|
||||
if (bytes > this.maxBytes) return false;
|
||||
this.delete(key);
|
||||
this.entries.set(key, { key, value: checked, bytes, touchedAt: now });
|
||||
this.bytes += bytes;
|
||||
this.evict();
|
||||
return this.entries.has(key);
|
||||
}
|
||||
|
||||
async getOrLoad(context: PrivateStructureContext, structureId: DecimalId, reader: PrivateStructureReader): Promise<PrivateStructureAccessObservation> {
|
||||
const hit = this.get(context, structureId);
|
||||
if (hit) return hit;
|
||||
const validContext = validatePrivateStructureContext(context);
|
||||
const value = await reader(validContext, structureId);
|
||||
const checked = validateObservation(value, validContext, structureId, nowOf(this.now));
|
||||
this.set(validContext, structureId, checked);
|
||||
return { ...checked };
|
||||
}
|
||||
|
||||
/** Remove one authority key. A revision change can use invalidateAuthority. */
|
||||
invalidate(context: PrivateStructureContext, structureId?: DecimalId): number {
|
||||
const valid = validatePrivateStructureContext(context);
|
||||
if (structureId !== undefined) {
|
||||
const key = privateStructureCacheKey(valid, structureId);
|
||||
return this.delete(key) ? 1 : 0;
|
||||
}
|
||||
return this.invalidateAuthority(valid);
|
||||
}
|
||||
|
||||
/** Invalidate every revision/scope entry for this user/principal/character. */
|
||||
invalidateAuthority(context: PrivateStructureContext): number {
|
||||
const valid = validatePrivateStructureContext(context);
|
||||
let removed = 0;
|
||||
for (const [key, entry] of this.entries) {
|
||||
// Keys are stable JSON; parsing is safe because all keys are generated here.
|
||||
const parsed = JSON.parse(key) as Partial<PrivateStructureContext> & { structureId?: string };
|
||||
if (parsed.userId === valid.userId && parsed.principal === valid.principal && parsed.characterId === valid.characterId) {
|
||||
this.delete(key); removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void { this.entries.clear(); this.bytes = 0; }
|
||||
|
||||
private delete(key: string): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return false;
|
||||
this.entries.delete(key);
|
||||
this.bytes -= entry.bytes;
|
||||
return true;
|
||||
}
|
||||
private evict(): void {
|
||||
while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
|
||||
const oldest = this.entries.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
this.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A port that adds the bounded cache to a typed private ESI reader. */
|
||||
/**
|
||||
* Adapter from the fixed ESI structure operation to the authority-isolated
|
||||
* cache port. It accepts a token provider rather than retaining tokens in the
|
||||
* cache or its keys.
|
||||
*/
|
||||
export class EsiPrivateStructurePort implements PrivateStructurePort {
|
||||
constructor(
|
||||
private readonly esi: EsiClient,
|
||||
private readonly accessToken: string | ((context: PrivateStructureContext) => Promise<string> | string),
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
private readonly ttlMs = 60 * 60 * 1000,
|
||||
private readonly hooks?: { beforeAccess?: () => Promise<void>; onTransient?: (error: unknown) => Promise<void> },
|
||||
) {}
|
||||
|
||||
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
const valid = validatePrivateStructureContext(context);
|
||||
assertDecimalId(structureId, 'structure ID');
|
||||
await this.hooks?.beforeAccess?.();
|
||||
const observedAt = this.now();
|
||||
if (!Number.isFinite(observedAt)) throw new RangeError('private structure adapter clock is invalid');
|
||||
if (!valid.scopes.includes(ESI_STRUCTURES_SCOPE)) {
|
||||
return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'required structure scope is not granted' };
|
||||
}
|
||||
const token = typeof this.accessToken === 'function' ? await this.accessToken(valid) : this.accessToken;
|
||||
try {
|
||||
const observation = await this.esi.getStructure(valid.characterId, structureId, token, valid as AuthorizationKey);
|
||||
return {
|
||||
structureId, characterId: valid.characterId, status: 'accessible', evidence: 'esi_structure_access',
|
||||
name: observation.name, observedAt: observation.retrievedAt, expiresAt: observation.expiresAt,
|
||||
dockingPermission: 'unknown', solarSystemId: observation.solarSystemId, ownerId: observation.ownerId,
|
||||
typeId: observation.typeId, position: observation.position,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof EsiHttpError && error.status === 403) {
|
||||
return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI denied structure access' };
|
||||
}
|
||||
if (error instanceof EsiHttpError && error.status === 404) {
|
||||
return { structureId, characterId: valid.characterId, status: 'unavailable', evidence: 'unavailable', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI did not return the structure' };
|
||||
}
|
||||
if (error instanceof EsiRateLimitError || (error instanceof EsiError && (error.code === 'network' || error.code === 'deadline' || (error instanceof EsiHttpError && (error.status === 408 || error.status === 420 || error.status === 425 || error.status === 429 || error.status >= 500))))) await this.hooks?.onTransient?.(error);
|
||||
// Transport/deadline/rate-limit failures remain errors so the worker can
|
||||
// preserve retry/cooldown semantics; explicit 403/404 are observations.
|
||||
if (error instanceof EsiError) throw error;
|
||||
throw new Error('private structure lookup failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CachedPrivateStructurePort {
|
||||
constructor(private readonly reader: PrivateStructureReader, private readonly cache = new PrivateStructureCache(), private readonly beforeAccess?: () => Promise<void>) {}
|
||||
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
await this.beforeAccess?.();
|
||||
return await this.cache.getOrLoad(context, structureId, this.reader);
|
||||
}
|
||||
read(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
return this.access(context, structureId);
|
||||
}
|
||||
invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { return this.cache.invalidate(context, structureId); }
|
||||
getCache(): PrivateStructureCache { return this.cache; }
|
||||
}
|
||||
|
||||
export const AuthIsolatedPrivateStructureCache = PrivateStructureCache;
|
||||
export const PrivateStructureAccessCache = PrivateStructureCache;
|
||||
@@ -19,6 +19,7 @@ export const RELEASE_CAPABILITIES = [
|
||||
'wallet_balance',
|
||||
'inventory_export',
|
||||
'manufacturing_opportunities',
|
||||
'inventory_locations',
|
||||
] as const;
|
||||
|
||||
export type ReleaseCapability = (typeof RELEASE_CAPABILITIES)[number];
|
||||
|
||||
+84
-3
@@ -7,6 +7,7 @@ import type {
|
||||
UserId,
|
||||
SafeSyncJob,
|
||||
} from "$lib/domain/model";
|
||||
import type { EnrichedLocationNode, ReferenceEnrichmentPin } from "$lib/domain/inventory-location";
|
||||
|
||||
export type OAuthStateRecord = {
|
||||
stateHash: string;
|
||||
@@ -98,6 +99,24 @@ type AssetPage = {
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
generation: string | null;
|
||||
enrichmentStatus: string | null;
|
||||
enrichmentVersion: string | null;
|
||||
snapshotChanged: boolean;
|
||||
};
|
||||
export type EnrichmentManifest = {
|
||||
generation: string;
|
||||
status: string | null;
|
||||
version: string | null;
|
||||
inputPin: ReferenceEnrichmentPin | null;
|
||||
unresolvedCount: number | null;
|
||||
};
|
||||
export type EnrichmentChunkPage = {
|
||||
chunks: { index: number; rows: EnrichedLocationNode[] }[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
generation: string | null;
|
||||
status: string | null;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
const SNAPSHOT_CHANGED = "Convex asset snapshot changed during read";
|
||||
@@ -112,6 +131,8 @@ const QUERY = {
|
||||
syncJob: "functions:bridgeSyncJob",
|
||||
cooldown: "functions:bridgeCooldown",
|
||||
workerCooldown: "functions:bridgeSyncWorkerCooldown",
|
||||
enrichmentManifest: "functions:bridgeEnrichmentManifest",
|
||||
enrichmentChunks: "functions:bridgeEnrichmentChunksPage",
|
||||
} as const;
|
||||
|
||||
const MUTATION = {
|
||||
@@ -136,9 +157,12 @@ const MUTATION = {
|
||||
refreshSyncJobAuthority: "functions:bridgeRefreshSyncJobAuthority",
|
||||
restartSyncJobGeneration: "functions:bridgeRestartSyncJobGeneration",
|
||||
stageSyncAssets: "functions:bridgeStageSyncAssets",
|
||||
stageSyncEnrichment: "functions:bridgeStageSyncEnrichment",
|
||||
resetSyncEnrichment: "functions:bridgeResetSyncEnrichment",
|
||||
commitSyncJob: "functions:bridgeCommitSyncJob",
|
||||
failSyncJob: "functions:bridgeFailSyncJob",
|
||||
deferSyncJob: "functions:bridgeDeferSyncJob",
|
||||
recordSyncCooldown: "functions:bridgeRecordSyncCooldown",
|
||||
acquireRefreshLease: "functions:bridgeAcquireRefreshLease",
|
||||
renewRefreshLease: "functions:bridgeRenewRefreshLease",
|
||||
releaseRefreshLease: "functions:bridgeReleaseRefreshLease",
|
||||
@@ -328,6 +352,8 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
const assets: Asset[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pinnedGeneration: string | null | undefined;
|
||||
let pinnedEnrichmentStatus: string | null | undefined;
|
||||
let pinnedEnrichmentVersion: string | null | undefined;
|
||||
try {
|
||||
// Each query reads bounded snapshot chunks. The guard prevents a
|
||||
// malformed backend cursor from turning a request into an infinite loop.
|
||||
@@ -341,9 +367,12 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
args.expectedGeneration = pinnedGeneration;
|
||||
}
|
||||
const result: AssetPage = await this.query<AssetPage>(QUERY.assetsPage, args);
|
||||
if (result.snapshotChanged) throw new Error(SNAPSHOT_CHANGED);
|
||||
if (pinnedGeneration === undefined) {
|
||||
pinnedGeneration = result.generation;
|
||||
} else if (result.generation !== pinnedGeneration) {
|
||||
pinnedEnrichmentStatus = result.enrichmentStatus;
|
||||
pinnedEnrichmentVersion = result.enrichmentVersion;
|
||||
} else if (result.generation !== pinnedGeneration || result.enrichmentStatus !== pinnedEnrichmentStatus || result.enrichmentVersion !== pinnedEnrichmentVersion) {
|
||||
throw new Error(SNAPSHOT_CHANGED);
|
||||
}
|
||||
assets.push(...result.page);
|
||||
@@ -400,8 +429,56 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
await this.workerMutation(MUTATION.stageSyncAssets, { jobId, leaseToken, index, assets });
|
||||
}
|
||||
|
||||
public async commitSyncJob(jobId: string, leaseToken: string, chunkCount: number, syncedAt: number, expiresAt: number): Promise<boolean> {
|
||||
return await this.workerMutation<boolean>(MUTATION.commitSyncJob, { jobId, leaseToken, chunkCount, syncedAt, expiresAt });
|
||||
public async stageSyncEnrichment(jobId: string, leaseToken: string, index: number, rows: EnrichedLocationNode[]): Promise<void> {
|
||||
await this.workerMutation(MUTATION.stageSyncEnrichment, { jobId, leaseToken, index, rows });
|
||||
}
|
||||
|
||||
public async resetSyncEnrichment(jobId: string, leaseToken: string): Promise<void> {
|
||||
await this.workerMutation(MUTATION.resetSyncEnrichment, { jobId, leaseToken });
|
||||
}
|
||||
|
||||
public async commitSyncJob(jobId: string, leaseToken: string, chunkCount: number, syncedAt: number, expiresAt: number, enrichment?: { status: 'complete' | 'partial' | 'failed' | 'unavailable'; version?: string; inputPin?: ReferenceEnrichmentPin; chunkCount: number; unresolvedCount: number; error?: string }): Promise<boolean> {
|
||||
return await this.workerMutation<boolean>(MUTATION.commitSyncJob, {
|
||||
jobId, leaseToken, chunkCount, syncedAt, expiresAt,
|
||||
...(enrichment === undefined ? {} : {
|
||||
enrichmentStatus: enrichment.status,
|
||||
...(enrichment.version === undefined ? {} : { enrichmentVersion: enrichment.version }),
|
||||
...(enrichment.inputPin === undefined ? {} : { enrichmentInputPin: enrichment.inputPin }),
|
||||
enrichmentChunkCount: enrichment.chunkCount,
|
||||
enrichmentUnresolvedCount: enrichment.unresolvedCount,
|
||||
...(enrichment.error === undefined ? {} : { enrichmentError: enrichment.error }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public async enrichmentManifest(userId: string, characterId: number, expectedGeneration?: string): Promise<EnrichmentManifest | null> {
|
||||
return await this.query<EnrichmentManifest | null>(QUERY.enrichmentManifest, { userId, characterId, ...(expectedGeneration === undefined ? {} : { expectedGeneration }) });
|
||||
}
|
||||
|
||||
public async enrichmentChunks(userId: string, characterId: number, generation: string): Promise<EnrichedLocationNode[]> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const rows: EnrichedLocationNode[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pinnedStatus: string | null | undefined;
|
||||
let pinnedVersion: string | null | undefined;
|
||||
try {
|
||||
for (let page = 0; page < 100_000; page += 1) {
|
||||
const result: EnrichmentChunkPage = await this.query<EnrichmentChunkPage>(QUERY.enrichmentChunks, { userId, characterId, expectedGeneration: generation, paginationOpts: { numItems: 8, cursor } });
|
||||
if (result.generation !== generation) throw new Error(SNAPSHOT_CHANGED);
|
||||
if (pinnedStatus === undefined) { pinnedStatus = result.status; pinnedVersion = result.version; }
|
||||
else if (result.status !== pinnedStatus || result.version !== pinnedVersion) throw new Error(SNAPSHOT_CHANGED);
|
||||
rows.push(...result.chunks.flatMap((chunk: { index: number; rows: EnrichedLocationNode[] }) => chunk.rows));
|
||||
if (result.isDone) return rows;
|
||||
if (result.continueCursor === cursor) throw new Error('Convex enrichment pagination did not advance');
|
||||
cursor = result.continueCursor;
|
||||
}
|
||||
throw new Error('Convex enrichment pagination exceeded its safety bound');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === SNAPSHOT_CHANGED && attempt < 2) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(SNAPSHOT_CHANGED);
|
||||
}
|
||||
|
||||
public async failSyncJob(jobId: string, leaseToken: string): Promise<boolean> {
|
||||
@@ -416,6 +493,10 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
return await this.workerQuery<{ until: number; group?: string; kind: string } | null>(QUERY.workerCooldown, { context, ...(group === undefined ? {} : { group }) });
|
||||
}
|
||||
|
||||
public async recordSyncCooldown(context: string, until: number, group?: string): Promise<boolean> {
|
||||
return await this.workerMutation<boolean>(MUTATION.recordSyncCooldown, { context, until, ...(group === undefined ? {} : { group }) });
|
||||
}
|
||||
|
||||
public async acquireRefreshLease(userId: string, characterId: number, ownerId: string, leaseMs?: number): Promise<RefreshLease | null> {
|
||||
return await this.workerMutation<RefreshLease | null>(MUTATION.acquireRefreshLease, { userId, characterId, ownerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dat
|
||||
const CONSENT_CAPABILITIES = new Set<Capability>([
|
||||
'asset_search',
|
||||
'asset_valuation',
|
||||
'inventory_locations',
|
||||
'wallet_balance',
|
||||
'manufacturing_opportunities',
|
||||
]);
|
||||
@@ -33,7 +34,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
if (!CONSENT_CAPABILITIES.has(capabilityValue as Capability)) error(400, 'Unsupported consent capability');
|
||||
const capability = capabilityValue as Capability;
|
||||
// Domain capability and deployment release gate are separate controls.
|
||||
if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
|
||||
if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'inventory_locations' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
|
||||
&& !capabilityEnabled(capability, config.capabilityAllowlist)) {
|
||||
error(503, capabilityDisabledReason(capability));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
characters: [],
|
||||
identityEnabled: false,
|
||||
walletEnabled: false,
|
||||
locationEnabled: false,
|
||||
error: capabilityDisabledReason('identity'),
|
||||
};
|
||||
}
|
||||
@@ -27,6 +28,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
characters: await charactersFor(current?.userId ?? null),
|
||||
identityEnabled: true,
|
||||
walletEnabled: capabilityEnabled('wallet_balance', config.capabilityAllowlist),
|
||||
locationEnabled: capabilityEnabled('inventory_locations', config.capabilityAllowlist),
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import LiveCharacters from '$lib/components/LiveCharacters.svelte';
|
||||
let { data } = $props();
|
||||
const datasetLabel = (dataset: string) => dataset.replaceAll('_', ' ');
|
||||
const assetsScope = 'esi-assets.read_assets.v1';
|
||||
const structuresScope = 'esi-universe.read_structures.v1';
|
||||
const locationState = (character: any) => character.authorizationStatus !== 'active' || !character.scopes.includes(assetsScope)
|
||||
? 'reduced' : character.scopes.includes(structuresScope) ? 'granted' : 'missing';
|
||||
</script>
|
||||
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
|
||||
<p class="muted">Connect each character explicitly. One primary character is the only signed-out login; linked characters are data-only until you switch the primary while authenticated.</p>
|
||||
@@ -22,6 +26,7 @@
|
||||
<p class="muted">Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}</p>
|
||||
{/if}
|
||||
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
|
||||
<p class="muted">Inventory locations: <strong>{data.locationEnabled ? locationState(character) : 'unavailable'}</strong>{#if data.locationEnabled && locationState(character) === 'missing'} · Structure consent is required for private location evidence.{/if}</p>
|
||||
{#if data.mode !== 'demo' && data.identityEnabled}
|
||||
<div class="row">
|
||||
{#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'}
|
||||
@@ -33,6 +38,14 @@
|
||||
<input type="hidden" name="targetCharacterId" value={character.id} />
|
||||
<button class="quiet">{character.authorizationStatus === 'active' ? 'Refresh authorization' : 'Reconnect'}</button>
|
||||
</form>
|
||||
{#if character.authorizationStatus === 'active' && data.locationEnabled && character.scopes.includes(assetsScope) && !character.scopes.includes(structuresScope)}
|
||||
<form method="POST" action="/auth/eve">
|
||||
<input type="hidden" name="capability" value="inventory_locations" />
|
||||
<input type="hidden" name="intent" value="upgrade_dataset" />
|
||||
<input type="hidden" name="targetCharacterId" value={character.id} />
|
||||
<button class="quiet">Authorize private locations</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if character.authorizationStatus === 'active' && data.walletEnabled}
|
||||
<form method="POST" action="/auth/eve">
|
||||
<input type="hidden" name="capability" value="wallet_balance" />
|
||||
|
||||
Reference in New Issue
Block a user