Files
eden/src/lib/server/inventory/query-service.ts
T

619 lines
38 KiB
TypeScript

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 };