wip: checkpoint reviewed T09 inventory query core

This commit is contained in:
jb
2026-09-20 12:26:49 -04:00
parent 0c9197f841
commit 84f4272709
5 changed files with 1815 additions and 0 deletions
+489
View File
@@ -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;
@@ -0,0 +1,377 @@
import type {
InventoryFilters,
InventorySort,
Isk,
SelectedResource,
} from '../../domain/model';
import { compareDecimalIds, isDecimalId, stableSerialize } from '../../domain/inventory-location';
import { parseIsk, sumIsk, validateQuantity } from '../../domain/money';
import {
projectionResourceKey,
MAX_INVENTORY_MONEY_TEXT_LENGTH,
validateInventoryProjection,
type InventoryProjectionManifest,
type InventoryProjectionRow,
} from './projection';
import type { InventoryCursorState } from '../../domain/model';
export const INVENTORY_QUERY_MAX_PAGE_SIZE = 1_000;
export const INVENTORY_QUERY_MAX_MATCHING_ROWS = 50_000;
export const INVENTORY_QUERY_MAX_RESOURCES = 1_000;
export type InventoryResourceStatus = 'ready' | 'partial' | 'stale' | 'authorization_required' | 'unavailable';
/** Server-resolved metadata; callers cannot create authority by supplying this object. */
export type InventoryResourceSnapshot = {
userId: string;
resource: SelectedResource;
characterId: number;
characterName?: string;
status: InventoryResourceStatus;
generation: string | null;
referenceVersion: string | null;
priceVersion: string | null;
enrichmentVersion: string | null;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
grantedScopes?: readonly string[];
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
reason?: string;
};
export type InventoryLocationSelector = 'unresolved';
export type InventoryPricingSelector = 'priced' | 'unpriced';
/** Normalized filter values consumed by persistence adapters. */
export type ValidatedInventoryFilters = {
characterIds: readonly string[];
typeIds: readonly number[];
customName: string | null;
categoryIds: readonly number[];
groupIds: readonly number[];
minQuantity: bigint | null;
maxQuantity: bigint | null;
locationIds: readonly string[];
systemIds: readonly number[];
regionIds: readonly number[];
blueprintState: 'all' | 'original' | 'copy' | 'unknown';
pricing: InventoryPricingSelector | null;
location: InventoryLocationSelector | null;
};
export type InventoryRepositoryPageRequest = {
callerUserId: string;
selectedResources: readonly SelectedResource[];
filters: ValidatedInventoryFilters;
sort: InventorySort;
offset: number;
pageSize: number;
};
export type InventoryRepositoryPage = {
status: 'ready';
rows: readonly InventoryProjectionRow[];
matchingRows: number;
matchingQuantity: string;
estimatedValue: Isk;
nextOffset: number | null;
resources: readonly InventoryResourceSnapshot[];
} | {
status: Exclude<InventoryResourceStatus, 'ready'>;
rows: readonly InventoryProjectionRow[];
matchingRows: number;
matchingQuantity: string;
estimatedValue: Isk | null;
nextOffset: number | null;
resources: readonly InventoryResourceSnapshot[];
reason: string;
};
/**
* The runtime seam T13/T07 will implement against Convex indexes. It is
* intentionally page-oriented: a production adapter must compute the match
* set and all-match aggregates in bounded indexed reads, rather than handing
* the query service an unbounded asset snapshot.
*/
export interface InventoryQueryRepository {
authorizedResources(userId: string): Promise<readonly InventoryResourceSnapshot[]>;
readPage(request: InventoryRepositoryPageRequest): Promise<InventoryRepositoryPage>;
getCursor(token: string): Promise<InventoryCursorState | null>;
putCursor(state: InventoryCursorState): Promise<void>;
replaceCursor(state: InventoryCursorState): Promise<void>;
}
export type InventoryProjectionWriter = {
stageProjection(manifest: InventoryProjectionManifest): Promise<void>;
publishProjection(userId: string, resource: SelectedResource, generation: string): Promise<void>;
};
function plainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], field: string): void {
if (Object.keys(value).some((key) => !allowed.includes(key))) throw new TypeError(`${field} contains unknown fields`);
}
function validateCursorRecord(value: unknown, field: string, allowNull = false): void {
if (!plainRecord(value)) throw new TypeError(`${field} is invalid`);
const keys = Object.keys(value);
if (keys.length > INVENTORY_QUERY_MAX_RESOURCES) throw new RangeError(`${field} cap exceeded`);
for (const key of keys) {
if (!key || typeof value[key] !== 'number' || !Number.isSafeInteger(value[key]) || (value[key] as number) < 0) {
if (allowNull && value[key] === null) continue;
throw new TypeError(`${field} contains an invalid value`);
}
}
}
/** Validate untrusted cursor envelopes before any domain cursor helper dereferences them. */
export function validateInventoryCursorState(value: unknown): asserts value is InventoryCursorState {
if (!plainRecord(value) || typeof value.token !== 'string' || value.token.length === 0 || value.token.length > 256 || (value.status !== 'active' && value.status !== 'exhausted') || !plainRecord(value.position) || !plainRecord(value.pin)) throw new TypeError('cursor state envelope is invalid');
exactKeys(value, ['token', 'status', 'position', 'pin'], 'cursor state');
exactKeys(value.position, ['offset', 'lastItemId'], 'cursor position');
exactKeys(value.pin, ['callerUserId', 'queryFingerprint', 'selectedResources', 'authorizationRevisions', 'authorizationEpochs', 'snapshotGenerations', 'referenceVersion', 'priceVersion', 'expiresAt'], 'cursor pin');
const position = value.position as { offset?: unknown; lastItemId?: unknown };
if (!Number.isSafeInteger(position.offset) || (position.offset as number) < 0 || (position.offset as number) > INVENTORY_QUERY_MAX_MATCHING_ROWS || ((position.offset as number) === 0 ? position.lastItemId !== null : !isDecimalId(position.lastItemId))) throw new TypeError('cursor state position is invalid');
const pin = value.pin;
if (typeof pin.callerUserId !== 'string' || pin.callerUserId.length === 0 || pin.callerUserId.length > 256 || typeof pin.queryFingerprint !== 'string' || pin.queryFingerprint.length === 0 || pin.queryFingerprint.length > 256 || !Array.isArray(pin.selectedResources) || pin.selectedResources.length === 0 || pin.selectedResources.length > INVENTORY_QUERY_MAX_RESOURCES || typeof pin.expiresAt !== 'number' || !Number.isFinite(pin.expiresAt) || pin.expiresAt < 0) throw new TypeError('cursor state pin is invalid');
const resourceKeys = new Set<string>();
for (const resource of pin.selectedResources) {
if (!plainRecord(resource) || !plainRecord(resource.principal)) throw new TypeError('cursor state resource pin is invalid');
exactKeys(resource, ['principal', 'requiredScopes', 'authorizationRevision', 'authorizationEpoch'], 'cursor resource');
exactKeys(resource.principal, ['kind', 'id', 'ownerUserId'], 'cursor principal');
const pinned = resource as unknown as SelectedResource;
if (pinned.principal.kind !== 'character' || !isDecimalId(pinned.principal.id) || typeof pinned.principal.ownerUserId !== 'string' || pinned.principal.ownerUserId.length > 256 || !Array.isArray(pinned.requiredScopes) || pinned.requiredScopes.length === 0 || pinned.requiredScopes.length > 64 || pinned.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(pinned.requiredScopes).size !== pinned.requiredScopes.length || !sameScopes(pinned.requiredScopes, [...pinned.requiredScopes].sort()) || resourceKeys.has(pinned.principal.id) || !Number.isSafeInteger(pinned.authorizationRevision) || pinned.authorizationRevision < 0 || !Number.isSafeInteger(pinned.authorizationEpoch) || pinned.authorizationEpoch < 0) throw new TypeError('cursor state resource pin is invalid');
resourceKeys.add(pinned.principal.id);
}
validateCursorRecord(pin.authorizationRevisions, 'cursor authorization revisions');
validateCursorRecord(pin.authorizationEpochs, 'cursor authorization epochs');
if (!plainRecord(pin.snapshotGenerations) || Object.keys(pin.snapshotGenerations).length > INVENTORY_QUERY_MAX_RESOURCES || Object.values(pin.snapshotGenerations).some((generation) => typeof generation !== 'string' || !generation || generation.length > 256)) throw new TypeError('cursor snapshot pin is invalid');
for (const map of [pin.authorizationRevisions, pin.authorizationEpochs, pin.snapshotGenerations]) {
if (!plainRecord(map) || Object.keys(map).length !== resourceKeys.size || Object.keys(map).some((key) => !key.startsWith('character:') || !resourceKeys.has(key.slice('character:'.length)))) throw new TypeError('cursor resource maps are invalid');
}
if (pin.referenceVersion !== null && (typeof pin.referenceVersion !== 'string' || !pin.referenceVersion || pin.referenceVersion.length > 256)) throw new TypeError('cursor reference pin is invalid');
if (pin.priceVersion !== null && (typeof pin.priceVersion !== 'string' || !pin.priceVersion || pin.priceVersion.length > 256)) throw new TypeError('cursor price pin is invalid');
}
function sortedScopes(scopes: readonly string[]): string[] { return [...scopes].sort(); }
function sameScopes(left: readonly string[], right: readonly string[]): boolean { return stableSerialize(sortedScopes(left)) === stableSerialize(sortedScopes(right)); }
function immutableEqual(left: unknown, right: unknown): boolean { return stableSerialize(left) === stableSerialize(right); }
function resourceMatches(left: SelectedResource, right: SelectedResource): boolean {
return left.principal.kind === right.principal.kind && left.principal.kind === 'character' && right.principal.kind === 'character'
&& left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId
&& left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch
&& sameScopes(left.requiredScopes, right.requiredScopes);
}
function compareTextValue(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function compareText(left: string | null, right: string | null): number {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
return compareTextValue(left, right);
}
function compareRows(left: InventoryProjectionRow, right: InventoryProjectionRow, sort: InventorySort): number {
let result = 0;
switch (sort.field) {
case 'character': result = left.characterId - right.characterId; break;
case 'name': result = compareTextValue(left.name, right.name) || compareTextValue(left.customName ?? '', right.customName ?? ''); break;
case 'quantity': result = validateQuantity(left.quantity) < validateQuantity(right.quantity) ? -1 : validateQuantity(left.quantity) > validateQuantity(right.quantity) ? 1 : 0; break;
case 'location': result = compareText(left.location.name, right.location.name)
|| (left.location.systemId === null ? (right.location.systemId === null ? 0 : 1) : right.location.systemId === null ? -1 : left.location.systemId - right.location.systemId)
|| (left.location.regionId === null ? (right.location.regionId === null ? 0 : 1) : right.location.regionId === null ? -1 : left.location.regionId - right.location.regionId);
break;
case 'estimated_value': {
if (left.estimatedValue === null && right.estimatedValue !== null) result = 1;
else if (left.estimatedValue !== null && right.estimatedValue === null) result = -1;
else if (left.estimatedValue !== null && right.estimatedValue !== null) result = parseIsk(left.estimatedValue) < parseIsk(right.estimatedValue) ? -1 : parseIsk(left.estimatedValue) > parseIsk(right.estimatedValue) ? 1 : 0;
break;
}
}
if (result !== 0) return sort.direction === 'ascending' ? result : -result;
// Decimal IDs cannot be compared as JS numbers. Character ID follows the
// item ID so equal ESI item IDs from two characters remain distinct.
result = compareDecimalIds(left.itemId, right.itemId) || left.characterId - right.characterId;
return result;
}
function matches(row: InventoryProjectionRow, filters: ValidatedInventoryFilters): boolean {
if (filters.characterIds.length > 0 && !filters.characterIds.includes(String(row.characterId))) return false;
if (filters.typeIds.length > 0 && !filters.typeIds.includes(row.typeId)) return false;
if (filters.categoryIds.length > 0 && (row.categoryId === null || !filters.categoryIds.includes(row.categoryId))) return false;
if (filters.groupIds.length > 0 && (row.groupId === null || !filters.groupIds.includes(row.groupId))) return false;
if (filters.customName !== null && !(row.customName ?? '').toLowerCase().includes(filters.customName.toLowerCase())) return false;
const quantity = validateQuantity(row.quantity);
if (filters.minQuantity !== null && quantity < filters.minQuantity) return false;
if (filters.maxQuantity !== null && quantity > filters.maxQuantity) return false;
if (filters.pricing !== null && row.pricing !== filters.pricing) return false;
if (filters.blueprintState === 'copy' && row.isBlueprintCopy !== true) return false;
if (filters.blueprintState === 'original' && row.isBlueprintCopy !== false) return false;
if (filters.blueprintState === 'unknown' && row.isBlueprintCopy !== undefined && row.isBlueprintCopy !== null) return false;
const hasLocationFilter = filters.location !== null || filters.locationIds.length > 0 || filters.systemIds.length > 0 || filters.regionIds.length > 0;
if (!hasLocationFilter) return true; // unresolved rows are included by the unfiltered view.
if (filters.location === 'unresolved') return row.unresolvedLocation;
if (row.unresolvedLocation) return false; // never infer a named location from an unresolved ID.
if (filters.locationIds.length > 0 && !filters.locationIds.includes(row.location.locationId)) return false;
if (filters.systemIds.length > 0 && (row.systemId === null || !filters.systemIds.includes(row.systemId))) return false;
if (filters.regionIds.length > 0 && (row.regionId === null || !filters.regionIds.includes(row.regionId))) return false;
return true;
}
function validatePageRequest(request: InventoryRepositoryPageRequest): void {
if (!request || typeof request.callerUserId !== 'string' || !request.callerUserId) throw new TypeError('repository caller is invalid');
if (!Array.isArray(request.selectedResources) || request.selectedResources.length === 0) throw new TypeError('repository resource selection is empty');
if (!Number.isSafeInteger(request.offset) || request.offset < 0 || request.offset > INVENTORY_QUERY_MAX_MATCHING_ROWS || !Number.isSafeInteger(request.pageSize) || request.pageSize < 1 || request.pageSize > INVENTORY_QUERY_MAX_PAGE_SIZE) throw new RangeError('repository page bounds are invalid');
if (!request.sort || request.sort.tieBreaker !== 'item_id') throw new TypeError('repository sort is invalid');
}
/**
* Bounded fixture adapter used by the pure-core tests. It is deliberately
* exported as a fixture-grade adapter, not presented as the Convex delivery.
* The production implementation must satisfy the same page-oriented port.
*/
export class InMemoryInventoryQueryRepository implements InventoryQueryRepository, InventoryProjectionWriter {
private readonly active = new Map<string, InventoryProjectionManifest>();
private readonly staged = new Map<string, InventoryProjectionManifest>();
private readonly resources = new Map<string, InventoryResourceSnapshot>();
private readonly cursors = new Map<string, InventoryCursorState>();
constructor(initial: readonly InventoryProjectionManifest[] = []) {
for (const manifest of initial) {
validateInventoryProjection(manifest);
const key = this.key(manifest.userId, manifest.resource, manifest.generation);
const existing = this.staged.get(key);
if (existing && !immutableEqual(existing, manifest)) throw new Error('immutable projection snapshot conflict');
if (!existing) this.staged.set(key, structuredClone(manifest));
const staged = this.staged.get(key)!;
if (manifest.status === 'complete') {
const active = this.active.get(key);
if (active && !immutableEqual(active, staged)) throw new Error('immutable published projection conflict');
if (!active) this.active.set(key, structuredClone(staged));
}
this.upsertResource(manifest, manifest.status === 'complete' ? 'ready' : 'partial');
}
}
async stageProjection(manifest: InventoryProjectionManifest): Promise<void> {
validateInventoryProjection(manifest);
if (manifest.rows.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository projection cap exceeded');
const key = this.key(manifest.userId, manifest.resource, manifest.generation);
const existing = this.staged.get(key);
if (existing) {
if (!immutableEqual(existing, manifest)) throw new Error('immutable projection snapshot conflict');
return;
}
this.staged.set(key, structuredClone(manifest));
}
async publishProjection(userId: string, resource: SelectedResource, generation: string): Promise<void> {
const staged = this.staged.get(this.key(userId, resource, generation));
if (!staged) throw new Error('projection generation is not staged');
if (staged.userId !== userId || !resourceMatches(resource, staged.resource)) throw new Error('projection authority fence changed');
if (staged.status !== 'complete' || !staged.coverage.complete) throw new Error('partial projection cannot be published as complete');
const key = this.key(userId, resource, generation);
const existing = this.active.get(key);
if (existing && !immutableEqual(existing, staged)) throw new Error('immutable published projection conflict');
if (!existing) this.active.set(key, structuredClone(staged));
this.upsertResource(staged, 'ready');
}
/** Seed an authorization/coverage state with no active rows for unavailable tests. */
seedResource(snapshot: InventoryResourceSnapshot): void {
if (!snapshot || typeof snapshot.userId !== 'string' || !snapshot.userId || snapshot.resource.principal.kind !== 'character') throw new TypeError('resource snapshot is invalid');
this.resources.set(this.resourceKey(snapshot.userId, snapshot.resource), { ...snapshot, resource: { ...snapshot.resource, requiredScopes: [...snapshot.resource.requiredScopes] } });
}
async authorizedResources(userId: string): Promise<readonly InventoryResourceSnapshot[]> {
if (typeof userId !== 'string' || !userId) throw new TypeError('repository user is invalid');
return [...this.resources.values()].filter((resource) => resource.userId === userId).sort((left, right) => left.characterId - right.characterId);
}
async readPage(request: InventoryRepositoryPageRequest): Promise<InventoryRepositoryPage> {
validatePageRequest(request);
const available = await this.authorizedResources(request.callerUserId);
const selected = available.filter((candidate) => request.selectedResources.some((resource) => resourceMatches(resource, candidate.resource)));
if (selected.length !== request.selectedResources.length) return this.failedPage('authorization_required', selected, 'selected resource is not authorized');
if (selected.some((resource) => resource.status !== 'ready')) {
const failed = selected.find((resource) => resource.status !== 'ready')!;
const failedStatus = failed.status === 'ready' ? 'unavailable' : failed.status;
return this.failedPage(failedStatus, selected, failed.reason ?? `resource ${failed.characterId} coverage is unavailable`);
}
const manifests: InventoryProjectionManifest[] = [];
for (const resource of selected) {
if (!resource.generation) return this.failedPage('unavailable', selected, `resource ${resource.characterId} has no generation`);
const manifest = this.active.get(this.key(request.callerUserId, resource.resource, resource.generation));
if (!manifest || manifest.status !== 'complete' || !manifest.coverage.complete) return this.failedPage('unavailable', selected, `resource ${resource.characterId} generation is unavailable`);
const manifestScopes = manifest.authority.scopes ?? manifest.resource.requiredScopes;
if (manifest.authority.userId !== resource.userId || !resourceMatches(manifest.resource, resource.resource) || manifest.authority.characterId !== resource.characterId || manifest.authority.grantRevision !== resource.grantRevision || manifest.authority.authorizationEpoch !== resource.authorizationEpoch || manifest.authority.credentialVersion !== resource.credentialVersion || !sameScopes(manifestScopes, resource.grantedScopes ?? resource.resource.requiredScopes) || manifest.referenceVersion !== resource.referenceVersion || manifest.priceVersion !== resource.priceVersion || manifest.enrichmentVersion !== resource.enrichmentVersion) return this.failedPage('stale', selected, `resource ${resource.characterId} projection authority or input pin changed`);
manifests.push(manifest);
}
const rows = manifests.flatMap((manifest) => manifest.rows);
if (rows.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository match cap exceeded');
const matching = rows.filter((row) => matches(row, request.filters)).sort((left, right) => compareRows(left, right, request.sort));
if (matching.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository matching cap exceeded');
let matchingQuantity = 0n;
for (const row of matching) matchingQuantity += validateQuantity(row.quantity);
const estimatedValue = sumIsk(matching.map((row) => row.estimatedValue ?? '0'));
const page = matching.slice(request.offset, request.offset + request.pageSize);
const nextOffset = request.offset + page.length < matching.length ? request.offset + page.length : null;
return { status: 'ready', rows: page, matchingRows: matching.length, matchingQuantity: matchingQuantity.toString(10), estimatedValue, nextOffset, resources: selected };
}
async getCursor(token: string): Promise<InventoryCursorState | null> {
if (typeof token !== 'string' || !token) throw new TypeError('cursor token is invalid');
const state = this.cursors.get(token);
return state ? structuredClone(state) : null;
}
async putCursor(state: InventoryCursorState): Promise<void> {
validateInventoryCursorState(state);
if (this.cursors.has(state.token)) throw new Error('cursor token already exists');
this.cursors.set(state.token, structuredClone(state));
}
async replaceCursor(state: InventoryCursorState): Promise<void> {
validateInventoryCursorState(state);
const existing = this.cursors.get(state.token);
if (!existing) throw new Error('cursor token does not exist');
if (!immutableEqual(existing.pin, state.pin) || existing.token !== state.token) throw new Error('cursor pin is immutable');
this.cursors.set(state.token, structuredClone(state));
}
private failedPage(status: Exclude<InventoryResourceStatus, 'ready'>, resources: readonly InventoryResourceSnapshot[], reason: string): InventoryRepositoryPage {
return { status, rows: [], matchingRows: 0, matchingQuantity: '0', estimatedValue: null, nextOffset: null, resources, reason };
}
private upsertResource(manifest: InventoryProjectionManifest, status: InventoryResourceStatus): void {
this.resources.set(this.resourceKey(manifest.userId, manifest.resource), {
userId: manifest.userId,
resource: { ...manifest.resource, requiredScopes: [...manifest.resource.requiredScopes] },
characterId: manifest.authority.characterId,
status,
generation: manifest.generation,
referenceVersion: manifest.referenceVersion,
priceVersion: manifest.priceVersion,
enrichmentVersion: manifest.enrichmentVersion,
grantRevision: manifest.authority.grantRevision,
authorizationEpoch: manifest.authority.authorizationEpoch,
credentialVersion: manifest.authority.credentialVersion,
grantedScopes: [...(manifest.authority.scopes ?? manifest.resource.requiredScopes)],
fetchedAt: manifest.fetchedAt,
upstreamEffectiveAt: manifest.upstreamEffectiveAt,
});
}
private resourceKey(userId: string, resource: SelectedResource): string { return `${userId}\u0000${projectionResourceKey(resource)}`; }
private key(userId: string, resource: SelectedResource, generation: string): string { return `${this.resourceKey(userId, resource)}\u0000${generation}`; }
}
export type { InventoryFilters };
+618
View File
@@ -0,0 +1,618 @@
import { randomUUID } from 'node:crypto';
import type {
CompleteReportProvenance,
DatasetKind,
InventoryCursor,
InventoryCursorLookup,
InventoryCursorPin,
InventoryCursorState,
InventoryFilters,
InventoryQueryResult,
InventoryRow,
InventorySort,
Isk,
SelectedResource,
} from '../../domain/model';
import { ESI_ASSETS_SCOPE, validateInventoryCursorUse, validateInventoryPageResult } from '../../domain/capabilities';
import { compareDecimalIds, isDecimalId, stableSerialize } from '../../domain/inventory-location';
import { parseIsk, validateQuantity } from '../../domain/money';
import {
INVENTORY_QUERY_MAX_MATCHING_ROWS,
INVENTORY_QUERY_MAX_PAGE_SIZE,
type InventoryLocationSelector,
type InventoryPricingSelector,
type InventoryQueryRepository,
type InventoryRepositoryPage,
type InventoryRepositoryPageRequest,
type InventoryResourceSnapshot,
validateInventoryCursorState,
type ValidatedInventoryFilters,
} from './query-repository';
import { MAX_INVENTORY_MONEY_TEXT_LENGTH, validateInventoryProjectionRow, type InventoryProjectionRow } from './projection';
export const DEFAULT_INVENTORY_CURSOR_TTL_MS = 15 * 60 * 1_000;
export const MAX_INVENTORY_CURSOR_TTL_MS = 24 * 60 * 60 * 1_000;
export const INVENTORY_QUERY_MAX_FILTER_VALUES = 1_000;
export const MAX_INVENTORY_CURSOR_TOKEN_LENGTH = 256;
export type InventoryQueryRequest = {
callerUserId: string;
/** Omit to select all currently linked, server-authorized characters. */
selectedResources?: readonly SelectedResource[];
filters: InventoryFilters & {
pricing?: InventoryPricingSelector;
location?: InventoryLocationSelector;
};
sort?: InventorySort;
pageSize?: number;
cursor?: InventoryCursor | string | null;
};
export type InventoryQueryServiceOptions = {
repository: InventoryQueryRepository;
now?: () => number;
cursorTtlMs?: number;
maxPageSize?: number;
};
type NormalizedRequest = {
callerUserId: string;
selectedResources: readonly SelectedResource[];
resourceSelectionExplicit: boolean;
filters: ValidatedInventoryFilters;
sort: InventorySort;
pageSize: number;
cursor: InventoryCursor | null;
};
type CursorFailure = 'authorization_required' | 'stale' | 'unavailable';
function finiteNow(clock: () => number): number {
const value = clock();
if (!Number.isFinite(value)) throw new RangeError('inventory query clock returned an invalid timestamp');
return value;
}
function validIdList(value: unknown, field: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`);
const result = value.map((id, index) => {
if (!isDecimalId(id)) throw new TypeError(`${field}[${index}] is invalid`);
return id;
});
if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`);
return [...result].sort(compareDecimalIds);
}
function validNumberList(value: unknown, field: string): number[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`);
const result = value.map((id, index) => {
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new TypeError(`${field}[${index}] is invalid`);
return id;
});
if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`);
return [...result].sort((left, right) => left - right);
}
function validQuantity(value: unknown, field: string): bigint | null {
if (value === undefined) return null;
if (typeof value !== 'string' || !/^(?:0|[1-9][0-9]*)$(?![\s\S])/.test(value) || value.length > 128) throw new TypeError(`${field} must be a bounded non-negative integer string`);
return BigInt(value);
}
function normalizeFilters(input: unknown): ValidatedInventoryFilters {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('inventory filters are required');
const raw = input as Record<string, unknown>;
const allowed = new Set(['characterIds', 'typeIds', 'customName', 'categoryIds', 'groupIds', 'minQuantity', 'maxQuantity', 'locationIds', 'systemIds', 'regionIds', 'blueprintState', 'pricing', 'location']);
for (const key of Object.keys(raw)) if (!allowed.has(key)) throw new TypeError(`unsupported inventory filter: ${key}`);
const typeIds = validNumberList(raw.typeIds, 'typeIds');
const categoryIds = validNumberList(raw.categoryIds, 'categoryIds');
const groupIds = validNumberList(raw.groupIds, 'groupIds');
const systemIds = validNumberList(raw.systemIds, 'systemIds');
const regionIds = validNumberList(raw.regionIds, 'regionIds');
const locationValues = raw.locationIds === undefined ? [] : raw.locationIds;
if (!Array.isArray(locationValues) || locationValues.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('locationIds filter cap exceeded');
const locationIds: string[] = [];
let location: InventoryLocationSelector | null = raw.location === undefined ? null : raw.location === 'Unresolved' ? 'unresolved' : raw.location as InventoryLocationSelector;
for (const [index, value] of locationValues.entries()) {
if (value === 'unresolved' || value === 'Unresolved') {
if (location !== null && location !== 'unresolved') throw new TypeError('location selectors conflict');
location = 'unresolved';
} else if (isDecimalId(value)) locationIds.push(value);
else throw new TypeError(`locationIds[${index}] is invalid`);
}
if (location !== null && location !== 'unresolved') throw new TypeError('location filter is invalid');
if (location === 'unresolved' && (locationIds.length > 0 || systemIds.length > 0 || regionIds.length > 0)) throw new TypeError('unresolved location filter cannot be combined with named location filters');
if (raw.customName !== undefined && (typeof raw.customName !== 'string' || raw.customName.length > 512)) throw new TypeError('customName filter is invalid');
if (raw.blueprintState !== undefined && !['all', 'original', 'copy', 'unknown'].includes(raw.blueprintState as string)) throw new TypeError('blueprintState filter is invalid');
const pricing = raw.pricing === undefined ? null : raw.pricing as InventoryPricingSelector;
if (pricing !== null && pricing !== 'priced' && pricing !== 'unpriced') throw new TypeError('pricing filter is invalid');
const minQuantity = validQuantity(raw.minQuantity, 'minQuantity');
const maxQuantity = validQuantity(raw.maxQuantity, 'maxQuantity');
if (minQuantity !== null && maxQuantity !== null && minQuantity > maxQuantity) throw new RangeError('quantity filter range is invalid');
return {
characterIds: validIdList(raw.characterIds, 'characterIds'),
typeIds,
customName: raw.customName === undefined || raw.customName === '' ? null : raw.customName as string,
categoryIds,
groupIds,
minQuantity,
maxQuantity,
blueprintState: (raw.blueprintState ?? 'all') as ValidatedInventoryFilters['blueprintState'],
locationIds: [...new Set(locationIds)].sort(compareDecimalIds),
systemIds,
regionIds,
pricing,
location,
};
}
function normalizeSort(value: unknown): InventorySort {
if (!value || typeof value !== 'object') throw new TypeError('inventory sort is required');
const candidate = value as Partial<InventorySort>;
if (!['character', 'name', 'quantity', 'location', 'estimated_value'].includes(candidate.field as string)) throw new TypeError('inventory sort field is invalid');
if (candidate.direction !== 'ascending' && candidate.direction !== 'descending') throw new TypeError('inventory sort direction is invalid');
if (candidate.tieBreaker !== 'item_id') throw new TypeError('inventory sort tie-breaker is invalid');
return { field: candidate.field!, direction: candidate.direction, tieBreaker: 'item_id' };
}
function normalizeCursor(value: unknown): InventoryCursor | null {
if (value === undefined || value === null) return null;
if (typeof value === 'string') {
if (!value) throw new TypeError('inventory cursor token is empty');
if (value.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized');
return { token: value };
}
if (!value || typeof value !== 'object' || typeof (value as InventoryCursor).token !== 'string' || !(value as InventoryCursor).token) throw new TypeError('inventory cursor is malformed');
if ((value as InventoryCursor).token.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized');
return { token: (value as InventoryCursor).token };
}
function sameScopeOrder(scopes: readonly string[]): boolean {
return scopes.every((scope, index) => index === 0 || scopes[index - 1]! <= scope);
}
function samePrincipal(left: SelectedResource, right: SelectedResource): boolean {
return left.principal.kind === 'character' && right.principal.kind === 'character'
&& left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId;
}
function sameResource(left: SelectedResource, right: SelectedResource): boolean {
return samePrincipal(left, right) && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch
&& left.requiredScopes.length === right.requiredScopes.length && left.requiredScopes.every((scope, index) => scope === right.requiredScopes[index]);
}
function resourceSelectionKey(resource: SelectedResource): string {
return resource.principal.kind === 'character'
? `character\u0000${resource.principal.ownerUserId}\u0000${resource.principal.id}`
: `${resource.principal.kind}\u0000${'id' in resource.principal ? resource.principal.id : ''}`;
}
function snapshotSelectionKey(resource: InventoryResourceSnapshot): string {
return `character\u0000${resource.userId}\u0000${resource.characterId}`;
}
function sameSnapshotPins(left: InventoryResourceSnapshot, right: InventoryResourceSnapshot): boolean {
const leftScopes = left.grantedScopes ?? left.resource.requiredScopes;
const rightScopes = right.grantedScopes ?? right.resource.requiredScopes;
return left.userId === right.userId && left.characterId === right.characterId && sameResource(left.resource, right.resource)
&& left.status === right.status && left.generation === right.generation && left.referenceVersion === right.referenceVersion
&& left.priceVersion === right.priceVersion && left.enrichmentVersion === right.enrichmentVersion
&& left.grantRevision === right.grantRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion
&& left.fetchedAt === right.fetchedAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt
&& leftScopes.length === rightScopes.length && leftScopes.every((scope, index) => scope === rightScopes[index]);
}
function canonicalResources(resources: readonly SelectedResource[]): SelectedResource[] {
return [...resources].sort((left, right) => {
if (left.principal.kind !== 'character' || right.principal.kind !== 'character') return 0;
return compareDecimalIds(left.principal.id, right.principal.id);
}).map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes].sort() }));
}
function copyCanonicalResources(resources: readonly SelectedResource[]): SelectedResource[] {
return resources.map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes] }));
}
function canonicalSnapshots(resources: readonly InventoryResourceSnapshot[]): InventoryResourceSnapshot[] {
return [...resources].sort((left, right) => compareDecimalIds(String(left.characterId), String(right.characterId)));
}
function digest(value: unknown): string {
const text = stableSerialize(value);
let first = 2166136261;
let second = 2166136261 ^ 0x9e3779b9;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
first = Math.imul(first ^ code, 16777619);
second = Math.imul(second ^ (code + index), 2246822519);
}
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
}
function versionDigest(values: readonly (string | null)[]): string | null {
const unique = [...new Set(values)];
if (unique.length === 0 || unique.every((value) => value === null)) return null;
return unique.length === 1 ? unique[0]! : `multi-${digest(unique.sort())}`;
}
function resourceMaps(resources: readonly InventoryResourceSnapshot[]) {
const authorizationRevisions: Record<string, number> = {};
const authorizationEpochs: Record<string, number> = {};
const snapshotGenerations: Record<string, string> = {};
const credentialVersions: Record<string, number> = {};
const enrichmentVersions: Record<string, string | null> = {};
for (const resource of canonicalSnapshots(resources)) {
const key = `character:${resource.characterId}`;
authorizationRevisions[key] = resource.grantRevision;
authorizationEpochs[key] = resource.authorizationEpoch;
credentialVersions[key] = resource.credentialVersion;
if (resource.generation) snapshotGenerations[key] = resource.generation;
enrichmentVersions[key] = resource.enrichmentVersion;
}
return { authorizationRevisions, authorizationEpochs, snapshotGenerations, credentialVersions, enrichmentVersions };
}
function queryFingerprint(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): string {
const maps = resourceMaps(resources);
return `inventory-${digest({
callerUserId: request.callerUserId,
resources: copyCanonicalResources(request.selectedResources),
filters: {
...request.filters,
minQuantity: request.filters.minQuantity?.toString() ?? null,
maxQuantity: request.filters.maxQuantity?.toString() ?? null,
},
sort: request.sort,
pageSize: request.pageSize,
...maps,
referenceVersions: canonicalSnapshots(resources).map((resource) => resource.referenceVersion),
priceVersions: canonicalSnapshots(resources).map((resource) => resource.priceVersion),
})}`;
}
function cursorPin(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, expiresAt: number): InventoryCursorPin {
const maps = resourceMaps(resources);
return {
callerUserId: request.callerUserId,
queryFingerprint: fingerprint,
selectedResources: copyCanonicalResources(request.selectedResources),
authorizationRevisions: maps.authorizationRevisions,
authorizationEpochs: maps.authorizationEpochs,
snapshotGenerations: maps.snapshotGenerations,
referenceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.referenceVersion)),
priceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.priceVersion)),
expiresAt,
};
}
function cursorLookup(token: string, request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, now: number): InventoryCursorLookup {
const pin = cursorPin(request, resources, fingerprint, now);
return { token, callerUserId: request.callerUserId, queryFingerprint: fingerprint, selectedResources: pin.selectedResources, authorizationRevisions: pin.authorizationRevisions, authorizationEpochs: pin.authorizationEpochs, snapshotGenerations: pin.snapshotGenerations, referenceVersion: pin.referenceVersion, priceVersion: pin.priceVersion, now };
}
type NonReadyInventoryResult = Exclude<InventoryQueryResult, { status: 'ready' }>;
function invalidCursorResult(failure: CursorFailure, reason: string): NonReadyInventoryResult {
return { status: failure, rows: [], summary: null, reason };
}
function provenance(resources: readonly InventoryResourceSnapshot[], now: number, priceVersion: string | null, referenceVersion: string | null): CompleteReportProvenance {
const orderedResources = canonicalSnapshots(resources);
const selectedResources = copyCanonicalResources(orderedResources.map((resource) => resource.resource));
const datasetPins = orderedResources.map((resource) => ({
selectedResource: resource.resource,
principal: resource.resource.principal,
dataset: 'assets' as const,
coverage: { kind: 'snapshot' as const, generation: resource.generation!, complete: true as const },
authorizationRevision: resource.grantRevision,
authorizationEpoch: resource.authorizationEpoch,
fetchedAt: resource.fetchedAt,
upstreamEffectiveAt: resource.upstreamEffectiveAt,
}));
const maps = resourceMaps(orderedResources);
return {
reportId: `inventory-${digest({ selectedResources, datasetPins, now })}`,
generatedAt: now,
fetchedAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.fetchedAt])),
upstreamEffectiveAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.upstreamEffectiveAt])),
input: {
selectedResources,
requiredDatasetKinds: ['assets'] as readonly [DatasetKind],
pinSet: 'exact_selected_resource_dataset_set',
datasetPins: datasetPins as unknown as CompleteReportProvenance['input']['datasetPins'],
authorizationRevisions: maps.authorizationRevisions,
authorizationEpochs: maps.authorizationEpochs,
snapshotGenerations: maps.snapshotGenerations,
appendCheckpoints: {},
referenceVersion,
priceVersion,
},
sourceSkew: false,
assumptions: ['Reference and price inputs are immutable versions pinned by the projection manifest.'],
};
}
function stripRows(rows: readonly InventoryProjectionRow[]): readonly InventoryRow[] {
// Keep only the documented public inventory projection. In particular, never
// spread persisted rows: adapters may contain private index fields or
// credential-like additions that must not cross the query boundary.
return rows.map((row) => ({
itemId: row.itemId,
characterId: row.characterId,
typeId: row.typeId,
quantity: row.quantity,
locationId: row.locationId,
locationType: row.locationType,
locationFlag: row.locationFlag,
isSingleton: row.isSingleton,
isBlueprintCopy: row.isBlueprintCopy,
name: row.name,
typeName: row.typeName,
categoryId: row.categoryId,
groupId: row.groupId,
customName: row.customName,
systemId: row.systemId,
regionId: row.regionId,
estimatedValue: row.estimatedValue,
unitPrice: row.unitPrice,
pricing: row.pricing,
unresolvedLocation: row.unresolvedLocation,
unresolvedReasons: [...row.unresolvedReasons],
location: {
itemId: row.location.itemId,
locationId: row.location.locationId,
sourceLocationId: row.location.sourceLocationId,
resolution: row.location.resolution,
kind: row.location.kind,
name: row.location.name,
systemId: row.location.systemId,
regionId: row.location.regionId,
evidence: row.location.evidence,
unresolvedReasons: [...row.location.unresolvedReasons],
},
})) as unknown as readonly InventoryRow[];
}
function validateRepositoryPage(page: InventoryRepositoryPage, request: NormalizedRequest, offset: number): void {
if (!page || typeof page !== 'object' || !['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes((page as { status?: unknown }).status as string) || !Array.isArray(page.rows) || page.rows.length > request.pageSize) throw new TypeError('inventory repository returned an invalid page');
if (!Array.isArray(page.resources) || page.resources.length !== request.selectedResources.length) throw new TypeError('inventory repository returned an invalid resource pin');
if (!Number.isSafeInteger(page.matchingRows) || page.matchingRows < 0 || page.matchingRows > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('inventory repository matching row cap exceeded');
if (page.nextOffset !== null && (!Number.isSafeInteger(page.nextOffset) || page.nextOffset < 0 || page.nextOffset > INVENTORY_QUERY_MAX_MATCHING_ROWS)) throw new TypeError('inventory repository continuation offset is invalid');
if (page.status === 'ready') {
if (page.rows.length > page.matchingRows || typeof page.matchingQuantity !== 'string' || page.matchingQuantity.length > MAX_INVENTORY_MONEY_TEXT_LENGTH || !/^(?:0|[1-9][0-9]*)$/.test(page.matchingQuantity) || typeof page.estimatedValue !== 'string' || page.estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new TypeError('inventory repository aggregate is invalid');
parseIsk(page.matchingQuantity);
parseIsk(page.estimatedValue);
if (parseIsk(page.estimatedValue) < 0n) throw new TypeError('inventory repository aggregate is negative');
const end = offset + page.rows.length;
if (end > page.matchingRows) throw new TypeError('inventory repository page exceeds summary');
if (page.rows.length === 0) {
if (offset < page.matchingRows || page.nextOffset !== null) throw new TypeError('inventory repository returned an empty page before the end');
} else if (end < page.matchingRows) {
if (page.nextOffset !== end) throw new TypeError('inventory repository continuation skips or loops');
} else if (page.nextOffset !== null) {
throw new TypeError(`inventory repository returned a continuation after the end (${offset},${page.rows.length},${page.matchingRows},${page.nextOffset})`);
}
} else if (page.rows.length !== 0 || page.matchingRows !== 0 || page.matchingQuantity !== '0' || page.estimatedValue !== null || page.nextOffset !== null || typeof page.reason !== 'string' || !page.reason) {
throw new TypeError('inventory repository unavailable response is invalid');
}
}
function validateReturnedResourcePins(resources: readonly InventoryResourceSnapshot[], authoritative: readonly InventoryResourceSnapshot[]): void {
const authoritativeByKey = new Map(authoritative.map((resource) => [snapshotSelectionKey(resource), resource]));
const seen = new Set<string>();
for (const resource of resources) {
const key = snapshotSelectionKey(resource);
const expected = authoritativeByKey.get(key);
if (!expected || seen.has(key) || !sameSnapshotPins(resource, expected)) throw new TypeError('inventory repository resource pin does not match authoritative snapshot');
seen.add(key);
}
if (seen.size !== authoritativeByKey.size) throw new TypeError('inventory repository resource pin does not match authoritative snapshot');
}
function validateRepositoryRows(rows: readonly InventoryProjectionRow[], resources: readonly InventoryResourceSnapshot[]): void {
const owners = new Map(resources.map((resource) => [resource.characterId, resource]));
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || !Number.isSafeInteger(row.characterId) || row.characterId <= 0 || typeof row.itemKey !== 'string' || seen.has(row.itemKey) || !Number.isSafeInteger(row.typeId) || row.typeId <= 0 || !Number.isSafeInteger(row.quantity) || row.quantity < 0) throw new TypeError('inventory repository returned a malformed row');
validateQuantity(row.quantity);
const owner = owners.get(row.characterId);
if (!owner || row.resourceKey !== `character:${row.characterId}` || row.generation !== owner.generation || row.referenceVersion !== owner.referenceVersion || row.priceVersion !== owner.priceVersion || row.enrichmentVersion !== owner.enrichmentVersion) throw new TypeError('inventory repository row pin does not match selected resource');
validateInventoryProjectionRow(row, {
userId: owner.userId,
resource: owner.resource,
authority: { userId: owner.userId, characterId: owner.characterId, grantRevision: owner.grantRevision, authorizationEpoch: owner.authorizationEpoch, credentialVersion: owner.credentialVersion, scopes: owner.grantedScopes },
generation: owner.generation!,
referenceVersion: owner.referenceVersion!,
priceVersion: owner.priceVersion,
enrichmentVersion: owner.enrichmentVersion,
});
seen.add(row.itemKey);
}
}
/** Pure-core query orchestration over a bounded, typed persistence port. */
export class InventoryQueryService {
private readonly repository: InventoryQueryRepository;
private readonly now: () => number;
private readonly cursorTtlMs: number;
private readonly maxPageSize: number;
constructor(options: InventoryQueryServiceOptions);
constructor(repository: InventoryQueryRepository, options?: Omit<InventoryQueryServiceOptions, 'repository'>);
constructor(optionsOrRepository: InventoryQueryServiceOptions | InventoryQueryRepository, extraOptions: Omit<InventoryQueryServiceOptions, 'repository'> = {}) {
const options: InventoryQueryServiceOptions = 'repository' in optionsOrRepository
? optionsOrRepository
: { repository: optionsOrRepository, ...extraOptions };
this.repository = options.repository;
this.now = options.now ?? (() => Date.now());
this.cursorTtlMs = options.cursorTtlMs ?? DEFAULT_INVENTORY_CURSOR_TTL_MS;
this.maxPageSize = options.maxPageSize ?? INVENTORY_QUERY_MAX_PAGE_SIZE;
if (!Number.isSafeInteger(this.cursorTtlMs) || this.cursorTtlMs < 1 || this.cursorTtlMs > MAX_INVENTORY_CURSOR_TTL_MS) throw new RangeError('inventory cursor TTL is invalid');
if (!Number.isSafeInteger(this.maxPageSize) || this.maxPageSize < 1 || this.maxPageSize > INVENTORY_QUERY_MAX_PAGE_SIZE) throw new RangeError('inventory page cap is invalid');
}
async query(input: InventoryQueryRequest): Promise<InventoryQueryResult> {
const now = finiteNow(this.now);
const request = await this.normalize(input);
let resources: readonly InventoryResourceSnapshot[];
try {
resources = await this.repository.authorizedResources(request.callerUserId);
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory resource coverage is unavailable' };
}
this.validateResources(resources, request.callerUserId);
const selected = this.resolveSelection(request, resources);
if (selected === null) return invalidCursorResult('authorization_required', 'Inventory resource selection is not authorized for caller');
const resolved: NormalizedRequest = { ...request, selectedResources: selected };
if (selected.length === 0) return invalidCursorResult('authorization_required', 'No authorized linked character resources are available');
const selectedKeys = new Set(selected.map(resourceSelectionKey));
const selectedSnapshots = canonicalSnapshots(resources.filter((resource) => selectedKeys.has(resourceSelectionKey(resource.resource))));
const failure = selectedSnapshots.find((resource) => resource.status !== 'ready');
if (failure) {
const failureStatus = failure.status === 'ready' ? 'unavailable' : failure.status;
return { status: failureStatus, rows: [], summary: null, reason: failure.reason ?? `Character ${failure.characterId} inventory coverage is unavailable` };
}
const fingerprint = queryFingerprint(resolved, selectedSnapshots);
let offset = 0;
let cursorState: InventoryCursorState | null = null;
if (resolved.cursor) {
try {
cursorState = await this.repository.getCursor(resolved.cursor.token);
} catch {
return invalidCursorResult('unavailable', 'Inventory cursor storage is unavailable');
}
if (!cursorState) return invalidCursorResult('unavailable', 'Inventory cursor is unknown or expired');
try {
validateInventoryCursorState(cursorState);
} catch {
return invalidCursorResult('unavailable', 'Inventory cursor state is malformed');
}
if (cursorState.token !== resolved.cursor.token) return invalidCursorResult('unavailable', 'Inventory cursor token envelope is malformed');
const lookup = cursorLookup(resolved.cursor.token, resolved, selectedSnapshots, fingerprint, now);
const validation = validateInventoryCursorUse(cursorState, lookup);
if (!validation.valid) {
const status: CursorFailure = validation.reason === 'caller_mismatch' || validation.reason === 'resource_mismatch' || validation.reason === 'authorization_mismatch' ? 'authorization_required' : validation.reason === 'input_version_mismatch' || validation.reason === 'expired' || validation.reason === 'exhausted' ? 'stale' : 'unavailable';
return invalidCursorResult(status, `Inventory cursor rejected: ${validation.reason}`);
}
offset = cursorState.position.offset;
}
const pageRequest: InventoryRepositoryPageRequest = { callerUserId: resolved.callerUserId, selectedResources: resolved.selectedResources, filters: resolved.filters, sort: resolved.sort, offset, pageSize: resolved.pageSize };
let page: InventoryRepositoryPage;
try {
page = await this.repository.readPage(pageRequest);
} catch (error) {
if (error instanceof TypeError) throw error;
if (error instanceof RangeError) return { status: 'unavailable', rows: [], summary: null, reason: error.message };
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory query storage is unavailable' };
}
validateRepositoryPage(page, resolved, offset);
validateReturnedResourcePins(page.resources, selectedSnapshots);
if (page.status !== 'ready') return { status: page.status, rows: [], summary: null, reason: page.reason };
if (selectedSnapshots.some((resource) => resource.status !== 'ready')) throw new TypeError('ready repository page contains a non-ready resource');
validateRepositoryRows(page.rows, selectedSnapshots);
const summary = { matchingRows: page.matchingRows, matchingQuantity: page.matchingQuantity, estimatedValue: page.estimatedValue, scope: 'all_matching_rows' as const };
if (page.rows.length === 0 && resolved.cursor) {
if (!cursorState) throw new TypeError('inventory continuation cursor state is missing');
try {
await this.repository.replaceCursor({ ...cursorState, status: 'exhausted' });
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' };
}
const result: InventoryQueryResult = { status: 'ready', rows: [], summary, nextCursor: null, pageState: 'after_end', provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))) };
if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid after-end page');
return result;
}
let nextCursor: InventoryCursor | null = null;
if (page.rows.length > 0) {
const token = randomUUID();
const position = { offset: page.nextOffset ?? offset + page.rows.length, lastItemId: page.rows[page.rows.length - 1]!.itemId };
try {
await this.repository.putCursor({ token, status: 'active', position, pin: cursorPin(resolved, selectedSnapshots, fingerprint, now + this.cursorTtlMs) });
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' };
}
nextCursor = { token };
}
const result: InventoryQueryResult = {
status: 'ready',
rows: stripRows(page.rows),
summary,
nextCursor,
pageState: resolved.cursor ? 'continuation' : 'initial',
provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))),
} as InventoryQueryResult;
if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid page');
return result;
}
execute(input: InventoryQueryRequest): Promise<InventoryQueryResult> {
return this.query(input);
}
private async normalize(input: InventoryQueryRequest): Promise<NormalizedRequest> {
if (!input || typeof input !== 'object' || typeof input.callerUserId !== 'string' || !input.callerUserId) throw new TypeError('inventory caller is required');
const pageSize: unknown = input.pageSize;
if (!Number.isSafeInteger(pageSize) || (pageSize as number) < 1 || (pageSize as number) > this.maxPageSize) throw new RangeError(`inventory page size must be 1..${this.maxPageSize}`);
const boundedPageSize = pageSize as number;
const resourceSelectionExplicit = input.selectedResources !== undefined;
if (resourceSelectionExplicit && (!Array.isArray(input.selectedResources) || input.selectedResources.length > INVENTORY_QUERY_MAX_FILTER_VALUES)) throw new RangeError('selected resource cap exceeded');
return {
callerUserId: input.callerUserId,
selectedResources: resourceSelectionExplicit ? canonicalResources(input.selectedResources!) : [],
resourceSelectionExplicit,
filters: normalizeFilters(input.filters),
sort: normalizeSort(input.sort),
pageSize: boundedPageSize,
cursor: normalizeCursor(input.cursor),
};
}
private validateResources(resources: readonly InventoryResourceSnapshot[], userId: string): void {
if (!Array.isArray(resources) || resources.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('authorized resource cap exceeded');
const principals = new Set<string>();
for (const resource of resources) {
if (!resource || resource.userId !== userId || resource.userId.length > 256 || !resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.ownerUserId !== userId || !Array.isArray(resource.resource.requiredScopes) || resource.resource.requiredScopes.length > 64 || resource.resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.resource.requiredScopes).size !== resource.resource.requiredScopes.length || !sameScopeOrder(resource.resource.requiredScopes) || !resource.resource.requiredScopes.includes(ESI_ASSETS_SCOPE) || !isDecimalId(resource.resource.principal.id) || !Number.isSafeInteger(Number(resource.resource.principal.id)) || resource.characterId !== Number(resource.resource.principal.id) || principals.has(resource.resource.principal.id)) throw new TypeError('authorized resource repository returned malformed or cross-tenant data');
if (!['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes(resource.status)) throw new TypeError('authorized resource status is invalid');
if (!Number.isSafeInteger(resource.grantRevision) || resource.grantRevision < 0 || !Number.isSafeInteger(resource.authorizationEpoch) || resource.authorizationEpoch < 0 || !Number.isSafeInteger(resource.credentialVersion) || resource.credentialVersion < 0 || resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('authorized resource counters are stale or inconsistent');
if (resource.grantedScopes !== undefined && (!Array.isArray(resource.grantedScopes) || resource.grantedScopes.length > 64 || resource.grantedScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.grantedScopes).size !== resource.grantedScopes.length || !sameScopeOrder(resource.grantedScopes) || !resource.grantedScopes.includes(ESI_ASSETS_SCOPE) || resource.resource.requiredScopes.some((scope: string) => !resource.grantedScopes!.includes(scope)))) throw new TypeError('authorized resource scopes are inconsistent');
if (resource.status === 'ready' && resource.grantedScopes === undefined) throw new TypeError('ready resource is missing current authority scopes');
if (resource.status === 'ready' && (typeof resource.generation !== 'string' || !resource.generation || typeof resource.referenceVersion !== 'string' || !resource.referenceVersion)) throw new TypeError('ready resource is missing an immutable input pin');
if (resource.generation !== null && (typeof resource.generation !== 'string' || !resource.generation || resource.generation.length > 256)) throw new TypeError('resource generation is invalid');
if (resource.referenceVersion !== null && (typeof resource.referenceVersion !== 'string' || !resource.referenceVersion || resource.referenceVersion.length > 256)) throw new TypeError('resource reference pin is invalid');
if (resource.priceVersion !== null && (typeof resource.priceVersion !== 'string' || !resource.priceVersion || resource.priceVersion.length > 256)) throw new TypeError('resource price pin is invalid');
if (resource.enrichmentVersion !== null && (typeof resource.enrichmentVersion !== 'string' || !resource.enrichmentVersion || resource.enrichmentVersion.length > 256)) throw new TypeError('resource enrichment pin is invalid');
if (resource.characterName !== undefined && (typeof resource.characterName !== 'string' || resource.characterName.length > 512)) throw new TypeError('resource character name is invalid');
if (resource.reason !== undefined && (typeof resource.reason !== 'string' || resource.reason.length > 512)) throw new TypeError('resource availability reason is invalid');
if (resource.fetchedAt !== null && !Number.isFinite(resource.fetchedAt)) throw new TypeError('resource fetched timestamp is invalid');
if (resource.upstreamEffectiveAt !== null && !Number.isFinite(resource.upstreamEffectiveAt)) throw new TypeError('resource upstream timestamp is invalid');
principals.add(resource.resource.principal.id);
}
}
private resolveSelection(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): readonly SelectedResource[] | null {
if (!request.resourceSelectionExplicit) return canonicalResources(resources.map((resource) => resource.resource));
if (request.selectedResources.length === 0) return null;
const available = new Map(resources.map((snapshot) => [resourceSelectionKey(snapshot.resource), snapshot.resource]));
const selected = new Map<string, SelectedResource>();
for (const candidate of request.selectedResources) {
const key = resourceSelectionKey(candidate);
if (selected.has(key)) throw new TypeError('inventory resource selection contains duplicate characters');
const authorized = available.get(key);
if (!authorized || !sameResource(candidate, authorized)) return null;
selected.set(key, candidate);
}
return request.selectedResources;
}
}
export async function queryInventory(options: InventoryQueryServiceOptions, request: InventoryQueryRequest): Promise<InventoryQueryResult> {
return new InventoryQueryService(options).query(request);
}
export { normalizeFilters, normalizeSort };
+7
View File
@@ -0,0 +1,7 @@
[
{"itemId":"9007199254740993","characterId":42,"typeId":100,"quantity":2,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":true},
{"itemId":"9007199254740995","characterId":42,"typeId":101,"quantity":3,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":false},
{"itemId":"9007199254740997","characterId":42,"typeId":102,"quantity":1,"locationId":"8000000000000001","locationType":"other","locationFlag":"Hangar","isBlueprintCopy":true},
{"itemId":"9007199254740999","characterId":43,"typeId":100,"quantity":4,"locationId":"60003760","locationType":"station","locationFlag":"Hangar"},
{"itemId":"9007199254741001","characterId":43,"typeId":103,"quantity":5,"locationId":"9999999999999999","locationType":"other","locationFlag":"Hangar"}
]
+324
View File
@@ -0,0 +1,324 @@
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'bun:test';
import type { Asset, InventoryType, SelectedResource } from '../src/lib/domain/model';
import { createReferenceEnrichmentPin, type EnrichedLocationNode } from '../src/lib/domain/inventory-location';
import { buildInventoryProjection, validateInventoryProjection, type ProjectionAuthority } from '../src/lib/server/inventory/projection';
import { InMemoryInventoryQueryRepository } from '../src/lib/server/inventory/query-repository';
import { InventoryQueryService } from '../src/lib/server/inventory/query-service';
const fixture = JSON.parse(readFileSync('tests/fixtures/inventory/query-assets.json', 'utf8')) as Asset[];
const types: InventoryType[] = [
{ id: 100, name: 'Tritanium', categoryId: 4, groupId: 18 },
{ id: 101, name: 'Pyerite', categoryId: 4, groupId: 18 },
{ id: 102, name: 'Blueprint Copy', categoryId: 9, groupId: 100 },
];
const resource = (userId: string, characterId: number, revision = 1): SelectedResource => ({
principal: { kind: 'character', id: String(characterId), ownerUserId: userId },
requiredScopes: ['esi-assets.read_assets.v1'],
authorizationRevision: revision,
authorizationEpoch: 1,
});
const authority = (userId: string, characterId: number, revision = 1): ProjectionAuthority => ({ userId, characterId, grantRevision: revision, authorizationEpoch: 1, credentialVersion: 1, scopes: ['esi-assets.read_assets.v1'] });
const locationPin = (userId: string, characterId: number, generation: string, revision = 1) => createReferenceEnrichmentPin({
assetGeneration: generation,
authority: { userId, principal: `character:${characterId}`, characterId, scopes: ['esi-assets.read_assets.v1'], grantRevision: revision, authorizationEpoch: 1, credentialVersion: 1 },
referenceVersion: 'ref-v1',
});
function resolvedLocation(asset: Asset, name = 'Jita'): EnrichedLocationNode {
return {
...(asset as any),
parentItemId: null,
root: { kind: 'external', id: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag },
depth: 0,
breadcrumbs: [],
fitted: { classification: 'not_fitted', rawFlag: asset.locationFlag, reason: 'ordinary_location_flag' },
fittedClassification: 'not_fitted',
isFitted: false,
unresolved: [],
customName: null,
nameStatus: 'not_requested',
position: null,
locationStatus: 'not_requested',
location: {
kind: 'station', id: asset.locationId, name, parentId: '30000142', evidence: 'system_listed', sourceVerified: false,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: {
kind: 'system', id: '30000142', name: 'Jita', parentId: '10000002', evidence: 'verified', sourceVerified: true,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: {
kind: 'region', id: '10000002', name: 'The Forge', parentId: null, evidence: 'verified', sourceVerified: true,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null,
},
},
region: null,
},
} as EnrichedLocationNode;
}
function makeRepository(userId = 'user-a') {
const resources = [resource(userId, 42), resource(userId, 43)];
const manifests = resources.map((selected) => {
const characterId = Number((selected.principal as { id: string }).id);
const assets = fixture.filter((asset) => asset.characterId === characterId);
return buildInventoryProjection({
userId, resource: selected, authority: authority(userId, characterId), generation: `assets-${characterId}`,
referenceVersion: 'ref-v1', priceVersion: 'prices-v1', enrichmentVersion: `enrich-${characterId}`,
assets, types, prices: [{ typeId: 100, unitPrice: '100' }, { typeId: 101, unitPrice: '200' }],
locations: assets.filter((asset) => asset.itemId !== '9007199254741001' && asset.itemId !== '9007199254740997').map((asset) => resolvedLocation(asset)),
locationInputPin: locationPin(userId, characterId, `assets-${characterId}`),
});
});
return { repository: new InMemoryInventoryQueryRepository(manifests), resources, manifests };
}
const query = (cursor: { token: string } | null = null, extra: Record<string, unknown> = {}) => ({
callerUserId: 'user-a', filters: {}, sort: { field: 'name' as const, direction: 'ascending' as const, tieBreaker: 'item_id' as const }, pageSize: 2, cursor, ...extra,
});
describe('T09 inventory projection/query core', () => {
test('projects exact money, BPC/unpriced rows and legacy singleton unknown', async () => {
const { repository } = makeRepository();
const result = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 10 });
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.summary).toMatchObject({ matchingRows: 5, matchingQuantity: '15', estimatedValue: '1200', scope: 'all_matching_rows' });
expect(result.rows.filter((row) => row.isSingleton === null)).toHaveLength(3);
expect(result.rows.filter((row) => row.isSingleton === false)).toHaveLength(1);
expect(result.rows.filter((row) => row.isSingleton === true)).toHaveLength(1);
expect((result.rows.find((row) => row.isBlueprintCopy === true) as any)?.estimatedValue).toBeNull();
expect((result.rows.find((row) => row.isBlueprintCopy === true) as any)?.pricing).toBe('unpriced');
});
test('includes unresolved locations only in the unfiltered view and explicit Unresolved filter', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const all = await service.query({ ...query(), pageSize: 20 });
expect(all.status).toBe('ready');
if (all.status !== 'ready') return;
expect((all.rows as any[]).filter((row) => row.unresolvedLocation)).toHaveLength(2);
const quantityFiltered = await service.query({ ...query(), pageSize: 20, filters: { minQuantity: '3' } });
expect(quantityFiltered.status).toBe('ready');
const stations = await service.query({ ...query(), pageSize: 20, filters: { locationIds: ['60003760'] } });
expect(stations.status).toBe('ready');
if (stations.status !== 'ready') return;
expect(stations.rows).toHaveLength(3);
const unresolved = await service.query({ ...query(), pageSize: 20, filters: { location: 'unresolved' } });
expect(unresolved.status).toBe('ready');
if (unresolved.status !== 'ready') return;
expect(unresolved.rows).toHaveLength(2);
expect((unresolved.rows as any[]).every((row) => row.location.name === null)).toBe(true);
});
test('pages deterministically across characters and exposes a terminal after-end page', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const seen: string[] = [];
let page = await service.query(query());
expect(page.status).toBe('ready');
while (page.status === 'ready' && page.pageState !== 'after_end') {
seen.push(...page.rows.map((row) => `${row.characterId}:${row.itemId}`));
if (!page.nextCursor) break;
page = await service.query(query(page.nextCursor));
}
expect(page).toMatchObject({ status: 'ready', pageState: 'after_end', nextCursor: null });
expect(new Set(seen).size).toBe(5);
const again: string[] = [];
let repeat = await service.query(query());
while (repeat.status === 'ready' && repeat.pageState !== 'after_end') {
again.push(...repeat.rows.map((row) => `${row.characterId}:${row.itemId}`));
if (!repeat.nextCursor) break;
repeat = await service.query(query(repeat.nextCursor));
}
expect(again).toEqual(seen);
});
test('pins tenant, authority, generations and cursor versions', async () => {
const { manifests } = makeRepository();
const other = makeRepository('user-b');
// Both tenants use one server-side cursor store, but their resources and
// projections remain separately owner-bound.
const sharedRepository = new InMemoryInventoryQueryRepository([...manifests, ...other.manifests]);
const service = new InventoryQueryService({ repository: sharedRepository, now: () => 100 });
const first = await service.query(query());
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
expect((await service.query({ ...query(first.nextCursor), filters: { typeIds: [100] } })).status).toBe('unavailable');
const borrowed = await new InventoryQueryService({ repository: sharedRepository, now: () => 100 }).query({ ...query(first.nextCursor), callerUserId: 'user-b' });
expect(borrowed.status).toBe('authorization_required');
let now = 100;
const expiring = new InventoryQueryService({ repository: sharedRepository, now: () => now, cursorTtlMs: 10 });
const expiringPage = await expiring.query(query());
expect(expiringPage.status).toBe('ready');
if (expiringPage.status !== 'ready' || !expiringPage.nextCursor) return;
now = 111;
expect((await expiring.query(query(expiringPage.nextCursor))).status).toBe('stale');
});
test('does not publish partial projection and reports unavailable coverage explicitly', async () => {
const { repository, manifests } = makeRepository();
const partial = buildInventoryProjection({
userId: 'user-a', resource: resource('user-a', 44), authority: authority('user-a', 44), generation: 'partial',
referenceVersion: 'ref-v1', assets: [], types, coverage: { complete: false, reason: 'name coverage unavailable' },
});
await repository.stageProjection(partial);
await expect(repository.publishProjection('user-a', resource('user-a', 44), 'partial')).rejects.toThrow('partial');
repository.seedResource({ userId: 'user-a', resource: resource('user-a', 44), characterId: 44, status: 'partial', generation: 'partial', referenceVersion: 'ref-v1', priceVersion: null, enrichmentVersion: null, grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, fetchedAt: null, upstreamEffectiveAt: null, reason: 'name coverage unavailable' });
const result = await new InventoryQueryService({ repository, now: () => 100 }).query(query(null, { pageSize: 10 }));
expect(result.status).toBe('partial');
expect(manifests).toHaveLength(2);
});
test('rejects arbitrary filters, malformed sorts, and oversized caps before reads', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100, maxPageSize: 10 });
await expect(service.query({ ...query(), filters: { hack: 'all' } as any })).rejects.toThrow('unsupported inventory filter');
await expect(service.query({ ...query(), sort: { field: 'random' as any, direction: 'ascending', tieBreaker: 'item_id' } })).rejects.toThrow('sort field');
await expect(service.query({ ...query(), pageSize: 11 })).rejects.toThrow('page size');
await expect(service.query({ ...query(), filters: { typeIds: new Array(1_001).fill(1) } })).rejects.toThrow('filter cap');
await expect(service.query({ ...query(), filters: { minQuantity: '-1' } })).rejects.toThrow('bounded non-negative');
});
test('runtime-validates a persistence response instead of exposing cross-tenant rows', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
return { ...page, rows: [{}] } as any;
};
await expect(new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 1 })).rejects.toThrow('malformed row');
});
test('allowlists public rows instead of exposing persisted private fields', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
return { ...page, rows: page.rows.map((row: any) => ({ ...row, credential: 'secret', accessToken: 'secret' })) } as any;
};
const result = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 1 });
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect((result.rows[0] as any).credential).toBeUndefined();
expect((result.rows[0] as any).accessToken).toBeUndefined();
expect((result.rows[0] as any).resourceKey).toBeUndefined();
expect((result.rows[0] as any).location).toBeDefined();
});
test('keeps immutable projections and cursor pins idempotent but non-mutable', async () => {
const { repository, manifests } = makeRepository();
const manifest = manifests[0]!;
const malformedCounters = structuredClone(manifest);
malformedCounters.authority.grantRevision = -1;
malformedCounters.resource.authorizationRevision = -1;
await expect(repository.stageProjection(malformedCounters)).rejects.toThrow('projection authority is stale');
await repository.stageProjection(manifest);
const changed = structuredClone(manifest);
changed.fetchedAt = 123;
await expect(repository.stageProjection(changed)).rejects.toThrow('immutable projection snapshot conflict');
await repository.publishProjection('user-a', manifest.resource, manifest.generation);
const first = await new InventoryQueryService({ repository, now: () => 100 }).query(query());
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
const state = await repository.getCursor(first.nextCursor.token);
expect(state).not.toBeNull();
await expect(repository.replaceCursor({ ...state!, pin: { ...state!.pin, expiresAt: state!.pin.expiresAt + 1 } })).rejects.toThrow('cursor pin is immutable');
});
test('rejects page loops, skips, silent truncation, and unknown discriminants', async () => {
for (const mutate of [
(page: any) => ({ ...page, nextOffset: 999 }),
(page: any) => ({ ...page, nextOffset: null }),
(page: any) => ({ ...page, status: 'mystery' }),
]) {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => mutate(await original(request));
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query())).rejects.toThrow(/repository.*(continuation|page|invalid)/);
}
});
test('rejects forged page pins and rows against authoritative selected snapshots', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
const resources = page.resources.map((snapshot: any, index: number) => index === 0 ? { ...snapshot, generation: 'foreign-generation' } : snapshot);
const rows = page.rows.map((row: any, index: number) => index === 0 ? { ...row, generation: 'foreign-generation' } : row);
return { ...page, resources, rows } as any;
};
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query())).rejects.toThrow('authoritative snapshot');
});
test('bounds raw explicit resource selection before traversing it and preserves explicit empty selection', async () => {
const { repository } = makeRepository();
let traversed = false;
const oversized = new Array(1_001);
Object.defineProperty(oversized, 0, { enumerable: true, get() { traversed = true; return resource('user-a', 42); } });
await expect(new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), selectedResources: oversized as any })).rejects.toThrow('selected resource cap');
expect(traversed).toBe(false);
const empty = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), selectedResources: [] });
expect(empty.status).toBe('authorization_required');
});
test('does not serve a ready generation after its authority revision changes', async () => {
const { repository } = makeRepository();
const current = (await repository.authorizedResources('user-a'))[0]!;
repository.seedResource({ ...current, resource: { ...current.resource, authorizationRevision: 2 }, grantRevision: 2 });
const result = await new InventoryQueryService({ repository, now: () => 100 }).query(query());
expect(result.status).toBe('stale');
});
test('requires exact T08 generation/reference/authority pins and rejects non-member rows', () => {
const assets = fixture.filter((asset) => asset.characterId === 42);
const selected = resource('user-a', 42);
const stale = resolvedLocation(assets[0]!);
(stale as any).typeId = 101;
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets, types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [resolvedLocation(assets[0]!)], locationInputPin: locationPin('user-a', 42, 'wrong') })).toThrow('generation does not match');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [stale], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
const staleReference = resolvedLocation(assets[0]!);
(staleReference.location.system as any).referenceVersion = 'ref-v2';
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [staleReference], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [resolvedLocation(fixture.find((asset) => asset.characterId === 43)!)], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
const unresolved = buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: null, assets: [assets[0]!], types, locations: [] , locationInputPin: locationPin('user-a', 42, 'assets-42') });
expect(unresolved.rows[0]!.location).toMatchObject({ kind: 'unknown', name: null, systemId: null, regionId: null, evidence: 'unknown' });
});
test('deep-validates stored arithmetic, BPC pricing, immutable price provenance, and money bounds', () => {
const { manifests } = makeRepository();
const bad = structuredClone(manifests[0]!);
bad.rows[0]!.estimatedValue = '999';
expect(() => validateInventoryProjection(bad)).toThrow('priced arithmetic');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'no-price-version', referenceVersion: 'ref-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }] })).toThrow('price version');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'huge-price', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '1'.repeat(129) }] })).toThrow('money text bounds');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'huge-type-name', referenceVersion: 'ref-v1', assets: [fixture[0]!], types: [{ id: 100, name: 'x'.repeat(513), categoryId: 4 }], prices: [] })).toThrow('type response is malformed');
const forgedStructure = structuredClone(manifests[0]!);
forgedStructure.rows[0]!.location = { ...forgedStructure.rows[0]!.location, kind: 'structure', evidence: 'private_structure_access' };
expect(() => validateInventoryProjection(forgedStructure)).toThrow('structure scope');
const builtStructure = resolvedLocation(fixture[0]!);
(builtStructure.location as any).kind = 'structure';
(builtStructure.location as any).evidence = 'private_structure_access';
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'structure-build', referenceVersion: 'ref-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [builtStructure], locationInputPin: locationPin('user-a', 42, 'structure-build') })).toThrow('private structure evidence requires pinned structure scope');
});
test('rejects oversized cursor tokens before cursor storage access', async () => {
const { repository } = makeRepository();
let cursorReads = 0;
const original = repository.getCursor.bind(repository);
repository.getCursor = async (token: string) => { cursorReads += 1; return original(token); };
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query({ token: 'x'.repeat(257) }))).rejects.toThrow('oversized');
expect(cursorReads).toBe(0);
});
test('canonical resource ordering keeps equivalent cursor continuations valid and malformed cursor envelopes fail closed', async () => {
const { repository, resources } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const first = await service.query({ ...query(), selectedResources: [resources[1]!, resources[0]!] });
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
const continued = await service.query({ ...query(first.nextCursor), selectedResources: [resources[0]!, resources[1]!] });
expect(continued.status).toBe('ready');
const original = repository.getCursor.bind(repository);
repository.getCursor = async (token: string) => ({ ...(await original(token))!, pin: undefined } as any);
expect((await service.query(query(first.nextCursor))).status).toBe('unavailable');
});
});