diff --git a/docs/design-records/t02-contracts.md b/docs/design-records/t02-contracts.md index bd13036..91a7872 100644 --- a/docs/design-records/t02-contracts.md +++ b/docs/design-records/t02-contracts.md @@ -25,7 +25,7 @@ `InventoryQuery` is the first query surface: resolved resource selection, validated filters (character/type/name/category/group/quantity/location/system/region/blueprint state), stable sort, bounded page size and a pinned cursor. `InventoryQueryResult` has explicit ready, partial, stale, authorization-required and unavailable states. -`ReferenceReaderPort` mirrors T03’s indexed `ReferenceReader`: type search, category/group, region/constellation/system and station methods use its bounded selection/record shapes. `pin()` returns a `ReferenceReaderHandle` with immutable `pinnedVersion` and explicit close ownership, so multiple calls share one artifact. `LocationReader` is only a narrow adapter over that pinned reader and synthesizes explicit `kind: 'unknown'` facts for empty ID lookups; it is not a competing reference implementation. Private structure names/evidence are not public reference facts. +`src/lib/domain/reference.ts` is the canonical pure reference-reader contract shared with T03. It preserves narrow `ReferenceRepository.types(ids)/version()`, adds T03-compatible bounded offset search and list selections, and exposes independently closable `pin()`/`snapshot()` handles with immutable `pinnedVersion`. Inventory pagination remains a service-level opaque cursor, not a reference-reader adapter. `ReferenceStationRecord.evidence` (`verified` or `system_listed`) is authoritative, with `sourceVerified` retained as its compatibility projection and nullable names preserved. `LocationReader` returns immutable-versioned facts; verified/system-listed station presence uses `sourceVerified: true/false`, while absent IDs are explicit `kind: 'unknown'` with `sourceVerified: null`, `name: null`, and no guessed parent. Private structure names/evidence are not public reference facts. `WalletBalanceResult` is opt-in and resource-scoped. Each component carries exact `Isk`, local fetch time, nullable upstream effective time, coverage and authorization revision. Partial character coverage is visible. diff --git a/fixtures/contracts/t02.ts b/fixtures/contracts/t02.ts index 39cc481..7ae7631 100644 --- a/fixtures/contracts/t02.ts +++ b/fixtures/contracts/t02.ts @@ -5,9 +5,12 @@ import type { EntityResolution, InventoryCursor, InventoryCursorState, + InventoryCursorLookup, InventoryQuery, InventoryRow, InventoryQueryResult, + LocationFact, + ReferenceStationRecord, ResourceAuthorization, SignedMoneyNormalizationPolicy, WalletBalanceResult, @@ -62,6 +65,17 @@ export const singletonRows: readonly Pick[] = [ { isSingleton: null }, ]; +export const verifiedNamelessStation: ReferenceStationRecord = { + id: 60000001, systemId: 30000142, systemName: 'Jita', regionId: 10000002, + name: null, typeId: 1531, ownerId: 1000125, evidence: 'verified', sourceVerified: true, x: null, y: null, z: null, +}; +export const systemListedStationPlaceholder: ReferenceStationRecord = { + ...verifiedNamelessStation, id: 60000002, evidence: 'system_listed', sourceVerified: false, +}; +export const absentLocation: LocationFact = { + kind: 'unknown', id: '60000003', name: null, parentId: null, sourceVerified: null, referenceVersion: 'reference-g1', +}; + export const ambiguousType: EntityResolution<{ id: number; name: string }> = { status: 'ambiguous', candidates: [{ id: 34, name: 'Tritanium' }, { id: 35, name: 'Pyerite' }], @@ -133,6 +147,26 @@ export const inventoryCursorState: InventoryCursorState = { }, }; +export const inventoryCursorLookup: InventoryCursorLookup = { + token: inventoryCursor.token, + callerUserId: 'fixture-user-a', + queryFingerprint: 'sha256:fixture-query', + selectedResources: [syntheticResource], + authorizationRevisions: { 'character:90000000000000001': 7 }, + authorizationEpochs: { 'character:90000000000000001': 1 }, + snapshotGenerations: { assets: 'assets-g7' }, + referenceVersion: 'reference-g1', + priceVersion: 'prices-g1', + now: 1_700_000_200_000, +}; + +export const borrowedInventoryCursorLookup: InventoryCursorLookup = { ...inventoryCursorLookup, callerUserId: 'fixture-user-b' }; +export const changedQueryInventoryCursorLookup: InventoryCursorLookup = { ...inventoryCursorLookup, queryFingerprint: 'sha256:changed-query' }; +export const changedRevisionInventoryCursorLookup: InventoryCursorLookup = { + ...inventoryCursorLookup, + authorizationRevisions: { 'character:90000000000000001': 8 }, +}; + export const inventoryQuery: InventoryQuery = { callerUserId: 'fixture-user-a', selectedResources: [syntheticResource], diff --git a/src/lib/domain/capabilities.ts b/src/lib/domain/capabilities.ts index 5b6aadb..db63456 100644 --- a/src/lib/domain/capabilities.ts +++ b/src/lib/domain/capabilities.ts @@ -9,6 +9,8 @@ import type { DatasetJob, DatasetKind, DatasetSyncState, + InventoryCursorLookup, + InventoryCursorState, MissingRequirement, Requirement, ResourceAuthorization, @@ -79,6 +81,10 @@ const SYSTEM_JOB_DATASETS: Readonly right.includes(value)); } +/** Validate every server-resolved value before consuming an opaque cursor. */ +export function validateInventoryCursorUse(state: InventoryCursorState, lookup: InventoryCursorLookup): InventoryCursorValidation { + if (state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' }; + if (state.status === 'exhausted') return { valid: false, reason: 'exhausted' }; + if (!Number.isFinite(lookup.now) || !Number.isFinite(state.pin.expiresAt) || lookup.now >= state.pin.expiresAt) return { valid: false, reason: 'expired' }; + if (state.pin.callerUserId !== lookup.callerUserId) return { valid: false, reason: 'caller_mismatch' }; + if (state.pin.queryFingerprint !== lookup.queryFingerprint) return { valid: false, reason: 'query_mismatch' }; + if (!sameSelectedResourceList(state.pin.selectedResources, lookup.selectedResources)) return { valid: false, reason: 'resource_mismatch' }; + if (!sameRecord(state.pin.authorizationRevisions, lookup.authorizationRevisions) + || !sameRecord(state.pin.authorizationEpochs, lookup.authorizationEpochs)) return { valid: false, reason: 'authorization_mismatch' }; + if (!sameRecord(state.pin.snapshotGenerations, lookup.snapshotGenerations) + || state.pin.referenceVersion !== lookup.referenceVersion || state.pin.priceVersion !== lookup.priceVersion) return { valid: false, reason: 'input_version_mismatch' }; + return { valid: true }; +} + +function sameSelectedResourceList(left: readonly SelectedResource[], right: readonly SelectedResource[]): boolean { + return left.length === right.length && left.every((resource) => right.some((candidate) => sameSelectedResource([resource], candidate))); +} + +function sameRecord(left: Readonly>, right: Readonly>): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return leftKeys.length === rightKeys.length && leftKeys.every((key) => right[key] === left[key]); +} + /** Validate the exact selected-resource × required-dataset pin set. */ export function validateCompleteReportPins(input: CompleteReportInputPin): CompleteReportPinValidation { if (input.pinSet !== 'exact_selected_resource_dataset_set') return { valid: false, reason: 'unexpected_pin' }; diff --git a/src/lib/domain/model.ts b/src/lib/domain/model.ts index d1256c8..39173ec 100644 --- a/src/lib/domain/model.ts +++ b/src/lib/domain/model.ts @@ -27,6 +27,27 @@ export type InventoryType = { groupId?: number; volume?: number; }; +export type { + LocationFact, + LocationReader, + ReferenceCategoryRecord, + ReferenceConstellationRecord, + ReferenceGroupRecord, + ReferenceIdSelection, + ReferenceListOptions, + ReferenceReader, + ReferenceReaderHandle, + ReferenceReaderPort, + ReferenceRegionRecord, + ReferenceRepository, + ReferenceSearchOptions, + ReferenceStationEvidence, + ReferenceStationRecord, + ReferenceStationSelection, + ReferenceSystemRecord, + ReferenceSystemSelection, + ReferenceTypeRecord, +} from './reference'; /** Integer cents (0.01 ISK), serialized as decimal text. No floating-point accumulation. */ export type Isk = string; export type ValuationMethod = 'reference_estimate' | 'immediate_liquidation' | 'expected_sell_order' | 'replacement'; @@ -41,10 +62,6 @@ export interface CharacterAssetRepository { characters(userId: UserId): Promise; assets(userId: UserId, characterId: number): Promise; } -export interface ReferenceRepository { - types(ids: number[]): Promise; - version(): Promise; -} export interface MarketEstimates { estimates(typeIds: number[]): Promise; } @@ -401,6 +418,19 @@ export type InventoryCursorPin = { export type InventoryCursorPosition = { offset: number; lastItemId: DecimalId | null }; export type InventoryCursor = { token: string }; export type InventoryCursorState = { token: string; pin: InventoryCursorPin; status: 'active' | 'exhausted'; position: InventoryCursorPosition }; +/** Server-resolved values supplied on every continuation lookup; no caller signature is required. */ +export type InventoryCursorLookup = { + token: string; + callerUserId: UserId; + queryFingerprint: string; + selectedResources: readonly SelectedResource[]; + authorizationRevisions: Readonly>; + authorizationEpochs: Readonly>; + snapshotGenerations: Readonly>; + referenceVersion: ReferenceVersion | null; + priceVersion: PriceVersion | null; + now: number; +}; export type InventoryQuery = { /** Resolved from the verified session; never accepted as caller authority. */ callerUserId: UserId; @@ -436,57 +466,6 @@ export type InventoryQueryResult = | { status: 'ready'; rows: readonly InventoryRow[]; summary: InventorySummary; nextCursor: null; pageState: 'after_end'; provenance: CompleteReportProvenance } | { status: 'partial' | 'stale' | 'authorization_required' | 'unavailable'; rows: readonly InventoryRow[]; summary: InventorySummary | null; reason: string; provenance?: ReportProvenance }; -export type ReferenceCategoryRecord = { id: number; name: string; published?: boolean }; -export type ReferenceGroupRecord = { id: number; categoryId: number; name: string; published?: boolean }; -export type ReferenceTypeRecord = { id: number; name: string; categoryId: number; groupId: number; published?: boolean; volume?: number }; -export type ReferenceRegionRecord = { id: number; name: string | null }; -export type ReferenceConstellationRecord = { id: number; name: string | null; regionId: number | null }; -export type ReferenceSystemRecord = { - id: number; name: string; constellationId: number; regionId: number | null; - x: number; y: number; z: number; securityStatus: number; securityClass: string | null; -}; -export type ReferenceStationRecord = { - id: number; systemId: number; systemName: string | null; regionId: number | null; - name: string | null; typeId: number | null; ownerId: number | null; - x: number | null; y: number | null; z: number | null; -}; -export type ReferenceSearchOptions = { query: string; categoryId?: number; groupId?: number; published?: boolean; limit?: number; offset?: number }; -export type ReferenceListOptions = { ids?: number[]; query?: string; limit?: number; offset?: number }; -export type ReferenceIdSelection = number[] | ReferenceListOptions | undefined; -export type ReferenceGroupSelection = number[] | (ReferenceListOptions & { categoryId?: number }) | undefined; -export type ReferenceSystemSelection = number[] | (ReferenceListOptions & { constellationId?: number; regionId?: number }) | undefined; -export type ReferenceStationSelection = number[] | (ReferenceListOptions & { systemId?: number }) | undefined; - -/** Structural mirror of T03's indexed reader; records stay immutable/version-pinned. */ -export interface ReferenceReader extends ReferenceRepository { - searchTypes(options: ReferenceSearchOptions): Promise; - searchTypesByName(query: string, options?: Omit): Promise; - categories(selection?: ReferenceIdSelection): Promise; - groups(selection?: ReferenceGroupSelection): Promise; - regions(selection?: ReferenceIdSelection): Promise; - constellations(selection?: ReferenceIdSelection): Promise; - systems(selection?: ReferenceSystemSelection): Promise; - stations(selection?: ReferenceStationSelection): Promise; -} -export interface ReferenceReaderHandle extends ReferenceReader { - readonly pinnedVersion: ReferenceVersion; - close(): void; -} -/** Main reader owns a manifest-pinned handle; callers use one handle across calls. */ -export interface ReferenceReaderPort extends ReferenceReader { - pin(): ReferenceReaderHandle; -} -export type LocationFact = { - kind: 'region' | 'system' | 'station' | 'unknown'; - id: DecimalId; - name: string | null; - parentId: DecimalId | null; - referenceVersion: ReferenceVersion; -}; -/** Narrow adapter over a pinned T03 reader; unknown IDs remain explicit. */ -export interface LocationReader { - location(id: DecimalId): Promise; -} export type WalletBalanceComponent = { component: 'wallet_balance'; diff --git a/src/lib/domain/reference.ts b/src/lib/domain/reference.ts new file mode 100644 index 0000000..69af037 --- /dev/null +++ b/src/lib/domain/reference.ts @@ -0,0 +1,74 @@ +import type { DecimalId, InventoryType, ReferenceVersion } from './model'; + +/** Narrow compatibility boundary retained for existing asset consumers. */ +export interface ReferenceRepository { + types(ids: number[]): Promise; + version(): Promise; +} + +export type ReferenceCategoryRecord = { id: number; name: string; published?: boolean }; +export type ReferenceGroupRecord = { id: number; categoryId: number; name: string; published?: boolean }; +export type ReferenceTypeRecord = { id: number; name: string; categoryId: number; groupId: number; published?: boolean; volume?: number }; +export type ReferenceRegionRecord = { id: number; name: string | null }; +export type ReferenceConstellationRecord = { id: number; name: string | null; regionId: number | null }; +export type ReferenceSystemRecord = { + id: number; name: string; constellationId: number; regionId: number | null; + x: number; y: number; z: number; securityStatus: number; securityClass: string | null; +}; + +export type ReferenceStationEvidence = 'verified' | 'system_listed'; +export type ReferenceStationRecord = { + id: number; systemId: number; systemName: string | null; regionId: number | null; + name: string | null; typeId: number | null; ownerId: number | null; + /** Evidence distinguishes authoritative station rows from system-list-only IDs. */ + evidence: ReferenceStationEvidence; + /** Compatibility projection; true exactly when evidence is `verified`. */ + sourceVerified: boolean; + x: number | null; y: number | null; z: number | null; +}; + +/** T03-compatible bounded offset search; inventory cursors remain service-level. */ +export type ReferenceSearchOptions = { query: string; categoryId?: number; groupId?: number; published?: boolean; limit?: number; offset?: number }; +export type ReferenceListOptions = { ids?: number[]; query?: string; limit?: number; offset?: number }; +export type ReferenceIdSelection = number[] | ReferenceListOptions | undefined; +export type ReferenceGroupSelection = number[] | (ReferenceListOptions & { categoryId?: number }) | undefined; +export type ReferenceSystemSelection = number[] | (ReferenceListOptions & { constellationId?: number; regionId?: number }) | undefined; +export type ReferenceStationSelection = number[] | (ReferenceListOptions & { systemId?: number }) | undefined; + +/** Structural mirror of T03's indexed reader; records stay immutable/version-pinned. */ +export interface ReferenceReader extends ReferenceRepository { + searchTypes(options: ReferenceSearchOptions): Promise; + searchTypesByName(query: string, options?: Omit): Promise; + categories(selection?: ReferenceIdSelection): Promise; + groups(selection?: ReferenceGroupSelection): Promise; + regions(selection?: ReferenceIdSelection): Promise; + constellations(selection?: ReferenceIdSelection): Promise; + systems(selection?: ReferenceSystemSelection): Promise; + stations(selection?: ReferenceStationSelection): Promise; +} + +export interface ReferenceReaderHandle extends ReferenceReader { + readonly pinnedVersion: ReferenceVersion; + close(): void; +} + +/** Main reader owns a manifest-pinned handle; callers use one handle across calls. */ +export interface ReferenceReaderPort extends ReferenceReader { + pin(): ReferenceReaderHandle; + snapshot(): ReferenceReaderHandle; +} + +export type LocationFact = { + kind: 'region' | 'system' | 'station' | 'unknown'; + id: DecimalId; + name: string | null; + parentId: DecimalId | null; + /** Null means the ID is absent; otherwise it preserves station source evidence. */ + sourceVerified: boolean | null; + referenceVersion: ReferenceVersion; +}; + +/** Narrow service adapter; unknown IDs are explicit and names are never synthesized. */ +export interface LocationReader { + location(id: DecimalId): Promise; +} diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index f8eb7b6..9956e66 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -10,8 +10,9 @@ import { validateConsentCallback, validateDatasetJobFence, validateCompleteReportPins, + validateInventoryCursorUse, } from '../src/lib/domain/capabilities'; -import { afterEndInventoryCursorState, afterEndInventoryResult, ambiguousType, appendDatasetState, authorization, consent, datasetState, inventoryCursor, inventoryCursorState, inventoryQuery, inventoryResult, approvedMoneyPolicy, singletonRows, syntheticResource, systemJob, walletResult } from '../fixtures/contracts/t02'; +import { absentLocation, afterEndInventoryCursorState, afterEndInventoryResult, ambiguousType, appendDatasetState, authorization, borrowedInventoryCursorLookup, changedQueryInventoryCursorLookup, changedRevisionInventoryCursorLookup, consent, datasetState, inventoryCursor, inventoryCursorLookup, inventoryCursorState, inventoryQuery, inventoryResult, approvedMoneyPolicy, singletonRows, systemListedStationPlaceholder, syntheticResource, systemJob, verifiedNamelessStation, walletResult } from '../fixtures/contracts/t02'; describe('T02 typed contract fixtures', () => { test('separates snapshot generations from append checkpoints and unknown upstream time', () => { @@ -48,6 +49,10 @@ describe('T02 typed contract fixtures', () => { expect(inventoryQuery.sort.tieBreaker).toBe('item_id'); expect(inventoryQuery.authorizationRevisions['character:90000000000000001']).toBe(7); expect(inventoryCursorState.pin.callerUserId).toBe('fixture-user-a'); + expect(validateInventoryCursorUse(inventoryCursorState, inventoryCursorLookup)).toEqual({ valid: true }); + expect(validateInventoryCursorUse(inventoryCursorState, borrowedInventoryCursorLookup)).toEqual({ valid: false, reason: 'caller_mismatch' }); + expect(validateInventoryCursorUse(inventoryCursorState, changedQueryInventoryCursorLookup)).toEqual({ valid: false, reason: 'query_mismatch' }); + expect(validateInventoryCursorUse(inventoryCursorState, changedRevisionInventoryCursorLookup)).toEqual({ valid: false, reason: 'authorization_mismatch' }); expect(inventoryCursorState.pin.authorizationEpochs['character:90000000000000001']).toBe(1); expect(inventoryResult.status).toBe('ready'); if (inventoryResult.status === 'ready') { @@ -76,6 +81,17 @@ describe('T02 typed contract fixtures', () => { expect(singletonRows.map((row) => row.isSingleton)).toEqual([true, false, null]); }); + test('station evidence preserves verified, system-listed, and absent states', () => { + expect(verifiedNamelessStation.name).toBeNull(); + expect(verifiedNamelessStation.evidence).toBe('verified'); + expect(verifiedNamelessStation.sourceVerified).toBe(true); + expect(systemListedStationPlaceholder.evidence).toBe('system_listed'); + expect(systemListedStationPlaceholder.sourceVerified).toBe(false); + expect(absentLocation.kind).toBe('unknown'); + expect(absentLocation.sourceVerified).toBeNull(); + expect(absentLocation.parentId).toBeNull(); + }); + test('ambiguous entity matches remain explicit instead of selecting by name', () => { expect(ambiguousType.status).toBe('ambiguous'); if (ambiguousType.status === 'ambiguous') expect(ambiguousType.candidates).toHaveLength(2);