From 84f4272709dcd9326214794a1b8f73aed3764e36 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 20 Sep 2026 12:26:49 -0400 Subject: [PATCH] wip: checkpoint reviewed T09 inventory query core --- src/lib/server/inventory/projection.ts | 489 +++++++++++++++ src/lib/server/inventory/query-repository.ts | 377 +++++++++++ src/lib/server/inventory/query-service.ts | 618 +++++++++++++++++++ tests/fixtures/inventory/query-assets.json | 7 + tests/inventory-query.test.ts | 324 ++++++++++ 5 files changed, 1815 insertions(+) create mode 100644 src/lib/server/inventory/projection.ts create mode 100644 src/lib/server/inventory/query-repository.ts create mode 100644 src/lib/server/inventory/query-service.ts create mode 100644 tests/fixtures/inventory/query-assets.json create mode 100644 tests/inventory-query.test.ts diff --git a/src/lib/server/inventory/projection.ts b/src/lib/server/inventory/projection.ts new file mode 100644 index 0000000..36198c5 --- /dev/null +++ b/src/lib/server/inventory/projection.ts @@ -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): ProjectionLocation { + const enriched = byItem.get(asset.itemId); + if (!enriched) { + return { + itemId: asset.itemId, + locationId: asset.locationId, + sourceLocationId: asset.locationId, + resolution: 'unresolved', + kind: 'unknown', + name: null, + systemId: null, + regionId: null, + evidence: 'unknown', + unresolvedReasons: ['location_unavailable'], + }; + } + + const reference = enriched.location; + const system = reference.system; + const region = reference.region; + const unresolvedReasons = [...new Set(enriched.unresolved)].sort(); + // T08's locationStatus describes optional per-item position helper data; + // `not_requested` does not make a known station/system unresolved. Explicit + // expired/forbidden/unavailable helper coverage remains unresolved. + const unresolved = reference.kind === 'unknown' || (reference.kind === 'structure' && reference.evidence === 'unknown') || (enriched.locationStatus !== 'resolved' && enriched.locationStatus !== 'not_requested') || unresolvedReasons.length > 0; + if (unresolved) { + const reasons = unresolvedReasons.length > 0 ? unresolvedReasons : (reference.kind === 'unknown' || reference.evidence === 'unknown') ? ['reference_unknown'] : [`location_${enriched.locationStatus}`]; + return { + itemId: asset.itemId, + locationId: asset.locationId, + sourceLocationId: asset.locationId, + resolution: 'unresolved', + kind: 'unknown', + name: null, + systemId: null, + regionId: null, + evidence: 'unknown', + unresolvedReasons: reasons, + }; + } + return { + itemId: asset.itemId, + locationId: reference.id, + sourceLocationId: asset.locationId, + resolution: 'resolved', + kind: reference.kind, + name: reference.name, + systemId: system && isDecimalId(system.id) && Number.isSafeInteger(Number(system.id)) ? Number(system.id) : null, + regionId: region && isDecimalId(region.id) && Number.isSafeInteger(Number(region.id)) ? Number(region.id) : null, + evidence: reference.evidence, + unresolvedReasons, + }; +} + +function validatedPrices(value: PriceSnapshot | readonly PriceEstimate[] | undefined): Map { + if (value !== undefined && !Array.isArray(value) && !('prices' in value)) throw new TypeError('projection price response is malformed'); + if (value !== undefined && !Array.isArray(value) && 'prices' in value) { + const snapshot = value as PriceSnapshot; + if (typeof snapshot.source !== 'string' || !snapshot.source || !Number.isFinite(snapshot.asOf) || !Number.isFinite(snapshot.expiresAt) || snapshot.expiresAt < snapshot.asOf) throw new TypeError('projection price snapshot metadata is malformed'); + } + const entries: readonly PriceEstimate[] = value === undefined ? [] : Array.isArray(value) ? value : 'prices' in value ? value.prices : []; + if (!Array.isArray(entries) || entries.length > INVENTORY_PROJECTION_MAX_PRICES) throw new RangeError('projection price cap exceeded'); + const result = new Map(); + for (const entry of entries) { + if (!entry || !validTypeId(entry.typeId) || result.has(entry.typeId)) throw new TypeError('projection price response is malformed'); + let price: string; + try { + price = canonicalMoney(entry.unitPrice, 'projection price'); + if (parseIsk(price) < 0n) throw new RangeError('negative price'); + } catch (error) { + if (error instanceof RangeError && error.message.includes('bounds')) throw error; + throw new TypeError('projection price response is malformed'); + } + result.set(entry.typeId, price); + } + return result; +} + +function validatedTypes(types: readonly InventoryType[]): Map { + if (!Array.isArray(types) || types.length > INVENTORY_PROJECTION_MAX_TYPES) throw new RangeError('projection type cap exceeded'); + const result = new Map(); + for (const type of types) { + if (!type || !validTypeId(type.id) || result.has(type.id) || typeof type.name !== 'string' || !type.name.trim() || type.name.length > 512 + || !validTypeId(type.categoryId) || (type.groupId !== undefined && !validTypeId(type.groupId))) { + throw new TypeError('projection type response is malformed'); + } + result.set(type.id, { ...type }); + } + return result; +} + +function validatedAsset(asset: unknown, characterId: number): asset is Asset { + if (!asset || typeof asset !== 'object') return false; + const candidate = asset as Partial; + return isDecimalId(candidate.itemId) && isDecimalId(candidate.locationId) && candidate.characterId === characterId + && validTypeId(candidate.typeId) && typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0 + && typeof candidate.locationType === 'string' && candidate.locationType.length > 0 && candidate.locationType.length <= 32 && typeof candidate.locationFlag === 'string' && candidate.locationFlag.length > 0 && candidate.locationFlag.length <= 128 + && (candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean') + && (candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean'); +} + +function locationReferenceMatchesVersion(location: EnrichedLocationNode['location'], referenceVersion: string): boolean { + return location.referenceVersion === referenceVersion + && (location.system === null || locationReferenceMatchesVersion(location.system, referenceVersion)) + && (location.region === null || locationReferenceMatchesVersion(location.region, referenceVersion)); +} + +function validateT08Locations(input: InventoryProjectionInput): void { + if (input.locations === undefined) { + if (input.locationInputPin !== undefined) throw new TypeError('location input pin requires location rows'); + return; + } + if (!input.locationInputPin) throw new TypeError('location rows require an enrichment input pin'); + const authority = authorityContext(input); + validateReferenceEnrichmentPin(input.locationInputPin, { + assetGeneration: input.generation, + referenceVersion: input.referenceVersion, + authority, + }); + const assets = new Map>(); + for (const asset of input.assets) { + if (asset && typeof asset === 'object' && typeof (asset as Partial).itemId === 'string') assets.set((asset as Partial).itemId!, asset as Partial); + } + const seen = new Set(); + for (let offset = 0; offset < input.locations.length; offset += INVENTORY_LOCATION_ROW_CHUNK) { + const chunk = input.locations.slice(offset, offset + INVENTORY_LOCATION_ROW_CHUNK); + validatePersistedLocationRows(chunk, { authority }); + for (const row of chunk) { + const asset = assets.get(row.itemId); + if (!asset || row.characterId !== input.authority.characterId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || row.isSingleton !== asset.isSingleton || row.isBlueprintCopy !== asset.isBlueprintCopy || !locationReferenceMatchesVersion(row.location, input.referenceVersion) || seen.has(row.itemId)) throw new TypeError('location row does not match the asset generation'); + seen.add(row.itemId); + } + } +} + +/** Build one immutable, generation-bound projection. No database or network calls occur here. */ +export function buildInventoryProjection(input: InventoryProjectionInput): InventoryProjectionManifest { + const key = validateResource(input); + if (!Array.isArray(input.assets) || input.assets.length > INVENTORY_PROJECTION_MAX_ROWS) throw new RangeError('projection row cap exceeded'); + if (input.locations !== undefined && (!Array.isArray(input.locations) || input.locations.length > INVENTORY_PROJECTION_MAX_ROWS)) throw new RangeError('projection location row cap exceeded'); + validateT08Locations(input); + const types = validatedTypes(input.types); + const prices = validatedPrices(input.prices); + const locationRows = new Map(); + for (const row of input.locations ?? []) { + if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || row.characterId !== input.authority.characterId || locationRows.has(row.itemId)) throw new TypeError('projection location rows are malformed'); + locationRows.set(row.itemId, row); + } + + const seen = new Set(); + const rows: InventoryProjectionRow[] = []; + for (const source of input.assets) { + if (!validatedAsset(source, input.authority.characterId)) throw new TypeError('projection asset is malformed or belongs to another character'); + if (seen.has(source.itemId)) throw new TypeError('projection contains duplicate item ID'); + seen.add(source.itemId); + const type = types.get(source.typeId); + const location = locationFor(source, locationRows); + let unitPrice: Isk | null = null; + let estimatedValue: Isk | null = null; + let pricing: 'priced' | 'unpriced' = 'unpriced'; + if (source.isBlueprintCopy !== true && type) { + unitPrice = prices.get(source.typeId) ?? null; + if (unitPrice !== null) { + if (input.priceVersion === undefined || input.priceVersion === null) throw new TypeError('priced projection rows require a price version'); + // Keep the shared money/quantity functions at the arithmetic boundary; + // this is intentionally not a second valuation implementation. + validateQuantity(source.quantity); + estimatedValue = multiplyIsk(unitPrice, source.quantity); + if (estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError('projection estimated value exceeds money text bounds'); + pricing = 'priced'; + } + } + const row: InventoryProjectionRow = { + ...source, + name: type?.name ?? 'Unknown type', + typeName: type?.name ?? 'Unknown type', + categoryId: type?.categoryId ?? null, + groupId: type?.groupId ?? null, + customName: locationRows.get(source.itemId)?.customName ?? null, + systemId: location.systemId, + regionId: location.regionId, + isSingleton: source.isSingleton ?? null, + estimatedValue, + resourceKey: key, + itemKey: `${input.authority.characterId}:${source.itemId}`, + generation: input.generation, + referenceVersion: input.referenceVersion, + priceVersion: input.priceVersion ?? null, + enrichmentVersion: input.enrichmentVersion ?? null, + unitPrice, + pricing, + location, + unresolvedLocation: location.resolution === 'unresolved', + unresolvedReasons: location.unresolvedReasons, + }; + rows.push(row); + } + + const coverage = input.coverage ?? { complete: true }; + if (typeof coverage.complete !== 'boolean' || (coverage.reason !== undefined && (typeof coverage.reason !== 'string' || coverage.reason.length > 512)) || (!coverage.complete && (!coverage.reason || coverage.reason.length > 512))) throw new TypeError('projection coverage is malformed'); + if ((input.fetchedAt !== undefined && input.fetchedAt !== null && !Number.isFinite(input.fetchedAt)) || (input.upstreamEffectiveAt !== undefined && input.upstreamEffectiveAt !== null && !Number.isFinite(input.upstreamEffectiveAt))) throw new TypeError('projection timestamps are invalid'); + return { + version: 1, + userId: input.userId, + resource: { ...input.resource, requiredScopes: [...input.resource.requiredScopes] }, + authority: { ...input.authority }, + generation: input.generation, + status: coverage.complete ? 'complete' : 'partial', + coverage: { ...coverage }, + referenceVersion: input.referenceVersion, + priceVersion: input.priceVersion ?? null, + enrichmentVersion: input.enrichmentVersion ?? null, + locationInputPin: input.locationInputPin ?? null, + rows, + fetchedAt: input.fetchedAt ?? null, + upstreamEffectiveAt: input.upstreamEffectiveAt ?? null, + }; +} + +function validateProjectionAuthority(userId: string, resource: SelectedResource, authority: ProjectionAuthority): void { + if (!resource || resource.principal.kind !== 'character' || resource.principal.ownerUserId !== userId || !isDecimalId(resource.principal.id)) throw new TypeError('inventory projection resource is invalid'); + if (!Array.isArray(resource.requiredScopes) || resource.requiredScopes.length > 64 || resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(resource.requiredScopes) || new Set(resource.requiredScopes).size !== resource.requiredScopes.length || !resource.requiredScopes.includes(ESI_ASSETS_SCOPE)) throw new TypeError('inventory projection resource scopes are invalid'); + if (!authority || !Number.isSafeInteger(Number(resource.principal.id)) || String(Number(resource.principal.id)) !== resource.principal.id || authority.userId !== userId || authority.characterId !== Number(resource.principal.id) || !Number.isSafeInteger(authority.characterId) || authority.characterId <= 0 || !validCounter(authority.grantRevision) || !validCounter(authority.authorizationEpoch) || !validCounter(authority.credentialVersion) || authority.grantRevision !== resource.authorizationRevision || authority.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('inventory projection authority is stale'); + const scopes = authority.scopes ?? resource.requiredScopes; + if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(scopes) || new Set(scopes).size !== scopes.length || !scopes.includes(ESI_ASSETS_SCOPE) || resource.requiredScopes.some((scope) => !scopes.includes(scope))) throw new TypeError('inventory projection authority scopes are invalid'); +} + +function validateProjectionLocation(value: unknown, asset: { itemId: string; locationId: string }, scopes: readonly string[]): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection location is invalid'); + const location = value as Partial; + const allowed = ['itemId', 'locationId', 'sourceLocationId', 'resolution', 'kind', 'name', 'systemId', 'regionId', 'evidence', 'unresolvedReasons']; + if (Object.keys(location).some((key) => !allowed.includes(key)) || location.itemId !== asset.itemId || !isDecimalId(location.locationId) || location.sourceLocationId !== asset.locationId || !isDecimalId(location.sourceLocationId)) throw new TypeError('inventory projection location identity is invalid'); + if (location.resolution !== 'resolved' && location.resolution !== 'unresolved') throw new TypeError('inventory projection location resolution is invalid'); + if (!Array.isArray(location.unresolvedReasons) || location.unresolvedReasons.length === 0 && location.resolution === 'unresolved' || location.unresolvedReasons.length > 24 || location.unresolvedReasons.some((reason) => typeof reason !== 'string' || reason.length > 128) || stableReasonOrder(location.unresolvedReasons) === false) throw new TypeError('inventory projection location reasons are invalid'); + if (location.resolution === 'unresolved') { + if (location.locationId !== asset.locationId || location.kind !== 'unknown' || location.name !== null || location.systemId !== null || location.regionId !== null || location.evidence !== 'unknown') throw new TypeError('unresolved location contains inferred facts'); + } else { + if (!['station', 'system', 'region', 'structure'].includes(location.kind ?? '') || (location.name !== null && (typeof location.name !== 'string' || location.name.length > 512)) || (location.systemId === undefined || location.systemId !== null && (!Number.isSafeInteger(location.systemId) || location.systemId <= 0)) || (location.regionId === undefined || location.regionId !== null && (!Number.isSafeInteger(location.regionId) || location.regionId <= 0)) || !['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(location.evidence ?? '')) throw new TypeError('resolved location metadata is invalid'); + if (location.kind === 'structure' && location.evidence !== 'private_structure_access') throw new TypeError('structure location evidence is invalid'); + if (location.kind === 'structure' && location.evidence === 'private_structure_access' && !scopes.includes(ESI_STRUCTURES_SCOPE)) throw new TypeError('private structure location requires pinned structure scope'); + if ((location.kind === 'system' || location.kind === 'region') && location.evidence !== 'verified') throw new TypeError('public location evidence is invalid'); + if (location.kind === 'station' && location.evidence !== 'verified' && location.evidence !== 'system_listed') throw new TypeError('station location evidence is invalid'); + } +} + +export function validateInventoryProjectionRow(value: unknown, context: { + userId: string; + resource: SelectedResource; + authority: ProjectionAuthority; + generation: string; + referenceVersion: string; + priceVersion: string | null; + enrichmentVersion: string | null; +}): asserts value is InventoryProjectionRow { + validateProjectionAuthority(context.userId, context.resource, context.authority); + const source = value && typeof value === 'object' ? { ...(value as Record), isSingleton: (value as Partial).isSingleton === null ? undefined : (value as Partial).isSingleton } : value; + if (!validatedAsset(source, context.authority.characterId)) throw new TypeError('inventory projection row source facts are invalid'); + const row = value as InventoryProjectionRow; + if (row.resourceKey !== resourceKey(context.resource) || row.itemKey !== `${context.authority.characterId}:${row.itemId}` || row.generation !== context.generation || row.referenceVersion !== context.referenceVersion || row.priceVersion !== context.priceVersion || row.enrichmentVersion !== context.enrichmentVersion) throw new TypeError('inventory projection row pin is invalid'); + if (typeof row.name !== 'string' || !row.name || row.name.length > 512 || typeof row.typeName !== 'string' || !row.typeName || row.typeName.length > 512 || (row.categoryId !== null && !validTypeId(row.categoryId)) || (row.groupId !== null && !validTypeId(row.groupId)) || (row.customName !== null && (typeof row.customName !== 'string' || row.customName.length > 512))) throw new TypeError('inventory projection row reference facts are invalid'); + if (row.isSingleton !== null && typeof row.isSingleton !== 'boolean') throw new TypeError('inventory projection singleton fact is invalid'); + if (row.pricing !== 'priced' && row.pricing !== 'unpriced') throw new TypeError('inventory projection pricing state is invalid'); + const unitPrice = row.unitPrice === null ? null : canonicalMoney(row.unitPrice, 'inventory projection unit price'); + const estimatedValue = row.estimatedValue === null ? null : canonicalMoney(row.estimatedValue, 'inventory projection estimated value'); + if (row.pricing === 'priced') { + if (context.priceVersion === null || unitPrice === null || estimatedValue === null || parseIsk(unitPrice) < 0n || parseIsk(estimatedValue) < 0n || multiplyIsk(unitPrice, row.quantity) !== estimatedValue) throw new TypeError('inventory projection priced arithmetic is invalid'); + } else if (unitPrice !== null || estimatedValue !== null) throw new TypeError('inventory projection unpriced arithmetic is invalid'); + if (row.isBlueprintCopy === true && row.pricing !== 'unpriced') throw new TypeError('blueprint copy cannot be priced'); + if (row.unresolvedLocation !== (row.location.resolution === 'unresolved')) throw new TypeError('inventory projection unresolved flag is invalid'); + if (row.unresolvedReasons.join('\u0000') !== row.location.unresolvedReasons.join('\u0000')) throw new TypeError('inventory projection unresolved reasons do not match location'); + validateProjectionLocation(row.location, row, context.authority.scopes ?? context.resource.requiredScopes); + if (row.systemId !== row.location.systemId || row.regionId !== row.location.regionId) throw new TypeError('inventory projection location index does not match evidence'); +} + +export function validateInventoryProjection(value: unknown): asserts value is InventoryProjectionManifest { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection is invalid'); + const candidate = value as Partial; + if (candidate.version !== 1 || (candidate.status !== 'complete' && candidate.status !== 'partial') || !Array.isArray(candidate.rows) || candidate.rows.length > INVENTORY_PROJECTION_MAX_ROWS) throw new TypeError('inventory projection manifest is invalid'); + if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.generation !== 'string' || !candidate.generation || candidate.generation.length > 256 || typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion || candidate.referenceVersion.length > 256) throw new TypeError('inventory projection pin is invalid'); + if (candidate.priceVersion !== null && (typeof candidate.priceVersion !== 'string' || !candidate.priceVersion || candidate.priceVersion.length > 256)) throw new TypeError('inventory projection price pin is invalid'); + if (candidate.enrichmentVersion !== null && (typeof candidate.enrichmentVersion !== 'string' || !candidate.enrichmentVersion || candidate.enrichmentVersion.length > 256)) throw new TypeError('inventory projection enrichment pin is invalid'); + if (typeof candidate.coverage !== 'object' || candidate.coverage === null || typeof candidate.coverage.complete !== 'boolean' || (candidate.status === 'complete') !== candidate.coverage.complete || (candidate.coverage.reason !== undefined && (typeof candidate.coverage.reason !== 'string' || candidate.coverage.reason.length > 512)) || (candidate.status === 'partial' && (!candidate.coverage.reason || candidate.coverage.reason.length > 512))) throw new TypeError('inventory projection coverage is invalid'); + if (candidate.fetchedAt !== null && (typeof candidate.fetchedAt !== 'number' || !Number.isFinite(candidate.fetchedAt)) || candidate.upstreamEffectiveAt !== null && (typeof candidate.upstreamEffectiveAt !== 'number' || !Number.isFinite(candidate.upstreamEffectiveAt))) throw new TypeError('inventory projection timestamps are invalid'); + if (!candidate.resource || !candidate.authority) throw new TypeError('inventory projection authority is invalid'); + validateProjectionAuthority(candidate.userId, candidate.resource, candidate.authority); + const hasResolvedLocation = candidate.rows.some((row) => !!row && typeof row === 'object' && (row as InventoryProjectionRow).location?.resolution === 'resolved'); + if (candidate.locationInputPin === null) { + if (hasResolvedLocation) throw new TypeError('resolved projection rows require a location input pin'); + } else { + validateReferenceEnrichmentPin(candidate.locationInputPin, { assetGeneration: candidate.generation, referenceVersion: candidate.referenceVersion, authority: authorityContext({ userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, assets: [], types: [] }) }); + } + const seen = new Set(); + for (const row of candidate.rows) { + validateInventoryProjectionRow(row, { userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, priceVersion: candidate.priceVersion, enrichmentVersion: candidate.enrichmentVersion }); + if (seen.has(row.itemKey)) throw new TypeError('inventory projection contains duplicate item identity'); + seen.add(row.itemKey); + } +} + +export function projectionResourceKey(resource: SelectedResource): string { + return resourceKey(resource); +} + +export function compareProjectionRows(left: InventoryProjectionRow, right: InventoryProjectionRow): number { + return left.characterId - right.characterId || compareDecimalIds(left.itemId, right.itemId); +} + +/** Stable aliases used by the later Convex staging adapter. */ +export const projectInventoryGeneration = buildInventoryProjection; +export const validateProjectedInventory = validateInventoryProjection; diff --git a/src/lib/server/inventory/query-repository.ts b/src/lib/server/inventory/query-repository.ts new file mode 100644 index 0000000..ffb36d0 --- /dev/null +++ b/src/lib/server/inventory/query-repository.ts @@ -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; + 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; + readPage(request: InventoryRepositoryPageRequest): Promise; + getCursor(token: string): Promise; + putCursor(state: InventoryCursorState): Promise; + replaceCursor(state: InventoryCursorState): Promise; +} + +export type InventoryProjectionWriter = { + stageProjection(manifest: InventoryProjectionManifest): Promise; + publishProjection(userId: string, resource: SelectedResource, generation: string): Promise; +}; + +function plainRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys(value: Record, 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(); + 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(); + private readonly staged = new Map(); + private readonly resources = new Map(); + private readonly cursors = new Map(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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 }; diff --git a/src/lib/server/inventory/query-service.ts b/src/lib/server/inventory/query-service.ts new file mode 100644 index 0000000..0a9134a --- /dev/null +++ b/src/lib/server/inventory/query-service.ts @@ -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; + 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; + 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 = {}; + const authorizationEpochs: Record = {}; + const snapshotGenerations: Record = {}; + const credentialVersions: Record = {}; + const enrichmentVersions: Record = {}; + 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; + +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(); + 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(); + 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); + constructor(optionsOrRepository: InventoryQueryServiceOptions | InventoryQueryRepository, extraOptions: Omit = {}) { + 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 { + 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 { + return this.query(input); + } + + private async normalize(input: InventoryQueryRequest): Promise { + 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(); + 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(); + 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 { + return new InventoryQueryService(options).query(request); +} + +export { normalizeFilters, normalizeSort }; diff --git a/tests/fixtures/inventory/query-assets.json b/tests/fixtures/inventory/query-assets.json new file mode 100644 index 0000000..aa1256a --- /dev/null +++ b/tests/fixtures/inventory/query-assets.json @@ -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"} +] diff --git a/tests/inventory-query.test.ts b/tests/inventory-query.test.ts new file mode 100644 index 0000000..680693d --- /dev/null +++ b/tests/inventory-query.test.ts @@ -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 = {}) => ({ + 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'); + }); +});