Files
eden/src/lib/server/inventory/projection.ts
T

490 lines
32 KiB
TypeScript

import type {
Asset,
InventoryRow,
InventoryType,
Isk,
PriceEstimate,
PriceSnapshot,
SelectedResource,
} from '../../domain/model';
import {
compareDecimalIds,
isDecimalId,
validatePersistedLocationRows,
validateReferenceEnrichmentPin,
type EnrichedLocationNode,
type InventoryLocationAuthority,
type ReferenceEnrichmentPin,
} from '../../domain/inventory-location';
import { ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE } from '../../domain/capabilities';
import { multiplyIsk, parseIsk, toIsk, validateQuantity } from '../../domain/money';
/** Hard limits for a single immutable projection. Persistent adapters may use smaller limits. */
export const INVENTORY_PROJECTION_MAX_ROWS = 50_000;
export const INVENTORY_PROJECTION_MAX_TYPES = 50_000;
export const INVENTORY_PROJECTION_MAX_PRICES = 50_000;
export const MAX_INVENTORY_MONEY_TEXT_LENGTH = 128;
export const INVENTORY_LOCATION_ROW_CHUNK = 100;
export type ProjectionAuthority = {
userId: string;
characterId: number;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
/** Actual granted scopes, when supplied by the server authority reader. */
scopes?: readonly string[];
};
export type ProjectionLocation = {
itemId: string;
/** Resolved root location ID; unresolved rows retain only the source ID. */
locationId: string;
sourceLocationId: string;
resolution: 'resolved' | 'unresolved';
kind: 'station' | 'system' | 'region' | 'structure' | 'unknown' | null;
name: string | null;
systemId: number | null;
regionId: number | null;
evidence: 'verified' | 'system_listed' | 'private_structure_access' | 'unknown' | null;
unresolvedReasons: readonly string[];
};
/**
* A query row is deliberately richer than the shared UI row. The extra fields
* are private index metadata and are stripped only by consumers that need the
* narrower InventoryRow contract.
*/
export type InventoryProjectionRow = InventoryRow & {
resourceKey: string;
itemKey: string;
generation: string;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
typeName: string;
unitPrice: Isk | null;
pricing: 'priced' | 'unpriced';
location: ProjectionLocation;
/** True only when the location facts are explicitly unresolved. */
unresolvedLocation: boolean;
unresolvedReasons: readonly string[];
};
export type ProjectionCoverage = {
complete: boolean;
reason?: string;
};
export type InventoryProjectionInput = {
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
coverage?: ProjectionCoverage;
referenceVersion: string;
priceVersion?: string | null;
enrichmentVersion?: string | null;
fetchedAt?: number | null;
upstreamEffectiveAt?: number | null;
assets: readonly Asset[];
types: readonly InventoryType[];
prices?: PriceSnapshot | readonly PriceEstimate[];
/** T08 rows are optional; absence is explicit unresolved location coverage. */
locations?: readonly EnrichedLocationNode[];
/** Required whenever location rows are supplied; pins the exact T08 input. */
locationInputPin?: ReferenceEnrichmentPin;
};
export type InventoryProjectionManifest = {
version: 1;
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
status: 'complete' | 'partial';
coverage: ProjectionCoverage;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
locationInputPin: ReferenceEnrichmentPin | null;
rows: readonly InventoryProjectionRow[];
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
};
function validCounter(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function validTypeId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function canonicalScopes(scopes: readonly string[]): boolean {
return scopes.every((scope, index) => index === 0 || scopes[index - 1]! <= scope);
}
function stableReasonOrder(reasons: readonly string[]): boolean {
return reasons.every((reason, index) => index === 0 || reasons[index - 1]! < reason);
}
function resourceKey(resource: SelectedResource): string {
if (resource.principal.kind !== 'character') throw new TypeError('inventory projections require character resources');
return `character:${resource.principal.id}`;
}
function authorityContext(input: InventoryProjectionInput): InventoryLocationAuthority {
const scopes = [...(input.authority.scopes ?? input.resource.requiredScopes)].sort();
return {
userId: input.userId,
principal: `character:${input.authority.characterId}`,
characterId: input.authority.characterId,
scopes,
grantRevision: input.authority.grantRevision,
authorizationEpoch: input.authority.authorizationEpoch,
credentialVersion: input.authority.credentialVersion,
};
}
function canonicalMoney(value: unknown, field: string): Isk {
if (typeof value === 'string' && value.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError(`${field} exceeds money text bounds`);
let text: string;
try { text = toIsk(value as Isk); } catch { throw new TypeError(`${field} is invalid`); }
if (text.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError(`${field} exceeds money text bounds`);
parseIsk(text);
return text;
}
function validateResource(input: InventoryProjectionInput): string {
if (typeof input.userId !== 'string' || input.userId.length === 0 || input.userId.length > 256) throw new TypeError('projection user is required');
if (!input.resource || input.resource.principal.kind !== 'character') throw new TypeError('projection resource must be a character');
if (input.resource.principal.ownerUserId !== input.userId) throw new TypeError('projection resource owner mismatch');
if (!isDecimalId(input.resource.principal.id)) throw new TypeError('projection resource character ID is invalid');
if (!validCounter(input.authority.characterId) || input.authority.characterId <= 0) throw new TypeError('projection character ID is invalid');
if (String(input.authority.characterId) !== input.resource.principal.id) throw new TypeError('projection character resource mismatch');
if (input.authority.userId !== input.userId) throw new TypeError('projection authority owner mismatch');
if (!validCounter(input.authority.grantRevision) || !validCounter(input.authority.authorizationEpoch) || !validCounter(input.authority.credentialVersion)) throw new TypeError('projection authority counters are invalid');
if (input.resource.authorizationRevision !== input.authority.grantRevision || input.resource.authorizationEpoch !== input.authority.authorizationEpoch) throw new TypeError('projection authority revision mismatch');
const requiredScopes = input.resource.requiredScopes;
const grantedScopes = input.authority.scopes ?? requiredScopes;
if (!Array.isArray(requiredScopes) || requiredScopes.length > 64 || requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(requiredScopes) || new Set(requiredScopes).size !== requiredScopes.length || !requiredScopes.includes(ESI_ASSETS_SCOPE)) throw new TypeError('projection resource scopes are invalid');
if (!Array.isArray(grantedScopes) || grantedScopes.length > 64 || grantedScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(grantedScopes) || new Set(grantedScopes).size !== grantedScopes.length || !grantedScopes.includes(ESI_ASSETS_SCOPE) || requiredScopes.some((scope) => !grantedScopes.includes(scope))) throw new TypeError('projection authority scopes are invalid');
if (typeof input.generation !== 'string' || input.generation.length === 0 || input.generation.length > 256) throw new TypeError('projection generation is required');
if (typeof input.referenceVersion !== 'string' || input.referenceVersion.length === 0 || input.referenceVersion.length > 256) throw new TypeError('projection reference version is required');
if (input.priceVersion !== undefined && input.priceVersion !== null && (typeof input.priceVersion !== 'string' || input.priceVersion.length === 0 || input.priceVersion.length > 256)) throw new TypeError('projection price version is invalid');
if (input.enrichmentVersion !== undefined && input.enrichmentVersion !== null && (typeof input.enrichmentVersion !== 'string' || input.enrichmentVersion.length === 0 || input.enrichmentVersion.length > 256)) throw new TypeError('projection enrichment version is invalid');
return resourceKey(input.resource);
}
function locationFor(asset: Asset, byItem: Map<string, EnrichedLocationNode>): ProjectionLocation {
const enriched = byItem.get(asset.itemId);
if (!enriched) {
return {
itemId: asset.itemId,
locationId: asset.locationId,
sourceLocationId: asset.locationId,
resolution: 'unresolved',
kind: 'unknown',
name: null,
systemId: null,
regionId: null,
evidence: 'unknown',
unresolvedReasons: ['location_unavailable'],
};
}
const reference = enriched.location;
const system = reference.system;
const region = reference.region;
const unresolvedReasons = [...new Set(enriched.unresolved)].sort();
// T08's locationStatus describes optional per-item position helper data;
// `not_requested` does not make a known station/system unresolved. Explicit
// expired/forbidden/unavailable helper coverage remains unresolved.
const unresolved = reference.kind === 'unknown' || (reference.kind === 'structure' && reference.evidence === 'unknown') || (enriched.locationStatus !== 'resolved' && enriched.locationStatus !== 'not_requested') || unresolvedReasons.length > 0;
if (unresolved) {
const reasons = unresolvedReasons.length > 0 ? unresolvedReasons : (reference.kind === 'unknown' || reference.evidence === 'unknown') ? ['reference_unknown'] : [`location_${enriched.locationStatus}`];
return {
itemId: asset.itemId,
locationId: asset.locationId,
sourceLocationId: asset.locationId,
resolution: 'unresolved',
kind: 'unknown',
name: null,
systemId: null,
regionId: null,
evidence: 'unknown',
unresolvedReasons: reasons,
};
}
return {
itemId: asset.itemId,
locationId: reference.id,
sourceLocationId: asset.locationId,
resolution: 'resolved',
kind: reference.kind,
name: reference.name,
systemId: system && isDecimalId(system.id) && Number.isSafeInteger(Number(system.id)) ? Number(system.id) : null,
regionId: region && isDecimalId(region.id) && Number.isSafeInteger(Number(region.id)) ? Number(region.id) : null,
evidence: reference.evidence,
unresolvedReasons,
};
}
function validatedPrices(value: PriceSnapshot | readonly PriceEstimate[] | undefined): Map<number, Isk> {
if (value !== undefined && !Array.isArray(value) && !('prices' in value)) throw new TypeError('projection price response is malformed');
if (value !== undefined && !Array.isArray(value) && 'prices' in value) {
const snapshot = value as PriceSnapshot;
if (typeof snapshot.source !== 'string' || !snapshot.source || !Number.isFinite(snapshot.asOf) || !Number.isFinite(snapshot.expiresAt) || snapshot.expiresAt < snapshot.asOf) throw new TypeError('projection price snapshot metadata is malformed');
}
const entries: readonly PriceEstimate[] = value === undefined ? [] : Array.isArray(value) ? value : 'prices' in value ? value.prices : [];
if (!Array.isArray(entries) || entries.length > INVENTORY_PROJECTION_MAX_PRICES) throw new RangeError('projection price cap exceeded');
const result = new Map<number, Isk>();
for (const entry of entries) {
if (!entry || !validTypeId(entry.typeId) || result.has(entry.typeId)) throw new TypeError('projection price response is malformed');
let price: string;
try {
price = canonicalMoney(entry.unitPrice, 'projection price');
if (parseIsk(price) < 0n) throw new RangeError('negative price');
} catch (error) {
if (error instanceof RangeError && error.message.includes('bounds')) throw error;
throw new TypeError('projection price response is malformed');
}
result.set(entry.typeId, price);
}
return result;
}
function validatedTypes(types: readonly InventoryType[]): Map<number, InventoryType> {
if (!Array.isArray(types) || types.length > INVENTORY_PROJECTION_MAX_TYPES) throw new RangeError('projection type cap exceeded');
const result = new Map<number, InventoryType>();
for (const type of types) {
if (!type || !validTypeId(type.id) || result.has(type.id) || typeof type.name !== 'string' || !type.name.trim() || type.name.length > 512
|| !validTypeId(type.categoryId) || (type.groupId !== undefined && !validTypeId(type.groupId))) {
throw new TypeError('projection type response is malformed');
}
result.set(type.id, { ...type });
}
return result;
}
function validatedAsset(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
&& validTypeId(candidate.typeId) && typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0
&& typeof candidate.locationType === 'string' && candidate.locationType.length > 0 && candidate.locationType.length <= 32 && typeof candidate.locationFlag === 'string' && candidate.locationFlag.length > 0 && candidate.locationFlag.length <= 128
&& (candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean')
&& (candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean');
}
function locationReferenceMatchesVersion(location: EnrichedLocationNode['location'], referenceVersion: string): boolean {
return location.referenceVersion === referenceVersion
&& (location.system === null || locationReferenceMatchesVersion(location.system, referenceVersion))
&& (location.region === null || locationReferenceMatchesVersion(location.region, referenceVersion));
}
function validateT08Locations(input: InventoryProjectionInput): void {
if (input.locations === undefined) {
if (input.locationInputPin !== undefined) throw new TypeError('location input pin requires location rows');
return;
}
if (!input.locationInputPin) throw new TypeError('location rows require an enrichment input pin');
const authority = authorityContext(input);
validateReferenceEnrichmentPin(input.locationInputPin, {
assetGeneration: input.generation,
referenceVersion: input.referenceVersion,
authority,
});
const assets = new Map<string, Partial<Asset>>();
for (const asset of input.assets) {
if (asset && typeof asset === 'object' && typeof (asset as Partial<Asset>).itemId === 'string') assets.set((asset as Partial<Asset>).itemId!, asset as Partial<Asset>);
}
const seen = new Set<string>();
for (let offset = 0; offset < input.locations.length; offset += INVENTORY_LOCATION_ROW_CHUNK) {
const chunk = input.locations.slice(offset, offset + INVENTORY_LOCATION_ROW_CHUNK);
validatePersistedLocationRows(chunk, { authority });
for (const row of chunk) {
const asset = assets.get(row.itemId);
if (!asset || row.characterId !== input.authority.characterId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || row.isSingleton !== asset.isSingleton || row.isBlueprintCopy !== asset.isBlueprintCopy || !locationReferenceMatchesVersion(row.location, input.referenceVersion) || seen.has(row.itemId)) throw new TypeError('location row does not match the asset generation');
seen.add(row.itemId);
}
}
}
/** Build one immutable, generation-bound projection. No database or network calls occur here. */
export function buildInventoryProjection(input: InventoryProjectionInput): InventoryProjectionManifest {
const key = validateResource(input);
if (!Array.isArray(input.assets) || input.assets.length > INVENTORY_PROJECTION_MAX_ROWS) throw new RangeError('projection row cap exceeded');
if (input.locations !== undefined && (!Array.isArray(input.locations) || input.locations.length > INVENTORY_PROJECTION_MAX_ROWS)) throw new RangeError('projection location row cap exceeded');
validateT08Locations(input);
const types = validatedTypes(input.types);
const prices = validatedPrices(input.prices);
const locationRows = new Map<string, EnrichedLocationNode>();
for (const row of input.locations ?? []) {
if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || row.characterId !== input.authority.characterId || locationRows.has(row.itemId)) throw new TypeError('projection location rows are malformed');
locationRows.set(row.itemId, row);
}
const seen = new Set<string>();
const rows: InventoryProjectionRow[] = [];
for (const source of input.assets) {
if (!validatedAsset(source, input.authority.characterId)) throw new TypeError('projection asset is malformed or belongs to another character');
if (seen.has(source.itemId)) throw new TypeError('projection contains duplicate item ID');
seen.add(source.itemId);
const type = types.get(source.typeId);
const location = locationFor(source, locationRows);
let unitPrice: Isk | null = null;
let estimatedValue: Isk | null = null;
let pricing: 'priced' | 'unpriced' = 'unpriced';
if (source.isBlueprintCopy !== true && type) {
unitPrice = prices.get(source.typeId) ?? null;
if (unitPrice !== null) {
if (input.priceVersion === undefined || input.priceVersion === null) throw new TypeError('priced projection rows require a price version');
// Keep the shared money/quantity functions at the arithmetic boundary;
// this is intentionally not a second valuation implementation.
validateQuantity(source.quantity);
estimatedValue = multiplyIsk(unitPrice, source.quantity);
if (estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError('projection estimated value exceeds money text bounds');
pricing = 'priced';
}
}
const row: InventoryProjectionRow = {
...source,
name: type?.name ?? 'Unknown type',
typeName: type?.name ?? 'Unknown type',
categoryId: type?.categoryId ?? null,
groupId: type?.groupId ?? null,
customName: locationRows.get(source.itemId)?.customName ?? null,
systemId: location.systemId,
regionId: location.regionId,
isSingleton: source.isSingleton ?? null,
estimatedValue,
resourceKey: key,
itemKey: `${input.authority.characterId}:${source.itemId}`,
generation: input.generation,
referenceVersion: input.referenceVersion,
priceVersion: input.priceVersion ?? null,
enrichmentVersion: input.enrichmentVersion ?? null,
unitPrice,
pricing,
location,
unresolvedLocation: location.resolution === 'unresolved',
unresolvedReasons: location.unresolvedReasons,
};
rows.push(row);
}
const coverage = input.coverage ?? { complete: true };
if (typeof coverage.complete !== 'boolean' || (coverage.reason !== undefined && (typeof coverage.reason !== 'string' || coverage.reason.length > 512)) || (!coverage.complete && (!coverage.reason || coverage.reason.length > 512))) throw new TypeError('projection coverage is malformed');
if ((input.fetchedAt !== undefined && input.fetchedAt !== null && !Number.isFinite(input.fetchedAt)) || (input.upstreamEffectiveAt !== undefined && input.upstreamEffectiveAt !== null && !Number.isFinite(input.upstreamEffectiveAt))) throw new TypeError('projection timestamps are invalid');
return {
version: 1,
userId: input.userId,
resource: { ...input.resource, requiredScopes: [...input.resource.requiredScopes] },
authority: { ...input.authority },
generation: input.generation,
status: coverage.complete ? 'complete' : 'partial',
coverage: { ...coverage },
referenceVersion: input.referenceVersion,
priceVersion: input.priceVersion ?? null,
enrichmentVersion: input.enrichmentVersion ?? null,
locationInputPin: input.locationInputPin ?? null,
rows,
fetchedAt: input.fetchedAt ?? null,
upstreamEffectiveAt: input.upstreamEffectiveAt ?? null,
};
}
function validateProjectionAuthority(userId: string, resource: SelectedResource, authority: ProjectionAuthority): void {
if (!resource || resource.principal.kind !== 'character' || resource.principal.ownerUserId !== userId || !isDecimalId(resource.principal.id)) throw new TypeError('inventory projection resource is invalid');
if (!Array.isArray(resource.requiredScopes) || resource.requiredScopes.length > 64 || resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(resource.requiredScopes) || new Set(resource.requiredScopes).size !== resource.requiredScopes.length || !resource.requiredScopes.includes(ESI_ASSETS_SCOPE)) throw new TypeError('inventory projection resource scopes are invalid');
if (!authority || !Number.isSafeInteger(Number(resource.principal.id)) || String(Number(resource.principal.id)) !== resource.principal.id || authority.userId !== userId || authority.characterId !== Number(resource.principal.id) || !Number.isSafeInteger(authority.characterId) || authority.characterId <= 0 || !validCounter(authority.grantRevision) || !validCounter(authority.authorizationEpoch) || !validCounter(authority.credentialVersion) || authority.grantRevision !== resource.authorizationRevision || authority.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('inventory projection authority is stale');
const scopes = authority.scopes ?? resource.requiredScopes;
if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(scopes) || new Set(scopes).size !== scopes.length || !scopes.includes(ESI_ASSETS_SCOPE) || resource.requiredScopes.some((scope) => !scopes.includes(scope))) throw new TypeError('inventory projection authority scopes are invalid');
}
function validateProjectionLocation(value: unknown, asset: { itemId: string; locationId: string }, scopes: readonly string[]): void {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection location is invalid');
const location = value as Partial<ProjectionLocation>;
const allowed = ['itemId', 'locationId', 'sourceLocationId', 'resolution', 'kind', 'name', 'systemId', 'regionId', 'evidence', 'unresolvedReasons'];
if (Object.keys(location).some((key) => !allowed.includes(key)) || location.itemId !== asset.itemId || !isDecimalId(location.locationId) || location.sourceLocationId !== asset.locationId || !isDecimalId(location.sourceLocationId)) throw new TypeError('inventory projection location identity is invalid');
if (location.resolution !== 'resolved' && location.resolution !== 'unresolved') throw new TypeError('inventory projection location resolution is invalid');
if (!Array.isArray(location.unresolvedReasons) || location.unresolvedReasons.length === 0 && location.resolution === 'unresolved' || location.unresolvedReasons.length > 24 || location.unresolvedReasons.some((reason) => typeof reason !== 'string' || reason.length > 128) || stableReasonOrder(location.unresolvedReasons) === false) throw new TypeError('inventory projection location reasons are invalid');
if (location.resolution === 'unresolved') {
if (location.locationId !== asset.locationId || location.kind !== 'unknown' || location.name !== null || location.systemId !== null || location.regionId !== null || location.evidence !== 'unknown') throw new TypeError('unresolved location contains inferred facts');
} else {
if (!['station', 'system', 'region', 'structure'].includes(location.kind ?? '') || (location.name !== null && (typeof location.name !== 'string' || location.name.length > 512)) || (location.systemId === undefined || location.systemId !== null && (!Number.isSafeInteger(location.systemId) || location.systemId <= 0)) || (location.regionId === undefined || location.regionId !== null && (!Number.isSafeInteger(location.regionId) || location.regionId <= 0)) || !['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(location.evidence ?? '')) throw new TypeError('resolved location metadata is invalid');
if (location.kind === 'structure' && location.evidence !== 'private_structure_access') throw new TypeError('structure location evidence is invalid');
if (location.kind === 'structure' && location.evidence === 'private_structure_access' && !scopes.includes(ESI_STRUCTURES_SCOPE)) throw new TypeError('private structure location requires pinned structure scope');
if ((location.kind === 'system' || location.kind === 'region') && location.evidence !== 'verified') throw new TypeError('public location evidence is invalid');
if (location.kind === 'station' && location.evidence !== 'verified' && location.evidence !== 'system_listed') throw new TypeError('station location evidence is invalid');
}
}
export function validateInventoryProjectionRow(value: unknown, context: {
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
}): asserts value is InventoryProjectionRow {
validateProjectionAuthority(context.userId, context.resource, context.authority);
const source = value && typeof value === 'object' ? { ...(value as Record<string, unknown>), isSingleton: (value as Partial<InventoryProjectionRow>).isSingleton === null ? undefined : (value as Partial<InventoryProjectionRow>).isSingleton } : value;
if (!validatedAsset(source, context.authority.characterId)) throw new TypeError('inventory projection row source facts are invalid');
const row = value as InventoryProjectionRow;
if (row.resourceKey !== resourceKey(context.resource) || row.itemKey !== `${context.authority.characterId}:${row.itemId}` || row.generation !== context.generation || row.referenceVersion !== context.referenceVersion || row.priceVersion !== context.priceVersion || row.enrichmentVersion !== context.enrichmentVersion) throw new TypeError('inventory projection row pin is invalid');
if (typeof row.name !== 'string' || !row.name || row.name.length > 512 || typeof row.typeName !== 'string' || !row.typeName || row.typeName.length > 512 || (row.categoryId !== null && !validTypeId(row.categoryId)) || (row.groupId !== null && !validTypeId(row.groupId)) || (row.customName !== null && (typeof row.customName !== 'string' || row.customName.length > 512))) throw new TypeError('inventory projection row reference facts are invalid');
if (row.isSingleton !== null && typeof row.isSingleton !== 'boolean') throw new TypeError('inventory projection singleton fact is invalid');
if (row.pricing !== 'priced' && row.pricing !== 'unpriced') throw new TypeError('inventory projection pricing state is invalid');
const unitPrice = row.unitPrice === null ? null : canonicalMoney(row.unitPrice, 'inventory projection unit price');
const estimatedValue = row.estimatedValue === null ? null : canonicalMoney(row.estimatedValue, 'inventory projection estimated value');
if (row.pricing === 'priced') {
if (context.priceVersion === null || unitPrice === null || estimatedValue === null || parseIsk(unitPrice) < 0n || parseIsk(estimatedValue) < 0n || multiplyIsk(unitPrice, row.quantity) !== estimatedValue) throw new TypeError('inventory projection priced arithmetic is invalid');
} else if (unitPrice !== null || estimatedValue !== null) throw new TypeError('inventory projection unpriced arithmetic is invalid');
if (row.isBlueprintCopy === true && row.pricing !== 'unpriced') throw new TypeError('blueprint copy cannot be priced');
if (row.unresolvedLocation !== (row.location.resolution === 'unresolved')) throw new TypeError('inventory projection unresolved flag is invalid');
if (row.unresolvedReasons.join('\u0000') !== row.location.unresolvedReasons.join('\u0000')) throw new TypeError('inventory projection unresolved reasons do not match location');
validateProjectionLocation(row.location, row, context.authority.scopes ?? context.resource.requiredScopes);
if (row.systemId !== row.location.systemId || row.regionId !== row.location.regionId) throw new TypeError('inventory projection location index does not match evidence');
}
export function validateInventoryProjection(value: unknown): asserts value is InventoryProjectionManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection is invalid');
const candidate = value as Partial<InventoryProjectionManifest>;
if (candidate.version !== 1 || (candidate.status !== 'complete' && candidate.status !== 'partial') || !Array.isArray(candidate.rows) || candidate.rows.length > INVENTORY_PROJECTION_MAX_ROWS) throw new TypeError('inventory projection manifest is invalid');
if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.generation !== 'string' || !candidate.generation || candidate.generation.length > 256 || typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion || candidate.referenceVersion.length > 256) throw new TypeError('inventory projection pin is invalid');
if (candidate.priceVersion !== null && (typeof candidate.priceVersion !== 'string' || !candidate.priceVersion || candidate.priceVersion.length > 256)) throw new TypeError('inventory projection price pin is invalid');
if (candidate.enrichmentVersion !== null && (typeof candidate.enrichmentVersion !== 'string' || !candidate.enrichmentVersion || candidate.enrichmentVersion.length > 256)) throw new TypeError('inventory projection enrichment pin is invalid');
if (typeof candidate.coverage !== 'object' || candidate.coverage === null || typeof candidate.coverage.complete !== 'boolean' || (candidate.status === 'complete') !== candidate.coverage.complete || (candidate.coverage.reason !== undefined && (typeof candidate.coverage.reason !== 'string' || candidate.coverage.reason.length > 512)) || (candidate.status === 'partial' && (!candidate.coverage.reason || candidate.coverage.reason.length > 512))) throw new TypeError('inventory projection coverage is invalid');
if (candidate.fetchedAt !== null && (typeof candidate.fetchedAt !== 'number' || !Number.isFinite(candidate.fetchedAt)) || candidate.upstreamEffectiveAt !== null && (typeof candidate.upstreamEffectiveAt !== 'number' || !Number.isFinite(candidate.upstreamEffectiveAt))) throw new TypeError('inventory projection timestamps are invalid');
if (!candidate.resource || !candidate.authority) throw new TypeError('inventory projection authority is invalid');
validateProjectionAuthority(candidate.userId, candidate.resource, candidate.authority);
const hasResolvedLocation = candidate.rows.some((row) => !!row && typeof row === 'object' && (row as InventoryProjectionRow).location?.resolution === 'resolved');
if (candidate.locationInputPin === null) {
if (hasResolvedLocation) throw new TypeError('resolved projection rows require a location input pin');
} else {
validateReferenceEnrichmentPin(candidate.locationInputPin, { assetGeneration: candidate.generation, referenceVersion: candidate.referenceVersion, authority: authorityContext({ userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, assets: [], types: [] }) });
}
const seen = new Set<string>();
for (const row of candidate.rows) {
validateInventoryProjectionRow(row, { userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, priceVersion: candidate.priceVersion, enrichmentVersion: candidate.enrichmentVersion });
if (seen.has(row.itemKey)) throw new TypeError('inventory projection contains duplicate item identity');
seen.add(row.itemKey);
}
}
export function projectionResourceKey(resource: SelectedResource): string {
return resourceKey(resource);
}
export function compareProjectionRows(left: InventoryProjectionRow, right: InventoryProjectionRow): number {
return left.characterId - right.characterId || compareDecimalIds(left.itemId, right.itemId);
}
/** Stable aliases used by the later Convex staging adapter. */
export const projectInventoryGeneration = buildInventoryProjection;
export const validateProjectedInventory = validateInventoryProjection;