619 lines
38 KiB
TypeScript
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 };
|