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): 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 { 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(); 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 { if (!Array.isArray(types) || types.length > INVENTORY_PROJECTION_MAX_TYPES) throw new RangeError('projection type cap exceeded'); const result = new Map(); 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; 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>(); for (const asset of input.assets) { if (asset && typeof asset === 'object' && typeof (asset as Partial).itemId === 'string') assets.set((asset as Partial).itemId!, asset as Partial); } const seen = new Set(); 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(); 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(); 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; 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), isSingleton: (value as Partial).isSingleton === null ? undefined : (value as Partial).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; 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(); 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;