wip: checkpoint reviewed T09 inventory query core
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user